21 KiB
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. 问题现象
当前复现现象已经很明确:
- 直接打开
project项目。 - 打开
project/Assets/Scenes/Main.xc。 - 界面会显示
Runtime streaming scene assets...,这段时间窗口还能继续操作。 - 在大约 8000 ms 左右,Editor 突然开始明显卡死。
- 卡死十多秒后,体积云对象才真正显示出来,随后窗口恢复。
对比样本:
mvs/VolumeRenderer使用的是同一份cloud.nvdb。- 运行
mvs/VolumeRenderer/run.bat时,大约 3 秒左右即可加载并显示。
这说明:
- 慢点不在“这份
.nvdb根本无法解析”。 - 慢点也不在“当前机器根本无法承载这份体积数据”。
- 真正的问题出在 Editor 主线里的额外同步收口路径。
3. 当前已经确认的事实
3.1 当前 Library 对体积资源做的不是“运行时加速缓存”
当前源文件:
project/Assets/cloud.nvdb- 文件大小:
590,241,000bytes
当前 artifact:
project/Library/Artifacts/.../main.xcvol- 文件大小:
590,240,896bytes
这说明当前 .xcvol 基本就是:
- 一个较小的 header
- 加上一份几乎原样的 NanoVDB payload
也就是说,当前 Library 在体积资源这条链路上做的是:
- 导入身份缓存
- metadata 缓存
- source -> artifact 统一入口
但它还没有做到:
- 为运行时准备更轻的 cooked payload
- 为 GPU 上传准备更直接的 runtime-ready 数据
- 为首帧显示准备真正低成本的预热结果
结论:
- 当前
Library对.nvdb是“导入缓存”,不是“运行时性能缓存”。
3.2 当前 VolumeField CPU 侧存在多次大拷贝
当前体积资源进入运行时时,大致会经历:
- 从
.xcvol读取整个 payload 到临时缓冲。 VolumeField::Create(...)再把 payload 拷贝进VolumeField::m_payload。RenderResourceCache::UploadVolumeField(...)再构造一个新的uploadData。- 再把这份数据写入 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 变体。
因此这次卡死不是单一开销,而是两类重活叠加:
- 590MB 体积 payload 的运行时实现化
- 体积 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 主线额外职责:
AssetDatabaseAssetRef -> 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:先把真实耗时切开,看清楚谁最重
虽然根因已经明确,但时间占比仍需正式打点。
本阶段必须先拿到真实分段耗时,不允许后续继续靠体感猜。
需要新增的时间切片:
Scene DeserializeAssetRef ResolveVolumeFieldLoader.ReadArtifactVolumeField.CreateVolume GPU UploadVolumetric Shader Variant CompileVolumetric PSO CreateFirst Volume Visible
需要新增的日志与状态:
Main.xc打开时,针对cloud.nvdb输出完整链路耗时。- 把“异步流式加载完成”和“体积 render-ready”拆开显示。
这一阶段的目的不是修性能,而是:
- 锁死真正的大头时间占比
- 避免后续错误优化无关路径
8.2 主线 B:去掉体积 payload 的多次大拷贝
这是本轮最核心的工程改动之一。
正式方向:
VolumeField不能继续默认把 590MB payload 再拷进一份新的m_payload。.xcvol读取后,应该改成:- 文件映射
- 或共享只读 blob
- 或单所有权 payload 容器
RenderResourceCache::UploadVolumeField(...)不能再额外构造一份等体积的uploadData再拷一次。- 上传路径必须直接消费 loader 产出的只读 payload 视图。
本阶段完成后,应达到:
.xcvol -> VolumeField不再发生无意义的大内存复制。VolumeField -> RenderResourceCache也不再产生第二份 590MB 临时副本。
这一步做完,即使还没异步 GPU 上传,卡顿也会先明显下降。
8.3 主线 C:把 GPU 上传从首次绘制路径里拿出去
当前真正错误的不是“上传很重”,而是“上传发生在第一次真正 draw 的时候”。
正式方向:
- 为
VolumeField建立明确的 runtime residency 状态机:UnloadedCpuReadyGpuUploadingGpuReadyFailed
- CPU 侧 payload 一旦 ready,就立即进入独立的 GPU 预热队列。
BuiltinVolumetricPass::DrawVisibleVolume(...)不允许再承担首次重量级上传。- draw path 的职责改为:
- 如果
GpuReady,正常绘制 - 如果未 ready,跳过或显示占位,不得同步收口
- 如果
对 D3D12 的具体要求:
BufferType::Storage不能继续把大体积 volume payload 当普通 upload-heap 常驻缓冲来处理。- 需要引入正式的:
- staging/upload buffer
- default heap storage buffer
- copy queue 或专用 upload path
- volume buffer 上传完成后再切换到可读状态,而不是在 draw path 上临时补。
这一步是解决“8000 ms 后突然卡死”的主修复点。
8.4 主线 D:把体积 shader / PSO 首编从体积首帧里拿出去
当前体积云第一次真正绘制时,渲染链路里还会叠加:
- builtin volumetric shader variant 首次真正编译
- pipeline layout / descriptor layout 构建
- PSO 创建
正式方向:
builtin://shaders/volumetric的实际运行时使用变体需要正式预热。- 预热时机不放在“第一次真正 draw”。
- 预热应在以下时机之一完成:
- scene structure ready 之后的后台预热阶段
- volume material 绑定后立刻排队预热
BuiltinVolumetricPass首次执行时只能命中已存在的 shader variant / PSO cache。
当前 D3D12 路线还要额外处理一个问题:
- 现在编译 flags 是
D3DCOMPILE_DEBUG | D3DCOMPILE_SKIP_OPTIMIZATION
这本身会显著拉长编译时间。
本轮需要明确一条正式策略:
- Debug 能力是否仍要保留。
- 如果保留,默认 editor 交互路径不能在首帧同步承担这份成本。
- 可以接受“后台慢编”,不能接受“首帧卡死慢编”。
8.5 主线 E:修正场景加载进度模型
当前 Runtime streaming scene assets... 的语义不完整。
这会直接误导调试,也会误导后续调度逻辑。
正式方向:
- 把 scene load progress 拆成四段:
Structure ReadyCPU Asset StreamingGPU Residency PrewarmRender Warmup
pendingAsyncLoads == 0时,只能说明 CPU loader 阶段接近结束。- 只有 volume GPU ready 且体积 pass 关键 shader/PSO ready 时,才能算真正 ready。
- UI 需要把这几个阶段明确显示出来,避免再把后半段卡顿误判成“明明 streaming 都结束了却又卡”。
9. 执行阶段
Phase 0:诊断与基线固化
目标:
- 用日志和时间切片把当前问题彻底量化。
任务:
- 为
Main.xc的cloud.nvdb打通完整性能时间线。 - 输出 warm cache / cold cache 两组基线。
- 输出 MVS 对比基线。
验收标准:
- 能明确给出 CPU 读取、CPU 拷贝、GPU 上传、shader 编译、PSO 创建各自耗时。
- 不再用“感觉像是这里慢”做判断。
Phase 1:体积 payload 低拷贝重构
目标:
- 消除
.xcvol -> VolumeField -> UploadVolumeField链路中的重复大拷贝。
任务:
- 重构
VolumeField的 payload 持有方式。 - 重构
.xcvolloader 的结果表示。 - 重构
RenderResourceCache::UploadVolumeField(...)的输入形式。
验收标准:
- warm cache 情况下,CPU 内存峰值明显下降。
VolumeField载入完成时不再出现等量级的重复 payload 副本。
Phase 2:异步 GPU 上传与渲染脱钩
目标:
- 第一次绘制体积对象时,不再承担首次 GPU upload。
任务:
- 增加 volume GPU prewarm 队列。
- 建立 GPU residency 状态机。
- 改掉 draw path 的同步上传兜底。
验收标准:
- 首次打开
Main.xc后,不再在体积第一次出现在视野中时发生长时间整窗阻塞。 - 体积对象未 ready 时允许暂时不显示,但不允许卡死窗口。
Phase 3:体积 shader / PSO 预热
目标:
- 不再把
volumetric.shader的首次真实可绘制准备放在体积首帧。
任务:
- 为 active backend 预热体积 shader variant。
- 为 volume material / pass 预热关键 PSO。
- 把 shader/PSO 首编首建从 draw path 移走。
验收标准:
- 首次显示体积云时,不再同时伴随大段 shader/PSO 同步创建时间。
- 日志能证明首帧命中的是已准备好的可绘制状态。
Phase 4:场景状态机与 UI 收口
目标:
- 让场景打开状态和真实资源准备阶段一致。
任务:
- 拆分 load status 阶段。
- 修正
Runtime streaming scene assets...的语义。 - 在 Project/Viewport/Console 中统一反映同一份阶段状态。
验收标准:
- UI 文案与真实阶段一致。
- 不再出现“streaming 结束了但实际上后面还有一次大卡死”的错误认知。
Phase 5:回归、压力样本与收口
目标:
- 用真实样本和自动化验证确认这条路线已经稳定。
任务:
- 用当前
cloud.nvdb做 warm/cold 双场景回归。 - 回归
Main.xc打开、关闭、再次打开。 - 检查体积云显示、Editor 响应性、日志阶段切片。
验收标准:
- warm cache 打开
Main.xc时无二次长阻塞。 - cold cache 首次导入时也保持可交互。
- 体积云显示时间显著收敛,接近 MVS 量级。
10.1 当前执行进展(2026-04-10)
当前代码实现已经进入正式重构阶段,已落地的第一批改动如下:
VolumeField新增 owned payload 创建路径,.xcvol读取结果不再在VolumeField::Create(...)内发生第二次整块复制。VolumeFieldLoader的 artifact 载入路径已改为“读入 payload -> 直接 move 进VolumeField”,去掉 artifact load 阶段的重复大拷贝。RenderResourceCache::UploadVolumeField(...)不再构造整块uploadData临时副本,改为直接消费VolumeFieldpayload。RenderResourceCache已为 volume 引入最小可用的 residency 状态:UninitializedUploadingReadyFailed
- volume GPU 上传已改为分帧推进,当前实现按固定 chunk 预算逐帧写入,避免第一次真正绘制时一次性同步吞下整块 payload。
BuiltinVolumetricPass已拆出资源预热步骤:- 先推进 volume upload
- 再预建 volume pass layout / pipeline
- draw path 只消费
Ready的 volume,未 ready 时直接跳过,不再兜底触发重量级上传
- 当前已补最小日志:
Volume GPU upload startedVolume GPU upload ready
这批改动的意义是:
- 先把 warm cache 路径里最重的两类同步收口拆开:
.xcvol -> VolumeField的重复 CPU 大拷贝- 首次 draw path 内的一次性整块 GPU 上传
- 先把“卡死 Editor”问题从根上打散成可推进、可观察、可继续优化的状态。
当前这批改动还没有完成的部分:
- 还没有把
.xcvol升级成真正 runtime-ready 的 cooked artifact,当前只是先把现有 artifact 的运行时消费链路做轻。 - 还没有补齐完整的分段耗时打点。
- 还没有把 volumetric shader / PSO 的首编完全前移到更早阶段。
11. 涉及模块范围
预计会涉及但不限于以下模块:
engine/include/XCEngine/Resources/Volume/VolumeField.hengine/src/Resources/Volume/VolumeField.cppengine/src/Resources/Volume/VolumeFieldLoader.cppengine/include/XCEngine/Rendering/Caches/RenderResourceCache.hengine/src/Rendering/Caches/RenderResourceCache.cppengine/include/XCEngine/Rendering/Passes/BuiltinVolumetricPass.hengine/src/Rendering/Passes/BuiltinVolumetricPass.cppengine/src/RHI/D3D12/D3D12Device.cppengine/src/RHI/D3D12/D3D12Buffer.cppengine/src/Resources/BuiltinResources.cppeditor/src/Managers/SceneManager.cppeditor/src/Viewport/ViewportHostService.h
如果 Phase 2 仍然不足,再考虑是否需要新增专门的 runtime-prewarm 辅助模块。
但在本轮中,不应先为了结构好看而过早引入新的大模块。
12. 风险与边界
11.1 风险一:只优化 CPU 拷贝,但仍保留首帧同步 GPU 上传
结果:
- 会变快,但不会从根上解决“突然卡死”。
11.2 风险二:只做 GPU 上传异步,但 shader / PSO 首编仍在首帧
结果:
- 体积数据路径变快,但体积首帧依然可能因为 shader 现编而卡死。
11.3 风险三:一上来先设计新的磁盘 runtime cache
结果:
- 复杂度先上去了,
- 但如果真正的大头是同步上传和首编,收益会被高估。
因此本轮策略必须是:
- 先打点
- 先移走同步重活
- 再决定是否需要更重的磁盘级 runtime cache
13. 完成标志
当以下条件同时成立时,这份计划才算完成:
- 打开
project/Assets/Scenes/Main.xc时,Editor 不再出现二次长时间整窗卡死。 cloud.nvdb的 warm cache 路径已经不再依赖首次绘制时的同步重资源收口。- 体积 shader / PSO 的首次准备不再挤在体积首帧。
- 场景进度状态能正确区分 CPU streaming、GPU prewarm 和 render-ready。
- 当前这条路径的耗时分布可以通过日志直接解释,不再需要靠猜。
14. 一句话结论
这次问题的根不在“Library 有没有命中”,而在“当前 Library 只缓存了导入结果,没有缓存运行时真正昂贵的实现化结果,而且这部分实现化被错误地放到了体积第一次真正绘制时同步完成”。
本轮修复必须围绕这个根因展开,而不是继续把注意力放回导入判定本身。