让 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';

其中最关键的是 SparkRendererSplatMesh

  • 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);
});

页面容器使用 100vw100vh,所以窗口大小变化时,需要同步更新相机宽高比和 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 端沉浸式可视化场景。

相关推荐
落日漫游2 小时前
代码报错难排查?借助Gemini快速修复
前端
Darling噜啦啦2 小时前
JavaScript 数组深度解析:从纯函数到二维数组陷阱,一文吃透前端数据结构核心
前端·javascript·数据结构
万少2 小时前
一封邮件,让我重新打开了搁置半年的鸿蒙应用
前端·javascript·后端
wjj不想说话2 小时前
你的小程序活动页,可能已经成了后台配置的杂物间
前端
梦想是准点下班2 小时前
androidStudio打包,我又又又忘了
前端
槑有老呆2 小时前
栈队列链表,三个故事就懂了
前端
ViavaCos2 小时前
pnpm v11 的安全策略,让我踩了个坑
前端
To_OC2 小时前
从一段定时器代码,重新捋清 JS 同步、异步与 Promise
前端·javascript·代码规范
持敬chijing2 小时前
Web渗透之前后端漏洞-XSS漏洞原理攻击防御全流程
前端·安全·web安全·网络安全·网络攻击模型·安全威胁分析·xss