nVisual高斯泼溅场景:Three.js + HT 双引擎融合实践
2026/8/1 10:37:03 网站建设 项目流程

背景

数据中心可视化平台通常需要多种场景模式——2D 拓扑、3D 设备、全景图等。引入 3D Gaussian Splatting(3DGS)后,用户可以在真实世界的扫描场景中放置和管理 IT 设备,实现「所见即所得」的资产管理体验。

本文介绍这一高斯泼溅场景的技术实现,核心挑战是让两个独立的 WebGL 渲染引擎在同一个视口中协同工作

整体架构:双渲染层叠加

场景采用两个 WebGL 渲染引擎在 z 轴上叠加的架构:

┌─────────────────────────────────────┐ │ HT 层 (z-index:2) │ ← HT Graph3dView │ pointer-events:none │ 渲染交互式 3D 设备模型 │ │ ├─────────────────────────────────────┤ │ Spark 层 (z-index:1) │ ← Three.js WebGLRenderer + SparkRenderer │ │ 渲染高斯泼溅点云场景 └─────────────────────────────────────┘
引擎职责
Spark 层Three.js + SparkRenderer加载 .ply/.spz/.ksplat 文件,渲染逼真的 3DGS 点云场景,拥有相机控制权
HT 层Hightopo Graph3dView叠加可交互的 3D 设备模型(机柜、设备、图标),负责选中/拖拽/属性编辑

两个<div>全视口堆叠。HT 层默认pointer-events: none,鼠标事件穿透给 Spark;进入编辑或放置模式时才切为auto

这个设计的核心优势是关注点分离:Spark 专注于高质量 splat 渲染,HT 专注于设备模型的交互逻辑,各自在自己的领域做到最好。

相机同步:让两个引擎看同一个地方

最核心的技术挑战是确保两个渲染引擎始终共享同一视角。由于 Spark 拥有相机控制权(用户通过鼠标/键盘自由移动),HT 必须每帧跟随。

同步逻辑

syncCameraFromSpark(){constcam=this.camera// Three.js PerspectiveCameraconsteye=cam.position// 相机位置constdir=cam.getWorldDirection(dirBuf)// 视线方向constfovDeg=cam.fov// 视场角// 跳过亚像素变化,避免无意义的 GPU 重绘if(Math.abs(eye.x-lastEx)<1e-5&&...)return// 写入 HT 相机g3d.setEye([eye.x,eye.y,eye.z])g3d.setCenter([eye.x+dir.x*CAM_DIST,eye.y+dir.y*CAM_DIST,eye.z+dir.z*CAM_DIST])g3d.setFovy(fovDeg*Math.PI/180)// HT 使用弧度g3d.validateImpl()// 当前帧同步重绘}

两个关键决策

1.validateImpl()而非invalidate()

这是整个同步机制中最细微但也最关键的差别:

  • invalidate()将重绘请求排队到 HT 自己的requestAnimationFrame,产生 1 帧滞后
  • validateImpl()在当前调用栈内同步执行重绘

1 帧的滞后在静态场景中无所谓,但在连续移动(WASD 飞行、右键平移)时,HT 设备模型会明显「漂移」——看起来像是漂浮在点云场景上方,跟不上相机的步调。

2. 渲染循环中的调用顺序

controls.update(camera) // 1. 应用用户输入到 Three.js 相机 syncCameraFromSpark() // 2. 将新相机状态同步到 HT(同步重绘) renderer.render(scene, cam) // 3. Spark 用同一相机渲染 splat

如果顺序反过来(先渲染 Spark 再同步 HT),HT 永远比 Spark 晚一帧。左键纯旋转时因为 eye 不变偶尔不明显,但 WASD 平移和右键 pan 时漂移非常明显。

CAM_DIST 的取值

setCenter需要世界空间中的注视目标点,但 Three.js 只有视线方向向量。我们用eye + dir * CAM_DIST构造虚拟目标点。这个常量的取值有讲究:

  • 太小(如 10):HT 的 lookAt 归一化后方向角精度不够,设备位置有偏差
  • 太大(如 1000):浮点精度问题,远距离的 center 坐标可能累积误差
  • 取 100:在典型 GS 场景(相机距原点 1-20 单位)中表现稳定

输入处理:三路消费者的事件优先级

输入处理是整个场景最复杂的部分,涉及三个独立的消费者竞争同一批 DOM 事件:

消费者功能注册位置优先级
场景组件的鼠标/键盘 handler设备放置、选中、编辑window capture最高
SparkControls(内置 FpsMovement)场景 orbit/pan/zoom/WASD 移动renderer canvas
HT 全局快捷键 handler3D 场景快捷键(D/U/S/L/T 移动设备)window bubble最低,需屏蔽

鼠标事件:capture 阶段拦截

使用 window capture 阶段注册鼠标事件,在 SparkControls 之前拦截:

window.addEventListener('mousedown',onMouseDown,true)// capturewindow.addEventListener('mousemove',onMouseMove,true)

onMouseDown的处理逻辑按优先级排列:

  • 放置模式→ 在 splat 场景中创建设备(不传给 SparkControls)
  • 已选中设备→ 允许 HT 原生拖拽/调整大小
  • 点击新设备→ 选中并进入编辑模式
  • 点击空白→ 穿透给 SparkControls 执行 orbit 旋转

这里有一个容易忽略的细节:handler 必须限定在场景容器内。如果 handler 无差别拦截所有 window 点击,用户点击侧栏按钮、菜单、弹窗等 UI 时,编辑模式会误判为「点击空白」→ 退出编辑 → 清掉 HT 选中状态 → 属性面板的「设置」tab 拿不到数据。修复方式是加一层范围守卫:

if(e.target&&!e.target.closest('#scene-container'))return// 非场景区域,放行

键盘事件:精巧的冒泡阻断

键盘事件的处理最为微妙。HT 在三处监听了键盘:

  • keydown(window bubble):触发复制/粘贴/全选等
  • keyup(window bubble):将 D/S/U/L/T 映射为设备移动快捷键

当用户在 GS 场景中按 WASD 飞行时,HT 的 keyup handler 会拦截到 S/D → 尝试移动设备 →selection.size() === 0→ 弹出 “No object selected” alert。用preventDefault阻止不掉,必须在事件到达 HT 之前阻断。

解决方案是在document的 bubble 阶段注册:

document.addEventListener('keydown',onKeyDown,false)// bubble,不是 capturedocument.addEventListener('keyup',onKeyUp,false)

关键洞察在于 DOM 事件流:

capture: window → document → ... → target bubble: target → ... → document → window
  • window capture阶段stopPropagation():事件直接死亡,永远到不了 document → FpsMovement 完全失效,WASD 不能用 ❌
  • document bubble阶段stopPropagation():同节点的 FpsMovement 先触发(注册顺序在前),事件被阻断在 document,不到 window → HT 收不到 ✅

同时必须只用stopPropagation而不用stopImmediatePropagation——后者会杀掉 document 上所有后续 listener,包括 FpsMovement。

编辑模式下 WASD 不屏蔽

当设备处于编辑模式时,WASD 不应该触发放置/飞行,而应该让 HT 处理(如方向键微调位置)。所以在 WASD 阻断中加了守卫:

if(!editingMode&&!placing&&['w','a','s','d'].includes(e.key)){e.stopPropagation()}

编辑模式下的 WASD 放行,让 HT 正常处理——此时场景导航应该被禁用,因为用户在操作设备而非浏览场景。

设备放置:屏幕坐标到 3D 世界坐标

在 3DGS 场景中放置设备,需要将屏幕点击位置映射到 splat 场景中的 3D 坐标。由于相机是 Three.js PerspectiveCamera,使用 Three.js Raycaster 做射线投射:

screenToWorldPos(clientX,clientY,R){// 屏幕像素 → NDC(归一化设备坐标)constndc=newVector2((x/w)*2-1,-(y/h)*2+1,)// Three.js 射线constraycaster=newRaycaster()raycaster.setFromCamera(ndc,camera)constdir=raycaster.ray.direction.clone().normalize()// 沿视线方向距离 R 处放置return{x:camera.position.x+dir.x*R,y:camera.position.y+dir.y*R,z:camera.position.z+dir.z*R,}}

放置距离 R 的计算

R 取max(camera.position.length() * 0.5, 2),其中下限 2 是为了避开 HT 近裁面。场景初始化时 HT 近裁面设为setNear(1)

  • 默认相机位置 (0, 1, 3),距原点 ~3.16,R = 1.58 > 1 → 设备可见 ✓
  • 用户 WASD 靠近原点到 (0, 0.3, 1),距原点 ~1.04,R = 0.52 < 1 → 设备落入近裁面以内,HT 裁剪掉,不可见 ✗

硬下限 2 确保无论相机多近,设备都不会被裁剪。注意camera.position.length() === 0的边界情况:Math.max(0, 2) = 2正确兜底,而||fallback 只在严格等于 0 时触发,对小但不为零的值无效。

坐标系转换

Three.js 和 API 使用不同的坐标系约定:

Three.js: Y=上, Z=前后 API: Y=水平面(俯视), Z=高度

创建设备时需要翻转 Y 和 Z:

constworldPos={x:pos.x,y:pos.z,z:pos.y}

场景切换与资源管理

组件需要处理三种生命周期变化:进入场景、URL 变更(更换 .ply 文件)、退出场景。

进入场景

initScene() → rendering3D() 加载 HT 设备数据到 dataModel → initSpark() 创建 Three.js 渲染器 + 加载 PLY → attachGraph3dView() 接管主项目的 HT 视图 → bindEvents() 注册鼠标/键盘/ResizeObserver

退出场景

退出时不是销毁 HT 视图,而是完整还原到进入前的状态:

destroyScene()→ 退出编辑/放置模式 → 取消 requestAnimationFrame → 移除所有DOM事件监听 → dispose Three.js 资源(renderer 等) →restoreGraph3dView()// 归还 HT 视图→ 置空引用

restoreGraph3dView还原的关键状态:

状态还原方式
DOM 位置将 HT canvas 插回原始父节点的原始位置
相机参数恢复 Eye / Center / Fovy / Near / Far
交互器恢复 MovableFunc / Pannable / HandleScroll
CSS恢复 background / pointerEvents / cursor
地板节点恢复 floor 和 floorImage 的 3d.visible

为什么借而不是建

组件不创建独立的 HT 视图,而是从主项目的 init3dView 单例中「借走」graph3dView 的 DOM 元素。原因是:

  • HT Graph3dView 的初始化开销不小,而且与 dataModel / SelectionModel 深度绑定
  • 项目中多个场景(全景图、GS)共享同一个 dataModel 单例,各自接管同一视图可以复用已有的设备数据和交互基础设施
  • 退出时完整还原保证了主 3D 视图切换到其他模式时状态正确

URL 变更:热切换 PLY 文件

通过 watch 监听资产 URL 变化,支持用户在场景设置中切换 .ply 文件后无缝重载:

onUrlChange(newUrl,oldUrl){if(!oldUrl||newUrl===oldUrl)return// 跳过首次挂载dataModel.clear()// 清空旧设备destroyScene()// 销毁当前 Three.js 上下文loading=true// 显示加载 UInextTick(()=>initScene())// DOM 清理后重新初始化}

三个细节值得注意:

  1. !oldUrl跳过首次挂载——挂载时也触发 watch,不需要重复初始化
  2. dataModel.clear()必须在destroyScene之前——销毁时依赖 dataModel 来恢复地板节点状态
  3. nextTick确保restoreGraph3dView的 DOM 操作完成后再重新接管

设备编辑与持久化

HT 设备与可编辑状态

在 GS 场景中,设备模型通过 HT Node 表示。编辑模式下:

enterEditMode(node){editingMode=trueeditingNode=node// 只允许移动当前节点和它的 taggle handlesg3d.setMovableFunc((data)=>{if(data.a('type')==='taggle')returntrue// resize handleif(data===node)returndata.s('3d.movable')!==falsereturnfalse})}

setMovableFunc的精妙之处:它不是全局「是否可移动」的开关,而是一个逐节点判定函数。通过返回针对特定节点的函数,可以做到:

  • 选中的设备可拖拽
  • 设备的 taggle(resize handle)可拖拽
  • 其他所有节点(包括其他设备)不可拖拽

这样就实现了「一次编辑一个设备」的 UX,同时利用 HT 原生的拖拽和 resize 能力,不需要自己实现。

endMove 持久化

HT 的endMove事件在用户完成拖拽后触发。此时将 HT 坐标写回 API:

onEndMove(e){constp3=node.p3()// [x, height, z]consts3=node.s3()// [w, h, d]// 尺寸安全 clampconstclampedS3=s3.map(v=>Math.max(0.5,Math.min(500,v)))// HT 坐标系 (x, z=height, y=depth) → API 坐标系constbody={x:p3[0],// 水平 Xy:p3[2],// 水平 Y(HT 的 z 轴)z:p3[1],// 高度(HT 的 y 轴)width:clampedS3[0],depth:clampedS3[1],height:clampedS3[2],angle:-(node.r3()[1]*180/Math.PI),// Y 轴旋转,弧度转度}api.updateDevice(id,body)}

尺寸 clamp [0.5, 500] 防止意外拖成零或天文数字导致设备消失或炸裂。

解决的关键问题

ResizeObserver:侧栏展开时的图形错位

侧栏折叠/展开改变 CSS 布局,但不触发window.resize事件。此时容器实际尺寸变了,但 Three.js 的PerspectiveCamera.aspect和 HT 的投影矩阵仍使用旧宽高比 → 设备在屏幕上的位置偏移。

解决方案:用ResizeObserver监听场景容器的尺寸变化:

resizeObserver=newResizeObserver(()=>resizeHandler())resizeObserver.observe(containerElement)

ResizeObserver在容器尺寸因任何原因(CSS、JS、flex 布局)变化时都会触发,比window.resize覆盖更全面。

Canvas 隐藏期间的残影

PLY 加载期间 HT 层被隐藏(display:none)。浏览器对隐藏 canvas 的validateImpl()调用跳过实际 GPU 绘制,但像素缓冲区保留隐藏前的最后一帧。当 HT 层重新显示时,用户看到的是前一个场景(如全景图的地板)的残留画面。

修复:在加载完成后强制刷新:

onLoad:()=>{loading=falsenextTick(()=>{if(g3d)g3d.invalidate()// 强制 HT 用当前数据重绘})}

nextTick确保 DOM 已经从display:none切换到可见状态后再调重绘。

ES2015 class field 与 Vue 2 的反应式系统

Vue 2 在初始化组件 data 时会过滤掉以_$开头的属性。用 ES class field 声明的_keys = {}会被静默删除:

// 这样写,_keys 实际上不存在_keys={}// 后续访问 this._keys.w → Cannot read properties of undefined

解决方案是用非前缀命名(如wasdKeys),或在mounted中赋值(赋值操作绕过过滤)。

modelRawS3 的场景适配

HT 设备在 GS 场景中的视觉尺寸由modelRawS3 / 25决定。全景场景中 camera 距原点 ~100 单位,使用[100, 100, 100]产生 4 单位大小的设备——合适。GS 场景中相机距原点仅 ~3 单位,同样的值会让设备填满整个视口。

在场景初始化时设置:

store.commit('changeModelRawS3',[2,2,2])// 产生 0.08 单位的设备

camera.updateMatrixWorld(true) 的必要性

在相机同步逻辑开头加入:

camera.updateMatrixWorld(true)

虽然getWorldDirection()内部会调用updateWorldMatrix,但 SparkControls 在update()中可能直接修改camera.positioncamera.quaternion而不标记 matrix 为脏。显式调用确保 world matrix 在本次同步中是新鲜的,防止设备位置偶尔「跳」一下。

总结

这个高斯泼溅场景展示了多渲染引擎融合的设计模式,其技术要点可以归纳为:

  1. 关注点分离:Spark 管渲染,HT 管交互,边界由 DOM 的 z-index 和 pointer-events 定义
  2. 精确的帧同步:先同步相机再渲染,用validateImpl而非invalidate消除帧滞后
  3. 最小侵入的生命周期:借用而非创建 HT 视图,退出时完整还原状态
  4. 事件流的深度利用:理解 capture/bubble 的差异,精确控制事件在消费者之间的分发
  5. 防御性编程:尺寸 clamp、坐标范围检查、ResizeObserver 兜底、边界条件处理

这类双引擎架构适用于「真实感渲染 + 可交互覆盖层」的通用场景——全景图、点云可视化、BIM 模型等都可以复用同样的设计思路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询