Niagara Ribbon 粒子不死导致的组件泄漏:一场战斗从 45 FPS 掉到 23 FPS
Niagara Ribbon 粒子不死导致的组件泄漏:一场战斗从 45 FPS 掉到 23 FPS
一场战斗打到后半段,帧时间从 22 ms 涨到 44 ms,帧率对半砍。抓 trace 之后发现罪魁是玩家的闪避拖尾特效——它在场景里稳态堆了 702 个存活实例,而玩家每秒最多闪避一两次。
根因在 Ribbon 发射器的 Particle State 模块:三个「让粒子死掉」的开关全是关的,于是组件永远不回池,每闪避一次净增 6 个,攒到 117 次就是 702。
这篇记录整个链条:怎么从 trace 定位到资产、为什么静态审计工具查不出来、运行时怎么在十分钟内实锤根因,以及归因过程中我自己踩的两个坑。数据来自一个 UE 5.x ARPG 项目的 Win64 Development 构建实机 trace。
现象:同一场战斗,帧时间单调爬升
数据是同一场战斗的 7 个采样窗口(1 = 战斗前期,7 = 后期)。这 7 段是 Unreal Insights 的独立时间切片,时间戳并不连续——这一点后面会变得很重要。各段帧数不同(5/10/11/11/7/32/21),下表全部按帧归一化,直接比原始总量会得出完全错误的结论。
| 段 | 帧数 | 平均帧时间 (ms) | FPS | UWorld_Tick (ms/帧) |
|---|---|---|---|---|
| 1 | 5 | 14.13 | 70.8 | 11.01 |
| 2 | 10 | 20.25 | 49.4 | 17.05 |
| 3 | 11 | 19.91 | 50.2 | 15.84 |
| 4 | 11 | 20.33 | 49.2 | 17.03 |
| 5 | 7 | 19.56 | 51.1 | 15.68 |
| 6 | 32 | 22.39 | 44.7 | 17.97 |
| 7 | 21 | 44.01 | 22.7 | 25.29 |
样本量得先声明:段 1 只有 5 帧、段 5 只有 7 帧,按 Insights 方法论的口径(有效样本 < 30 帧要标注"样本数不足")这几段的均值只能当趋势读,段边界首尾帧还可能被截断,对段 1 的比值影响可达 ±20%。好在下文的核心结论不依赖这些均值——段 7 的 702 是 21 帧里逐帧核验出来的恒定值,倍率也大到 20% 误差翻不动。
曲线里藏着两个性质完全不同的问题,必须分开处理:
| 阶段 | 帧时间变化 | 性质 | 主因 |
|---|---|---|---|
| 段 1 → 段 6 | 14.13 → 22.39 ms(+8.26 ms) | 缓慢、弥散 | 没有单一热点:角色数约 12%、Niagara 约 16%,其余是渲染管线整体抬升 |
| 段 6 → 段 7 | 22.39 → 44.01 ms(+21.62 ms) | 断崖(采样窗口间跳变) | 单个特效资产的实例泄漏 |
先把「降画质」这条路堵死
7 段的 Pacing 判定一致:瓶颈全程在 CPU。
| 段 | 判定 | GPU 忙 (ms) | GPU 空闲 (ms) |
|---|---|---|---|
| 1 | GameThread 单独主导(领先次席 3.7 ms) | 7.1 | 10.2 |
| 2–6 | GameThread / RenderThread / RHIThread 三线程并列 | 9.6–13.3 | 9.4–10.8 |
| 7 | 三线程并列(相差 4.5 ms 内) | 14.5 | 30.6 |
段 7 的 GPU 有 30.6 ms 在闲着。降阴影分辨率、调 DLSS/FSR 档位、降 Lumen 质量——对这个问题在数值上一点用都没有。这条结论要第一时间告诉美术和 TA,否则他们会往降画质的方向白跑一趟。
从 trace 定位到具体资产
CPU 粒子模拟涨了 100 倍
VectorVM_Legacy 是 Niagara 的 CPU 粒子虚拟机(不是 GPU 模拟):
| 指标 | 段1 | 段2 | 段3 | 段4 | 段5 | 段6 | 段7 |
|---|---|---|---|---|---|---|---|
| VectorVM 调用数/帧 | 45.0 | 36.3 | 29.5 | 44.1 | 28.9 | 37.0 | 758.5 |
| VectorVM ms/帧 | 0.51 | 0.75 | 0.74 | 1.09 | 0.95 | 1.75 | 52.68 |
| 单次调用 ms | 0.0113 | 0.0206 | 0.0249 | 0.0247 | 0.0328 | 0.0472 | 0.0694 |
| NiagaraSetDynamicData/帧 | 16.0 | 15.3 | 19.0 | 21.4 | 17.1 | 25.2 | 739.0 |
第三行值得单独看一眼:段 1→6 调用数基本持平,单次成本却从 0.0113 涨到 0.0472(4.2x)。调用数不变而单次变贵,说明已经存在的长生命周期特效内部粒子数在缓慢累积——这是段 7 泄漏的早期征兆,同源问题,只是当时还没到爆发点。
调用树(来自最差帧的 Callees 导出):
ExecuteForegroundTask / ExecuteTask / VectorVM_Legacy / ParallelFor / ParallelFor Task
└─ 独占 52.78 ms
VectorVM_Legacy 自身 Excl 只有 2.08 ms,开销全在其下的 ParallelFor Task——单个发射器的粒子数已经大到触发了 Niagara 内部并行化。
系统名会直接出现在计时器里
对比段 1 和段 7 的 TimerStats.csv,筛出「段 7 有、段 1 无」的项:
| Niagara 系统 | 段1 | 段4 | 段6 | 段7(实例/帧) |
|---|---|---|---|---|
| NS_Player_Dodge_Trail(闪避拖尾) | – | – | – | 702 |
| NS_Enemy_Wind_Storm | – | 0.9 | – | 57.1 |
| NS_Enemy_Impact_SmokeTrail | – | 2.2 | 3.9 | 3.8 |
| NS_Player_StateLoop | 4.0 | 4.5 | 4.8 | 4.8 |
闪避拖尾在段 7 共 14,040 次事件,首帧为 0、其余 20 帧恒为 702(逐帧核验过)。它占场景内全部 Niagara 渲染实例的 90.7%,且在段 1–6 里一次都没渲染过。
这里有个容易被跳过的细节:段 7 内部这 702 从第 1 帧起就持平,是泄漏完成后的平台期。 累积过程发生在段 6 结束与段 7 开始之间的未抓帧区间,trace 从头到尾没拍到实例数爬升的过程。所以「段 6→段 7 断崖」在相当程度上是采样形态造成的,真实累积大概率平缓得多;同理,117 次闪避 是 702 ÷ 6 反推出来的,不是观测计数。
归因边界:这 758 次模拟到底能不能算到它头上
这一节最容易写成假证据,所以我把归因的边界单独拎出来说。
VectorVM_Legacy 的 source 恒为 NiagaraScriptExecutionContext.cpp:308。本次抓帧没开 Niagara 逐系统 CPU 通道,计时器里根本不存在 per-system / per-emitter 作用域——所以「这 758 次 CPU 模拟属于哪个资产」,没法靠调用栈 join 出来。
更麻烦的是,那 14,040 次带资产名的事件全部发生在 RenderThread 0(逐事件核验过),走的是渲染侧 GetDynamicMeshElements 的可见性路径,和 worker 线程上的 VectorVM 是两套独立作用域。二者的关联是量级吻合的强相关:
- 场景内全部 Niagara 渲染实例合计 ≈ 774/帧,闪避拖尾占 702(90.7%)
- 引擎自带计数器
Scene/Visibility/OcclusionCull/NumTestedQueries= 776,与 774 吻合 99.7%,佐证这些计数确实等于「当帧存活并参与可见性测试的实例数」 - VectorVM 调用数 758/帧 与实例总数 774/帧 相差 16 个,约 2.1%
所以在实测之前,「闪避拖尾是主因」只是高置信推断;trace 并没有直接证明它。要铁证得重跑并开启 Niagara CPU 通道。这个区分值得单列一段——把推断写成事实,下一个接手的人会在错误前提上浪费一整天。
它同时污染渲染线程
每个 UNiagaraComponent 都是一个可渲染 primitive,每帧向渲染线程推 transform:
| 指标(次/帧) | 段1 | 段6 | 段7 |
|---|---|---|---|
UpdateTransformCommand | 40.8 | 53.1 | 1501.0 |
FScene_UpdatePrimitiveTransform | 27.0 | 39.2 | 753.5 |
ResetRT / ResetBuffersCommand | 22.4 | 54.2 | 1574.1 |
FRHICommandDrawIndexedPrimitive | 134.4 | 207.2 | 942.1 |
FPrimitiveSceneInfo_CacheMeshDrawCommand | 1.8 | 2.4 | 62.9 |
ComputeViewRelevance | 0.8 | 1.0 | 23.8 |
753 个 primitive transform ≈ 702 个拖尾组件 + 常规场景物件。这解释了段 7 为什么三条线程并列——GameThread 的 WaitUntilTasksComplete 从 18.04 涨到 22.38 ms/帧,它在等被 Niagara 打满的 worker 池(Tasks::ScheduledTasks 峰值 84 → 418,任务数/帧 885 → 2672)。
读这个数要留个心眼:TimerStats.csv 是跨全部线程聚合的,WaitUntilTasksComplete 这类等待项反映的是 worker 池整体空转,不能直接当成 GameThread 单线程开销读。
反证:物理运动体数量全程没涨
Chaos/Solver/Bodies/NumMoving 全程稳定在 62–66(段 1 = 66,段 7 = 66)。物理运动体数量没涨——段 7 的断崖跟「敌人/尸体越打越多」这个最直觉的假设无关。
注意别扩大化:这条只否定了段 7 断崖。段 1→6 的缓慢增长里确实有角色数增长,后面会讲。
运行时实测:十分钟定位根因
静态翻资产翻了很久没结果,换成运行时观测立刻见效。PIE 里执行:
fx.Niagara.Debug.Hud Enabled=1 OverviewEnabled=1 SystemFilter=NS_Player_Dodge_Trail
反复闪避,观察 HUD:
| 阶段 | 组件编号 | Emitters | Particles |
|---|---|---|---|
| 进战斗未闪避 | 无 | — | — |
| 闪避若干次 | NiagaraComponent_6 ~ _11 | 0 / 3 | 1 |
| 继续闪避 | _12 ~ _17 | 0 / 3 | 1 |
| 继续闪避 | _36 ~ _41 | 0 / 3 | 1 |
两个决定性事实:
- 组件编号单调递增,每次闪避 +6,从不复用。 6 对应 GameplayCue 里配的 6 个挂点,
702 ÷ 6 = 117,与 trace 的量级对得上。 Emitters - 0 / 3。 发射器全部已结束——特效早就播完了,组件却还挂在角色身上不销毁。
第 2 条把嫌疑范围一下子收窄到「发射器都停了,系统为什么不上报 Complete」。
原理:Particle State 的三个开关全是关的
打开该系统引用的 Ribbon 发射器模板 → Particle Update → Particle State 模块:
☐ Kill Particles When Lifetime Has Elapsed
☐ Loop Particles Lifetime
☐ Let Infinitely Lived Particles Die When Emitter Deactivates
三个生命周期终止选项一个都没勾。Emitter 面板显示 1 Particles,与 HUD 里每个组件恒为 Particles - 1 精确对应——那一个粒子就是不肯死的钉子户。
完整因果链:
Kill Particles When Lifetime Has Elapsed = OFF
→ Ribbon 粒子永不按寿命死亡
Let Infinitely Lived Particles Die When Emitter Deactivates = OFF
→ 发射器停用时也不杀它(最后一道保险也关着)
→ 粒子永远存活
→ 系统永远不上报 Complete(尽管 Emitters 已 0/3)
→ GameplayCue 的 bAutoRelease 永远不触发
→ UNiagaraComponent 永不回池
→ 每次闪避净增 6 个 → 117 次闪避 = 702 个常驻实例
→ CPU 粒子模拟 52.68 ms/帧 → 帧时间 44 ms
生成侧的配置本身是对的
Burst 型 GameplayCue 的配置没有任何问题:
GameplayCue.Dodge.Basic → NS_Player_Dodge_Trail
BurstParticles[0..5] bAutoRelease = true
挂点:脊柱 ×2、大腿/小腿 ×2、双手 ×2
bAutoRelease = true 意味着组件在系统 Complete 后自动回池。池化机制本身是好的,只是触发条件永远不成立。这一点很关键——如果只看生成侧的代码和资产配置,会得出「一切正常」的结论,然后卡住。
顺带说一句 Burst 路径的特性,它放大了这类问题的后果。项目里的 Cue 管理组件有两条路:
// Execute(Burst):句柄虽生成,但无任何移除机制
FGameplayCueHandle UGameplayCueManageComponent::ExecuteGameplayCueOnOwner(
const FGameplayTag GameplayCueTag, const FGameplayCueParametersProject& Parameters)
{
const FGameplayCueHandle NewHandle = GenerateHandle(...);
UGameplayCueFunctionLibrary::ExecuteGameplayCueOnActor(GetOwner(), GameplayCueTag, Parameters);
return NewHandle;
}
对比 AddGameplayCueOnOwner 配有 RemoveGameplayCueOnOwner 可显式移除,Execute/Burst 本质是 fire-and-forget。一旦它生成的组件自身不会自动结束,就没有任何回收途径。用 Burst 触发的特效,「能自己走到 Complete」是硬性前提。
为什么静态审计工具判 OK
项目里有个 Niagara 静态审计命令,跑这个资产的结论是 Verdict: OK。它检查的是 Emitter 级的 Loop Behavior——各个 emitter 确实都是 Once,配置完全正确。问题出在 Particle 级的 State 模块,不在检查范围内。
两级配置各自合规,组合起来却死锁。 这是静态一致性检查的固有盲区:它验证的是每一层的取值合法性,验证不了跨层语义。给这类工具补规则时,应该加上组合判定——emitter 为 Once、但粒子无限存活且不随 emitter 停用而死亡,就该告警。
修复与收益
在 Ribbon 发射器的 Particle State 模块中启用粒子生命周期终止,让系统能够 Complete,bAutoRelease 自然就正常回池了。两种改法:
| 改法 | 效果 | 适用 |
|---|---|---|
勾选 Let Infinitely Lived Particles Die When Emitter Deactivates | 保留无限寿命粒子,但发射器一停即杀 | 最小改动,视觉影响最小 |
勾选 Kill Particles When Lifetime Has Elapsed + 设有限 Lifetime | 粒子定时消失 | 拖尾本就该定时消失时 |
验证方法就是原样再跑一遍 DebugHud:修复前组件编号单调递增(_6.._11 → _12.._17 → _36.._41),修复后组件正常释放,编号不再无限增长。
预期收益:
| 指标 | 修复前 | 预期修复后 |
|---|---|---|
| 拖尾实例/帧 | 702 | 个位数 |
VectorVM_Legacy | 52.68 ms/帧 | ~1.8 ms/帧 |
| draw call | 942/帧 | ~210/帧 |
| primitive transform | 753/帧 | ~40/帧 |
| 段 7 帧时间 | 44.01 ms(22.7 FPS) | ~23 ms(约 43 FPS) |
这些数字是按段 6 水平推算的,写这篇时还没重抓 trace 实测。修完应该重跑同一战斗流程再抓一次确认——推算值不等于实测值。
排查过程中踩的坑
混合聚合计时器会掩盖子类型增长
分析段 1→6 的缓慢增长时,我一开始看 FActorComponentTickFunction::ExecuteTick 只涨 1.22x,就否定了「角色数量增长」这个假设。这是错的——该计时器是所有组件类型的混合大桶,把特定类型的增长稀释掉了。按类型拆开:
| 指标 | 段1 | 段3 | 段6 | 比值 |
|---|---|---|---|---|
| CMC Tick 次/帧 | 1.20 | 2.18 | 2.91 | 2.42x |
| CMC 单次耗时 (us) | 640.8 | 587.3 | 615.7 | 0.96x(持平) |
| SkinnedMesh Tick 次/帧 | 18.40 | 23.00 | 25.12 | 1.37x |
| 组件 Tick 次/帧(大桶) | 100.4 | 121.6 | 122.0 | 1.22x |
「数量涨 2.42x、单次耗时持平 0.96x」是「同类对象变多」的教科书特征。判断某类对象是否变多,必须拆到该类型自己的计时器。
量级上要打个折:CMC 从 1.2 涨到 2.9 个/帧,绝对增量只有约 1.7 个角色,对应 0.77 → 1.79 ms/帧,只占 +8.26 ms 总增量的 12%。方向成立,但远不是主要贡献者。
顺着这个特征往下猜,最可能的机制是死亡 NPC 的 CharacterMovementComponent 没停 Tick:AI 停了、物理不再重定位(与 Chaos NumMoving 持平吻合),但只要没有显式 SetComponentTickEnabled(false),CMC 的 tick 函数每帧照跑。观测到的全部特征(CMC 涨、物理不涨、AI 不涨)都对得上。
根因未明时先加限流,是治标
排查中途我曾经在根因还没定位时,先给资产挂了个 NiagaraEffectType 并设 MaxInstances=24 当实例上限。两个问题:
significance_handler = None,剔除逻辑可能根本不生效- 即便生效,也只是给「组件早该销毁却没销毁」开个溢流口
这个改动最后 revert 了。运行时实测(DebugHud)十分钟就定位了根因,比反复静态分析资产快得多。对于「实例为什么不释放」这类生命周期问题,应该优先走运行时观测,先看清楚再动手。
限流本身作为长期防回归手段仍然值得做——给全部战斗特效统一挂 EffectType、设 Max Instances 和距离剔除,是一次性投入。只是别拿它当根因修复。
大型 Niagara 资产不要走 dump 路径
这个闪避拖尾资产 1.17 MB(对一个拖尾特效来说异常大),对它执行资产 dump 会直接把编辑器搞崩;同资产三个月前的 dump 产物就有 3.3 MB。大型 Niagara 系统请用 DebugHud 和控制台审计命令看。
抓帧配置本身也有缺口
这次 trace 缺两类通道,导致部分结论只能靠相关性支撑:
- Niagara 逐系统 CPU 通道——缺了就无法把 VectorVM 开销 join 到具体资产(前面「归因边界」整节都在处理这个缺口)
- AI/GAS 命名计时器——本次 trace 里完全没有 BehaviorTree / EQS / GameplayEffect / GameplayCue / Attribute 等命名计时器。这不等于这些系统开销为零,更可能是被折叠进匿名蓝图 VM 计时器或通用
ExecuteTask桶里了。段 7 的Standalone graph event达 1081/帧、匿名任务 1204/帧,那里就是潜在的藏匿处。
「trace 里没看到」和「开销为零」是两回事。 下次抓帧前先确认要看的系统有没有打点。
同类风险
Particle State 那三个开关默认就是关闭的(这个默认值随 Niagara 版本变过,验证时留意自己的引擎版本)。任何用了 Ribbon + 无限寿命粒子、又靠 Burst Cue 触发的特效,都可能有一模一样的问题——而且静态审计查不出来。
同一份 trace 里 NS_Enemy_Wind_Storm 有 57.1 实例/帧,也偏高,是下一个该查的。更彻底的做法是对全部 Ribbon 类特效做一次批量审查。
还有一个容易忽略的连带风险:被改的那个 Ribbon 发射器是通用参数化模板。如果被其他特效继承或复用,这次改动会波及其他资产——改之前跑一次 Reference Viewer 确认影响范围。