使用 GaussianSplats3D 在 Vue 3 中构建交互式 3D 高斯点云查看器

在计算机图形学和三维重建领域,高斯点云(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 坐标

四、扩展建议

  1. 性能优化
    • 对超大点云(>10M 点)启用 progressive: true
    • 添加加载进度条(当前仅显示"1/1",可扩展为字节进度)
  2. 交互增强
    • 支持框选、测量距离、导出选中点
    • 集成 Three.js 后处理(如 SSAO、Bloom)
  3. 多模型支持
    • 扩展 addSplatScene 支持同时加载多个 .ply 文件
  4. 移动端适配
    • 检测触摸事件,优化 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>
相关推荐
ConardLi1 天前
开源我的 GPT-Image2 生图 Skill,附大量玩法指南
前端·人工智能·后端
我是Superman丶1 天前
Antigravity Retry 自动重试脚本
前端·javascript·vue.js
是大强1 天前
nvm安装node成功npm失败
前端·npm·node.js
scott1985121 天前
3DGUT与3DGRT
3d
\xin1 天前
pikachu自编CSRF(GET),CSRF(POST),CSRF(token)
前端·csrf
是大强1 天前
前端一个项目用node20 一个项目用node14 怎么切换
前端
不老刘1 天前
Git Cherry-Pick:微前端架构下的“精准医疗”与最佳实践
前端·git
LIO1 天前
ESLint 极简指南:让代码既规范又一致
前端·eslint
明月_清风1 天前
前端工程化七连问:从紧急修复到版本控制,一文打通工程化任督二脉
前端·前端工程化
账号已注销free1 天前
Vue3 defineProps使用指南
vue.js