
前一阵子在做一个 FPS 项目的 Horde 玩法玩家守点、敌人一波一波从四面八方涌上来。原型阶段只放了 5 个敌人帧率稳得很等我把场景里同时活跃的敌人拉到 30 个以后帧率直接从 60 掉到 20 出头开枪瞬间甚至能跌破 15。项目用的是 UE 5.1踩完这个坑之后我把 UE FPS 性能优化相关的手段几乎全过了一遍——最后发现多敌人场景的瓶颈并不是某一个点而是游戏线程、渲染线程、动画系统一起失衡。这篇就以这个 30 个敌人的场景为例把整个定位、优化、验证的思路和实操记录列出来。不管你是刚接触 UE 的新手还是在 Boss 战、生存玩法、大世界刷怪上被性能打爆的老开发应该都能从这里找到几条能直接抄作业的方案。1. 多敌人场景为什么特别容易卡先搞清时间花在哪1.1 先分清性能消耗发生在哪个线程UE 的帧时间主要由三块组成游戏线程Game Thread、渲染线程Render Thread、GPU。游戏线程负责蓝图、AI、动画、物理的输入逻辑渲染线程负责收集场景里的绘制指令、做剔除、生成阴影相关的 CPU 工作GPU 才是真正把像素画出来的硬件。很多人在多敌人场景里一卡就下意识去降画质、关阴影结果发现没什么用因为瓶颈根本不在 GPU。判断方法很简单控制台输入stat unit这个命令会显示 Frame、Game、Draw、GPU 四个时间。Frame 大致等于最慢的那个线程加开销。如果 Game 那一项常年 15~20ms 以上说明你的问题在游戏逻辑如果 Draw 很高渲染线程的 CPU 开销爆了如果 GPU 很高那才轮到画质、分辨率、后期处理背锅。我当时的读数大概是Game 22ms、Draw 9ms、GPU 7ms。很明显瓶颈在游戏线程GPU 其实还有余量。这种时候跑去关阴影、砍分辨率就是把钱花在错误的地方。1.2 多敌人玩法的三座大山以我的项目为例30 个敌人带来的开销集中在这三块AI 逻辑每个敌人一个 AIController默认每帧 Tick加上行为树每帧决策、感知系统每帧重复判断30 个敌人就是 30 份重复劳动。动画系统每个敌人一个 SkeletalMeshComponent一个 AnimBP如果 AnimBP 里有计算方向、速度插值、IK 之类的节点每个都要在游戏线程里求值一次。渲染的隐藏成本每个敌人都是单独的骨骼网格一个角色可能拆出 4~6 个材质区块加上动态阴影一个敌人能吃掉好几百的 draw call。这三块叠加在一起30 个敌人就把游戏线程打满了。关键是它们都隐藏在“正常写功能”的过程中——单个敌人时完全看不出来数量一多就爆。这也是为什么多敌人场景是 FPS 性能优化里最典型的考题。2. 定位问题的完整链路从 Stat 命令到 Unreal Insights2.1 Stat unit 和 Stat game 的读数怎么解读stat unit是第一步的粗筛想要进一步定位 Game 线程内部的开销可以看stat game。这个命令会把游戏线程内部的耗时按类别摊开比如 Animation、Behavior Tree、AIPerception、Navigation、Physics 等。我这里当时比较明显的是 Animation 和 AI 两个大类都很高Physics 也在缓慢增长。注意一点stat game的输出是一个平均值或滚动值它适合看分布不适合抓瞬间尖峰。真正的尖峰问题——比如某个瞬间 30 个敌人同时重新寻路、同时播放受击动画——需要 Unreal Insights 才能抓到。2.2 Unreal Insights 抓游戏线程的瞬时峰值UE 5.1 里自带的 Unreal Insights 是我这次定位的主力工具。做法很简单PIE 跑起来之后在控制台输入trace.start打一会枪、让敌人刷一波然后trace.stop打开 Unreal Insights 窗口加载采集文件重点看游戏线程的时间轴。在这里你可以看到每个 Tick 里面 AI 相关的事件、Anim 更新事件、物理模拟事件各自占了多少时间。我最开始以为 AI 是大头结果 Insights 里面Animation事件居然比AI还高一截——这就是数据比直觉靠谱的例子。2.3 GPU 侧用 ProfileGPU 采集当你确认 GPU 时间也不低的时候可以用profilegpu命令。它会输出一长串 GPU 计时结果按总耗时排序。重点关注 Shadow Depth、BasePass、Translucency 这类 Pass 的耗时。我当时 GPU 不算瓶颈但profilegpu仍然抓到了一个问题场景里动态阴影生成的耗时一直在涨原因是 30 个敌人开了一堆阴影投射阴影深度 Pass 的三角面数惊人。这就为后面第四节的阴影优化提供了证据。3. 游戏线程降载实战AI、行为树、寻路与物理逐一开刀3.1 不要让 AIController 闲着也每帧 TickUE 里新建的 AIController 默认是会 Tick 的但多敌人场景里大部分敌人在大部分时间根本没有“需要每帧决策”的事——要么还没发现玩家要么已经进入固定攻击循环。让它们每帧 Tick 纯粹是浪费。我的做法是给场景加了一个EnemyManager由它统一管理敌人的状态而敌人自身的 AIController 默认关闭 Tick。大致逻辑状态机分三档休眠Sleep、警戒Alert、战斗Combat。休眠状态下AIController 禁止 Tick行为树不跑动画只播 Idle。玩家进入一定半径后Manager 把该敌人唤醒再开启 AI。敌人死亡后立即把 Tick、碰撞、动画全部停掉而不是等着引擎慢慢回收。这个改动立竿见影。30 个敌人里同时处于战斗状态的往往只有靠近玩家的 6~8 个其余全在休眠。游戏线程的 AI 开销直接砍掉三分之二。3.2 行为树更新频率和一帧里的决策数量行为树如果每帧从头评估优先级、每个任务都重新执行一次30 个敌人就是灾难。行为树实际上有两种浪费一种是确定不需要每帧决策的树另一种是每帧决策里做了太多重复检查。我建议给行为树也设一个更新间隔。UE 的BehaviorTreeComponent默认跟着 Controller Tick但你可以设置组件 TickInterval。比如把行为树的 TickInterval 调到 0.1~0.2 秒对大多数射击游戏里的敌人完全够用。敌人又不是格斗游戏的对手120ms 的决策延迟玩家根本感知不到但 CPU 压力直接减半。另一个思路是减少“每帧重复感知”。AIPerception 里的视觉感知本质上每帧都在发射线检测玩家是否可见。30 个敌人 × 每帧一根或几根射线这开销很可观。把感知组件的更新间隔调大比如 0.2~0.3 秒同步一次玩家跑到掩体后面、敌人“追丢目标”的响应会慢一点点但绝大多数时候可以在游戏性上接受。3.3 寻路请求的合并与物理碰撞的收敛寻路也是多敌人场景的隐形坑。每个敌人独立请求 Path如果都在同帧重新寻路路径搜索的格子遍历会瞬间打满一两个线程。我的做法是敌人只在到达当前路径终点、或者目标位置发生大变化时才重新寻路。把寻路请求放进一个队列每帧最多处理 3~4 个而不是一次性全算。NavMesh 的运行时分区和半径不要开太大敌人的可行走区域远远比玩家角色少。物理方面的改动看着小但积少成多敌人的 CapsuleComponent 如果不需要接收伤害事件就把Generate Overlap Events关掉需要检测玩家进入警戒范围时用一个大半径的 SphereOverlap 按秒频率轮询而不是每个敌人的 PhysicsBody 持续跟玩家做碰撞交互。另外千万别轻易给敌人开 CCD连续碰撞检测那东西在高并发下能把游戏线程拖垮除非你的子弹或敌人移动快到会隧穿否则没必要。4. 渲染线程与 GPU 侧的取舍Draw Call、剔除与动态阴影4.1 静态合并和实例化把 Draw Call 降下来先看stat scenerendering里面会有网格绘制相关的计数。我当时的场景除了 30 个角色还有一堆刷怪点、掩体、地面杂物。这些静态物如果全是独立 StaticMeshComponent一个物件就是一次或好几次 draw call数量攒到几千很正常。办法是场景里的静态装饰能合并的用 HLOD 批量合并能复用的用 ISMInstanced Static Mesh或 HISMHierarchical Instanced Static Mesh。比如地面石头、木箱、弹药箱这些全部换成实例化网格整个场景的静态 draw call 能从 1800 降到 500 左右。敌人本身是骨骼网格不能实例化但至少别让静态物件再添乱。4.2 距离剔除和 LOD 参数怎么定才不明显穿帮动态物体里单个骨骼网格的 draw call 不一定高但 30 个一起出现在渲染线程里每个还有 4~6 个材质区块渲染线程就吃不消了。这里有两个关键参数剔除距离Cull Distance给每个敌人设置CachedMaxDrawDistance距离玩家超过某个值比如 80 米就完全不渲染。对 FPS 来说这个距离之外的敌人玩家反正看不清硬渲染纯属浪费。LOD给敌人骨骼网格生成 2~3 级 LOD屏幕占比低于一定阈值就用低模。最好把骨骼网格的 LOD 距离调成“近程 0.3、中程 0.15、远 0.06”这样的比例配合材质简化在玩家视角里几乎看不出区别。还有一个容易被忽略的遮挡剔除。默认的遮挡剔除在某些室内/半掩体场景未必生效可以输入r.VisualizeOccludedPrimitives 1来可视化看哪些物体被剔除了。如果很多物体明明被墙挡住还在绘制说明遮挡距离或者查询设置有问题。4.3 动态阴影是性能黑洞怎么治30 个带骨骼动画的敌人如果每个都投动态阴影阴影深度 Pass 要重新绘制全部阴影体这开销比敌人本身的 BasePass 还大。我的处理分三层只看距离投影距离砍到玩家周围 20~25 米超出这个范围的敌人干脆让它们不投射阴影。区分层级最近 8~10 个敌人保留完整动态阴影中距离敌人只投射接触阴影Contact Shadow更远的关闭阴影投射。环境光照烘焙场景里的静态物体使用烘焙光照只保留一个动态方向光管玩家角色和近处敌人。这样阴影的计算量被限制在一个很小的集合里画面观感几乎没有下降。如果你的项目可以接受一定程度的视觉妥协还可以把阴影分辨率整体调低比如把阴影贴图从 2048 降到 1024。对这个玩法来说玩家注意力在准星和敌人身上远处敌人脚下阴影边缘的锯齿根本没人看。5. 动画系统的隐形浪费AnimBP、骨骼采样与动画 LOD5.1 AnimBP 每帧全量求值是最容易忽略的坑说真的很多项目优化到最后剩下的大头就是动画。AnimBP 每帧执行一次里面的 Every Tick 节点、获取速度、计算方向、角度插值、IK 求解都是游戏线程开销。单个敌人无所谓30 个敌人一起跑每个 AnimBP 里哪怕只多一个“每帧计算 MoveDirection”30 倍放大之后都是肉眼可见的耗时。Insights 里看动画时间高时我做的第一件事不是改节点而是砍节点。凡是和最终输出姿势无关的中间计算尽量用 AnimGraph 的状态机替代蓝图节点把复杂计算从每帧改为事件驱动——比如受击、换弹、跳跃这类一次性事件才去计算对应骨骼权重。还要提一个关键词Fast Path。AnimBP 里直接用变量绑定的属性可以走 Fast Path避免每帧调用蓝图虚拟机。做法是把常见的参数速度、朝向、是否在移动放到 AnimBP 的变量里并且让这些变量的计算发生在你的角色或 Manager 中而不是在蓝图里每帧拉取骨骼数据。这个改完以后我的动画线程时间下降最明显。5.2 用动画 LOD 和 Tick 间隔换回帧时间动画更新的调度也是大头。默认情况下 SkeletalMeshComponent 会在可见时每帧更新 Pose。但多敌人场景里很多敌人虽然可见却在玩家视野边缘或者远处。对这类敌人我用两个手段降载VisibilityBasedAnimTickOption把远处敌人的骨骼网格组件设为“仅渲染时更新 Pose”并且对超远敌人再降级为“不更新骨骼只保留最后一次姿势”配合 LOD 切换成低模。组件 TickInterval对中远距离敌人把骨骼网格组件的 TickInterval 调到 0.05~0.1 秒。也就是敌人动画每 50~100ms 才更新一次。对射击游戏里的杂兵来说这种轻微卡肉感反而会让动作更“顿”但玩家通常注意不到远处敌人的流畅度变化。还有个技巧给骨骼网格的每个 LOD 绑定不同的 AnimBP。远景 LOD 用只有“Idle 简单移动”两三个状态的极简 AnimBP近景 LOD 才用完整动画蓝图。这样远处敌人不仅渲染便宜连动画计算都便宜。5.3 程序化动画和“视觉欺骗”策略如果敌人数量还要继续往上堆比如 60~100 个单靠减 LOD 就不够了。此时可以上程序化动画方案远处敌人不播放完整动画序列而是直接用程序驱动的简单朝向和位移配合速度控制的极简姿势。用引擎术语说就是“把动画开销从骨骼动画转到数学计算”——前者是 AnimBP 的每帧求值后者可能只是一句位置插值。我的实际经验是玩家对远处敌人动作的第一印象来自轮廓和速度而不是手指骨骼角度。只要朝向、移动方向正确、枪口方向和视线一致绝大多数游戏性问题都不存在。视觉欺骗不是偷懒是用预算换核心体验。6. 优化前后的实测数据帧率、1% Low 和内存6.1 三个阶段的性能对比下面是我这个 30 敌人场景在同一个测试关卡里的数据测试配置是 i7-10700K RTX 30601080pEpic 画质档指标基线第一阶段AI 降载第二阶段渲染降载第三阶段动画降载平均帧率26 FPS47 FPS60 FPS68 FPS1% Low9 FPS21 FPS39 FPS45 FPSGame Thread22 ms12 ms11 ms8 msRender Thread9 ms8 ms5 ms5 msGPU7 ms7 ms5 ms5 ms静态 Draw Call18001800500500动态 Draw Call640640380320三个阶段加起来平均帧率从 26 涨到 681% Low 从 9 涨到 45。其中第一阶段 AI 降载是最划算的改动最小、收益最大第二阶段渲染降载收益其次第三阶段动画降载属于“再抠一点、精益求精”的部分。内存上也有改善关闭了大量敌人不必要的 Tick、感知、动画更新之后运行时对象数量和 GPU 内存占用都有下降具体数值没有逐项记录但游戏从“偶尔卡顿”变成了“稳定能玩”。6.2 1% Low 比平均帧更能体现流畅度平均帧率 68 FPS 听起来不错但如果 1% Low 只有 20 FPS玩家体感依然是卡顿——因为帧生成时间不稳定在激烈战斗时频繁掉帧。这就是为什么优化不能只看平均帧也要看 1% Low 和 0.1% Low。1% Low 提高的本质是“尖峰的削平”。我的第一阶段改动只把平均帧从 26 提到 47但 1% Low 从 9 提到 21体感提升比平均帧更明显。因为 AI 降载消除的是“30 个敌人同时决策”的瞬发尖峰而这正是掉帧体感的来源。优化时如果只盯着平均帧很难理解为什么“改了 AI 之后游戏感觉流畅了很多”。6.3 低配机和移动端的差异化配置优化完主机/PC 后我还给低配场景做了一套 Scale 设置。UE 的 Scalability 系统里sg.*系列命令可以分别控制分辨率、阴影、后期、纹理质量。我给低配档的配置是sg.ResolutionQuality 75 sg.ShadowQuality 1 sg.PostProcessQuality 2 r.Shadow.Virtual.Enable 0 r.DynamicRes.OperationMode 2移动端和低配 PC 的共同点是 GPU 更弱但 CPU 往往更弱所以 AI 和动画的降载方案必须作为底层默认而不是“性能不够时才开”。这也是我把 AI 降载做成 Manager 默认逻辑的原因——它是从架构上保证 60 个敌人也能跑的底线。7. 把这些优化固化下来性能预算与回归测试7.1 给每个玩法场景建立性能预算表优化完成之后最怕的是下周别人加了一个“敌人受伤冒血花”的功能又把性能拖回去了。所以我把这次经验沉淀成一张性能预算表写进项目文档项目预算备注AI 逻辑2 ms包含行为树、感知、决策动画更新3 ms包含全部可见敌人的 Pose 更新物理与寻路1 ms包含射线、碰撞、路径请求渲染线程4 ms包含剔除、绘制指令收集GPU4 ms包含阴影、BasePass、后期有了预算表新增玩法就必须回答“这笔开销花在哪、从哪个预算里扣”。我是用这种方式拦住很多“看起来没多大事”的功能。7.2 把性能数据写进自动化测试UE 的 Unreal Insights 和功能测试可以结合起来做性能回归。简单做法是打包一个自动化 PIE 脚本让测试场景跑固定步骤进入关卡、放 30 个敌人、触发一波攻击。跑完以后自动抓stat unit和 1% Low写进日志跟上次的基线对比。超过阈值就直接标红。哪怕没有完整的自动化框架至少也应该做到“每个版本发布前跑一次性能场景”的流程。多敌人场景最怕的不是当时卡而是后来某天某个改动悄悄把 AI 或者动画开销带回来等玩家发现已经晚了。7.3 最容易复发的问题和团队协作建议最后说几个我在项目里实际遇到过、后来反复中招的点新增功能性节点时无意识放大成本比如敌人血条。如果每个敌人头顶都挂一个 Widget 组件30 个敌人就是 30 个 UI draw call 和 30 次每帧更新。血条要只对被准星瞄准的敌人显示或者放到屏幕边缘指示器里。不要为了让“看起来聪明”引入重计算敌人 AI 不需要每帧思考“我该往左绕还是往右绕”一个 1 秒决策的 BT 足够自然。不少开发者喜欢给 AI 加一堆随机噪声、探测、预判这些在单敌人都没事多敌人就是灾难。阴影和剔除参数被全局覆盖团队里如果有人改了r.Shadow.CSM.MaxCascades或者全局 LOD 距离可能突然把之前的优化全部打回原形。建议把这些参数写进项目的 DefaultEngine.ini 按平台覆盖而不是靠每个人手动调。别把“关掉动画更新”做成无差别开关如果直接关闭所有远距离敌人的动画更新玩家用狙击镜看过去会发现敌人集体“冰冻”非常出戏。正确做法是被玩家视野注视的敌人立刻提升到完整动画 LOD其余继续降载。狙击镜的视野范围本质上是一次额外的优先级判断成本很低但体验差别巨大。我的体会是多敌人场景的优化不是某一个“大招”能解决的。最有效的组合往往是AI 状态机把大多数敌人按进休眠、渲染端用距离和 LOD 把远处开销削平、动画端用代价更小的更新策略换取体验。先把这三层做对项目到 60 人乃至上百人的规模才不至于重新推翻重做。这个案例里最大的收获不是帧率数字本身而是掌握了“先看瓶颈、再用数据驱动决策”的完整流程。