在计算机图形学和三维重建领域,高斯点云(Gaussian Splatting) 正迅速成为一种高效、高质量的渲染技术。它通过将场景表示为带有协方差信息的 3D 高斯分布集合,实现了逼真的实时渲染效果。本文将带你深入了解如何使用开源库 @mkkellogg/gaussian-splats-3d,在 Vue 3 项目中构建一个功能完整的交互式高斯点云查看器。
一、什么是 GaussianSplats3D?
GaussianSplats3D 是由开发者 Matthew Kellogg 开发的一个轻量级 JavaScript 库,专为在 Web 浏览器中渲染 3D Gaussian Splatting 场景 而设计。它基于 WebGL 构建,支持 .ply 格式的高斯点云数据(通常由 3D Gaussian Splatting 论文 的官方实现导出),并提供了:
- 实时渲染
- 相机控制(OrbitControls)
- 渐进式加载(Progressive Loading)
- 点选与悬停交互
- 响应式布局支持
该库封装了底层复杂的着色器逻辑和数据解析流程,让前端开发者可以像使用普通 3D 查看器一样轻松集成高斯点云。
GitHub 仓库:https://github.com/mkkellogg/gaussian-splats-3d
二、如何在 Vue 3 项目中引入 GaussianSplats3D?
1. 安装依赖
npm install @mkkellogg/gaussian-splats-3d
注意:该库依赖 WebGL 2 和现代浏览器环境,不支持 IE。
2. 准备高斯点云数据
你需要一个符合格式的 .ply或者.splat 文件(通常包含 x, y, z, scale_x, scale_y, scale_z, opacity, rot_0~3, f_dc_0~2 等属性)。可从 官方 Demo 数据 下载测试。
将 .ply 文件放在本地目录下尝试,如 public/ply/ 目录下( public/ply/point_cloud.ply),以便通过 /ply/xxx.ply 路径直接访问。
三、构建一个 Vue 3 高斯点云组件
1. 导入依赖与定义 Props
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import * as GaussianSplats3D from '@mkkellogg/gaussian-splats-3d'
import { setupSplatInteractions } from '../comment/splatInteraction'
-
使用 Vue 3 Composition API。
-
useRoute用于从 URL 路径获取模型文件名(如/viewer/bunny.ply)。 -
setupSplatInteractions是自定义的交互模块(后文会说明)。const props = defineProps({
url: String, // 可直接传入模型 URL
lockCamera: Boolean, // 是否锁定相机(禁用旋转缩放)
cameraPosition: Array,// 初始相机位置
cameraLookAt: Array, // 相机注视点
progressive: Boolean // 是否启用渐进式加载
})
2. 动态确定模型 URL
const modelUrl = computed(() => {
// 优先使用 props.url
// 其次使用路由参数 route.params.filename
// 默认 fallback 到 /ply/point_cloud.ply
})
- 组件调用 :
<GaussianViewer :url="'/ply/dragon.ply'" /> - 路由驱动 :访问
/viewer/dragon.ply自动加载
3. 初始化 Viewer 并加载模型
核心函数 initViewerAndLoad() 完成以下工作:
✅ 创建 Viewer 实例
viewer = new GaussianSplats3D.Viewer({
rootElement: containerRef.value,
selfDrivenMode: true, // 自动渲染循环
useBuiltInControls: !props.lockCamera, // 是否启用轨道控制器
initialCameraPosition: props.cameraPosition,
initialCameraLookAt: props.cameraLookAt,
})
✅ 响应容器尺寸变化
resizeObserver = new ResizeObserver(() => viewer?.forceRenderNextFrame())
确保窗口缩放时画面正确重绘。
✅ 加载点云数据(三种策略)
js
编辑
if (viewer.addSplatScene) {
await viewer.addSplatScene(modelUrl.value)
} else if (props.progressive && viewer.downloadAndBuildSingleSplatSceneProgressiveLoad) {
await viewer.downloadAndBuildSingleSplatSceneProgressiveLoad(modelUrl.value)
} else {
await viewer.downloadAndBuildSingleSplatSceneStandardLoad(modelUrl.value)
}
addSplatScene:适用于已预加载的场景(较少用)- 渐进式加载:边下载边渲染,用户体验更流畅
- 标准加载:完整下载后再渲染
推荐开启
progressive: true提升大模型加载体验。
✅ 设置交互行为
interactionHandle = setupSplatInteractions(viewer, {
onHover: (hit) => { /* 显示悬停坐标 */ },
onSelect: (hit) => { /* 显示点击坐标和距离 */ }
})
setupSplatInteractions 是一个封装了射线检测(raycasting)的工具函数,监听鼠标事件并返回命中的高斯点信息。
✅ 锁定相机(如果需要)
if (props.lockCamera) {
cam.position.set(...props.cameraPosition)
cam.lookAt(...props.cameraLookAt)
viewer.controls.enabled = false
}
适用于展示固定视角的场景(如产品展示)。
4. 生命周期管理
-
onMounted:初始化 Viewer -
watch(modelUrl):当 URL 变化时,销毁旧实例并加载新模型 -
onBeforeUnmount:清理资源(防止内存泄漏)function disposeViewer() {
resizeObserver?.disconnect()
interactionHandle?.dispose()
viewer?.stop()
viewer?.dispose()
}
这是 WebGL 应用的关键:必须手动释放 GPU 资源。
5. UI 层:状态提示与调试信息
模板部分使用两个 overlay 展示:
- 顶部:加载状态、错误信息、调试日志(如 renderMode)
- 底部:鼠标悬停/点击的 3D 坐标
四、扩展建议
- 性能优化 :
- 对超大点云(>10M 点)启用
progressive: true - 添加加载进度条(当前仅显示"1/1",可扩展为字节进度)
- 对超大点云(>10M 点)启用
- 交互增强 :
- 支持框选、测量距离、导出选中点
- 集成 Three.js 后处理(如 SSAO、Bloom)
- 多模型支持 :
- 扩展
addSplatScene支持同时加载多个.ply文件
- 扩展
- 移动端适配 :
- 检测触摸事件,优化 OrbitControls 的 touch 行为
五、总结
通过 GaussianSplats3D + Vue 3,我们仅用不到 200 行代码就实现了一个功能完整的高斯点云查看器。它具备:
- 灵活的模型加载方式(props 或路由)
- 响应式布局
- 交互反馈(悬停/点击)
- 相机控制开关
- 错误处理与调试支持
这项技术非常适合用于 数字孪生、文物数字化、自动驾驶感知可视化 等前沿领域。随着 Gaussian Splatting 生态的成熟,相信未来会有更多 Web 端应用涌现。
javascript
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import * as GaussianSplats3D from '@mkkellogg/gaussian-splats-3d'
import { setupSplatInteractions } from '../comment/splatInteraction'
// 获取路由参数
const route = useRoute()
// 定义 props,允许通过 props 或路由参数传入 URL
const props = defineProps({
url: {
type: String,
default: '', // 默认为空,将从路由参数获取
},
lockCamera: {
type: Boolean,
default: false,
},
cameraPosition: {
type: Array,
default: () => [0, -10, 5],
},
cameraLookAt: {
type: Array,
default: () => [0, 0, 0],
},
progressive: {
type: Boolean,
default: true,
},
})
const containerRef = ref(null)
const isLoading = ref(false)
const errorText = ref('')
const debugText = ref('')
const hoverText = ref('')
const selectText = ref('')
const loadedCount = ref(0)
// 计算属性:确定要加载的 URL
const modelUrl = computed(() => {
const fromProp = String(props.url || '').trim()
if (fromProp) {
if (fromProp.startsWith('http://') || fromProp.startsWith('https://') || fromProp.startsWith('/')) return fromProp
return `/ply/${fromProp.replace(/^\/+/, '')}`
}
const filename = String(route?.params?.filename || '').trim()
if (filename) {
if (filename.startsWith('http://') || filename.startsWith('https://') || filename.startsWith('/')) return filename
return `/ply/${filename.replace(/^\/+/, '')}`
}
return '/ply/point_cloud.ply'
})
let viewer
let resizeObserver
let interactionHandle
function disposeViewer() {
try {
resizeObserver?.disconnect?.()
} catch {
// ignore
}
try {
interactionHandle?.dispose?.()
} catch {
// ignore
}
try {
viewer?.stop?.()
} catch {
// ignore
}
try {
viewer?.dispose?.()
} catch {
// ignore
}
viewer = undefined
resizeObserver = undefined
interactionHandle = undefined
}
async function initViewerAndLoad() {
errorText.value = ''
isLoading.value = true
loadedCount.value = 0
if (!containerRef.value) return
try {
viewer = new GaussianSplats3D.Viewer({
rootElement: containerRef.value,
selfDrivenMode: true,
cameraUp: [0, 0, 1],
useBuiltInControls: !props.lockCamera,
initialCameraPosition: props.cameraPosition,
initialCameraLookAt: props.cameraLookAt,
})
resizeObserver = new ResizeObserver(() => {
try {
viewer?.forceRenderNextFrame?.()
} catch {
// ignore
}
})
resizeObserver.observe(containerRef.value)
debugText.value = `renderMode=${String(viewer?.renderMode ?? '')}\nLoading: ${modelUrl.value}`
if (viewer?.addSplatScene) {
await viewer.addSplatScene(modelUrl.value)
loadedCount.value = 1
} else if (props.progressive && viewer?.downloadAndBuildSingleSplatSceneProgressiveLoad) {
await viewer.downloadAndBuildSingleSplatSceneProgressiveLoad(modelUrl.value)
loadedCount.value = 1
} else if (viewer?.downloadAndBuildSingleSplatSceneStandardLoad) {
await viewer.downloadAndBuildSingleSplatSceneStandardLoad(modelUrl.value)
loadedCount.value = 1
} else {
throw new Error('GaussianSplats3D Viewer API not compatible: cannot find load method')
}
applyFullSizeCanvas()
applyLockedCamera()
interactionHandle = setupSplatInteractions(viewer, {
onHover: (hit) => {
if (!hit?.point) {
hoverText.value = ''
return
}
const p = hit.point
hoverText.value = `悬停: [${p.x.toFixed(3)}, ${p.y.toFixed(3)}, ${p.z.toFixed(3)}]`
},
onSelect: (hit) => {
if (!hit?.point) {
selectText.value = '选中: (无)'
return
}
const p = hit.point
selectText.value =
`选中: [${p.x.toFixed(3)}, ${p.y.toFixed(3)}, ${p.z.toFixed(3)}]` +
(hit.distance != null ? ` 距离=${Number(hit.distance).toFixed(3)}` : '')
},
onError: (err) => {
console.warn('高斯交互错误:', err)
},
debug: true,
})
viewer?.start?.()
} catch (e) {
console.error(e)
errorText.value = e?.message ? String(e.message) : String(e)
} finally {
isLoading.value = false
}
}
function applyFullSizeCanvas() {
const canvas = containerRef.value?.querySelector('canvas')
if (!canvas) return
canvas.style.width = '100%'
canvas.style.height = '100%'
canvas.style.display = 'block'
}
function applyLockedCamera() {
if (!props.lockCamera) return
const cam = viewer?.camera
if (!cam) return
const p = Array.isArray(props.cameraPosition) ? props.cameraPosition : [0, -10, 5]
const t = Array.isArray(props.cameraLookAt) ? props.cameraLookAt : [0, 0, 0]
cam.position.set(Number(p[0] ?? 0), Number(p[1] ?? -10), Number(p[2] ?? 5))
cam.lookAt(Number(t[0] ?? 0), Number(t[1] ?? 0), Number(t[2] ?? 0))
cam.updateMatrixWorld?.()
// 禁用交互,让相机保持固定
if (viewer?.controls) viewer.controls.enabled = false
}
onMounted(async () => {
await initViewerAndLoad()
})
watch(
() => modelUrl.value,
async (next, prev) => {
if (String(next || '') === String(prev || '')) return
disposeViewer()
await initViewerAndLoad()
}
)
onBeforeUnmount(() => {
disposeViewer()
})
</script>
<template>
<div ref="containerRef" class="viewer">
<div v-if="isLoading || errorText || debugText" class="overlay">
<div v-if="isLoading" class="overlayLine">加载中: {{ loadedCount }} / 1</div>
<div v-if="errorText" class="overlayLine">{{ errorText }}</div>
<div v-if="debugText" class="overlayLine debug">{{ debugText }}</div>
</div>
<div v-if="hoverText || selectText" class="overlay overlay2">
<div v-if="hoverText" class="overlayLine">{{ hoverText }}</div>
<div v-if="selectText" class="overlayLine">{{ selectText }}</div>
</div>
</div>
</template>
<style scoped>
.viewer {
position: relative;
width: 100%;
height: 100vh;
overflow: hidden;
background: #0b0f19;
}
.overlay {
position: absolute;
top: 12px;
left: 12px;
z-index: 10;
padding: 8px 10px;
background: rgba(0, 0, 0, 0.55);
color: #fff;
font-size: 12px;
border-radius: 6px;
pointer-events: none;
}
.overlayLine {
line-height: 1.4;
}
.overlay2 {
top: auto;
bottom: 12px;
}
.debug {
white-space: pre-wrap;
max-width: 70vw;
}
</style>
