news 2026/10/17 1:33:48

Unity类红警单位移动:从NavMesh烘焙到点击寻路全拆解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Unity类红警单位移动:从NavMesh烘焙到点击寻路全拆解

两年多前我开始在 Unity 里做一款俯视角战争模拟的试玩 Demo,第一个把我锤到墙上的问题就是:单位移动怎么做到像样?不是“点哪走哪”就完了,而是走路要绕开障碍、连续下达命令要立即切换目标、单位多起来还需要有一点阵型感。这些都是即时战略类游戏里最常见的体感,也是“类红警移动”的核心。后来我把 Unity 官方一个 RTS 玩法示例里那套基于 NavMeshAgent 的移动骨架扒下来,改写成自己的控制流程,才算是把问题理清爽。这篇文章就把这套思路完整拆开:从内部逻辑、场景烘焙、点击链路,到状态切换和踩坑记录,都会用大白话讲透,适合正在做即时战略、MOBA、生存类玩法,或者只想知道 NavMesh 到底该怎么用的 Unity 开发者。

1. 先搞懂:类红警移动到底在解决什么问题

很多人一上来就抱着 NavMeshAgent 函数手册猛查,这是不对的。你连要解决的问题都没拆干净,后面写代码一定会拧巴。类红警游戏的移动,表面上是“鼠标点一下地面,单位走过去”,但把它放进真实游戏里,你要面对的是下面这一堆诉求同时成立。

1.1 即时战略里的移动不只是“走过去”

先列几个玩家体感层面的硬性要求,你感受一下:

  • 点击目标点后,单位如果碰到墙、河、山体,得自动绕路,而不是傻乎乎地顶在障碍物上左右摇头。
  • 在单位还没走到当前目标时,如果我又点了另一个位置,它要立刻转向新目标,路径要重新算,不能把旧路径走完。
  • 多个单位被同时选中并下达命令时,不能所有人挤成一个点,至少要稍微散开,看起来像一支部队在行动。
  • 到达目的地后,单位要停在一个稳定位置,不能因为反复修正路径而在原地抖来抖去。
  • 移动过程中单位朝向要平滑转向,动画要从 idle 切到 run,停下后又切回 idle,这些都要跟移动状态联动。

如果只用最简单的方式,比如 Update 里朝目标点 lerp 移动,遇到第一个障碍就卡死;如果用几十个 if 自己处理避障和寻路,那基本是在重造一个导航系统,工作量远超你的预期。所以类红警移动本质上是一个“输入层 + 寻路层 + 单位状态层”三层联动的事情,而不是单个移动函数。

1.2 为什么拿 NavMeshAgent 做地基

开发 RTS 玩法的时候,选型上有几条路:自己写 A*、用现成的网格寻路插件、用 Unity 自带的 NavMesh。我的选择非常明确,用 NavMeshAgent,理由有三个:

第一,Unity 自带的导航系统把“寻路”和“避障”两个最麻烦的问题替你兜底了。我只需要把场景里的障碍物静态标记好,烘焙出 NavMesh,每个单位挂一个 NavMeshAgent,然后不停地把目标点赋给 agent.destination,它就会自己算路径、自己绕障碍、自己处理跟障碍物的碰撞。

第二,官方示例里的做法已经被大量项目验证过,很多商业游戏和独立游戏的 RTS 原型都沿用这套思路,出问题也好搜、好问。自己写寻路算法,看似自由度高,但你得同时解决路径点平滑、A* 的启发函数、动态障碍物更新、单位互相避让,这一套下来,光调试时间就能把一个原型周期吃光。

第三,NavMeshAgent 内置的 Steering 行为让单位移动起来很自然:加速、减速、角速度转向、停止距离都是预设好的。这些参数你调一调就能做出“重型坦克缓慢稳重”和“轻型单位轻快灵活”的差异化手感,而我如果用自己写的移动逻辑,手感全靠手搓,细节量大得吓人。

有人会担心 NavMesh 是 Unity 私有格式,拿不出来,跨引擎不方便。这个担心确实存在,但对一个用来验证玩法、跑通逻辑的项目来说,先做出来远比先做对架构重要。你真要跨引擎,到时候再换寻路库也不迟,移动控制层的代码结构只要写干净,替换成本很低。

1.3 官方案例里的移动骨架到底长什么样

Unity 官方 RTS 示例的移动骨架,理解起来其实就四行:

  • 用射线检测玩家的点击坐标;
  • 拿点击坐标去 NavMesh 上采样一个合法目标点;
  • 把目标点赋给选中单位的 NavMeshAgent.destination;
  • 监听 agent 的路径状态和剩余距离,同步单位的动画、朝向和选中状态。

这个骨架没有一丝多余。我在自己做的时候想过加“命令队列”“编队阵型”“移动攻防切换”,后来全删了,先让骨架跑通。因为骨架跑通之后,你会发现大部分高级玩法都是在这个基础上叠加逻辑:命令队列就是存多个 destination,编队阵型就是给不同单位的目标点加偏移,攻防切换只是不同状态机之间的跳转。

所以你第一步不是去找奇技淫巧,而是把上面这四条链路打通,像呼吸一样自然,然后再谈扩展。

2. 场景落地:导航网格和 Agent 参数

移动逻辑写再多,如果场景里的 NavMesh 是歪的,单位照样走不对。导航网格烘焙这一步看起来简单,其实有一堆隐藏参数决定最终效果。这里面的坑我踩了不少,下面按实操顺序讲。

2.1 第一步:标记障碍物并生成 NavMesh

先把场景搭好。地面用一个大 Plane,或者任意一个 Mesh,保证它是一块封闭且朝上的表面。障碍物可以是 Cube、Model 或任意凸体,只需要在 Inspector 右上角把 Navigation Area 改成 Navigation Static。

注意,这个标记不是让 GameObject 身上多一个组件,而是告诉烘焙系统“这个物体是静态世界的一部分,路径计算要考虑它的阻挡”。大多数人在这一步出错是因为只标了障碍物,没标地面。实际上地面也必须标记为 Navigation Static,不然烘焙出来是空的。

标记完之后,打开 Window -> AI -> Navigation 面板,选到 Bake 标签。如果项目里装了 AI Navigation 包,会对应有 NavMesh Surface 组件;旧版就是 Navigation 面板。点 Bake,等几秒,Scene 视图里会显示蓝青色的网格,那就是可通行区域。

烘焙完先转动视角检查几件事:墙壁边缘有没有缺口、台阶高度处有没有断层、可通行区域是不是完全覆盖了期望的地面。如果发现不该通行的地方是蓝色,说明我漏了标记障碍物,或者障碍物被其他 GameObject 的碰撞体挡住了。

我自己的习惯是给地面和静态障碍物各建一层 Layer,烘焙前用代码或者手动把这一层的碰撞体全部标记为 Navigation Static,这样场景里其他特效、胶囊碰撞体、装饰物就不会把网格烘焙得七零八落。

2.2 关键参数:不是随便填就行

Bake 面板里有几个参数,直接影响单位寻路的精细度和性能,我在下面用表格列一下,并解释每个参数的实际意义。

参数常用值实际影响
Agent Radius0.5决定路径的最小通道宽度。如果单位视觉体型小但半径调太大,狭窄通道就走不过,表现为明明有空隙却绕远路。
Agent Height2.0决定单位能通过的最低高度。比如低矮屋檐下如果小于这个值,就过不去。
Max Slope30-45控制单位能爬的坡度。超过该坡度,路径会绕开。
Step Height0.3控制单位能直接迈上的台阶高度。超过这个值,会当作悬崖处理。
Min Region Area2小于该值的孤立可通行区域会被剔除,避免单位被引导到一块小悬崖上。

这些参数既影响烘焙,又影响运行时单位采样。最让我头疼的坑是:单位胶囊体半径明明是 0.5,NavMeshAgent 上的 Radius 也设成 0.5,但烘焙面板里的 Agent Radius 被设成了 2。结果所有窄门都不通,单位总是绕路。因为烘焙时用的是面板里的全局参数,而不是单个 Agent 身上的参数。

所以要养成一个习惯:先确定一个单位的基准尺寸,把烘焙参数和所有 Agent 实例统一设置。我建议把 Agent Radius 设成单位半径的 1.2 倍,留一点余量,避免路径贴边。

2.3 Scene 里加动态障碍物怎么办

静态场景烘焙一次就行了,但很多地图里有门、升降平台、开合墙体这样的动态障碍。Unity 有专门组件处理这个:NavMeshObstacle。

我给门或墙体挂上 NavMeshObstacle,把 Shape 设成 Box,Carve 属性打开。这样当障碍物移动或旋转时,它会在 NavMesh 上凿出一个实时缺口,单位就会绕开。Carve 打开后有个参数叫 Carve Only Stationary,如果勾选了,那么这个障碍物只有在完全静止时才会生效,单位不会再移动过程中躲避它,自己看着办。

实际使用中,Carve 的变动越少越好,因为每次变化都触发局部网格更新,很消耗性能。游戏里同时跟玩家抢时间的开关门,控制在三五个以内就够了,不要做几十个 Carve 同时开合的场景。

3. 点击控制链路的完整实现

场景网格搞定后,接下来是最核心的输入链路:鼠标点击如何变成一个单位移动命令。这部分的代码不复杂,但要把“点单位选中”和“点地面移动”两件事分开,不然逻辑会很乱。

3.1 从屏幕坐标到世界坐标

Unity 里屏幕点击转世界坐标,我用的方式是:

Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition); Physics.Raycast(ray, out RaycastHit hit, 1000f, moveLayerMask);

这里有个关键点:鼠标点击的时候,射线可能会打到地面、单位、墙壁、UI 各种东西。所以我建议把地面单独放在一个 Layer,比如叫 Ground;单位放在 Unit Layer。这样通过 LayerMask 精确区分点击目标。

我习惯把 LayerMask 分成两组:

  • 地面层(Ground Layer):用于移动命令的目标点拾取;
  • 单位层(Unit Layer):用于选择单位。

3.2 点击分拣逻辑

完整的分拣逻辑是这样的:

if (Input.GetMouseButtonDown(0)) { if (EventSystem.current.IsPointerOverGameObject()) return; // 点在 UI 上,忽略 Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition); if (Physics.Raycast(ray, out RaycastHit hit, 1000f, unitLayerMask)) { // 点到了单位,执行选中逻辑 SelectUnit(hit.collider.GetComponent<Unit>()); return; } if (Physics.Raycast(ray, out hit, 1000f, groundLayerMask)) { // 点到了地面,给所有选中单位发移动命令 CommandMove(hit.point); } }

注意,这里我把 EventSystem 的检查放在最前面,因为 UI 在屏幕最上层,很多新手被坑就是因为点击地面移动,结果 UI 挡住了射线,单位不动。

3.3 NavMesh.SamplePosition 的使用

射线打到地面的点,理论上应该已经在 NavMesh 上了,但实务中地面可能存在缝隙、模型接缝、或鼠标点在悬崖边缘。为了避免 agent.destination 被设置成一个不可达的点,我会先用 NavMesh.SamplePosition 把目标点修正到最近的 NavMesh 上:

Vector3 target; if (NavMesh.SamplePosition(hit.point, out NavMeshHit navHit, 2.0f, NavMesh.AllAreas)) { target = navHit.position; } else { return; // 没找到合法目标点,放弃 }

这个函数相当于“吸附”效果,把射线打出来的坐标旁边 2 米纳网格内最近的一个合法点取出来。这样单位就不会出现“一直在原地转圈找路”或“停在目的地外沿发呆”的尴尬状态。

3.4 单个单位与多个单位的移动命令

选中单位可能有多个。如果同时给一群单位设置同一个 destination,那它们会挤成一团,这在 RTS 里非常难看。解决办法是在目标点周围加偏移,让每个单位的实际目标点不一样。

我写过一个简易的群组偏移:

public void CommandMove(Vector3 center) { int count = selectedUnits.Count; for (int i = 0; i < count; i++) { Unit unit = selectedUnits[i]; if (unit == null) continue; Vector3 offset = GetFormationOffset(i, count); unit.SetDestination(center + offset); } }

GetFormationOffset 方法就是一个二维阵列分布,比如 2-3 人小队就横排,5-6 人小队就两行,单位多一点就扇形展开。你也可以用最简单的方式:从队伍序号算出围绕中心的圆环角度,让每个单位落在这个环上。

float angle = (360f / count) * i * Mathf.Deg2Rad; Vector2 circle = new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)) * 1.5f; return new Vector3(circle.x, 0f, circle.y);

这样多选单位的移动会自然散开,而不是叠在一个点上。

3.5 完整 Unit 控制脚本

下面是单单位侧的核心脚本,我简化掉了动画部分,只保留移动行为:

[RequireComponent(typeof(NavMeshAgent))] public class Unit : MonoBehaviour { private NavMeshAgent agent; private bool isSelected; void Awake() { agent = GetComponent<NavMeshAgent>(); } public void SetSelected(bool value) { isSelected = value; // 在这里刷新选中高亮,比如换材质或显示圈圈 } public void SetDestination(Vector3 target) { agent.SetDestination(target); } public bool HasReachedDestination() { if (!agent.pathPending) { if (agent.remainingDistance <= agent.stoppingDistance) { if (!agent.hasPath || agent.velocity.sqrMagnitude == 0f) { return true; } } } return false; } }

HasReachedDestination 这一段是我调了很久才满意的。官方示例里经常直接用 agent.remainingDistance < agent.stoppingDistance,但有些情况下单位没到目标点,remainingDistance 也会短暂地小于 stoppingDistance。所以我会再加上 velocity 判断,确保单位真正停下来了再切状态。

4. 移动逻辑的手感调优与状态管理

单位会走了之后,你会发现它走得很“机械”:转向太突然、停下来还往前蹭一下、动画切得生硬。这些手感问题基本都出在状态管理和 NavMeshAgent 参数联动上。这节我聊聊怎么让单位“活”起来。

4.1 从官方案例里学到的停止判定

官方示例的移动停止判断逻辑可以总结成一句话:只有当目标点已经设置、路径没有重新计算、剩余距离小于停止距离、并且单位速度已经接近零的时候,才算真正到达。

我自己的生产级判定是这样的:

public bool IsMoving() { if (agent.pathPending) return true; if (agent.remainingDistance > agent.stoppingDistance) return true; if (agent.velocity.magnitude > 0.01f) return true; return false; }

这个判定放在 Update 里驱动一个 IsMoving 属性,单位控制器拿它切动画。你只需要知道,NavMeshAgent 的实际停止点不是 destination,而是 destination 减去 Stopping Distance 之后的一小段距离。如果 Stopping Distance 设成 0,单位会试图反复精确对准目标点,反而容易因为误差产生抖动。我建议设成 0.2 左右。

4.2 朝向控制:别让单位横着走

NavMeshAgent 默认会自动转向,角速度参数 Angular Speed 控制转向快慢。如果单位是坦克、装甲车这类带炮塔的东西,你可能想让它机身和移动方向分离。我的做法是让 NavMeshAgent 的自动转向关掉,只保留移动,转向自己写。

public void Update() { Vector3 direction = agent.velocity; direction.y = 0; if (direction.magnitude > 0.01f) { Quaternion targetRotation = Quaternion.LookRotation(direction); transform.rotation = Quaternion.Slerp(transform.rotation, targetRotation, Time.deltaTime * angularSpeed); } }

把 agent.updateRotation 设为 false,然后自己用 Slerp 旋转,手感会平滑很多。官方示例里用的也是类似的方式,因为直接让 agent 控制转向,速度太快,战斗单位看起来像装了陀螺仪。

4.3 巡逻与路径点扩展

类红警的移动还有一个标配功能:巡逻。给单位一串路径点,让它按顺序走,走完最后一圈重新回到第一个。在 NavMeshAgent 上实现巡逻非常自然,因为它本身就有“完成一个目标后再去下一个”的能力。

public class PatrolUnit : MonoBehaviour { private Vector3[] waypoints; private int currentIndex; private NavMeshAgent agent; public void StartPatrol(Vector3[] points) { waypoints = points; currentIndex = 0; agent.SetDestination(waypoints[currentIndex]); } void Update() { if (waypoints == null || waypoints.Length == 0) return; if (agent.remainingDistance <= agent.stoppingDistance && !agent.pathPending) { currentIndex = (currentIndex + 1) % waypoints.Length; agent.SetDestination(waypoints[currentIndex]); } } }

如果想让巡逻看起来更自然,每个路径点之间加一个随机扰动。比如:

Vector3 randomOffset = new Vector3(Random.Range(-1f, 1f), 0, Random.Range(-1f, 1f)); agent.SetDestination(waypoints[currentIndex] + randomOffset);

4.4 动画状态机的联动

单位在 idle 和 run 之间切换,需要在 Unit 脚本里暴露一个公共状态,比如 MovementState 枚举:

  • Idle
  • Moving
  • Patrol

然后将这些状态发给 Animator:

animator.SetBool("isMoving", IsMoving());

这块没有太多技术难度,但提醒一下:RTS 里的移动动画非常忌讳“瞬切”。如果单位从平常步行进入跑步时动画突然换掉,视觉上会非常僵硬。可以给动画器加一层 Blend Tree,按速度把 idle、walk、run 混合起来,而不是直接布尔切换。

5. 实操中常踩的坑和排查思路

这段内容全是我在开发过程中被真实折磨过的问题。我把它们整理成一张速查表,后面再把几个只靠现象不好定位的坑单独拿出来说。

5.1 常见问题速查表

现象可能原因解决方案
单位完全不移动destination 不在 NavMesh 上,或者 Agent 的 isStopped 为 true用 NavMesh.SamplePosition 吸附目标点,并确保没有设置 agent.isStopped = true
单位一直绕远路Agent Radius 偏大,窄道被烘焙成不可通行调小烘焙面板的 Agent Radius,重新 Bake
单位到达后原地抖动Stopping Distance 设成 0设置 0.1-0.5 的停止距离
点地面没反应UICaster 挡住了射线在射检前先检查 EventSystem.current.IsPointerOverGameObject()
多个单位挤成一团缺少阵型偏移按单位序号给目标点添加圆环偏移
单位走一半突然停住路径被动态障碍物 Carve 更新打断检查 NavMeshObstacle 的 Carve 频率,优化动态网格更新
动画切得太生硬直接切换 idle/run 布尔使用 Blend Tree 按速度混合动画

5.2 关于 NavMesh 没生成或没更新的坑

最典型的状况:改完场景、加完障碍物,运行游戏时单位还是无视新墙体,直直穿过去。原因几乎都是忘记重新 Bake。Unity 的 NavMesh 是独立的数据缓存,不改动场景静态标记,就不会自动刷新。

我在开发过程中给自己定了一个简单规则:每次改完场景地形,第一件事就是重新烘焙。如果项目人多,我会写一个编辑器脚本,快捷键一键烘焙,避免有人漏掉。

另一个坑是,运行状态下动态加的障碍物用 Carve 没问题,但如果是在运行状态动态改一个物体的 Navigation Static 标记,那是不生效的。Navigation Static 是烘焙时的标志,运行时改它需要在运行时用 NavMeshSurface 的 UpdateNavMesh 接口,别指望改动立刻生效。

5.3 移动手感调优的个人经验

最后分享几条我自己调参时的经验,能帮你少走很多弯路。

首先,速度不要给太高。NavMeshAgent 的 Speed 设 6 以上时,单位在转弯处会产生漂移感,看起来不像是走路,像溜冰。想表现快速单位,与其提高速度,不如降低角速度,让它在转向时减速、保持稳定感。

其次,Acceleration 要区分单位类型。重型单位的 Acceleration 设 2-3,起步慢,刹车也慢;轻型单位设 8-10,点一下立刻窜出去。这个差异感对“类红警手感”非常重要,比把美术做得花哨管用得多。

第三,如果你的游戏有暂停功能,注意把 NavMeshAgent 的 velocity 在暂停时清零,恢复时重新计算,不然单位会带着旧速度冲出去。

第四,多个单位同时移动时,给单位加一个最小间距约束。最简单的方式是在单位之间加一个 Sphere Collider 或 NavMeshAgent.Radius 调大一点。但别只靠碰撞体,真碰撞会把路径寻址搞乱,单位互相推开后重新规划路径,形成来回抖动。更稳的方案是让每个单位的目标点独立,同时降低 Agent 之间的避障档次,让它们轻微重叠但不完全重合。

提示:NavMeshAgent 自带的 Avoidance Priority 可以用来设置高优先级单位在碰撞时主动避让。但要注意,Avoidance 只处理速度层面的避让,不处理路径重算,不能用它替代 Carve 障碍物。

最后再分享一个小技巧

我自己的 Unity 项目里所有单位脚本都继承一个 MoveController 基类,里面封装好 SetDestination、IsMoving、OnReachedDestination 这几个事件。这样移动功能是一棵树,后面要加寻路可视化、命令历史、操作回放,都不用改单位核心代码。

实际开发里,类红警移动最容易被低估的不是那几行寻路代码,而是“单位停下来之后的状态恢复”。很多游戏到后期出 bug,都是因为单位到达目标点后状态没有正确切回 idle,导致动画还是跑步姿势,技能也打不出来。我建议在 Unit 脚本里,每次 SetDestination 都重置一下 hasReached 标记,然后在到达回调里做一次强制状态复位,后期会省很多排查时间。

这个方案是从一个官方 RTS 示例里拆出来的,自己也已经在一款模拟项目里跑过,踩过的坑基本都在上面。你照着我这个顺序做一遍,单位移动那一块不会卡你超过一星期。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/17 1:33:47

基于GD32VF103VBT6与MP2642GR-Z的两串锂电池均衡方案设计与实现

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/17 1:33:35

从说话到写作:掌握口头记录转书面成文的完整路径

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/17 1:32:59

C++数据类型底层逻辑:内存视角深度解析类型系统与转换陷阱

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/17 1:32:48

双电池被动均衡的工程实现:MP2642GR-Z与PIC32MZ协同设计要点

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华