
1. 项目概述为什么Unity AI Navigation是动态场景的基石在Unity里做游戏尤其是涉及到角色移动的寻路是个绕不开的话题。十年前我们可能还在手动写A*算法或者用NavMesh烘焙一个静态地图就万事大吉。但现在玩家和项目的要求都高了场景里不再是静态的摆设箱子可以被推开桥梁会断裂敌人会动态搭建掩体——这就是动态寻路要解决的问题。Unity内置的AI Navigation系统特别是它的NavMesh组件早已不是简单的“烘焙完就完事”的工具它提供了一套从静态烘焙到动态更新的完整解决方案。这个项目就是带你从零开始亲手搭建一个能应对这些变化的动态寻路场景。无论你是想做一个RTS游戏里单位绕过被摧毁的建筑还是一个解谜游戏里实时改变的通路亦或是AI敌人对玩家搭建的临时路障做出反应这里面的核心逻辑都是一样的。很多人对Unity NavMesh的印象还停留在“Window - AI - Navigation”烘焙一下然后给角色挂个NavMeshAgent组件调用SetDestination就结束了。这确实能解决80%的静态场景寻路需求。但剩下的20%恰恰是提升游戏真实感和策略深度的关键。动态寻路的核心在于让NavMesh这个“地图”活起来能够响应游戏运行时的变化。这不仅仅是性能优化更是游戏逻辑设计的一部分。通过这个实战你会掌握如何让AI“看见”并适应一个不断变化的世界这是构建高级游戏AI的第一步。2. 核心思路与方案选型动态更新 vs. 局部重建面对动态场景我们主要有两种技术思路选择哪一种取决于你的场景变化是“局部小改动”还是“全局大变动”。2.1 方案一NavMesh障碍物NavMeshObstacle这是处理动态寻路最直接、最常用的方案。它的原理很简单在场景中移动的、会阻挡AI的物体上挂载一个NavMeshObstacle组件。这个组件有两种工作模式Carve模式这是最强大的功能。当障碍物启用时它会在运行时从已有的NavMesh中“雕刻”掉自己所占的区域相当于实时在寻路地图上挖个洞。AI在计算路径时会自动避开这个区域。当障碍物移动时这个“洞”也会跟着移动。这完美解决了移动的箱子、开关的门、被驾驶的车辆这类需求。障碍物模式更像是一个虚拟的碰撞体AI的NavMeshAgent在寻路时会将其视为不可穿越的边界但不会真正修改底层的NavMesh数据。开销更小适用于大量但简单的阻挡物。注意NavMeshObstacle的Carve功能虽然方便但频繁移动或大量使用会对性能产生持续开销因为它需要动态更新NavMesh数据。通常建议对关键的运动物体使用对于静止后就不再移动的物体可以考虑方案二。2.2 方案二运行时NavMesh烘焙NavMeshSurface当场景发生结构性改变比如一栋建筑被炸毁、一座桥被搭建起来这时NavMeshObstacle就不够用了因为我们需要的是永久性地改变可行走区域。这就需要用到Unity的NavMeshSurface组件来自AI Navigation包和运行时烘焙API。其工作流程是在场景中创建一个空物体添加NavMeshSurface组件它定义了烘焙的参数如代理大小、可行走层。游戏运行时当发生重大地形变化如建筑摧毁你通过代码移除或添加对应的场景几何体。调用NavMeshSurface.BuildNavMesh()或它的异步方法在运行时重新烘焙整个或局部NavMesh。这个方案的优点是能处理任意复杂的、永久性的地形变化。缺点是运行时烘焙是重量级操作即使只烘焙一小块区域通过NavMeshBuilder.BuildNavMeshData配合NavMeshBuildSettings和NavMeshBuildSource可以做到局部更新其耗时也比NavMeshObstacle的雕刻要高得多不能每帧调用。2.3 我们的选型与混合策略对于一个综合性的动态寻路场景我们几乎总是采用混合策略这也是工业级项目的常见做法高频、小范围的动态阻挡使用NavMeshObstacleCarve模式。例如玩家角色、可推动的箱子、开关的门。低频、大范围的地形改变使用运行时局部NavMesh烘焙。例如可摧毁的墙壁、可搭建的楼梯。静态背景在编辑器中预先烘焙好运行时不变。这样既能保证对动态事件的实时响应又能将性能开销控制在合理范围内。本次实战将同时实现这两种机制让你体会其中的差异和适用场景。3. 基础环境搭建与静态烘焙在玩动态之前必须把静态的基础打牢。我们首先创建一个标准的3D项目并确保AI Navigation包已导入Unity 2019.4及以上版本通常已内置较新版本在Package Manager中搜索“AI Navigation”进行安装或更新。3.1 场景搭建与导航静态层设置创建一个简单的测试场景一个Plane作为地面上面放置几个Cube作为静态的墙壁和房屋。关键步骤在于设置导航区域NavMesh Area。在Navigation窗口Window - AI - Navigation的“Areas”标签页你会看到默认的“Walkable”区域。我们可以增加新的区域例如Jump索引1用于标记可以跳过的矮墙。Not Walkable索引2明确不可行走的区域但有时我们更倾向于用物体本身不参与烘焙来实现。更实用的做法是为不同地形设置不同成本。例如将“泥泞”地形的成本设为3意味着AI会优先选择成本为1的普通道路即使泥泞路更近。这通过在场景中物体的材质或Renderer组件上设置“Navigation Area”属性来实现。3.2 导航网格烘焙参数详解切换到Navigation窗口的“Bake”标签页这里的参数决定了最终生成的NavMesh质量Agent Radius代理半径。想象AI是一个圆柱体这是它的身体半径。值太小AI会贴墙走值太大狭窄通道可能无法生成可行走区域。通常设置为角色碰撞体半径的80%-90%。Agent Height代理高度。AI能通过的最低门框高度。Max Slope最大爬坡角度。超过这个角度的斜坡将被视为不可行走。Step Height可跨越台阶高度。低于此值的台阶AI会直接走上去而不是寻找绕路。Drop Height最大下落高度。AI可以安全跳下的最大高度与Jump功能关联。Jump Distance最大跳跃距离。水平跳跃的最大距离。设置好参数后例如Radius0.5 Height2.0 Max Slope45 Step Height0.3点击“Bake”按钮。烘焙完成后场景视图会显示蓝色的NavMesh网格。这就是AI眼中的“地图”。3.3 创建导航代理NavMeshAgent创建一个胶囊体Capsule作为我们的AI角色。为其添加NavMeshAgent组件。核心参数包括Speed移动速度。Angular Speed转向速度。Acceleration加速度。Stopping Distance到达目标点多远时停止。Auto Braking接近目标时是否自动减速。Obstacle Avoidance避障质量。设置优先级Priority可以让一些AI主动为其他AI让路。写一个简单的测试脚本让AI点击地面移动using UnityEngine; using UnityEngine.AI; public class ClickToMove : MonoBehaviour { private NavMeshAgent agent; private Camera mainCamera; void Start() { agent GetComponentNavMeshAgent(); mainCamera Camera.main; } void Update() { if (Input.GetMouseButtonDown(0)) { Ray ray mainCamera.ScreenPointToRay(Input.mousePosition); if (Physics.Raycast(ray, out RaycastHit hit)) { // 关键确保点中的位置在NavMesh上 if (NavMesh.SamplePosition(hit.point, out NavMeshHit navHit, 1.0f, NavMesh.AllAreas)) { agent.SetDestination(navHit.position); } } } } }至此一个基础的静态寻路场景就完成了。AI已经可以在预烘焙的地图上自由移动。4. 动态寻路核心实现NavMesh障碍物实战现在让我们引入动态元素。首先实现可移动的障碍物。4.1 创建可移动的障碍物方块创建一个Cube命名为“DynamicObstacle”。为其添加以下组件Rigidbody使其受物理引擎影响可以被推动或受力移动。勾选“Is Kinematic”如果你希望用脚本而非物理力来控制它的移动。NavMeshObstacle这是核心。设置参数Shape选择Box与Cube匹配。Carve务必勾选。这是启用动态雕刻的关键。Move Threshold移动阈值。障碍物移动距离超过此值才会触发NavMesh更新。适当提高如0.1可以减少不必要的频繁更新。Time To Stationary静止时间。障碍物停止移动后等待多久才将其视为静止并可选停止雕刻。默认即可。Carve Only Stationary仅当静止时雕刻。如果希望障碍物移动时也产生阻挡不要勾选此项。4.2 编写障碍物移动脚本为了让障碍物动起来我们写一个简单的脚本让它能在水平面上被鼠标拖动。using UnityEngine; public class DraggableObstacle : MonoBehaviour { private Vector3 screenPoint; private Vector3 offset; private NavMeshObstacle obstacle; private bool isDragging false; void Start() { obstacle GetComponentNavMeshObstacle(); if (obstacle null) { Debug.LogError(NavMeshObstacle component missing!); } } void OnMouseDown() { screenPoint Camera.main.WorldToScreenPoint(transform.position); offset transform.position - Camera.main.ScreenToWorldPoint(new Vector3(Input.mousePosition.x, Input.mousePosition.y, screenPoint.z)); isDragging true; // 开始拖动时确保障碍物是启用的并且正在雕刻 if (obstacle ! null) { obstacle.enabled true; obstacle.carving true; } } void OnMouseDrag() { if (!isDragging) return; Vector3 curScreenPoint new Vector3(Input.mousePosition.x, Input.mousePosition.y, screenPoint.z); Vector3 curPosition Camera.main.ScreenToWorldPoint(curScreenPoint) offset; curPosition.y transform.position.y; // 保持Y轴不变在平面上拖动 transform.position curPosition; } void OnMouseUp() { isDragging false; // 松开鼠标后障碍物仍然启用并雕刻除非你希望它停止后变成非阻挡状态。 // 如果需要可以在这里设置一个计时器一段时间后设置 obstacle.carving false; } }将脚本挂载到“DynamicObstacle”上。运行游戏点击并拖动这个方块。你会发现当方块移动时蓝色的NavMesh网格会实时地“避开”它。让你的AI角色尝试走向方块另一侧的目标点它会自动绕开移动中的方块。这就是动态雕刻的魅力。4.3 障碍物参数调优与性能考量Carve Only Stationary这是一个重要的性能开关。如果勾选障碍物只在静止时才会雕刻NavMesh移动过程中NavMesh是完整的AI可能会计算出一条穿过移动中障碍物的路径直到障碍物停下。这适合像“最终停下的路障”这类物体。对于持续移动的障碍如巡逻的敌人则不能勾选。Move Threshold如果你的障碍物因为物理抖动而轻微移动可能会触发频繁的NavMesh更新。适当增加此值例如从0.1到0.3可以有效降低性能开销。开销管理场景中同时活动的NavMeshObstacle尤其是开启雕刻的数量不宜过多建议不超过几十个。对于大量的小型动态阻挡物如一群密集的、移动缓慢的虫子可以考虑使用Obstacle Avoidance的“障碍物模式”而非雕刻模式或者使用更简化的碰撞处理逻辑。5. 高级动态运行时NavMesh烘焙与局部更新接下来处理更剧烈的场景变化——可摧毁的墙壁。我们创建一个Cube作为墙壁并计划在运行时销毁它然后更新NavMesh让AI能通过新出现的缺口。5.1 设置可摧毁墙壁与NavMeshSurface首先需要导入或确保已安装“AI Navigation”包包含NavMeshSurface。在场景中创建一个Cube作为墙壁确保其静态标记Static flag中不包含“Navigation Static”。因为我们不希望在初始烘焙时把它算进去而是由NavMeshSurface动态管理。然后创建一个空游戏对象命名为“RuntimeNavMesh”为其添加NavMeshSurface组件。在组件中Agent Type选择你使用的代理类型默认Humanoid。Collect Objects选择“Volume”或“All Children”。这里我们选“Volume”。Size设置一个包围盒覆盖你希望运行时烘焙的动态物体区域。比如把包围盒设置得足够大能包含那面可摧毁的墙及其周围区域。Layer Mask指定哪些层的物体会被收集用于烘焙。确保你的墙壁所在的层被包含。初始状态下先不烘焙。我们的静态地面已经由Window-Navigation烘焙好了。NavMeshSurface将负责管理动态部分的网格。5.2 实现墙壁摧毁与局部烘焙为墙壁写一个简单的脚本当被点击时“摧毁”它实际上是禁用渲染和碰撞并触发NavMesh更新。using UnityEngine; using UnityEngine.AI; // 需要引入AI命名空间 public class DestructibleWall : MonoBehaviour { private NavMeshSurface navMeshSurface; private bool isDestroyed false; void Start() { // 假设NavMeshSurface对象在场景中只有一个或者通过其他方式引用 navMeshSurface FindObjectOfTypeNavMeshSurface(); if (navMeshSurface null) { Debug.LogError(No NavMeshSurface found in the scene!); } } void OnMouseDown() { if (isDestroyed) return; // 1. “摧毁”墙壁禁用渲染器和碰撞体 GetComponentRenderer().enabled false; GetComponentCollider().enabled false; isDestroyed true; // 2. 触发NavMesh更新 if (navMeshSurface ! null) { // 方法A同步重建整个Surface简单但可能卡顿 // navMeshSurface.BuildNavMesh(); // 方法B推荐异步重建避免帧率卡顿 StartCoroutine(RebuildNavMeshAsync()); } } private System.Collections.IEnumerator RebuildNavMeshAsync() { // 使用NavMeshBuilder的异步接口进行更精细的控制 // 这里我们简单调用Surface的异步方法如果版本支持 // 对于较新版本可以直接 // navMeshSurface.UpdateNavMesh(navMeshSurface.navMeshData); // 但更通用的做法是 AsyncOperation operation navMeshSurface.UpdateNavMeshAsync(navMeshSurface.navMeshData); while (!operation.isDone) { yield return null; } Debug.Log(NavMesh updated after wall destruction.); // 或者如果你需要完全重新收集几何体并烘焙 // NavMeshData newData navMeshSurface.BuildNavMeshData(); // if (newData ! null) // { // NavMesh.RemoveAllNavMeshData(); // 小心这会移除所有NavMesh // NavMesh.AddNavMeshData(newData); // } } }重要提示直接调用navMeshSurface.BuildNavMesh()会在主线程执行如果动态物体很多会造成明显的卡顿。在生产环境中务必使用异步更新。上述代码中的UpdateNavMeshAsync是一个简化示例实际可能需要根据Unity版本和具体需求使用NavMeshBuilder.BuildNavMeshDataAsync配合NavMeshBuildSources来收集需要更新的局部区域数据实现真正的增量更新这是性能最优解。5.3 结合使用动态障碍物与可导航区域更新现在你的场景中同时存在一个由NavMeshObstacle控制的、可拖动的动态方块。一面可摧毁的墙壁摧毁后通过NavMeshSurface更新导航网格。运行游戏先让AI去一个被墙壁挡住的目标点它无法到达。点击摧毁墙壁等待NavMesh更新完成可以在UpdateNavMeshAsync的协程里加个调试日志或进度提示。更新后再次命令AI去同一个目标点它会从墙壁的缺口处通过。同时你仍然可以拖动那个动态方块。AI在寻找路径时会同时考虑静态烘焙的NavMesh地面。运行时由NavMeshSurface更新的NavMesh墙壁缺口区域。由NavMeshObstacle实时雕刻出的空洞移动的方块。这三者共同构成了一个完整的、动态的寻路世界。6. 路径搜索优化与高级代理控制有了动态的地图AI的寻路逻辑也需要更精细的控制。6.1 分层成本与区域掩码Area Mask之前提到过为不同区域设置不同的成本。现在我们来使用它。假设我们创建了一个“Mud”泥泞区域成本为3。在场景中创建一个Plane为其材质分配“Mud”区域。 在AI的NavMeshAgent组件上有一个Area Mask属性。它决定了AI可以行走在哪些区域。默认是“Everything”。你可以取消勾选“Mud”那么AI将完全不会踏上泥泞地。或者保持勾选但AI在寻路时会计算成本如果泥泞地是捷径它可能会选择但通常会优先选择成本低的道路。 在代码中你可以动态改变Area Maskagent.areaMask (1 NavMesh.GetAreaFromName(Walkable)) | (1 NavMesh.GetAreaFromName(Jump)); // 这行代码设置代理只能走在Walkable和Jump区域。6.2 动态目标与路径状态查询在动态场景中目标点本身也可能是移动的如追击玩家。简单的每帧SetDestination会造成大量路径重计算。一个优化方案是设置一个合理的路径更新频率。public class FollowMovingTarget : MonoBehaviour { public Transform target; private NavMeshAgent agent; private float pathUpdateInterval 0.5f; // 每0.5秒更新一次路径 private float timer 0f; void Start() { agent GetComponentNavMeshAgent(); } void Update() { timer Time.deltaTime; if (timer pathUpdateInterval) { timer 0; if (target ! null agent.isOnNavMesh) { agent.SetDestination(target.position); } } // 实时检查路径状态 if (agent.hasPath) { // 检查路径是否被阻塞例如一个动态障碍物突然出现在路径上 if (agent.isPathStale || agent.pathStatus NavMeshPathStatus.PathPartial || agent.pathStatus NavMeshPathStatus.PathInvalid) { Debug.Log(Path is invalid or stale, recalculating...); // 可以立即触发一次重新寻路或者等待下一次定时更新 agent.ResetPath(); agent.SetDestination(target.position); } // 计算剩余距离用于判断是否到达 if (!agent.pathPending agent.remainingDistance agent.stoppingDistance) { if (!agent.hasPath || agent.velocity.sqrMagnitude 0f) { // 真正到达目的地 OnDestinationReached(); } } } } void OnDestinationReached() { // 处理到达目标后的逻辑 } }6.3 避障与优先级管理在多个AI同时寻路时避免它们挤在一起。NavMeshAgent的Obstacle Avoidance属性是关键。Quality避障质量。设置为“High Quality”会进行更精确的预测和规避但计算量更大。对于大量AI使用“Low Quality”或“No Obstacle Avoidance”然后通过其他逻辑如分离力处理碰撞可能是更好的选择。Priority优先级0-99。数值越低优先级越高。高优先级的AI会迫使低优先级的AI让路。你可以为不同类型的AI如英雄单位 vs 小兵设置不同的优先级。7. 常见问题、性能优化与调试技巧在实际开发中你会遇到各种奇怪的问题。这里记录一些典型坑点和解决方案。7.1 NavMesh常见问题排查表问题现象可能原因排查步骤与解决方案AI卡在角落或物体边缘Agent Radius太大或NavMesh在边缘处生成不准确。1. 减小NavMeshAgent的Radius。 2. 检查烘焙设置中的“Agent Radius”是否与代理设置一致。 3. 在编辑器Navigation窗口的“Object”标签页检查卡住位置的物体确保其“Navigation Area”设置正确且参与了烘焙如果是静态物体。SetDestination返回falseAI不移动目标点不在NavMesh上。1. 使用NavMesh.SamplePosition对目标点进行采样。 2. 检查目标点所在的区域是否在Agent的areaMask内。 3. 确保NavMesh已正确烘焙运行时烘焙是否完成。动态障碍物无效AI直接穿过去NavMeshObstacle的Carve未启用或障碍物Layer未被NavMesh识别。1. 确认NavMeshObstacle组件勾选了“Carve”。 2. 检查障碍物是否在NavMeshObstacle的“Carve Only Stationary”状态下移动。 3. 确保障碍物的Layer在Navigation窗口的“Layers”标签页中对应的“Default Area”不是“Not Walkable”或者你期望的区域。运行时烘焙后AI仍然不走新路NavMesh数据没有成功添加或更新。1. 确认NavMeshSurface.BuildNavMesh()或更新方法被成功调用且未报错。 2. 在Scene视图的“Navigation”显示模式下查看新的NavMesh网格是否生成蓝色区域。 3. 检查新烘焙的区域是否与原有NavMesh连接。断开的部分需要手动处理连接或确保烘焙范围重叠。性能下降尤其在动态障碍物多时NavMeshObstacle的Carve更新开销大或运行时烘焙太频繁。1. 为NavMeshObstacle增加“Move Threshold”。 2. 评估是否所有障碍物都需要Carve能否用简单的碰撞体避障逻辑替代。 3. 将运行时烘焙操作放在协程中异步进行并限制频率如每秒最多一次。 4. 使用NavMeshBuilder.BuildNavMeshDataAsync进行局部增量更新而非全量重建。7.2 性能优化实战心得分帧处理如果你有上百个需要更新目的地的AI不要在同一帧调用所有SetDestination。可以分散到多帧完成。LOD for AI对于远离玩家或不在屏幕内的AI可以降低其寻路频率甚至暂停寻路只做简单的预设移动。简化碰撞形状NavMeshObstacle和用于烘焙的Mesh尽量使用简单的碰撞体Box, Sphere, Capsule而不是复杂的Mesh Collider。Unity的NavMesh系统在处理简单几何体时效率高得多。合理使用代理半径不要盲目使用过大的半径。较小的半径能生成更密集的NavMesh让AI有更多路径选择但也增加了寻路图的复杂度。需要根据游戏类型和AI大小权衡。7.3 调试与可视化技巧Scene视图导航叠加层在Scene视图左上角点击“Gizmos”下拉菜单可以调整NavMesh、NavMeshAgent路径等的显示颜色和透明度。这是最直观的调试工具。绘制路径线在AI的脚本中使用Debug.DrawLine或Gizmos.DrawLine在OnDrawGizmos中绘制agent.path.corners可以清晰看到AI计算的路径。检查IsOnNavMesh在让AI做任何移动操作前用agent.isOnNavMesh检查它是否在有效导航网格上。如果不在可能需要使用NavMesh.SamplePosition将其“放置”到最近的NavMesh点上Warp功能有时不可靠。使用NavMeshQuery进行高级查询对于需要预先判断某点是否可达、或计算两点间路径成本的高级需求可以研究NavMeshQuery类它提供了比NavMeshAgent更底层的接口。动态寻路系统的构建是一个从静态到动态从基础到高级不断权衡性能与效果的过程。它没有唯一的正确答案只有最适合你项目需求的组合方案。从NavMeshObstacle的实时雕刻到NavMeshSurface的运行时烘焙再到代理自身的避障与路径优化每一层都为你的游戏世界注入更多活力与可能性。