UE AI 性能优化实践
UE AI 性能优化实践
这些经验来自一个第一人称潜行 RPG 项目。它是纯开发项目、不是优化项目——排期按 feature 塞得很满,以实现为主、code review 少、写的时候基本不考虑性能,AI 用了 GOAP,而且没有 Tick 框架。等功能堆得差不多,性能债一次性爆出来,跑 profile 定策略,主要就两块:视觉查询和 GOAP 规划(后面版本又多了传送门)。GOAP 那部分的优化放在 UE 中实现 GOAP,这里讲剩下的:碰撞查询、Tick、群体避让。
精细化碰撞查询
UE 自带的 AI 感知(AI Sense Sight)是切一个可配置的球形出来做视觉查询。项目的需求更细:一个角色挂了三个 Mesh 分别做视觉查询,既查 Pawn 又要穿过多个传送门通道查。代价是单个角色的视觉查询单帧能到 0.5ms,战斗场景一堆角色叠起来就压垮了帧。
为什么 overlap 被调了两次
Profile 里发现 CharacterMovementComponent 和 USkeletalMeshComponent 的 CompleteParallelAnimationEvaluation 都会触发 UpdateOverlaps(而且 AIController 里也会调 movement 组件),等于一帧更新了两遍 overlap。拆开看是两条独立路径:
移动之后的 overlap 更新:
CharacterMovementComponent::CallMovementUpdateDelegate广播USceneComponent::EndScopedMovementUpdate执行委托里的UpdateOverlaps(前提是开了bCheckOverlap,受p.SkipUpdateOverlapsOptimEnabled控制)USceneComponent::UpdateOverlapsImplOverlapMultiImpl进物理CollisionConversions::ConvertOverlapResultsImpl
动画 Finalize 之后的 overlap 更新:动画更新完,UE 在物理参与动画混合时也会更新一次 overlap。实测下来大部分情况这次更新是白做的,关掉它省了约 9ms。
自定义查询没那么灵
试过把注册式的 bCheckOverlap 换成自己调 OverlapMulti / ComponentOverlapMulti(基于 Mesh shape 查询)。结论是:在优化掉 animation finalize 那次多余更新之后,改自定义查询并不能再带来多少收益。方向对,但边际效应递减。
真正省下来的几刀
- 砍查询数量:战斗状态只保留一个最大的视觉查询,非战斗状态才用三个。
- 去掉
CharacterMesh0上的 overlap 查询:战斗场景单角色少 300~500us;扣掉 CharacterMovement 之后,overlap 占了约 40% 的查询消耗。 - 算总账:战斗时约 15 个角色的 CharacterMovement + AIController Tick,单个平均减 400us,
400us × 15 ≈ 6ms。一帧省 6ms 是很实的收益。
有个连带坑:关掉 Mesh 上小胶囊体的 overlap event 会让某个陷阱类技能失效——那个技能靠小胶囊体做精确碰撞检测,换大胶囊体就不准了。而另一个快速的范围攻击技能可以用大胶囊体,因为它够快,不准的问题被速度弱化掉了。折中方案:平时只开大胶囊体的 overlap event,一旦大胶囊体碰上了,再打开 CharacterMesh0 的 overlap event 做精确判定。只开大胶囊体、关小胶囊体时没有明显额外消耗。
再往下想做 Mesh 的异步查询,可惜 UE 目前只提供简单形状的异步查询,不支持以 Mesh 做异步——要做只能参考 simple shape 的异步实现自己撸,这个还躺在计划表里。
Tick 管理
项目没有 Tick 框架,问题就出在这。举个例子,某个传送门相关组件,单个组件在一帧的 Tick 里可能不受控地突然被调用无数次。单次 Tick 也许没几个时间,但一旦是 O(N),就没人说得清总共花多少——写 Tick 的时候你根本不知道将来 N 会长到多大。
这引出 Tick 的两个关注点:一是程序员得对自己 Tick 里的代码负责,能优化就优化;二是需要一个 Tick 管理来控制单帧 Tick 的最大数量,N 过大时按规则分摊到多帧。
先上了个简化版:按「AI 与玩家距离」和「AI 是否被渲染」拉长 Tick 间隔。
if (Actor->WasRecentlyRendered() == false)
{
// 没被渲染,拉大 Tick 间隔
}
void SetTickInterval(APawn* Actor, float Interval)
{
if (Actor == nullptr) return;
Actor->SetActorTickInterval(Interval);
if (UPawnMovementComponent* Movement = Actor->GetMovementComponent())
{
Movement->SetComponentTickInterval(Interval);
}
TArray<UActorComponent*> SkMeshComps =
Actor->K2_GetComponentsByClass(USkeletalMeshComponent::StaticClass());
for (auto* It : SkMeshComps)
{
if (It) { It->SetComponentTickInterval(Interval); }
}
}
简化版有个明显缺陷:它不是真正的分帧,只是各自拉长间隔,某一帧照样可能同时到期、压力堆到一起,没能把负载平均开。
改了 Tick 间隔,动画就不平滑了
拉长 Tick 间隔之后动画会跳,得做插值。项目用了 root motion 移动,直接写插值会被 root motion 拉回去;后来靠把 Tick 间隔调得没那么激进来弱化这个副作用。另外 NPC Optimization 插件里改 MovementComponent 的 Max Simulation Time Step 和 Max Simulation Iterations 会影响移动平滑——不过那个插件的方案我试下来不太能直接用,有穿墙、浮空之类的表现问题,它本质上也是在设 Tick 间隔。
下一步的正解是做一个 Tick 管理器:让各角色的组件来注册,管理器按优先级把 Tick 调用更均匀地摊到各帧。
动态避让:DetourCrowd 的抖动
项目 AI 移动开了 root motion,而 root motion 会接管一部分移动逻辑,导致 RVO 的转向被它抢走、转不了向。所以避让改用 AI Crowd Manager 的方案——它底层是 DetourCrowd,和 root motion 不冲突。
转向抖动
UE 的导航用的是第三方 recastnavigation,分三块:Detour(导航)、DetourCrowd(群体避让)、Recast(导航网格生成),UE 在外面封了一层。抖动的现象是 AI 转向时来回抽搐,原库在低速时似乎本来就有这个毛病,怀疑是实现问题。
- 调参:试过,没用。
- 可行的 workaround:给 MoveInput 建一个历史队列,对最近若干帧的输入做平均 + 插值(lerp),把突变磨平。抖动才压下去。
DetourCrowd 还有两个硬伤
- 难调试:Visual Logger 看不到 AI Crowd 的调试信息,自带的 Crowd Debug 又不够用,出问题基本靠猜。
- 缺乏对 Nav Link 的支持:跳跃、爬梯这类 Nav Link 场景,DetourCrowd 支持得不好。
两个「用到顶」的边界
项目最后把 UE 的两处硬上限都撞穿了,值得记一笔:
- 碰撞通道耗尽:UE 一共 32 个碰撞通道,除去引擎保留的,留给用户大概 18 个——全用完了。多传送门通道 + 多 Mesh 视觉查询是大户。设计碰撞通道时最好一开始就规划预算,别用一个加一个。
- UObject 数量触顶:GOAP 规划器每帧 new
UGOAPState,GC 追不上,直接把 UObject 顶穿崩溃。解法是对象池,详见 实现篇。
一条通用心得
这套优化做下来,最省事的收益全来自「别做白工」——多余的那次 animation finalize overlap、非战斗状态下多余的两个视觉查询、没被渲染还在满频 Tick 的远处 AI。真正难啃的(真正的分帧 Tick 管理、Mesh 异步查询、DetourCrowd 抖动)反而收益/成本比一般。先把白工砍干净,再谈架构级优化,顺序别反了。