资讯详情

资讯详情

Unity AI开发:自主移动控制系统与NavMesh寻路避障实战

做游戏开发这么多年凡是涉及 AI 角色我都会跟人强调一个观点一个角色能不能“活”起来首先看的不是它打了多炫的伤害数字而是它会怎么走路、怎么转向、怎么避开障碍、怎么从 A 点自己找到 B 点。这套东西在 Unity 引擎里有一个统一的说法叫智能角色的自主移动控制系统。今天这篇就围绕这个主题把我实际项目里用到的原理、组件参数、代码结构和坑一次讲清楚。这篇内容适合正在入门 Unity AI 的开发者也适合已经做过简单寻路、但觉得角色行为“很傻”想优化的朋友。标题里的第3章可以理解成一个系列教程里承上启下的位置——前面已经解决了角色怎么动、动画怎么播的问题现在要解决的是“让它自己决定怎么动”的问题。你把这套系统理解透彻之后后面的行为树、状态机、以及任务系统都会顺很多。1. 先搞清楚智能角色的“自主移动”到底在解决什么问题很多人第一次接触 Unity 的 NavMeshAgent觉得无非就是把一个组件拖上去然后调用SetDestination()角色就会自己走了。能跑通一个 Demo 和真正能放进项目里稳定运行中间差的距离很大。我见过不少新手把寻路做出来了结果角色卡在墙角不停抖、绕远路、走到一半被一个刚添加的箱子挡住直接原地发呆。这些问题的源头往往不是代码写错了而是对“自主移动控制系统”这七个字拆解得不够。1.1 从“会动”到“会自己动”自主移动的初级形态我们先定义一下什么叫“自主移动”。在 Unity 里让一个物体动起来有很多种方式直接改 Transform、用 Rigidbody 施加力、用 CharacterController 的 Move 方法、甚至用动画根运动 Root Motion 驱动。这些都属于“动”但它们的共同特点是动作的发起方是外部逻辑角色本身没有“决策权”。自主移动不一样。它的核心特征是有目标、有路径、有感知、有反馈。角色自己拿到一个目标点之后需要完成以下几个连续动作判断自己在哪目标在哪。规划一条可达的运动路径也就是寻路。沿着路径前进过程中要避让障碍物和其他角色。在到达目标、遇到阻挡、目标变化时调整自己的行为。你会发现这已经不是单纯移动的问题了而是一个轻量级的闭环控制系统。你在 Unity 里看到角色自己绕过栅栏、走台阶、穿过窄门这些都是这个闭环跑起来的结果。所以我说自主移动控制系统是整个游戏 AI 动作层的地基。地基没打牢后面的行为树再复杂也白搭。1.2 自主移动控制系统的核心组成感知-决策-执行既然它是一个系统那就不能只盯着 NavMeshAgent 组件本身。我在项目里习惯把它拆成三层来看感知层负责回答“我现在处在什么环境里”。这一层常见的手段包括射线检测前方有没有障碍、利用 NavMesh 的 SampleHeight 判断地面高度、用距离计算评估目标是否可达。感知结果决定了决策层要不要重新规划路径。决策层负责回答“下一步我该干什么”。这是状态机、行为树高层逻辑所在的层次。比如角色当前状态是“巡逻”那么决策层会不断生成一个又一个巡逻点状态切换成“追击”后决策层会持续把玩家的位置丢给移动层。执行层负责把决策变成实际的位移和表现。这一层就是 NavMeshAgent、Rigidbody、CharacterController、动画系统协同工作的区域。执行层的核心要求是“稳”——移动过程中不能抖动、不能穿墙、不能频繁重算路径。我见过最典型的错误是有人把决策层的逻辑写进了执行层里。比如在 Update 里每帧判断目标距离、每帧调用 SetDestination结果路径被疯狂重算表现就是角色在原地转圈CPU 也在无辜飙升。后面讲状态管理时我会专门说这个问题怎么避免。2. 底层方案选型为什么 Unity 里绝大多数移动都绕不开 NavMesh自己玩的时候你随便写写没问题但放到真实项目里寻路方案基本只有两条路用 Unity 自带的 NavMesh或者自己实现一套 A*。我的建议很简单——除非你有非常特殊的需求否则默认选 Unity 自带的 NavMesh 系统。因为自主移动控制系统最值钱的部分往往不在寻路算法本身而在寻路之外的避障、动态更新、动画衔接这类工程问题上。2.1 NavMesh 与 A* 的关系NavMesh 不是算法是数据结构这里必须纠正一个常见误区NavMesh 不等于寻路算法。NavMesh 全称 Navigation Mesh导航网格它其实是把关卡的地形、台阶、障碍物边界预先处理成一张“可走面的多边形网格”。真正在这张网上找路的过程背后往往仍然是 A* 或类似算法。打个比方A* 是“怎么在地图上找路线”的思路NavMesh 是“地图本身被提前画成了适合行走的区域”。你提前烘焙好 NavMesh相当于给角色准备了一张只标记了“人能走”和“人不能走”的地图。寻路时系统在这张网格上做多边形到多边形的路径搜索最后输出一条折线路径再由 NavMeshAgent 平滑跟随。那为什么不直接用格子地图加 A*要搞一个 NavMesh因为格子地图在 3D 游戏里有两个麻烦一是格子数量巨大二是有斜坡、台阶、高低差的时候格子很难表达“可攀爬”或者“可跳跃跨越”。NavMesh 直接基于地面坡度、障碍物高度生成一块块可导航的面天然解决这两个问题。2.2 两种常见实现路线内置 NavMesh 与手写寻路如果你在项目中看到有人坚持手写 A*大概原因有三类网格地图过关、走廊窄路特别多希望精确控制或者游戏玩法本身强依赖格子逻辑比如塔防又或者是对 NavMesh 在动态场景下的烘焙更新性能不满意想自己搞一套轻量方案。我给你列个对比方便自己判断对比维度Unity 内置 NavMesh手写 A* 格子地图落地速度快烘焙 组件即可用慢需要自己处理网格数据与路径平滑3D 环境适配斜坡、台阶、高差处理出色需要大量额外编码动态障碍物NavMeshObstacle Carve 模式可用需要动态更新格子数据多人同屏性能有局部避障但仍要控制代理数量可控性强但算法与数据结构全要自己维护典型适用大多数 3D 动作、RPG、模拟经营2D 策略、塔防、固定路线迷宫我的判断标准很简单如果你的关卡是连续的地面地形不是网格化的棋盘那就老老实实用 NavMesh。手写 A* 表面上看是“更底层更酷”但后续维护量会让你后悔。自主移动控制系统需要的是一套成熟稳定的路径生成机制而不是让你从头造轮子。3. 动手搭建一个基础的自主移动控制系统实操重点这一部分是最重要的。我按项目里实际操作顺序从场景准备、组件参数到最小可运行代码一步步拆给你看。3.1 场景准备与 NavMesh 烘焙先说最简单的场景搭建思路创建一个 Plane 当底板再放几个 Cube 当障碍物然后做一个 Capsule 当玩家角色。注意Plane 和 Cube 都需要有 Mesh Collider否则烘焙时不会参与计算。这一步很多新手会忘结果场景看起来挺正常但烘焙出来的 NavMesh 就是一片空白。选中场景里的所有静态物体在 Inspector 顶部把 Static 勾上这是烘焙导航网格的前提。如果不勾 Static烘焙时会默认忽略这些物体。然后打开 Window AI Navigation在 Bake 面板里设置参数Agent Radius代理的半径要跟你实际角色的碰撞半径匹配。Agent Height代理高度防止路径穿过低矮缝隙。Max Slope可行走的坡度角度默认 45 度。Step Height允许的台阶高度比如 0.3 意味着角色能迈上 30 厘米以内的台阶。Min Region Area小于这个面积的独立可走区会被剔除防止出现一些不可达的小碎片。点 Bake正常情况下场景里会出现一层蓝色覆盖区域这就是烘焙出来的 NavMesh。蓝色区域必须完整覆盖角色可行走的全部地面如果有空洞或者裂缝后面的寻路一定会出问题。3.2 NavMeshAgent 组件参数逐个拆解给角色挂上 NavMeshAgent 组件之后先别急着写代码把参数过一遍。每个参数我都用实际碰到的现象给你解释这样你印象能深一点。Speed代理的最大移动速度。如果你的角色还有自己的移动速度上限比如动画根运动或者角色的额外移动脚本这些数值一定要统一否则会出现走得比动画快、或者速度变量互相覆盖的问题。Angular Speed最大转向角速度。降低这个值角色转弯更慢看起来更自然但调太低会导致角色一边走路一边画圈。我一般习惯把转向平滑交给转向逻辑这个值会给一个中等水平比如 360 到 720 之间。Acceleration加速度。这个参数影响角色从静止到全速的响应速度。数值太高角色起步像被兔子蹬了一脚数值太低角色追人的时候会很肉。常规值 8 到 12 之间比较舒服。Stopping Distance到达目标多少距离内算“到达”。这个很关键。如果设成 0角色会试图完全贴到点位上而路径终点往往有误差于是它会在终点附近一直调整看起来像原地抖动。建议至少设到 0.5 或者更大再配合你自己的到达判距来切换状态。Auto Braking到达终点附近是否自动减速。巡逻、走到目标点这类场景保持开启比较自然。如果是追击玩家这种需要贴身跟的目标建议关闭让代理保持速度避免到了玩家身边突然一顿一顿。Radius 和 Height物理尺寸影响避障计算务必与角色实际尺寸接近。Obstacle Avoidance Type避障精度。精度越高 CPU 开销越大普通移动用 High Quality 即可几百个单位同屏再考虑降档。3.3 最简移动控制代码设定目标、路径跟随、到达判定下面这个代码是自主移动控制系统的最小骨架我直接给你一个能跑的版本。using UnityEngine; using UnityEngine.AI; [RequireComponent(typeof(NavMeshAgent))] public class SmartAgentController : MonoBehaviour { private NavMeshAgent agent; private Transform target; [SerializeField] private float arriveDistance 1f; private void Start() { agent GetComponentNavMeshAgent(); } private void Update() { if (target ! null) { agent.SetDestination(target.position); } // 到达判定根据剩余的路径长度判断而不是直接看剩余距离 if (agent.pathPending false agent.remainingDistance agent.stoppingDistance agent.hasPath true) { // 到达目标之后的逻辑比如切换到 Idle } } public void MoveToPoint(Vector3 point) { agent.SetDestination(point); target null; } public void FollowTarget(Transform t) { target t; } }这里有个新手最容易看走眼的地方到达判定不要只看remainingDistance因为当 agent 还没有计算好路径时remainingDistance是 0。如果你不先查pathPending角色可能站在原地就误判成“到目的地了”。这个细节我实际排查过很久路径计算的第一帧就会遇到这种问题。另外SetDestination并不需要每帧调用。在这个例子里我处理得偷懒了一些把目标点写在 Update 里方便演示。真实项目里你应该只在目标变化时才调用后面讲状态管理时我会给更完整的结构。4. 让移动看起来“聪明”避障、动态障碍与平滑转向路径找出来是一回事角色真正走起来能不能顺畅绕过别人能不能在障碍物突然出现时做出反应这才是“智能”和“死板”的分水岭。4.1 静态避障 vs 动态避障RVO 避障原理NavMesh 本身只代表静态场景如果两个角色迎面而行或者一个箱子突然被推到路中间纯 NavMesh 路径规划是反应不过来的。这时候要靠动态避障机制。Unity 的避障底层叫 RVOReciprocal Velocity Obstacles互惠速度障碍。这个概念听起来复杂实际可以理解成每个带避障的代理都会推测周围其他代理在一小段时间之后的位置再去调整自己的移动方向避免撞上。它跟物理碰撞不一样物理碰撞是已经撞上再弹开RVO 是提前绕开。这就带来一个重要结论避障层处理的是“短时修正”路径规划处理的是“全局路线”。角色沿着 NavMesh 路径走遇到别的角色挡住去路时避障层会让它稍微偏一下绕过去之后又自动回到原路径上。所以你在项目中不必对每个 NPC 做复杂的排队逻辑RVO 已经帮你处理了一部分。4.2 动态障碍物的两种处理方式NavMeshObstacle vs 重新烘焙动态障碍物的处理Unity 提供了 NavMeshObstacle 组件。它有两种模式一种是纯粹的“避开”一种是“切洞”。我主要讲切洞模式也就是 Carve 选项。勾选 Carve 后障碍物会在 NavMesh 上动态挖出一个洞正在寻路的代理能感知到这块区域不能走路径会重新规划没有勾选 Carve 的障碍物只是通过避障让代理绕一下。区别在于如果障碍物很大比如一辆卡车停在路中间靠 RVO 避障可能绕不过去因为路径本身仍然横穿障碍物而 Carve 模式会强迫路径重新规划从障碍物周围走。Carve 模式有个性能铁律动态切洞操作比较消耗资源不能大量同时存在。我踩过坑在关卡里放了十几个 Door 全部开了 Carve结果运行时 NavMesh 每帧都在重新切洞帧率直接掉到 30 以下。后来改成只有真正需要动态阻挡的门调用enabled开启其他一律静态烘焙性能就回来了。4.3 平滑旋转与动画混合让角色移动不僵硬移动控制做到这一步角色已经能找路和避障了但看起来还不够像人。原因通常是两点转向太生硬动画没有和速度匹配。NavMeshAgent 默认的旋转是希望角色面向移动方向。如果你想让角色转身更自然可以关掉 agent 的updateRotation然后自己用Vector3.Slerp或者Quaternion.Slerp做平滑旋转private void SmoothRotate(float rotateSpeed) { if (agent.velocity.sqrMagnitude 0.1f) { Quaternion targetRotation Quaternion.LookRotation(agent.velocity.normalized, Vector3.up); transform.rotation Quaternion.Slerp(transform.rotation, targetRotation, Time.deltaTime * rotateSpeed); } }动画混合这块最常见的方式是用 Animator 的参数绑定 agent 的即时速度。比如在 Update 里把agent.velocity.magnitude换算成 0 到 1 之间的量传给 Animator 的speed参数。但要注意agent.velocity 和动画速度之间需要做平滑过渡直接赋值会导致动画像被拉扯一样。我习惯用Mathf.SmoothDamp做一个标量版的平滑private float smoothSpeed; [SerializeField] private float smoothTime 0.2f; private void UpdateAnimatorSpeed() { float targetSpeed agent.velocity.magnitude / agent.speed; smoothSpeed Mathf.SmoothDamp(smoothSpeed, targetSpeed, ref smoothSpeedVelocity, smoothTime); animator.SetFloat(Speed, smoothSpeed); }这套写法是我项目里一直沿用的小模板。重点是让动画曲线和实际移动速度之间留出一个缓冲看起来就不会有“轮子转了但方向没跟上”的违和感。5. 移动控制系统的状态管理别让角色像个愣头青如果你让一个角色只做一件“走到 A 点”的事上面的代码已经够了。但只要角色稍微复杂一点比如巡逻、追击、返回、发呆几种状态来回切换你就要考虑移动控制系统的状态管理了。不然代码会变成一堆 if 套 else改一个需求要牵连一大片。5.1 移动状态机设计与代码骨架我的习惯是用一个枚举加一个简单状态类来控制。这里直接给你一个参考结构它是从实际项目里简化出来的public enum MovementState { Idle, Patrol, Chase, Return }每个状态里需要关心的核心接口有三个进入状态时做什么、每帧更新做什么、退出状态时做什么。在移动控制系统这个层面状态机主要管两件事目标是谁、当前副本也就是移动目标的合法性。private MovementState currentState; private void UpdateStateMachine() { switch (currentState) { case MovementState.Idle: // 不发目标 break; case MovementState.Patrol: HandlePatrol(); break; case MovementState.Chase: HandleChase(); break; case MovementState.Return: HandleReturn(); break; } } private void ChangeState(MovementState newState) { if (currentState newState) return; currentState newState; // 进入新状态时重置代理的路径防止旧路径残留影响新状态 agent.ResetPath(); }注意ResetPath()这个细节。切换状态时如果不重置路径代理可能还会按旧路径走一小段导致角色看起来“违抗指令”。这是状态切换里最容易犯的毛病。5.2 到达、等待、巡逻的切换逻辑巡逻是自主移动控制系统里很典型的一个场景。它要回答的问题很简单我到了这个点接下来去哪我常用一个循环数组的写法[SerializeField] private Transform[] patrolPoints; private int patrolIndex; private void HandlePatrol() { if (agent.pathPending) return; if (agent.remainingDistance agent.stoppingDistance) { patrolIndex (patrolIndex 1) % patrolPoints.Length; agent.SetDestination(patrolPoints[patrolIndex].position); } }下面这种写法有个优势巡逻点之间的停留时间可以用Idle状态插入比如到达之后先切换成Idle等三秒再切回Patrol从用户视角看就是角色在站点巡逻时有停顿感这个可以看巡逻业务的需求。如果你不想做得太复杂直接在到达后做一个Timer再发下一个点也可以。5.3 多代理目标分配时的常见坑同时更新、目标被夺当多个角色同时跟进一个目标点比如一堆小兵同时追主角最容易出现的问题是目标点被反复竞争。每个小兵每帧都往玩家当前坐标SetDestination路径重算非常频繁而且所有小兵很容易挤在同一方向被彼此的 RVO 避障互相堵住。我实际项目里的处理方式是把追击目标的更新频率降低不要每帧更新而是每隔 0.2 秒或者 0.5 秒更新一次目标位置。这样既不会出现明显滞后路径重算压力也小很多。另外还要注意“目标被夺”的问题。比如两个角色都想去同一个资源点结果两个都跑过去发现只能由一个拾取。这时移动控制系统自身不应负责处理资源归属它只负责走到点附近。到了之后具体谁能拿要交给上一层的职责判断。很多新手会试图在移动层强行写“先到先得”结果把移动逻辑和业务逻辑搅在一起后面想改需求改到头皮发麻。6. 实测中的常见问题与排查技巧实录下面这些坑我基本都在真实项目里撞过。写成速查表你照着排查能省一下午。6.1 角色不走、原地抖动、卡死角这三类问题是寻路系统最典型的“日常”。角色不走通常的检查顺序是先看 NavMesh 有没有烘焙成功场景里有没有蓝色覆盖面。再看角色挂的 NavMeshAgent 是否 enabled某个状态切换时有没有被误关。再检查SetDestination传入的目标点是不是在 NavMesh 上。用NavMesh.SamplePosition在代码里打个点确认落在可走区域这一步是排查“角色没反应”最快的方法。原地抖动绝大多数因为StoppingDistance太小代理在终点附近反复微调。把停止距离调到 0.5 以上再观察。卡死角往往发生在角落、窄道路径规划认为可以通过但代理的物理半径或避障半径太大实际身体过不去。这时候调小 Radius或者检查窄道处的 NavMesh 间隙是否小于代理直径。烘焙参数里 Agent Radius 设大了也会导致窄通道烘焙出来就是断的。6.2 寻路路径不合理 / 绕远路NavMesh 默认只会输出一条可达路径不代表最短路径。如果你发现角色明显绕远常见原因是某一片区域被烘焙成了不可达或者Max Slope设置太小把本来能走的斜坡剔除了。还有一个容易被忽视的点相邻区域必须共享 NavMesh 边界否则寻路会认为两个区域完全不连通。排查时可以在 Scene 视图打开 Navigation 可视化把 Areas 显示打开看路径经过的每个节点的面积颜色是否正常。如果绕远是因为多个区域之间的连接太过稀疏可以手动添加 Off-Mesh Link让角色可以跳过两个可走区域之间的缺口。6.3 Agent 与刚体同时控制的冲突最后一个坑十个人九个踩角色既挂了 NavMeshAgent又挂了 Rigidbody 或者 CharacterController然后还写了一段物理移动代码。两个移动源同时生效的结果就是角色要么抽搐要么被物理撞飞。规则很简单如果做寻路移动就让 NavMeshAgent 作为唯一的移动执行者物理组件只做碰撞检测不做力驱动的位移。如果你用的不是 NavMeshAgent 自带移动而是想自己做那就干脆把 agent 的updatePosition关掉只让它提供路径数据实际位移由你的物理移动代码自己处理。两者只能选一条线。我这里给一个排查路径是否异常的调试可视化技巧在角色身上挂一个小的 LineRenderer或者用Debug.DrawLine把agent.path.corners绘制出来。这样你一眼就能看到角色当前走的完整路径比盯着 Game 视图猜疑乱猜快得多。写在最后的实际建议根据我自己的项目经验自主移动控制系统最容易低估的部分不是寻路算法而是移动时的“手感”和“稳定性”。路径计算通常几百行代码就能跑通但要让一个角色在上下坡、窄道、拥堵环境里走起来不穿模、不抖动、不原地打转需要反复调参和大量场景测试。如果你正在做一个多角色的项目我建议你从一开始就把上面这些状态切换、动态障碍、动画混合的逻辑想清楚而不是等功能堆起来后再返工。最后再给一个顺手的小技巧在 Unity 的 Navigation 窗口里把Show Pathfinding勾上运行时就能看到代理每一次路径搜索的边界和结果排查某些“为什么走过去又走回来”的问题时特别好用。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →