☰
Unity寻路实战:弃用NavMesh转投A* Pathfinding Project Pro
2026/9/29 21:44:20 网站建设 项目流程

先说我为什么放着Unity原生的NavMesh不用,非要换成A* Pathfinding Project Pro这套寻路插件:之前做多人在线对战项目的AI原型,地图里同时要放上百个AI单位各自找路,原生NavMesh在初始阶段倒是不难用,但等状态一复杂起来——运行时开门关门、临时封锁区域、一堆单位挤同一道门——调起来就很头疼。后来把核心寻路换成A* Pathfinding Project Pro,路径计算和移动逻辑变得清楚得多。这篇博文会把从选型、搭图、接移动、动态更新到性能调优的完整过程写下来,多数内容在Grid Graph场景下通用,希望能给正在做AI寻路却不知道从哪下手的Unity开发者一点参考。

1. 为什么选A* Pathfinding Project,而不是Unity自带NavMesh

1.1 NavMesh真正让人难受的地方

Unity原生NavMesh在初学者阶段确实够用:烘焙一下关卡,给角色挂个NavMeshAgent,设置目标点就能跑。可一旦需求超过"从A点走到B点",它的短板就暴露得很明显。

第一个痛点是运行时动态修改。比如MOBA里的防御塔被打掉之后,小兵原本绕路的路径应该变成直线;或者一扇铁门被玩家打开后,AI应该从门口穿过而不是继续贴墙绕。用原生NavMesh做这种事,最常见的方案是NavMeshObstacle的Carve模式,但大量动态物体同时雕刻时,网格的一致性和重建时机都很难控。更麻烦的是,如果想给某个区域加临时的通行Cost(比如路过沼泽减速),原生组件做起来非常别扭。

第二个痛点是路径过程的可控性。原生NavMeshAgent把路径计算和移动渲染都打包进了一个组件,看起来方便,可一旦需要自定义寻路逻辑——比如让某类AI只在固定路线上巡逻、让不同阵营的单位对同一片区域有不同的通行偏好、或者在某个点强行插入一段"必须绕过去再回来"的逻辑——你就不得不在外部做各种补丁。

第三个痛点是性能视角不透明。NavMeshAgent的寻路计算到底花了多少毫秒、当前有多少个agent在同时请求路径、哪些计算是可以合并的,这些信息在原生体系里很难直观看到。项目一旦进入优化阶段,这种黑盒状态很致命。

1.2 A* Pathfinding Project Pro哪些地方值得信任

A* Pathfinding Project让我愿意换方案的核心原因,不只是它实现了A*算法,而是它把"寻路"这件事拆成了几层:Graph负责描述地图、Path负责计算路径、移动组件负责执行,三者的边界很清楚。

我用的Pro版本里,它的Recast Graph可以直接基于场景里已有的几何体实时生成导航网格,这对于那种需要快速搭原型的关卡非常合适。Grid Graph则更适合规则化的地图,尤其是RTS和MOBA这种网格感很强的场景,节点分布整齐,局部更新方便。它还提供了RVO局部避障组件,专门解决多个单位挤在一起时的避让问题。

实际项目里我最依赖的两个特性,一个是GraphUpdateObject,可以在运行时有针对性地更新一小块区域,而不是把全图重新扫描一遍;另一个是多线程寻路,可以让大量AI单位同时发起路径请求时,计算被分散到后台线程,主线程不会因为寻路集中请求而掉帧。

这套分层设计的好处是,出问题的时候你能很快判断是哪一环出了问题:路径没更新,多半是Graph层面;路径计算了但单位不走,多半是移动层面;路径走得怪,通常要检查移动参数和避障配置。这种可排查性,原生NavMesh给不了。

2. 一小时内搭出第一个可运行的Grid Graph场景

2.1 场景布置与关键Layer设置

拿到插件后,第一步不是在角色身上挂组件,而是先在场景里创建一个空物体,取名叫Pathfinder,然后挂上AstarPath组件。这个AstarPath就是整个寻路系统的总管理器,它负责管理所有Graph、处理路径请求、执行扫描。

AstarPath组件挂上以后,需要在Graphs列表里添加一个Graph。如果关卡是平面地图、建筑分布比较规整,直接选Grid Graph;如果关卡地形起伏很大,带山坡、桥洞、多层结构,用Recast Graph会更合理。这篇主要以Grid Graph为例,因为它最容易理解也最好调。

添加Grid Graph之后,会看到一系列参数,什么Width、Depth、Node Size、Center。这些先别急着改,我第一次用的时候踩过一个很典型的坑:直接扫场景,结果AI整体判定地面全是"不可通行"——因为当时地面本身挂着Collider,而Grid Graph默认碰撞测试会把所有三角面都考虑进去,地面被当成了障碍物。

正确的做法是,在Project Settings的Physics层管理里单独建立一个Obstacle层,把需要绕开的物体——围墙、柱子、树干、建筑——都放到这个层上,然后在Grid Graph的碰撞检测里,让Collision Mask只管Obstacle层。这样扫描时,地面完全不会干扰判断,路径自然就干净了。

2.2 Grid Graph参数背后那笔计算账

Grid Graph的工作方式,可以理解成把地图切成了一个个均匀的小方格,每个方格就是一个节点。节点之间通过相邻关系连接,寻路算法在这些节点上跑。所以Node Size是重中之重,它直接决定了路径的精细度和成本。

举个直观的例子:一张100米乘100米的地图,如果Node Size设成1米,那么横向100个节点、纵向100个节点,总计10000个节点;如果把Node Size改成0.5米,节点数就变成200乘200,也就是40000个节点。节点越多,路径越精细,能避开更小的障碍,但扫描时间和寻路计算量也会跟着涨。

我的建议是,先看角色碰撞体的直径,再决定Node Size。一个直径约1米的角色,Node Size选0.5或0.6通常合适,既能保证路径识别障碍物的灵敏度,也不至于让节点数量爆炸。如果角色是坦克那种大体积单位,甚至可以选1米。千万不要为了追求"精细"就把Node Size往0.1调,一个大地图直接多出几十万节点,后面怎么优化都救不回来。

除了Node Size,还有一个容易被忽略的参数是Aspect Ratio或者叫Node Stretch,它控制横向节点和纵向节点的比例。如果你做的地图本身是不规则的矩形,可以调整这个值来适应地图比例,否则会出现一大块没用的空区域也被扫描成可通行节点,白白浪费内存和计算。

2.3 扫描后怎么验证路径不是自欺欺人

配置完成以后,点一下Scan按钮。如果设置正确,Scene视图里会出现一片绿色的节点区域,这就是AI可以行走的地方。有障碍物的地方,节点会被直接标成不可通行的红色或者干脆缺失。

验证路径是否正常,最简单的办法是直接在场景里拖一个目标点,然后观察绿色路径线。路径线会从起点一路连到目标点,如果绕开了所有障碍物,说明Graph扫描基本合格。如果发现路径线穿墙,先不要急着怪AI,回头检查两件事:第一,墙是不是真的在Obstacle层上;第二,Graph有没有在墙体移动或删除之后重新扫描。

还有一个小习惯是我后来才养成的:每次在编辑器里改完地图,比如挪了墙、新增了路障,记得手动重新扫描一次Graph。这个插件不会像某些工具一样在编辑器里自动同步场景改动,如果你觉得"地图明明改了,AI却还在走老路线",多半就是忘了重新Scan。

3. 给AI装上"大脑":Seeker、Path和移动组件的配合

3.1 Seeker是寻路入口,AIPath是移动执行器

Graph搭建好之后,接下来要让一个具体角色能走路。需要给这个角色挂两个组件,一个叫Seeker,一个叫AIPath或者RichAI。

Seeker的职责是跟Pathfinder总管理器打交道:当角色需要从当前点走到目标点时,Seeker会负责发起路径请求,并在路径计算完成后接收结果。AIPath则负责把计算出来的路径变成实际移动。它们之间的界限很清楚,Seeker管"怎么走",AIPath管"走起来"。

AIPath和RichAI怎么选,我的经验是:如果角色在地面上移动,转向不需要太曲率,用AIPath就够了,它的参数简单直接,代码也更轻;如果做的是比较复杂的载具类单位,需要更平滑的转向和更贴近路径曲率的移动方式,RichAI更合适。大部分角色的AI,AIPath是足够用的。

AIPath上有几个关键参数值得花时间调。Speed是移动速度,Pick Next Waypoint Dist决定了角色离路径点多远就算"到达了下一个点",End Reached Distance则是整体到终点的最小判定距离。这几个参数如果设得太小,角色会在路径点上反复横跳,看起来像在抖动;设得太大,又会导致角色提前转弯或者目标未到就停下来。

3.2 手写路径请求的参数与回调处理

虽然AIPath会自动处理路径请求,但实际项目里我们经常需要手动请求路径,比如在战斗中把AI切换到一个新目标时,想在最开始强制刷新一次路径,不等到repathRate自然触发。这时候就要用Seeker.StartPath。

下面是最基础的代码结构:

using Pathfinding; using UnityEngine; public class ManualPathRequest : MonoBehaviour { public Seeker seeker; public Transform target; void Start() { seeker.StartPath(transform.position, target.position, OnPathComplete); } void OnPathComplete(Path p) { if (p.error) { Debug.Log("路径计算失败,目标点可能不可达"); return; } // vectorPath就是从起点到目标点的一串世界坐标 Vector3[] waypoints = p.vectorPath.ToArray(); // 把waypoints交给移动逻辑去逐点执行 GetComponent<YourMover>().SetWaypoints(waypoints); } }

这里有几个细节需要特别注意。第一,回调里返回的Path对象是一个会被插件复用的对象,也就是说,OnPathComplete跑完之后,如果你把Path引用存下来,下一次寻路时这个对象可能已经被覆盖了。正确的做法是只把需要的Vector3路径点拷贝出来,不要长期持有Path对象本身。

第二,p.vectorPath的顺序是从起点到终点,不要手滑从数组尾部开始取,很多初学者会把目标点当成第一个路径点,导致角色全程倒退走路。

第三,p.error是个布尔值,它只在路径真的计算失败时才是true。如果你想让AI在目标点不可达时原地停下,需要在回调里做明确的失败分支处理,而不能默认路径一定成功。

3.3 Repath频率的控制艺术

AIPath组件内部有一个Repath Rate参数,它控制着AI多长时间重新计算一次路径。这个参数如果设成每秒一次,角色会每秒都向Pathfinder请求一次新路径;如果设成0.2秒一次,那请求就会非常频繁。

很多新人一上来就把Repath Rate拉到很小,觉得这样AI反应更快。但寻路请求是有成本的,即便有路径缓存,每次计算都要重新遍历节点图。在一个有几百个AI的战场上,如果所有单位的Repath Rate都小于0.1秒,那光寻路计算就能把主线程吃掉一大部分。

我的建议是,根据角色状态动态控制Repath Rate。普通移动状态不要小于0.3秒,战斗中追击也不建议小于0.1秒。如果单位只是在一条直线上移动,目标点没有变,压根不需要频繁重新寻路,甚至可以手动把Repath Rate调到1秒以上,只在目标点变化时调用StartPath。

4. 动态世界里的路径维护:GraphUpdateObject与RVO避障

4.1 区域级更新才是运行时修改地形的正确姿势

真正让A* Pathfinding Project Pro值回票价的功能,是运行时动态更新地图。比如一扇门被打开了、一座桥被炸断了、一块区域被结界封锁了,这些都需要让AI的路径认知同步变化。

如果这时候你去调用全图扫描Scan,那相当于把整张地图重新算一遍,慢且没必要。正确做法是用GraphUpdateObject,只更新一个指定的包围盒区域。

using Pathfinding; using UnityEngine; public class DynamicObstacle : MonoBehaviour { public Vector3 updateSize = new Vector3(2f, 2f, 2f); public void BlockArea(Vector3 center) { Bounds bounds = new Bounds(center, updateSize); GraphUpdateObject guo = new GraphUpdateObject(bounds); guo.Walkable = false; AstarPath.active.UpdateGraphs(guo); } public void UnblockArea(Vector3 center) { Bounds bounds = new Bounds(center, updateSize); GraphUpdateObject guo = new GraphUpdateObject(bounds); guo.Walkable = true; AstarPath.active.UpdateGraphs(guo); } }

这段代码的意思很直接:以center为中心,把一块2米乘2米乘2米的区域标记成不可通行,对应障碍物生成;再调用一次,把同一块区域标记回可通行,对应障碍物拆除。

要提醒的是,如果有多个障碍物同时变化,最好把它们的bounds合并成一个更大的AABB一次性更新,而不是在循环里一个一个调用UpdateGraphs。原因是多次小范围更新比一次大范围更新的总开销可能更高,并且会让图状态在一个帧内频繁切换,容易出现路径计算过程中图变了的情况。

4.2 NavmeshCut适合做什么

如果你的项目用的是Recast Graph,还要知道一个组件叫NavmeshCut。它可以在运行时实时切割导航网格,效果非常直观:把组件挂在会动的遮挡物上——比如升降的闸门、移动的机关墙——它就会自动把自身位置占据的导航网格部分切掉。

NavmeshCut的适用场景是"障碍物本身会动",比如一堵不断上下移动的墙,如果用GraphUpdateObject,你得每帧计算新位置再提交更新,不仅代码麻烦,性能也浪费。而NavmeshCut是挂上组件,让它自动跟随物体位置去平滑地改变切割区域。

不过要注意,NavmeshCut只对NavMesh类的Graph有效,Grid Graph是不认的。如果你在用Grid Graph做规则化地图,动态障碍就老老实实用GraphUpdateObject,别在这上面纠结。

4.3 RVO局部避障解决的是"挤在一起"的问题

战场里经常出现一幅画面:几百个AI同时往一个方向走,结果在狭窄路口扎堆,互相卡住。这个问题的根源不是寻路,而是局部避让策略。

A* Pathfinding Project Pro提供的RVO组件就是用来做局部避让的。具体来说,它在已有全局路径的基础上,在移动层面对周围单位进行速度规划,让每个单位尽量避开其他人,但又不偏离全局路径太远。

使用RVO需要两步:场景里挂一个RVOSimulator作为避障管理器,每个需要避障的单位身上挂RVOController。RVOController上几个参数要重点关注:Radius代表单位的真实碰撞半径,Max Neighbours代表每个单位同时考虑多少个邻近单位,Layer Mask则决定哪些图层会被纳入避障计算。

我调RVO参数走过弯路,最明显的问题是单位会在窄通道里左右摇摆。原因是Radius设得太大,或者Max Neighbours太高,导致每个单位都要不停闪避周围十几个人。真正合理的做法是,Radius贴近实际碰撞体尺寸,Max Neighbours控制在8到12之间,Layer Mask只包含单位之间互相避让的层,不要包含场景障碍物。

还有一个重要认知:RVO只负责速度避让,不能替代Collider。寻路层保证AI能避开墙,避障层保证AI能避开其他AI,但最终的物理碰撞约束仍然要靠角色身上的Collider收口。把Collider拿掉只靠RVO,单位还是会互相穿透。

5. 支撑几百个AI不卡顿的调优思路

5.1 全图Scan是性能黑洞,能躲就躲

很多人习惯在每次地图变化时直接调Scan()重新扫描整个Graph,这在Demo阶段没什么感觉,但地图一大,问题就来了。

举个例子,一张150米乘150米的地图,Node Size设0.4米,节点数大概是375乘375,也就是将近140000个节点。全图扫描这种节点量级的Graph,在编辑器里可能都要花几十毫秒,在移动设备上更是灾难。如果这个扫描发生在战斗进行中,玩家会明显感觉到画面卡顿。

所以我的第一个优化原则是:如果你不确定某次更新需要覆盖多大范围,宁可用GraphUpdateObject把范围圈小一点,也不要随手全图Scan。全图Scan只在两种情况下使用:游戏开场前的预扫描,或者是地图整体结构发生了翻天覆地的变化。其余时候,局部更新都是更优解。

5.2 多线程让同时算路径的压力分散掉

大规模AI场景里,真正的压力来源不是移动,而是大量单位同时发起的路径请求。如果每个单位都在同一帧里要求Pathfinder计算新路径,且只有一个线程在算,那这个线程会瞬间成为瓶颈。

Pro版本的多线程寻路,就是把这些路径计算任务分散到后台线程去跑。主线程只需要提交"我要从这到那"的请求,后台线程算完之后再通过回调把结果传回主线程。这样即使有几百个AI同时在请求路径,主线程也不会被寻路计算塞满。

判断你的项目是不是需要多线程,有一个简单方法:屏蔽所有AI的移动组件,只保留寻路请求,看看帧率下降多少。如果帧率还在掉,说明寻路计算本身已经成了瓶颈,这时候去调移动参数意义不大,应该考虑怎么让寻路计算分担出去,或者降低路径请求频率。

5.3 路径请求频率和路径池的边界在哪里

在A* Pathfinding Project里,路径对象是会被池化复用的。也就是说,系统会尽量复用之前用过的Path对象,而不是每次重新new一个。这对性能是好事,但给开发带来了一个隐蔽的问题:如果你把Path对象存下来供后续使用,之后它可能会被系统回收复用,里面的数据早就变了。

所以一个铁律是:回调结束之后,只保留从Path对象里拷贝出来的Vector3路径点,不要存Path本身。这不仅是好习惯,也是避免很多诡异Bug的必要手段。

另一个优化点是少发无效请求。如果目标点没有变化,路径计算就不该反复触发。很多卡顿其实是因为AI每帧都在请求同样的路径,而Pathfinder不得不计算一个它已经计算过的结果。在代码里加一层判断,检测一下目标点跟上次相比是否偏离了某个阈值,没变就不重新寻路,这个简单的操作往往能砍掉一多半的路径请求。

5.4 路径失败之后的兜底逻辑

无论Graph怎么配置,总会遇到目标点不可达的情况,比如目标点被包围在墙里,或者因为地形动态变化导致原本可达的点变成了死角。这时候AI如果什么都不做,会傻站着。

我之前在RTS项目里处理这种问题,用的是AstarPath.active.GetNearest。它的作用是:给定一个任意世界坐标,找到离它最近的可通行节点。这样当目标点不可达时,就把这个最近的可通行点当作新的目标点,重新发起一次路径请求。

NNInfo nearest = AstarPath.active.GetNearest(targetPosition, NNConstraint.Default); ai.destination = nearest.position; seeker.StartPath(ai.position, ai.destination, OnPathComplete);

这套兜底逻辑非常实用,尤其是玩家右键点了一块岩石高地中间的空地时,单位不会站在原地发呆,而是自动走到距离那个点最近的可通行位置上。需要注意的是,GetNearest返回的节点有可能依然失败,所以兜底代码里还要保留对p.error的判断,宁可让AI停在原地,也不要让它朝错误方向乱走。

6. 实测里最常见的5个坑和排查链路

6.1 AI在空地上原地打转,看着像没路径

这个问题的典型特征是:AI明明站在一大片空地中央,目标点也在空地上,但角色就是不朝目标走,最多在原地转圈。

按照寻路的层级去排查。第一步查Graph:打开Scene视图的Graph显示,确认绿色节点覆盖到了角色和目标点。如果角色脚下的节点是红色或缺失,那问题大概率是碰撞Mask设置不对,地面被当成了障碍物。第二步查Seeker的StartPath调用:有没有真的往目标点发起请求,或者AtStart时目标点是不是被设置成了一个无效坐标。第三步查移动层:AIPath的Pick Next Waypoint Dist是不是太大了,导致角色认为自己已经到达了某个中间路径点,于是反复在寻路下一段。

大部分情况下,第一步就能解决问题,因为配置错Layer比组件挂错更常见。

6.2 动态障碍物出现了,AI却仍然穿墙

如果你在游戏运行中创建了一个障碍物,并且已经在代码里给它挂上了Collider,可是AI照样穿墙走,这时候先别怀疑寻路算法,Graph根本不知道这个墙存在。

Grid Graph的节点是在扫描时生成的,运行时新增的Collider不会自动影响已经扫描好的节点。你需要手动处理动态障碍,要么用GraphUpdateObject标记它为不可通行,要么使用支持动态切割的Recast Graph加NavmeshCut。只是简单地在场景里多一个带Collider的物体,是不会改变导航图的。

另外还要检查Gate的类型和代码:如果你的障碍物是子物体,确保你在更新时用了障碍物在世界空间的位置和实际Bounds,而不是用了父物体的位置,否则更新区域偏移了,墙自然还是"透明"的。

6.3 单位在窄路口挤成一团,左右摇摆

这基本是RVO参数没调好的症状,而不是寻路问题。排查优先级从前到后是:Radius是不是比实际碰撞体大了一截?Max Neighbours是不是太高导致每个单位都在躲所有单位?Layer Mask里是不是混入了场景静态障碍物?

把Radius压到真实碰撞尺寸、Max Neighbours收到10左右,最后把Layer Mask中的静态障碍层去掉,绝大多数摇摆问题能消失。如果还晃,就检查Global内的预期目标速度是不是跟移动组件的Speed冲突了,两者不一致会让RVO计算出来的速度方向反复横跳。

6.4 上坡或上下楼梯时角色前后抖动

Grid Graph处理高度变化的能力本身比较弱,它是二维格子的概念,不同高度的地块默认是连通的。这意味着如果地图上有斜坡或者台阶,AI很容易在上下坡过程中把路径点生成在靠近坡坎的位置,导致移动时不停上下振动。

解决思路有两个方向。一是把Node Size调小一些,让路径点对高度变化更敏感,尽量绕开陡峭的边线。二是给移动逻辑加一层Smoothing,让路径点的转向和竖直方向变化不要太突兀。如果你本身就对地形起伏很敏感,建议直接换Recast Graph来处理这类关卡,不要在Grid Graph上投入太多调参时间。

6.5 编辑器里改了地图,AI却还在走老路线

这个坑我踩过不止一次:在Scene视图里挪了一堵墙,然后直接进Play模式测试,结果AI依然按照旧墙的位置绕路,看起来特别傻。

原因前面已经提过——这个插件在编辑器里不会自动跟着场景改而实时更新Graph。你在编辑状态下调整了地形或者障碍物之后,必须重新执行一次Scan,让新的障碍信息烘焙进Graph节点里,运行时的寻路才会反映地图的真实变化。养成这个习惯之后,这类"明明改了地图却不生效"的疑问会少很多。

另外,如果是在Play模式里临时移动了障碍物,跑完测试回到编辑器后,Graph可能停留在运行状态改过的样子。这种情况下,再点一次Scan,把Graph重置回编辑器状态,能避免很多奇怪的后遗症。

如果你在集成时也遇到看着像"穿墙"实际是Graph和物理体没同步的问题,优先从Graph更新这一环排查,先不要急着去调移动参数和转向参数。寻路插件的价值在于把路径计算的复杂度摊开在面前,出了问题逐层定位,反而比原生方案更好下手。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询