问题
一个中等规模的室外高斯场景,单文件就能达到数百 MB 到数 GB。浏览器不可能一次性下载并解析它,即使下载完成,显存也装不下全部高斯。流式加载的目标是:视角近的区域用高精度数据,远处用粗糙数据,并且只请求当前视野内需要的分块。
PlayCanvas 的 Streamed SOG 格式正是为此设计的:它把场景组织为多级 LOD,每一级再切分为若干分块,分块之间通过 lod-meta.json 描述关系。查看器根据相机位置决定加载哪些分块。
整体流程
在我们的离线管线中,LOD 生成分为四步:
- 准备 LOD0:直接使用编排层物化好的
baked.ply,它已经包含文档变换并与碰撞共用。 - 逐级抽稀:每一级的目标点数按比例递减,默认比例为 50%,直到点数降到阈值(默认一百万)以下,或达到最大级数。
- 回读并堆叠:把所有级别的 PLY 重新读出,堆叠为一个多 LOD 数据源。
- 写出流式包:调用
writeLodSource,输出lod-meta.json与分块目录。
第二步之前,LOD 转换 CLI 还会对 LOD0 做一次 Morton 空间排序。Morton 曲线把空间上相近的高斯放在文件中相邻的位置,使后续抽稀与分块读取的局部性更好。
为什么必须落盘再回读
这里踩过一个关键的坑。decimateSource 的输出是 stream-once 的:它只能被读取一次。而 writeLodSource 每一级都需要读两遍。如果把抽稀的输出直接链式交给下一步,第二遍读取会得到空数据,或者在不报错的情况下写出残缺的分块。
因此每一级抽稀之后必须先写成临时 PLY 文件,再回读。这会多占用磁盘,但保证了每一步的输入都可以重复读取。这条规则被写进了 AGENTS.md 的不变量列表中,修改该流程时必须遵守。
两种抽稀策略
管线提供了 fromBase 选项,决定每一级从哪里抽稀:
- 逐级链式(默认):第 k 级从第 k-1 级抽稀。实现直观,但每一级的有损误差会叠加,远处的画质可能逐级下降。
- 从 LOD0 直接抽稀:第 k 级从 LOD0 抽稀到
round(count0 · ratio^k)个点。每一级都要从大源重新读取,速度更慢,但画质更稳定。
默认仍保持链式,是为了不改变已有产物的行为;需要更稳定远景画质时可以开启 fromBase。
分块与内存
导出时的关键参数有两个:分块数量(默认 512)与分块范围(默认 16)。分块数决定了单个文件的大小,分块范围决定了空间划分的粒度。这两个参数对查看器的请求数量与每次请求的数据量都有直接影响,需要结合场景尺寸调整。
LOD 转换 CLI 在多机部署时还有一个保护:遇到 GPU 设备丢失(如 DXGI_ERROR_DEVICE_HUNG)时自动切换到下一张 GPU,全部失败则回退到 CPU。它的输出是 JSONL 事件流,便于 Worker 统计每个阶段的耗时。
小结
流式 LOD 的核心不在于算法多复杂,而在于三件事:数据能否重复读取、中间结果能否落盘、参数能否与场景规模对应。把这三件事放在管线的固定位置处理,比事后调参更可靠。