让 Three.js 场景更真实:我用高斯泼溅和 SparkJS 做了一个可交互的 3D Demo

最近在做 Three.js 场景时,尝试把高斯泼溅模型接入到普通的 WebGL 三维场景中。相比传统的 glTF、OBJ、FBX 这类网格模型,高斯泼溅更适合表达真实扫描场景:它不是由三角面组成,而是由大量带颜色、透明度、方向和尺度的 3D Gaussian 点元组成。

本文会基于一个完整的 Demo,介绍如何使用 Three.js + SparkJS 加载 .ksplat 文件,并把它纳入 Three.js 的相机、控制器、环境贴图、坐标轴、变换控件和包围盒调试体系中。

如果你更关注快速落地,而不是从零开发完整的三维编辑、拖拽、场景管理和发布能力,也可以看看低代码可视化三维搭建平台 Meteor3D。它提供拖拉拽式的三维场景搭建能力,用户可以快速基于高斯泼溅模型组织真实场景,再结合其他三维资产、标注和交互能力完成可视化应用搭建。

最终效果包括:

  • 使用 SparkJS 加载并渲染 .ksplat 高斯泼溅模型
  • 通过 OrbitControls 旋转、平移、缩放观察模型
  • 通过 TransformControls 拖拽移动或旋转模型
  • 加载 HDR 作为背景和环境光照
  • 显示模型包围盒,辅助定位高斯模型
  • 支持 UI 输入动态缩放高斯模型

1. 高斯泼溅和传统模型有什么不同

传统 Three.js 模型通常是网格结构:

  • 顶点
  • 三角面
  • 法线
  • UV
  • 材质贴图

高斯泼溅模型则不是这样。它更像是一批空间中的"椭球点云",每个点元都携带颜色、透明度、旋转和缩放信息。渲染时,这些高斯点会根据相机视角投影到屏幕上,经过排序、混合之后形成连续的真实感画面。

这也意味着,高斯泼溅模型在 Three.js 中不能简单地当作普通 Mesh 来处理。我们需要一个专门的渲染器来完成高斯点的排序、投影和混合,这里用到的是 SparkJS。

2. 依赖引入

Demo 使用了 importmap 来管理浏览器端 ES Module:

html 复制代码
<script type="importmap">
{
    "imports": {
        "three": "./three184/build/three.module.js",
        "three/addons/": "./three184/examples/jsm/",
        "@sparkjsdev/spark": "https://sparkjs.dev/releases/spark/2.1.0/spark.module.js"
    }
}
</script>

这里有三类依赖:

  • three:Three.js 核心库
  • three/addons/:OrbitControls、TransformControls、RGBELoader 等扩展模块
  • @sparkjsdev/spark:高斯泼溅渲染相关能力

进入业务代码后,核心 import 如下:

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

其中最关键的是 SparkRenderer 和 SplatMesh。

  • SparkRenderer:负责把 SparkJS 的高斯泼溅渲染流程接入 Three.js renderer
  • SplatMesh:表示一个高斯泼溅模型对象,可以添加到 Three.js 场景树中

3. 创建 Three.js 基础场景

基础场景部分和普通 Three.js 项目基本一致:

js 复制代码
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
    75,
    box.clientWidth / box.clientHeight,
    0.1,
    100000
);
camera.position.set(5, 5, 5);

const renderer = new THREE.WebGLRenderer({
    antialias: true,
    alpha: true,
    logarithmicDepthBuffer: true
});
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(box.clientWidth, box.clientHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1;
box.appendChild(renderer.domElement);

这里有一个值得注意的配置:

js 复制代码
logarithmicDepthBuffer: true

高斯泼溅场景有时会和大尺度三维场景结合,比如 GIS、建筑扫描、城市级数据等。开启对数深度缓冲可以缓解远近裁剪范围过大时的深度精度问题,减少闪烁和穿插。

相机远裁剪面设置到了 100000,也是为了给后续接入更大场景预留空间。

4. OrbitControls:让用户自由观察

观察控制器直接挂到相机和 canvas 上:

js 复制代码
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;

开启 enableDamping 后,相机控制会带一点惯性,交互体验更自然。不过要注意,开启 damping 后必须在每一帧调用:

js 复制代码
controls.update();

所以动画循环中会写成:

js 复制代码
renderer.setAnimationLoop(() => {
    controls.update();
    renderer.render(scene, camera);
});

5. 接入 SparkRenderer

高斯泼溅真正接入 Three.js 的关键代码是这两行:

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

SparkRenderer 接收已有的 Three.js WebGLRenderer,然后作为一个对象添加到 Three.js 场景里。这样做的好处是,高斯泼溅不需要脱离 Three.js 单独维护一套渲染上下文,而是可以和原来的场景、相机、控制器一起工作。

这也是 Three.js 集成高斯泼溅时比较舒服的一点:我们仍然可以使用熟悉的场景树组织方式。

6. 加载 .ksplat 高斯模型

模型路径定义如下:

js 复制代码
const splatUrl = './assets/deskFlower.ksplat';

然后创建 SplatMesh:

js 复制代码
const deskFlower = new SplatMesh({
    url: splatUrl
});

为了方便整体控制,Demo 没有直接把 deskFlower 加到场景,而是先创建了一个 Group:

js 复制代码
const splatGroup = new THREE.Group();
splatGroup.position.set(2, 2, -3);
scene.add(splatGroup);

deskFlower.quaternion.set(0, 0, -1, 0);
splatGroup.add(deskFlower);

这个 Group 非常重要。后面 TransformControls 实际 attach 的是 splatGroup,而不是直接 attach deskFlower。

这样做有几个好处:

  • 可以把高斯模型的内部姿态修正和外部场景变换分开
  • 方便统一移动、旋转、缩放整个高斯模型
  • 后续如果一个高斯对象外面还要挂包围盒、标注、辅助点,也更容易组织

这里的:

js 复制代码
deskFlower.quaternion.set(0, 0, -1, 0);

是对模型朝向做一次修正。不同来源的高斯数据坐标系可能不一样,比如 Z-up、Y-up、左右手坐标系差异等,实际接入时经常需要做一次旋转校正。

7. 等待模型初始化完成

SplatMesh 的加载是异步的。只有等 initialized 完成后,才能安全获取包围盒、绑定变换控件等:

js 复制代码
deskFlower.initialized.then(() => {
    const boundingBox = deskFlower.getBoundingBox(false);
    centerPivotOnBoundingBox(splatGroup, deskFlower, boundingBox);

    const boundingBoxHelper = createBoundingBoxHelper(boundingBox, 0xffff00);
    deskFlower.add(boundingBoxHelper);
    transformControls.attach(splatGroup);

    statusEl.innerText = '加载完成';
    statusEl.style.color = '#00ff00';
}).catch((err) => {
    statusEl.innerText = '加载失败 (请检查控制台)';
    statusEl.style.color = '#ff0000';
    console.error('加载出错:', err);
    alert('加载失败,请确认 .ksplat 路径正确,并使用本地服务器访问页面。');
});

这里做了几件事:

  1. 获取高斯模型包围盒
  2. 根据包围盒重新调整 pivot
  3. 创建黄色线框包围盒
  4. 把 TransformControls 绑定到 splatGroup
  5. 更新加载状态

如果加载失败,页面会提示检查 .ksplat 路径,并提醒使用本地服务器访问。因为 ES Module、HDR、模型文件加载都涉及浏览器安全策略,直接双击 HTML 文件经常会遇到跨域或路径问题。

8. 为什么要重新调整模型 pivot

这是整个 Demo 中比较关键的细节。

很多扫描模型的原点并不在物体中心。如果直接旋转,会出现"绕远处某个点转圈"的现象;如果用 TransformControls 操作,也会感觉坐标轴位置不对。

Demo 通过包围盒中心重新修正 Group 和 Mesh 的相对位置:

js 复制代码
function centerPivotOnBoundingBox(group, mesh, box) {
    const localCenter = box.getCenter(new THREE.Vector3());
    const groupCenterOffset = localCenter.clone().applyQuaternion(mesh.quaternion);

    group.position.add(groupCenterOffset);
    mesh.position.sub(groupCenterOffset);
    splatBasePosition.copy(mesh.position);
}

这段逻辑的思路是:

  • 先拿到模型本地包围盒中心
  • 根据模型自身 quaternion 把中心偏移转换到当前方向下
  • 把 Group 移动到这个中心位置
  • 再把 Mesh 反向移动相同偏移

这样从视觉结果看,模型位置没有发生突变;但从场景层级看,splatGroup 的原点已经被移动到了模型中心附近。

最终效果就是:

  • TransformControls 的坐标轴出现在模型中心
  • 旋转模型时围绕自身中心旋转
  • 后续缩放时也更符合直觉

9. 绘制高斯模型包围盒

由于高斯泼溅本身不是传统三角网格,调试时很难一眼看出它的空间范围。Demo 使用 LineSegments 手动创建了一个包围盒:

js 复制代码
function createBoundingBoxHelper(box, color) {
    const { min, max } = box;
    const vertices = [
        min.x, min.y, min.z, max.x, min.y, min.z,
        max.x, min.y, min.z, max.x, max.y, min.z,
        max.x, max.y, min.z, min.x, max.y, min.z,
        min.x, max.y, min.z, min.x, min.y, min.z,

        min.x, min.y, max.z, max.x, min.y, max.z,
        max.x, min.y, max.z, max.x, max.y, max.z,
        max.x, max.y, max.z, min.x, max.y, max.z,
        min.x, max.y, max.z, min.x, min.y, max.z,

        min.x, min.y, min.z, min.x, min.y, max.z,
        max.x, min.y, min.z, max.x, min.y, max.z,
        max.x, max.y, min.z, max.x, max.y, max.z,
        min.x, max.y, min.z, min.x, max.y, max.z
    ];

    const geometry = new THREE.BufferGeometry();
    geometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3));

    const material = new THREE.LineBasicMaterial({
        color,
        depthTest: false,
        transparent: true,
        opacity: 0.9
    });

    const helper = new THREE.LineSegments(geometry, material);
    helper.renderOrder = 10;
    return helper;
}

这里没有直接使用 Box3Helper,而是手写顶点。这样可以更灵活地控制材质参数,比如:

  • depthTest: false:让线框不被模型遮挡
  • transparent: true:支持透明度
  • renderOrder = 10:提高辅助线的渲染顺序

对于高斯泼溅这种半透明、混合渲染比较复杂的对象,调试辅助线最好显式控制渲染顺序和深度测试。

10. TransformControls:拖拽移动和旋转

Demo 中加入了 Three.js 自带的 TransformControls:

js 复制代码
const transformControls = new TransformControls(camera, renderer.domElement);
transformControls.setMode('translate');
transformControls.size = 1.5;
scene.add(transformControls.getHelper());

并且监听拖拽状态:

js 复制代码
transformControls.addEventListener('dragging-changed', (event) => {
    controls.enabled = !event.value;
});

这一步很重要。如果不处理,拖拽坐标轴时 OrbitControls 也会响应鼠标事件,导致模型和相机同时动,交互会很混乱。

这里的逻辑是:

  • 当 TransformControls 正在拖拽时,禁用 OrbitControls
  • 当拖拽结束时,恢复 OrbitControls

再配合键盘快捷键切换模式:

js 复制代码
window.addEventListener('keydown', (event) => {
    if (event.key.toLowerCase() === 'w') transformControls.setMode('translate');
    if (event.key.toLowerCase() === 'e') transformControls.setMode('rotate');
});

这样就可以用 W 切换移动,用 E 切换旋转,和很多三维编辑器里的习惯比较接近。

11. 缩放高斯模型

页面上提供了一个简单的 number input:

html 复制代码
<input id="scaleInput" type="number" min="0.1" max="10" step="0.1" value="1">

输入变化时调用:

js 复制代码
scaleInput.addEventListener('input', () => {
    const scale = Number(scaleInput.value);
    if (!Number.isFinite(scale) || scale <= 0) return;
    setSplatScale(scale);
});

缩放函数如下:

js 复制代码
function setSplatScale(scale) {
    deskFlower.scale.setScalar(scale);
    deskFlower.position.copy(splatBasePosition).multiplyScalar(scale);
}

这里除了设置 scale,还同步调整了 position。原因是前面为了把 pivot 移到包围盒中心,对 deskFlower.position 做过反向偏移。如果只缩放模型,不缩放这个偏移量,模型会相对 Group 原点发生不符合预期的偏移。

所以这里保存了一个 splatBasePosition,每次缩放时都基于原始偏移重新计算位置。

12. HDR 环境背景

Demo 使用 RGBELoader 加载 HDR:

js 复制代码
function loadHDR() {
    const rgbeLoader = new RGBELoader();
    rgbeLoader.load('./assets/day.hdr', (texture) => {
        texture.mapping = THREE.EquirectangularReflectionMapping;
        scene.background = texture;
        scene.environment = texture;
    }, undefined, (err) => {
        console.warn('HDR 加载失败:', err);
    });
}

这里同时设置了:

js 复制代码
scene.background = texture;
scene.environment = texture;

background 负责显示背景,environment 负责给 Three.js PBR 材质提供环境反射。对于当前高斯泼溅模型来说,HDR 主要是提供更接近真实环境的空间氛围;如果场景中还有 glTF 模型、金属材质、玻璃材质,那么 environment 的效果会更加明显。

13. 窗口自适应

最后是标准的 resize 处理:

js 复制代码
window.addEventListener('resize', () => {
    camera.aspect = box.clientWidth / box.clientHeight;
    camera.updateProjectionMatrix();
    renderer.setSize(box.clientWidth, box.clientHeight);
});

页面容器使用 100vw 和 100vh,所以窗口大小变化时,需要同步更新相机宽高比和 renderer 尺寸,否则画面会被拉伸。

14. 实践中的几个注意点

必须使用本地服务器

不要直接双击打开 HTML 文件。建议用任意静态服务器启动项目,例如:

bash 复制代码
npx serve .

或者使用 VS Code / Cursor 插件里的 Live Server。

原因是:

  • ES Module import 有浏览器安全限制
  • .ksplat 模型需要通过 HTTP 请求加载
  • .hdr 文件同样需要通过 HTTP 请求加载

注意模型坐标系

不同工具导出的高斯模型坐标系可能不同。接入后如果发现模型倒了、左右反了、朝向不对,可以先从这几个地方排查:

  • quaternion
  • rotation
  • scale
  • 数据导出时的 up axis

当前 Demo 使用:

js 复制代码
deskFlower.quaternion.set(0, 0, -1, 0);

就是在处理模型朝向问题。

高斯模型适合放进 Group 管理

不要急着直接操作 SplatMesh。更推荐外面套一层 THREE.Group:

js 复制代码
const splatGroup = new THREE.Group();
splatGroup.add(deskFlower);
scene.add(splatGroup);

这样后续做拖拽、吸附、标注、坐标转换、批量显示隐藏都会更方便。

包围盒对调试非常有用

高斯泼溅看起来像一团真实影像,但它的空间边界并不直观。加一个包围盒可以快速判断:

  • 模型是否加载到预期位置
  • 模型是否被错误缩放
  • pivot 是否在中心附近
  • TransformControls 是否绑定到了正确对象

15. 总结

这份 Demo 的重点不只是"把 .ksplat 显示出来",而是把高斯泼溅真正接入到 Three.js 的交互体系里。

核心链路可以概括为:

  1. 创建 Three.js 场景、相机和 WebGLRenderer
  2. 创建 SparkRenderer 并加入场景
  3. 使用 SplatMesh 加载 .ksplat 文件
  4. 用 THREE.Group 包裹高斯模型,方便整体控制
  5. 等待 initialized 后获取包围盒
  6. 根据包围盒中心修正 pivot
  7. 添加 TransformControls、OrbitControls 和辅助线框
  8. 在动画循环中正常渲染 Three.js 场景

高斯泼溅很适合用于真实场景扫描、室内空间展示、文物数字化、街景重建等方向。而 Three.js 的优势在于成熟的场景管理、交互控制和 Web 生态。两者结合之后,可以在浏览器里快速做出既真实又可交互的三维展示系统。

后续还可以继续扩展:

  • 多个 .ksplat 模型的加载和显隐控制
  • 高斯泼溅与 glTF 模型混合展示
  • 与 GIS 坐标、3D Tiles、地图底图结合
  • 增加点击拾取、标注、测距等业务能力
  • 对高斯模型做 LOD、裁剪和性能优化

如果不想从零写完整的三维编辑、拖拽、场景管理和发布能力,也可以尝试低代码可视化三维搭建平台 Meteor3D。它提供拖拉拽式的三维场景搭建能力,用户可以快速基于高斯泼溅模型组织真实场景,再结合其他三维资产、标注和交互能力完成可视化应用搭建。

这也是我认为高斯泼溅值得和 Three.js 一起研究的原因:它负责真实感表达,Three.js 负责三维应用能力,组合起来会非常适合 Web 端沉浸式可视化场景。

相关推荐
子兮曰3 天前
jev-ultrafast 深度解析:7 秒订机票的浏览器 Agent 是如何炼成的
前端·后端·agent
子兮曰3 天前
Jev 爆发一周:7 秒 Agent 背后的 System One 生态与三场争议
前端·后端·ai编程
前端小万3 天前
写公众号赚了 3000 块后,我做了一款叫 "一键成稿" 的软件
前端·微信小程序
爱勇宝3 天前
ZCode 开源 24 小时:一份没有历史的账本,回答不了"有没有偷代码"
前端·后端·chatglm (智谱)
三十而立洋3 天前
Cookie 详解:从产生到安全,一次讲透
前端·javascript
卡布鲁3 天前
把一个 Vite + Vue3 应用塞进 qiankun (React + Umi3) 主站:十个坑的复盘
前端·javascript·react.js
汉堡大王95273 天前
Jev:不是聊天机器人, 而是一个智能 if 语句
前端·人工智能·后端
梦想很大很大3 天前
从运行事实到回归证据:Workrun 的 Telemetry 与 Evaluation 实践
前端·人工智能·后端
计算机魔术师3 天前
Meta Muse agent 接入 Shopify 的 Shop Pay 实现代理式购物
前端
沙蒿同学3 天前
我用 Go 搭了一条 AI Agent 流水线:从 1 张商品图到一整套淘宝详情页
前端·javascript·后端