ARTICLE DETAIL

资讯详情

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

UE多敌人场景FPS性能优化:从瓶颈定位到实操方案

UE多敌人场景FPS性能优化:从瓶颈定位到实操方案 做 FPS 项目的朋友应该都有过这种经历单机测试一切流畅一放进大量敌人的场景里帧数立刻断崖式下跌开枪手感都跟着变粘。尤其是那种敌人数量往上堆的设计——尸潮、兽群、敌军小队同时扑过来——性能问题往往比玩法问题更早找上门。这篇我用自己的实际项目经验聊聊多敌人场景下 UE 的 FPS 性能优化思路先怎么定位瓶颈再从哪里下手最后实测哪些改动收益最大。先说结论多敌人场景的卡顿绝大多数不是 GPU 扛不住而是 CPU 端被吃干了。每多一个敌人就意味着多一个 Actor、多一套骨骼动画更新、多一份 AI Tick、多几次寻路查询这些全都在挤主线程。搞清楚这个前提优化的方向才不会跑偏。1. 为什么敌人一多帧率就崩先找准瓶颈1.1 FPS 掉帧的表面现象与真实瓶颈很多人一遇到掉帧第一反应是显卡不够好然后疯狂降画质、砍阴影、调分辨率结果帧数纹丝不动。这是因为在多敌人场景里瓶颈往往根本不在 GPU。一个敌人进入场景后牵扯到的东西比你想的多得多骨骼网格体的动画更新、蒙皮计算、状态机切换、感知系统的 Tick、行为树的执行、寻路组件的查询、碰撞查询、以及最容易被忽略的——每个 Actor 的 Spawn 和 Destroy 开销。我见过最典型的例子一个场景里有 80 个敌人GPU 渲染时间只有 6ms但游戏线程Game Thread帧耗时冲到 18ms 以上。这时候你就算把渲染分辨率降到 720p帧数也不会有本质提升因为显卡在等 CPU 喂数据。判断瓶颈在哪最简单粗暴的方法就是看三个时间值GameThread、RenderThread、GPU。用stat unit命令在控制台敲一下屏幕上会出现类似Frame: 19.5ms Game: 12.3ms Render: 4.1ms GPU: 6.2ms这样的数据。哪一项最高瓶颈就在哪。1.2 用 stat 命令快速定位 CPU/GPU 瓶颈stat unit只是第一层看到 Game Thread 高还得继续往下拆。我常用的命令组合是stat unit看整体帧时间构成快速确认瓶颈域stat game看游戏线程细分包括 Actor Tick、物理、导航等stat streaming看流送和加载是否在卡帧stat animation看动画系统开销stat AI看 AI 系统感知、行为树开销stat RHI/stat GPU看渲染线程和 GPU 开销stat Slow显示最耗时的任务列表这些命令配合起来用基本能画出一张时间都花哪去了的图。比如stat AI显示感知系统占了 4ms那优化的重点就非常明确了。还有一个我特别推荐的习惯开着stat unit进场景后把敌人数量从 10 逐步加到 50每次记录帧时间变化。哪个环节的耗时随敌人数量线性增长哪个就是你优化收益最大的地方。1.3 多敌人场景的三个典型瓶颈地带根据我的经验敌人数量上来之后瓶颈高度集中在三个地带。第一个是动画更新。每个带骨骼网格体的角色都在做动画采样、骨骼更新、蒙皮这是 CPU 的一个大头。特别是角色都朝不同方向跑、播放不同攻击动画时动画蓝图每个 tick 都在执行。第二个是 AI 逻辑。感知系统、行为树、寻路请求每一个都是实打实的开销。默认情况下行为树节点每帧都会评估感知系统每一帧都会做视野和听觉检测这些在敌人多的时候会迅速演变成灾难。第三个是场景中的动态 Actor 开销。每个敌人都要 Tick每个 Tick 都要处理碰撞、更新位置、检查与其他 Actor 的交互。UE 的 Actor Tick 是分布式的大量 Tick 叠加起来主线程时间就被吃掉了。知道这三个瓶颈地带接下来就可以对症下药。下面每一章我针对一个地带给出具体可落地的优化方案。2. 渲染层优化让每个敌人物尽其用2.1 骨骼网格体的 LOD 与动画 LOD先说骨骼网格体的 LOD。UE 里给骨骼网格体设置 LOD 非常方便你只需要在 Skeletal Mesh 资产里把 LOD 数量配好引擎会根据距离自动切换。但很多人配 LOD 只降了模型面数忘了动画的 LOD 同样重要——甚至更重要。我的经验是距离远到一定程度时玩家根本看不清楚敌人的骨骼动画细节。你完全可以让 20 米开外的敌人不播放完整动画或者使用UAnimationAsset::GetPlayRate降低动画采样频率。UE 的骨骼网格体 LOD 可以附带一套AnimSequence的 LOD 映射高模配高精动画低模配简化动画距离远了以后动画更新也相应变糙。这里面有个小技巧AnimationLODBias可以控制动画的 LOD 偏置让远处角色的动画采样率降下来省掉的 CPU 时间非常可观。另外bUseRefPoseOnInitAnim这个经典开关也要利用起来。对距离很远的敌人可以直接开启使用参考姿势来跳过动画计算代价是角色看起来是 T 字站姿——但如果你配合好距离阈值在完全超出视野的位置这么做玩家根本察觉不到。我记得有一个项目把 80% 的远处敌人直接冻结动画CPU 动画开销直接降了 60% 以上。2.2 顶点动画与烘焙动画放弃蒙皮当你需要同屏出现大量敌人比如 50 个以上时常规的骨骼动画系统本身就是一个瓶颈无论你怎么 LOD 都有限。这时候就该考虑更激进的方案顶点动画 或 烘焙动画。原理不复杂骨骼动画每帧要做骨骼姿态计算和蒙皮矩阵运算而顶点动画把每一帧的顶点位置预先算好存在顶点色或纹理里渲染时用自定义材质直接读取完全绕开骨骼系统。这意味着你丢掉了动态的物理模拟、布娃娃死亡、骨骼可破坏部位这些功能换来的是巨大的人数容量提升。我实际做一个尸潮场景时用的是另一种思路把 30 个敌人组成一个小队共享同一条动作轨迹。这 30 个敌人渲染的是同一个骨骼网格体但通过材质里的 UV 偏移或者顶点位移做出微小的差异感。配合 GPU Instance 的特性玩家看过去会以为每个敌人都不同但实际底层渲染批次只有一个。这个方法对大量同类敌人场景特别合适像丧尸、蚂蚁、无人机这类单位视觉上有差异的需求并不高关键是数量感。2.3 实例化与 Draw Call 合并敌人数量多了以后Draw Call 数量会飙升。每个敌人即使只有两个 Draw Call80 个敌人就是 160 次调用每次调用都有 CPU 到 GPU 的通信开销。解决这个问题场景里大量重复出现的敌人务必走实例化渲染。UE 里实现实例化比较成熟的方案是Instanced Static Mesh Component或者Hierarchical Instanced Static Mesh Component (HISM)。但对于会移动、会攻击、会死亡的敌人移动的骨骼网格体不能直接套用 Instanced Static Mesh 方案。这时候有两个思路一是把活着的敌人用骨骼网格体渲染死亡的敌人尸体立刻切换成静态网格体并入 HISM 管理——尸体会积累非常多这一步收益极大二是在上面说的顶点动画基础上把一群敌人合并进同一个渲染批次。虚幻引擎的Nanite目前对于非刚性变形的骨骼网格体支持有限所以 FPS 里的敌人优化还是得靠传统手段。我说个实操细节把尸体切到静态网格体时材质尽量用同一个实例化材质不要每具尸体都单独创建材质实例否则内存和渲染状态切换会把你省下来的时间又吞回去。统一材质用颜色变化或贴图扰动做差异感就够了。2.4 遮挡剔除与距离剔除配置FPS 是典型的第一人称视角场景视野被墙壁、掩体遮挡的情况非常多遮挡剔除Occlusion Culling在这里的收益比任何其他类型的游戏都大。UE 自带的遮挡剔除机制一般能胜任但有几个配置必须检查。Frustum Culling视锥剔除是默认开启的这个没问题。重点是r.OcclusionCulling和相关距离设置。我有一次排查一个奇怪的问题敌人的模型明明在墙后面但帧数依然被拖累。查了半天发现是Near Plane太小导致大量遮挡查询把 CPU 打满了。把近裁剪面从 10cm 调到 100cm 后遮挡查询数量明显下降帧数立刻恢复。另外记得用Distance Culling控制敌人在超过一定距离后彻底不渲染。UE 里可以给 Actor 设置NetCullDistanceSquared网络端和 LOD 距离渲染端。我的做法是150 米以外的敌人直接隐藏100 米至 150 米之间用简化版模型50 米以内才上完整细节。这个距离规划要根据你的地图尺寸来定但原则是一致的——让远处的敌人用最低的成本存在。3. 逻辑层优化AI 与行为系统的降本增效3.1 行为树更新频率的控制行为树是 UE AI 系统最常用的决策框架但它也是 CPU 消耗大户。默认情况下行为树会每帧 Tick每个节点都执行一次逻辑判断。敌人数量从 10 个变到 50 个行为树的开销也跟着翻五倍——这里的翻倍是乘法级的因为节点越多每个敌人的开销越大。我强烈建议所有多敌人项目都改成低频更新优先级唤醒的架构。具体做法行为树的执行频率从每帧改成每秒 5 到 10 次也就是用一个 Timer 或按帧号取模的方式驱动。对于大多数 AI 决策——比如该不该追玩家要不要切换攻击方式100 毫秒一次的判断频率完全够用人眼根本感知不到延迟。更精细化的做法是分层决策上层战略决策比如是否追击、是否包围用低频率执行下层战术动作比如当前攻击动画的选择用高频率执行再往下运动控制比如朝向修正可以用更快的频率。这样既保持了 AI 的反应速度又把大规模计算的成本压了下来。在实现层面我习惯给不同的行为树实例挂不同的 Tick Interval或者干脆用AITickSharingManager把多个 AI 的 Tick 集中管理实测 CPU 时间能省 30% 到 40%。3.2 感知系统的分层设计UE 的AIPerceptionComponent默认会做视野检测视线追踪、听觉检测和感觉检测每一项都有实际的开销。敌人多的时候感知系统之间互相触发的频率很高往往是性能崩溃的隐形推手。我的思路是把感知设计成粗筛精查两层。粗筛层用最简单的距离判断和扇形角度判断先过滤掉 90% 根本不可能感知到玩家的敌人只有通过粗筛的敌人才进入精查层调用真正的视野追踪、遮挡检测。实战中你完全可以在玩家角色周围维护一个玩家广播器玩家把自己的位置、移动速度、是否射击等信息集中广播出去所有敌人监听这个广播信号而不是每个敌人各自去感知场景。这种做法把感知从每敌人独立查询变成了全局事件分发数量越多收益越明显。还有一个小细节听觉感知的触发器如果设计得好能省下大量视觉检测的开销。比如玩家开枪时发一个声响事件周围 30 米内的敌人才会被唤醒进入警戒状态30 米外的敌人保持低功耗巡逻模式。这种事件驱动 分级唤醒的设计既符合游戏逻辑远处敌人本来就不该听到枪声又天然控制了计算量。3.3 批量更新与帧分摊每个敌人一个 Tick每帧都在跑是最直观的浪费。多敌人场景务必做批量更新Batching和时间分摊Time Slicing。时间分摊很好理解敌人数量有 60 个可以分成 6 组每组 10 个每帧只更新一组这样每个敌人的逻辑更新频率是每 6 帧一次。对于 FPS 里的杂兵敌人这种延迟完全无感。关键是怎么分组——我的习惯是按距离分离玩家近的敌人比如 20 米内全速更新中距离的隔一帧更新远距离的每 4 帧更新一次。这里可以用Actor.SetActorTickInterval配合距离判断动态调整每个敌人的 Tick 间隔。批量更新则是把同类操作合并。比如 60 个敌人都要检测玩家位置你可以在一个统一的 Manager 里循环处理而不是让 60 个敌人各自调用 60 次GetActorLocation。看似很小但积少成多主线程省出来的时间非常可观。这种集中管理 分帧处理的模式是我在多敌人项目里首选的架构方式。3.4 用 EQS 还是自研方案行为选择的前置代价很多 AI 会用到Environment Query System (EQS)来选点比如找掩体、找攻击位置。EQS 本身非常强大但它的一次查询可能在场景里做几十次测试代价不低。敌人一多每一帧可能同时有十几个 EQS 查询在跑CPU 直接被打穿。我的建议EQS 的查询请求要做频率限制和结果缓存。距离上一次查询结果在 0.5 秒内的敌人直接复用旧结果不重新查只在玩家的相对位置变化超过一定阈值时才允许新的查询。真实项目里我见过把 EQS 查询频率从每帧改成每 1 秒AI 的找掩体行为几乎看不出变化但 CPU 开销直接降了一半。4. 寻路与移动系统优化4.1 NavMesh 的生成与预算寻路Pathfinding在多敌人场景里的消耗非常反直觉——它和敌人数量不成线性关系而是近似于 N 个对 M 个查询点的平方级关系。每个敌人用一个NavMeshPath做寻路、每帧更新路径敌人多起来之后寻路系统会成为另一大 CPU 杀手。第一刀先砍 NavMesh 的生成精度。别用满精度格子默认尺寸足够的情况下把 Cell Size 从默认值稍微调大Agent Radius 和 Agent Height 精确匹配你的敌人碰撞体而不是放大余量。生成精度的每一点提升换来的都是轻量级寻路查询时间的下降。第二刀限制路径更新频率。敌人不需要每帧重新计算路径除非障碍物变化剧烈。我的默认配置是每 0.5 到 1 秒重新寻路一次中间只做路径跟随。对于 FPS 里的杂兵你甚至可以只在敌人状态切换比如从巡逻变追击时才重新寻路。4.2 Crowd 系统替代独立寻路当同屏移动的敌人非常多时每个敌人独立寻路 独立避障会让 CPU 迅速饱和。这时候要果断切换到 UE 的 Crowd 系统基于 RVO 的群体避障。Crowd 的核心思路是共享路径 局部避障。同一个区域的敌人共享一个大的 NavMesh 路径每个敌人只做局部的速度调整来避免互相穿插。这样显著的降低了寻路的整体开销。代价是单个敌人的路径可能不是最优解群体移动偶尔会有涌流效果——但说实话对大量敌人冲锋的场景来说这种群体流动感反而更自然。这里有个容易踩的坑Crowd 系统要求敌人的移动由CrowdFollowingComponent控制如果你的 AI 用了自定义移动逻辑需要做适配。另外 Crowd 代理的数量也不是无上限的超出一定程度要分层——远处的敌人走简化逻辑近处的才进入精确避障模式。4.3 碰撞与物理的取舍敌人数量多了物理碰撞的开销会跟着涨。默认情况下每个敌人身上可能挂着 Capsule 碰撞体、多个武器碰撞体、Detect 通道物理引擎要为它们做遍历和检测。多敌人场景要严格控制碰撞体数量和碰撞通道。我的习惯是敌人身上保留一个 Capsule用于阻挡和伤害判定其余的碰撞体全部设为仅查询或者干脆禁用只在触发死亡等特定事件时临时启用。在项目设置里把Collision Disable相关的调优选项打开特别是p.EnableDynamicRebuild之类的动态重建开关能减少物理体结构变化时的开销。另外物理材质尽量复用不要每个敌人创建独立的 Physical Material 实例材质切换是物理引擎的一个隐藏性能坑。5. 内存与游戏对象管理5.1 对象池与敌人生命周期多敌人场景第一大忌是频繁 Spawn 和 Destroy 敌人。每次SpawnActor和DestroyActor都会引起内存分配、组件注册、场景更新、垃圾回收标记等一系列操作。一个 100 个敌人的尸潮场景如果你在 10 秒内反复生成和销毁敌人卡顿几乎是必然的。解决方案就是对象池Object Pool。提前生成一批敌人 Actor但保持未启动状态比如放在地图外或者禁用所有组件。游戏需要刷怪时从池里取一个来激活敌人死亡后不销毁而是重置状态放回池中。这个方案能让 Spawn/Despawn 的开销趋近于零。对象池要注意两个细节一是池内对象的状态重置必须彻底包括血量、动画状态、AI 状态、感知数据、是否在 NavMesh 上等漏掉任何一项都可能导致敌人复活后行为异常二是池容量要按最高并发量设定别省内存省到池不够用又走回 SpawnActor 的老路。5.2 Actor 的初始化与激活策略对象池里禁用和激活本身也有开销。我看到不少项目把池里没用到的敌人设置SetActorHiddenInGame(true)SetActorEnableCollision(false)但 Tick 还在跑——那和没禁用有什么区别呢正确的思路是完全禁用时调用SetActorTickEnabled(false)同时把bHidden设为 true碰撞全部关闭。更进一步可以用Deactivate和Activate来控制 Actor 的活跃状态但更直接的方案是把池子放在地图外的不可见区域配合bCanEverAffectNavigation关闭让完全休眠的敌人在任何系统里都不产生开销。激活时再统一初始化、回到战场。5.3 流送与关卡分块大型 FPS 地图 多敌人同时在场内存压力也会成为帧率杀手——不是显存不够而是加载导致的主线程卡顿。解决方案是关卡分块流送Level Streaming。把地图分成几个大区域敌人按区域归组。玩家靠近时区域流送加载并激活敌人玩家离开后区域卸载敌人整体休眠。World Partition是 UE5 里比较推荐的方式配置好了以后会自动处理相邻区域的加载。但注意流送边界上的敌人要小心处理别在加载/卸载过程中出现敌人凭空消失或敌人卡在半空中的 Bug。我的做法是在流送边界外 50 米处就预加载保证玩家永远看不到正在加载的过程。5.4 垃圾回收与内存碎片的规避UE 的 UObject 垃圾回收GC在 Actor 数量庞大的时候会周期性造成卡顿。多敌人项目中你每帧可能都在创建临时对象——比如子弹的碰撞通知、UI 的伤害数字、行为树运行的临时查询结果——这些都会堆积到 GC 的待回收列表里。规避手段有两个层面。代码层面尽量复用临时对象用NewObject创建并缓存而不是每次都新建蓝图层面避免高频执行节点创建临时结构体变量。项目设置层面调整 GC 的时间预算和触发频率比如让 GC 在帧间隙分片执行gc.TimeBetweenPurgingPendingKillObjects而不是固定在某几帧集中清扫。实测中把 GC 触发策略从周期清扫改成低内存压力时清扫可以显著减少掉帧尖刺。6. 实测数据与调优经验6.1 一个实际项目的优化前后对比以我之前做的一个塔防 FPS 项目为例目标场景是同时在场 60 个敌人。初始状态在测试机上帧率只有 38 到 45 FPS最低帧掉到 30 以下明显能感觉到开枪时准星发飘。通过stat unit定位Game Thread 耗时 14.8msGPU 只有 4.2msCPU 侧瓶颈确认。我按照上面这套逻辑依次做的优化是敌人动画 LOD 远处冻结动画Game Thread 降到 10.2ms感知系统改成事件驱动 粗筛精查降到 7.6ms行为树 Tick 间隔改为 0.15 秒 EQS 频率限制降到 5.9ms寻路改为 Crowd 系统 路径更新频率限制降到 4.5ms尸体切换静态网格并入 HISM 对象池渲染线程和 GC 开销同步下降最终稳定在 75 到 85 FPS最低帧保持在 65 以上。整个调优过程大约用了两周大部分时间其实花在第一步的动画优化上——因为动画系统的开销最大改动涉及资产配置也最多。6.2 优化顺序建议很多朋友一上来就砍模型面数、改材质复杂度这是在错误的方向上浪费精力。我给的建议顺序是第一步永远是定位瓶颈stat unitstat gamestat AI画清时间分配图第二步做逻辑层降耗Tick 间隔、行为树频率、感知切换。这部分改动不涉及美术资产风险最低收益最快第三步做动画层优化LOD、冻结远端动画、必要时顶点动画改造。这是 CPU 收益最大的一步第四步做渲染层优化实例化、遮挡剔除、Draw Call 合并。这时 GPU 才会变成瓶颈但这时候你的 GPU 优化才是有效率的第五步做内存与生命周期对象池、GC 策略、流送配置解决的是偶发性的掉帧尖刺这个顺序的本质是先把每帧必须做的重复计算降下来再考虑一次性但代价高的重构。模型面数如果真成了瓶颈大概率是前面几步走完了之后的事。6.3 关于优化做过头的教训也说点反面教训。有一次我把敌人的动画更新间隔调得太过激进远处敌人直接冻结结果玩家的感受不是流畅而是敌人会瞬移。优化过了头游戏体验反而崩了。动画 LOD 的切换要做平滑过渡至少要有距离缓冲区不要在同一个距离点反复切换。这也提醒我每一次优化调整完一定要用真人实际游玩来体验别只看性能数据——数字漂亮 ≠ 手感好。另一个教训是过早优化是万恶之源。项目还在玩法验证阶段时拼命优化多敌人性能往往是在错误的方向上浪费大量时间。等核心玩法定型了确认敌人规模上限确实需要高并发时再系统性做优化也不迟。6.4 常见问题速查表整理几个我实际遇到过的典型问题和对应解法当作速查手册问题现象可能原因排查与解决敌人一多GameThread 飙升动画更新 AI Tick 叠加stat animation、stat AI定位降 Tick 频率并开启动画 LODGPU 时间高但画面不算精美Draw Call 过多或 Overdraw 严重用stat RHI查 Draw Call 数走向实例化和材质合并帧数周期性掉帧尖刺GC 集中清扫 或 流送加载调整 GC 时间预算检查边界流送是否在玩家视野内触发远处敌人仍在消耗大量性能忽略距离管理设置 LOD 距离链和可见性 Cull Distance冻结远端动画敌人穿过彼此行为奇怪Crowd 和独立寻路混用统一移动模式检查 NavMesh Agent 半径和 Avoidance 配置玩家转身时卡顿视线外敌人刚被剔除/加载检查遮挡剔除的查询开销适当放宽近裁剪面内存持续上涨Spawn/Destroy 产生的对象泄漏排查对象池的状态重置是否彻底用memreport检查敌人生成瞬间卡死一次生成过多 Actor 且初始化重分帧生成把生成操作平摊到多个帧这些配置和参数都不是死的要根据具体项目调整。但方向是对的多敌人场景优化的本质就是控制每个敌人的单位成本让数量增加带来的开销从线性增长变成亚线性增长甚至摊平。我个人在实际项目中的体会是FPS 多敌人优化最大的坑不是技术方案不会做而是不知道时间花在哪了。先把 profiling 工具用熟练学会看数据说话后面所有优化动作都会变得顺理成章。再就是优化做完一轮别急着收工回到场景里重新跑完整流程重点盯最低帧而不是平均帧——玩家感知到的卡顿往往就藏在那几个最低帧里。最后分享一个小技巧每次改动前后用同一段固定路径的回放记录帧时间曲线把改动前后的曲线叠在一起对比。这比你看十个 stat 数字都直观。帧时间曲线的抖动幅度往往是比平均值更诚实的性能指标。保留好这份对比记录你会清楚地知道每一步优化到底产生了多大价值这也是我长时间实践下来觉得最实用的一套工作流。
返回列表