【Web】使用Vue3+PlayCanvas开发3D游戏(十一)渲染3D高斯泼溅效果

文章目录

一、效果

二、简介

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

三、技术选型与核心依赖

  1. 核心依赖
    Vue3 (Setup 语法糖):作为前端框架,负责组件化开发、生命周期管理、Props 传参;
    @mkkellogg/gaussian-splats-3d:专门用于解析和渲染高斯泼溅模型(.ply 格式)的高性能库,内置 WebGL 渲染管线和相机控制;
    ResizeObserver:监听容器尺寸变化,保证 Canvas 自适应渲染;
    自定义交互封装:实现高斯泼溅模型的 hover/select 事件监听,输出空间坐标等交互信息。
  2. 前置准备
    确保项目已安装依赖:
bash 复制代码
npm install @mkkellogg/gaussian-splats-3d --save

四、核心组件实现(PlyCanvasViewer.vue)

4.1、组件结构设计

组件分为 3 个核心区域:

  • 渲染容器:承载高斯泼溅模型的 Canvas 画布;
  • 状态 Overlay:展示加载状态、错误信息、调试日志;
  • 交互 Overlay:展示鼠标悬停 / 选中的 3D 空间坐标信息。

4.2、核心功能解析

  1. 高斯泼溅 Viewer 初始化
    通过GaussianSplats3D.Viewer创建渲染实例,核心配置项说明:

rootElement:渲染容器 DOM 节点;

selfDrivenMode:自驱动渲染模式(自动维护渲染循环);

cameraUp:相机向上方向(设置为[0,0,1]适配高斯泼溅模型坐标系);

useBuiltInControls:是否启用内置相机控制(通过lockCameraProps 控制);

initialCameraPosition/initialCameraLookAt:相机初始位置和看向目标。

  1. 相机控制优化
    默认情况下,高斯泼溅 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
  1. 模型加载兼容处理
    适配不同版本的@mkkellogg/gaussian-splats-3d API,提供 3 种加载方式:
  • addSplatScene:基础加载方式;
  • downloadAndBuildSingleSplatSceneProgressiveLoad:渐进式加载(适合大模型);
  • downloadAndBuildSingleSplatSceneStandardLoad:标准加载。
  1. 交互能力整合
    通过setupSplatInteractions封装高斯泼溅的 hover/select 交互:
  • onHover:鼠标悬停时输出 3D 空间坐标;
  • onSelect:鼠标点击时输出坐标 + 距离信息;
  • 交互信息通过 Overlay 展示在画布底部,不遮挡渲染内容。
  1. 自适应渲染
    使用ResizeObserver监听容器尺寸变化,触发forceRenderNextFrame保证 Canvas 自适应:
js 复制代码
resizeObserver = new ResizeObserver(() => {
  try {
    viewer?.forceRenderNextFrame?.()
  } catch { /* ignore */ }
})
resizeObserver.observe(containerRef.value)
  1. 资源销毁
    在组件卸载前,通过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>
相关推荐
cool32002 小时前
4D实验八:Dubbo微服务 + 注册中心
前端·kubernetes
军军君012 小时前
数字孪生监控大屏实战模板:商圈大数据监控
前端·javascript·vue.js·typescript·前端框架·echarts·three
wanhengidc2 小时前
服务器 数据科技发展
运维·服务器·爬虫·科技·游戏·智能手机
Clank的游戏栈2 小时前
AI实战:如何更好的使用AI开发游戏项目
人工智能·游戏
方安乐2 小时前
try catch vs 异步捕获
前端·javascript·vue.js
chenbin___2 小时前
鸿蒙RN position: ‘absolute‘ 和 zIndex 的兼容性问题(转自千问)
前端·javascript·react native·harmonyos
晴天丨2 小时前
Vue 3项目架构设计:从2200行单文件到24个组件
前端·vue.js
blanks20202 小时前
为 Zed 编辑器 添加 flutter dart snippets
前端·flutter
慧一居士2 小时前
Vue中的 h 作用和使用方法介绍
前端·vue.js