使用 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>
相关推荐
打小就很皮...3 小时前
《在 React/Vue 项目中引入 Supademo 实现交互式新手指引》
前端·supademo·新手指引
C澒3 小时前
系统初始化成功率下降排查实践
前端·安全·运维开发
C澒3 小时前
面单打印服务的监控检查事项
前端·后端·安全·运维开发·交通物流
pas1363 小时前
39-mini-vue 实现解析 text 功能
前端·javascript·vue.js
CoLiuRs4 小时前
Image-to-3D — 让 2D 图片跃然立体*
python·3d·flask
Swift社区4 小时前
Flutter 路由系统,对比 RN / Web / iOS 有什么本质不同?
前端·flutter·ios
雾眠气泡水@4 小时前
前端:解决同一张图片由于页面大小不统一导致图片模糊
前端
开发者小天4 小时前
python中计算平均值
开发语言·前端·python
我谈山美,我说你媚4 小时前
qiankun微前端 若依vue2主应用与vue2主应用
前端