1亿个3D高斯点,在浏览器里实时渲染并且保持可交互帧率,这件事我第一次听到时是持怀疑态度的。一年前我自己做过一个Web端高斯泼溅项目,导出一个800MB的COLMAP场景,拖进WebGL渲染器,笔记本风扇直接起飞,拖动视角卡成幻灯片,最后浏览器干脆弹了"Aw, Snap"。后来我把3D Gaussian Splatting(3DGS)相关的开源方案挨个试了一圈,发现大家卡住的原因高度一致:数据量太大,单一手段根本救不回来。Spark 2.0能把这套东西真正端进浏览器,靠的不是什么黑魔法,而是三根支柱——连续LoD、RAD流式格式、GPU虚拟内存——把"该画谁、数据怎么来、显存往哪放"三个问题分开解决。
这篇文章适合这几类人看:被大场景3DGS整到内存爆掉的前端工程师,想在Web端做城市级点云或扫描场景展示的开发者,以及想搞明白LoD和流式加载在WebGPU上到底怎么落地的同学。我会先讲清楚这三项技术各自解决什么问题,为什么缺一个都行不通,然后贴一段我实测的接入流程和踩坑记录。文中涉及的API和工具名以我手上的版本为准,不同版本可能有出入,但底层的设计思路是通用的。
1. 为什么是三条腿:先看清问题出在哪一层
很多人在优化大场景渲染时有个误区,觉得"显存不够就压缩、下载太慢就开多线程"。实际上3DGS的瓶颈不是一个,而是三个叠在一起。先算一笔账,你就明白为什么单点优化没用。
1.1 数据量、带宽、排序:三重夹击
先说数据量。一个标准的高斯泼溅场景,每个高斯点至少要存位置(3个float)、协方差矩阵对应的尺度和旋转(通常3+4个float)、不透明度(1个float)、球谐系数(SH,三阶就是48个float)。粗算下来一个点接近240字节。1亿个点,不压缩就是23GB以上,就算工程上砍掉高阶SH、降位深压缩到40字节一份,也要4GB。这个量级已经超过绝大多数手机和一半以上笔记本的整机内存。
再看传输。4GB文件在100Mbps宽带下需要5分多钟,移动网络就更不用提了。而3DGS这类场景和传统模型不一样,用户不可能等你全量下载完再开始看。没有流式方案,1亿点就是一句空话。
最后是排序。3DGS的渲染核心是"按深度从远到近排序 + alpha混合",每帧都要对所有参与绘制的点按相机距离排序。100M个点全量排序是上亿数量级的key-value排序,CPU端用快排每帧要跑几十毫秒到几百毫秒,直接告别实时。就算只排可视范围内的点,几百万个点的GPU排序也要求渲染器有compute shader,WebGL2根本做不到。
这三个问题性质不同:数据量大是存储问题,下载慢是传输问题,排序重是算力问题。想用一个办法通吃,必然顾此失彼。
1.2 三件套的分工:加载层、细节层、驻留层
Spark 2.0的处理方式是把问题拆开,各管一段。
RAD流式格式管的是"传输+加载":把场景从一个大文件拆成可按需获取的块,相机看到哪就加载哪。连续LoD管的是"细节决策":让渲染器在每个视角下只画"看得清"的高斯,远处用合并后的粗粒度点,近处才用原始细粒度点。GPU虚拟内存管的是"显存驻留":1亿点的逻辑数据映射到有限的物理显存里,不常用的页被换出去,常用的页被钉住。
打个比方,逛一座城市。LoD决定你站在楼顶看街道时只画路网,走到路口才画店铺招牌;RAD负责按你所在的街区下载对应比例尺的地图,而不是一次性下载全国路网;GPU虚拟内存则是你手里的内存记事本——只记当前街区,走到下一个街区再换页。
三条腿缺一不可:有LoD没RAD,细节再多数据传不过来;有RAD没LoD,块切得再细,渲染器还是被迫画超出能力的高斯总数;有LoD和RAD没有GPU虚拟内存,TS流数据到了显存边界立刻爆掉,前两项优化全部白费。
2. 连续LoD:把"抽稀换模型"改成"高斯属性连续渐变"
LoD(Level of Detail)这个词做图形的人不陌生,地形渲染和网格模型都在用,高程数据做地形LOD更是老经典。但3DGS场景里的LoD和传统mesh LoD完全不是一回事,做不好就是满屏闪烁和明暗跳变。
2.1 离散LoD为什么在3DGS里特别难受
传统网格模型的做法是预生成3到5个精度档,一个房子从远处看用低模,走近换高模。切换瞬间的"popping"大多能忍,游戏里常用距离阈值配上一点透明度过渡就糊弄过去了。
3DGS不行。画面里每一个像素是大量半透明高斯球从远到近叠出来的,任何离散切换都会让一小片区域的密度和颜色突然变化。相机只要一转动,切换边界就沿着物体轮廓滑动,极其显眼。
另一个问题是档位数量。100M个点的场景,覆盖范围从城市尺度到厘米级细节,跨度可能是五到六个数量级。要保证任何距离都不跳变,离散LoD至少需要十几档。每档都是一份完整数据,存储直接爆炸。
所以Spark 2.0必须走连续LoD路线:等级之间的切换不是"换一套高斯",而是把两档的属性按比例插值,让位置、大小、透明度连续变化,眼睛捕捉不到切换点。
2.2 归一化系数:连续LoD的核心数学
连续LoD的构建过程通常是离线的。先把场景原始高斯按空间位置组织成八叉树类结构,每个内部节点把它下面的一簇高斯"合并"成一个代表高斯。合并不是简单求平均,难点在于:一簇高斯代表一个体积内分布的多个小高斯,合并后单个大高斯要能在同样视角下产生相近的屏幕贡献。
这里的关键是透明度归一化。想象一堵墙表面钉了一百个小灯泡,你要把它合并成一个大灯泡放在墙的中心。如果只把亮度简单相加,换挡瞬间画面会突然亮一倍;只有保证合并前后"总光通量"守恒,才能无缝过渡。Spark 2.0的做法是为每个内部节点计算一个归一化系数,渲染时父级高斯的透明度要乘以这个系数,使得它的叠加结果和全部子级基本等价。
这个思路和高程地形渲染里经典的几何过渡是相通的。十年前做DEM地形的人就明白,三角形网格细分/合并时,顶点位置必须随误差连续插值,否则地形会出现明显的"接缝"和"裂缝"。3DGS把同样思想搬到了高斯属性上。
2.3 运行时怎么决定每个点画在哪一级
连续LoD的运行时决策逻辑非常直接。渲染器为每个高斯节点计算它在屏幕上的投影大小,如果投影面积大于一个像素的若干倍,说明它还"值得被细分",Shader就沿着层级树的子节点方向继续读取;如果投影面积已经小于阈值,说明它的细节人眼已经无法分辨,就停在当前节点。
这里要强调"连续"两个字的含义。节点的选择不是整数档位,而是一个浮点数深度。假设某个高斯基元处于第3.4级,Shader会同时读取第3级和第4级的属性,按0.6/0.4的比例混合位置、尺度和透明度。这意味着如果你从远处匀速飞向场景,所有高斯都在进行连续的属性渐变,而不是在某一个距离瞬间切换。我实测下来,这个方案在慢速镜头下几乎察觉不到生命周期变化。
调参方面,比较常用的是lodBias这类全局参数,相当于整体往"更细"或"更粗"方向偏移,大场景展示时调低一点能显著提升帧率,代价是远景稍微模糊。还有maxLod上限,防止用户怼到高斯表面时无限细分导致显存失控。另外值得注意的是,球谐系数也有级别:低细节档位只保留0阶或1阶SH,只有靠近时才启用完整三阶SH,这对降低带宽压力帮助很大。
3. RAD流式格式:把"整包下载"换成"按需取块"
3DGS原始数据格式是PLY或者自定义的splat二进制,本质都是"一个大文件"。1亿点的大文件没法直接流式,因为你想渲染其中一个角落,也必须先把前面几百MB的无关数据读完。RAD格式就是为了解决这个问题设计的。
3.1 为什么现有格式救不了
PLY是目前最通用的3DGS导出格式,文本头加二进制体,结构简单,但它没有任何空间组织概念,唯一访问方式是顺序读取。splat格式虽然把属性拍平了,本质还是一个大数组。Draco之类压缩方案能缩小体积,但压缩和解压耗CPU,而且压缩的是"整个文件",并没有把场景切成空间上独立的单元,依然不能按需加载。
还有一类思路是转换成分块瓦片,类似地图切图,每块一个独立文件。但3DGS有半透明混合特性,直接按普通瓦片切,块与块之间的高斯可能会互相透明叠加,切块不当会导致接缝处明显发亮或发暗。RAD的处理方式是:空间切块之外,每一块内部还保留了完整的层级信息,跨块边缘的高斯会被复制进相邻块的边界区域,保证混合一致性。
3.2 空间目录加独立数据块
RAD格式的整体结构,我理解大致分三层:头部、空间索引目录、数据块。头部记录场景包围盒、总高斯数量、块大小等全局信息。空间索引目录是一个轻量级的空间查找结构,通常是网格或八叉树,记录每个格子对应的数据块在文件中的偏移量和长度。数据块则按空间区域划分,每个块内部自带一个小型LoD金字塔。
每个块内部有独立的元信息:包围盒、高斯数量、最低LOD档、压缩方式、字节偏移。这样做的好处是单个请求就能拿到"一个区域从粗到细的所有数据";渲染器根据相机位置知道这个区域需要多细的细节,然后决定是只读块内的低级数据,还是把块内的精细层也拉下来。
压缩上RAD做得很激进。位置坐标用块包围盒做局部坐标系,然后量化成uint16或uint32,相比全局float省了一大截;旋转四元数压缩成3分量加符号位,配合16bit量化;球谐系数按等级分级存储,低等级用查表量化。整体压下来,1亿点场景转成RAD之后通常能控制在1到2GB以内,某些规整场景甚至能压到几百MB。
3.3 加载优先级和缓存策略
流式加载不能简单地"看到哪块下哪块"。相机快速旋转时,新出现的块如果按普通优先级排队,画面会一片模糊等好几秒。
比较稳妥的策略是分两阶段。第一阶段,无论相机在哪,先请求全场景的低粒度数据,让画面快速出现一个可辨认的轮廓,这个过程因为数据量小,往往几百毫秒完成。第二阶段,按相机距离和朝向给可见块排优先级:正前方、距离近的块先加载,侧后方、距离远的块延后。如果相机在做匀速直线运动,还可以根据速度预测下一批会进入视锥的块,提前预取。
缓存策略上,我试下来觉得最关键的一点是要有"滞留区"。简单LRU在用户反复绕圈观察某个物体时容易抖动——刚被淘汰的块马上又要加载。Spark 2.0的做法是在LRU之外给每个块一个保留计数,最近被引用过的块即使LRU排名靠后,也先留在内存里观察几帧再淘汰。这个细节对交互体验影响非常大,没有它,绕圈操作会让下载器疯狂重复请求同一批块,网络开销和渲染卡顿同时爆炸。
4. GPU虚拟内存:在WebGPU上自己做一层swap
看到"GPU虚拟内存"这个词,很多人会以为WebGPU原生支持了类似显存虚拟化的能力。实际上WebGPU到今天都没有暴露稀疏资源,Vulkan里的稀疏绑定在Web端不可用。Spark 2.0所谓的GPU虚拟内存,是用软件在WebGPU之上实现的一套分页系统。
4.1 WebGPU的显存约束
先说清楚WebGPU给了什么限制。首先,WebGPU规范对storage buffer的默认绑定上限给的是128MiB这个量级,实际浏览器实现会按设备能力放宽,但整块buffer的maxBufferSize通常也在1GiB前后晃悠。也就是说,哪怕你机器有16GB显存,也没法一次性创建一个能装下1亿高斯的巨型buffer。
其次,单个shader stage默认只有少量storage buffer绑定名额。WebGPU的默认限制是每个shader stage只能绑8个storage buffer,这决定了你无法一口气把几十个物理页全部塞进一个渲染管线。
最后,WebGPU没有稀疏资源。换句话讲,你无法像Vulkan那样让一块虚拟大资源只有一部分commit到显存。要在浏览器里实现"逻辑上1亿点、物理上只驻留一部分",必须自己维护一个页表,在GPU侧做间接寻址。
4.2 页表、物理页、回退缓冲
Spark 2.0的实现思路可以理解成一个微型操作系统分页机制。
首先把场景的全部逻辑数据看作一个巨大的虚拟地址空间,按固定大小分页,比如每页64MiB。GPU端维护一张页表,记录每个虚拟页号对应到哪个物理页槽位。物理页池则在初始化时按设备显存情况分配出来,比如在8GB显存的机器上分配4个64MiB的物理页,留下容量给颜色缓冲、深度缓冲和排序用的临时buffer。
所有高斯在渲染时都用全局ID寻址。Shader拿到一个高斯ID,先查页表得到"该高斯在哪个物理页、页内偏移是多少",然后真正去读数据。如果页表显示这个页不在物理内存中,就跳转到一块常驻的回退缓冲——里面存着整棵LoD树顶层的粗粒度代表节点。这样永远不会出现"读不到数据导致渲染崩溃"的情况,代价只是画面短暂地降级成粗糙轮廓。
CPU端的管理器负责物理页的替换策略:LRU、锁页、预取队列。每一帧渲染前,渲染器会把当前需要使用的页"pin"住,防止绘制执行期间被CPU端淘汰掉;帧结束后解除pin。这个设计和操作系统把进程的页钉在物理内存里防止换页打断DMA是一个道理。
4.3 三层之间怎么协同
这套系统的精妙之处在于三个层通过全局高斯ID串成了流水线。
RAD流式加载解码一个数据块之后,不是直接交给渲染器,而是先交给虚拟内存管理器,由它决定把这些数据放进哪个虚拟页、数据块里的高斯ID如何落到页表。LoD决策器根据相机视角计算当前帧需要哪些层级的高斯,生成一张虚拟页访问热点表;虚拟内存管理器基于这张热点表决定物理页的驻留和淘汰顺序。
我在调试时最深的感受是,这三个模块耦合得非常紧:流式加载的粒度必须和虚拟页大小对齐,否则一个块跨了两个页,读入时要做两次拷贝;LoD树的节点划分又必须和RAD块边界对齐,否则一个高斯会同时属于两个块,产生重复渲染。这也是这类系统最难的地方——单独实现任何一个组件都不难,难的是把三层的边界统一起来。
5. 实操接入:从装环境到跑起1亿点
前面讲了这么多原理,这一节分享一下我实际把Spark 2.0跑起来的完整链路,包括环境要求、数据转换和前端接入。我手上用的版本接口如下,升级到新版本时记得先看一遍changelog。
5.1 环境要求
浏览器方面,需要支持WebGPU的Chrome或Edge,部分平台可能需要开启实验性WebGPU flag。显卡建议至少4GB显存跑小型场景,要流畅跑1亿点的城市级场景,8GB显存是起步线。预处理转换工具跑在Node.js环境下,需要Node 18以上,并且转换大场景时机器内存建议64GB以上,否则会在构建LoD树时挂掉。
装好之后可以通过一个简单的WebGPU检测函数确认环境没问题:
async function checkWebGPU() { if (!navigator.gpu) return false; const adapter = await navigator.gpu.requestAdapter(); return !!adapter; }5.2 数据转换
输入普通PLY或SPLAT文件,用离线工具转成RAD格式。转换命令大概是这个形态:
spark-cli convert scene.ply -o scene.rad \ --quantize 16 \ --max-lod 12 \ --page-size 64其中quantize控制属性量化精度,数值越高质量越好但体积越大;max-lod控制LoD树最大深度;page-size对应虚拟内存页大小,要和后续前端配置保持一致。
转换1亿点场景相当耗时,我这边跑一次大约需要几十分钟到几个小时,取决于CPU和磁盘速度。这个过程中工具会做三件事:把高斯组织成LoD树、按空间切块、量化压缩写出RAD格式。如果内存不够,可以先用空间划分工具把原始点云按区域切开,分批转换,最后再合并索引。
5.3 前端接入
前端接入非常简洁,核心代码量不大:
import { SparkViewer } from '@spark/web'; const viewer = new SparkViewer({ canvas: document.querySelector('#canvas'), url: '/data/scene.rad', pageSize: 64, maxResidentBytes: 4 * 1024 * 1024 * 1024, // 显存驻留上限 lodBias: 0.8, }); viewer.addEventListener('load', () => { console.log('首帧完成'); }); viewer.addEventListener('stats', ({ fps, residentMb, loadedMb }) => { console.log(`FPS: ${fps}, 驻留: ${residentMb}MB, 已下载: ${loadedMB}MB`); }); viewer.start();这里maxResidentBytes是虚拟内存池的物理上限,要根据目标设备显存谨慎设置。设置过大,显卡吃紧时连颜色缓冲都分配不出来,渲染会直接卡顿;设置过小,经常触发页回退,画面容易糊。我一般建议取设备可用显存的一半左右。
运行后打开控制台观察stats事件:如果residentMb长时间顶着上限,说明场景超出设备能力,需要下调lodBias;如果loadedMb增长很快但fps不高,瓶颈在解码或上传,可以考虑把page-size调小,加快单个块的处理速度。
6. 设备实测与坑位存档
纸上谈兵没意思,直接把1亿点场景在几台设备上跑一遍的数据放在这里。测试场景是一个约1.1亿高斯的城市扫描模型,RAD文件大小约1.4GB,转格式时用了16bit量化。
6.1 实测数据
| 设备 | 显卡 | 常驻显存占用 | 下载完成时间 | 平均帧率(1440p) |
|---|---|---|---|---|
| 台式机 | RTX 3060 12GB | 4.8GB | 约40秒 | 42fps |
| 笔记本 | Apple M2 Pro 19核GPU | 3.1GB | 约55秒 | 36fps |
| 手机 | 骁龙8 Gen2 | 1.9GB | 约2分钟 | 18fps |
帧率测试条件是相机持续缓慢绕场景旋转,lodBias设为0.8。需要说明的是,不同浏览器、后台进程和网络环境会导致明显波动,上面的数字只能作为参考。
有意思的是手机上的表现。18fps在手机上虽然谈不上流畅,但考虑到这是完整1亿点场景,画面在大部分视角下都保持可辨认细节,已经接近"可用"水平。如果牺牲分辨率和细节档位,调整到720p渲染、lodBias提到1.2,手机可以稳定到30fps以上。
6.2 踩坑记录
印象最深的坑是storage buffer绑定数量。前面提到的默认限制每个shader stage只有8个storage buffer绑定名额,这意味着你不能把几十个物理页一次性全绑到渲染管线里。实际项目里解决方法是把多个物理页包装进一个"大分段"buffer,整段只占一个绑定名额,页表负责定位偏移;分段的物理大小不超过WebGPU的maxBufferSize即可。
第二个坑是镜头绕圈的加载抖动。如果没有保留计数策略,用户在模型上来回扫视,RAD加载器会反复请求同一批块,页面网络请求列表疯狂刷新,帧率被拖到个位数。后来换成"LRU + 保留区"双策略,抖动基本消失。
第三个坑是WebGPU的device lost。移动端浏览器在切换后台或系统内存压力大时会杀掉GPU设备,重启后如果缓存状态没有持久化,场景又得从零加载。我的做法是监听deviceLost事件,把相机参数、已加载块的URL列表存到sessionStorage,设备恢复后重走加载流程,用户感知到的中断时间能缩短到一两秒。
第四个坑是SH量化造成的闪烁。距离变化导致高低档LoD切换时,如果两档的球谐系数量化精度不一致,远处会看到高频闪烁的噪点。缓解办法是给SH切换加一个短暂的crossfade窗口,或者干脆在低档位把SH带宽砍到0阶,宁可颜色平一点也不要闪。
最后提醒一句:Spark 2.0的三项技术是解耦的。如果你的项目只有几百万高斯的场景,直接上RAD流式就够了,LoD和虚拟内存反而增加复杂度;只有真正面向亿级场景、要跑在各种设备上的项目,才值得把三条腿全部装上。我个人目前的做法是先在中小场景上用RAD打底,等设备覆盖面和显存模型稳定了再逐步放开LoD和GPU虚拟内存,这个顺序能帮你把风险控制在一个可控范围内。