文章目录
一、效果

二、简介
2.1、本文介绍
高斯泼溅(Gaussian Splatting)是当前 3D 可视化领域的热门技术,凭借其超高的渲染精度和视觉表现力,成为 3D 重建、数字孪生等场景的优选方案。本文将基于 Vue3 + @mkkellogg/gaussian-splats-3d 库实现 3D 高斯泼溅效果的渲染,并整合交互、相机控制、自适应布局等核心能力,延续 Vue3+PlayCanvas 系列的开发风格,完成高斯泼溅模型的可视化落地。
2.2、什么是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
三、技术选型与核心依赖
- 核心依赖
Vue3 (Setup 语法糖):作为前端框架,负责组件化开发、生命周期管理、Props 传参;
@mkkellogg/gaussian-splats-3d:专门用于解析和渲染高斯泼溅模型(.ply 格式)的高性能库,内置 WebGL 渲染管线和相机控制;
ResizeObserver:监听容器尺寸变化,保证 Canvas 自适应渲染;
自定义交互封装:实现高斯泼溅模型的 hover/select 事件监听,输出空间坐标等交互信息。 - 前置准备
确保项目已安装依赖:
bash
npm install @mkkellogg/gaussian-splats-3d --save
四、核心组件实现(PlyCanvasViewer.vue)
4.1、组件结构设计
组件分为 3 个核心区域:
- 渲染容器:承载高斯泼溅模型的 Canvas 画布;
- 状态 Overlay:展示加载状态、错误信息、调试日志;
- 交互 Overlay:展示鼠标悬停 / 选中的 3D 空间坐标信息。
4.2、核心功能解析
- 高斯泼溅 Viewer 初始化
通过GaussianSplats3D.Viewer创建渲染实例,核心配置项说明:
rootElement:渲染容器 DOM 节点;
selfDrivenMode:自驱动渲染模式(自动维护渲染循环);
cameraUp:相机向上方向(设置为[0,0,1]适配高斯泼溅模型坐标系);
useBuiltInControls:是否启用内置相机控制(通过lockCameraProps 控制);
initialCameraPosition/initialCameraLookAt:相机初始位置和看向目标。
- 相机控制优化
默认情况下,高斯泼溅 Viewer 的相机旋转存在角度限制(垂直方向 ±85°),通过修改控制器参数解除限制:
js
// 解除垂直旋转角度限制
viewer.controls.maxPolarAngle = Math.PI
viewer.controls.minPolarAngle = 0
// 解除水平旋转角度限制
viewer.controls.maxAzimuthAngle = Infinity
viewer.controls.minAzimuthAngle = -Infinity
// 调整旋转速度
viewer.controls.rotateSpeed = 1.2
- 模型加载兼容处理
适配不同版本的@mkkellogg/gaussian-splats-3d API,提供 3 种加载方式:
- addSplatScene:基础加载方式;
- downloadAndBuildSingleSplatSceneProgressiveLoad:渐进式加载(适合大模型);
- downloadAndBuildSingleSplatSceneStandardLoad:标准加载。
- 交互能力整合
通过setupSplatInteractions封装高斯泼溅的 hover/select 交互:
- onHover:鼠标悬停时输出 3D 空间坐标;
- onSelect:鼠标点击时输出坐标 + 距离信息;
- 交互信息通过 Overlay 展示在画布底部,不遮挡渲染内容。
- 自适应渲染
使用ResizeObserver监听容器尺寸变化,触发forceRenderNextFrame保证 Canvas 自适应:
js
resizeObserver = new ResizeObserver(() => {
try {
viewer?.forceRenderNextFrame?.()
} catch { /* ignore */ }
})
resizeObserver.observe(containerRef.value)
- 资源销毁
在组件卸载前,通过disposeViewer方法释放所有资源:
- 断开ResizeObserver监听;
- 销毁交互实例;
- 停止渲染循环并销毁 Viewer 实例,避免内存泄漏。
五、核心代码
PlyCanvasViewer.vue
html
<template>
<div style="width:100%;height:100vh;background:#000">
<div ref="containerRef" class="viewer">
<!-- 加载/错误/调试信息 overlay -->
<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>
<!-- 悬停/选中信息 overlay -->
<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 id="container"></div>
</div>
</div>
</template>
<script setup>
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import * as GaussianSplats3D from '@mkkellogg/gaussian-splats-3d'
import { setupSplatInteractions } from '../comment/splatInteraction'
// 定义 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)
const modelUrl = '/ply/splat.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,
})
// ========== 解除控制器角度限制 ==========
if (viewer.controls && !props.lockCamera) {
// 解除垂直旋转角度限制(默认是{ min: -85, max: 85 })
viewer.controls.maxPolarAngle = Math.PI // 允许旋转到任意垂直角度
viewer.controls.minPolarAngle = 0 // 可选:如果需要完全无限制,设为 0 或 -Math.PI
// 解除水平旋转角度限制(默认无限制,如需确认可显式设置)
viewer.controls.maxAzimuthAngle = Infinity
viewer.controls.minAzimuthAngle = -Infinity
// 可选:调整旋转速度(如果需要)
viewer.controls.rotateSpeed = 1.2 // 数值越大旋转越快
}
// ===========================================
resizeObserver = new ResizeObserver(() => {
try {
viewer?.forceRenderNextFrame?.()
} catch {
// ignore
}
})
resizeObserver.observe(containerRef.value)
debugText.value = `renderMode=${String(viewer?.renderMode ?? '')}\nLoading: ${modelUrl}`
if (viewer?.addSplatScene) {
await viewer.addSplatScene(modelUrl)
loadedCount.value = 1
} else if (props.progressive && viewer?.downloadAndBuildSingleSplatSceneProgressiveLoad) {
await viewer.downloadAndBuildSingleSplatSceneProgressiveLoad(modelUrl)
loadedCount.value = 1
} else if (viewer?.downloadAndBuildSingleSplatSceneStandardLoad) {
await viewer.downloadAndBuildSingleSplatSceneStandardLoad(modelUrl)
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,
async (next, prev) => {
if (String(next || '') === String(prev || '')) return
disposeViewer()
await initViewerAndLoad()
}
)
onBeforeUnmount(() => {
disposeViewer()
})
</script>
<style scoped>
.viewer {
position: relative;
width: 100%;
height: 100%;
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>