资讯动态

Vue3 + Three.js 构建 3D 可视化场景的完整工程实践

发布时间:2026/9/26 7:21:01 来源:尧图企业网站定制
1. 3D可视化需求里Vue3与Three.js的分工边界很多同学第一次接触 Three.js 时习惯把整个 3D 场景写在一个全局脚本里创建渲染器、加载模型、绑定事件、维护动画状态全都堆在一起。一开始确实能跑模型转起来、能点击、能高亮感觉挺不错。可一旦要接业务数据——比如设备告警、温湿度、机柜状态、实时点位——就会立刻发现不对劲Three.js 只管“画”数据变化要主动调用接口去改场景里的对象页面上的弹窗、列表、状态标签又要手动操作 DOM多个视图之间共享选中设备的状态还得自己搞一套事件总线。整个项目越写越像一个巨大的面条工厂。这就是为什么要把 Three.js 放进 Vue3 项目里。Three.js 负责渲染、交互和场景管理Vue3 负责业务数据的组织、组件化和响应式状态。简单说Three.js 解决“怎么画出来”Vue3 解决“画面跟着业务怎么变”。两者的边界如果切得清楚项目越往后写越轻松尤其是需要频繁联调后端接口、展示实时数据的场景。我实际做过的“机房 3D 可视化大屏”项目就是这种组合。模型是建模师导出的 GLB 文件设备点位有几百个后端每 5 秒推一次监控数据。最初纯 JS 版本光维护“点击某个机柜后右侧面板展示什么信息”这一条状态链路就写了三百多行事件逻辑。后来重构为 Vue3 Three.js状态交给 Vue3 的响应式系统3D 场景只负责根据状态改颜色、改显隐、处理点击拾取代码量下降一大截排查问题的效率也高很多。1.1 直接写纯 Three.js 会遇到什么瓶颈纯 Three.js 不是不能做数据可视化而是当业务复杂到一定程度你会发现自己必须手动实现很多 Vue/React 早就解决的问题状态同步设备从“正常”变成“告警”后要同步修改模型材质颜色、热区图标、右侧面板文字、列表滚动位置。纯 JS 里每个环节都得手动调用少一个就出 bug。DOM 管理模型坐标转屏幕坐标后生成标签、弹窗、提示框纯 JS 要么直接拼字符串要么 createElement 一长串。在 Vue3 里这些就是一个组件 一个 v-if。生命周期页面关闭、路由切换、组件销毁时要手动清掉定时器、渲染循环、事件监听、GPU 资源。Vue3 的 onUnmounted 钩子天然提供了一个统一回收的位置。所以在这个方案里我始终强调一个分工原则Three.js 的世界里不存业务状态Vue3 的响应式数据里不存 Three.js 对象。1.2 Vue3 响应式机制和 Three.js 场景怎么衔接Vue3 的 ref、reactive 非常好用但如果你想把整个 THREE.Scene、Mesh、Camera 一股脑塞进 reactive 里就会踩到性能大坑。Vue3 的响应式代理会递归劫持对象属性访问而 Three.js 的对象内部属性极多帧循环里每帧都在读写 position、rotation、matrix一旦被代理拦截性能会肉眼可见地下降。我推荐的做法是分两层业务层用 Vue3 的 ref、reactive 存可序列化的数据比如设备列表、告警状态、选中设备的 ID。渲染层Three.js 对象放在普通的 Map 或 Object 里不参与响应式由渲染层自己管理或者放在组件 setup 的普通变量里。两层之间通过“命令式”的同步函数连接。数据变了watch 触发调用一个专门的 updateSceneState 函数把当前数据同步到三维场景中。不是用 v-for 去渲染 3D 模型而是用 watch 遍历模型 Map 来更新。2. 工程初始化从 Vite 空项目到第一个渲染器搭建环境这件事看似简单但有几个细节会影响后续开发效率。我建议直接上 TypeScript模型对象的结构比较固定类型提示能省掉很多查文档的时间。npm create vitelatest 3d-vue-app -- --template vue-ts cd 3d-vue-app npm install npm install three types/three安装完 three 和 types/three 之后就能在组件里引入 Three.js 了。这里有一个很多新手会踩的坑Vite 对 three 的 ESM 支持很成熟直接import * as THREE from three即可不要去用全局 script 方式引入否则会造成重复加载和类型混乱。2.1 在组件里创建渲染器和场景Vue3 组件里创建 3D 场景最干净的方式是给canvas一个 ref然后从组件上下文中取到这个 DOM 节点再在里面创建渲染器。template div refcontainerRef classscene-container canvas refcanvasRef/canvas /div /template script setup langts import { ref, onMounted, onUnmounted } from vue import * as THREE from three import { OrbitControls } from three/examples/jsm/controls/OrbitControls.js const containerRef refHTMLDivElement() const canvasRef refHTMLCanvasElement() let renderer: THREE.WebGLRenderer let scene: THREE.Scene let camera: THREE.PerspectiveCamera let controls: OrbitControls let animationId: number function initScene() { const container containerRef.value! const width container.clientWidth const height container.clientHeight renderer new THREE.WebGLRenderer({ canvas: canvasRef.value, antialias: true, alpha: true }) renderer.setSize(width, height) renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)) scene new THREE.Scene() camera new THREE.PerspectiveCamera(45, width / height, 0.1, 1000) camera.position.set(30, 25, 30) camera.lookAt(0, 0, 0) controls new OrbitControls(camera, renderer.domElement) controls.enableDamping true const ambientLight new THREE.AmbientLight(0xffffff, 0.5) scene.add(ambientLight) const directionalLight new THREE.DirectionalLight(0xffffff, 1) directionalLight.position.set(10, 20, 10) scene.add(directionalLight) } function animate() { animationId requestAnimationFrame(animate) controls.update() renderer.render(scene, camera) } onMounted(() { initScene() animate() }) onUnmounted(() { cancelAnimationFrame(animationId) renderer.dispose() controls.dispose() }) /script这里有一个关键点渲染器只创建一次不要在每次数据变化时重建。如果你把 renderer、scene 放到 setup 的普通变量而不是 ref 里就能避免响应式代理带来的性能损耗。同时也建议把animate里的 controls.update 保留因为 OrbitControls 的阻尼效果需要每帧更新才能生效。2.2 自适应容器尺寸页面窗口一变3D 画面会变形这是最容易忽略的问题。最简单可靠的方式是用 ResizeObserver 监听容器尺寸更新相机 aspect 和渲染器尺寸。const resizeObserver new ResizeObserver(() { if (!containerRef.value || !renderer || !camera) return const width containerRef.value.clientWidth const height containerRef.value.clientHeight camera.aspect width / height camera.updateProjectionMatrix() renderer.setSize(width, height) }) onMounted(() { resizeObserver.observe(containerRef.value!) }) onUnmounted(() { resizeObserver.disconnect() })如果在组件里用了 el-dialog 这类动态显隐的容器需要注意dialog 首次打开之前容器宽度是 0渲染器尺寸会变成 0。解决方式是给容器一个最小宽高或者等 dialog 打开后再调一次 resizeObserve 回调。3. 模型的载入与场景布置GLB 文件、灯光与相机控制Three.js 支持的模型格式很多实际项目里最推荐的是 GLB。GLB 是 GLTF 的二进制版本一个文件包含了网格、材质、贴图、动画不需要额外加载外部图片适合前端部署和按需加载。建模软件如 Blender、3ds Max、C4D 都能直接导出。加载模型用的是 GLTFLoader如果模型经过了 Draco 压缩还需要配合 DRACOLoaderimport { GLTFLoader } from three/examples/jsm/loaders/GLTFLoader.js import { DRACOLoader } from three/examples/jsm/loaders/DRACOLoader.js const dracoLoader new DRACOLoader() dracoLoader.setDecoderPath(https://www.gstatic.com/draco/versioned/decoders/1.5.6/) const loader new GLTFLoader() loader.setDRACOLoader(dracoLoader) loader.load(/models/room.glb, (gltf) { const model gltf.scene scene.add(model) fitModelToView(model) })3.1 模型导入后的常用处理建模师给的文件通常不会正好符合你的页面视角。我拿到模型后一般会做三件事归一化坐标、重置缩放、调整朝向。function fitModelToView(model: THREE.Object3D) { const box new THREE.Box3().setFromObject(model) const center box.getCenter(new THREE.Vector3()) const size box.getSize(new THREE.Vector3()) // 让模型居中方便使用相机 lookAt model.position.sub(center) // 根据模型尺寸调整相机距离 const maxSize Math.max(size.x, size.y, size.z) if (maxSize 0) { camera.position.set(maxSize * 1.2, maxSize * 0.8, maxSize * 1.2) } }这三步看起来基础但能避免一半以上的“模型跑到视野外”问题。另一个常见问题是建模师和前端的坐标系约定不一致有些软件默认 Z 轴向上Three.js 是 Y 轴向上如果导出来歪了直接model.rotation.x Math.PI / 2把模型翻正即可。3.2 材质与灯光怎么搭才不会翻车GLB 模型自带的材质通常基于 PBR看起来正常但直接放进页面里往往偏暗。原因是没有给场景加足够的灯光和环境贴图。Three.js 默认没有环境光所以模型只能靠你手动加的灯光照亮。我常用的灯光组合是一个环境光打底一个方向光做主光一个点光源做局部补光。环境光强度 0.4 到 0.6方向光强度 1 到 1.5。如果想要更真实的金属质感可以给场景加一个 HDR 环境贴图import { RGBELoader } from three/examples/jsm/loaders/RGBELoader.js import { PMREMGenerator } from three const rgbeLoader new RGBELoader() const pmremGenerator new PMREMGenerator(renderer) rgbeLoader.load(/env.hdr, (texture) { const envMap pmremGenerator.fromEquirectangular(texture).texture scene.environment envMap })HDR 环境贴图对模型质感的提升非常明显尤其是机柜、设备、机械结构这类金属表面。如果不想额外加载 HDR也可以用THREE.PMREMGenerator配合 RoomEnvironment 生成默认环境代码更轻量import { RoomEnvironment } from three/examples/jsm/environments/RoomEnvironment.js const pmremGenerator new THREE.PMREMGenerator(renderer) scene.environment pmremGenerator.fromScene(new RoomEnvironment()).texture这个方案我在多个项目中用过效果稳定不需要外部资源强烈建议先试这个。3.3 OrbitControls 的配置细节OrbitControls 是 Three.js 官方提供的轨道控制器支持鼠标拖拽旋转、缩放、平移。在数据可视化项目里我通常会限制旋转范围和缩放范围避免用户把视角转到模型底部或者穿模到模型内部。controls.minDistance 5 controls.maxDistance 200 controls.maxPolarAngle Math.PI / 2.2 controls.enablePan true controls.target.set(0, 2, 0)这里maxPolarAngle限制俯仰角防止相机转到模型地下仰视视觉观感会好很多。target通常就是模型中心可以手动设置成设备区域的重点位置。4. 射线拾取与响应式数据绑定让 3D 模型可以点击3D 模型加载进来之后最核心的数据互动就是“点击模型上的某个部件获取该部件的业务数据”。比如机房场景里点击一台服务器右侧弹出这台服务器的 CPU、内存、状态信息点击一台空调显示温湿度曲线。这个功能的实现依赖 Three.js 的 Raycaster射线检测。原理很简单从相机位置向鼠标点击方向发射一条射线穿过场景中的所有物体返回它击中物体的列表。我们可以通过object.name或者object.userData来辨认点击的是哪个设备。4.1 Raycaster 的标准写法先把鼠标点击的屏幕坐标转换成标准化设备坐标NDC再通过 raycaster 和相机计算射线const raycaster new THREE.Raycaster() const mouse new THREE.Vector2() function onCanvasClick(event: MouseEvent) { const rect renderer.domElement.getBoundingClientRect() mouse.x ((event.clientX - rect.left) / rect.width) * 2 - 1 mouse.y -((event.clientY - rect.top) / rect.height) * 2 1 raycaster.setFromCamera(mouse, camera) const intersects raycaster.intersectObjects(scene.children, true) if (intersects.length 0) { const obj intersects[0].object handleModelClick(obj) } }这里有一个值得注意的点intersectObjects第二个参数传 true表示递归检测子物体。但如果场景里有很多无关对象递归检测会浪费性能。更优雅的方式是把所有“可点击设备”统一加到一个分组THREE.Group中只对该分组做射线检测。没有业务含义的墙面、地板、装饰物都不需要放到可点击分组中。4.2 userData 是模型和业务数据之间的桥每个 THREE.Object3D 都有一个userData属性非常适合存业务标识。建模师导出的模型通常有很多 Mesh 层级比如一个“机柜”下面有柜体、门板、螺丝。我们不能让每个 Mesh 都能被点击高亮否则用户体验很怪。正确做法是在模型加载完成后遍历树结构把带有设备标记的节点统一提取出来设置一个自定义属性。function collectDevices(root: THREE.Object3D) { const devices new Mapstring, THREE.Object3D() root.traverse((child) { if (child instanceof THREE.Mesh child.userData?.deviceId) { devices.set(child.userData.deviceId, child) } }) return devices }建模师在软件里给各部件命名或者写自定义属性后GLB 导出时这些属性会带进 userData。如果建模师不方便写属性也可以在 load 之后根据名称匹配if (child.name.startsWith(device-)) { child.userData.deviceId child.name.replace(device-, ) }有了这个 Map业务数据就可以和模型轻松对应起来。后端返回deviceId、状态值我们直接devices.get(deviceId)拿到对应的 Mesh 修改颜色、透明度或显隐。4.3 点击后回传数据到 Vue3 状态Three.js 的事件回调里无法直接使用 Vue 模板语法但从事件里调用 setup 作用域内的函数毫无障碍。设备点击后要做的事通常是把deviceId写入一个 ref然后 Vue 的模板就会自动渲染对应的信息面板。const selectedDevice refDeviceInfo | null(null) function handleModelClick(obj: THREE.Object3D) { const device deviceMap.get(obj.userData.deviceId) if (!device) return selectedDevice.value { id: device.deviceId, name: device.name, status: device.status, temperature: device.temperature, cpu: device.cpu, memory: device.memory } }模板里只需要div v-ifselectedDevice classdevice-panel h3{{ selectedDevice.name }}/h3 p状态: {{ selectedDevice.status }}/p p温度: {{ selectedDevice.temperature }}/p pCPU: {{ selectedDevice.cpu }}/p /div这是一个典型的“单向数据流”3D 模型是视觉载体点击事件产生数据Vue3 负责渲染业务面板。后续如果要从面板反查模型也只需要watch(selectedDevice)再调用模型高亮函数即可。5. 用业务数据反向驱动模型状态颜色、闪烁与显隐模型和数据互动的另一半能力是让模型根据数据变化自动更新。最典型的场景后端推送过来一批设备状态正常设备显示绿色告警设备显示红色并闪烁离线设备变灰。这一节是我项目里踩坑最多的地方重点讲实现方式和性能细节。5.1 根据状态修改材质颜色修改 Mesh 颜色最直接的方式是访问它的 material改 color。但这里有个大坑如果多个 Mesh 用同一个材质实例你改了一个其他全部跟着变。因为 Three.js 的 Material 默认是共享引用而不是每个 Mesh 独立一份。所以我会在模型加载完成并识别设备之后给所有可点击设备克隆一份独立材质避免状态互相干扰function prepareMaterials(model: THREE.Object3D) { model.traverse((child) { if (child instanceof THREE.Mesh) { const materials Array.isArray(child.material) ? child.material : [child.material] child.material materials.map((mat) mat.clone()) } }) }克隆材质是有代价的每个材质都会增加显存占用所以只在确实需要独立变化的设备 Mesh 上做。装饰性地板、墙面这类大面积相同材质保持共享即可。接下来写一个同步函数把设备和状态的关系应用到 3D 场景const statusColorMap: Recordstring, number { normal: 0x22c55e, warning: 0xf59e0b, error: 0xef4444, offline: 0x9ca3af } function applyDeviceStatus(deviceId: string, status: string) { const mesh deviceMap.get(deviceId) if (!mesh) return const material mesh.material as THREE.MeshStandardMaterial const color statusColorMap[status] || 0xffffff material.color.setHex(color) // 告警设备闪烁 if (status error) { flashingSet.add(mesh) } else { flashingSet.delete(mesh) } }闪烁的实现不要用 setTimeout因为频率不稳定也不和渲染帧同步。更好的做法是在动画循环里判断时间const flashingSet new SetTHREE.Mesh() function animate() { animationId requestAnimationFrame(animate) const time performance.now() / 1000 flashingSet.forEach((mesh) { const material mesh.material as THREE.MeshStandardMaterial const intensity (Math.sin(time * 4) 1) / 2 material.emissive.setScalar(intensity * 0.8) }) controls.update() renderer.render(scene, camera) }这个方案不会有定时器误差视觉节奏也和帧率保持一致。5.2 数据变化时如何更新模型位置和显隐除了颜色业务数据还可以驱动模型在场景中的位置、角度、显隐。比如一组设备根据负载高低做位移或者根据告警状态是否展开某个部件。位置更新相对简单function updateDevicePosition(deviceId: string, x: number, y: number, z: number) { const mesh deviceMap.get(deviceId) if (!mesh) return mesh.position.set(x, y, z) }显隐切换用mesh.visible即可。有一个优化技巧用Group收纳一整组相同逻辑的设备切换整个组的 visible比遍历几十个 Mesh 逐个切换高效得多。比如我一共有 200 台服务器分成 10 组每组 20 台。后端有个“只看 1 号模块”的需求直接隐藏另外 9 个 GroupmoduleGroups.forEach((group, moduleId) { group.visible moduleId activeModuleId || activeModuleId all })这比遍历 200 个 Mesh 快很多也是我们用场景图管理层级带来的实际收益。5.3 大数据量下的 InstancedMesh 思路如果你要渲染上千个同类设备比如仓库货架上的几千个 SKU 点位普通 Mesh 会带来大量绘制调用GPU 扛不住。这种情况要改用 InstancedMesh实例化网格。它只渲染一次几何体但可以在一个绘制调用内渲染几千个实例每个实例可以独立设置位置、颜色、状态。const geometry new THREE.BoxGeometry(1, 1, 1) const material new THREE.MeshStandardMaterial({ color: 0xffffff }) const count 5000 const instancedMesh new THREE.InstancedMesh(geometry, material, count) const matrix new THREE.Matrix4() const color new THREE.Color() instancedData.forEach((item, index) { matrix.setPosition(item.x, item.y, item.z) instancedMesh.setMatrixAt(index, matrix) const statusColor statusColorMap[item.status] || 0xffffff color.setHex(statusColor) instancedMesh.setColorAt(index, color) }) scene.add(instancedMesh)数据更新时只需要重新计算对应索引的矩阵和颜色然后标记实例矩阵和颜色需要更新instancedMesh.instanceMatrix.needsUpdate true instancedMesh.instanceColor!.needsUpdate true这个方案可以轻松支撑数千个动态设备点位是纯 Mesh 方案完全没法比的。在设备点位密集的 3D 场景里建议优先考虑 InstancedMesh。6. 几个绕不开的坑资源释放、响应式陷阱与渲染性能这节内容全部来自实际项目里翻过车之后总结出来的经验不一定写在官方文档里但对正在用 Vue3 Three.js 做项目的同学有直接帮助。6.1 组件销毁时 Three.js 资源释放Vue 路由切走之后3D 场景如果没被正确销毁GPU 内存会持续占用页面多切换几次就卡到不能动。我在 onUnmounted 里做四件事onUnmounted(() { cancelAnimationFrame(animationId) // 1. 销毁控制器 controls?.dispose() // 2. 遍历场景释放几何体和材质 scene?.traverse((child) { if (child instanceof THREE.Mesh) { child.geometry?.dispose() const materials Array.isArray(child.material) ? child.material : [child.material] materials.forEach((mat) { mat.dispose() Object.values(mat).forEach((value) { if (value instanceof THREE.Texture) { value.dispose() } }) }) } }) // 3. 销毁渲染器 renderer?.dispose() // 4. 移除事件监听和观察器 canvasRef.value?.removeEventListener(click, onCanvasClick) resizeObserver?.disconnect() })这里特别要注意纹理资源的释放。材质 dispose 不会自动释放纹理必须要手动遍历材质的 properties 找到 Texture 再 dispose否则显存依然会涨。6.2 不要把 Three.js 对象放进 Vue3 响应式系统前面提过一次这里单独拿出来强调。Vue3 的 reactive 和 ref 会对对象做深层代理而 Three.js 场景对象内部的属性量极大。把 mesh 放进 reactive 数组后每当你访问mesh.position这样的属性Vue 都会走 Proxy 的 get 拦截。动画循环里每帧要读写大量属性性能损耗非常直观。我实际测试过一个包含 500 个 Mesh 的场景把 meshes 放进 reactive[] 后帧率从 60 掉到 30 左右。改回普通 Map 存模型对象后帧率立刻恢复。所以 Three.js 对象永远放在非响应式容器里只有业务数据设备列表、选中 ID、状态才放进 Vue3 响应式系统。6.3 小心 watch 的触发频率实时数据推送的接口可能非常频繁比如每 3 秒推 100 条设备状态。如果你对推送数据做了reactiveDeviceList.value newListVue3 会触发大量依赖更新。配合 watcher 去改变 3D 场景有可能导致重复渲染或跟不上数据帧率。我的习惯是在组件里做一个节流池每次推送先累积数据在下一次 requestAnimationFrame 之前统一更新let pendingStatusMap new Mapstring, string() let updateScheduled false function pushStatusBatch(batch: DeviceStatus[]) { batch.forEach((item) { pendingStatusMap.set(item.deviceId, item.status) }) if (!updateScheduled) { updateScheduled true requestAnimationFrame(() { pendingStatusMap.forEach((status, deviceId) { applyDeviceStatus(deviceId, status) }) pendingStatusMap.clear() updateScheduled false }) } }这样即使后端几毫秒内连续推 10 批数据3D 场景也只刷新一次视觉上完全无感知性能压力小很多。6.4 渲染性能的兜底手段如果模型本身非常大、面数很高、纹理很大前端能做的是有限度的。先说三个立竿见影的优化第一renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))。在 4K 屏幕上 devicePixelRatio 可能到 2 或更高但 3D 场景根本不需要这么高的渲染精度限制到 2 就能省掉大量渲染开销。第二给 OrbitControls 的旋转、缩放事件增加节流不要在控制器的 change 事件里做重计算。第三按需渲染模式。如果场景是静态的只有用户交互或数据变化时才需要渲染那就不要跑持续循环let needsRender true function markForRender() { needsRender true } function animate() { animationId requestAnimationFrame(animate) if (!needsRender) return needsRender false controls.update() renderer.render(scene, camera) } // 任何场景变化后调用 markForRender()这个模式在模型多、机型老的环境下特别管用。当然如果你有持续动画、闪烁状态每帧大变那就别用按需渲染否则emissive.setScalar的动态效果会消失。在实际项目里我更倾向于保持一个统一的动画循环把闪炼、转场、性能优化都放进一个循环里管。按需渲染适合纯展示类页面有实时数据刷新的场景还是推荐常规循环。最后再分享一个我的个人习惯做 Vue3 Three.js 的模型数据互动项目时建模师导出模型之后我会先在纯 Three.js 的测试页里加载一遍确认坐标、材质、视角都没问题再往 Vue 组件里接业务逻辑。模型问题定位在 3D 层业务问题定位在 Vue 层排查效率会高很多。项目越到后期这个前置测试能给你省下的时间越多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑