做FPS优化的人都有一个共同的噩梦:打开测试地图,拉出二十个敌人,帧数直接从120掉到45,鼠标一甩就是一阵阵的屏幕撕裂。多敌人场景之所以难搞,不是某一个系统出了大问题,而是动画、AI、渲染、物理全挤在同一个屋子里开会,每个人都在抢CPU和GPU的时间片。这篇文章我想用一次实际项目中的优化经历,完整拆一遍“多敌人FPS场景”的性能瓶颈怎么定位、怎么拆解、怎么一步步压回目标帧率。
内容会覆盖从Unreal Insights的帧数据分析,到渲染侧的DrawCall削减、逻辑侧AI更新频率的调整、动画LOD和物理碰撞的精简,最后落到用性能预算表和1% low帧校验结果。整个过程是纯实战经验,涉及具体命令和配置参数。最适合正在被多敌人场景卡到怀疑人生的UE开发者,或者想把引擎优化从“玄学”变成“工程”的团队。
1. 场景分析与瓶颈定位:先别急着砍,找出真正的凶手
多敌人场景的性能问题,最忌讳一上来就猜。有人看一眼掉帧就条件反射去关阴影,有人马上调低屏幕百分比,结果帧数没涨多少,画面糊成一片。我一开始也这么干过,后来才发现,与其瞎猜,不如用引擎自带的工具把一分钟时长的帧数据录下来,让数据告诉你瓶颈在哪。
1.1 多敌人场景到底压垮了什么
先明确一下概念。UE里一帧的时间是被多个线程瓜分的,主线程(GameThread)负责游戏逻辑、AI决策、动画更新、物理模拟的准备;渲染线程(RenderThread)负责场景图遍历、剔除、DrawCall提交;RHI线程负责和显卡驱动打交道,把渲染命令真正送进去。你在帧数面板里看到的Frame、Game、Draw、GPU,就是这几条线程各自的花费。
多敌人场景下,最典型的伤害是同步发生的:每个敌人都有自己的动画蓝图和AnimInstance,格斗或射击动作一多,GameThread上动画更新会随着人数线性增长;每个敌人都有自己的AI控制器,感知、寻路、行为树在运转;每个敌人都是一个Actor,有碰撞盒和可见性检测;到了渲染端,每个常规网格体至少产生1~2个DrawCall,20个敌人加上枪口火花、弹壳、血雾特效,DrawCall轻松破千。问题就出在这里:四条线程哪一条先到极限,你的帧数瓶颈就在哪一条。
我先用一个生活化类比帮自己理清思路。主线程像是一个餐厅的点单服务员,所有客人的需求都经过他处理;渲染线程像是后厨的备餐台,把所有菜按顺序摆好;GPU是炒锅,真正开火出菜。如果服务员太慢,客人等得久,这就是GameThread瓶颈;如果备餐台堆满菜传不过去,这是RenderThread瓶颈;如果炒锅火力不够,这是GPU瓶颈。餐厅要提升翻台率,光买更好的锅没用,你得先看出排队到底卡在哪个环节。
1.2 用工具说话:Unreal Insights和Stat命令的正确用法
定位瓶颈首选Unreal Insights,它可以在引擎里启动录制,也可以运行时按快捷键捕获。我用的是启动时加-tracing参数的方式,只录60秒,覆盖进战斗开火和刷出理想状态的二十个敌人,然后把数据拖到Unreal Insights里分析。界面上最直观的是帧时间线,四条颜色的横条分别代表GameThread、RenderThread、RHI线程和GPU Busy时间,哪条压到顶部红线,那就是首要瓶颈。
除了录帧,我还会用一套固定的Stat命令来快速看关键计数器,命令如下:
stat unit stat game stat render stat rhi stat anim stat engine这套命令在编辑器模式就可以跑。stat unit能看到Frame、Game、Draw、GPU的总耗时,stat anim能看到动画更新的具体耗时,stat engine能看到Actor、Tick、Collision相关的开销。第一次测多敌人场景时,我在控制台敲了stat unit,屏幕显示GameThread在35秒时已经吃了22ms,RenderThread只有12ms,GPU只有8ms。事实证明瓶颈在主线程,不在渲染,那我后续的优化重点就往动画和AI逻辑上砸,而不是去折腾材质和阴影。
2. 渲染侧优化:让GPU和DrawCall一起瘦身
虽然我这次定位到主线程瓶颈,但多敌人场景的渲染压力同样不容忽视——尤其是当你把敌人数量堆到50甚至80,DrawCall数高得会让任何一个手机GPU直接罢工。这部分我把渲染侧的优化策略整理出来,即使你的瓶颈在主线程,先把DrawCall压低,也能给后续的GPU峰值留出喘息空间。
2.1 绘制调用过载?先看剔除和实例化
多敌人场景里,最常见的绘制调用浪费是:玩家后面站着一群敌人,玩家看不到他们,但引擎依然逐帧提交它们的骨骼网格体、武器、附属部件。UE的默认视锥剔除能干掉屏幕外的物体,但藏在背后的物体视锥可能仍然正向,就需要靠遮挡剔除或距离剔除兜底。
我用的是分层策略:
- 近战范围内(0~10米):保持完整的骨骼网格、LOD0、完整材质和阴影。
- 中距离(10~25米):切换到LOD1,关闭动态阴影,使用简化材质。
- 远距离(25米以上):切换到LOD2或直接用Imposter模块,生成一张公告板纹理,绘制成本几乎为零。
关键参数在骨骼网格体上设置MinimumLOD和LODDistanceScreenSize,同时打开Use True Velocity,让LOD切换更平滑。实测在中距离关闭阴影后,DrawCall直接砍掉一半。
如果敌人是同一批模型,我强烈建议把远处的敌人换成实例化静态网格体(Instanced Static Mesh)或HISM(Hierarchical Instanced Static Mesh)。HISM最大的优势是自动把几十个敌人的网格体合并成一个DrawCall,配合剔除系统单独管理每一实例的可见性。当玩家视角转向敌群时,HISM可以只渲染屏幕中心附近的实例,甚至能把一个80敌人场景的DrawCall压到十几个。
2.2 Shader与材质优化的成本账
材质是GPU耗电大户,多敌人场景中尤其容易踩坑的是overdraw。角色的皮肤、盔甲、枪械各自带了一套复杂的PBR材质,贴上法线、粗糙度、AO、各向异性贴图,每一次BasePass都把这些计算结果跑一遍。敌人一多,GPU的像素填充率就会被彻底吞掉。
我采用的做法是为敌人角色做材质质量级别分级:编辑器模式下保留PBR细节,打包后或战斗状态下切换成简化版本。做法是在材质里开启Quality Level节点,针对低质量的级别关闭细节纹理,甚至替换一个无光照版本。这种方案在移动端优化里非常常见,UE项目也直接支持。
还有一个经验是材质中少用全局函数和复杂表达式,尽量将纹理采样限制在BaseColor、Roughness、Metallic三个关键通道。我拿引擎自带的Lyra演示项目做过测试,它的角色材质已经是很标准的优化版本,但依然能通过降低鲁棒性获得20%的GPU时间盈余。
2.3 光照与阴影:多敌人时最容易忽略的大坑
动态阴影是帧率杀手。敌人多,意味着每个敌人为了投射阴影,都要进入shadow map渲染一遍自己的网格体,这会让阴影Pass的DrawCall瞬间翻倍。测试时五个敌人还能稳60帧,拉到二十个时GPU总线直接烧红,问题多半就出在阴影上。
控制阴影成本有几招:
- 限制阴影距离:将级联阴影贴图(Cascaded Shadow Maps)的
MaxDistance控制在30米以内,远处的敌人直接不投阴影,最多依靠环境光遮蔽模拟效果。 - 减少阴影级联数:默认3~4级,如果场景不是大开阔地,2级完全够用,每少一级都能省下一大截shadow map面积。
- 非重要角色关闭投影:在距离超过一定阈值后,把敌人网格体上的
Cast Shadow关掉,用一张预烘焙的AO贴图在材质里模拟接触阴影。
我记得当时把阴影距离从50米缩到25米,GPU时间直接掉了30%,画面观感并没有明显衰减。多敌人场景的光照优化,性价比最高的永远是先动阴影,而不是去把屏幕百分比从100%拉到50%。
3. 逻辑与AI侧优化:别让每帧做重复劳动
把渲染侧清了一遍,把帧率从45拉回60发现依然有波动,再接上Unreal Insights看,GameThread的时间又开始往上冒。这一轮,我决定对AI和游戏逻辑动刀。
3.1 AI感知与更新频率:牺牲一点“聪明劲”换流畅
UE的AI感知系统非常强大,可以监听视觉、听觉、触碰,但它默认会每帧去轮询所有感知通道。多敌人场景下,二十个敌人同时开着视觉感知去扫描玩家位置,主线程瞬间爆炸。这时候的正当优化思路是降低感知更新频率,让AI“偶尔看一眼”,而不是每帧都全功率工作。
具体做法是在AIPerception组件里找到UpdateInterval,把默认值从0改到0.3秒甚至0.5秒。0.5秒的感知延迟对普通射击游戏完全够用,玩家从掩体后面露头,AI在0.5秒内作出反应,肉眼几乎察觉不到延迟。反而它省下了大量CPU时间,因为在0.5秒的时间窗内,AI不用重新执行GenerationalSensing。
同样,在AIController的TickInterval里,我把角色Tick也从60Hz改到30Hz。多敌人场景里AI不需要每帧都决策,行为树会有一些Waiting任务,可以隔帧跑。2026年FPS游戏都在拼低延迟和1% low帧流畅度,很少人关心AI的更新频率,这块省出来的时间往往比渲染优化还要可观。
3.2 导航与寻路:NavMesh不是越多越好
多敌人AI离不开寻路,但UE的NavMesh在没有改变时也是每帧计算同样的路径。当二十个敌人走到一个路口,由于他们同时抢占路径,寻路组件会频繁重新计算路径,产生大量A*搜索。
我在项目里做了几件事:
- 将NavMesh生成网格的自动重新生成改为手动重build,除非有墙体破坏或关卡更新,否则不重算。
- 为大型敌群共享路径寻路:不能让二十个敌人走完全不同的路径,而是让同一个小队共用一个
FollowingComponent或Path Corridor,只让Leader寻路,其余跟随。 - 使用
UNavigationSystemV1::GetPathCost缓存结果,避免每帧重复查询。
路径搜索开销主要靠查表解决。地图稍微复杂点时,A*搜索在CPU上偶尔会有几十毫秒的暴击,这个暴击就是1% low帧出现的原因之一。换成Leader寻路后,暴击概率会大幅下降,1% low帧的数据也变得平滑。
3.3 Gameplay代码的并发与缓存
很多多敌人优化多半栽在“每帧全局扫描”这类写法上。比如某个玩家技能要检测范围内所有敌人,有人就在Tick里写GetAllActorsOfClass,然后遍历所有敌人。这种函数会触遍整个关卡的所有Actor,串联查询,巨耗性能。
我的经验是:
- 使用UE的Detection Volume或
UNiagaraSystem事件记录进入半径的Actor,只在进出时更新列表,不在Tick里遍历。 - 将伤害检测从Tick改成定时器或事件驱动,例如开枪时只对射线单一检查。
- 用Actor属性缓存来避免重复操作,尤其是Animation Related状态和时间戳,它们本就是高频访问点。
优化前,我调试时看stat engine里Tick开销高达9ms。优化后,把那种每帧检查敌人距离的逻辑全部改成事件驱动,Tick开销直接降到2ms以下。多敌人场景不缺性能,缺的是正确的数据结构。
4. 动画与物理优化:把CPU时间花在刀刃上
动画和物理是多敌人场景主线程的第二大债权人。一个敌人跑动时,它的骨骼动画系统每帧都在更新骨骼变换,物理子系统又在处理胶囊体碰撞和射击反弹,两者叠加,开销惊人。
4.1 骨骼动画LOD:远处敌人不需要那么精细
UE的骨骼网格体默认坚持全LOD动画计算,骨骼数量越多,动画更新开销越大。多敌人场景中根本不需要远处的敌人去跑骨骼蒙皮甚至布娃娃物理,此时必须使用动画预算分配器。
在项目设置里启用Animation Budget Allocator后,可以用预算规定所有动画动画实例的总耗时,比如10ms。预算分配器会优先保证靠近镜头的敌人动画质量,把远离镜头的敌人动画的更新频率降到30Hz甚至15Hz,代价是远处的敌人播放动作可能不够丝滑,但玩家根本看不到那么远。
我还给每个敌人设置了基于距离的骨骼LOD,在CameraDistance的ScreenSize超过一定阈值后,其陷阱骨骼网格体的更新被截断,改成Using LODData或者只更新Root Motion。这一招能让动画系统的CPU占用率下降一半。动画LOD不是玄学,它来源于一个简单的道理:GPU在渲染远处物体时可以偷懒,CPU在计算远处骨骼时同样应该偷懒。
4.2 物理碰撞:不要让子弹和敌人互相折磨
物理系统在多敌人场景中最容易被滥用。敌人本身的碰撞胶囊体,枪械弹道的Block通道,场景的StaticMesh碰撞,每个碰撞体只要活性参与物理模拟,就会产生CPU开销。
我做的第一件事是检查碰撞通道设置:把敌人身体的物理模拟设为Fixed,只保留碰撞查询响应,关闭物理模拟,也就是让它像一座活着的“静态雕像”,而不是随时会受力弹跳。子弹的物理模拟极其浪费,改用射线检测(LineTrace),不需要真正的子弹物理体,就可以瞬间判定命中。
还有一点是从Flash Flashbolt视角给敌人的碰撞体只开胶囊体,不管发射的枪口烟雾特效。特效面片不要参与碰撞通道,否则每个特效碎片都会和世界碰撞体交互,画面没变好,CPU已经爆炸。
4.3 粒子特效的合并与限制
多人的战场总躲不开火花、血雾、死亡爆炸。Niagara系统可以把大量粒子合并到一个GPU渲染容器里,尤其是火焰和烟雾。
我做了一个粒子池:预设最多2000颗粒子,超过上限的旧粒子被杀掉。这样一来,敌人被击杀时爆出的一百个粒子瞬间被分发到粒子池,而不是每个击杀新建一个组件、创建一批新粒子、再销毁变量,避开了垃圾回收。实测粒子特效的GC峰值降了約40%,1% low帧中的尖刺少了很多。
5. 数据驱动的调优闭环:用Profiler和统计验证一切
前面讲了很多具体优化,但优化的过程不是做完一条就能算数,必须有个闭环。这个闭环的核心是:建立性能预算表,用工具测量每次改动前后的数据,再靠1% low帧校验是否真的流畅。
5.1 建立性能预算表
在优化开始前,我第一件事是定好目标帧率和各线程预算。举例,目标60FPS,一帧可用时间是16.6ms:
- GameThread预算:6ms
- RenderThread预算:5ms
- GPU Busy预算:5ms
- RHI线程预算:3ms
- 额外余量:2ms
这是一个比较健康的分配方式。实际项目里如果主线程偏重,可以适当把GameThread调到7ms,RenderThread压到4ms。关键是把预算数字写成Excel表,每次修改后往回填数据,这样一眼就能看出哪里达标哪里超支。
我用一张测试表现状举例:
| 线程/资源 | 优化前耗时 | 优化后耗时 | 预算 |
|---|---|---|---|
| GameThread | 22ms | 7ms | 6ms |
| RenderThread | 12ms | 6ms | 5ms |
| GPU Busy | 8ms | 4ms | 5ms |
| RHI Thread | 5ms | 3ms | 3ms |
看着这份表格,我就知道下一步必须继续优化GameThread里的动画和AI,直到把22ms压到6ms以内。预算表最重要的作用是防止你学了十几个优化技巧后到处乱用,而是在数字化约束下按优先级出牌。
5.2 基于数据的迭代流程
我自己的优化顺序基本固定为三条线:
- 第一轮,先把渲染侧的阴影和实例化处理掉,因为它们对帧率影响最直观,改也最快。
- 第二轮,把AI的更新频率和路径缓存处理好,降低GameThread峰值。
- 第三轮,再把动画预算分配和骨骼LOD调完,看数字余量还有多少。
每一轮结束我都会重录一次Unreal Insights,更新预算表。很多人优化只做一轮就觉得够了,但往往余额只有一点点,未来加了新功能又会马上崩。我的习惯是直到预算表每行都比目标值低15%以上,才算真正“完成”。
5.3 1% low帧与流畅度的关系
平均帧率是骗人的,战斗中真正的体验是掉帧尖刺的多少。1% low帧的定义是:把所有帧的渲染时间排序,取最慢的1%帧的的平均帧时间,再换算成FPS。如果平均帧是60 FPS,但1% low只有20 FPS,说明游戏中频繁出现卡顿,体验很糟。
普遍共识是:1% low帧至少要保证大于45,才能算得上“流畅”。多敌人场景中最容易出现1% low暴跌的原因是资源加载突刺,比如敌人在远处首次出现在视线中,它的骨骼网格体和材质纹理瞬间被送进内存,导致主线程或GPU等待资源加载。
我处理资源加载的方法是将敌人资产放进AssetManager的PrimaryAsset列表,并在关卡开始前执行异步预加载(Load Primary Asset)。如果不想提前预加载太多,至少将Mip Load Wait Time调低一点,并开启Async Loading,让纹理流送不阻塞主线程。
6. 常见问题与排查实录
多敌人优化是修不完的,仔细走了一遍总会有几个陷阱。我把项目里踩过的坑结合现实经验摘几个典型,做成一个速查表,方便读者反复参考。
6.1 优化后还是卡:这几件事最容易遗漏
| 现象 | 可能原因 | 解决方向 |
|---|---|---|
| 帧数波动但Thread不超 | 内存碎片或GC峰值 | 使用对象池、分帧销毁 |
| 多人同时爆特效后卡顿 | 粒子导致DrawCall突增 | 粒子池化、GPU粒子、限流 |
| 平移视角时掉帧 | 资源流送阻塞 | Async Loading、预加载资产 |
| AI同时开火时掉帧 | Audio或动画通知大量触发 | 合并音效触发、降低AnimationNotify间距 |
| 远处敌群跳动 | LOD切换过快影响观感 | 调整LODDistanceScreenSize和LodTransitionDuration |
比如对象池是我反复强调的招数。敌人死亡后不要立刻Destroy Actor,而是放进对象池,等待下次刷怪时复用。这时你不再需要频繁创建Actor的资源分配,也能大幅度减少GC的卡顿尖峰,直接改善1% low帧。
还有一个隐蔽问题是大量的低成本动画通知在同一帧触发。例如二十个敌人同时播放开火音效,每个音效都创建了一个SoundWave实例,瞬时占据内存,撞击音效系统会引起内存峰。解决办法:用SoundMix限流音频文件,或限制同时播放的VoiceCount。
6.2 一个真实的0.1% Low问题排查过程
我印象最深的是一次加载了一个竞技场地图,平时稳75 FPS,但只要两个小兵上墙极限贴脸,镜头快速转动的那一瞬间,帧数就会瞬间跌到40以下,肉眼可见地顿一下。Unreal Insights里记录的时间线显示,第1200帧处用一条非常长的Delay出现在GameThread上,几乎占据了整个时间线。
顺着时间线往下看,很快定位到是一条动画通知(Animation Notify),触发了角色身上一个特效的血雾生成,而这个特效的Niagara资产当时并没有被预加载。动画播放带了资源加载,加载需要读取磁盘,磁盘I/O在那一帧挂起。最终我手动在Niagara资源设置了WarmUpTime,并把它添加进预加载清单,重新测试后0.1% low帧提升了80%,从肉眼可感知的顿挫变成不可感知的微波动。
这是典型的“性能问题不在代码逻辑,而在资源加载时机”的案例。做FPS多敌人优化,越到后面越要留一只眼睛盯住资源系统和IO调度,而不只是旧式思维里的DrawCall和Tick。
我个人在实际操作中的体会是:多敌人场景的优化从来不是一次冲锋就能搞定的,它更像一个持续迭代的工程。每次你很好的压低那一层,就又会发现新一层瓶颈浮出来。但只要坚持用Unreal Insights记录、维护性能预算表,并重视1% low数据,你就能把这种看似玄学的过程,慢慢变成一条条肉眼可见的绿色数据条。