大家在做 Unity3D 游戏开发的时候,只要涉及角色移动、怪物追踪或者寻路 AI,迟早会遇到 AI Navigation 这套系统。早些年大家习惯叫它 NavMesh,写脚本用 NavMeshAgent,现在新版 Unity 里直接叫 AI Navigation 包了,功能也做了不少整合。今天这篇就从概念到实战,把这块讲透,你在自己的简单小游戏项目里也能直接用起来。
先说透一个核心认知:AI Navigation 不是让你手动写 A* 寻路算法的,它是一套可视化寻路工作流。你把场景里的地面、障碍物、斜坡处理一遍,烘焙出导航网格,然后给角色挂上 NavMeshAgent 组件,设置目的地,剩下的事情——走哪条路、怎么绕开墙角、怎么跳过台阶,全部交给引擎。做 3D 项目、做俯视角 2D 项目、做 MOBA 类小 demo,这套东西都适用。我最近接了几个外包项目,从场景漫游到怪物巡逻,清一色都是靠它实现的,稳定性确实比手写路径点靠谱太多。
下面我会从四个大方向展开:先讲整体设计思路和选型考量,再讲核心组件和参数细节,然后给出一套可以直接抄作业的实战流程,最后分享我踩过的坑和排查方法。文章末尾还会补一些提升技巧,属于个人经验总结。
1. 整体设计思路与方案选型
1.1 AI Navigation 到底解决什么问题
先聊聊这个系统存在的意义。游戏角色移动这件事,表面看是“从 A 点走到 B 点”,实际拆开是三个问题:位置怎么表示、路径怎么算、移动过程怎么表现。位置表示简单,Vector3 就够;移动过程也简单,插值、平滑、转向,脚本写起来不难。最难的是路径怎么算,因为场景不是一张白纸,有墙、有台阶、有沟壑、有会移动的障碍物。你让角色直直地朝目标走,它大概率会卡在墙角动弹不得。
AI Navigation 的核心价值就在于把“路径计算”这件事从代码里解放出来。它在构建阶段把场景的可行走区域和不可行走区域变成一张网格,叫 NavMesh。网格上的每个多边形都标记了是否可走、和哪些多边形相邻、连接的代价是多少。运行时 AI 只需要在这张网格上搜索路径,效率远高于实时计算几何体相交。这就好比我们出门前先看地图规划路线,而不是边走边拿尺子量每一步能不能踩下去。
需要说明的是,这套系统本质上还是 A* 算法的工业级封装,只不过它在预处理阶段做了大量优化。网格的三角形数量是可控的,路径搜索的节点数量比场景原始几何体少好几个量级,所以性能表现非常稳定。对于中小型项目来说,完全不需要自己造轮子。
1.2 什么时候该用、什么时候不该用
AI Navigation 虽然好用,但不是万能的。我在项目里见过不少反面案例,这里一并说清楚。
适合用的场景:静态场景为主、障碍物大多固定的游戏,比如 MOBA 类防御塔地图、RPG 地下城、办公室场景漫游、怪物从固定巢穴出发巡逻攻击玩家。这类场景烘焙一次 NavMesh,运行期几乎零开销。
不太适合的场景:障碍物频繁移动、大量单位需要实时改变路径的玩法,比如吃鸡类毒圈缩到很小、物理破坏导致地形巨变、任意建造改变可行走区域。虽然 AI Navigation 提供动态避障和 NavMesh Obstacle 组件,但频繁改网格会产生较大性能开销,效果也不如专门的 RVO 避障算法流畅。说实话我在做多单位实时战术游戏的时候,最终还是选择了混合方案——静态地形用 AI Navigation,动态单位之间的避让用自写 RVO。
另外还有一个容易被忽视的点:AI Navigation 对角色控制权是“全权接管”的。一旦你把目的地交给 NavMeshAgent,它的位置和朝向都由引擎控制,你再去手动改 transform.position 就会出问题。如果你的角色移动需要精确响应物理碰撞、或者要用动画驱动位移(比如攀爬、翻滚),建议把 NavMeshAgent 的约束放宽,或者改用 CharacterController 自己采样网格路径。这个取舍我在后面的参数部分还会细讲。
1.3 版本选型和包管理
Unity 从 2020.3 之后把 AI Navigation 拆成了独立包,如果你用的是新版本编辑器,需要确认一下包是否已经引入。项目里如果直接把旧项目的 Assets 文件夹拷过来,经常会出现脚本丢失的问题,因为命名空间从 UnityEngine.AI 迁移到了 Unity.AI.Navigation,部分 API 也做了调整。
实操建议:先打开 Window -> Package Manager,搜索 AI Navigation,点击安装。不同 Unity 版本对应的包版本不一样,比如 Unity 2021 对应 1.1.x,Unity 2022 对应 1.4.x,Unity 6 对应 2.x。挑选的时候就选默认推荐的稳定版,不要追最新版本,因为踩到兼容坑没人替你负责。
核心 API 其实变化不大:NavMeshAgent 仍然在 UnityEngine.AI 命名空间下,烘焙相关操作变成了 GameObject 上的 NavMeshSurface 组件。旧版那种 Navigation 窗口里的 Bake 按钮虽然还有,但 Unity 官方推荐用 NavMeshSurface,因为不用你手动去管理场景烘焙的时机和范围,多个 Surface 还能叠加。我在大型场景里就是用多个 Surface 分区块烘焙,加载时再决定哪些区域需要启用,这样加载速度提升了好几个档次。
2. 核心组件与参数细拆
2.1 烘焙前的准备:场景标记与组件挂载
先把场景收拾干净。你需要给地面、墙壁、阶梯分别指定导航静态属性。选中物体后,在 Inspector 右上角的 Static 下拉菜单里勾选 Navigation Static,这一步决定这个物体是否参与烘焙。如果漏掉,烘焙出来你会发现角色直接穿墙。
这里有个很多人忽略的细节:Static 菜单分为多个 Flag,你要单独勾 Navigation Static,而不是只勾了 Lightmap Static 就完事。两个是完全独立的静态标记,Lightmap 管光照烘焙,Navigation 管寻路网格烘焙,哪怕你光照贴图烘得很好,Navigation 没勾照样无法产生障碍。
地形组件的话,建议在 Navigation 窗口里调整默认参数时多留意一下斜坡角度和台阶高度。这部分我在实战流程里细说。如果有动态开关的门,不要标记为 Navigation Static,应该给门挂 NavMesh Obstacle 组件,运行时打开或关闭它的状态,网格会自动更新。
2.2 代理参数逐个解读
NavMeshAgent 的 Inspector 上有一堆参数,初看头晕,实际上每个都很直观。我把常用的列成一张表,附上我个人推荐的经验值范围,方便直接抄作业。
| 参数名 | 作用 | 推荐值 | 备注 |
|---|---|---|---|
| Speed | 移动速度 | 3.5 ~ 6 | 单位每秒移动距离,按角色尺度调整 |
| Angular Speed | 转向速度 | 120 ~ 720 | 度数/秒,越高转向越跟手 |
| Acceleration | 加速度 | 8 ~ 12 | 影响起步和刹车反应 |
| Stopping Distance | 停止距离 | 0.1 ~ 0.5 | 距离目标多近时算到达 |
| Auto Braking | 自动刹车 | 开启 | 到目标后自动减速 |
| Radius | 代理半径 | 0.3 ~ 0.8 | 决定它能不能挤过缝隙 |
| Height | 代理高度 | 1.5 ~ 2.5 | 用于判断净空高度 |
| Base Offset | 代理原点偏移 | 0 | 一般无需调整,容器高度补偿时用 |
| Obstacle Avoidance Type | 动态障碍避让等级 | High Quality | 优先级和精度可选值 |
重点说一下 Radius 和 Obstacle Avoidance Type。Radius 不是视觉半径,它是寻路时用来膨胀障碍物的半径。你可以想象成角色是个圆筒,路能不能过取决于圆筒直径和通道宽度的比较。如果通道刚好比角色宽一点点,AI 一般也能挤过去,这是因为 Radius 值在烘焙时就已经参与了网格边缘收缩。
Obstacle Avoidance Type 有点反直觉:选 High Quality 反而会让单位之间避让变得保守,单位多了以后会出现集体摇摆。我一般搭配 Priority 来用:重要单位 Priority 设为 0,路边小怪设为 50。Priority 数值越小越优先移动,避免了两个单位面对面时互相僵持的“顶牛”现象。
2.3 可以编程控制的高级行为
除了 Inspector 上直接调的参数,代码层面有很多可玩的花活。最常见的三个是 SetDestination、isStopped、velocity。
SetDestination 是最基础的移动指令,直接传入 Vector3 就能让代理开始移动。但有一点必须注意:目标点必须落在 NavMesh 上,如果目标点在网格外,调用会返回 false,角色纹丝不动。做点击移动功能时,我习惯先用 NavMesh.SamplePosition 做采样,把鼠标射线产生的点投影到最近的网格表面,再交给 Agent。
isStopped 适合做巡逻暂停、对话暂停之类。置为 true 时代理会在原地停住,路径计算也暂停,但已算好的路径还能继续用。职业习惯来说,暂停时最好同时记录一下 currentPath 状态,这样恢复时不会出现角色在暂停期间被其他系统移动了位置之后重新规划路径的偏差。
velocity 属性很少在文档里被重点说明,但它非常实用——可以用来判断角色当前是静止、巡航还是被卡住。检查 velocity.sqrMagnitude 是否长时间小于某个阈值(比如 0.01),就能检测到“目标点在墙上但代理还在傻乎乎地走”的异常状态。
另外还有两个少有人用但很有价值的 API:NavMesh.CalculatePath 可以提前计算好路径但不实际移动,适合做进攻预告、线路展示;NavMesh.Raycast 可以检测某条直线上是否有网格障碍,适合做视线检测,比 Physics.Raycast 更贴合寻路语义,因为它是基于网格数据而不是物理碰撞体,不会受移动小道具影响。
3. 实战流程:从场景搭建到角色移动
3.1 环境搭建与 Surface 组件配置
这里以 Unity 2022.3 LTS 为例,带大家完整过一遍。假设我们做一个俯视角 3D 的怪物追击小游戏,场景里有一片空地、几栋房子、一些散落的树木,还有一座小桥,桥下是不能走的河面。
第一步,准备地形和静态障碍物。地面用 Terrain 或者一个 Plane 都行,房子树木这类障碍物一定要勾 Navigation Static。按我前面说的,到 Inspector 右上角 Static 下拉菜单里把 Navigation Static 勾上,不要只勾整个 Static 的总开关然后不管了,单独确认这一项最稳妥。
第二步,给地面挂 NavMeshSurface 组件。选中地形对象,Add Component 搜索 NavMeshSurface,添加。这个组件会告诉引擎:从这一块开始烘焙,范围覆盖到它所在的 GameObject 的所有子物体,以及整个场景中和它相交的静态物体。
NavMeshSurface 上的关键参数有这几个:Agent Type 下拉菜单、Include Layers、Use Geometry。Agent Type 默认是 Humanoid,这是我们在 Project Settings 里定义的代理类型,包含半径高度等一整套参数。如果你要多个尺寸的角色(比如大象和蚂蚁同场),就注册多个 Agent Type。Use Geometry 有两个选项:Render Meshes 和 Physics Colliders。选 Physics Colliders 时,烘焙几何体以碰撞体为准,这样能保证行得通的路径一定不会撞墙,代价是 Collider 必须精细;选 Render Meshes 适合纯视觉展示的场景,效率高但容易出现穿模。
调好之后点击 Bake,等进度条转完,检查一下 Scene 视图里蓝色的网格覆盖区域。网格是蓝色半透明的,它会贴合地形表面,在障碍物边缘收缩,在斜坡上最多延伸到最大坡度限制的地方。
3.2 角色设置与移动控制
接下来造一个小怪。创建一个 Capsule,上面放个球当脑袋,挂上 NavMeshAgent 组件。参数按 2.2 的表格先给一组初始值,后面调试中再改。
写一个最简单的移动脚本,把控制逻辑分开。核心目标有两个:一是监听目标位置输入,二是设置 Destination 并处理异常。我提供一份精简代码:
using UnityEngine; using UnityEngine.AI; public class SimpleNavController : MonoBehaviour { private NavMeshAgent agent; void Start() { agent = GetComponent<NavMeshAgent>(); } void Update() { if (Input.GetMouseButtonDown(0)) { Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition); if (Physics.Raycast(ray, out RaycastHit hit, 100f)) { MoveToPoint(hit.point); } } } public void MoveToPoint(Vector3 target) { if (NavMesh.SamplePosition(target, out NavMeshHit hit, 1.0f, NavMesh.AllAreas)) { agent.SetDestination(hit.position); } else { Debug.Log("目标点不在导航网格上"); } } }这段代码里做了一个关键防护:通过 NavMesh.SamplePosition 把射线命中点投影到 NavMesh 上,避免点击到网格边界外导致角色不动。其中 1.0f 是搜索半径,如果你的场景尺度较大,可以适当调大这个值,但是注意它不是一个无限大的范围,半径过大可能采样到意料之外的位置,太小又采不到,一般 0.5 到 2 之间比较稳妥。
到这里,一个点击地面怪物走过去的 demo 就完成了。你运行一下,点空地角色走过去,点房子背面角色自动绕路,点桥面角色走桥过河,这就是 AI Navigation 最基础的完整体验。
3.3 用 Off-Mesh Link 实现跳跃与跨沟
很多简单小游戏项目里要角色跳过裂缝、翻过矮墙,这类场景适合 Off-Mesh Link。它本质上是在 NavMesh 上手工布一条“捷径”,角色走到起点附近时自动跃迁到终点。
做法是:在缝隙两边各放一个空的 GameObject,都挂上 Off-Mesh Link 组件。把 Start 字段拖到起点物体,End 字段拖到终点物体,勾选 Bi Directional(双向),这样从哪边都能跳过去。然后在 NavMeshAgent 的 Inspector 里,确保一条属性是自动处理的:Agent 靠近起点一定距离内时,如果目标在终点方向,它就自动走这个链接。
这块有两个容易踩的坑:一是起点和终点物体必须离 NavMesh 表面足够近,一般在一个 Radius 内,否则找不到入口;二是跳跃动画需要手动播放,引擎只负责移动轨迹,不会触发你的 Animator 状态。我的做法是监听 NavMeshAgent 的 currentPath 状态,或者在动画状态机里用移动速度差来判断是否处于 Off-Mesh Link 中。更优雅的做法是通过 NavMeshAgent 的 pathPending、currentOffMeshLinkData 等属性判断是否正在越过链接,然后播放相应的动画。
3.4 动态障碍物与运行时开关门
在简单小游戏项目里,门、升降台、移动平台这些动态元素也很常见。我给一个建议:不要把这些物体标记为 Navigation Static,而是挂 NavMesh Obstacle 组件。这个组件有几个模式,默认的 Carve 模式会在物体移动时实时挖掉相应区域的网格,让 AI 绕开它;当物体移开后,网格会在短时间内恢复。
Carve 模式的参数有一个很关键:Carve Only Stationary。如果勾上,只有物体静止时才生成空洞,移动过程中 AI 可以从它身上穿过去。这在某些情况下是想要的效果,但如果不小心勾上又没意识到,会出现 AI 穿墙的怪问题。我建议做开关门时不要勾该项,让按钮按下时门板移动并立即阻挡路径,AI 会临时绕路,看起来更符合直觉。
运行时改走路区域还有另一个手段:NavMeshSurface 的动态烘焙。你可以在代码里调用 surface.BuildNavMesh() 重新生成局部网格。注意 Building 期间会卡顿,不建议在游戏核心帧内频繁调用,最好在切换房间或加载场景时触发。
4. 常见问题与排查技巧实录
4.1 角色卡住不动的排查清单
这是我被问得最多的问题。角色没有报错,也不移动,就站在原地干瞪眼。大多数时候是下面这几种原因:
| 可能原因 | 判断方法 | 解决手段 |
|---|---|---|
| 目标点脱离 NavMesh | 在 Scene 视图里看蓝色网格是否覆盖目标点 | 用 SamplePosition 修正 |
| NavMeshAgent 被禁用 | Inspector 里 isActiveAndEnabled 是否勾选 | 检查代码有没有误禁组件 |
| Radius 太大挤不进窄路 | 把 Radius 临时调小测试 | 调整代理参数或改地图 |
| 路径计算失败 | 打印 pathStatus 查看状态 | 用 CalculatePath 手动调试 |
| 动画状态锁住位移 | 检查 Animator 是否应用 Apply Root Motion | 关闭根骨骼移动或改为脚本驱动 |
其中最难定位的是第五种,动画锁住位移。很多游戏角色动画自带位移,Animator 面板里 Apply Root Motion 开着,动画播放时会把角色往前推,而 NavMeshAgent 也在往前推,两边打架,最后表现出来就是角色原地“抖动”或完全不动。解决方法是把 Root Motion 关掉,由 Agent 全权控制位置,动画只播动作和朝向。
4.2 多个单位互相卡位的处理方案
小怪数量多起来以后,你会发现一个经典问题:一群 AI 堵在狭窄入口无人进去,或者两个单位对面相逢后互相推挤,卡成雕塑。这背后的原因在于 Obstacle Avoidance 用的是本地避障,只管自己和附近几个邻居的关系,没有全局意识。
我的处理组合拳是:第一步,把 Obstacle Avoidance Type 从 High Quality 降为 Low Quality,降低避障频率和敏感度;第二步,给不同单位设置不同 Priority,让低优先级的单位稍微“让”一下路;第三步,在单位群体中使用小的 Radius 值,并配合 collision 代理来避免穿模;第四步,针对出门时容易拥堵的点,手工加一个“门禁检查”:如果目的地需要经过拥堵区域,让后期来的单位暂停等待几秒再重新寻路。
这个方法不完美,但应付中小规模的 RTS 或副本怪物已经足够。更多细节优化其实已经属于“群体寻路”范畴,如果项目复杂度很大,建议引入专业的流场寻路算法。
4.3 烘焙出来的网格坑坑洼洼或悬空
如果你发现网格上有很多没必要的孔洞,或者网格悬在离地面一段距离的半空中,大概率是这两个原因。一个是静态标记没勾全,某些物体没参与烘焙,但它占据了地面位置,地面上出现空洞;另一个是 Use Geometry 选了 Render Meshes,而模型网格自身有复杂曲面,烘焙的细节太多。
处理方式:先把 Use Geometry 切到 Physics Colliders,这样烘焙结果以碰撞体构成的地面为准,可以省掉很多视觉细节;然后把 Agent Type 的半径调大一点,让网格边缘收得圆润一些。烘焙出来的悬空网格多半是地面的碰撞体没有压平,多检查 Terrain 的底平面和 Collider 的范围对齐。
还有一个冷门但高概率的坑:场景里的高度差如果是通过“地面斜率”实现的,比如坡度大于 Agent Type 里 Max Slope 的默认值,那么这段斜坡直接不可行走。这时不代表网格破损,而是你的参数限制了角色爬坡能力。根据角色设计调整 Max Slope,或者把斜坡改成阶梯并加 Off-Mesh Link,都能解决。
4.4 性能问题:加载场景和烘焙卡顿
大型项目场景加载时,如果每个 NavMeshSurface 都自动执行 BuildNavMesh,帧率瞬间掉到个位数,这是很多团队踩过的坑。建议做法是把 NavMesh Surface 的收集模式和烘焙模式都改成手动,在场景加载完成后分帧或离线烘焙,运行时只加载预先序列化的 NavMeshData。
具体做法:编辑器里把 Surface 设在 Not Generated 模式,然后用脚本收集所有子场景的 Surface 数据,序列化到 AssetDatabase 里,运行时读取。这个方案我第一次用是在一个关卡大地图里,把原来 3 秒的加载卡顿压缩到 0.2 秒,效果立竿见影。
5. 进阶技巧与个人经验补充
聊到这里,基础实战基本覆盖完了。最后分享一个我自己很依赖的提升玩法:沿着路径画轨迹,用来做技能指示器或者怪物进攻路线预告。这个功能其实不复杂,通过 NavMeshAgent 的 path 属性拿到所有拐点,然后用 LineRenderer 渲染即可。
NavMeshPath path = new NavMeshPath(); if (NavMesh.CalculatePath(transform.position, targetPosition, NavMesh.AllAreas, path)) { lineRenderer.positionCount = path.corners.Length; lineRenderer.SetPositions(path.corners); }这个在我的 MOBA 类 demo 里用来展示攻击路线和撤退路线,效果非常直观。还有一个小技巧是配合 Timeline 做英雄预演,提前算好整段移动路径,然后每帧把 Agent 的坐标拉回到对应路径点,实现完全可控的“演示模式”——这个思路做新手引导很好用,不用依赖实时寻路的不确定性。
AI Navigation 这套系统在游戏开发里的地位,就好比物理引擎和动画系统一样,属于“基础设施”而非“特色功能”。大多数项目不需要你研究它的内部原理,但你需要知道哪些参数影响哪些行为,遇到问题往哪些方向排查。读完这篇文章,建议你先拿一个简单的场景,亲手烘焙、挂组件、写三五个脚本,把基础流程跑通。等这步掌握了,什么追击、巡逻、动态寻路都是顺水推舟的事。
我个人的感受是:寻路算法本身并不神秘,真正的复杂度永远在你的内容——场景怎么设计、角色怎么表现、异常怎么处理。AI Navigation 的价值在于把这套网格生成和路径搜索高度产品化,让我们把精力集中到真正体现玩法差异的地方去。对新手来说,不用一开始就沉迷于手写 A*,先用好这套成熟方案,你的简单小游戏项目会立刻变得更加耐玩。