做FPS性能优化尤其是多敌人同屏这种场景我一般会先定一个调子先把帧时间预算画出来再谈别的。UE里敌人一多就掉帧原因往往不是单一模块而是GameThread、渲染管线、内存分配这几条线同时被压垮。我最近复盘了一个以20个AI敌人同屏为目标的射击Demo优化过程从最初的58fps平均帧、1% low掉到42fps一路优化到81fps平均帧、1% low稳定64fps中间踩了不少坑也沉淀了一套可复用的方法论。这篇内容主要面向UE开发者、技术策划和想做帧数优化的TA同学也适合那些刚接触Unreal Insights、对性能调优没有完整思路的新手。我尽量把每一步的思路、具体命令、参数设置和取舍讲透不搞那种优化一下就好了的玄学而是落到可执行的操作上。你可以把它当成一份多敌人场景FPS优化的实战手册来读。1. 开局先看瓶颈多敌人场景到底卡在哪FPS项目里敌人一多就掉帧几乎每个做UE的人都会遇到。我做过一个偏巷战玩法的射击Demo关卡里同时存在20个AI敌人建筑密集、交火频繁角色、载具、破坏物全挤在一个半封闭街区里。测试机是一台两年前的中端Windows笔记本实测平均帧勉强能看但最低帧经常掉到40上下敌我双方一交火画面就像被什么东西拽住一样体感非常糟糕。我刚接手时第一反应是敌人太多渲染扛不住但拿数据一测就发现想错了。用Unreal Insights跑了一遍GameThread的平均帧时间竟然高达14msGPU只有10ms明摆着CPU在拖后腿。多敌人场景的卡顿根源往往不在渲染而在游戏线程的逻辑计算每个敌人都要跑AI行为树、感知更新、动画求值、物理模拟这些全部串行在GameThread上。敌人数量从5个涨到20个光行为树和感知的Tick开销就可能翻三四倍。所以做优化前先分清两种卡顿。一种是平均帧掉说明整体负载超标CPU或GPU长期过载另一种是瞬卡也就是常说的1% low帧低原因是某个瞬时峰值一波敌人同时刷新、开火粒子特效爆发、GC垃圾回收、或者资源流送卡顿。多敌人场景这两种问题都会出现而且常常交织排查手法完全不同。平均帧低靠统计CPU耗时逐模块优化瞬卡则要看帧时间曲线的尖峰很多人只盯平均帧把瞬卡问题当成了负载问题怎么调都压不住。另外一个容易踩的坑是Editor里看到的数据不代表打包后的表现。Editor本身有大量调试开销AI感知、动画蓝图在PIE模式下的耗时和独立进程能差出一倍。我在优化时一律用Development配置的独立进程跑测试有条件就打Shipping包保证数据可信。1.1 两类卡顿的不同根源先解释一下我提到的两个指标。平均帧时间看的是整体负载60帧目标下一帧预算是16.6ms所有模块加起来超过这个值平均帧就会掉。1% low帧是统计所有帧中最慢的1%数据的平均帧时间代表最糟糕的体验你平时觉得有点卡基本都是它决定的。多敌人场景里两者都要盯优化手段也完全不同。平均帧掉的最常见原因是GameThread过载。行为树节点的Tick、感知系统的周期性更新、动画蓝图求值、物理资产同步这些在敌人数量增加时基本都是线性增长。尤其是动画系统一个敌人身上挂一个复杂AnimBP20个敌人同时跑单动画求值就可能吃掉6到8毫秒。在16.6ms的预算里这已经是非常奢侈的开销。所以我做优化时第一个查的往往是Animation而不是AI。瞬卡则更隐蔽。敌人在某一帧统一生成会触发大量资产的同步加载初始化玩家和所有敌人同时开火粒子特效、贴花、物理碎片都在同一帧创建GPU峰值随即翻倍再叠加一次GC回收帧时间可能瞬间冲到30ms以上。这类问题用Insights看每一个尖峰对应的调用栈比较容易定位到具体事件然后用对象池、加载预判、特效数量限制等办法逐个排掉。1.2 三个容易踩的业务设计坑第一个坑是AI逻辑里塞了太多实时计算。比如行为树Root节点做复杂的条件判断每帧执行比如敌人之间互相做可见性判断O(n^2)级别的遍历。这些设计在敌人少的时候完全感受不到但数量一上来立刻爆炸。我见过一个项目敌人数翻倍后帧率掉了一半查到最后是行为树装饰器里每帧做距离检测和朝向判断里面还夹带了字符串比较和动态数组的Find操作属于典型的看不见的线性开销。第二个坑是动画资产没有做降级处理。美术小伙伴习惯给所有敌人套非常完整的材质球和骨骼层级看起来一个敌人面数不高但材质复杂度很高半透明分层叠加、动态mask、世界位置偏移全用上。20个敌人站在一起时隐藏的poly count其实不高但材质指令的峰值会让人怀疑人生。做角色时提前规划LOD材质和事后补救完全是两种成本。第三个坑是忽略敌人之间的交互热点。比如多个敌人同时开火时所有弹道特效、枪口闪光、受击粒子同帧触发GPU峰值直接翻倍。这种峰值和平均负载不是一个问题靠简单的LOD或者降分辨率不一定有效往往需要做同屏特效数量限制、特效池化、以及开火事件的错帧处理。优化不是只调一两个参数而要对多个维度做系统性梳理。2. 用工具说话定位性能瓶颈的标准打法在改任何代码之前先建立数据基线。UE自带的Unreal Insights是我做多敌人优化的标配工具配合stat系列控制台命令可以快速定位绝大部分瓶颈。没有数据支撑就去改代码调参数本质上是在赌。2.1 Unreal Insights 的正确打开方式Unreal Insights可以记录游戏运行时的定时器、CPU调用栈、内存分配、网络流量等信息最常用的视图是Timing Insights。它能展示整个帧内每个线程的时间线GameThread、RenderThread、RHI线程、工作线程各自耗时是多少一眼看清谁在拖后腿。多敌人场景里我通常会在Insights里看GameThread的调用栈按耗时排序找到最大的那个模块。使用上比较省事的方式是先给项目加分析通道打包时在BuildConfiguration里启用Trace支持或者运行时执行-tracedefault,cpu,gpu,memory命令行参数。跑一段固定流程后会生成 .utrace 文件拖进UnrealInsights就能分析。平时在Editor里快速调试也可以直接按 CtrlShiftI 打开Insights窗口但它记录的是Editor进程数据只能当参考。我强烈建议项目里维护一套可重复的性能测试流程固定关卡、固定角色路径、固定战斗事件序列跑30秒生成一次记录。每次改动后重跑一遍和基线对比帧时间曲线。这个基线思维非常重要没有它你根本无法判断某个改动是变好了还是变坏了尤其是帧率波动本来就大的战斗场景。2.2 stat系列命令游戏线程永远的第一嫌疑人Insights适合做深度分析日常快速定位则用stat系列命令。以下是我在Console面板里敲得最多的几条stat unit显示帧时间分解包括Frame、Game、Draw、GPU。看到Game数值高基本是GameThread在背锅。stat game进一步细分游戏线程的开销能看出行为树、动画、AI感知等大类。stat animation单独看动画求值时间多敌人场景里这个数字往往非常感人建议必敲。stat ai查看AI逻辑相关开销行为树和感知分开统计。stat RHI看Draw Call数量高不高一目了然。stat GPUGPU端渲染开销概览能看出光照、阴影、后处理各自占比。stat memory内存统计排查GC和资产驻留问题。我的习惯是先开stat unit看总账然后按耗时最大的项继续下钻。比如发现Game很高敲stat game看到Animation占比最大再敲stat animation具体看是哪个动画节点在烧CPU。这个逐层下钻的思路比一开始就开Insights看整个调用栈来得快适合日常快速验证。2.3 GPU侧的性能指标与判断GPU侧的判断比CPU侧麻烦一点因为stat unit里的GPU数值高并不一定代表显卡真正满载。有时候是CPU在等GPU你看到的是伪GPU瓶颈有时候是GPU确实吃满了但CPU也在空转等待。我的判断经验是看Frame时间和GPU时间的关系如果Frame大于GPU说明CPU一侧是主要瓶颈如果GPU接近16.6ms且大于Frame说明GPU确实过载。多敌人场景里GPU侧的常见高负载包括动态光源的阴影深度渲染、半透明粒子叠加、复杂的屏幕后处理如全局光照、体积雾、以及非Nanite模型的高模渲染。排查时可以用 r.VisualizeOccludedPrimitives 这类可视化命令看剔除效果或者逐个关闭后处理项来对比帧时间变化。记住一个原则先确认再改动每次改动都用同一份录制脚本对比数据。另外需要明确一点Editor里的GPU数据也不可信。编辑器窗口、视口覆盖、材质编辑器预览都会增加额外开销。我在验证GPU改动时只用Standalone模式或打包程序并且把画质通过命令行固定住避免设置变化造成的数据干扰。3. CPU侧的硬核优化AI逻辑与动画系统确认瓶颈在CPU侧之后真正的优化工作才开始。多敌人场景的CPU优化我认为核心是三个系统AI、动画、业务Tick。把这三块处理干净绝大多数同屏压力都能缓解。3.1 行为树与感知系统的降频策略行为树是AI逻辑的核心载体但也是性能杀手之一。每个行为树节点在Tick时都在消耗GameThread时间。敌人多时首要是降频。我的习惯是给AIController设置一个合理的TickInterval一般0.1到0.2秒也就是每秒5到10次的低频逻辑更新。敌人AI本来就不需要每帧都感知降频后体感差异很小CPU收益却很可观。感知系统同样要控制频率。UE自带的AIPerception组件默认每0.25秒更新一次感知结果如果项目里把这个值调得很小比如0.05秒那么20个敌人每帧都在做感知计算这绝对是大坑。感知系统的另一个关键参数是Stimuli的MaxAge设得过长会导致周围的刺激点永远不消失感知系统一直维持高占用。这两个参数最好在项目规划时就考虑好不要等到性能出问题再来调。行为树本身也不要设计得太深。每帧求值一棵深度10的树和一棵深度4的树开销差距是实打实的。很多策划习惯用装饰器做大量条件判断这些装饰器往往每帧都会执行。我的建议是能用任务节点缓存的逻辑就不要装饰器每帧判断能用感知结果判断的就不要实时检索。这个思路说起来简单做起来需要策划和程序一起去改树结构但只要做了收益非常明显。3.2 动画系统骨骼网格体与URO动画系统是另一个大头而且经常被忽略。多敌人场景下每个角色的动画蓝图都在GameThread上运行AnimGraph求值。如果不做任何优化一个稍复杂的AnimBP可能吃掉0.3到0.5毫秒20个敌人就是6到10毫秒。所以动画优化的优先级非常高。第一个手段是UROUpdate Rate Optimizations。这是UE内置的骨骼网格体优化选项可以降低骨骼网格体的更新频率并让动画实例保持在较低的更新率。我在项目中通常把近处敌人设为1每次骨骼更新都更新远处敌人设为1/2、1/3甚至更低。配合LOD使用效果更明显。用这个方法后动画求值从6.8ms降到2.3ms可以说是CPU优化里性价比最高的一步。第二个手段是动画蓝图的精简。AnimBP里挂着一堆状态机、混合逻辑、IK、同步节点这些在无用的时刻可能仍然在求值。我见过不少项目的AnimBP里有复杂的LayeredBlend但在大多数状态下根本不输出。更好的做法是用Cache节点缓存计算结果把复杂的层级混合只在关键帧触发时求值。另外建议在低LOD状态下直接走简单的AnimBP甚至关闭AnimBP求值用帧率换细节在敌人身上完全可接受。还有一个经验骨骼网络的同步也需要注意。如果项目里服务器只关心玩家角色的动画同步那AI角色的动画可以不同步给客户端用本地预测即可。这样既节省带宽也减轻了CPU的骨骼复制成本。多人在线项目尤其有用。3.3 从业务层面砍掉无效TickAI和动画优化完之后还要检查游戏里每个Actor的Tick。每个Actor只要有Tick每帧都占开销尤其是那些每帧只为了刷新一个UI角落、一个定时器状态的Actor数量少还好多了就是隐性浪费。FPS场景里敌人身上经常挂各种组件比如血量显示、受击闪光、状态机这些组件默认都在Tick。我在项目中会分几个层次处理能用TimerManager解决的不要用Tick。比如血量回复、技能CD用SetTimer按需触发比每帧判断好得多。必须Tick的尽量设置TickInterval。比如某些状态链0.2秒更新一次完全够没必要每帧刷新。Tick里避免做寻路查找、路径计算这类重型操作。寻路本身可以异步执行或者用更粗的网格做路径规划避免在GameThread上卡住。另外一个隐藏开销点是网络复制。如果项目使用UE默认的Replication每帧都会检查属性变化并广播敌人数量多了之后很费。我在优化时会把AI的选路结果、攻击状态这些用RPC或者属性复制控制好频率避免不必要的同步。很多团队做性能优化只盯着渲染漏了网络复制的性能消耗其实它一样会把GameThread拉满。4. 渲染与资源侧优化让GPU稳压在16.6ms内CPU侧优化告一段落后渲染侧同样重要。多敌人场景里如果敌人使用的都是高质量角色资产即使逻辑再合理GPU负载也会很紧张。这部分的优化思路和CPU侧不同需要更多考虑场景级的设计。4.1 LOD与Nanite模型面数不是越少越好先打破一个误区并非LOD越多越好。LOD切换的判定本身需要消耗CPU来做距离计算层级再多也不一定都派上用场。我的经验是至少给主要敌人骨架做2到3级LOD。近战敌人因为频繁贴近镜头LOD0保持足够细节远处敌人用LOD2面数和材质复杂度都降下来。注意LOD的切换距离要测试实际游戏视角来定不要拍脑袋。如果项目用的是UE5可以优先考虑Nanite。Nanite可以自动处理模型层级面数再大也能在合理范围内渲染。但Nanite有一个限制它不支持所有材质特性特别是需要WorldPositionOffset做动画的模型会落到传统渲染路径。所以我的结论是视觉上需要复杂顶点动画或半透明效果的用传统LOD偏静态结构的能上Nanite就上Nanite。敌人角色如果比较偏静态底座做一半Nanite一半LOD也不是不行。材质降级也是渲染优化的关键一环。同一个模型如果材质里有多个贴图混合、复杂的法线计算方法可以在低LOD时切换到简化的材质包。我常用 MaterialQualityLevel 做LOD材质切换在Scalability里把材质质量设为Medium或Low降低贴图采样和指令复杂度。这个操作对GPU时间的影响有时候比单纯降面数还要大。4.2 遮挡剔除把看不见的敌人先毙掉多敌人场景里很多敌人可能位于遮挡物背后比如箱子、墙体、斜坡。如果不做剔除这些不可见的角色依然在走完整的渲染管线。UE默认使用视锥剔除只能排除屏幕外的对象屏幕内被遮挡的物体依然会浪费渲染资源。遮挡剔除的配置就更讲究了。UE的遮挡剔除依赖遮挡体Occluder的配置。建议在关卡里给主要的大型遮蔽物比如大箱车、建筑墙体打上Occluder标签同时检查Actor的Visibility设置。多敌人巷战这种场景大量敌人其实藏在墙体后面适当配置遮挡体能明显降低可见集合的规模。另一个细节是敌人骨骼网格体带大量可活动破坏部分时遮挡体的工作量大很多这时可以考虑额外做一层简化碰撞作为遮挡体。另一个实用功能是预计算遮挡剔除Precomputed Visibility在静态场景里非常有效但要注意它会引入些许加载时间。我一般是静态场景用预计算动态场景用运行时遮挡体搭配使用效果最理想。查看剔除效果可以用 r.VisualizeOccludedPrimitives 可视化命令确认哪些物体被遮挡了避免出现该遮挡的没遮挡、不该遮挡的被遮挡这种坑。4.3 减少Draw Call的工程手段Draw Call高是多敌人场景的常客。每个敌人模型由多个组件构成加上武器、配件、装甲组件可能一个敌人产生10个以上的Draw Call。20个敌人就是200个Draw Call再加上场景里其他对象很容易撞上RHI瓶颈。解决思路通常是合并与实例化。合并静态资产的常见做法是使用HLODHierarchical LOD把多个小的静态网格体合并成一个资产。对动态角色可以尽量复用同一个SkinnedMesh配合材质实例化的参数调整减少材质切换的Draw Call。实例化静态网格体ISM很适合大量相同对象比如箱子、弹药、植被可以大幅减少Draw Call。不过敌人是骨骼网格体一般不直接进ISM通常是靠合并网格或分LOD遮丑来处理。另一个优化点是减少材质数量。同一个敌人身上如果头部一个材质通道、身体一个、武器一个总共三个材质那它至少会产生3个Draw Call。把一些不必要拆分的材质合并成一个用纹理阵列或顶点色控制差异可以把Draw Call压下来。建议在做角色规划时就确定材质通道数量不要等美术全做完了再来合材质成本很高。还有Post Process的影响。过多的后处理缓冲切换也会增加Draw Call和带宽压力。在低端设备上关掉不必要的Bloom、降低SSR质量对帧时间的改善往往非常明显。做这类调整时一定要用GPU Profiler验证而不是凭感觉。多敌人场景里大量交火时的光照爆炸效果很容易把后处理压力顶上去这类瞬时过载还需要另外做特效预算控制。5. 实战复盘一个20人同屏场景的优化案例理论讲得再多不如一个完整案例有说服力。下面是我近期做过的一个小巷战Demo的优化记录目标平台是Windows的常规中端笔记本开发环境为UE 5.3。所有数据都来自Development独立进程同一段30秒战斗录制1080p分辨率。5.1 初始数据与优化目标测试场景是一条长约200米的小巷建筑密集两侧有大量箱子和杂物。关卡里同时存在的敌人最高20个另外还有少量可破坏物和动态光源。测试条件固定为Epic画质、关闭垂直同步。初始数据相当不乐观平均帧58fps1% low帧只有42fpsGPU帧时间约10ms但GameThread帧时间平均达到14ms明显是CPU拖了后腿。更细的数据分布动画求值占6.8msAI逻辑占2.6ms其他组件Tick占2.1ms物理占1.2ms。这个分布非常典型动画是最大的CPU消耗其次是AI逻辑。我定的目标是平均帧稳定到至少70fps以上1% low提升到60fps以上GameThread帧时间压到10ms附近并保持画质主观体验基本不变。这里有一个取舍点完全保持Epic画质还要压那么多逻辑必须靠大范围的架构调整而不是局部调参。5.2 分阶段优化的过程记录第一阶段先处理动画。我给所有敌人开启了URO近战敌人设更新率1中距离设为1/2远处设为1/3。同时精简了AnimBP去掉战斗状态下用不到的复杂混合IK只限制在近距离敌人上。这个阶段完成后动画求值从6.8ms降到2.3msGameThread降到9.6ms左右平均帧上升到67fps1% low大约是52fps。这一阶段的效果最明显也最省力。第二阶段处理AI逻辑与感知。我把AIController的TickInterval设为0.15秒感知更新间隔调到0.3秒行为树的装饰器里明显减少了每帧判断的距离运算改为缓存值。AI逻辑从2.6ms降到1.1ms。同时把物理精度降低碰撞资产的检测层级简化物理部分从1.2ms降到0.6ms。到这里GameThread总耗时大约7.8ms目标基本达成。第三阶段处理渲染侧。我给所有重要遮挡物补充了Occluder配置开启Precomputed Visibility把远处敌人的材质降到低质量等级。同屏Draw Call从3400左右降到1800左右GPU帧时间从10ms降到8ms。平均帧继续上升到81fps1% low也提升到64fps。这个结果说明CPU侧降到瓶颈以下之后渲染侧的优化才能真正体现出收益CPU不降下来直接动渲染效果会打折扣。5.3 最终数据与取舍思考最终数据平均帧81fps1% low帧64fpsGameThread平均7.2msGPU平均8.1ms整体已经达到预设目标。最直观的体感是交火瞬间不再有粘滞感粒子特效峰值出现时帧时间也能稳住不会再像以前一样直接宕到40fps以下。优化到这里平均帧和1% low都在安全线内体验问题基本解除。当然优化是有代价的。感知更新频率降低后个别敌人对玩家位置的瞬时反应会慢半拍打起来需要额外调整敌方感知的转角阈值。URO开启后远处敌人的动画会出现轻微的跳变感这需要美术和程序一起把控。做性能优化很多时候不完全是技术活还是与表现体验之间的平衡活。你要清楚每个参数调到什么程度既满足帧率目标又不破坏玩法核心这才是合格的优化。6. 常见问题排查与避坑实录最后分享一些实际调试中常见的误区和技巧。这部分内容比较零散但都是我踩过的坑里总结出来的值得认真看一遍。6.1 问题定位中的误区第一个误区是只看平均帧不看帧时间分布。有些项目平均帧70fps但1% low帧只有35fps视觉上会感觉卡顿严重。我的习惯是记录每帧的帧时间曲线凡是峰值超过25ms的帧都要追查原因。这类峰值往往来自GC、资源流送、AI事件爆发等瞬时开销处理和持续高负载不是一回事你用持续负载的优化手段去解峰值问题基本无解。第二个误区是Editor里调试数据当正式结果。Editor窗口渲染和逻辑都有额外开销表现不一定准。用PIE测性能时我常先把Use Less CPU in Background这类选项关掉尽量模拟独立进程的运行状态。更严谨的做法是直接打包Standalone跑用命令行参数固定画质设置。很多新手在Editor里看到一堆耗时以为要改一堆东西结果打包出去问题根本不在那里。第三个误区是只优化单个敌人。你要想的是同屏N个敌人叠加在一起的总体情况。单个敌人可能只占0.1ms20个就是2ms。不少开发者在单机单怪测试时觉得优化得很好了一进大规模战斗又卡就是因为没有做整体评估。所以在做优化验证时一定要用完整战斗场景别图省事只测一个敌人。6.2 性能优化的工程化管理我给项目做过一套性能预算表把每类系统在16.6ms内占用的配额写死比如动画不超过4ms、AI不超过2ms、物理不超过1ms、渲染不超过7ms。每轮改动后对照预算调整而不是等性能崩了再救火。这样做最大的好处是团队里每个人都清楚性能是资产写新功能前先问自己会不会超预算。预算表要放在团队文档里随时可见而不是只在程序脑子里。GC管理也不能忽略。多敌人场景里频繁生成和销毁Actor、特效、音效会造成大量内存垃圾。我习惯提前做对象池在关卡启动时预生成一批敌人实例和特效实例战斗时激活、战斗结束回收。这样GC压力小很多也减少了反复加载资源的卡顿。对象池的实现在UE里不算难收益却非常直观尤其是大量敌人死亡和复活的玩法。还有批次编译的技巧。UE的Shader编译是打包时间的重灾区和运行时性能关系不大但和开发效率有关系。建议把常用材质和Shader提前编译好或使用共享的Shader库避免每次Build都重新编译一遍。这只是工程上的效率优化不影响运行时表现但对团队迭代速度帮助很大。6.3 我的几点体感与建议做性能优化不是用优化指令堆出来的而是要形成一套系统性的工作流先有可重复的性能基线再有能定位到具体模块的工具最后才是各种优化手段的组合拳。很多团队会跳过前两步直接进第三步结果就是改很多、提升有限。我自己最初也犯过这个毛病后来养成先记录再改动的习惯效率才真正提上来。我还非常推荐把性能测试自动化。用命令行启动打包好的测试关卡让它自动跑战斗逻辑输出性能日志。每次提交代码前跑一遍看帧时间曲线有没有异常。这套自动化可以减少很多只有老板在测试机上发现卡顿的尴尬情况。自动化脚本用Python或者UE自带的会话前端都能写关键是坚持跑。最后一个小技巧性能优化的时候多留意target frame time的余量。目标60fps时16.6ms是红线但尽量留出2到4ms的余量。UI系统、网络事件、加载抖动这些隐藏开销都会在关键时刻冒出来。别把预算卡得太死给未来内容留一点空间这是我在多个项目里总结出来的经验。毕竟性能优化不是一次性工作而是随着版本迭代不断调整的过程。