UE5.8 Take Recorder Niagara Sim Cache 崩溃:根因定位与修复方案

Summary

UE5.8 Take Recorder Niagara Sim Cache 崩溃:根因定位与修复方案

0. 文档状态

项目 内容
记录日期 2026-08-06(第二轮独立调查,取代旧报告的"未定位到引擎源码函数"结论)
前置文档 UE58_Niagara_TakeRecorder_SimCache_Crash_Report.md(现象与测试矩阵仍有效,其"当前推断"章节已被本文取代)
调查状态 已定位到引擎源码级根因,崩溃函数、触发数据、竞态时序均有独立证据
修复状态 项目级自动修复钩子已安装(Content/Python/tr_niagara_cache_fix.py),待真实录制回归验证
引擎版本 5.8.1-56057345+++UE5+Release-5.8(Launcher 安装版,含完整源码,无 PDB)

1. 一句话根因

Take Recorder 停止录制时在同一帧内同步保存了 take 资产,而 Niagara Sim Cache 的终结函数 EndWrite() 被推迟到下一帧才执行且不标脏包——导致磁盘上的缓存永远带着未转换的"插值映射表"(内容是粒子 UniqueID 而非帧间索引)。一旦资产从磁盘重新加载(编辑器重启,或 MRQ 触发 GC 后重载),任何"存活帧→空帧"边界上的插值读取都会对空缓冲区解引用,产生 EXCEPTION_ACCESS_VIOLATION reading 0x0

2. 崩溃点符号化(无 PDB,导出符号 + 反汇编定位)

52 个崩溃转储中 7 种不同 PCallStackHash 实为同一崩溃点,顶部 5 帧偏移完全一致,仅调用入口不同(游戏线程 UNiagaraComponent::TickComponent 或 TaskGraph 异步任务):

偏移 函数(导出符号最近邻 + 运行时函数表)
+84c261 UNiagaraSimCache::Read(float, FNiagaraSystemInstance*)(EXACT)
+84ec79 UNiagaraSimCache::ReadFrame(int32, float, FNiagaraSystemInstance*)(EXACT)
+84d179 FNiagaraSimCacheHelper::ReadDataBuffer(内联于 NiagaraSimCache.cpp 编译单元)
+84ce1b ReadFloatBuffers/ReadCustomBuffers 内部帧
+7efd15 插值拷贝函数(InterpFloats/InterpPositions 家族)内的崩溃指令

崩溃指令反汇编(UnrealEditor-Niagara.dll RVA 0x7efd15):

7efcfc: mov  rax, [rsi]              ; InterpMapping 数组
7efd08: mov  edx, [rax+rcx*4]       ; idx = InterpMapping[i]
7efd0b: cmp  edx, -1                 ; INDEX_NONE 则跳过帧B
7efd15: movss xmm0, [rbp+rdx*4]     ; ← 崩溃:帧B FloatData 基址(rbp)为 NULL
7efd1b: subss xmm0, xmm1             ; lerp(A, B, FrameFraction)

语义:Dest[i] = lerp(SourceA[i], SourceB[InterpMapping[i]], FrameFraction)。当插值目标帧 B 的粒子数为 0 时其 FloatData 为空数组(GetData()==nullptr);此时只要 InterpMapping[i] != INDEX_NONE 就必然读空指针。

3. 引擎源码证据链(均为 5.8.1 随引擎安装的源码)

  1. 插值映射的正确形态由 EndWrite() 生成
    Niagara/Private/NiagaraSimCache.cpp:785-870——UNiagaraSimCache::EndWrite() 把每帧 InterpMapping 从"写入时暂存的粒子 UniqueID"转换为"指向下一帧同 ID 粒子的索引,粒子消亡则 INDEX_NONE",并把最后一帧全部置 INDEX_NONE。
    关键事实:UniqueID 是从 0 开始的小整数,未转换时看起来就像合法索引,且写入路径(NiagaraSimCacheHelper.h:719-725 WriteDataBuffer)暂存的正是 UniqueID。

  2. Take Recorder 的 EndWrite 被推迟一帧
    NiagaraSimCaching/.../MovieSceneNiagaraTrackRecorder.cpp

    • FinalizeTrackImpl()(第 198-201 行)只设置 bRequestFinalize = true
    • 真正的 EndWrite(true)OnRecordFrame(绑定于 GEngine::OnPostEditorTick,第 257-275 行)里执行——即停止录制后的下一次编辑器 Tick
    • EndWrite 前后没有任何 Modify()/MarkPackageDirty()
  3. take 资产在同一帧内被同步保存(早于 EndWrite)
    TakeRecorder.cpp::StopInternal:第 1337 行 Sources->StopRecording(...)(内部触发各 track recorder 的 Finalize 标记)→ 第 1409/1415 行同步保存根序列;
    TakeRecorderSources.cpp::StopRecording 第 700-717 行同步保存所有 subsequence 资产(Niagara Cache 的 SimCache 对象正是 outer 在 subscene 的 section 上)。
    ⇒ 保存发生在 EndWrite 之前,且 EndWrite 之后包不脏、永远不会再落盘。

  4. 插值是引擎强制开启的,不是项目配置
    MovieSceneNiagaraCacheTrack.cpp:85-86:每个 Take Recorder 创建的 Niagara Cache section 都被强制 CacheParameters.bAllowInterpolation = true; bAllowVelocityExtrapolation = true;FNiagaraSimCacheCreateParameters 本身的默认值是 false)。
    所有 UE5.8 用户用 Take Recorder 录 Niagara 都会生成带插值映射的缓存,全部暴露在此竞态下。

4. 数据证据(本工程实测)

4.1 缓存内容(无头探针,read_position_attribute 逐帧计数)

两把枪的缓存(Scene_1_05 两个 subscene 内嵌 NiagaraSimCache_0,30 帧,时长 0.9667s):

Actor1: [16, 10, 0, 0, ..., 0]   ← 帧0=16粒子, 帧1=10, 帧2起全空
Actor2: [16, 12, 0, 0, ..., 0]

死亡边界帧对 = (帧1 → 帧2空)。枪口闪光只存活 2 帧,其余 28 帧为空帧。
section CacheParameters 实测:allow_interpolation=True, interpolate_all_types=True, allow_velocity_extrapolation=True(RenderingOnly 捕获模式)。

4.2 磁盘字节证据(毒资产指纹)

对两个 subscene .uasset 的二进制扫描:

  • 偏移 0x13d1d 处均存在连续递增 uint32 序列 0..15(帧 0 的 InterpMapping = 原始 UniqueID,16 个粒子按出生顺序编号);
  • 全文件不存在任何 ≥4 dword 的 0xFFFFFFFF——若 EndWrite 执行过,帧 1 的映射应为 10~12 个连续 INDEX_NONE(0xFFFFFFFF)

两份缓存在磁盘上均为未终结的"生"数据。这同时提供了免崩溃的毒资产检测指纹:

  • 毒:帧 0 映射为纯递增 0..N-1 且无 FF 串;
  • 健康:帧(死亡边界)映射为连续 FFFFFFFF

4.3 运行时复现

  • 无 Sequencer/MRQ/TakeRecorder 参与:无头编辑器中 SetSimCache + DesiredAge 扫描 Actor1 缓存 → 进程在死亡边界年龄段崩溃(exit -1,日志戛然而止)。
  • 用户 52 个崩溃转储中全部 AV 崩溃与该签名一致(含 F872B158... 主哈希,读地址 0x0)。
  • 注意:-RenderOffscreen 无头编辑器不可靠(视口非实时 → Niagara 组件不 tick → 读取路径不执行,会产生假阴性)。运行时验证必须在真实编辑器或 -game 模式做。

5. 症状完整解释

症状 机制
刚录完直接播放 sequence 没问题 停止录制的下一帧 EndWrite 修正了内存中的映射表;本会话内评估用的都是这份健康内存数据
进 MRQ 闪退 MRQ 渲染会关闭 Sequencer 编辑器并激进 GC → subscene 资产从磁盘重载(生数据);MRQ 逐帧确定性采样必然落进"存活帧→空帧"的插值窗口
重启编辑器后连播放都闪退 一切从磁盘加载,播放时实时采样落进死亡边界窗口即崩(窗口宽度=该帧对的实际录制间隔,命中与否与帧时序有关,故偶发"第一遍过、第二遍崩")
旧报告 T09/T10"第一枪过、第二枪崩" 两份缓存都是毒资产;差异只在死亡边界窗口的宽度/位置与采样时序的命中概率
崩溃前后资产 SHA-256 不变 本质不是资产被"写坏",而是从未被写对;EndWrite 修正只存在于内存,从不落盘

6. 修复方案

6.1 已安装:项目级自动修复钩子(零引擎改动)

Content/Python/tr_niagara_cache_fix.py(由 Content/Python/init_unreal.py 自动加载):

  • 监听 TakeRecorderSubsystem.TakeRecorderFinished
  • 等 5 个编辑器 Tick(确保推迟的 EndWrite 已执行、内存映射已转换);
  • 递归查找该 take 及其所有 subscene 中带 MovieSceneNiagaraCacheTrack 的序列,强制重存save_loaded_asset(only_if_is_dirty=False))。

效果:磁盘资产 = 终结后的健康数据,重启/MRQ 均安全。对录制流程零侵入。

6.2 现有毒 take 的处置

已存在的 Scene_1_04Scene_1_05 各 subscene 缓存无法脚本原位修复EndWrite 未暴露给脚本,内存中也已是生数据)。选项:

  1. 重录 take(推荐,钩子已装,新 take 自动健康);
  2. Sequencer 里对 Cache Track 执行重录(Cache re-record,走 CacheTrackRecorder 流程),完成后手动保存 subscene——保存发生在 EndWrite 之后,同样治愈;
  3. 维持旧规避:静音 Cache Track + Auto Activate 实时特效。

6.3 可选加固:关闭插值录制

录制前把 Cache section 的 CacheParameters.bAllowInterpolation / bAllowVelocityExtrapolation 设为 false:缓存不再生成插值映射,彻底免疫此竞态(30fps 录制 30fps 渲染无画质损失)。可作为对钩子的双保险,但装了 6.1 后非必需。

6.4 引擎级正确修复(建议随 bug 报告提交 Epic)

任选其一即可根治:

  • UMovieSceneNiagaraTrackRecorder::FinalizeTrackImpl()同步调用 EndWrite(不再等下一帧);或
  • OnRecordFrame 的 finalize 分支在 EndWrite 后对 SimCache 的包 MarkPackageDirty()(让后续保存能落盘);或
  • UTakeRecorderSources::StopRecording 把 subscene 保存推迟到所有 track recorder 真正终结之后。
  • 防御性修复:FNiagaraSimCacheHelper::ReadCustomBuffers 在帧 B FloatData 为空时跳过插值(按 A 帧直读),使生数据最多表现为画面错误而非崩溃。

7. 验收清单(对应旧报告第 13 节)

  • 钩子机制端到端验证(2026-08-06 无头编辑器实测):TakeRecorderFinished 委托触发 → 5 tick 后自动重存正确的 subscene,引擎日志 [TRNiagaraCacheFix] installed / re-saved 1/1 sequence(s)(测试 take Scene_1_06,无头下 Niagara 不 tick 故只捕 1 帧,字节指纹判读需真实编辑器录制的多帧样本);
  • 真实编辑器录制新 take Scene_1_07(2026-08-06):暴露钩子 v1 缺陷——面板 UI 发起的录制不会广播 TakeRecorderSubsystem.TakeRecorderFinished,钩子未触发,4 个 subscene 落盘即毒指纹(0..15 原始 UniqueID、零 FF 串)。钩子 v2 已加轮询兜底is_recording() 状态沿),任何发起方式均可捕获;
  • 会话内手动触发 _resavere-saved 4/4)后字节指纹翻转为健康态:每个 subscene 出现 10~13 dword 的 FFFFFFFF 死亡边界映射串、原始递增串消失(Actor2 残留 0..6 为正确的原位存活重映射)——毒→愈的同资产对照证据成立
  • 新 take Scene_1_07(治愈后):MRQ/MRG 连续渲染 2 次不闪退、特效画面正确(2026-08-06 用户实测;唯一弹窗为既有的 TSR FTSRRejectShadingCS PSO 预缓存 ensure,哈希 8AC020C8,自 08-03 即存在,与本 bug 无关,可用 r.PSOPrecache.Validation=0 关闭);
  • 新 take:冷重启后父序列连续播放 ≥5 次不闪退(建议顺手验证);
  • 旧毒 take(Scene_1_04/1_05)重录后同样通过上述检查。

8. Epic 报告模板(更新版)

UE 5.8.1 Take Recorder + Niagara Sim Cache: recorded take assets are saved
before UNiagaraSimCache::EndWrite runs, leaving un-finalized interpolation
mappings on disk -> guaranteed EXCEPTION_ACCESS_VIOLATION (read 0x0) on reload.

Chain:
1. UMovieSceneNiagaraCacheTrack::CreateNewSection forces
   CacheParameters.bAllowInterpolation = true (MovieSceneNiagaraCacheTrack.cpp:85).
2. During recording, WriteDataBuffer stages raw particle UniqueIDs in each
   frame's InterpMapping (NiagaraSimCacheHelper.h:719-725).
3. UNiagaraSimCache::EndWrite converts these to next-frame indices /
   INDEX_NONE (NiagaraSimCache.cpp:812-870). For Take Recorder this call is
   deferred to the next editor tick (MovieSceneNiagaraTrackRecorder.cpp:198,270)
   and does not dirty the package.
4. UTakeRecorder::StopInternal saves the take and every subscene synchronously
   in the same stack (TakeRecorder.cpp:1409, TakeRecorderSources.cpp:714) -
   i.e. BEFORE EndWrite. The corrected mapping never reaches disk.
5. Raw UniqueIDs alias valid indices. After the asset is reloaded (editor
   restart, or MRQ closing Sequencer + GC), any interpolated read whose next
   frame has fewer/zero particles dereferences a null FloatData buffer:
   UNiagaraSimCache::Read -> ReadFrame -> interp copy, crash offsets
   +7efd15/+84ce1b/+84d179/+84ec79/+84c261 in UnrealEditor-Niagara,
   PCallStackHash F872B158DE32273B333D43CEE376A5A37A52D6CD.

Repro (100%, no MRQ needed): record any burst-type Niagara effect (particles
die before recording stops) with Take Recorder; restart the editor; play the
take (or set the cache on a component with DesiredAge sweeping across the
last-alive -> first-empty frame pair).

Disk evidence: the saved subscene uasset contains the frame-0 mapping as a
strictly ascending 0..N-1 uint32 run and no INDEX_NONE runs anywhere;
re-saving the asset after EndWrite has run produces the expected INDEX_NONE
runs and eliminates the crash.

Suggested fixes: call EndWrite synchronously in FinalizeTrackImpl (or dirty
the package after the deferred EndWrite / save subscenes after finalize), and
defensively skip interpolation in FNiagaraSimCacheHelper::ReadCustomBuffers
when the target frame's buffers are empty.

9. 本轮新增工具

工具 位置 用途
自动修复钩子 Content/Python/tr_niagara_cache_fix.py 录制后自动重存,根治新 take
任务注入 shim Content/Python/init_unreal.py 加载钩子;CLAUDE_UE_TASK 环境变量注入无头自动化脚本
缓存探针 scratchpad probe_cache.py 无崩溃 dump 缓存参数/逐帧粒子数
毒资产字节检测 见 4.2 指纹(可按需脚本化) 免运行时判断 take 是否健康
符号化脚本 scratchpad symbolicate.py / disasm.py 无 PDB 的导出符号最近邻定位 + 反汇编

What Type of Bug are you experiencing?

Rendering (Graphics / Niagara)

Steps to Reproduce

| Take Recorder刚录完包含Niagara的片段,直接播放 sequence 没问题 | 停止录制的下一帧 EndWrite 修正了内存中的映射表;本会话内评估用的都是这份健康内存数据 |
| 进 MRQ 闪退 | MRQ 渲染会关闭 Sequencer 编辑器并激进 GC → subscene 资产从磁盘重载(生数据);MRQ 逐帧确定性采样必然落进"存活帧→空帧"的插值窗口 |
| 重启编辑器后连播放都闪退 | 一切从磁盘加载,播放时实时采样落进死亡边界窗口即崩(窗口宽度=该帧对的实际录制间隔,命中与否与帧时序有关,故偶发"第一遍过、第二遍崩") |
| 旧报告 T09/T10"第一枪过、第二枪崩" | 两份缓存都是毒资产;差异只在死亡边界窗口的宽度/位置与采样时序的命中概率 |
| 崩溃前后资产 SHA-256 不变 | 本质不是资产被"写坏",而是从未被写对;EndWrite 修正只存在于内存,从不落盘 |

Expected Result

Take Recorder 录制 → Sequencer 播放 → MRQ 渲染,特效(Niagara Sim Cache)状态正常,全程无闪退。

Observed Result

Take Recorder 录制(Char Niagara) → Sequencer 播放 → MRQ 渲染 → Unreal5.8 Crash

Affects Versions

5.8

Platform(s)

Windows

Upload an image