简介:本资源是一套基于Three.js实现3D-Gaussian-Splatting算法的Web端三维重建实战项目,面向前端工程师、计算机视觉初学者及WebGL图形开发爱好者,解决在浏览器中轻量级部署高斯溅射三维重建的技术落地难题。压缩包共83个文件,含55个核心JavaScript模块(涵盖SplatMesh、Raycaster、Viewer等渲染与交互逻辑)、8个HTML演示页(如garden.html、truck.html等多场景实例)、4个WASM加速模块及3个JSON配置文件,整体仅2.2MB,兼顾功能完整性与加载效率。已有1223人学习下载。项目提供从零搭建的完整流程教程、可直接运行的源码结构(含rollup构建配置、three-shim兼容层、VR/XR扩展支持),以及包含OrbitControls、SceneHelper、UI组件等工程化封装的成熟代码范式,特别适合快速理解高斯溅射原理、调试点云渲染效果并拓展至虚拟现实或数字孪生应用。
1. 为什么用 Three.js 跑 3D-Gaussian-Splatting 不是“炫技”,而是三维重建落地的关键折中点
你手头有一组手机拍的 20 张咖啡杯照片,想快速生成可交互、带真实光照感的 3D 模型——传统 NeRF 渲染一帧要 30 秒,Mesh 重建又丢失细节。这时,3D-Gaussian-Splatting(3DGS)给出新解:它把场景表达为数万颗带位置/协方差/透明度/颜色的高斯椭球,渲染快、保细节、支持实时编辑。但官方实现基于 PyTorch + CUDA,在浏览器里跑不动。而 Three.js 的PointsMaterial和ShaderMaterial恰好能复现其核心思想:用粒子系统模拟高斯椭球的投影与混合。这不是“降级妥协”,而是面向 Web 端交付的合理技术选型——无需 GPU 服务器、不依赖 Python 环境、用户扫码即看、支持 WebGL2 的设备都能跑。本项目正是围绕这一目标构建:从 COLMAP 稠密重建输出的.ply点云出发,将每个点扩展为带协方差矩阵的高斯参数,再通过 Three.js 自定义着色器完成 splatting 渲染。适合三维重建初学者理解算法本质,也适合前端工程师接入实景建模业务流。
2. 从点云到高斯参数:Three.js 中实现 3D-Gaussian-Splatting 的数据预处理链路
3D-Gaussian-Splatting 的输入不是原始图像,而是已配准的稀疏点云(如 COLMAP 输出)及其对应的相机位姿。Three.js 本身不处理 SfM,因此必须在前端之外完成几何重建,再将结果结构化为可被 WebGL 消费的数据格式。整个预处理链路分三步:点云增强、协方差计算、参数序列化。
2.1 点云增强:用 OpenCV 补全法向量与尺度信息
官方 3DGS 训练需每个点具备位置p、尺度s、旋转R(或四元数)、不透明度α、球谐系数SH。但 COLMAP 导出的.ply通常只含x,y,z,red,green,blue。缺失的法向量和尺度需通过邻域分析补全:
# 使用 open3d 进行法向量估计与尺度初始化 import open3d as o3d import numpy as np pcd = o3d.io.read_point_cloud("colmap/sparse/points3D.ply") pcd.estimate_normals(search_param=o3d.geometry.KDTreeSearchParamHybrid(radius=0.1, max_nn=30)) pcd.normalize_normals() # 将法向量转为旋转(假设初始朝向为 z 轴) def normal_to_rotation(normal): z = normal / np.linalg.norm(normal) x = np.cross(z, [0, 0, 1]) if abs(np.dot(z, [0,0,1])) < 0.99 else np.cross(z, [1,0,0]) x /= np.linalg.norm(x) y = np.cross(z, x) return np.column_stack([x, y, z]).astype(np.float32) rotations = np.array([normal_to_rotation(np.asarray(pcd.normals)[i]) for i in range(len(pcd.points))]) scales = np.full((len(pcd.points), 3), 0.01).astype(np.float32) # 初始尺度设为 0.01 单位提示:尺度不能全设为常量。实际项目中应根据点云局部密度动态计算——例如用 KDTree 查询每个点最近 10 个邻居的距离均值,再映射到
[0.005, 0.03]区间。否则远处点会过度模糊,近处点则锯齿明显。
2.2 协方差矩阵生成:从旋转+尺度推导高斯椭球形状
3DGS 中每个高斯由协方差矩阵Σ = R @ diag(s²) @ R.T定义。Three.js 无法直接传入 3×3 矩阵,需将其压缩为 6 维向量:[σ_xx, σ_yy, σ_zz, σ_xy, σ_xz, σ_yz]。这是 WebGL 传输效率与着色器解包便利性的平衡点:
def build_covariance_matrix(rotation, scale): scale_mat = np.diag(scale ** 2) cov = rotation @ scale_mat @ rotation.T return np.array([cov[0,0], cov[1,1], cov[2,2], cov[0,1], cov[0,2], cov[1,2]], dtype=np.float32) covariances = np.array([build_covariance_matrix(rotations[i], scales[i]) for i in range(len(pcd.points))])2.3 参数打包:生成 Three.js 可直接加载的二进制缓冲区
Three.js 加载大量粒子时,BufferGeometry比Geometry性能高 5–8 倍。需将位置、颜色、协方差、不透明度、球谐系数全部写入ArrayBuffer,并按字段对齐(float32 × N):
| 字段 | 维度 | 类型 | 说明 |
|---|---|---|---|
| position | 3 | float32 | x,y,z |
| color | 3 | float32 | r,g,b归一化到[0,1] |
| covariance | 6 | float32 | σ_xx,σ_yy,σ_zz,σ_xy,σ_xz,σ_yz |
| opacity | 1 | float32 | α ∈ [0.01, 0.99],避免完全透明导致深度测试异常 |
| sh_coeff | 45 | float32 | 前 3 阶球谐系数(l=0→2, 共(l+1)²=9个 RGB 分量 →9×3=27,但 3DGS 实际用 15 个 SH 系数 × 3 通道 = 45) |
// 前端加载时:从 .bin 文件解析 const loader = new THREE.FileLoader(); loader.load('gs_params.bin', (data) => { const buffer = new ArrayBuffer(data.length); const view = new DataView(buffer); const f32 = new Float32Array(buffer); // 按字段偏移读取(假设每点共 1+3+3+6+45 = 58 个 float32) const numPoints = data.length / (58 * 4); const positions = new Float32Array(numPoints * 3); const colors = new Float32Array(numPoints * 3); const covariances = new Float32Array(numPoints * 6); const opacities = new Float32Array(numPoints); const shCoeffs = new Float32Array(numPoints * 45); for (let i = 0; i < numPoints; i++) { const offset = i * 58; positions.set([f32[offset], f32[offset+1], f32[offset+2]], i*3); colors.set([f32[offset+3], f32[offset+4], f32[offset+5]], i*3); covariances.set(f32.slice(offset+6, offset+12), i*6); opacities[i] = f32[offset+12]; shCoeffs.set(f32.slice(offset+13, offset+58), i*45); } // 构建 BufferGeometry const geometry = new THREE.BufferGeometry(); geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3)); geometry.setAttribute('covariance', new THREE.BufferAttribute(covariances, 6)); geometry.setAttribute('opacity', new THREE.BufferAttribute(opacities, 1)); geometry.setAttribute('shCoeff', new THREE.BufferAttribute(shCoeffs, 45)); });注意:
shCoeff字段极大(45 维),若显存不足可降阶使用——实测仅保留l=0(1 个 DC 项)+l=1(3 个线性项)共 12 维,仍能保持基础光照响应;l=2(5 个二次项)用于增强镜面反射细节,非必需。
3. WebGL 着色器实现:用 Three.js ShaderMaterial 复现 3D-Gaussian-Splatting 渲染管线
Three.js 的ShaderMaterial是实现 3DGS 的核心载体。它绕过内置光照模型,直接在 fragment shader 中完成高斯椭球的屏幕空间投影、协方差变换、alpha 混合与球谐着色。整个着色器分为三阶段:顶点着色器做世界→裁剪变换、几何着色器(禁用)改用片元着色器内插、片元着色器执行 splatting 核心逻辑。
3.1 顶点着色器:传递必要世界空间信息
标准PointsMaterial的顶点着色器仅输出gl_Position,但 3DGS 需要在片元阶段获取点的世界坐标、相机方向、投影矩阵逆等。因此必须重写顶点着色器,将关键变量传入片元:
// vertex.glsl uniform mat4 modelViewMatrix; uniform mat4 projectionMatrix; uniform mat4 inverseProjectionMatrix; uniform mat4 inverseModelViewMatrix; attribute vec3 position; attribute vec3 color; attribute vec6 covariance; attribute float opacity; attribute vec45 shCoeff; varying vec3 vWorldPosition; varying vec3 vColor; varying vec6 vCovariance; varying float vOpacity; varying vec45 vShCoeff; void main() { vec4 worldPos = modelMatrix * vec4(position, 1.0); vWorldPosition = worldPos.xyz; vColor = color; vCovariance = covariance; vOpacity = opacity; vShCoeff = shCoeff; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }逻辑说明:
modelMatrix用于将点坐标转世界空间,供后续计算视角方向;inverseProjectionMatrix在片元中用于反向投影,是计算屏幕空间椭圆尺寸的关键。
3.2 片元着色器:实现 splatting 的五步核心计算
片元着色器是性能瓶颈所在,必须严格控制分支与纹理采样。以下是精简后的核心流程(省略球谐计算,聚焦 splatting 主干):
// fragment.glsl uniform vec3 cameraPosition; uniform mat4 viewMatrix; uniform mat4 projectionMatrix; uniform mat4 inverseProjectionMatrix; uniform mat4 inverseViewMatrix; varying vec3 vWorldPosition; varying vec3 vColor; varying vec6 vCovariance; varying float vOpacity; varying vec45 vShCoeff; vec3 evaluateSH(vec3 dir, vec45 sh) { // 简化版:仅用 l=0,1 阶(共 12 维),完整版见 GitHub 源码 float c0 = 0.282095; // √(1/4π) float c1 = 0.488603; // √(3/4π) vec3 rgb = c0 * sh.rgb; rgb += c1 * (sh.a * dir.x + sh.g * dir.y + sh.b * dir.z); return rgb; } void main() { // Step 1: 计算当前片元在世界空间中的位置(通过反向投影) vec4 clipSpace = inverseProjectionMatrix * vec4(gl_FragCoord.xy / vec2(1920.0, 1080.0) * 2.0 - 1.0, 0.0, 1.0); vec3 rayDir = normalize((inverseViewMatrix * vec4(clipSpace.xyz, 0.0)).xyz); // Step 2: 计算点到视线的向量 vec3 toPoint = vWorldPosition - cameraPosition; float depth = length(toPoint); // Step 3: 将协方差矩阵从世界空间转到屏幕空间(关键!) mat3 J = mat3( dFdx(vWorldPosition), dFdy(vWorldPosition), rayDir ); mat3 cov3D = mat3( vCovariance.x, vCovariance.d, vCovariance.e, vCovariance.d, vCovariance.y, vCovariance.f, vCovariance.e, vCovariance.f, vCovariance.z ); mat2 cov2D = J.xy * cov3D * transpose(J.xy); // 2×2 协方差 // Step 4: 计算高斯权重(二维正态分布概率密度) vec2 uv = (gl_FragCoord.xy - vUv.xy) / 1.0; // 屏幕坐标相对偏移(简化) float denom = 2.0 * (cov2D[0][0] * cov2D[1][1] - cov2D[0][1] * cov2D[1][0]); float expArg = -0.5 * (uv.x * uv.x * cov2D[1][1] - 2.0 * uv.x * uv.y * cov2D[0][1] + uv.y * uv.y * cov2D[0][0]) / denom; float weight = vOpacity * exp(expArg) / (3.1415926 * sqrt(denom)); // Step 5: 球谐着色 + alpha 混合 vec3 shColor = evaluateSH(normalize(toPoint), vShCoeff); vec3 finalColor = mix(vec3(0.0), vColor * shColor, weight); gl_FragColor = vec4(finalColor, weight); }参数说明:
denom是协方差矩阵行列式,决定椭圆面积;expArg控制高斯衰减速度;weight是最终 alpha 值,直接参与混合。实际项目中uv应通过dFdx/dFdy精确计算像素覆盖范围,此处为教学简化。
3.3 性能调优:Three.js 中控制 splatting 粒子数量与 LOD
10 万粒子在低端显卡上易掉帧。必须引入 LOD(Level of Detail)机制:根据距离动态开关粒子、降低协方差精度、跳过远点球谐计算。
// 动态 LOD 控制 const distance = camera.position.distanceTo(point.position); if (distance > 5.0) { // 远距离:只传 position + color + opacity,协方差设为单位阵,shCoeff 全零 point.userData.lod = 0; } else if (distance > 2.0) { // 中距离:传完整 covariance,shCoeff 截断至 12 维 point.userData.lod = 1; } else { // 近距离:全精度 point.userData.lod = 2; }提示:Three.js 不支持运行时切换
ShaderMaterial的 uniform 数量,因此需预编译多套着色器(lod0.glsl,lod1.glsl,lod2.glsl),并通过material.onBeforeCompile注入不同版本。
4. 项目源码结构与流程教程:从 COLMAP 到 Three.js 可视化的一站式实践路径
本项目采用“前后端分离”架构:Python 脚本完成重建与参数生成,Three.js 前端负责渲染。所有代码开源,目录结构清晰,适配 Windows/macOS/Linux,无需 Docker 或 Conda。
4.1 源码仓库组织(GitHub 风格)
3dgs-threejs/ ├── backend/ # 预处理脚本 │ ├── colmap_to_ply.py # COLMAP sparse 模型转 .ply │ ├── ply_to_gs.py # .ply → .bin(含协方差、SH 系数) │ └── requirements.txt ├── frontend/ # Three.js 可视化 │ ├── src/ │ │ ├── main.js # 场景初始化、相机控制、材质加载 │ │ ├── shaders/ # vertex.glsl + fragment.glsl(含 LOD 版本) │ │ └── utils/ # 相机轨道、UI 控制条、性能监控 │ ├── public/ │ │ ├── models/ # 生成的 gs_params.bin、camera.json │ │ └── images/ # 示例输入图(coffee_cup/) │ └── index.html ├── docs/ # 流程教程 Markdown │ ├── 01-colmap-setup.md # COLMAP 安装与特征匹配 │ ├── 02-ply-generation.md # 稠密重建与法向量估计 │ └── 03-threejs-deploy.md # 本地启动与参数调试 └── README.md4.2 五分钟跑通流程(Windows/macOS/Linux 通用)
步骤 1:安装 COLMAP(二进制版)
- Windows:下载
COLMAP-3.8-windows-cpu.zip,解压后将COLMAP.bat所在目录加入 PATH - macOS:
brew install colmap - Linux:
sudo apt install colmap
步骤 2:准备输入图像
将 15–30 张环绕拍摄的 JPG 图片放入images/coffee_cup/,确保有重叠(建议 60% 以上)。
步骤 3:运行重建流水线
cd backend python colmap_to_ply.py --image_dir ../frontend/public/images/coffee_cup \ --output_dir ../frontend/public/models/coffee_cup # 输出:coffee_cup/points3D.ply + coffee_cup/cameras.json python ply_to_gs.py --ply_path ../frontend/public/models/coffee_cup/points3D.ply \ --cameras_json ../frontend/public/models/coffee_cup/cameras.json \ --output_bin ../frontend/public/models/coffee_cup/gs_params.bin步骤 4:启动 Three.js 服务
cd frontend npm install npm run dev # 启动 Vite 开发服务器,访问 http://localhost:5173验证成功标志:页面加载后出现可拖拽旋转的咖啡杯模型,右上角显示 FPS ≥ 45(RTX 3060 及以上显卡),按
P键切换点云/高斯渲染模式,按L键查看 LOD 切换日志。
4.3 关键参数调试表:影响视觉质量的 5 个核心变量
| 参数名 | 位置 | 默认值 | 调整效果 | 推荐范围 |
|---|---|---|---|---|
max_splat_size | ply_to_gs.py | 0.03 | 控制最大高斯半径,过大导致糊成一团 | 0.005–0.05 |
sh_order | ply_to_gs.py | 2 | 球谐阶数,越高越精细但显存翻倍 | 0,1,2(不建议 3) |
opacity_threshold | fragment.glsl | 0.01 | 片元 alpha 低于此值则丢弃,提升性能 | 0.005–0.02 |
lod_distance | main.js | [2.0, 5.0] | LOD 切换距离阈值,需匹配场景尺寸 | 按模型 bbox 对角线长度 × 0.3 / 0.6 |
render_scale | main.js | 1.0 | 渲染分辨率缩放(0.5=半高清),平衡帧率与画质 | 0.5–1.5 |
5. 进阶技巧:在 Three.js 中实现 3D-Gaussian-Splatting 的实时编辑与多视角融合
3DGS 的真正价值不止于静态展示,而在于支持交互式编辑与增量重建。Three.js 提供了足够灵活的 API 实现这两类进阶能力,无需修改 WebGL 底层,仅靠 JavaScript 层逻辑即可达成。
5.1 实时编辑:拖拽调整单个高斯的位置与透明度
利用Raycaster拾取点击的粒子,再通过BufferAttribute直接修改其position与opacity属性:
const raycaster = new THREE.Raycaster(); const mouse = new THREE.Vector2(); function onDocumentMouseDown(event) { mouse.x = (event.clientX / window.innerWidth) * 2 - 1; mouse.y = -(event.clientY / window.innerHeight) * 2 + 1; raycaster.setFromCamera(mouse, camera); const intersects = raycaster.intersectObject(points); if (intersects.length > 0) { const index = Math.floor(intersects[0].index); const posAttr = points.geometry.attributes.position; const opaAttr = points.geometry.attributes.opacity; // 修改位置(示例:沿 z 轴移动 0.1) posAttr.setXYZ(index, posAttr.getX(index), posAttr.getY(index), posAttr.getZ(index) + 0.1 ); // 修改透明度 opaAttr.setX(index, Math.min(0.99, opaAttr.getX(index) + 0.1)); posAttr.needsUpdate = true; opaAttr.needsUpdate = true; } }注意:
needsUpdate = true必须显式设置,否则 GPU 缓冲区不会刷新。若批量编辑,应先收集所有索引,再统一调用setAttribute()提升性能。
5.2 多视角融合:合并多个 3DGS 模型为统一场景
当扫描大物体(如房间)需分区域重建时,会产生多个gs_params.bin。Three.js 可通过mergeBufferGeometries合并,但需统一坐标系:
// 加载第二个模型并对其应用刚体变换 const loader = new THREE.FileLoader(); loader.load('models/room_corner2.bin', (data) => { const geo2 = parseGsBin(data); const matrix = new THREE.Matrix4().makeRotationY(Math.PI / 2) .multiply(new THREE.Matrix4().makeTranslation(2.0, 0, 0)); geo2.applyMatrix4(matrix); // 关键:将第二区域对齐到第一区域坐标系 // 合并几何体 const merged = THREE.BufferGeometryUtils.mergeBufferGeometries([geo1, geo2]); points.geometry = merged; });5.3 性能监控:用 Stats.js + 自定义指标定位瓶颈
单纯看 FPS 不足以诊断问题。需监控三项关键指标:
| 指标 | 获取方式 | 健康阈值 | 优化方向 |
|---|---|---|---|
GPU Memory Used | renderer.info.memory.programs | < 80% | 减少shCoeff维度、启用 LOD |
Draw Calls | renderer.info.render.calls | < 100 | 合并BufferGeometry,避免 per-point material |
Splat Count Rendered | 自定义计数器 | < 50k(移动端)/< 150k(桌面端) | 动态剔除屏幕外粒子 |
// 在渲染循环中注入统计 function animate() { requestAnimationFrame(animate); // 统计当前可视粒子数 const visibleCount = points.geometry.attributes.position.count; document.getElementById('splat-count').textContent = `Splat: ${visibleCount.toLocaleString()}`; renderer.render(scene, camera); stats.update(); }技巧:Three.js 的
Frustum类可手动执行视锥剔除——遍历所有粒子,用frustum.containsPoint()判断是否在视锥内,再setDrawRange()限制渲染范围。实测在 20 万粒子场景中,可将Draw Calls从 200+ 降至 30 以内。
本文还有配套的精品资源,点击获取