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 渲染系统。

相关推荐
KaMeidebaby1 小时前
卡梅德生物技术快报|酵母双杂交 cDNA 文库构建与蛋白互作筛选流程
服务器·前端·数据库·人工智能·算法
沐风___1 小时前
App 上架之后:如何看数据、获取用户与持续迭代产品
服务器·前端·数据库
AAA大运重卡何师傅(专跑国道)2 小时前
力扣hot100
服务器·前端·数据库
GISer_Jing2 小时前
前端沙箱开源项目推荐(React/Next/Vue优先)
前端·react.js·开源
云水一下2 小时前
CSS3从零基础到精通(三):动感地带——过渡、动画、变形与响应式
前端·css3
KaMeidebaby3 小时前
卡梅德生物技术快报|Western Blot 实验应用:肺肠轴机制研究全流程技术解析
前端·数据库·人工智能·算法·百度
迁移科技3 小时前
AI+3D视觉赋能汽车箱体智能上下料
人工智能·3d·自动化·视觉检测
3DVisionary3 小时前
蓝光三维扫描:模具电极3D检测新方案
3d·智能制造·3d检测·非接触测量·蓝光三维扫描·xtom·模具电极
达达爱吃肉3 小时前
claude 接入deepseek 运行报错
java·服务器·前端