背景
数据中心可视化平台通常需要多种场景模式——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 全局快捷键 handler | 3D 场景快捷键(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 清理后重新初始化}三个细节值得注意:
!oldUrl跳过首次挂载——挂载时也触发 watch,不需要重复初始化dataModel.clear()必须在destroyScene之前——销毁时依赖 dataModel 来恢复地板节点状态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.position和camera.quaternion而不标记 matrix 为脏。显式调用确保 world matrix 在本次同步中是新鲜的,防止设备位置偶尔「跳」一下。
总结
这个高斯泼溅场景展示了多渲染引擎融合的设计模式,其技术要点可以归纳为:
- 关注点分离:Spark 管渲染,HT 管交互,边界由 DOM 的 z-index 和 pointer-events 定义
- 精确的帧同步:先同步相机再渲染,用
validateImpl而非invalidate消除帧滞后 - 最小侵入的生命周期:借用而非创建 HT 视图,退出时完整还原状态
- 事件流的深度利用:理解 capture/bubble 的差异,精确控制事件在消费者之间的分发
- 防御性编程:尺寸 clamp、坐标范围检查、ResizeObserver 兜底、边界条件处理
这类双引擎架构适用于「真实感渲染 + 可交互覆盖层」的通用场景——全景图、点云可视化、BIM 模型等都可以复用同样的设计思路。