多敌人场景,在UE里做FPS性能优化,大概是绕不开的一块硬骨头。我说的不是仓库里三五个AI站桩对射,而是二十几个AI同时在战区内交火、掩体穿插、扔雷、换弹、倒地、受击反馈,外加各种弹道特效和音效的那类大场面。帧数是以肉眼可见的速度往下掉的,从满帧60掉到35并不夸张,更折磨人的是掉帧节奏不规律,一会儿顿一下,一会儿又顿一下,看久了连操作手感都跟着变质。
这种项目的优化,绝大多数情况下不是某一个点出了问题,而是CPU、GPU、内存和资源流送四个方向同时被堆高负载。以我做过的一次多人交火FPS项目为例,目标是在1080p下稳定60帧,并且尽量减少帧率抖动,我把整个优化过程拆成了五块:瓶颈定位、CPU侧优化、GPU侧渲染优化、内存与资源流送优化、以及性能工具复盘。这篇就按这个顺序,把多敌人场景里最耗性能的点、能落地的优化手段和踩过的坑,完整拉一遍。
1. 多敌人FPS的性能瓶颈先定位:CPU、GPU还是内存
1.1 别再凭感觉优化,先把帧时间拆开看
很多项目的优化工作是从“我觉得某个功能比较卡”开始的,这种思路在多敌人场景里基本走不通。因为大量敌人同时存在时,卡顿来源会叠加。正确做法是先看每一帧的时间消耗到底在哪个线程。
UE的帧时间大致分四块:GameThread(游戏线程)、RenderThread(渲染线程)、RHIThread(底层绘制提交)以及GPU。可以简单理解成后厨:游戏线程是厨师负责炒菜,渲染线程是传菜员负责把菜端到窗口,RHI线程是窗口服务员把菜送到餐桌,GPU则是餐桌那一侧的真正消化环节。任何一环积压,整帧时间都会被拉长,而且拖着拖着下一帧也会被堵住,最终表现为帧数下降和卡顿。
我常用的开局操作是在控制台执行这几个指令:
stat unit:查看整体帧时间、Game/Render/GPU耗时,优化时的第一参考点stat game:看游戏线程内部细分项,比如Actor Tick、寻路、动画更新等stat scenerendering:看渲染线程的绘制相关耗时stat rhi:看底层绘制提交和资源绑定耗时ProfileGPU:在帧内逐项分析GPU消耗
有一次我开了一个据点战测试关卡,30个AI同时激活,stat unit显示GameThread耗时大约14ms,RenderThread大约9ms,GPU大约8ms。明确瓶颈在游戏线程,那么接下来动渲染、调材质,效果都不会好。多敌人场景里,绝大多数CPU瓶颈,其实都出在AI和动画这两件事上。
1.2 多敌人场景为什么特别吃CPU和GPU
多敌人场景的复杂度和敌人数量并不是线性关系,而是近似指数级的压力增长。每个敌人身上都挂着大量需要逐帧更新的东西:感知组件要扫描玩家和其他AI,行为树要评估各种决策,寻路系统要请求路径,动画蓝图要采样骨骼姿态,音效要播放脚步和枪声,伤害触发器和物理胶囊体也要参与碰撞检测。
渲染侧同理。一个敌人角色如果模型面数偏高、材质通道又多、还带动态阴影和半透明特效,十来个AI往屏幕里一站,绘制开销会直接碾压场景里的静态物。敌人规模一旦上到20个,静态场景即使提前烘焙好光照,整个帧时间还是会翻倍。
我处理这类问题的总体思路,是给每个敌人做“功能分级”。距离玩家近、正在战斗的敌人,保留完整的AI和动画质量。距离稍远的,降动画更新率、简化感知频率。距离很远的或者非战斗状态的,直接休眠或切换到低模模式。把“所有敌人无差别满功能运行”改成“按距离和状态动态降级”,多敌人才有继续往大做的可能。
2. CPU侧优化:AI和动画开销要控住
2.1 AI更新别把Tick当成万能开关
大量敌人同时运行行为树,CPU最直接的负载来源就是Tick。默认情况下Actor每帧都会被Tick,一个敌人身上的SceneComponent如果再开几个Tick,那就是好几倍的浪费。我在项目里做了一套极为简单的距离调度策略:
| 距离玩家范围 | 战斗状态 | AI更新频率 | 行为树更新 | 备注 |
|---|---|---|---|---|
| 0-15米 | 交战中 | 每帧 | 每帧 | 近距离必须完整反应 |
| 15-30米 | 可视或警戒 | 每2帧 | 每2帧 | 适当降低感知采样 |
| 30-60米 | 空闲/巡逻 | 每4-5帧 | 每4-5帧 | 只保留基本导航 |
| 60米以上 | 非战斗 | 不更新 | 进入休眠 | 禁止无意义Tick |
实现上不要直接改SetActorTickEnabled这种粗暴方案,因为多敌人时每帧去判断开和关也耗CPU。我会把敌人按IDhash分到不同帧去处理,保证每帧只有一部分AI在更新感知。比如30个敌人,每帧只有6个在做完整感知扫描,其他敌人按自己的时间片轮询。这样既不漏掉敌人行为,又能把CPU峰值削平。
另外,感知组件本身也要调。敌人数量一多,感知范围可以适当缩小,感知扫描间隔可以调大到0.1秒到0.2秒。玩家已经在背后开枪了,远处一个敌人还在每秒60次扫描周围,这纯属浪费。
2.2 动画更新是隐藏的大户,URO和骨骼LOD必须开
多敌人FPS里消耗CPU最大的往往不是AI逻辑,而是动画更新。每帧都要做骨骼采样、IK求解、动画蓝图状态机求值,还要根据动画序列输出骨骼变换。30个敌人的角色模型,动画更新开销能占到GameThread的一半以上。
这里有两个必备手段。
第一个是URO,也就是Update Rate Optimization。它允许你按距离降低骨骼网格体动画更新的频率,不是每帧刷新骨骼姿态,而是隔几帧刷新一次并做插值。UE引擎里有现成的方案,你可以在动画蓝图里启用Update Rate Optimization,或者针对骨骼网格体组件设置UpdateClothLOD、UpdateRateOptimizations相关参数。一个距离十米以上的敌人,动画更新频率从60Hz降到15Hz,视觉上几乎看不出区别,但CPU占用会明显下降。
第二个是骨骼网格体LOD。同屏敌人多的时候,不能所有人共用一套3万面高模加全套骨骼。我给敌人模型做了三档LOD,近战档保持完整骨骼和高质量材质,中距离用低密度骨骼,远距离直接切换到只有上半身骨骼细节的简化模型。配合UE的AutoLOD生成功能,可以先把单角色的三角形数量压下去,再在模型资产里设好每个LOD的切换距离。
动画蓝图本身也要做减法。多敌人用的动画蓝图,状态机里最好不要塞太多复杂计算逻辑,比如每帧做大量vector计算、射线检测、物理查询。把能缓存的值全部缓存起来,不要在AnimGraph里随机查寻路结果。动画蒙太奇里嵌套的Notify过多,也要精简。
2.3 碰撞、寻路和物理开销别一起引爆
AI数量多起来之后,碰撞、寻路和物理这三个系统最容易变成新的CPU黑洞。先说碰撞检测。每个胶囊体与其他物体的碰撞检测是不可避免的,但你可以让LOD低的敌人用简化碰撞体,比如距离远时关闭角色网格的碰撞响应,只保留胶囊体的基础阻挡。特别要避免的是每帧对大量AI做射线检测,一次射击命中检测遍历十几个敌人没问题,但每个AI的感知系统每帧都去做大量射线追踪,叠起来就非常可怕。
寻路这块也常出大问题。多敌人同时请求网格路径,尤其是动态障碍物更新的时候,NavMesh需要重新计算,直接卡掉几十毫秒不是开玩笑。我的做法是给寻路请求做分帧排队。战斗区块内的敌人,每帧只允许少数AI发起寻路请求,其他AI沿用上一帧已经计算好的路径。预计算路径缓存也要做,同一个据点里的巡逻路线,完全可以共用同一组寻路结果,没必要为每个独立的AI计算完全一致的路径。
物理方面,除非敌人角色必须死亡后掉落物理碎片,否则就不要把整个骨骼网格体设为Simulate Physics。多敌人场景下开启全身体物理模拟,基本等于让CPU当场罢工。普通敌人死亡直接播动画,只对少数特殊单位启用物理模拟。
另外提醒一句,多敌人FPS的AI感知里,EQS查询一定不要每帧跑,而且如果场景里有大量敌人同时做EnvironmentQuery,最好限流。查询频率设置成200到300毫秒一次已经足够玩家感知。
2.4 蓝图里到处ForEach,慢到离谱
写蓝图的时候很容易犯一个毛病:在某个Actor的Tick里放一个GetAllActorsOfClass或者For Each Loop,作用是把周围所有敌人循环处理一遍。单看逻辑问题不大,但30个敌人叠起来,就是每帧30次全场景敌人扫描,时间复杂度直接变成O(N²),怎么优化底层都救不回来。
正确思路是事件驱动加缓存。敌人出生时向某个全局管理器注册自己的引用,死亡时注销。需要统计附近敌人数量时,直接查这个已注册列表,不要每次跑到场景里按Class检索。需要给范围内敌人派发伤害时,也直接遍历缓存列表加距离判断,而不是用标签扫描。
如果某个逻辑实在需要高频扫描,比如玩家进入警戒区触发所有敌人警觉,可以考虑把这段逻辑用C++写,效率差距非常明显。顶层玩法逻辑可以留蓝图,但像批量遍历、批量查找这种底层循环,用C++做是稳赚不赔的。多敌人场景里,高频且重复的逻辑越早迁出蓝图越好。
3. GPU侧优化:渲染负载管理是另一条命脉
3.1 同屏多角色,绘制调用的账要细算
CPU优化到一定程度后,GPU很快会变成下一个瓶颈。多敌人场景里最直接的GPU压力,来自大量角色的绘制调用。
角色网格体跟静态网格体不一样,它天然带着骨骼和多个材质区块。如果每个敌人身上挂了4种不同材质,每个材质又对应不同的渲染状态,引擎就要分4次DrawCall来提交这个角色。一屏幕20个敌人,就是80次角色DrawCall,再加上武器的、技能的、场景里的,想稳帧很有难度。
应对方面从三个方向同时下手。一,减少角色材质通道数量。不用的材质槽全部清掉,贴图能合并就合并,敌人角色尽量做到单一材质或至多两个材质。二,开启实例化渲染。大量使用同一套模型和材质的敌人,可以考虑静态网格体实例化方案,在视觉允许时把多个敌人合并成实例化网格体,性能提升非常直接。三,材质本身的复杂度要克制。多敌人场景里所有角色都带一张几十MB的粗糙度贴图没问题,但如果材质里叠加三四个纹理取样、动态噪声和复杂的视差偏移,GPU的着色开销会直线上升。
3.2 剔除没做好,满屏敌人都是白渲
GPU性能里永远绕不开一个词:剔除。多敌人同屏时,很多敌人其实并没有全部出现在视野里,但引擎默认还是会进行一定程度的渲染处理。视锥剔除是常规手段,但还不够。我还会配合距离剔除和遮挡剔除来降低负载。
距离剔除是最容易理解的。敌人角色不要设成“无限距离可渲染”,你不可能看到800米外还有一个完整动画的角色在走动。给每个角色网格体设置合理的最大渲染距离,比如150米或200米,超出距离直接不渲染。这样远景部分的GPU压力能省掉一大块。
遮挡剔除在大规模场景里也很关键。墙角背后那些敌人本来不需要渲染,如果被遮挡剔除系统正确处理,就能减少不少像素着色和深度测试压力。对于静止的大型掩体和建筑,用PrecomputedVisibilityVolume预计算可见性,配合烘焙好的遮挡数据,比动态遮挡查询更稳定。
还有一个容易忽略的是阴影。多敌人场景如果全部投射动态阴影,CombineShadowMaps的压力会非常大。远处的敌人不需要投射实时阴影,只让近处交战区内的敌人产生动态阴影,远处的统一用烘焙Lightmap环境光替代,视觉影响很小,但性能会好看很多。
3.3 材质过载和Overdraw,画面华丽的隐形代价
战斗激烈的时候,粒子特效和半透明材质会漫天飞。多敌人FPS最容易出现的GPU问题是Overdraw,就是说同一个像素被画了很多次,比如多层粒子叠加、多重半透明爆炸特效、大片光线冲击波。你看到屏幕闪得越花,GPU干的活就越多。
具体到每个敌人身上,中弹时的受击特效不要无节制叠加。大量敌人同时受击,每个敌人身上若再挂两个粒子发射器,场景中粒子数量就会非常夸张。我给敌人受击特效设置了全局并发上限,超过上限后后面的特效自动弱化,优先保证玩家自身枪口和主要受击区域的反馈效果。
材质本身也要注意指令数。一个角色材质如果包含几十个材质指令,那每像素的着色开销都会上涨。在移动端或者中低端PC上,效果差异会非常明显。材质函数不是不能用,但定义过多的嵌套函数会影响编译优化。能用简单数学表达式实现的,就没必要包一层复杂的材质函数。
3.4 帧时间峰值与1% Low:卡顿比平均掉帧更致命
玩家对平均帧率的感知其实不如对卡顿的感知强。哪怕平均帧率挺高,只要能感觉到每隔一两秒顿一下,体验就会很差。所以多敌人场景优化,除了把平均帧时间拉低,更要关注1% Low帧,也就是帧时间排在倒数1%以上的那些帧表现。
1% Low差的本质,是某几帧的单个线程耗时突然暴增。在多敌人场景里,常见的帧率峰值来源有这么几个:一批敌人从休眠状态同时激活,导致那一帧突然有大量AI和动画逻辑涌进来;某个大型特效突然加载,贴图或着色器资源被即时加载进显存,造成渲染线程卡住;以及动态寻路批量重新计算。
针对这些瞬时峰值,要对症处理。敌人激活不要一次性全唤醒,分批加入战场,比如每隔0.05秒唤醒5个。大型特效在播放前提前加载资源或者预缓存贴图。寻路重新计算要做限流,避免一帧内同时处理所有敌人的路径更新。多敌人场景里,保持帧时间的平滑比单纯提高上限帧率更重要。
4. 内存与资源流送:多敌人带来的隐性压力
4.1 动画资产的上层建筑要共享
多敌人的项目往往不止一种敌人类型。步兵、盾兵、狙击手、无人机,每个都用一套独立的动画资产和动画蓝图,内存和加载时间就非常可观。这里最容易忽略的是,很多敌人的基础动作动画完全可以复用同一套资源。
我建议把所有敌人动画按类型分块,步兵和盾兵即使外观不同,也可以共用同一套步行、奔跑、瞄准、换弹动画,只根据不同骨骼重定向或者替换个别动作。UE里还可以使用Animation Sharing方案,让多个相同骨架的敌人角色共享同一个动画实例,渲染不同,但骨骼动画更新可以采取共享机制。这种方案对大量同骨架敌人效果立竿见影,唯一代价是不同的外观需要自己额外处理。
动画蓝图也用共享。不要让每个敌人Actor单独持有自己的AnimBlueprint实例,尽量通过某种管理器统一驱动动画。骨骼网格体变体之间只换Mesh资产和材质,不换动画结构和状态机,资源占用就能降下来。
4.2 模型LOD和纹理流送,值得花时间反复调
多数项目对Textures都开了流送,但流送池设置不合理时,多敌人激战会频繁触发贴图加载,引发卡顿。我遇到过一个情况,敌人类型有十几种,每个持枪配件都带独立材质,一旦同屏混合出现,流送池资源竞争非常激烈。
调整思路是控制同屏资源种类数量。给不同敌人用共用贴图集Atlas,而不是每人一个512MB材质集合。同时把材质LOD处理好,远距离用低分辨率贴图,近距离再用高精度。模型LOD的切换距离也要配合战斗密度来定,比如大场面时让所有角色的LOD切换距离整体往前调,中等距离的敌人会更快切换到低模,有效降低GPU负载。
大地图里多敌人刷新还涉及到WorldPartition和HLOD。如果关卡区域很大,敌人分布在不同的Level工作区里,合理设置HLOD可以让远处未加载区域只用极简网格代理,不必把所有高模敌人和装饰都载入内存。这里也顺带解决了一个问题:把敌人引用放在同一个Level里不切分,是所有内存爆炸和加载卡顿的根源之一。
4.3 音效资源和AI资源也要一起管
多敌人场景实际上还有一个细节领域:音效。每个敌人即使都不出声,引擎也可能同时计算它们的脚步、呼吸、枪声、受击反馈。几十个AI同时有音效播放时,音频节点和混音开销也会挤压CPU。
我限制同屏AI的语音和音效并发数量。距离远或者不在视线内的敌人,脚步声和语音直接不播放;混战场景里,最多同时播放指定上限的受击音效,其他敌人正被击中时只显示视觉反馈而不额外播声音。这样玩家注意力集中在眼前战斗上,整体体验不会受到影响。
资源层面还有一个容易被忽视的点,就是敌人AI用到的BehaviorTree、Blackboard和感知配置,如果是每个敌人一份实例,资源压力也会累加。尽量让同类型敌人共用BehaviorTree和感知配置,只在运行时设置各自的Blackboard值。多敌人场景,共享配置永远比复制配置更省资源。
5. 性能分析和问题排查,靠数据来验收
5.1 常用分析指令和Unreal Insights的配合使用
我优化多敌人场景时,一般分成两个阶段。第一个阶段用引擎自带Stat命令快速找到大方向,第二个阶段用Unreal Insights做细粒度分析。
按stat unit先看GameThread、RenderThread、GPU占比。比如GameThread占20ms,那么明细里再看stat game,定位到Actor Tick还是动画。渲染线程有问题时用stat scenerendering,看DrawCall数量和网格体绘制耗时。GPU侧用ProfileGPU,逐项看Shader复杂度。
Unreal Insights非常适合分析多敌人场景,因为它能录制每张帧里线程的详细调用链。我一般会在战斗场景里录制30秒到60秒的数据,重点看AI行为树Tick、动画蓝图更新、动画采样、寻路请求这些事件的调用次数和耗时。看到某个系统的耗时随着敌人数增长呈线性甚至超线性增长,那它大概率就是需要重点优化的对象。
5.2 别只看编辑器表现,Standalone和打包版才是真实数据
我吃过不少亏,在编辑器里跑性能测试,数据好看,但Standalone模式下反而掉帧严重,打包实机上更糟。原因是编辑器本身会消耗大量CPU和内存,同时资源加载方式也和打包版不同。多敌人场景的测试,最靠谱的方式是直接使用Standalone模式或打包后的开发版本,在目标硬件上跑同样的战斗场景。
测试场景也要有代表性。只测一个小规模遭遇战不够,要构造一个压力场景:30个敌人挤在同一个战区,玩家开枪拉仇恨后全员交战,同时玩家往敌人堆里扔烟雾弹,并且让多名敌人同时播放受击和死亡动画。说白了,把玩家最容易反馈“卡”的那一段录下来,反复测,反复优化。目标不只是平均帧率60,更要把1% Low尽量拉上去,别让峰值卡顿刺穿玩家的体验。
5.3 移动端平台的取舍和长线优化建议
多敌人FPS如果目标是移动端或中低端PC,优化取舍又不一样。移动端的GPU带宽更小,同屏角色数量最好控制更严,贴图分辨率可以整体下调,实例化合批的支持也更弱。在这种情况下,AI和动画的降级策略要更激进:远距离敌人不仅降动画更新率,最好直接切换成只有播放简单待机动画的替身模型,AI逻辑进入休眠状态,只在进入战斗弧内才唤醒。
从技术方向上说,这套优化如果能落到工具层,会省掉开发期大量人肉调整。比如做一个全局的“敌人质量等级”配置面板,统一控制同屏最大AI数、动画更新距离阈值、模型LOD距离、AI感知频率和剔除距离。测试时用同一套配置档位在低端机和中端机上各跑一遍,分别调整。多敌人场景从来不是一次性调完就固定的,后续加一个新敌人类型或新增一个大型战斗区域,都需要重新跑数据重新调阈值。
我个人在实际项目里最大的体会是:优化工作只要围着一帧的时间预算转,方向就不会错。先把每一帧的预算确定下来,比如游戏线程8ms、渲染线程8ms、GPU8ms,然后所有系统和效果都按这个盘子来分配。多敌人场景里舍不得砍功能的思路最危险,24个敌人满配火力全开看起来爽,但性能救不回来的时候,玩家并不会因为场上敌人少两个而觉得不好玩,反而会因为帧数稳定、操作流畅而对战斗评价更高。优化到最后,比的不是谁牺牲得更少,而是谁能让玩家在激烈交火中始终稳住手感。