公司动态
nVisual高斯泼溅场景:Three.js + HT 双引擎融合实践
背景数据中心可视化平台通常需要多种场景模式——2D 拓扑、3D 设备、全景图等。引入 3D Gaussian Splatting3DGS后用户可以在真实世界的扫描场景中放置和管理 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(){constcamthis.camera// Three.js PerspectiveCameraconsteyecam.position// 相机位置constdircam.getWorldDirection(dirBuf)// 视线方向constfovDegcam.fov// 视场角// 跳过亚像素变化避免无意义的 GPU 重绘if(Math.abs(eye.x-lastEx)1e-5...)return// 写入 HT 相机g3d.setEye([eye.x,eye.y,eye.z])g3d.setCenter([eye.xdir.x*CAM_DIST,eye.ydir.y*CAM_DIST,eye.zdir.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 再同步 HTHT 永远比 Spark 晚一帧。左键纯旋转时因为 eye 不变偶尔不明显但 WASD 平移和右键 pan 时漂移非常明显。CAM_DIST 的取值setCenter需要世界空间中的注视目标点但 Three.js 只有视线方向向量。我们用eye dir * CAM_DIST构造虚拟目标点。这个常量的取值有讲究太小如 10HT 的 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 在三处监听了键盘keydownwindow bubble触发复制/粘贴/全选等keyupwindow 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归一化设备坐标constndcnewVector2((x/w)*2-1,-(y/h)*21,)// Three.js 射线constraycasternewRaycaster()raycaster.setFromCamera(ndc,camera)constdirraycaster.ray.direction.clone().normalize()// 沿视线方向距离 R 处放置return{x:camera.position.xdir.x*R,y:camera.position.ydir.y*R,z:camera.position.zdir.z*R,}}放置距离 R 的计算R 取max(camera.position.length() * 0.5, 2)其中下限 2 是为了避开 HT 近裁面。场景初始化时 HT 近裁面设为setNear(1)默认相机位置 (0, 1, 3)距原点 ~3.16R 1.58 1 → 设备可见 ✓用户 WASD 靠近原点到 (0, 0.3, 1)距原点 ~1.04R 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 和 ZconstworldPos{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 / HandleScrollCSS恢复 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||newUrloldUrl)return// 跳过首次挂载dataModel.clear()// 清空旧设备destroyScene()// 销毁当前 Three.js 上下文loadingtrue// 显示加载 UInextTick(()initScene())// DOM 清理后重新初始化}三个细节值得注意!oldUrl跳过首次挂载——挂载时也触发 watch不需要重复初始化dataModel.clear()必须在destroyScene之前——销毁时依赖 dataModel 来恢复地板节点状态nextTick确保restoreGraph3dView的 DOM 操作完成后再重新接管设备编辑与持久化HT 设备与可编辑状态在 GS 场景中设备模型通过 HT Node 表示。编辑模式下enterEditMode(node){editingModetrueeditingNodenode// 只允许移动当前节点和它的 taggle handlesg3d.setMovableFunc((data){if(data.a(type)taggle)returntrue// resize handleif(datanode)returndata.s(3d.movable)!falsereturnfalse})}setMovableFunc的精妙之处它不是全局「是否可移动」的开关而是一个逐节点判定函数。通过返回针对特定节点的函数可以做到选中的设备可拖拽设备的 taggleresize handle可拖拽其他所有节点包括其他设备不可拖拽这样就实现了「一次编辑一个设备」的 UX同时利用 HT 原生的拖拽和 resize 能力不需要自己实现。endMove 持久化HT 的endMove事件在用户完成拖拽后触发。此时将 HT 坐标写回 APIonEndMove(e){constp3node.p3()// [x, height, z]consts3node.s3()// [w, h, d]// 尺寸安全 clampconstclampedS3s3.map(vMath.max(0.5,Math.min(500,v)))// HT 坐标系 (x, zheight, ydepth) → API 坐标系constbody{x:p3[0],// 水平 Xy:p3[2],// 水平 YHT 的 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监听场景容器的尺寸变化resizeObservernewResizeObserver(()resizeHandler())resizeObserver.observe(containerElement)ResizeObserver在容器尺寸因任何原因CSS、JS、flex 布局变化时都会触发比window.resize覆盖更全面。Canvas 隐藏期间的残影PLY 加载期间 HT 层被隐藏display:none。浏览器对隐藏 canvas 的validateImpl()调用跳过实际 GPU 绘制但像素缓冲区保留隐藏前的最后一帧。当 HT 层重新显示时用户看到的是前一个场景如全景图的地板的残留画面。修复在加载完成后强制刷新onLoad:(){loadingfalsenextTick((){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 模型等都可以复用同样的设计思路。