ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

UE FPS多敌人场景性能优化:从瓶颈定位到1% Low帧攻坚

UE FPS多敌人场景性能优化:从瓶颈定位到1% Low帧攻坚 做FPS项目最怕的一件事就是在测试场景里放满敌人。我记得第一次在关卡里铺了三十几个AI角色编辑器里跑还凑合按下Play之后帧数直接跌破401% Low帧惨到个位数瞄准时镜头像在泥浆里拖动。当时第一反应是想删掉一半敌人但项目负责人只问了一句删了敌人玩家打什么那之后我老老实实花了两周时间把UE FPS多敌人场景的性能优化从头做了一遍。这篇文章就借那次实战当例子聊聊我在定位瓶颈、砍CPU开销、压GPU渲染、稳住1% Low帧这几个环节里用到的具体思路和可以直接复现的操作希望能给正在被多敌人掉帧折磨的朋友一点参考。1. 多敌人场景为什么是 FPS 的“照妖镜”——先把问题定性1.1 普通场景掩盖了哪些问题很多人刚接触UE项目时只在测试关卡里放几个敌人跑个60帧没问题就觉得性能还行。等到敌人数量上到20、30个帧率立刻崩了这时候才开始查优化。问题其实从一开始就埋着单个敌人在大场景里测试时每个Actor的动画、AI、物理、渲染压力都很小CPU和GPU都有大量余量。但敌人一多成本不是简单叠加而是互相放大——动画要更新AI要决策感知要做检测骨骼网格体要渲染阴影要重新计算这些开销全部压在同一帧里。这里有个容易被忽略的点拿静态假人测试没意义。你摆一圈不会动的模型雕像当然不卡。真实敌人是活体每帧都要跑行为树、更新动画蓝图、做视线检测、计算移动。我见过有人用Mesh代替AI角色做压力测试得出结论“引擎没问题”结果一接上真实AI就露馅。多敌人场景测试必须用真正的AI Pawn带行为树、感知系统、完整动画蓝图否则数据没有任何参考价值。1.2 多敌人场景的四个放大维度我在那次项目里总结下来敌人数量上来之后受冲击最大的是四个维度AI逻辑与行为树每个Pawn每帧推进行为树、黑板、感知系统二十个敌人就是二十棵行为树在同一帧跑。感知系统还会触发大量Trace请求耗时随数量指数级上升。动画蓝图与骨骼更新每个敌人一个AnimInstance动画蓝图里的状态机、混合、IK、布料模拟都在逐帧执行。骨骼网格体的Skinning和Transform更新同样是逐角色计算。场景查询与物理敌人寻找目标、绕障、判断掩体都要做LineTrace或ShapeTrace。二十双眼睛同时扫视的场景碰撞查询请求可以轻松把物理引擎打满。渲染管线骨骼网格体的顶点量、材质复杂度、动态阴影投射、Overdraw全在GPU上堆积。尤其动态阴影每多一个活动物ShadowMap里就多一组要画的东西。这四个维度恰好横跨CPU和GPU两侧。很多人优化只盯着一个方向比如砸特效、降阴影结果CPU侧的AI和动画一分钱没省帧率依然难看。这也是为什么我后来坚持“先量化、再动手”别凭感觉瞎砍。1.3 性能预算的概念讲优化之前得先把“预算”这个词聊透。UE里一帧的耗时基本由三段组成游戏线程Game Thread、渲染线程Render Thread、GPU。以60帧为目标三段各自的总预算大约是16.6毫秒但三段是并行流水线工作的瓶颈往往只在其中最慢的一段。多敌人场景里最典型的状况是游戏线程被AI和动画吃满GPU被阴影和材质吃满两段同时逼近极限。这时候你只降画质GPU下来了游戏线程还在17毫秒以上帧率一样上不去。反过来只砍AI频率GPU的阴影开销又成了新的最高水位。所以我的习惯是任何一次性能优化都先把这三段耗时的占比测出来谁最高就先处理谁。这个习惯帮我省掉了大量无用功。2. 定位瓶颈工具和数据说了算2.1 先把问题稳定重现出来优化的前提是能稳定复现不能靠“偶尔卡一下”去猜。我针对多敌人场景做了一个专用的性能测试关卡固定地图区域、固定敌人数量、固定出生点、固定难度脚本玩家站到固定位置后开始计时。关卡里不刷额外的拾取物、不播大段过场保证每次跑出来的数据可以直接横向对比。我还给这个关卡配套了一组Console命令自动记录帧数据跑完直接生成CSV。这里要提醒一下测试时尽量用Development或DebugGame配置不要用Shipping因为Shipping会砍掉大量Debug开销测出来的数据会和实际调试状态差很多。另外编辑器里Play和打包后的表现经常不一样我的经验是“以打包后数据为准”编辑器数据用来做横向对比还可以绝对不能当作上线预期。2.2 stat 三兄弟Game Thread / Render Thread / GPU定位瓶颈最基础的工具就是stat unit。在控制台输入后屏幕上方会显示三列关键数据Frame Game Draw GPU 16.6ms 9.2ms 3.5ms 22.1ms其中Game是游戏线程耗时Draw是渲染线程耗时GPU是GPU耗时。哪个数值最接近甚至超过预算瓶颈就在哪。如果Game长期在16毫秒以上说明CPU侧AI和动画超支如果GPU在20毫秒出头而Game只有8毫秒那就别在AI上瞎使劲先把渲染开销压下来。stat unit只是第一层。往下细分我常用这几个命令搭配使用命令用途stat game看游戏线程各模块耗时比如AI、动画、物理、导航stat gpu看GPU各渲染阶段耗时比如BasePass、Shadow、Lighting、PostProcessstat rhi看RHI层绘制调用和资源状态变化stat scenerendering看渲染线程中场景收集、绘制排序的耗时animstats输出单个动画蓝图实例的更新耗时以我那次项目为例最初stat unit显示Game约9毫秒、Draw约3毫秒、GPU约22毫秒瓶颈很明显在GPU。但把阴影和材质优化一轮后Game Thread反而成了16毫秒——因为AI和动画问题从一开始就存在只是被GPU问题盖住了。这就是为什么要多测一轮“优化后数据”而不是只看第一组结果就收工。2.3 Unreal Insights 进一步挖函数级耗时stat系列能看到模块级数据但要知道具体是哪段代码在慢还是得上Unreal Insights。用trace.start启动捕获跑一段多敌人战斗后trace.stop导出打开Insights看Timing Insights。我最常看的是AnimInstance::UpdateAnimation和BehaviorTreeComponent::TickComponent这两类函数的耗时。如果这一项占了游戏线程的三分之一那大概率就是动画蓝图的实例数量过多或者行为树的Tick频率过高。这一套定位流程走下来基本能分清主次。也顺便说一句你不需要把Insights学得很深只要学会看“哪个函数占了最多Self Time”就够用。先解决最大的那个热点收益远大于把十个1%的小点各省一点。3. CPU侧的开刀顺序AI、动画、查询3.1 AI 的 Tick 是最大的隐形税UE里Actor默认每帧Tick二十个敌人就是二十次空转加上AI更新。单看一次Tick没多少但叠起来就不一样了。我在那次优化里做的第一件事就是给AI Pawn设置了分级Tick频率。近战敌人和玩家正在交战用0.1秒更新一次足够流畅中距离守卫0.2秒更新远距离巡逻AI干脆0.25秒以上。代码上很简单重写Pawn的Tick函数加一个时间累积// AMyEnemyCharacter.cpp void AMyEnemyCharacter::Tick(float DeltaSeconds) { Super::Tick(DeltaSeconds); // 昂贵的AI行为更新改成按固定间隔触发 TimeSinceLastAIUpdate DeltaSeconds; if (TimeSinceLastAIUpdate AIUpdateInterval) { TimeSinceLastAIUpdate 0.f; UpdateAIBehavior(); // 行为树、感知、导航都在这里跑 } }还有个细节是bAllowTickBeforeBeginPlay和默认的StartWithTickEnabled不需要持续Tick的AI直接禁用Tick靠事件驱动。比如站在远处警戒的敌人只在玩家进入一定半径后再唤醒Tick。这比任何频率调优都省得更多因为一旦不Tick那部分开销直接归零。3.2 动画系统能少算就少算动画是另一个大头。每个敌人一个动画蓝图实例状态机每帧在处理Locomotion、AimOffset、各种混合。二十个敌人里的动画节点复杂度可能完全一样但只有少数几个在玩家近处其余在几百米外它们的动画细节人眼根本看不到。我的做法分几层骨骼网格体LOD给SkeletalMesh设置两个以上LOD远处用减面几何体。动画LOD距离超过某个阈值切换到只播2-3个基础动画待机、移动、攻击的低精度动画不开复杂的混合和IK。禁用不可见角色的骨骼更新对不在屏幕内的敌人停掉骨骼变换计算只保留逻辑位置更新。引擎里有相关支持启用后远场景敌人能省下一大截CPU。全身IK这类功能一定慎用。IK能显著提升动作表现但在多敌人场景里每个角色都做全身IK求解是非常昂贵的。我在实践中只给玩家近战的重要AI角色开IK普通敌人一律关掉。视觉影响很小帧率收益却很明显。3.3 感知系统与视线检测的粗筛细筛AI感知系统的Sight检测默认逻辑会对每个潜在目标做视线检查这里面的成本大头是LineTrace。敌人多、目标多、感知更新又频繁的时候物理引擎的碰撞查询队列会迅速堆积。我的优化思路是“粗筛在前、细测在后”先用距离和朝向判断淘汰掉绝大多数目标只对可能看到的候选者做真正的LineTrace。在蓝图或C里实现时大概是这样bool AMyAIPerception::CanSeeTarget(AActor* Target) { // 粗筛距离和朝向 float DistSq FVector::DistSquared(GetActorLocation(), Target-GetActorLocation()); if (DistSq MaxSightRadiusSq) return false; FVector DirToTarget Target-GetActorLocation() - GetActorLocation(); DirToTarget.Normalize(); if (FVector::DotProduct(GetActorForwardVector(), DirToTarget) SightFOVCos) return false; // 细筛对候选者做真正的视线检测 FCollisionQueryParams Params(SCENE_QUERY_STAT(AISense_Sight), true, this); Params.AddIgnoredActor(Target); return !GetWorld()-LineTraceTestByChannel( GetActorLocation(), Target-GetActorLocation() FVector(0.f, 0.f, 100.f), ECC_Visibility, Params ); }同时把感知更新频率从每帧改成0.15秒一次。敌人AI的反应延迟人眼很难察觉但CPU每秒少跑了几十次群体Trace收益立竿见影。3.4 碰撞体、场景查询和对象池碰撞体这块容易被忽略。给敌人用的碰撞体尽量精简胶囊体就够不要挂一堆复杂的凸包或三角形Mesh碰撞。查询通道也要分开AI视线检测走ECC_Visibility玩家武器命中走ECC_Camera或自定义通道别让AI视线去撞玩家的武器检测。通道分对了碰撞查询的数量虽然没减少但每次查询命中的Primitive数量会明显下降。对象池也建议直接做。敌人频繁生成和销毁会产生大量Spawn/Despawn和GC压力。我那次改造后敌人生死都走对象池死亡时只是禁用、隐藏、回收下次生成直接重新激活。激活时代价远低于重新构造。这一步对第5章要讲的1% Low改善极大。4. GPU侧的渲染压力从阴影和 LOD 里挤时间4.1 骨骼网格体的渲染账本CPU侧处理完接下来看GPU。多敌人场景里每个敌人的骨骼网格体都在做顶点动画、材质着色、深度写入。GPU上的消耗主要由三部分组成顶点总量、材质复杂度、屏幕覆盖面积和Overdraw。顶点量靠LOD控制材质复杂度靠Shader优化Overdraw则和透明物体数量有关。这里提一下GPUSkinCache它把骨骼蒙皮计算放到GPU上可以显著降低CPU和渲染线程的压力。移动端机型支持不一用之前先查一下目标设备的支持能力并做好降级方案。PC上默认可以用但要注意它本身也会占GPU资源不能无脑全开。4.2 LOD 是性价比最高的渲染优化很多新人觉得LOD复杂其实引擎里一条命令的事。MeshLODSettings和SkeletalMesh LOD是现成的你只需要设定好过渡距离。我的经验是给多敌人场景设三层LODLOD级别过渡距离说明LOD0高模0-800玩家周围的敌人保留完整动画和材质细节LOD1中模800-2000减面模型关闭布料和复杂效果LOD2低模2000以上极端减面的替代模型基本没有动画细节LOD切换时的“弹跳感”是常见问题。可以在材质里做淡入淡出过渡或者用UE的Dithered LOD Transition视觉上会平滑很多代价是切换瞬间多一个Pass。敌人密集时效果明显可以接受。动画LOD同样重要距离远的敌人继续播放完整状态机纯属浪费切到简单动画后CPU侧和渲染线程同时受益这是多敌人场景里性价比最高的单点优化之一。4.3 阴影往往是隐藏的帧率杀手做FPS项目时画面的暗部表现很重要所以阴影容易越加越多。但阴影恰恰是多敌人场景里最贵的几个特效之一。每个投射动态阴影的敌人都要在ShadowMap里多占一份开销骨骼网格体的动态阴影比静态Mesh更贵。二十个敌人在同一个区域里阴影绘制时间可以轻松超过其他所有渲染阶段的总和。我的处理方式是分档裁剪级联阴影距离调近别让远处的敌人都带着阴影。距离较远的敌人直接关闭动态阴影投射或者切换到“仅接收阴影”模式。固定点位的敌人比如守卫生成点的战位用静态光照烘焙阴影动态阴影留给真正参与战斗的角色。移动端直接考虑关掉或大幅降低阴影分辨率低分辨率阴影加上软阴影也能好看不少。这些改动对视觉的影响控制得当的话几乎看不出来。尤其战斗场景里玩家注意力集中在准星周围远处敌人头顶有没有影子根本不会注意但帧率能救回来一大截。4.4 反射、半透明和后处理的取舍多敌人场景还容易忽略反射和后处理的累积开销。屏幕空间反射SSR在开阔场景里成本不低每帧都在全屏采样半透明物体和敌人重叠时会加剧Overdraw和排序负担高高挂着的Bloom、体积雾、环境光遮蔽看着精致但都是GPU预算里的固定支出。我在优化时做了一版“战斗模式”质量预设进入多敌人战斗场景时SSR降一档、体积雾关闭、AO降到低质量、半透明粒子数量减半。视觉差异很小战斗手感却顺滑很多。移动端需要更狠直接关掉SSR和体积雾用反射探针和简单的雾效代替。这也是前面说的“钱要花在刀刃上”的体现——玩家在FPS里看的是准星、目标和命中反馈不是远处水面上的倒影。5. 1% Low 帧的攻坚别让平均帧掩盖体验崩坏5.1 为什么多敌人场景的 1% Low 特别难堪平均帧率看着不低但玩起来就是一顿一顿这个问题很多人遇到过。平均帧率只是把一整个时间段的帧时间取了个均值完全掩盖了那些“灾难帧”。真正影响手感的是1% Low——把每帧耗时排序取最差的1%那部分帧的平均值。如果1% Low只有15哪怕平均帧60玩家体感就是频繁卡顿尤其在瞄准和开枪瞬间卡一下直接就崩了。多敌人场景天然的1% Low爆点就是“突发的群体事件”十几个敌人同时进入视野时动画首帧初始化、网格体首次加载、感知系统批量唤醒同时爆发或者一场战斗结束十几个敌人尸体同时销毁GC也跟着来一波。这些尖峰不会体现在平均帧里但玩家能清清楚楚感觉到。5.2 突发负载的来源拆解我排查多敌人场景的1% Low尖峰时主要看四个来源首次可见敌人从不可见变为可见的瞬间骨骼网格体、动画蓝图、材质资源要按需加载初始化帧会突然卡一下。Shader编译新材质、新特效第一次上屏时的编译卡顿这是FPS里最常见的掉帧来源之一尤其敌人身上的材质组合很多样时。GC与生成销毁大量生成或销毁敌人触发垃圾回收暂停。同帧唤醒多个AI同时结束休眠、同时发起感知检测、同时播放技能动画的“同频共振”。5.3 把尖峰摊平预加载、分帧、对象池应对思路就是把这些突发负载从“某一帧”摊开成“好多帧”。先说预加载和流送。敌人出现的位置提前放一个加载体积或者手动触发流送让资源和动画蓝图在玩家靠近前就常驻内存。材质编译和PSO问题用引擎的着色器预缓存和打包时的Shader收集流程处理多敌人战斗场景更要提前跑一遍把可能用到的组合都触达过。然后是对象池和分帧初始化。对象池解决GC问题敌人回收复用不再反复生成销毁。分帧初始化解决首次可见的问题——多个敌人同时被激活时把它们的感知、动画优先级、网格体加载分散到之后几帧里依次执行不要在同一帧全部顶上去。拿分帧初始化举个例子敌人激活时先只设置Transform和基本碰撞动画组件延迟到下一帧或随机延迟0.05到0.1秒再初始化。玩家看到的是一个自然的“敌人接敌”过程实际上背后的CPU负载被平摊开了。我当时实测的数据大概是这样指标优化前优化后平均帧率58~6278~821% Low1142游戏线程峰值16.8ms9.2msGPU峰值24.5ms13.1ms同屏敌人数量3240同屏敌人还多加了一批但帧率和稳定性都明显提升。1% Low从11帧到42帧手感的提升比“平均帧多20”更明显这才是战斗射击项目该盯的指标。6. 可复制的优化清单和一点项目复盘6.1 一个从低风险到高风险的落地顺序踩过一堆坑之后我给自己定了一个优化顺序风险从低到高排适合大多数多敌人FPS场景先用stat unit和Unreal Insights定位瓶颈记录基线数据。调整AI Tick频率和感知更新频率做粗筛细筛。给敌人做SkeletalMesh LOD和动画LOD禁用远处AI的IK和布料。削减动态阴影距离低质量阴影档位固定点位烘焙阴影。搭对象池敌人死亡回收消灭大量Spawn/Despawn。对批量出现的敌群做分帧初始化摊平首次可见的尖峰。最后才考虑整体画质档位的调整因为画质是产品卖点能不动就不动。每一步做完都要重新跑一遍测试关卡记录数据。不要一次改完再测那样根本分不清哪一步有效、哪一步白改。我见过有人一口气改了十个设置最后帧率好了却完全不知道是哪一步起效后续版本一回归又全崩回去。6.2 Lyra 项目和官方实践的启发UE5官方示例项目Lyra里其实有不少多人在线战斗的设计思路NPC的分帧更新、动画LOD的配置、模块化Gameplay框架都对多敌人项目有参考价值。不过我的建议是看思路别抄配置。Lyra是官方向导型项目它的参数设定是针对那种特定视角和场景的你的项目地图规模、敌人密度、玩法节奏不一样直接抄配置往往会“水土不服”。用工具和数据调出来的数字才是适合你自己的数字。6.3 我的几点体会那次优化做完之后我在团队里定了一条规矩任何人改动敌人行为、数量或者材质配置都要跑一遍性能测试关卡记录三张stat截图和一组1% Low数据超预算直接打回。这套流程不复杂但比任何高级技巧都管用。另外说个真话性能优化没有一次做完的时候。项目每加一个新系统、新功能之前的优化成果就可能被侵蚀。保留一个固定可复现的性能测试关卡让优化变成日常流程的一部分这才是多敌人场景不卡顿的真正解法。
返回列表