Spark 2.0:面向 Web 的 3DGS 可视化与大场景渲染平台详解

很多人第一次看到 Spark 2.0,会把它理解成一个"3DGS 重建工具"。

但更准确地说,Spark 2.0 的重点不是从图片重建 Gaussian Splat,而是如何把已有的 3DGS 资产在浏览器里高质量、低延迟、可交互地渲染出来

它解决的问题很具体:3DGS 资产动辄几百万到几千万个 splat,传统浏览器渲染器要么加载慢,要么内存爆,要么多个 splat 对象之间排序不正确。Spark 2.0 通过 THREE.js + WebGL2、全局 splat 排序、LoD Splat Tree、RAD 流式格式和 GPU 虚拟页表,把 3DGS 从"单个模型查看器"推进到"可组合的大型 Web 3D 世界渲染系统"。


1. Spark 2.0 是什么?

Spark 是 World Labs 开源的 3D Gaussian Splatting 渲染器,面向 Web 端,和 THREE.js 深度集成。你可以把它理解为:

SparkRenderer 负责把 Gaussian Splats 插入 THREE.js 渲染管线;SplatMesh 则像 THREE.Mesh 一样,代表一个可移动、可旋转、可动画的 3DGS 对象。

它支持常见 3DGS 文件格式,包括 .ply.spz.splat.ksplat.sog/.zip,以及 Spark 2.0 新增的 .rad 流式 LoD 格式。

Spark 的价值不在于生成 splat,而在于:

  1. 在浏览器里渲染已有 3DGS 资产;

  2. 支持多个 splat 对象在同一场景中正确混合;

  3. 支持大场景的 LoD 渲染与按需加载;

  4. 支持与 THREE.js mesh、灯光、相机、控制器、WebXR 等生态结合;

  5. 支持动态 splat 修改、颜色编辑、shader 扩展和动画效果。

一个很重要的点是:Spark 2.0 不是 WebGPU 优先,而是选择 WebGL2。这样做的原因很现实:WebGL2 的设备覆盖率更高,桌面、iOS、Android、VR 浏览器都更容易跑起来。Spark 的目标不是只服务高端桌面,而是让大规模 3DGS 可以在真实 Web 分发环境中运行。


2. 最小使用方式:用 Spark 显示一个 3DGS 模型

最简单的方式是直接用 CDN + importmap 写一个 index.html

复制代码
<style>
  body {
    margin: 0;
    overflow: hidden;
  }
</style>

<script type="importmap">
{
  "imports": {
    "three": "https://cdn.jsdelivr.net/npm/three@0.180.0/build/three.module.js",
    "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.180.0/examples/jsm/",
    "@sparkjsdev/spark": "https://sparkjs.dev/releases/spark/2.1.0/spark.module.js"
  }
}
</script>

<script type="module">
  import * as THREE from "three";
  import { SparkRenderer, SplatMesh } from "@sparkjsdev/spark";
  import { OrbitControls } from "three/addons/controls/OrbitControls.js";

  const scene = new THREE.Scene();

  const camera = new THREE.PerspectiveCamera(
    60,
    window.innerWidth / window.innerHeight,
    0.01,
    1000
  );
  camera.position.set(0, 0, 3);

  const renderer = new THREE.WebGLRenderer({
    antialias: false
  });
  renderer.setSize(window.innerWidth, window.innerHeight);
  document.body.appendChild(renderer.domElement);

  const controls = new OrbitControls(camera, renderer.domElement);

  const spark = new SparkRenderer({ renderer });
  scene.add(spark);

  const splat = new SplatMesh({
    url: "./assets/model.spz"
  });

  splat.position.set(0, 0, 0);
  splat.quaternion.set(1, 0, 0, 0);
  scene.add(splat);

  window.addEventListener("resize", () => {
    camera.aspect = window.innerWidth / window.innerHeight;
    camera.updateProjectionMatrix();
    renderer.setSize(window.innerWidth, window.innerHeight);
  });

  renderer.setAnimationLoop(() => {
    controls.update();
    renderer.render(scene, camera);
  });
</script>

核心只有三步:

复制代码
const spark = new SparkRenderer({ renderer });
scene.add(spark);

const splat = new SplatMesh({ url: "./assets/model.spz" });
scene.add(splat);

在 Spark 2.0 中,SparkRenderer 必须显式创建并加入 scene。否则 SplatMesh 不会被渲染出来。


3. 用 NPM / Vite 部署一个项目

如果你要做正式项目,建议使用 Vite。

复制代码
npm create vite@latest spark-viewer -- --template vanilla
cd spark-viewer
npm install three @sparkjsdev/spark
npm install
npm run dev

项目结构可以这样放:

复制代码
spark-viewer/
  public/
    assets/
      model.spz
      scene-lod.rad
      scene-lod-0.radc
      scene-lod-1.radc
  src/
    main.js
  index.html
  package.json

src/main.js

复制代码
import * as THREE from "three";
import { SparkRenderer, SplatMesh } from "@sparkjsdev/spark";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";

const scene = new THREE.Scene();

const camera = new THREE.PerspectiveCamera(
  60,
  window.innerWidth / window.innerHeight,
  0.01,
  1000
);
camera.position.set(0, 0, 3);

const renderer = new THREE.WebGLRenderer({
  antialias: false
});
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

const controls = new OrbitControls(camera, renderer.domElement);

const spark = new SparkRenderer({
  renderer,
  lodSplatScale: 1.0,
  maxStdDev: Math.sqrt(8)
});
scene.add(spark);

const splat = new SplatMesh({
  url: "/assets/model.spz"
});
scene.add(splat);

renderer.setAnimationLoop(() => {
  controls.update();
  renderer.render(scene, camera);
});

构建部署:

复制代码
npm run build

然后把 dist/ 部署到任意静态服务器即可,例如 Nginx、Vercel、Netlify、Cloudflare Pages、S3 + CloudFront。

如果使用 .rad + .radc 分块流式加载,要注意:

  1. .rad 和所有 .radc 文件必须一起上传;

  2. 路径必须保持一致;

  3. CDN 不能错误地改写或压缩二进制文件;

  4. 服务器最好支持 HTTP Range 或至少稳定支持并发 chunk 请求;

  5. 如果跨域加载,需要正确配置 CORS。


4. 如何启用 Spark 2.0 的 LoD 渲染

Spark 2.0 的大场景能力来自 LoD。

如果你只有一个普通 .spz.ply 文件,可以在浏览器里临时生成 LoD:

复制代码
const splat = new SplatMesh({
  url: "./model.spz",
  lod: true
});
scene.add(splat);

这会在后台 Web Worker 中运行快速 LoD 构建。小模型可以接受,但大模型不推荐在线构建,因为首次加载会慢。

更推荐的做法是:离线预处理成 .rad

复制代码
git clone https://github.com/sparkjsdev/spark.git
cd spark

npm install
npm run build:wasm
npm run build

npm run build-lod -- ./my-splats.ply --quality

输出通常是:

复制代码
my-splats-lod.rad

然后在代码中加载:

复制代码
const splat = new SplatMesh({
  url: "/assets/my-splats-lod.rad"
});
scene.add(splat);

如果要启用分块流式加载,构建时使用:

复制代码
npm run build-lod -- ./my-splats.ply --quality --rad-chunked

输出类似:

复制代码
my-splats-lod.rad
my-splats-lod-0.radc
my-splats-lod-1.radc
my-splats-lod-2.radc
...

加载时:

复制代码
const splat = new SplatMesh({
  url: "/assets/my-splats-lod.rad",
  paged: true
});
scene.add(splat);

这样浏览器不会一次性下载所有 splat 数据,而是先显示一个粗略版本,再根据相机视角不断拉取细节 chunk。


5. Spark 的渲染管线:为什么多个 Splat 能正确混合?

普通透明物体渲染有一个经典问题:透明对象必须按从远到近排序,否则混合结果会错。3DGS 更严重,因为一个模型可能有几百万个半透明 splat。

Spark 的基本渲染流程是三步:

  1. 收集场景中所有 SplatMesh 的 splat;

  2. 把它们变换到同一个坐标空间,生成一个全局 splat 列表;

  3. 按当前相机视角从后往前排序;

  4. 用一次 instanced draw call 渲染所有 splat。

这和"每个模型自己排序、自己画一遍"完全不同。后者在多个 splat 物体相互穿插时会出错,看起来像一个物体被贴在另一个物体上。Spark 通过全局排序,让多个 3DGS 对象真正存在于同一个 3D 空间。

每个 splat 在 GPU 上通常被画成一个朝向屏幕的 quad,也就是两个三角形。vertex shader 负责把 3D Gaussian 投影成屏幕上的椭圆范围;fragment shader 在椭圆内计算 Gaussian 衰减和 alpha,再通过硬件 blending 混合到 framebuffer。

所以 Spark 的渲染本质是:

复制代码
SplatMesh objects
  -> global splat accumulation
  -> view-dependent sorting
  -> instanced quad rendering
  -> Gaussian fragment evaluation
  -> alpha blending

6. Spark 2.0 的核心:LoD Splat Tree

普通 3DGS 文件是一个扁平 splat 集合。比如 1000 万个 splat,就是真的有 1000 万个点状高斯要加载、排序、渲染。

Spark 2.0 做了一件很关键的事:把 splat 组织成一棵 LoD Splat Tree。

这棵树的叶子节点是原始高精度 splat,内部节点是更粗的 splat。一个父节点 splat 近似表示它下面一组子 splat 的整体形状、颜色和透明度。树根则是整个对象的极粗略表示。

渲染时,Spark 不会固定渲染某一层,而是根据相机位置和屏幕尺寸,在树上切一刀:

复制代码
远处区域:使用较粗的父节点 splat
近处区域:展开到更细的子节点 splat
视野中心:分配更多细节
视野边缘或背后:分配更少细节

这就是 continuous LoD。它比传统"低模/中模/高模切换"更平滑,不容易出现突然 popping。

Spark 的 LoD 遍历大致如下:

复制代码
1. 从根节点开始,把根节点放入优先队列;
2. 计算节点投影到屏幕上的尺寸;
3. 每次取出屏幕尺寸最大的 splat;
4. 如果它已经足够小,或者是叶子,就加入渲染集合;
5. 如果还能继续细分,并且没有超过 splat budget,就替换为它的子节点;
6. 如果超过预算,就停止展开,把队列中剩余节点作为当前帧渲染集合。

这个算法的目标不是"显示所有 splat",而是在固定预算内显示最值得显示的 splat。

这就是 Spark 2.0 能稳定跑大场景的关键:不管原始场景有 500 万还是 5000 万 splat,每帧真正渲染的 splat 数可以控制在一个固定预算里。


7. splat budget:怎么调渲染质量和性能

SparkRenderer 中比较重要的 LoD 参数是:

复制代码
const spark = new SparkRenderer({
  renderer,
  lodSplatScale: 1.0,
  lodRenderScale: 1.0,
  coneFov0: 90,
  coneFov: 120,
  coneFoveate: 0.4,
  behindFoveate: 0.2
});

常用调法:

复制代码
// 更清晰,但更吃性能
spark.lodSplatScale = 1.5;

// 更快,但更糊
spark.lodSplatScale = 0.6;

// 提高最小屏幕 splat 尺寸,减少浪费在微小 splat 上的预算
spark.lodRenderScale = 2.0;

经验上可以这样理解:

设备 建议渲染预算
Quest 3 约 100 万以内
Android 手机 约 100 万到 200 万
iPhone 约 100 万到 300 万
普通桌面 约 100 万到 500 万
高端桌面 可能更高,但取决于场景和 GPU

Spark 还有一个重要优化参数:

复制代码
const spark = new SparkRenderer({
  renderer,
  maxStdDev: Math.sqrt(5)
});

maxStdDev 控制每个 Gaussian 绘制到多远的标准差范围。默认约为 sqrt(8),质量更完整;调小可以减少 fragment 填充压力,尤其适合 VR 或移动端。

另外,创建 THREE renderer 时建议:

复制代码
const renderer = new THREE.WebGLRenderer({
  antialias: false
});

因为 Gaussian Splatting 本身不是靠 MSAA 提升质量,开启 antialias 反而会增加巨大开销。


8. RAD 文件:为什么 Spark 2.0 不只用 PLY / SPZ?

传统 .ply 通常是 row-oriented,即一个 splat 的所有属性连续存储。它简单,但未压缩,文件巨大。

.spz 更紧凑,通常以 column-oriented 方式存储,比如所有 position 放一起、所有 opacity 放一起,并进行量化压缩。但它的问题是:不适合随机访问和渐进式加载。你往往要等整个文件下载完,才能恢复完整 splat 数据。

Spark 2.0 引入 .rad 格式,核心目标是:

  1. 压缩;

  2. 可随机访问;

  3. 可渐进式流式加载;

  4. 可扩展;

  5. 适配 LoD Splat Tree。

.rad 的结构可以理解为:

复制代码
RAD header
  -> JSON metadata
  -> chunk offsets
  -> RADC chunk 0
  -> RADC chunk 1
  -> RADC chunk 2
  -> ...

每个 .radc chunk 通常包含一组 splat 数据,比如 64K splats。Spark 先加载最粗的 chunk,让画面几乎立刻出现;然后根据相机视角继续加载最有价值的细节 chunk。

这意味着用户不需要等一个几百 MB 甚至 GB 级文件全部下载完。场景可以先以低细节显示出来,再逐步变清晰。


9. 渐进式 Streaming:画面为什么能"先粗后细"?

Spark 2.0 的 streaming 不是简单地按文件顺序下载,而是视角相关的优先级下载。

流程大概是:

复制代码
1. 加载 .rad header,知道所有 chunk 的 offset、大小和空间关系;
2. 先加载 chunk 0,得到全局粗略表示;
3. LoD 遍历时发现某些子节点所在 chunk 还没加载;
4. 当前帧先用父节点代替;
5. 根据遍历访问顺序,为缺失 chunk 排优先级;
6. Web Worker 并行下载和解码 chunk;
7. chunk 到达后写入 GPU page;
8. 下一帧可展开更细节点,画面变清晰。

这套机制让 Spark 更像一个 3DGS 版的地图瓦片系统:用户看哪里,细节就优先加载哪里。


10. GPU 虚拟内存:为什么大场景不会一次性爆显存?

大场景 3DGS 的瓶颈不只是下载,也包括 GPU 显存。即使服务器能提供 20GB 数据,浏览器和移动 GPU 也不可能全部放进去。

Spark 2.0 借鉴了虚拟内存思想:在 GPU 上分配一个固定大小的 splat page pool。例如桌面默认可以分配较多页,移动端少一些。每页对应固定数量的 splat,例如 64K。

可以把它理解成:

复制代码
虚拟数据:所有 .rad 文件里的所有 chunk
物理显存:固定数量的 GPU pages
page table:记录哪个 virtual chunk 当前被放在哪个 GPU page
eviction:不重要的 chunk 被换出,重要 chunk 被换入

当相机移动时,Spark 根据 LoD 遍历结果决定哪些 chunk 重要。重要 chunk 不在 GPU 中,就请求下载或解码;GPU page 满了,就用类似 LRU 的策略把低优先级 chunk 换出。

这就是为什么 Spark 可以浏览远大于 GPU 显存容量的 3DGS 世界。


11. Tiny-LoD 与 Bhatt-LoD:LoD 树怎么生成?

Spark 2.0 提供两类 LoD 构建算法。

Tiny-LoD

Tiny-LoD 更快,适合浏览器端或快速预览。它把空间划分成网格,把落在同一格内的 splat 合并成更粗的 splat,再逐级向上合并,直到形成根节点。

它的优点是:

  1. 快;

  2. 内存占用相对友好;

  3. 可以在 Web Worker 里临时生成;

  4. 适合交互式 on-demand LoD。

缺点是质量不如更慢的离线方法。

Bhatt-LoD

Bhatt-LoD 更适合离线构建。它基于 splat 的形状、颜色和统计相似度来决定哪些 splat 更应该被合并。它通常能生成更高质量的 LoD 树,但速度更慢。

实际项目建议:

复制代码
# 快速测试
npm run build-lod -- ./model.spz --quick

# 正式上线
npm run build-lod -- ./model.spz --quality --rad-chunked

如果你做的是产品级 Web 展示、数字孪生、大场景浏览、VR 展示,优先使用 --quality --rad-chunked


12. 一个推荐的生产级加载方案

对于普通小模型:

复制代码
const splat = new SplatMesh({
  url: "/assets/object.spz"
});
scene.add(splat);

对于中型模型,需要 LoD 但不一定 streaming:

复制代码
npm run build-lod -- ./object.spz --quality

const splat = new SplatMesh({
  url: "/assets/object-lod.rad"
});
scene.add(splat);

对于大型场景或多对象世界:

复制代码
npm run build-lod -- ./scene/*.spz --quality --rad-chunked

const spark = new SparkRenderer({
  renderer,
  lodSplatScale: 1.0,
  numLodFetchers: 3,
  maxStdDev: Math.sqrt(8)
});
scene.add(spark);

const cityBlock = new SplatMesh({
  url: "/assets/city-block-lod.rad",
  paged: true
});
scene.add(cityBlock);

const interior = new SplatMesh({
  url: "/assets/interior-lod.rad",
  paged: true
});
interior.position.set(20, 0, -10);
scene.add(interior);

Spark 会把多个 .rad 对象的 LoD 遍历和 page 管理统一起来,而不是每个对象各自为政。


13. 部署注意事项

1. 静态资源路径

确保 .rad.radc 文件在同一目录,并且命名不要被构建工具 hash 掉,除非你同时改写 Spark 可访问的路径。

推荐放在:

复制代码
public/assets/splats/

然后用:

复制代码
new SplatMesh({
  url: "/assets/splats/scene-lod.rad",
  paged: true
});

2. CDN 缓存

.radc 是稳定二进制 chunk,非常适合 CDN 缓存。可以设置较长缓存:

复制代码
Cache-Control: public, max-age=31536000, immutable

如果文件会更新,建议文件名带版本号:

复制代码
scene-v003-lod.rad
scene-v003-lod-0.radc

3. CORS

如果 splat 文件放在另一个域名,需要设置:

复制代码
Access-Control-Allow-Origin: *

或者指定你的前端域名。

4. MIME 类型

.rad.radc 可以当作二进制文件服务:

复制代码
application/octet-stream

5. 不要错误二次压缩

.radc 内部已经有压缩数据。服务器/CDN 不应再以错误方式强制 gzip、br 或转码,避免破坏 range / chunk 请求行为。


14. 常见问题

为什么我看不到模型?

检查:

复制代码
const spark = new SparkRenderer({ renderer });
scene.add(spark);

Spark 2.0 必须显式加入 SparkRenderer

为什么模型方向不对?

很多 3DGS 数据来自 OpenCV 坐标系,WebGL/THREE.js 坐标可能不同。可以尝试:

复制代码
splat.quaternion.set(1, 0, 0, 0);

或手动调整 rotation。

为什么移动端很卡?

先调低:

复制代码
spark.lodSplatScale = 0.5;
spark.maxStdDev = Math.sqrt(5);
renderer.setPixelRatio(1);

并确认:

复制代码
new THREE.WebGLRenderer({ antialias: false });

为什么大场景加载慢?

不要直接加载超大 .ply.spz。应该离线构建:

复制代码
npm run build-lod -- ./scene.spz --quality --rad-chunked

然后:

复制代码
new SplatMesh({
  url: "/assets/scene-lod.rad",
  paged: true
});

15. 总结

Spark 2.0 的核心不是"更会重建",而是"更会渲染"。

它把 3DGS Web 渲染拆成了几个关键系统:

  1. SplatMesh:像 THREE.Mesh 一样组织 3DGS 对象;

  2. SparkRenderer:接管 splat 收集、排序、渲染;

  3. 全局排序:让多个 splat 对象正确透明混合;

  4. LoD Splat Tree:让大场景按视角选择合适细节;

  5. RAD / RADC:让 3DGS 数据可压缩、可随机访问、可流式加载;

  6. SplatPager:用固定 GPU page pool 管理超大虚拟 splat 数据;

  7. Web Worker + Wasm:把 LoD、排序、下载、解码放到后台,避免阻塞主线程。

如果你的目标是把 3DGS 资产做成 Web 端可交互展示、数字空间浏览、产品展示、VR 场景或大规模世界可视化,Spark 2.0 是目前非常值得研究的方案。它不是一个单纯 viewer,而是一个面向浏览器的大规模 3DGS 渲染系统。

相关推荐
Pedantic1 小时前
SwiftUI 手势层级(Gesture Hierarchy)详解
前端
飘尘1 小时前
前端转型全栈(Java后端)的快速上手指引
前端·后端·全栈
一颗烂土豆1 小时前
Meshopt 压缩深度解析,为什么它比 Draco 更快
前端·javascript·webgl
浏览器工程师2 小时前
AI Agent 接浏览器任务,先别让它一路点到底
前端·后端
雨季mo浅忆2 小时前
VSCode自动格式化三要素
前端
爱勇宝3 小时前
深扒 Anthropic 1680 位工程师简历:应届生几乎没机会,AI 公司最缺的不是博士
前端·后端·程序员
kyriewen4 小时前
同事每天催我 Code Review,我写了个脚本让 AI 替我 review PR——现在他反过来催 AI 了
前端·javascript·ai编程
user20585561518136 小时前
Windows 项目安装时报 `node-sass` 错误,如何快速处理
前端
LiaCode6 小时前
Redis 在生产项目的使用
前端·后端