
1亿个3D高斯点在浏览器里实时渲染并且保持可交互帧率这件事我第一次听到时是持怀疑态度的。一年前我自己做过一个Web端高斯泼溅项目导出一个800MB的COLMAP场景拖进WebGL渲染器笔记本风扇直接起飞拖动视角卡成幻灯片最后浏览器干脆弹了Aw, Snap。后来我把3D Gaussian Splatting3DGS相关的开源方案挨个试了一圈发现大家卡住的原因高度一致数据量太大单一手段根本救不回来。Spark 2.0能把这套东西真正端进浏览器靠的不是什么黑魔法而是三根支柱——连续LoD、RAD流式格式、GPU虚拟内存——把该画谁、数据怎么来、显存往哪放三个问题分开解决。这篇文章适合这几类人看被大场景3DGS整到内存爆掉的前端工程师想在Web端做城市级点云或扫描场景展示的开发者以及想搞明白LoD和流式加载在WebGPU上到底怎么落地的同学。我会先讲清楚这三项技术各自解决什么问题为什么缺一个都行不通然后贴一段我实测的接入流程和踩坑记录。文中涉及的API和工具名以我手上的版本为准不同版本可能有出入但底层的设计思路是通用的。1. 为什么是三条腿先看清问题出在哪一层很多人在优化大场景渲染时有个误区觉得显存不够就压缩、下载太慢就开多线程。实际上3DGS的瓶颈不是一个而是三个叠在一起。先算一笔账你就明白为什么单点优化没用。1.1 数据量、带宽、排序三重夹击先说数据量。一个标准的高斯泼溅场景每个高斯点至少要存位置3个float、协方差矩阵对应的尺度和旋转通常34个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 shaderWebGL2根本做不到。这三个问题性质不同数据量大是存储问题下载慢是传输问题排序重是算力问题。想用一个办法通吃必然顾此失彼。1.2 三件套的分工加载层、细节层、驻留层Spark 2.0的处理方式是把问题拆开各管一段。RAD流式格式管的是传输加载把场景从一个大文件拆成可按需获取的块相机看到哪就加载哪。连续LoD管的是细节决策让渲染器在每个视角下只画看得清的高斯远处用合并后的粗粒度点近处才用原始细粒度点。GPU虚拟内存管的是显存驻留1亿点的逻辑数据映射到有限的物理显存里不常用的页被换出去常用的页被钉住。打个比方逛一座城市。LoD决定你站在楼顶看街道时只画路网走到路口才画店铺招牌RAD负责按你所在的街区下载对应比例尺的地图而不是一次性下载全国路网GPU虚拟内存则是你手里的内存记事本——只记当前街区走到下一个街区再换页。三条腿缺一不可有LoD没RAD细节再多数据传不过来有RAD没LoD块切得再细渲染器还是被迫画超出能力的高斯总数有LoD和RAD没有GPU虚拟内存TS流数据到了显存边界立刻爆掉前两项优化全部白费。2. 连续LoD把抽稀换模型改成高斯属性连续渐变LoDLevel 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 12GB4.8GB约40秒42fps笔记本Apple M2 Pro 19核GPU3.1GB约55秒36fps手机骁龙8 Gen21.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虚拟内存这个顺序能帮你把风险控制在一个可控范围内。