# NanoVDB 体积云加载阻塞与 Runtime 上传修复计划 文档日期:2026-04-10 适用范围:当前 `XCEngine` 的 `Resources / Asset / Rendering / RHI / Editor` 主线,目标问题为 editor 打开主场景后 `cloud.nvdb` 体积云加载时间长、首帧解锁后再次长时间卡死,而 `mvs/VolumeRenderer` 使用同一份 `cloud.nvdb` 仅约 3 秒即可完成加载与显示。 文档目标:把当前 editor 中 `NanoVDB` 体积云的加载链路,从“CPU 异步读完后在首个可见渲染帧同步创建并写入大体积 GPU 资源,导致主线程长时间阻塞”的错误运行模式,重构为接近 `mvs/VolumeRenderer` 的正确模式,即“CPU 异步读取 + GPU 本地 buffer 上传 + 上传完成前不阻塞编辑器交互 + 运行时不再把大体积 payload 留在 draw path 上处理”。 --- ## 1. 问题结论 当前问题不是单点 bug,而是三段成本叠加: 1. `AssetDatabase` 对 `.nvdb` 的 `main.xcvol` artifact 只是“metadata + 原始 NanoVDB payload”包装,几乎没有降低运行时 payload 成本。 2. `VolumeRendererComponent` 的 deferred scene load 只把 CPU 资源异步读到 `VolumeField`,没有提前完成 GPU 上传。 3. `BuiltinVolumetricPass` 首次真正消费 `VolumeField` 时,`RenderResourceCache::UploadVolumeField()` 在渲染线程同步创建 `StorageBuffer` 并调用 `SetData()` 写入整个 payload,直接把首个可见帧变成一次大块同步上传。 最关键的问题在第 3 条。 当前 D3D12 后端里: - `BufferType::Storage` 默认走 `UPLOAD heap` - `SetData()` 走 `Map + memcpy` - 于是体积云最终 shader 访问的不是 GPU 本地 `DEFAULT heap` buffer,而是 CPU 可写的 upload buffer 这与 `mvs/VolumeRenderer` 的链路根本不同。`mvs` 是: 1. 创建最终 `DEFAULT heap` buffer 2. 创建临时 `UPLOAD heap` staging buffer 3. CPU 只写 staging 4. 通过 `CopyBufferRegion` 拷到默认堆 5. 之后 shader 从 GPU 本地 buffer 读取 因此,第一阶段收益最大的修复,不是继续优化 artifact 文件,而是把 editor/runtime 的 volume buffer 上传路径改成和 `mvs` 同构。 --- ## 2. 现状与根因拆解 ## 2.1 当前 editor 链路 ### 场景打开阶段 1. `SceneManager::LoadScene()` 在 deferred scene load 作用域中恢复场景结构。 2. `VolumeRendererComponent` 仅恢复 `volumeRef -> path`,不立即同步 load。 3. 直到渲染抽取阶段调用 `GetVolumeField()` 时,才触发 `LoadAsync()`。 4. editor 状态栏中的 `Runtime streaming scene assets...` 只反映 CPU 侧异步资源请求计数。 ### CPU 资源完成阶段 1. `LoadAsync()` 完成后,`VolumeField` 已在 CPU 内存中可用。 2. 此时 editor 可以结束“streaming”状态,但 GPU 尚未完成 volume payload 驻留。 3. 首个真正绘制体积云的 pass 进入 `BuiltinVolumetricPass::DrawVisibleVolume()`。 4. `RenderResourceCache::GetOrCreateVolumeField()` 发现无缓存,触发 `UploadVolumeField()`。 5. `UploadVolumeField()` 在渲染线程里同步分配 buffer / SRV,并写入整个 payload。 ### 结果 1. 用户前面数秒可以交互,因为 CPU 异步读取没有阻塞主线程。 2. 当 CPU 资源可用且首次 draw 发生时,主线程突然承担完整 GPU 上传。 3. 由于 payload 约 `590 MB`,首个可见渲染帧被长时间卡死。 ## 2.2 当前 artifact 链路的问题边界 `cloud.nvdb` 已经命中 `Library/Artifacts/.../main.xcvol`,因此问题不是“每次重导入”。 但当前 `xcvol` 也没有真正消除运行时成本: 1. 写 artifact 时直接写出 `VolumeField` payload。 2. 读 artifact 时重新读入整个 payload 到 CPU 数组。 3. 运行时依然需要再把整块 payload 上传到 GPU。 换言之,当前 artifact 的价值主要是: - 导入结果稳定 - metadata 结构化 - 允许项目资产走统一 `AssetRef` / `Library` 流程 它还没有做到: - 运行时零拷贝或近零拷贝装载 - GPU 驻留态预烘焙 - 直接针对 volume draw path 的运行时加速 ## 2.3 与 `mvs/VolumeRenderer` 的本质差异 不是“都在传同一个 buffer,所以理论上应该一样快”,而是当前两者的最终资源模型不同: ### `mvs/VolumeRenderer` - 最终资源:`DEFAULT heap` GPU 本地 buffer - 中转资源:临时 `UPLOAD heap` - 上传模式:copy queue / direct queue 提交拷贝后等待完成 - draw path:只消费已上传完成的 GPU buffer ### 当前 editor - 最终资源:`UPLOAD heap` buffer - 中转资源:无专门 staging - 上传模式:draw path 内同步 `Map + memcpy` - draw path:首次消费时同时承担资源上传职责 这意味着: 1. editor 首帧 draw path 的职责过重 2. volume payload 的最终落点错误 3. 即使 CPU 读取时间相近,GPU 上传和后续 shader 读取性能仍会明显落后于 `mvs` --- ## 3. 修复目标 本次修复分为三个层级目标。 ## 3.1 一级目标:先把“8 秒后突然卡死十几秒”彻底打掉 要求: 1. 打开主场景后,editor 在 volume payload 首次可见前后都不出现长时间主线程冻结。 2. `BuiltinVolumetricPass` 不再承担大体积同步上传职责。 3. `StorageBuffer` 不再默认把 volume payload 留在 `UPLOAD heap` 作为最终运行时资源。 ## 3.2 二级目标:让 editor 的 volume GPU 上传路径和 `mvs` 同构 要求: 1. D3D12 下 volume payload 最终驻留在 `DEFAULT heap`。 2. CPU 只写 staging / upload 资源。 3. GPU 通过 copy 提交完成真正拷贝。 4. shader 后续只读取 GPU 本地资源。 ## 3.3 三级目标:继续把总加载时间向 `mvs` 靠近 要求: 1. 逐步削减 `xcvol -> CPU payload` 的运行时装载成本。 2. 未来允许 volume artifact 直接流向 GPU upload 路径,而不是“先完整常驻 CPU,再完整复制到 GPU”。 3. 在保持引擎正式资源体系一致性的前提下,把总时间尽量压向 `mvs` 的约 3 秒基线。 --- ## 4. 非目标 本轮不做以下内容,避免修复方向失焦: 1. 不重写 NanoVDB ray marching 算法本身。 2. 不把正式主线路径退化回 `mvs/VolumeRenderer` 的孤立 sample 结构。 3. 不先做 volume 压缩格式、体素裁剪重编码、分块稀疏 streaming。 4. 不先重构完整 SRP / render graph。 5. 不以“删除 Library 重建”作为修复方案。 6. 不为体积云单独发明 editor 私有旁路渲染器。 --- ## 5. 正式修复方向 ## 5.1 方向一:补齐“不可变 GPU 本地 buffer + 初始数据上传”能力 这是本轮最高优先级。 ### 当前缺口 当前 `RHIDevice::CreateBuffer(const BufferDesc&)` 只描述“创建一个 buffer”,但没有能力表达: - 最终资源要落在 GPU 本地内存 - 初始数据通过 staging copy 进入 - 创建后立即进入某个最终状态 因此 `RenderResourceCache::UploadVolumeField()` 只能: 1. `CreateBuffer()` 2. `CreateShaderResourceView()` 3. `SetData()` 对 D3D12 volume 来说,这条路是错的。 ### 目标能力 新增正式 buffer 创建能力,语义类似: - `CreateBuffer(const BufferDesc& desc, const void* initialData, size_t initialDataSize, ResourceStates finalState)` 或 - `CreateInitializedBuffer(...)` 要求: 1. D3D12 volume storage buffer 走 `DEFAULT heap` 2. 使用 upload staging 完成拷贝 3. 拷贝后转为 `GenericRead` 或对应 shader 可读状态 4. 返回对象仍然是统一 `RHIBuffer` ### 设计原则 1. 不要全局修改现有 `CreateBuffer(BufferType::Storage)` 的默认语义 2. 仅给需要“设备本地 + 初始数据上传”的资源新增专用路径 3. 保持旧代码依赖 `SetData()` 的场景继续可用 这一步是最大收益点,因为它同时解决: 1. 首帧主线程同步大 memcpy 2. volume 最终资源落在 upload heap 的错误模型 ## 5.2 方向二:把 volume GPU 上传从 draw path 前移 仅修正 D3D12 buffer 落点,还不够。 如果 volume 仍在 `BuiltinVolumetricPass::DrawVisibleVolume()` 首次执行时触发上传,那么: 1. 即使上传路径更正确 2. 首个可见帧依旧要等待 GPU upload 完成 3. editor 仍会出现明显顿挫 因此还需要把职责改成: ### 正确职责分层 #### CPU 异步资源层 - `VolumeRendererComponent` 异步拿到 `VolumeField` #### GPU 上传调度层 - 检测到 `VolumeField` CPU 资源完成后,提交 GPU upload 请求 - volume cache 进入 `Uploading` 状态 #### 渲染消费层 - `BuiltinVolumetricPass` 只消费 `GpuReady` 的 volume - 对尚未就绪的 volume 不绘制,不再临时上传 ### 目标状态机 建议为 volume runtime cache 明确引入: 1. `Uninitialized` 2. `CpuReady` 3. `Uploading` 4. `GpuReady` 5. `Failed` 首帧 draw path 不再负责从 `Uninitialized/CpuReady` 直接推进到 `GpuReady`。 ## 5.3 方向三:给 volume 增加正式上传队列或帧外预热入口 本轮至少需要一个最小正式机制,用于承接 GPU 上传工作。 ### 最小可落地形式 1. 在渲染系统或 `RenderResourceCache` 外围增加 volume upload service 2. 在主循环中轮询已完成的 CPU async load 3. 将 volume GPU upload 提交给渲染设备层 4. 上传完成后切换到 `GpuReady` ### 推荐正式方向 统一成“渲染资源上传服务”,后续 mesh / large texture 也可以逐步收口到这里。 本次 volume 修复可以先做 volume-only 版本,但接口命名不要把未来扩展堵死。 ## 5.4 方向四:削减 `xcvol` 的运行时 CPU 装载成本 这是第二优先级的大项。 当前 artifact 仍然要求: 1. 打开文件 2. 读 header 3. 分配 payload 数组 4. 把整个 payload 读入 CPU 5. 再把整个 payload 上传到 GPU 要接近 `mvs` 的总时间,后面必须继续推进: ### 正式方向 1. volume artifact header 与 payload 更明确分段 2. 支持 volume payload memory-mapping 或流式读入 upload buffer 3. 非必要时不长期保留 `590 MB` CPU payload 常驻 ### 本轮边界 本轮不要求一步做到 memory-mapped 零拷贝,但文档和接口设计必须为此留口。 --- ## 6. 分阶段实施计划 ## Phase 0:指标固化与基线采样 ### 目标 在改代码前先量化基线,避免后续只凭体感判断。 ### 任务 1. 记录当前 editor 打开 `Main.xc` 的三个时间点: - 场景结构恢复完成 - `Runtime streaming scene assets...` 结束 - volume 真正可见 2. 记录当前 `mvs/VolumeRenderer` 从启动到 volume 可见耗时。 3. 为 volume load / upload 补 focused trace,区分: - CPU artifact load - GPU upload begin - GPU upload complete 4. 记录 D3D12 volume buffer 创建类型与 heap 类型。 ### 交付 1. 一组可复现实测数字 2. 一组可复现日志样本 ## Phase 1:RHI 补齐 initialized GPU-local buffer 能力 ### 目标 让 D3D12 volume storage buffer 走 `DEFAULT heap + staging copy`。 ### 任务 1. 在 `RHIDevice` 层新增 initialized buffer 创建接口。 2. D3D12 实现复用现有纹理上传思路,构建: - upload queue - upload allocator - upload command list - fence / idle wait 3. volume 使用新接口,不再 `CreateBuffer + SetData()`。 4. 保证 SRV 创建逻辑不变,shader 读取接口不变。 ### 验收标准 1. D3D12 下 volume payload 最终 buffer 不再是 upload heap。 2. `RenderResourceCache::UploadVolumeField()` 不再通过 `SetData()` 写入大 payload。 3. 场景首次可见时的卡顿时长明显下降。 ## Phase 2:把 GPU 上传从 draw path 中移除 ### 目标 首个可见帧只消费 ready 资源,不执行大资源上传。 ### 任务 1. 在 volume runtime cache 层引入上传状态。 2. 在 CPU async load 完成后提交 GPU upload 请求。 3. `BuiltinVolumetricPass` 遇到 `Uploading` 状态时跳过该 volume。 4. editor 状态条可选择扩展为同时显示: - CPU streaming count - GPU upload pending count ### 验收标准 1. 打开场景后不再出现“streaming 结束后突然长时间卡死”。 2. editor 在 volume GPU 上传期间依旧保持交互。 3. volume 在 upload 完成后自然出现。 ## Phase 3:降低 `xcvol` 运行时 CPU 成本 ### 目标 继续逼近 `mvs` 的总时间,而不只是解决阻塞。 ### 任务 1. 评估 `VolumeFieldLoader` 是否允许 payload 延迟所有权或映射式读取。 2. 将 `xcvol` 的 metadata 和 payload 读取职责分层。 3. 评估“直接读入 upload staging”路径,减少中间副本。 ### 验收标准 1. 打开主场景后的总 volume 可见时间继续下降。 2. CPU 峰值内存和中间复制次数下降。 ## Phase 4:正式化验证与回归保护 ### 目标 确保修复不会在后续 volume / mesh / texture 路径上回归。 ### 任务 1. 增加 volume upload 相关单测或最小集成验证。 2. 补 D3D12 路径日志断言或 profiling 钩子。 3. 验证 Scene View / Game View / 运行时体积渲染路径一致。 ### 验收标准 1. 主场景 volume 打开稳定 2. `mvs` 与 editor 行为差距有明确量化解释 3. 后续可以继续迭代到更强的 runtime streaming 架构 --- ## 7. 涉及模块 本轮实现预计涉及以下模块: ### RHI - `engine/include/XCEngine/RHI/RHIDevice.h` - `engine/src/RHI/D3D12/D3D12Device.cpp` - 必要时: - `engine/include/XCEngine/RHI/RHITypes.h` - `engine/include/XCEngine/RHI/RHIEnums.h` - `engine/include/XCEngine/RHI/D3D12/D3D12Buffer.h` - `engine/src/RHI/D3D12/D3D12Buffer.cpp` ### Rendering - `engine/src/Rendering/Caches/RenderResourceCache.cpp` - `engine/include/XCEngine/Rendering/Caches/RenderResourceCache.h` - `engine/src/Rendering/Passes/BuiltinVolumetricPass.cpp` - 必要时新增 volume upload service 或相关状态结构 ### Resources / Components - `engine/src/Components/VolumeRendererComponent.cpp` - `engine/include/XCEngine/Components/VolumeRendererComponent.h` - `engine/src/Resources/Volume/VolumeFieldLoader.cpp` - `engine/include/XCEngine/Resources/Volume/VolumeField.h` ### Editor / Telemetry - `editor/src/Managers/SceneManager.cpp` - `editor/src/Viewport/ViewportHostService.h` ### Tests - `tests/Resources/Volume/` - `tests/Components/test_volume_renderer_component.cpp` - 必要时新增 rendering / integration 回归验证 --- ## 8. 风险与约束 ## 8.1 不能粗暴全局改 `StorageBuffer = DEFAULT heap` 原因: 1. 现有其他调用方可能默认依赖 `SetData()` 2. 全局改语义会引入隐藏回归 3. 本轮应以“新增 initialized immutable path”为主 ## 8.2 首先只确保 D3D12 主线正确 当前用户问题发生在 editor D3D12 主线,因此: 1. 本轮优先保证 D3D12 正确和快 2. Vulkan / OpenGL 保持兼容,不要求一步做到同等级优化 3. 但接口设计不要阻断后续跨后端统一 ## 8.3 不能让 draw path 同时承担恢复和上传职责 这条是硬约束。 只要 draw path 里仍然存在“第一次看到资源就同步上传 590MB”这件事,问题就没有从根上解决。 --- ## 9. 完成标准 认为本轮修复完成,至少要同时满足以下条件: 1. editor 打开 `Main.xc` 时,`Runtime streaming scene assets...` 结束后不再出现十几秒主线程卡死。 2. `cloud.nvdb` 的 volume payload 在 D3D12 下最终驻留于 GPU 本地 buffer,而不是 upload heap。 3. `BuiltinVolumetricPass` 不再在首次 draw 时同步执行大体积上传。 4. editor 的 volume 可见总时间相比当前主线显著下降。 5. 修复后的行为可以用日志和代码路径明确解释,而不是只靠体感判断“快了”。 --- ## 10. 本轮执行顺序 严格按以下顺序推进: 1. 固化指标与日志 2. 补 RHI initialized GPU-local buffer 能力 3. volume 改走新上传路径 4. 把 GPU upload 从 draw path 前移 5. 再评估 `xcvol` 运行时 CPU 装载优化 不跳步骤,不同时展开多个大方向,先把最大收益点打掉。