Files
XCEngine/docs/used/NanoVDB体积云场景首帧阻塞根因修复计划_2026-04-10.md

21 KiB
Raw Blame History

NanoVDB 体积云场景首帧阻塞根因修复计划

日期2026-04-10

1. 文档定位

这份计划只解决一个具体问题:

  • project 项目中打开带有 cloud.nvdb 体积云对象的 Main.xc 场景时Editor 在前期可交互,但在约 8 秒后出现一次明显的整窗卡死,随后再经过十多秒体积云才真正显示出来。

这份计划不讨论以下内容:

  • 不讨论 build/package/runtime 发布格式。
  • 不讨论 NanoVDB 渲染算法本身是否继续升级。
  • 不讨论多后端 rollout。
  • 不讨论重新设计整个 Library 体系。

这份计划关注的是:

  • 为什么当前 Library 已经存在,但含 .nvdb 的场景打开仍然会在首帧附近严重阻塞。
  • 为什么 mvs/VolumeRenderer 用同一份 cloud.nvdb 可以约 3 秒完成显示,而 Editor 要慢很多。
  • 应该如何把当前这条链路收口成一套真正可用的正式方案。

2. 问题现象

当前复现现象已经很明确:

  1. 直接打开 project 项目。
  2. 打开 project/Assets/Scenes/Main.xc
  3. 界面会显示 Runtime streaming scene assets...,这段时间窗口还能继续操作。
  4. 在大约 8000 ms 左右Editor 突然开始明显卡死。
  5. 卡死十多秒后,体积云对象才真正显示出来,随后窗口恢复。

对比样本:

  • mvs/VolumeRenderer 使用的是同一份 cloud.nvdb
  • 运行 mvs/VolumeRenderer/run.bat 时,大约 3 秒左右即可加载并显示。

这说明:

  • 慢点不在“这份 .nvdb 根本无法解析”。
  • 慢点也不在“当前机器根本无法承载这份体积数据”。
  • 真正的问题出在 Editor 主线里的额外同步收口路径。

3. 当前已经确认的事实

3.1 当前 Library 对体积资源做的不是“运行时加速缓存”

当前源文件:

  • project/Assets/cloud.nvdb
  • 文件大小:590,241,000 bytes

当前 artifact

  • project/Library/Artifacts/.../main.xcvol
  • 文件大小:590,240,896 bytes

这说明当前 .xcvol 基本就是:

  • 一个较小的 header
  • 加上一份几乎原样的 NanoVDB payload

也就是说,当前 Library 在体积资源这条链路上做的是:

  • 导入身份缓存
  • metadata 缓存
  • source -> artifact 统一入口

但它还没有做到:

  • 为运行时准备更轻的 cooked payload
  • 为 GPU 上传准备更直接的 runtime-ready 数据
  • 为首帧显示准备真正低成本的预热结果

结论:

  • 当前 Library.nvdb 是“导入缓存”,不是“运行时性能缓存”。

3.2 当前 VolumeField CPU 侧存在多次大拷贝

当前体积资源进入运行时时,大致会经历:

  1. .xcvol 读取整个 payload 到临时缓冲。
  2. VolumeField::Create(...) 再把 payload 拷贝进 VolumeField::m_payload
  3. RenderResourceCache::UploadVolumeField(...) 再构造一个新的 uploadData
  4. 再把这份数据写入 RHI buffer。

对 590MB 级别的体积数据来说,这不是“小开销”,而是主路径上的重负担。

3.3 当前 GPU 上传不是在后台完成,而是在第一次真正绘制时同步触发

当前体积云真正进入渲染时,会在 BuiltinVolumetricPass::DrawVisibleVolume(...) 中走:

  • RenderResourceCache::GetOrCreateVolumeField(...)
  • UploadVolumeField(...)

也就是说:

  • 体积资源即使 CPU 侧已经异步读好了,
  • GPU 侧 residency 的真正建立仍然是在第一次真正绘制该体积对象时才触发,
  • 而且当前实现是同步发生在渲染路径里。

这正好解释了现在的现象:

  • 前期 Runtime streaming scene assets... 时还能操作。
  • 到第一次真正要画体积云时,主线程/渲染线程突然进入大开销同步路径。

3.4 当前体积 shader / PSO 首次创建也会叠加到这次阻塞里

当前体积材质使用的是:

  • builtin://shaders/volumetric

该 builtin shader 最终会加载:

  • engine/assets/builtin/shaders/volumetric.shader

这个 shader 直接包含:

  • PNanoVDB.hlsl

而当前 D3D12 shader 编译路径明确使用:

  • D3DCOMPILE_DEBUG | D3DCOMPILE_SKIP_OPTIMIZATION

这意味着:

  • 体积 pass 第一次真正创建 pipeline state 时,
  • 很可能还会在主线程/渲染线程上同步现编一个包含 PNanoVDB 的大 shader 变体。

因此这次卡死不是单一开销,而是两类重活叠加:

  1. 590MB 体积 payload 的运行时实现化
  2. 体积 shader / PSO 的首次真实可绘制化

3.5 当前场景加载状态文本会误导真实阶段

当前 Runtime streaming scene assets... 的状态主要依赖:

  • ResourceManager::GetAsyncPendingCount()

它只能说明:

  • 异步 loader 队列还有没有待完成任务

它不能说明:

  • GPU 上传是否已经完成
  • 体积 shader 是否已经编好
  • PSO 是否已经 ready
  • 体积对象是否真的达到 render-ready

所以现在 UI 上看到的“streaming 快结束了”并不等于体积云真的快 ready 了。


4. 最根本的原因

把这次问题压缩成一句话:

当前 Library 缓存住的是体积资源的“导入结果”,但没有缓存住体积云真正昂贵的“运行时实现化结果”;而这部分实现化又被推迟到了第一次真正绘制时同步完成。

更展开一点,根因可以拆成四层:

4.1 第一层根因:缓存层次不对

现在缓存住的是:

  • source asset 身份
  • artifact 文件
  • metadata

没有缓存住的是:

  • 低拷贝可消费的 volume payload 视图
  • GPU-ready residency
  • shader / PSO 可直接绘制状态

4.2 第二层根因:时机不对

当前重活发生在:

  • 不是项目打开时
  • 不是场景结构恢复后后台预热时
  • 而是在第一次真正绘制体积云时

这导致体感非常差,因为卡顿被集中释放在用户已经开始操作视口之后。

4.3 第三层根因:实现路径太重

当前 590MB 体积数据在 Editor 里会经历多次 CPU 侧复制和一次同步 GPU 上传。
对这种量级的数据,这本身就足以形成长时间阻塞。

4.4 第四层根因:渲染首帧还叠加了 shader / PSO 首编

体积 pass 的首次真正绘制不是“只差最后一次 draw call”而是还可能同时触发

  • shader variant 首编
  • pipeline layout 创建
  • PSO 创建
  • descriptor set 初始化

所以这次卡死是“重资源 + 重 shader”叠加不是单点故障。


5. 为什么 mvs/VolumeRenderer 更快

mvs/VolumeRenderer 当前更快,不是因为它“缓存更高级”,而是因为它路径短得多。

它做的是:

  • 直接读源 .nvdb
  • 直接生成 GPU buffer
  • 直接编少量 shader
  • 直接渲染

它没有承担下面这些 Editor 主线额外职责:

  • AssetDatabase
  • AssetRef -> path 解析
  • scene/component 反序列化恢复
  • VolumeField 运行时抽象包装
  • RenderResourceCache 的通用缓存层
  • BuiltinVolumetricPass 的通用 descriptor / pass / pipeline 约束
  • 视口首帧时的 editor 额外渲染流

所以 MVS 快,说明的是:

  • 这份 .nvdb 并不是天然慢到 15 秒

而不是说明:

  • 现在的 Editor 路线只需要继续堆更多导入缓存就能变快

6. 修复目标

本轮修复目标不是“让 .xcvol 体积更小”,也不是“强行把所有工作前置到项目启动”。
本轮的正式目标是:

6.1 交互目标

  • 打开 Main.xc 后,不允许再出现“先可交互,再突然长时间整窗卡死”的体验。
  • 一旦场景已经进入可交互状态,后续体积云就只能继续后台预热,不能把窗口重新拖回不可响应。

6.2 架构目标

  • 把体积资源的“导入缓存”和“运行时实现化”明确分成两个阶段。
  • 首次绘制路径不能再承载 590MB 级别的同步重活。
  • 首次绘制路径只能消费已经 ready 的资源,或优雅跳过未 ready 的体积对象。

6.3 性能目标

在当前开发机和当前 cloud.nvdb 样本下:

  • warm cache 场景再次打开时:
    • 不允许出现超过 200 ms 的二次窗口无响应段。
    • 体积云从场景打开到可见的时间目标收敛到 3 s 以内。
    • 该目标以当前 mvs/VolumeRenderer 的约 3 s 作为对齐基线,正式目标是不慢于 MVS。
  • cold import 首次打开时:
    • 允许总体耗时更长,
    • 但不允许在体积资源真正显示前出现长时间窗口卡死。

6.4 诊断目标

  • 必须能明确区分:
    • scene structure ready
    • CPU payload ready
    • GPU upload in progress
    • shader / PSO prewarm in progress
    • render-ready

7. 本轮明确不做的错误修法

以下方案不能作为本轮主方案:

7.1 不能只继续优化 AssetDatabase::EnsureArtifact()

原因:

  • 这次 warm cache 场景下的主问题已经不是 source import 了。
  • 再继续只抠导入判定和 reimport不会解决“第一次真正绘制才卡死”。

7.2 不能只加更多“异步读文件”

原因:

  • 当前前半段已经异步了。
  • 真正卡死点在后半段 render-time realization。

7.3 不能一上来就先加第二套磁盘缓存目录

原因:

  • 当前最大问题首先是同步时机和多次拷贝。
  • 如果不先把“谁在什么时候做重活”改对,再加新 cache 文件夹只是继续堆复杂度。

7.4 不能只通过隐藏 UI 文本来掩盖问题

原因:

  • 现在不是提示文案不对,而是真有一段重度同步阻塞。

8. 正式修复方向

本轮采用四条主线并行收口,但执行顺序必须严格分阶段。

8.1 主线 A先把真实耗时切开看清楚谁最重

虽然根因已经明确,但时间占比仍需正式打点。
本阶段必须先拿到真实分段耗时,不允许后续继续靠体感猜。

需要新增的时间切片:

  1. Scene Deserialize
  2. AssetRef Resolve
  3. VolumeFieldLoader.ReadArtifact
  4. VolumeField.Create
  5. Volume GPU Upload
  6. Volumetric Shader Variant Compile
  7. Volumetric PSO Create
  8. First Volume Visible

需要新增的日志与状态:

  • Main.xc 打开时,针对 cloud.nvdb 输出完整链路耗时。
  • 把“异步流式加载完成”和“体积 render-ready”拆开显示。

这一阶段的目的不是修性能,而是:

  • 锁死真正的大头时间占比
  • 避免后续错误优化无关路径

8.2 主线 B去掉体积 payload 的多次大拷贝

这是本轮最核心的工程改动之一。

正式方向:

  1. VolumeField 不能继续默认把 590MB payload 再拷进一份新的 m_payload
  2. .xcvol 读取后,应该改成:
    • 文件映射
    • 或共享只读 blob
    • 或单所有权 payload 容器
  3. RenderResourceCache::UploadVolumeField(...) 不能再额外构造一份等体积的 uploadData 再拷一次。
  4. 上传路径必须直接消费 loader 产出的只读 payload 视图。

本阶段完成后,应达到:

  • .xcvol -> VolumeField 不再发生无意义的大内存复制。
  • VolumeField -> RenderResourceCache 也不再产生第二份 590MB 临时副本。

这一步做完,即使还没异步 GPU 上传,卡顿也会先明显下降。

8.3 主线 C把 GPU 上传从首次绘制路径里拿出去

当前真正错误的不是“上传很重”,而是“上传发生在第一次真正 draw 的时候”。

正式方向:

  1. VolumeField 建立明确的 runtime residency 状态机:
    • Unloaded
    • CpuReady
    • GpuUploading
    • GpuReady
    • Failed
  2. CPU 侧 payload 一旦 ready就立即进入独立的 GPU 预热队列。
  3. BuiltinVolumetricPass::DrawVisibleVolume(...) 不允许再承担首次重量级上传。
  4. draw path 的职责改为:
    • 如果 GpuReady,正常绘制
    • 如果未 ready跳过或显示占位不得同步收口

对 D3D12 的具体要求:

  1. BufferType::Storage 不能继续把大体积 volume payload 当普通 upload-heap 常驻缓冲来处理。
  2. 需要引入正式的:
    • staging/upload buffer
    • default heap storage buffer
    • copy queue 或专用 upload path
  3. volume buffer 上传完成后再切换到可读状态,而不是在 draw path 上临时补。

这一步是解决“8000 ms 后突然卡死”的主修复点。

8.4 主线 D把体积 shader / PSO 首编从体积首帧里拿出去

当前体积云第一次真正绘制时,渲染链路里还会叠加:

  • builtin volumetric shader variant 首次真正编译
  • pipeline layout / descriptor layout 构建
  • PSO 创建

正式方向:

  1. builtin://shaders/volumetric 的实际运行时使用变体需要正式预热。
  2. 预热时机不放在“第一次真正 draw”。
  3. 预热应在以下时机之一完成:
    • scene structure ready 之后的后台预热阶段
    • volume material 绑定后立刻排队预热
  4. BuiltinVolumetricPass 首次执行时只能命中已存在的 shader variant / PSO cache。

当前 D3D12 路线还要额外处理一个问题:

  • 现在编译 flags 是 D3DCOMPILE_DEBUG | D3DCOMPILE_SKIP_OPTIMIZATION

这本身会显著拉长编译时间。
本轮需要明确一条正式策略:

  1. Debug 能力是否仍要保留。
  2. 如果保留,默认 editor 交互路径不能在首帧同步承担这份成本。
  3. 可以接受“后台慢编”,不能接受“首帧卡死慢编”。

8.5 主线 E修正场景加载进度模型

当前 Runtime streaming scene assets... 的语义不完整。
这会直接误导调试,也会误导后续调度逻辑。

正式方向:

  1. 把 scene load progress 拆成四段:
    • Structure Ready
    • CPU Asset Streaming
    • GPU Residency Prewarm
    • Render Warmup
  2. pendingAsyncLoads == 0 时,只能说明 CPU loader 阶段接近结束。
  3. 只有 volume GPU ready 且体积 pass 关键 shader/PSO ready 时,才能算真正 ready。
  4. UI 需要把这几个阶段明确显示出来,避免再把后半段卡顿误判成“明明 streaming 都结束了却又卡”。

9. 执行阶段

Phase 0诊断与基线固化

目标:

  • 用日志和时间切片把当前问题彻底量化。

任务:

  1. Main.xccloud.nvdb 打通完整性能时间线。
  2. 输出 warm cache / cold cache 两组基线。
  3. 输出 MVS 对比基线。

验收标准:

  1. 能明确给出 CPU 读取、CPU 拷贝、GPU 上传、shader 编译、PSO 创建各自耗时。
  2. 不再用“感觉像是这里慢”做判断。

Phase 1体积 payload 低拷贝重构

目标:

  • 消除 .xcvol -> VolumeField -> UploadVolumeField 链路中的重复大拷贝。

任务:

  1. 重构 VolumeField 的 payload 持有方式。
  2. 重构 .xcvol loader 的结果表示。
  3. 重构 RenderResourceCache::UploadVolumeField(...) 的输入形式。

验收标准:

  1. warm cache 情况下CPU 内存峰值明显下降。
  2. VolumeField 载入完成时不再出现等量级的重复 payload 副本。

Phase 2异步 GPU 上传与渲染脱钩

目标:

  • 第一次绘制体积对象时,不再承担首次 GPU upload。

任务:

  1. 增加 volume GPU prewarm 队列。
  2. 建立 GPU residency 状态机。
  3. 改掉 draw path 的同步上传兜底。

验收标准:

  1. 首次打开 Main.xc 后,不再在体积第一次出现在视野中时发生长时间整窗阻塞。
  2. 体积对象未 ready 时允许暂时不显示,但不允许卡死窗口。

Phase 3体积 shader / PSO 预热

目标:

  • 不再把 volumetric.shader 的首次真实可绘制准备放在体积首帧。

任务:

  1. 为 active backend 预热体积 shader variant。
  2. 为 volume material / pass 预热关键 PSO。
  3. 把 shader/PSO 首编首建从 draw path 移走。

验收标准:

  1. 首次显示体积云时,不再同时伴随大段 shader/PSO 同步创建时间。
  2. 日志能证明首帧命中的是已准备好的可绘制状态。

Phase 4场景状态机与 UI 收口

目标:

  • 让场景打开状态和真实资源准备阶段一致。

任务:

  1. 拆分 load status 阶段。
  2. 修正 Runtime streaming scene assets... 的语义。
  3. 在 Project/Viewport/Console 中统一反映同一份阶段状态。

验收标准:

  1. UI 文案与真实阶段一致。
  2. 不再出现“streaming 结束了但实际上后面还有一次大卡死”的错误认知。

Phase 5回归、压力样本与收口

目标:

  • 用真实样本和自动化验证确认这条路线已经稳定。

任务:

  1. 用当前 cloud.nvdb 做 warm/cold 双场景回归。
  2. 回归 Main.xc 打开、关闭、再次打开。
  3. 检查体积云显示、Editor 响应性、日志阶段切片。

验收标准:

  1. warm cache 打开 Main.xc 时无二次长阻塞。
  2. cold cache 首次导入时也保持可交互。
  3. 体积云显示时间显著收敛,接近 MVS 量级。

10.1 当前执行进展2026-04-10

当前代码实现已经进入正式重构阶段,已落地的第一批改动如下:

  1. VolumeField 新增 owned payload 创建路径,.xcvol 读取结果不再在 VolumeField::Create(...) 内发生第二次整块复制。
  2. VolumeFieldLoader 的 artifact 载入路径已改为“读入 payload -> 直接 move 进 VolumeField”,去掉 artifact load 阶段的重复大拷贝。
  3. RenderResourceCache::UploadVolumeField(...) 不再构造整块 uploadData 临时副本,改为直接消费 VolumeField payload。
  4. RenderResourceCache 已为 volume 引入最小可用的 residency 状态:
    • Uninitialized
    • Uploading
    • Ready
    • Failed
  5. volume GPU 上传已改为分帧推进,当前实现按固定 chunk 预算逐帧写入,避免第一次真正绘制时一次性同步吞下整块 payload。
  6. BuiltinVolumetricPass 已拆出资源预热步骤:
    • 先推进 volume upload
    • 再预建 volume pass layout / pipeline
    • draw path 只消费 Ready 的 volume未 ready 时直接跳过,不再兜底触发重量级上传
  7. 当前已补最小日志:
    • Volume GPU upload started
    • Volume GPU upload ready

这批改动的意义是:

  • 先把 warm cache 路径里最重的两类同步收口拆开:
    • .xcvol -> VolumeField 的重复 CPU 大拷贝
    • 首次 draw path 内的一次性整块 GPU 上传
  • 先把“卡死 Editor”问题从根上打散成可推进、可观察、可继续优化的状态。

当前这批改动还没有完成的部分:

  1. 还没有把 .xcvol 升级成真正 runtime-ready 的 cooked artifact当前只是先把现有 artifact 的运行时消费链路做轻。
  2. 还没有补齐完整的分段耗时打点。
  3. 还没有把 volumetric shader / PSO 的首编完全前移到更早阶段。

11. 涉及模块范围

预计会涉及但不限于以下模块:

  • engine/include/XCEngine/Resources/Volume/VolumeField.h
  • engine/src/Resources/Volume/VolumeField.cpp
  • engine/src/Resources/Volume/VolumeFieldLoader.cpp
  • engine/include/XCEngine/Rendering/Caches/RenderResourceCache.h
  • engine/src/Rendering/Caches/RenderResourceCache.cpp
  • engine/include/XCEngine/Rendering/Passes/BuiltinVolumetricPass.h
  • engine/src/Rendering/Passes/BuiltinVolumetricPass.cpp
  • engine/src/RHI/D3D12/D3D12Device.cpp
  • engine/src/RHI/D3D12/D3D12Buffer.cpp
  • engine/src/Resources/BuiltinResources.cpp
  • editor/src/Managers/SceneManager.cpp
  • editor/src/Viewport/ViewportHostService.h

如果 Phase 2 仍然不足,再考虑是否需要新增专门的 runtime-prewarm 辅助模块。
但在本轮中,不应先为了结构好看而过早引入新的大模块。


12. 风险与边界

11.1 风险一:只优化 CPU 拷贝,但仍保留首帧同步 GPU 上传

结果:

  • 会变快,但不会从根上解决“突然卡死”。

11.2 风险二:只做 GPU 上传异步,但 shader / PSO 首编仍在首帧

结果:

  • 体积数据路径变快,但体积首帧依然可能因为 shader 现编而卡死。

11.3 风险三:一上来先设计新的磁盘 runtime cache

结果:

  • 复杂度先上去了,
  • 但如果真正的大头是同步上传和首编,收益会被高估。

因此本轮策略必须是:

  1. 先打点
  2. 先移走同步重活
  3. 再决定是否需要更重的磁盘级 runtime cache

13. 完成标志

当以下条件同时成立时,这份计划才算完成:

  1. 打开 project/Assets/Scenes/Main.xcEditor 不再出现二次长时间整窗卡死。
  2. cloud.nvdb 的 warm cache 路径已经不再依赖首次绘制时的同步重资源收口。
  3. 体积 shader / PSO 的首次准备不再挤在体积首帧。
  4. 场景进度状态能正确区分 CPU streaming、GPU prewarm 和 render-ready。
  5. 当前这条路径的耗时分布可以通过日志直接解释,不再需要靠猜。

14. 一句话结论

这次问题的根不在“Library 有没有命中”,而在“当前 Library 只缓存了导入结果,没有缓存运行时真正昂贵的实现化结果,而且这部分实现化被错误地放到了体积第一次真正绘制时同步完成”。
本轮修复必须围绕这个根因展开,而不是继续把注意力放回导入判定本身。