一个早晨,AI 又卡住了
老周是项目的 AI 程序员。那天早上十点,策划小林发来一段录像:地下遗迹关卡里,玩家拉开机关,石门缓缓升起,后面的巡逻守卫却站在原地打转,像撞上了一堵看不见的墙。小林在语音里说:门都开了,他怎么不过来?
老周把录像放慢三倍。守卫的期望路径确实穿过了石门,但脚下的 NavMesh 没有更新。石门在打开前是碰撞体,烘焙时被标记为不可行走;打开后碰撞移走了,可 NavMesh 数据还是旧的。守卫拿着旧地图找新路,只能在门口发呆。
这大概是每个用 Navigation Mesh 的团队都踩过的坑。UE5.8 的寻路系统依然建立在 Recast/Detour 之上,但在大世界、动态障碍、局部回避等场景下加了不少新细节。这篇文章就把我们踩过的坑和验证过的做法整理出来。
Recast 与 Detour:寻路的地基
UE 的 Navigation System 并不自己从头算三角形网格,它把脏活累活交给了 Mikko Mononen 写的 Recast 和 Detour。Recast 负责把场景碰撞体烘成 NavMesh,Detour 负责在网格上跑 A* 寻路。
flowchart TD
subgraph api ["Gameplay API"]
move["Move Request"]
query["Path Query"]
end
subgraph core ["Navigation Core"]
navsys["Navigation System"]
recast["Recast"]
detour["Detour"]
end
subgraph data ["World Data"]
navmesh["NavMesh Tiles"]
obstacle["Obstacle Manager"]
modifier["Nav Modifier"]
end
move --> navsys
query --> navsys
navsys --> recast
navsys --> detour
recast --> navmesh
detour --> navmesh
obstacle --> navsys
modifier --> navmesh玩法代码只认得 Move Request 和 Path Query。真正裁剪体素、分 Tile、跑 A* 都在下面。把两层拆开之后,策划调参数不会改到底层算法,程序改寻路也不用碰每个 AIController。
Recast 的体素烘焙
Recast 的第一步是把世界切成小方块。它在场景碰撞体周围撒一层体素,像把关卡倒进三维像素画。体素大小由 Cell Size 控制,越小越贴近碰撞轮廓,但烘焙时间和内存也越大。
flowchart TD
A["输入碰撞体"] --> B["体素化"]
B --> C["过滤可行走面"]
C --> D["区域划分"]
D --> E["轮廓追踪"]
E --> F["生成多边形"]
F --> G["细节网格"]过滤可行走面时,Recast 会看每个体素能不能站稳:坡度超过 Agent Max Slope 就剔除,悬空高度低于 Agent Height 也剔除。剩下的体素聚成区域,再追踪轮廓转成凸多边形。最后加的 Detail Mesh 把多边形表面细化,让路径沿着地面起伏走。
Detail Mesh 的背后是高度场。Recast 把每个多边形再细分成小三角形,让路径点贴着实际地面高度,不会浮在平均高度上。走斜坡和楼梯时,这个细节决定了 AI 会不会看起来在飘。
Detour 的路径查询
Detour 拿到 NavMesh 后,把凸多边形当节点跑 A*。它的启发函数是到目标点的欧氏距离,代价则综合了距离、地形类型和自定义开销。一条路径可以用下面的代价函数描述:
其中 是第 段多边形中心距离, 是地形惩罚, 是自定义开销。、、 是权重。Detour 默认只算距离,但你可以通过 Navigation Query Filter 注入自己的权重。
A* 在 NavMesh 上搜索时,节点是凸多边形,不再是单纯的坐标点。每次扩展会把相邻多边形的中心距加上启发估计。找到路径后,Detour 还会做一步弦化,把锯齿状的拐点拉成直线,让移动更自然。
NavMesh 生成与代理尺寸
烘焙 NavMesh 之前,必须先回答一个问题:这个网格是为谁烘的?不同体型的代理需要不同的可走空间。瘦小的侦察兵能钻的缝,重装坦克钻不过去。
UE 在 RecastNavMesh 代理设置里放了三项关键参数:
- Agent Radius :代理的圆柱半径。Recast 会把墙壁、障碍物往外扩 ,保证路径点离障碍至少有这么远。
- Agent Height :代理能站直的高度。低于 的空间会被标记为不可走。
- Agent Max Slope :能爬的最大坡度,超过这个角度的面直接剔除。
实际烘焙时,Recast 会做一个不可见的外扩。可行走区域和真实碰撞之间的最小距离 必须满足 ,否则代理会被卡住。这就是为什么有时玩家角色能走过去,AI 却在那抖:AI 的半径设大了,NavMesh 在这个位置留了空,但可行走多边形没覆盖到。
graph LR
A["碰撞体表面"] --> B["外扩半径 r"]
B --> C["可行走 NavMesh"]
C --> D["代理圆柱"]
E["高度不足 h 的区域"] --> F["被剔除"]还有一个容易漏的细节:Agent Max Step Height。它决定 AI 能不能把台阶当成平地走。设得太小,楼梯全被切断;设得太大,AI 会尝试从窗台翻出去。我们项目的做法是给不同体型做不同的 NavMesh Runtime Generation,比如人类、载具、飞行单位各自一张网格。
NavMesh Bounds Volume 的范围也要小心。范围太大,会把不需要的房间和室外空间一起烘进去;范围太小,AI 走到边界就找不到路。我们一般在关卡里按玩法区域拆成多个 Bounds,每个配不同的 Agent 参数。Runtime Generation 选 Dynamic 时,只在需要时生成 Tile,能省掉大量预烘焙时间。
NavLink:让 AI 学会跳跃
只在地面上走太无趣。遗迹里常有断桥、平台、窗台。NavLink 就是告诉 Recast:这两个点之间虽然没连着多边形,但 AI 可以执行一段自定义移动跳过去。
小林设计了一个双层回廊。下层守卫巡逻,玩家可以爬上二楼绕后。老周给断桥两端各放一个 NavLink Proxy,左端叫 Start,右端叫 End。NavMesh 烘焙后会在两点之间连一条虚线,Detour 寻路时把它当作普通边,只是走过这条边时要触发一段跳跃或攀爬动画。
// 来源:MyJumpLinkComponent.cpp
#include "NavLinkCustomComponent.h"
void UMyJumpLinkComponent::GetLinkData(
FVector& Left, FVector& Right, ENavLinkDirection::Type& Dir) const
{
Left = StartPoint;
Right = EndPoint;
Dir = ENavLinkDirection::BothWays;
}NavLink 还支持单向。比如二楼跳下一楼没问题,但想跳上去就得另找楼梯。把 Dir 设成 OneWay 后,Detour 只会按方向使用这条边。Custom Nav Link 还能挂回调,进入 Link 时播 Montage、加根运动、甚至改变移动模式。
Smart Link 比普通 NavLink 更灵活。它分左右两个点,还能挂事件:AI 到达左点时触发 OnReachRight 或 OnReachLeft,适合播放攀爬动画。蓝图里拖一个 NavLinkProxy 进场景,双击就能看到这两个点,调起来很直观。
不过 NavLink 有个限制:它只解决能不能到,不解决落点准不准。跳跃距离太长或起跳角度偏了,AI 可能挂在半空。老周的习惯是在 Link 两端各放一个 Trigger,检测 AI 着地后再切回正常寻路。
动态障碍物与 NavModifier:门后的守卫
回到开头那个卡住的守卫。问题根源是静态 NavMesh 不知道门已经开了。UE 提供了两套动态机制:NavModifier Volumes 和 Runtime Navigation Modifiers。
NavModifierVolume 是一块可以在运行时改变 NavMesh 开销或行走性的区域。常见用法是标记毒沼:走进去可以,但移动速度惩罚三倍。它并不真的重建网格,只是在查询时改代价。
真正要挡住路或开路,需要让 Tile 重新生成。Runtime NavMesh Modifier Component 挂在可移动物体上,比如那扇石门。门关闭时,组件把所在 Tile 标记为阻塞;门打开后,组件禁用,Tile 被排队重建。
sequenceDiagram
actor Player
participant Door
participant Modifier
participant NavSys
participant Guard
Player->>Door: 拉开机关
Door-->>Modifier: 切换状态
Modifier->>NavSys: 标记 dirty tile
NavSys->>NavSys: 增量重建
NavSys-->>Guard: 通知路径失效
Guard->>NavSys: 重新查询
NavSys-->>Guard: 返回新路径老周在石门上加了一个 UNavModifierComponent,配合 Timeline 控制开关。门打开后调用 SetNavigationRelevant(false),让系统知道这块碰撞不再影响 NavMesh。守卫立刻收到 OnPathUpdated 事件,重新找到通往玩家的路。
这里有个性能坑:如果每帧都改 Modifier,Tile 会反复重建。门的开关动画持续一秒,应该只在完全打开或关闭时切状态,而不是每一度旋转都更新。我们把重建阈值设成 0.3 秒一次,CPU 占用从 8 毫秒降到了 0.5 毫秒。
NavModifier 除了阻塞,还能改代价。我们把毒沼的 Cost 设成 5,雪地设成 2,普通地面保持 1。这样 AI 会优先走平地,被逼无奈时才踩毒。代价值不要设得太悬殊,否则 A* 会优先搜索大量远处节点,反而慢。
RVO 局部回避:人挤人怎么办
路径有了,AI 还是不能笔直地沿着线段走。走廊里同时刷出三个守卫,各自路径交汇在一点,硬走会穿模。Detour 只负责全局路径,局部避让要交给 RVO(Reciprocal Velocity Obstacles)。
RVO 的核心思想是:每个代理根据邻居的速度和位置,算出一个不会碰撞的新速度。它把问题转成速度空间里的线性规划,找最接近期望速度 且满足所有约束的 。
ORCA 是 Optimal Reciprocal Collision Avoidance 的缩写,可以理解成速度空间里的一堆半平面。每个邻居给你切掉一块不可选区域,剩下的交集就是安全速度集合。
时间范围 time horizon 是 RVO 的隐藏参数。设得太短,AI 只看不远的邻居,容易追尾;设得太长,远处的单位也会被纳入计算,浪费 CPU。我们项目在玩家周围用 2.5 秒,远处用 1 秒,帧数稳定很多。
UE 的 Crowd Manager 默认集成了 RVO。把 AIController 的 bUseFixedBrakingDistancePaths 打开,再把 Crowd Agent 配置挂上去,系统会自动把路径点拆成子目标,并在每个 tick 做局部避让。人数少时直接让 CharacterMovement 自己走也可以,但超过十个同屏单位,Crowd Manager 明显更稳。
RVO 也不是万能。狭窄门口两个代理迎面相遇,可能出现左右摇摆的门口僵持现象。一个土办法是给门口加隐形 NavModifier,强制单行道;另一个办法是调大 Height 忽略低处障碍,减少无意义避让。
寻路查询 API
游戏逻辑里最常见的需求是:AI 能不能到玩家那里?有多远?用哪个 API?
UNavigationSystemV1 提供了同步和异步两套查询。同步查询适合在生成点初始化时判断,异步查询适合运行时高频调用。
// 来源:AIController.cpp
UNavigationSystemV1* NavSys =
FNavigationSystem::GetCurrent<UNavigationSystemV1>(GetWorld());
FPathFindingQuery Query(this, *NavData, StartLoc, GoalLoc);
FPathFindingResult Result = NavSys->FindPathSync(Query);
if (Result.IsSuccessful())
{
DrawDebugPath(GetWorld(), Result.Path, FColor::Green, true);
}FindPathSync 会阻塞当前线程,路径长时可能掉帧。更好的做法是用 FindPathToLocationSynchronously 的异步版本,或者在 Behavior Tree 的 Move To 节点里让引擎自己调度。
查询时别忘了 Filter Class。默认 Filter 按距离算,但你可以派生 UNavigationQueryFilter 加自定义代价。比如精英怪宁愿绕远也不走毒沼,就在 Filter 里把毒沼的 Poly Cost 乘以 10。
除了找路径,ProjectPointToNavigation 也很常用。刷怪时把一个地面坐标投影到最近的 NavMesh 点上,避免怪出生在墙里。GetRandomReachablePointInRadius 则适合制造巡逻点,只要给中心点和半径,它会在可达区域内随机挑一个位置。
大世界 NavMesh 分区
UE5 的世界分区(World Partition)把大地图拆成 Cell,NavMesh 也跟着拆成 Tile。每张 NavMesh 由若干 Tile 组成,每个 Tile 独立烘焙和重建。Tile Size 是这里最关键的参数。
Tile Size 太小,Tile 数量爆炸,内存和查询开销都大;Tile Size 太大,一个动态障碍物会导致整张 Tile 重建,浪费时间。老周在 4km × 4km 的开放世界里测试,Tile Size 从 256 调到 1024,生成时间从 14 分钟降到了 3 分钟,但单次动态重建的耗时从 2 毫秒涨到了 18 毫秒。
折中方案是按区域给不同 Tile Size。城市街道动态元素多,Tile 小一点;野外山地几乎不动,Tile 大一点。UE5.8 支持多个 NavMesh Bounds Volume 叠加不同设置,也可以运行时根据距离切换 LOD 精度的 NavMesh。
另外,Streaming 场景加载时,NavMesh 数据可以随关卡一起流送。玩家走到新 Cell 时才加载对应 Tile,避免一次性全载进内存。要注意跨 Cell 的 NavLink 必须在两边都加载后才能连起来,否则 AI 会在边界愣住。
LOD NavMesh 在 UE5.8 里更实用了。远处单位不需要精细网格,可以用低精度 Tile 跑简单寻路,甚至只朝目标直线走。玩家靠近后再切到高精度。这样同屏单位多的时候,CPU 不会被精细网格拖住。
性能优化
聊了这么多机制,最后落到帧数上。NavMesh 相关的优化可以分成生成、查询、重建三条线。
生成阶段,最划算的是调 Cell Size 和 Tile Size。Cell Size 放大一倍,体素数量降到四分之一,烘焙速度大幅提升,代价是贴墙路径变糙。如果项目不是硬核潜行,0.5 米的 Cell Size 通常够用了。
查询阶段,避免每帧都 FindPath。Behavior Tree 的 Move To 节点内部有路径缓存,只有目标太远或路径被阻断时才会重查。自己写的话,可以每 0.5 秒做一次完整寻路,中间用路径点插值。
重建阶段,重点是控制 dirty tile 的数量。动态门、可破坏墙、移动平台这些,尽量用 NavModifier 改代价,而不是触发完整重建。实在不行,就把影响范围框在小的 NavMesh Bounds Volume 里,别让整个世界都跟着刷新。
flowchart TD
A["性能问题"] --> B{瓶颈在哪}
B -->|"生成慢"| C["调大 Cell Size / Tile Size"]
B -->|"查询慢"| D["降低重查询频率 / 缓存路径"]
B -->|"重建卡"| E["缩小 dirty 范围 / 用 Modifier 替代"]还有一个隐藏开销:Crowd Manager 的 RVO 计算。单位越多,邻居查询越贵。可以按距离裁剪 RVO 代理,远处的怪用简单移动,只把玩家周围 30 米内的单位放进 Crowd。
PIE 模式下,NavMesh 默认会增量生成。如果关卡特别大,可以在编辑器里先 Build Paths,把数据存到 Level,运行时直接加载。这样能避免玩家进入新区域时的卡顿。
调试时怎么看 NavMesh
老周调试寻路有个固定流程。打开 Show Navigation 后,可行走区域会铺上一层绿色,被阻塞的地方没有颜色。如果 AI 卡了,先看重绘的网格有没有覆盖到它脚下。没有绿色,说明 Bounds 没包住或者 Agent 参数把这块剔掉了。
Show Navigation 的快捷键在编辑器里是 P 键,但只有在视口启用 Gameplay 调试时才会显示路径线。老周有时直接用 recast.navmeshdrawrange 把绘制距离拉远,一次看完整张地图的 NavMesh 轮廓。路径线颜色也值得留意。蓝色代表正常路径,黄色代表被部分阻断,红色代表完全走不通。看到红色线穿过一扇门,就知道这扇门的 Tile 状态已经脏了。
再按 ~ 打开控制台,输入 showflags.navigation 1 可以看到 Tile 边界和路径线。Detour 的调试线会画出 A* 搜索过的多边形,颜色越红代表代价越高。如果路径线绕了一个大圈,多半是 Filter 里的权重写反了,或者某个 NavModifier 把整个房间标成了高代价。
NavLink 也有可视化。选中 NavLinkProxy,场景里会出现两个箭头,红色表示单向,绿色表示双向。箭头没连到 NavMesh 上,说明端点悬空或离可行走面太远。小林就遇到过端点埋在地板下几厘米,结果 AI 永远找不到这条 Link。
动态门的调试更隐蔽。老周会在门的 Modifier 组件上加一个打印,记录每次状态切换和 Tile 重建耗时。这样一眼就能看出来是不是每帧都在重建。配合 stat navigation 看整体耗时,定位瓶颈会快很多。
还可以用 UNavigationSystemV1::NavigationRaycast 做快速可达性检测。它比完整 FindPath 轻量,只返回能不能沿直线走,适合判断玩家是不是躲在掩体后。老周在掩体系统里用它替代了完整寻路,查询开销从每帧 0.4 毫秒降到 0.05 毫秒。
可视化工具虽多,但最有效的还是慢放录像。把 AI 卡住的那几秒录下来,逐帧对比路径线和实际位置,问题通常很快就能浮出水面。多花几分钟看录像,能省下几小时改错参数。NavMesh 可视化是排错的第一把钥匙。定位问题后,改参数反而最快。
小结
- NavMesh 是静态地图,动态变化必须让 Tile 重建或改 Modifier 代价。
- Agent Radius、Height、Slope 决定可行走空间,数值不对 AI 就会卡墙。
- NavLink 解决断桥、平台等自定义移动,但落点要自己校验。
- RVO 处理局部避让,人多时交给 Crowd Manager。
- 大世界靠 Tile 分区和 Streaming,生成与重建要平衡 Tile Size。
那天下午,老周把石门的 Modifier 改成状态切换,守卫终于能在门开后追出来了。小林又发来一段录像,这次守卫还懂得绕开玩家放的陷阱。老周喝了口咖啡,说:寻路这东西,地图对了,AI 才像活的。