2026/10/12 4:58:43

UE多敌人AI性能优化:从瓶颈定位到Tick与对象池实践

UE多敌人AI性能优化:从瓶颈定位到Tick与对象池实践 做多敌人 AI 的 FPS 项目最常用的一个词是“明明都没怎么动怎么就卡了”。早期我在某模拟项目里接手过一个 26 个敌对 AI 同时存在、战斗时还要再临时生成十几个敌人的场景目标平台是普通配置的 PC要求稳定 60 帧。一开始所有人都把矛头指向 AI 逻辑结果 Profile 一看GameThread 被各种 Tick 吃满RenderThread 也在排队Draw Call 倒是还好。真正的问题往往不是“敌人太聪明”而是“每个敌人都把全套功能当成每帧必需品在跑”。这篇文章就把我当时踩过的坑、试过的方案、以及最终落地的优化记录整理出来重点围绕“多敌人 AI 场景”这个具体语境聊 UE 里的性能优化思路。1. 先把瓶颈找出来不要凭感觉优化很多团队优化性能是从“删 AI Tick”开始这其实有点赌运气。UE 里的性能瓶颈可能出现在 GameThread、RenderThread 或 GPU三者占的时间比例不同处理方式完全不同。我见过有人花了半个月压缩贴图结果发现瓶颈是寻路查询太多压缩贴图根本无关痛痒。所以在碰任何代码之前先花一个下午把瓶颈定位清楚比什么都重要。1.1 用工具确认 CPU 还是 GPU而不是靠肉眼UE 引擎提供了一组非常直接的统计命令我一般按这个顺序看stat unit看 Frame、Game、Draw、GPU 的时间能快速判断瓶颈在哪个线程。stat game游戏线程里逻辑耗时比如 AI、蓝图、组件更新。stat rhiRHI 线程和 GPU 相关的耗时。stat gpuGPU 上的渲染耗时分布。stat engine看 Tick、角色移动、动画等引擎部件的统计。stat animation动画系统耗时多角色场景特别重要。stat navigation寻路系统耗时敌人一多就要盯这个。stat aiAI 感知和大脑更新的统计老版本直接看这个。用的时候不要只看最大帧漂到哪要看平均值和 P99。FPS 偶尔掉到 50 不一定是问题但如果 P99 都在 55 以下说明绝大多数帧都超了预算必须处理。另一个必须掌握的工具是 Unreal Insights。它能把每个帧的 GameThread、RenderThread、GPU 耗时和具体追踪点全部拉出来。多敌人场景下我最常用的操作是先把游戏跑到战斗压力最大的位置再开启 Trace记录 10 秒左右然后回到编辑器里按帧看 GameThread 的耗时叫你。重点看这几个名字FTickFunctionTask、AAIController、UNavigationSystemV1、APawn::Tick。如果这些名字占据了大部分 GameThread 时间那说明问题的核心在逻辑更新而不是渲染。用这些工具还有一个习惯数据要分平台记录。开发机上 60 帧不代表目标平台上流畅因为 GPU 和 CPU 的配比完全不同。最好在目标配置或接近目标配置的机器上统一测。1.2 多敌人场景的典型消耗分布多敌人 AI 场景里开销往往不是均匀分布的而是集中在几个模块上。我习惯列一个清单逐项确认模块典型原因表现动画更新每个角色每帧更新骨骼、IK、动画蓝图GameThread 和 RenderThread 都涨感知系统AI 每帧查询周围 Pawn、跑感知任务GameThread 耗时高出现尖峰寻路每个敌人频繁请求路径、Dynamic NavMesh 更新GameThread 高帧率不稳移动组件检测地面、扫碰撞、更新速度GameThread 高MoveComponent耗时多碰撞检测大量角色胶囊体、武器检测、投射物检测GameThread 和物理线程高Draw Call每个敌人网格、骨骼、材质实例数量过多RenderThread 或 GPU 排队生成/销毁战斗时频繁 Spawn/销毁敌人、武器、特效GC 尖峰加载卡顿注意这些模块之间还会互相放大。比如角色多了每个角色都发射一条射线检测玩家看起来每个检测只在 0.1ms 以内但几十个角色叠加起来就不是小数目了。多敌人场景里最常见的“隐性炸弹”是动画蓝图。很多角色的动画蓝图里挂了大量Event Tick每次动画更新都要执行蓝图逻辑比如切换状态、计算 BlendSpace 参数。一个角色可能不卡20 个角色同时做同样的事情GameThread 时间就爆了。这种事靠 stat 命令一眼就能看见但如果不主动去看很容易一直猜是“渲染问题”。1.3 从帧数据里读出的关键信号举个例子。当时测试场景里放 26 个敌对 AI不战斗的状态下stat unit显示Frame约 16.8msGame约 11.2msDraw约 3.5msGPU约 8.9msGame 11.2ms 显然是主要瓶颈。展开stat game之后动画和 AI 各占一半。再展开动画模块发现每个角色都在跑完整的动画蓝图而且 LOD 距离没设置26 个角色全部用了 LOD0 的骨骼网格顶点数极高。这一步的意义在于瓶颈是 GameThread而且是动画和 AI 的组合不是 GPU。所以后续优化的重心要放在“降低每个 AI 的更新成本”而不是盲目压缩材质和阴影。这里有一个经验如果 Draw 时间远大于 GPU通常意味着 Draw Call 太多或渲染状态切换太多。如果 Game 和 Draw 都不高但 GPU 高才考虑材质、光照、粒子。当年这个项目正好是 Game 明显更高方向就清晰了。2. 先解决最痛的敌人 AI 更新成本瓶颈确认后我开始动 AI 更新这一块。原则很简单不是每个 AI 都需要每帧全速思考也不是每个 AI 都需要每帧完整驱动动画。要做的不是删除功能而是把功能按“必要频率”和“合理距离”重新分配。2.1 拆分 Tick 频率不是每个 AI 每帧都要算引擎默认行为是只要有 Tick 功能启用基本上每帧都会调用。对 FPS 里的敌人来说很多计算并不需要每帧都做。例如状态机决策可以每 0.2 秒更新一次甚至 0.3 秒视线检测可以每 0.1~0.2 秒做一次对玩家的攻击判定、技能冷却判断每帧都要但这是少数播动画这件事本身受动画系统控制不用每帧去改状态。常用的做法是使用SetActorTickInterval或自定义时间窗。C 里比较优雅的方式是// 在 AMyEnemyCharacter 中 float AMyEnemyCharacter::GetDesiredTickInterval() { // 距离玩家越远更新频率越低 float DistSq FVector::DistSquared(GetActorLocation(), PlayerLocation); if (DistSq FMath::Square(3000.0f)) return 0.3f; if (DistSq FMath::Square(1500.0f)) return 0.15f; return 0.05f; } void AMyEnemyCharacter::UpdateTickInterval() { const float NewInterval GetDesiredTickInterval(); if (!FMath::IsNearlyEqual(NewInterval, LastTickInterval)) { SetActorTickInterval(NewInterval); LastTickInterval NewInterval; } }不用每帧调用UpdateTickInterval可以在感知范围内的事件或一个低频 Manager 里驱动。蓝图上也有SetActorTickInterval节点做法一致。关键是不要为了省 Tick 把所有角色都设成 0.2 秒这样近距离战斗时敌人会反应迟钝玩家会觉得 AI 是傻子。更平等的思路是“错帧更新”。把敌人分成几个批次第 0 帧更新 A 批第 1 帧更新 B 批以此类推。UE 自带的 Update Rate Optimization 也能做类似事情但手动控制会更灵活尤其是在 FPS 里需要保留“危机感”的时候——不能让每个敌人同时停顿、同时转向。2.2 感知系统的负载管理与范围阈值AI 感知系统是另一个大头。默认的AIPerception会把感知范围内所有的 Pawn 变化都广播出去同时还会周期性更新刺激强度。敌人数一多感知更新就有可能是 O(N×M) 的复杂度——每个敌人感知每个玩家敌人之间还互相感知负担立刻上来。我在项目里做了三个调整感知范围不让全员都一样。普通小兵感知距离 3000精英兵 5000狙击手 8000且感知更新间隔不同玩家被感知到后立刻进入已知状态后续低频刷新位置而不是每帧都重新做高精度感知关闭了“感知其他 AI”的通道。在多敌人 FPS 里敌人之间通常不需要互相监听这个通道默认开着就是浪费。如果你用UAIPerceptionComponent可以在AISenseConfig_Sight里调PeripheralVisionAngle、AutoSuccessRangeFromLastSeenLocation等参数。我的建议是先把范围调小再配合 LOD 方案玩家在远距离时只计算少量敌人一旦进入交战距离再切高频。还可以用扇区式更新把一个敌人周围 360 度分成 8 个扇区每帧只检测当前朝向的一个扇区。虽然检测精度略低但对大多数 FPS 敌人来说完全够用。这个做法对远程敌人尤其有效因为近战敌人需要持续追踪玩家位置。2.3 寻路与移动限制 Pathfinding 频率和路径更新多敌人同时寻路是帧率尖峰的头号元凶。场景里有 20 个敌人每个敌人都请求一次从当前位置到玩家位置的路径再加上动态障碍变化导致的重新寻路NavMesh 系统和路径查询线程直接就被塞满了。我先限制了两点UNavigationSystemV1里把PathfindingIterationLimit调到一个合理值避免单次寻路吃满过多时间每个敌人通过RequestMove发起寻路时加上“路径过期时间”。超过 0.5 秒或 1 秒的路径才允许重新请求。短时间内寻路结果差一点点没关系FPS 玩家并不关心敌人走的是不是全局最优路径更在意“敌人会不会穿墙、会不会卡住”。还有个很实用的做法动态更新 NavMesh 时不要整帧重建。如果场景里有大片可破坏物体或会移动的障碍把这些区域单独切成小块只在障碍变化时更新对应区块。Runtime Generation里可以设置Dynamic模式但要注意如果动态区域太大重建一次的成本依然很高。要配合脏区域标记让它只更新最必要的部分。移动组件方面可以适当调低MaxAcceleration、BrakingDeceleration或关掉UseAccelerationForPathFollowing这能减少移动时每帧的加速度计算量。不建议随便改否则敌人手感会变但配合低频 Tick 后路径跟随的压力会明显下降。3. 渲染与动画敌人多不代表 Draw Call 多一倍多敌人场景最容易让人忽略的是动画和渲染之间的联动。每个敌人都是一个带骨骼网格的 Actor默认情况下每帧都要做动画更新、骨骼重算和蒙皮绘制。就算逻辑全优化好了动画系统如果不降频帧数还是上不去。3.1 动画更新与 LOD引擎的UAnimationComponent和AnimInstance开销都不小。多角色优化里我用的组合拳是骨骼网格设置 LOD且 LOD 切换距离不要太大。比如 LOD0 到 LOD1 设为 1000~1500 单位LOD1 到 LOD2 设为 2500 单位动画蓝图通过ShouldSkipUpdate或UpdateRateOptimization跳过低优先级更新对远距离敌人直接关掉动画骨骼更新只保留一个简单的 Idle 姿态甚至直接播放一份预制动画序列。这里有个细节动画 LOD 和渲染 LOD 是两套机制。渲染 LOD 只会让网格顶点变少动画 LOD 才决定是否执行动画图表和骨骼更新。如果没有开bUseAnimationLOD那么即使渲染 LOD 已经降到最远档动画还是要每帧更新。很多朋友只设了网格 LOD没设置动画 LOD所以效果不明显。在项目里我常写这样一个函数void AMyEnemyCharacter::UpdateAnimationLOD() { const float DistToCamera FVector::Distance(GetActorLocation(), CameraLocation); if (DistToCamera AnimationLODDistance[0] GetMesh()-bUseAnimationLOD) { // 远端只保留最低帧率更新 SetAnimationTickInterval(0.1f); } else { SetAnimationTickInterval(0.033f); } }SetAnimationTickInterval可以控制 AnimInstance 的更新节律。对阵地上拿着望远镜观察敌人的玩家来说稍微粗糙的动画完全能被接受。真正的底线是近战敌人冲到脸前的那一刻动画不能跳变否则打击感全无。3.2 材质与 Mesh 合并/实例化多敌人 AI 如果每个敌人都用独立的动态材质实例哪怕只是改了颜色也会增加渲染状态切换。建议如果所有敌人共享同一套基础材质就把不同颜色的部分剥离出来用顶点色或纹理坐标偏移控制而不是每个角色生成一个材质实例。然后是网格实例化。FPS 里的敌人大多是骨骼网格不能直接走InstancedStaticMesh但如果场景里有大量静态装饰物比如子弹盒、油桶、路灯能用 ISM/HISM 就尽量用。敌人这块更实际的做法是“合并 Mesh 的静态部分”把武器、头盔、护甲这类不参与复杂动画的部件从骨骼里分离出来合并成少量静态网格。这样每个敌人模型的面数整体下降渲染压力也跟着下降。还有一个容易被忽视的点材质复杂度。多敌人场景里同一个敌人往往身上有多层材质每一层都有不同的材质函数、法线贴图、粗糙度判断。屏幕上有 20 个角色时材质指令数会被放大 20 倍。我在一个项目中把角色的皮肤材质从三层材质混合改成两层像素着色器压力掉了 15% 左右。对美术表现影响不大但对帧率帮助很明显。3.3 对着模型数量的减负剔除与遮挡敌人一多我们必须保证“玩家看不到的敌人不消耗渲染和动画预算”。UE 自带视锥剔除和遮挡剔除但遮挡剔除依赖渲染复杂度而且有些遮挡物本身开销就大。我的做法是再叠加一层“逻辑距离剔除”距离超过 5000 的敌人直接SetActorHiddenInGame(true)同时禁用动画更新和 Actor Tick不在玩家视野锥内的敌人降低感知和动画频率但不完全隐藏避免玩家突然转身时看到敌人凭空出现在室内关卡里用 Precomputed Visibility Volume 做预计算遮挡不要把角色放在一个没有遮挡的大厅里硬算。特别提醒SetActorHiddenInGame和SetActorEnableCollision(false)不是一回事。隐藏只影响渲染AI 逻辑、碰撞、寻路可能还在跑。如果希望彻底省下 CPU需要主动禁掉碰撞或把敌人移出活动状态。但小心别做太激进否则子弹打不到远处的敌人、敌人也无法发现玩家玩法就崩了。4. 战斗开始的瞬卡生成与死亡的对象管理多敌人 AI 场景一个非常“欺负人”的地方是平时 60 帧跑得好好的一进战斗瞬间生成 15 个敌人然后画面就卡成狗。这种瞬卡不是敌人 AI 太聪明而是对象生成和销毁带来的压力。要处理的是“批量创建对象”这件事本身。4.1 对象池避免大量生成销毁的 GC 和内存碎片UE 每个 Actor 生成都涉及类加载、组件创建、初始化等步骤。如果每次战斗都SpawnActor战斗结束后又DestroyActor内存分配和 GC 会在某个瞬间集中爆发。对象池的思路是预先创建一批敌人 Actor战斗时按需激活战斗后放到“待回收”列表里而不是销毁。我在项目里用的是很朴素的方案// 对象池管理器的核心片段 AMyEnemyCharacter* AMyEnemyPool::GetEnemyFromPool() { for (AMyEnemyCharacter* Enemy : PooledEnemies) { if (Enemy !Enemy-IsActive()) { Enemy-ActivateEnemy(SpawnLocation, SpawnRotation); return Enemy; } } // 如果池子不够再创建新角色并加入池 AMyEnemyCharacter* NewEnemy GetWorld()-SpawnActorAMyEnemyCharacter(EnemyClass, SpawnLocation, SpawnRotation); PooledEnemies.Add(NewEnemy); return NewEnemy; } void AMyEnemyPool::ReturnEnemyToPool(AMyEnemyCharacter* Enemy) { Enemy-DeactivateEnemy(); // 隐藏网格、禁用 Tick、禁用碰撞 }关键不是代码本身而是“激活/失活”要彻底。失活时必须把动画蓝图重置、把缓存的感知目标清空、把行为树停止掉否则下次激活时残留状态会带来各种诡异 bug。对象池的收益主要体现在两方面减少 Spawn 的瞬时开销以及避免 GC 扫不干净导致后续帧卡顿。4.2 AI 批量激活与激活队列即使有对象池也不要在同一帧激活 20 个敌人。激活过程中要重新初始化行为树、重新执行感知、重新绑定移动组件这些依然会占 GameThread 时间。我的做法是加入“分批激活队列”每帧最多激活 2~3 个敌人激活顺序按距离玩家由近到远优先让最近的压力先起来如果战斗开始时有演出需求可以做一个“生成警告”动画这样玩家视觉上也不会觉得敌人是瞬间冒出来的。这个方案也能用在后期异波形生成不是一次性生成一整波而是分成 3 到 4 个小批次每个批次间隔 2~3 帧。人眼感觉不到差异但性能曲线会平滑很多。另外建议每个敌人不要都在 BeginPlay 里立刻申请行为树和感知组件。可以放在ActivateEnemy时初始化如果愿意让 AI Controller 的 PostInitializeComponents 只做轻加载真正的逻辑等激活后再跑。这样确实会改一点框架设计但多敌人 FPS 这种场景很值得。4.3 死亡与回收的流程优化敌人死亡时最耗时的往往是布娃娃、掉落物、以及分解特效。上百根物理约束瞬间被激活物理引擎会哀嚎。经验是死亡时只对可见距离内的敌人启用布娃娃模拟布娃娃物理跑 2~3 秒后冻结不要一直模拟掉落物使用一个简单的“掉落物队列”不要每个敌人死亡都生成一堆独立 Actor死亡特效改用 Niagara 池同一个特效系统循环播放不要每次生成新的 Niagara System。把“生成-激活-战斗-死亡-回收”这整条链路串成一个状态机比单纯加对象池更有效。因为很多卡顿其实是状态切换不干净、旧资源没释放造成的。5. 实测调优流程与结果验证优化做了一堆最后得用数据说话。这个环节最怕的是“每个人各测各的参数不一致谁也不知道自己改动到底有没有效果”。所以我制定了一套固定的测试流程方便迭代。5.1 固定测试关卡与镜头路径多敌人场景的性能测试必须固定变量使用专门的 Test Map关卡里固定 30 个敌人站位镜头路径提前录制好用 SpectatorPawn 或 Matinee 走同一段路径确保每帧看到的场景一致分别在“无战斗”“交战中”“敌人死亡后 5 秒”三个阶段采样CPU 和 GPU 数据分别记录不能只记帧率。录制镜头路径这个习惯非常重要。人肉操作镜头会导致测试误差优化前后对比完全不可信。哪怕只是一个简单的 spline camera也比人手晃动测出来的结果可靠。5.2 帧时间分解对比我用一个表格记录优化前后的关键数据指标优化前优化后变化GameThread 平均耗时11.2ms5.4ms-51.8%RenderThread 平均耗时5.8ms4.2ms-27.6%GPU 平均耗时8.9ms6.1ms-31.5%P99 帧时间24.1ms11.7ms-51.5%Draw Call18501120-39.5%每秒 AI Tick 次数26×6026×12明显下降数据分析要看几个维度平均帧时间下来没有P99 下来没有如果平均下来但 P99 反而变高说明中间有尖峰可能是什么逻辑周期性爆发GPU 和 Game 是否平衡如果 Game 掉下来了但 GPU 还是高说明下一个瓶颈转到渲染了帧时间的“稳定性”比“平均帧率”更重要很多编辑器里看起来是 60 帧一打起来就是 40~50 波动玩家能明显感觉到。5.3 优化后的行为质量清单性能提升不能以“AI 行为质量下降”为代价。我会跑一遍行为质量测试确认以下几点近战敌人冲到玩家面前的频率和反应速度是否达标敌人是否会因为 Tick 降频而把玩家当“透明人”远程敌人的开火反应是否在可接受范围内布娃娃和死亡动画是否会出现明显跳变多波次刷怪时是否会出现“敌人突然全站在原地 1 秒”的情况。如果某项不合格我会对特定敌人类型单独提高 Tick 频率或感知距离而不是全局回滚。全局回滚是最省事的也是性能优化翻车最频繁的坑。6. 一些容易踩的坑与个人经验这一部分不算方法论是我在多次“多敌人 AI FPS 性能优化”里反复踩过的坑。写出来希望大家能少走弯路。6.1 过早优化与“看起来对”的优化我见过有人一上来就把所有敌人的 Tick 关掉然后敌人全部变木头人。这不是优化是阉割。正确顺序永远是先 Profile再定位再制定方案。如果 AI 反应速度本身是玩法核心那就不能用粗暴降频的方式解决得在感知算法层面做优化。“看起来对”的优化指的是某个改动让帧率从 50 升到 58但你不清楚是哪个改动奏效了。这种时候要克制住“反正变快了”的心理一定要拆分验证。我通常用版本分支或开关一次只打开一个优化项记录数据再叠加下一个。只有知道每个改动贡献了多少后续遇到更复杂的场景才能合理取舍。6.2 优化后 AI 行为质量下降被策划或玩家骂回来这个坑几乎人人都遇到。感知距离缩小后玩家躲在柱子后面敌人就找不到他了Tick 频率降低后敌人转向变慢被玩家绕后还反应不过来。这类问题不能等测试反馈再来补应该在优化时就预留“质量开关”。我的做法是把感知距离、Tick 间隔、动画 LOD 距离这些参数全部暴露为可配置的曲线或配置表。不同难度、不同敌人类型加载不同参数。上线之后发现某个敌人“太聪明”或“太笨”调整配置就行不需要重新写代码。6.3 只在编辑器里测没有发布构建验证编辑器里的性能数据有大量编辑器开销和缓存和发布构建完全是两个环境。多敌人 AI 场景尤其明显PIE 里因为编辑器逻辑、日志、自动保存等额外开销CPU 数据偏高发布构建里反而少了那一层负担。所以最终验收必须以 Development Test 或 Shipping 版本为准。另外发布构建里默认会开启很多优化比如一些引擎剔除和数据结构优化。如果发现某个优化在发布构建里效果不明显不要急着否定先看是不是发布构建引擎已经帮你处理了部分问题。6.4 日志和调试代码的残留最后说一个很隐蔽但影响巨大的事情调试日志。很多人在 AI 逻辑里加了UE_LOG或PrintString平时没注意敌人一多日志输出直接占掉大量 GameThread 时间。尤其是客户端全量日志时控制台每秒刷几百行帧率掉得离谱。发布前必须全局检查日志要么关掉要么只保留 Error 级别的输出。“多敌人 AI 场景优化”和普通渲染优化最大的区别在于它是一个“系统性问题”涉及逻辑、动画、渲染、物理、对象管理五个方面。我个人的经验是一定要用帧时间数据来指导每一步并且把“行为质量”当作第一优先级的验收标准。多敌人 FPS 的爽感恰恰来自“很多敌人同时在威胁你”一旦为了性能把 AI 变得迟钝优化就失去意义了。希望这些思路能帮你少走一些弯路。