首页 / 博客 / 人工智能
发布: 2026年1月8日 约 11 分钟 人工智能
NavMesh、A*与流场:让200个AI代理不再堵在城门口的游戏寻路性能优化实战

NavMesh、A*与流场:让200个AI代理不再堵在城门口的游戏寻路性能优化实战

在一个200个代理同时冲向城门的攻城场景里,寻路为什么会堵死,NavMesh又在哪些地方悄悄骗了你,以及A*和流场之间那条分界线到底划在哪里。本文只依据性能分析器给出的真实数据,从NavMesh几何、每帧请求预算一直讲到local avoidance参数,给出一条可复现的排查顺序。

两百个代理堵死在一道城门前

去年年底我在做一个攻城场景:一道城门,两百个NavMeshAgent朝它冲过去。游戏AI里的导航,恰恰就是在这类场景里崩掉的。QA附在每夜构建上的录像总是卡在同一个位置:离城门还有十五米,大约六十个代理互相摩擦、原地抖动,还有几个在往后退。头十秒里,真正进到城门后面院子的只有四十个左右。

代理离得远时帧时间是11 ms,靠近城门时涨到26 ms。我最初以为是A*炸了。性能分析器给出的答案不是这样:NavMesh.CalculatePath合计3.2 ms,而NavMeshAgent自身的内部更新占了9.8 ms。也就是说,瓶颈不在搜索,而在每个代理每帧都要做的那部分工作里。

两个各自独立的问题同时冒了出来,却被当成了一个:计算全局路径的开销,以及代理彼此之间的相对行为。任何把这两件事塞进同一个标题下的讨论都会僵住,因为它们的修法压根不在一个地方。不把它们拆开,你永远说不清是哪个旋钮动了哪个数字。我们这边的拆分是从一个习惯开始的:在性能分析器里分别盯住搜索那一行和代理更新那一行。

NavMesh真正看到的是什么

NavMesh是从场景的可行走表面烘焙出来的一张三角网格,并且从每一条边向内收缩了一个代理半径。代理能走的区域,和你在视口里看到的地面不是一回事。收到“代理卡住了”的报告时,第一件要做的事是在Navigation窗口里把网格显示出来,量一量门槛处实际还剩多少厘米的通道。Voxel Size保持默认的0.166 m时,我不止一次看到狭窄拐角处整块多边形凭空消失。

我们的门洞是1.6 m,Agent Radius是0.5 m,剩下的只有0.6 m的一条单道:一个代理过得去,两个过不去。把半径降到0.35看起来是最省事的改法,但它让整张地图上的代理都开始穿墙。正确的改动是在关卡这边把门洞放宽到2.4 m。代理半径不是一个外观参数,它是整套导航的基本单位。

第二个经典陷阱是目标点落在网格之外。这时SetDestination会返回false,而没有人去看这个返回值;或者路径以NavMeshPathStatus.PathPartial的形式回来,代理走到能到达的最近点就停在那里等着。从外面看,这就是“卡住了”。把每一个目标点都用NavMesh.SamplePosition在2 m半径内吸附到网格上之后,这类报告基本消失了。同样的检查也要用在出生点上;在网格之外生成的代理,从第一帧起就是废的。

楼梯、缝隙、门洞这类不连续的地方需要off-mesh link。手工摆放的OffMeshLink组件,行为要比自动生成的可预测得多。默认代价是1.0时,两百个代理里有一百四十个挤向同一条1.2 m的缝隙;把costOverride调到5.0之后,大部分改走那条更远但通畅的路线。link的两端都要落在网格上;一端悬空的link会被悄无声息地忽略掉。

A*的开销与分层寻路

A*寻路的开销随展开的节点数增长。在一张41,000面的NavMesh上,从地图一端到另一端的单次请求要0.35 ms。两百个代理在同一帧里发起请求,算下来是70 ms,但你不会看到这样一次卡顿:Unity会把请求排进队列,路径晚三四帧才送到,这段时间里代理仍然按旧方向跑。症状不是卡死,而是转向迟了。

分层寻路把这份开销削掉了两层。你先把地图切成若干区域,再把区域之间的通行关系写成一张portal图;搜索先在三四十个节点的粗图上跑(0.02 ms),然后只对下一个portal之前的那一段跑详细的A*。我们的平均请求从0.35 ms降到0.06 ms,而且再也不会一次性算完某个代理的整条路径。粗图在烘焙时构建一次,只有当动态障碍物封住某个portal时才在运行时更新。

试过又扔掉的替代方案是共享路径缓存。我们把目标点归整到4 m的格子里,给所有前往同一格的代理发同一条路径。对静止目标的命中率是70%,但在追击玩家时掉到18%,而失效判定的逻辑吃掉的比缓存省下的还多。那段代码被删了。只有目标点能连续几十帧不动的场景,路径共享才值这个价。

CrowdAgent.cs
using UnityEngine; using UnityEngine.AI; public sealed class CrowdAgent : MonoBehaviour { const int MaxRequestsPerFrame = 12; // shared by the whole crowd static int _requestsThisFrame; [SerializeField] NavMeshAgent _agent; [SerializeField] float _repathInterval = 0.45f; Vector3 _goal; float _nextRepath; void Update() { // Steering runs every frame; the A* query does not. if (Time.time < _nextRepath || _requestsThisFrame >= MaxRequestsPerFrame) return; // Snap the goal onto the mesh: an off-mesh SetDestination fails silently. if (!NavMesh.SamplePosition(_goal, out var hit, 2f, NavMesh.AllAreas)) return; _agent.SetDestination(hit.position); _requestsThisFrame++; _nextRepath = Time.time + _repathInterval + Random.value * 0.15f; // de-sync repaths } void LateUpdate() => _requestsThisFrame = 0; }
csharpCrowdAgent.cs

整支人群共用一张流场

如果两百个代理奔向同一个目标,跑两百次独立搜索毫无意义。流场的做法是从目标点反向做一次代价传播,然后往每个格子里写一个指向其最便宜邻居的方向向量。我们的是128x128的网格,格子边长0.5 m,一次完整重建要4.1 ms。传播一趟就填满所有格子,所以这份开销和代理数量完全无关。

关键在于这4.1 ms不是每帧都付,而是只在目标格子变化时才付。玩家跑动时这大概每秒发生三四次,也就是每秒十四毫秒左右。而单个代理的开销缩成两次双线性采样:0.004 ms,两百个代理合计0.8 ms。同样这群人走分层A*要花12 ms。

边界很清楚:每多一个不同的目标,就多一张场。目标超过三四个之后,内存和重建开销就会反超A*。所以我们停在了混合方案上:有名字的NPC和boss级代理走A*,人群模拟走流场。两者站在同一批NavMesh三角形上,不同的只是方向的来源。这个开关在代码里只是一个bool,策划可以直接在prefab上翻。

local avoidance不是全局导航

RVO以及由它衍生出的ORCA只做一件事:每个代理读取邻居的位置和速度,用半平面把会导致碰撞的速度切掉,再从剩下的集合里挑一个最接近自己期望速度的。它的视野只有一两秒,而且对关卡几何一无所知。这是一次朝向修正,不是导航。把一个代理单独交给它,RVO永远不会把它带到目标点。

所以指望local avoidance解决全局问题,从一开始就错了。面对一堵U形的院墙,RVO会把代理贴在墙上,而迎面相遇的两个代理会直接死锁。把obstacleAvoidanceType设成HighQuality并不能修好它,只会在两百个代理的9.8 ms里吃掉6.2 ms。降到Good之后是3.9 ms,在相机距离上看不出差别。

真正见效的是avoidancePriority这个字段。所有代理都停在默认值50时,局面始终对称,谁也不让谁。改成在生成时随机分配0到99之间的优先级,城门口的堵塞时间从4.2秒缩到1.1秒。这是一行代码的改动。

动态障碍物与path request预算

动态障碍物要用NavMeshObstacle加carving,但carving每移动一次就要重新烘焙一次tile。场景里有12个滚动的木桶时,我们看到规律出现的5.8 ms尖峰。把carvingMoveThreshold提到0.5 m并打开carveOnlyStationary之后,同一个场景降到了0.9 ms。永远不要把持续移动的东西做成障碍物,那是local avoidance该管的事。

预算这部分很简单:把每帧允许的完整路径请求数量定死。我们定的是12个。代理按0.45秒的间隔外加0到0.15秒的随机偏移来发请求,因为没有这点抖动,出生波会把所有请求砸进同一帧。预算满了以后请求不会被取消,只是顺延到下一帧,而这段时间代理还在沿着已有路径走,所以没人察觉得到。这个预算一定要在目标硬件上实测;我们在主机上用的12,到桌面端可以轻松放到30。

这个场景里,总帧时间从26 ms降到13.4 ms,寻路占的份额从13 ms降到2.1 ms,而其中最便宜的一笔收益,就是那行改优先级的代码。工作顺序建议这样排:先用眼睛核对NavMesh的几何形状,再给每帧请求加上预算,然后把人群搬到流场上,最后才去动local avoidance的参数。反着来的话,几个小时会消失在ORCA的参数里,而那道0.6米的门洞还原封不动地立在那儿。没有测量过的设置就不要改;这里的每一个数字都来自性能分析器,没有一个来自猜测。

← 全部文章