ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Unity RTS单位移动实战:基于NavMesh的寻路与群体避障方案

Unity RTS单位移动实战:基于NavMesh的寻路与群体避障方案

想做类似红警那种RTS单位移动的Unity开发者,通常第一反应是打开搜索引擎找“A*寻路算法”“Flow Field”,再一头扎进各种第三方插件的评测里。我在手边这个项目中把它彻底做完一遍后,最大的感触是:如果你的目标是先跑通“框选一队坦克,右键一点,整队绕开障碍物开到目标点”这个经典手感,Unity官方那个NavMesh方案就是效率最高的起点。它内置了导航网格烘焙、路径查询和局部避障,大部分底层逻辑已经替你写好了。

这篇内容就从官方案例和官方组件的角度出发,把类红警移动从场景准备到单位寻路、群体避障的完整链路拆开讲。适合不想从零手写寻路算法、想尽快在项目里获得可落地效果的Unity开发者,不管是PC端还是移动端,这套思路都通用。

1. 动手前先拆一拆:红警式移动到底在移动什么

1.1 移动手感的三层结构

很多人照着教程把NavMeshAgent拖到单位身上,设置好目的地,跑起来发现“能走,但根本不是红警那种感觉”。原因在于,“红警式移动”不只是一个单纯的状态机切换,它其实由三层东西共同构成。

第一层是寻路。红警的单位被命令移动时,会绕开建筑、残骸、水域等障碍物,而不是傻乎乎地直直撞上去。这背后是导航网格或寻路算法在实时计算路径,这也是NavMeshAgent最擅长的部分。

第二层是群体行为。框选几十个坦克,点击地图某个点,红警的做法并不是让所有坦克直接冲向同一个坐标,而是每个单位独立计算一条路径,在行进过程中互相让路、错开位置,最终停成一个不太规则的扇形或矩形阵型。这个细节直接影响手感,单纯把所有单位SetDestination到同一个点,你会发现它们全都叠在一起,这绝对不是RTS该有的观感。

第三层是操作反馈。点选目标后单位立刻转向、起步、加减速,镜头跟随,指示圈在目标点出现。这套即时反馈虽然不完全由移动逻辑实现,但移动逻辑必须做到不拖沓、不犹豫。

1.2 方案选型:为什么直接吃官方NavMesh这套

在做技术选型时,我当时列过一张表,对比了手写A*、第三方插件和官方NavMesh。结论其实很清晰,如果你的项目不是千人同屏的大型战略游戏,官方NavMesh完全够用,而且省掉大量时间成本。

方案优势劣势适合场景
自研A*寻路完全可控、可匹配特殊业务逻辑需要自己处理网格体素化、平滑路径、局部避障,调优成本很高有专职算法团队、玩法强依赖寻路细节
自研A* + 流场大规模群体移动性能优化潜力大实现复杂度高,移动端内存压力大千人以上同屏的战争游戏
官方NavMesh + NavMeshAgent开箱即用、烘焙可视化、自带避障动态地形支持弱、极端大规模群体需要额外优化中小规模RTS类游戏、类红警移动、大部分实时战斗游戏

关键点在于,红警这类游戏的核心乐趣是“指挥少量但多编队的单位”,不是“控制一千只蚂蚁每个都精准避开其他人”。官方NavMesh的寻路性能和表现力在这个量级下完全够用。官方案例之所以能成为学习的标杆,也正是因为它把多单位寻路、烘焙、动态障碍物这几个最容易出问题的地方都做了常规化处理,照着它走一遍就能避开许多坑。

1.3 官方素材里最值得抠的三个细节

我不是让你直接把官方示例里的代码拖到项目里用,而是建议你做三件事:先读官方文档里关于NavMeshAgent属性那一页,搞清楚每个参数的含义;再看官方示例中场景是怎么布置Navigation Layers和烘焙尺寸的;最后仔细研究示例脚本里处理“单位卡住”和“目标不是有效位置”时的优雅降级方式。

其中第三件事最容易被忽略。红警式的移动要求单位在目标位置被占据、目标点在NavMesh外面、路径被动态障碍物切断时,仍然以合理的方式表现。官方示例通常会用NavMesh.SamplePosition对目标点做一次有效性校正,把“玩家点的那个点”修正到“离这个点最近的可行走位置”。这个单行代码,就是你的单位不会把目标点停在悬崖外或障碍物体内的关键。

2. 把地图变成可走的“路”:导航网格烘焙

2.1 静态场景和基础参数设置

导航网格不是自动生成的,它依赖场景里物体的几何数据。在旧版本的Unity里,可以通过Window > AI > Navigation面板操作;Unity 2022及之后更推荐先安装AI Navigation包,在场景里挂NavMeshSurface组件来实际操作,同样的功能,工作流更清晰一些。

我建议你直接走AI Navigation这条新方案,因为官方未来重点维护的是这个包。安装方式是通过Package Manager搜索AI Navigation并导入,然后在场景中创建一个空物体,挂上NavMeshSurface组件。

接下来把所有静态地形、建筑、墙体等物体的层级中加上Navigation Static标记。这一步等同于告诉Unity“烘焙时请考虑这些物体”。地面本身也做同样的标记,但你要确保它是一个封闭的Mesh,而不是残缺的平面。很多开发者第一次烘焙出来单位全往地下钻,就是地形对象没有正确标记或烘焙区域没有覆盖完整。

2.2 Agent Radius背后的通道逻辑

点击NavMeshSurface的Bake按钮后,Unity会按照Agent相关参数生成网格。这里有几个参数我需要展开讲,因为它们直接决定了红警式移动的体验。

Agent Radius是最容易被低估的参数。它代表单位占用的横向半径。红警里的步兵和坦克需要的通道宽度完全不同。如果你做的是坦克单位,Agent Radius设0.5或更大,烘焙时小于该半径的通路就会被忽略,单位不会试图挤进一条坦克不可能通过的缝隙。如果你的游戏里同时有步兵和坦克两种单位,最合理的做法不是用同一个NavMesh走天下,而是分别用不同Agent Radius烘焙多个NavMeshData,给不同单位分配对应的NavMeshAgent。这在官方文档里叫“多个代理半径”,是类红警游戏的标准姿势。

Agent Height决定单位能否从低矮障碍物下方通过。Max Slope则是最大爬坡角度,45度是常规值,但红警里的单位往往更“乖”,建议设30度左右,避免坦克爬上非常陡的斜坡。Step Height用于处理台阶和路沿,一个0.5的台阶高度意味着单位能自动“走上”一级台阶而不需要真正跳跃。

烘焙完成后,进入Scene视图的NavMesh显示模式,你会看到蓝色的可行走区域。检查这三件事:建筑周围是否留有合理的通道宽度;单位不能走进去的区域是否覆盖正确;地形边缘是否出现不合理的尖角或空洞。如果空洞太多,把Agent Radius稍微调小再重新烘焙即可。

2.3 动态障碍与跳跃连接

红警地图里最常见的动态障碍就是建筑被摧毁后的残骸。你没法把残骸做成静态物体,所以需要在可被摧毁的物体上挂NavMeshObstacle组件。这个组件有两种模式:默认模式是位移模式,适合移动的障碍物;勾选Carve模式后,障碍物会实时在NavMesh上“挖洞”,把阻挡区域从可行走区域中扣除。

这里有个实际经验:Carve模式是运行时计算,开销不小,不要给几十个障碍物全开Carve。更好的做法是把“会影响寻路的动态物体”数量控制在一个合理范围,通常不超过20个,否则每帧的网格更新会让你明显感觉到卡顿。如果红警式的战斗场景里爆炸产生的残骸很多,我的做法是延迟一段时间后再启用Carve,短时间内让它以普通碰撞体的方式存在。

跨越断桥或沟壑的场景,要使用NavMeshLink。NavMeshLink就是在一个网络节点和另一个节点之间搭一条虚线的“桥”,你可以设置终点、方向、宽度。红警里不会飞的角色过桥、过传送带,基本都靠这个缝合寻路断点。使用时注意将Link的方向设置成双向,否则单位只能单向通行。

3. 点击地面,单位就动:核心移动逻辑

3.1 右键拾取世界坐标的两种常见写法

在Unity里获取鼠标点击的世界坐标,标准做法是Camera.main.ScreenPointToRay加Physics.Raycast。官方示例通常直接射线到地面,然后取hit.point作为目标点。

问题在于,地面如果是一个大Plane,而场景里还有桌椅、箱子等障碍物,射线可能打在这些障碍物上。你当然可以把地面单独放在一个Layer里,然后用LayerMask过滤射线检测,这是RTS项目最常见的做法。我的建议是,干脆把所有可移动单位也放在Unit层,地面放在Ground层,射线检测时只检测Ground层,后续做单位选中时再检测Unit层,互不干扰。

第二种情况是玩家点击的位置偏离了NavMesh,比如点到了地图边界外的景观区域。这时hit.point虽然在世界坐标上是某个地面位置,但它并不在导航网格上。直接把该点传给SetDestination,Agent会停在原地,或者表现出寻找最近可达点的“抽搐”行为。正确的做法是用NavMesh.SamplePosition做一次矫正,把目标点吸附到NavMesh上。这是最值得抄的一份代码:

if (NavMesh.SamplePosition(hit.point, out NavMeshHit navHit, 2f, NavMesh.AllAreas)) { unitAgent.SetDestination(navHit.position); }

第二个参数是矫正的原始命中点,第三个参数是最大吸附距离,2米通常合适。如果你期望的单位是坦克这种重装甲,可以放宽到3米,步兵则建议1.5到2米。

3.2 NavMeshAgent的调参才是手感发动机

把一个NavMeshAgent组件加到单位预设体上后,真正跟手感相关的属性主要有这五个。

Speed就是移动速度,红警里不同单位速度差异很大,坦克慢、侦察车快,你可以按单位类型配置。Angular Speed是单位旋转的角速度,步兵可以快一些,坦克适当降一点,不然坦克转身就像陀螺。Acceleration是加速度,这个很多人不调,但它决定单位从静止到全速的过渡时长。过大则单位起步非常“贼”,过小则看起来很迟钝。我一般步兵给40,坦克给20。Stopping Distance是单位距离目标点多远时停下,红警式移动中这个值不是单纯的一个点,它是一个小圆的半径,后续做编队阵型时会用到。

最后是Update Rotation。默认开启,Agent会让单位朝向路径方向。想要红警那种底盘先转向再前进的坦克效果,需要关闭它,自己用旋转插值处理。这块我放在后面专门说。

实例化单位后,要把目标点通过SetDestination传给它。只有这一个入口,Unity内部会完成路径生成和避障计算。千万不要自己每帧去改单位位置,那等于绕过了整个NavMesh系统。

3.3 转向、停靠与移动动画的衔接

红警的单位移动有一个明显特征:转向动作是独立于位移的。如果你只是用默认的UpdateRotation = true,单位会一边旋转一边前进,手感上虽然能接受,但做步兵这种小单位还行,做大型载具就“飘”了。

关闭UpdateRotation后,需要自己在移动过程中计算转向目标方向,并快速旋转。我常用这种写法:

if (agent.velocity.sqrMagnitude > 0.1f) { Quaternion targetRotation = Quaternion.LookRotation(agent.velocity.normalized); transform.rotation = Quaternion.RotateTowards(transform.rotation, targetRotation, 720f * Time.deltaTime); }

720度每秒意味着单位能在0.25秒内完成180度掉头,接近红警里装甲车辆快速调头的感觉。如果你想让坦克更“重”,可以降到360度甚至240度每秒,让玩家明显感知到转向延迟。注意当速度接近零时,不要强行计算朝向,否则单位会在原地疯狂转圈。

停靠逻辑方面,红警单位到达目的地后通常不会再移动。这里要让StoppingDistance大于0,并且在一段延时后把agent.velocity清零,或者直接用agent.ResetPath()切断路径。否则单位会在目标点附近反复寻找更优路径,看起来像在碎步抖动,这个细节很影响观感。

3.4 框选一队兵一起移动

RTS的框选通常涉及鼠标拖拽生成屏幕矩形,然后挑选投影在矩形内的单位。做选中不是这里的重点,重点是把“选中结果”和“移动指令”连起来。

选中单位后,右键点击地面事件发生,最朴素的代码如下:

public void CommandMoveTo(Vector3 destination) { foreach (var unit in selectedUnits) { NavMeshAgent agent = unit.GetComponent<NavMeshAgent>(); agent.SetDestination(destination); } }

这样确实能让一整队单位全部开始移动,但效果是全部挤到同一个点,非常难看。更接近红警的常见做法是:不直接使用相同的目标点,而是给每个单位在目标点周围分配一个偏移位置。最浅层的实现是保存单位的原始队形偏移量,移动时将目标点加上偏移量后再传给Agent。具体实现思路后面会展开,这里先记住一个原则:每个单位的目标点应该是“目标区域内的一个差异化坐标”,而不是全局坐标本身。

4. 一群人一起走:避障与编队行进

4.1 NavMeshAgent的“你让让我、我让让你”

单位的群体移动,Unity内部用的是局部避障算法,它在每一帧根据周围Agent的位置和速度计算出一个不会碰撞的临时移动方向。你不需要写任何额外代码,前提是确保所有单位都挂在同一个NavMesh上,并且Radius属性设置合理。

NavMeshAgent上的Radius属性是运行时避障半径。注意,它和烘焙时的Agent Radius是两个概念。烘焙半径决定路径能不能通过缝隙,运行时Radius决定单位之间身体的间距。比如步兵的运行时Radius设0.4,坦克设1.2,站在一起时坦克会推开步兵而不是强行穿插。

Priority是避障优先级,值越小优先级越高。红警里高级单位往往更容易推开低级单位。想要哪种单位穿行时拥有更高的通行权,就把它的Priority设成0或1。不过要小心,相同优先级的Agent相互避障时表现最自然,不会出现某一边频繁停止等待的情况。

有一个容易被忽略的问题:Agent的避障计算只对“正在使用Agent移动部件移动”的单位生效。如果你关闭了Agent的updateRotation但不关闭updatePosition,那没问题。可是如果你图方便直接操作transform.position,避障就会彻底失效。实战中遇到单位穿模,先检查是否有一处代码直接改位置。

4.2 集结点与停止距离:不让一队兵叠成一个人

要让一队兵像红警那样队伍散开、队形保持、最终铺开,最实用的技巧是“队形偏移量 + 停止距离”。

在单位被框选时,记录每个单位相对于队伍中心的偏移量。当玩家右键点击目的地时,以目标点为中心,加上各自的偏移量,得到每个单位独特的目标点。这就是红警式编队移动的基础,不需要任何高级算法。

Vector3 formationOffset = unit.localFormationOffset; Vector3 destination = targetPoint + formationOffset; agent.SetDestination(destination);

这里还需要配合Stopping Distance。如果想把队伍停成一个大圆圈,把StoppingDistance设置为2米,那么每个单位在接近自己目标点2米半径内就算到达,队伍自然形成一个多个单位交错的松散圈形,而不是士兵扎堆。

更进阶的做法是为纵深部队设置第二层偏移:让后排单位的目标点在更远处,而不是所有偏移量都在目标点附近随机分布。否则移动长距离后,队伍很容易被压缩成一条长龙,速度快的先到,速度慢的后到,队形散架。我的土办法是先按单位在屏幕里框选时的行列顺序分配偏移,用行距和列距控制编队纵深,实战效果非常接近红警的坦克阵型前推。

4.3 大规模单位移动的上限

把几十个单位同时SetDestination后,NavMeshAgent的局部避障会在每一帧计算每个Agent与他人的碰撞规避。如果有50个单位,它还能流畅处理;如果一下子框选500个,每个Agent都在做邻居搜索,CPU会出现肉眼可见的卡顿。

这不是说官方NavMesh方案不行,相反,红警这类游戏的角色数量通常在几十到一两百的范畴。超过这个量,优化的优先级就不是避障算法,而是单位模型、碰撞体数量以及Agent的更新频率。我常做的优化是,对距离玩家镜头较远的单位,把Agent更新频率降低到每3帧一次。具体来说就是设置一个nextUpdateTime,每隔0.1秒调用一次SetDestination。由于路径本身在变化,这种低频更新对远距离单位观感影响极小,但性能提升立竿见影。

另外一个技巧是,当单位到达目的地并停稳后,调用agent.isStopped = true减少继续计算路径的开销。等玩家再次下达移动指令时再置为false。

5. 实跑过程中的坑:问题排查与避坑心得

5.1 点击了没反应,八成不是代码问题

我在做这个功能时遇到过最经典的“点击无反应”案例,问题出在EventSystem上。如果你的鼠标射线被UI遮挡,点击事件根本不会传给相机,右键移动自然没有任何反馈。排查方式是临时把Canvas关闭,如果点击立刻恢复,说明是UI在吞射线。实际项目里通常给UI根Canvas加一个GraphicRaycaster,普通UI事件不会有问题,但全屏透明的Image会挡住一切,所以UI面板最外层的透明图不要开Raycast Target。

更隐蔽的原因是没有正确配置LayerMask。如果地面没有单独放在Ground层,而代码里只检测Ground层,点击必然没反应。检查方式是把Physics.Raycast的输出打印出来,确认射线确实命中了地面,再逐层检查Layer。

5.2 Agent在半空飘着、走上房顶、卡台阶

单位在NavMesh上“飘”着走,通常是烘焙时地形对象与地面重叠,或者烘焙生成的NavMesh边缘因Agent Radius过大造成了悬空边。解决方式是检查地面模型有没有单位量级的法线翻转,把地面单独拆出来烘焙,避免建筑等复杂模型的底面参与网格生成。

走上房顶的问题,基本是房顶可站立区域被错误地算成了可行走区域。红警里坦克不能上屋顶,但Unity默认没有任何规则知道它是“房顶”。检查方法是把房顶对象的Navigation Static取消,再重新烘焙。如果有些房顶是动态可破坏的,就用NavMeshObstacle动态挖洞。

卡台阶虽然是Bug,但表现形式很微妙:单位抵达台阶底部后原地来回挪动,像在找路又找不到。我把Agent的Step Height调大到能覆盖台阶高度就解决了。顺便说一句,在烘焙后改Step Height不会生效,必须重新Bake。

5.3 一群单位互相堵死

框选中多队单位同时移动时,最容易出现“堵车死锁”现象。两个单位迎面相遇,互相避让,却在狭窄通道里互相卡住。NavMeshAgent自带避障能处理大多数情况,但它不是万能的。我的处理办法是给“AI控制下的单位”加一个检测:如果单位在数秒内速度一直很低,就往正后方位移一小段距离,强制打破僵局。这个方法比较粗暴,但很有效。

有时堵死也和烘焙有关。通道宽度如果刚好是Agent Radius的两倍,两个单位会互相碰撞。解决办法是从模板上调整,把通道拓宽或者调小单位烘焙半径。这类问题在运行时很难完美解决,所以我建议在地图设计阶段就保证通道宽度至少是单位Width的三倍。

5.4 手动接管转向的注意点

前文提到了写代码手动控制单位旋转。这里要提醒你,关闭updateRotation后,寻路并没有失效,但这会让Agent在路径拐角处晕头转向。因为路径是由多个WayPoint组成的,Agent到达拐角前可能尚未完全转向,自动移动方向与当前朝向不一致。我的经验是开启AutoBraking并通过StoppingDistance控制单位在拐角前减速,同时把手动转向的角速度调高,让单位能快速跟随路径方向变化。如果你发现单位在路上画圈,那多半就是转向速度跟不上路径更新,把RotateTowards的角速度从300提高到600或800就好。

最后聊几句

在实跑这个方案之前,我一直纠结“手写寻路是不是更有掌控感”。做过一轮后我的结论是,非必要不手写,官方案的成熟度和稳定性远超想象,把精力省下来去做单位和地图的交互表现更值得。如果你做的是类红警的RTS移动,NavMesh + NavMeshAgent这套组合可以稳定覆盖从几台坦克到上百单位的移动需求。要注意的是,RTS移动手感不是靠单一技术决胜,避障参数、编队偏移、停止距离、转向速度这些参数都拧在一起,每次调整都建议在真实地图上多次试跑,别光看Ignore。小技巧是,把常用Agent配置保存成预设体,不同单位类型直接引用,能省下大量重复调整的时间。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进