最近在折腾三维可视化大屏,一套比较稳妥的组合是Vue3 + Three.js。用这套方案做3D模型与数据互动很顺手,比如设备模型按实时状态变色、点击模型弹出指标、数据变化驱动模型动作等。我把这套实现方案从工程搭建到项目落地完整拆一遍,如果你正在接这类需求,可以直接按这个路子走,不用自己再绕远路试错。
做3D可视化最怕的不是不会写效果,而是写完代码半个月之后自己都看不懂。Vue3的Composition API刚好可以帮我们把场景、模型、交互逻辑拆成独立函数,Three.js负责3D渲染,Vue3负责状态和生命周期,两者各干各的,又能通过响应式数据连起来。下面我会按真实项目的推进顺序,把这个方案一步步讲清楚。
1. 为什么是Vue3 + Three.js这套组合
1.1 Three.js解决的是3D渲染层
市面上做3D展示的手段不少,原生WebGL门槛太高,写个三角形还好,要处理光照、模型加载、相机轨道控制这些,没几个人能从零手搓。CSS3D做简单卡片还行,一旦涉及复杂模型、材质、pbr光照就完全不够用。Three.js把WebGL封装成了相对好用的场景图结构,让开发思路从“怎么操作GPU”变成“怎么摆布场景里的对象”,这个抽象级别刚好适合做业务型3D应用。
更关键的是Three.js的生态足够成熟。GLTFLoader加载glb模型、OrbitControls控制相机、Raycaster做拾取、CSS2DRenderer做标签,这些能力都是项目里高频使用的基础件。社区里能找各种现成案例,从机械手臂到园区楼宇到产品展示,踩坑成本被大幅压低。对于设备监控、数字孪生这类场景,Three.js能承载的颗粒度比CSS方案高好几个档位。
1.2 Vue3解决的是数据交互层
纯Three.js写一个场景不难,难的是让它和业务数据联动。项目里经常遇到这种需求:后端推送一条温度报警,模型里对应设备要变红;用户点击模型上的某个部件,右侧面板要展示它最近的指标曲线。这类东西如果全用命令式代码去写,update函数会越来越长,最后变成没人敢动的意大利面条。
Vue3的响应式系统非常契合这种场景。你可以把设备状态、指标、选中对象都定义成ref或reactive,然后在watchEffect里统一处理“数据变化之后要同步哪些模型状态”。这样数据层和渲染层就分开了:后端只管给数据,前端组件只管渲染UI,Three.js场景只管根据最新状态做视觉输出。三者各司其职,调试起来也简单得多。
另外Vue3的Composition API对逻辑提取特别友好。场景初始化可以抽成useThree,模型加载可以抽成useModel,点击交互可以抽成useRaycaster,每个hook都是独立的、可测试的,不会再出现一个组件里几百行Three代码的尴尬情况。用Vue2写这种项目也不是不行,但Option API把data、methods、watch拆得太散,复杂3D逻辑很难组织得干净。
2. 从零初始化一个三维可视化工程
2.1 用Vite快速搭建Vue3项目
现在的Vue3项目我基本都用Vite起步,冷启动快、依赖安装少、HMR也跟手。先建工程:
npm create vite@latest 3d-dashboard -- --template vue cd 3d-dashboard npm install装Three.js的时候建议把类型声明也装上:
npm install three npm install -D @types/threeVite对Three.js的支持很友好,直接import就能用,不需要额外配置。如果你后面要连后端,顺手把vue-router、pinia一起装上,这些不是必须,但真实项目基本逃不掉。
2.2 三步搭出基础3D场景
很多新手一上来就堆模型,结果连相机往哪看都还没搞明白,画面一片黑。我的习惯是先搭一个空场景,确认渲染循环跑通了,再往里面塞东西。基础场景就三个步骤:初始化渲染器、创建相机和轨道控制、启动动画循环。
以Vue3组件为例,核心代码长这样:
<template> <div ref="containerRef" class="scene-container"></div> </template> <script setup> import { ref, onMounted, onUnmounted } from 'vue'; import * as THREE from 'three'; import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; const containerRef = ref(null); let renderer, scene, camera, controls; let animationId; function init() { const container = containerRef.value; const width = container.clientWidth; const height = container.clientHeight; // 1. 场景和相机 scene = new THREE.Scene(); camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000); camera.position.set(80, 50, 80); camera.lookAt(0, 0, 0); // 2. 渲染器 renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); renderer.setSize(width, height); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); container.appendChild(renderer.domElement); // 3. 轨道控制器,方便调试 controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; // 4. 基础灯光:环境光+方向光 const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); scene.add(ambientLight); const dirLight = new THREE.DirectionalLight(0xffffff, 1.2); dirLight.position.set(30, 50, 30); scene.add(dirLight); } function animate() { animationId = requestAnimationFrame(animate); controls.update(); renderer.render(scene, camera); } onMounted(() => { init(); animate(); window.addEventListener('resize', onResize); }); function onResize() { const container = containerRef.value; if (!container) return; const width = container.clientWidth; const height = container.clientHeight; camera.aspect = width / height; camera.updateProjectionMatrix(); renderer.setSize(width, height); } onUnmounted(() => { cancelAnimationFrame(animationId); renderer.dispose(); }); </script>这段代码里的几个点我额外说明一下。renderer.domElement是画布,一定要挂载到有明确高度的容器里,否则canvas高度为0,画面自然空白。setPixelRatio最大限制到2就够了,再高收益很小但性能开销直线上升。AmbientLight保证模型整体能被看清,DirectionalLight能打出立体感,两者都属于基础标配。
2.3 gltf模型资源怎么准备
场景跑通之后就要加载模型了。现在3D模型的主流格式是gltf和glb,glb是gltf的二进制格式,文件更小,加载速度更快,Web端基本首选glb。加载用GLTFLoader:
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; const loader = new GLTFLoader(); loader.load('/models/equipment.glb', (gltf) => { scene.add(gltf.scene); }, (progress) => { console.log('加载进度', (progress.loaded / progress.total) * 100); });模型来源一般有三个方向。一是设计师用Blender、3ds Max建模后导出glb;二是网上找免费模型,注意看清授权协议,尤其商用项目;三是用代码生成简单几何体拼装,比如厂房、机柜这类比较规整的设备,用BoxGeometry、CylinderGeometry组合也够用,不一定非要外部模型。
我踩过一个坑:外部模型导入后坐标千奇百怪,有的埋在场景下面,有的自带旋转。解决办法是加载完统一做一次归一化:用Box3计算模型包围盒,把模型中心移到原点,再缩放到合适比例。不然你后面做点击交互、数据定位都会很痛苦。
3. 数据互动是怎么一步步缠到模型上
3.1 用响应式数据驱动模型状态
场景和模型都有了,开始做数据互动。这里最关键的一个思路转变是:不要再想着“某个事件发生时去改Three对象”,而是让Vue的响应式数据成为唯一数据源,任何状态变化都通过watchEffect自动同步到3D场景。
举个设备监控场景的例子。我从后端拿到设备列表,定义成这样:
import { ref, watchEffect } from 'vue'; const deviceList = ref([ { id: 'device-01', name: '边缘网关', status: 'normal', temperature: 42 }, { id: 'device-02', name: '空调机组', status: 'warning', temperature: 68 }, { id: 'device-03', name: '配电柜', status: 'error', temperature: 83 }, ]);把加载好的模型节点按id存成一个映射表:
const modelMap = { 'device-01': meshNode1, 'device-02': meshNode2, 'device-03': meshNode3, };然后在watchEffect里写同步逻辑:
watchEffect(() => { deviceList.value.forEach(item => { const mesh = modelMap[item.id]; if (!mesh) return; const material = mesh.material; // 根据状态切换颜色 if (item.status === 'normal') { material.color.set('#16a34a'); } else if (item.status === 'warning') { material.color.set('#facc15'); } else if (item.status === 'error') { material.color.set('#dc2626'); } // 温度超过60度,让模型轻微抖动 mesh.userData.isOverheated = item.temperature > 60; }); });后面只要直接修改deviceList里某个设备的temperature,watchEffect自动就会跑到对应模型并更新颜色。甚至你可以在Vue组件里对deviceList做过滤、排序,视图和模型都会跟着变,完全不需要手动调Three接口。用这种方式,前端从后端拿WebSocket推送也好、轮询接口也好,只需要及时更新数据源,3D场景自然跟着动。
3.2 点击拾取模型并弹出实时数据
在Three.js里做点击选中,核心是Raycaster。它本质上是模拟一条从相机出发经过鼠标位置的光线,检测这条线和场景里哪些模型相交。直接上代码:
import { Raycaster, Vector2 } from 'three'; const raycaster = new Raycaster(); const pointer = new Vector2(); function onPointerClick(event) { const rect = renderer.domElement.getBoundingClientRect(); pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; raycaster.setFromCamera(pointer, camera); const intersects = raycaster.intersectObjects(scene.children, true); if (intersects.length > 0) { let obj = intersects[0].object; // 向上找携带deviceId的父级节点 while (obj && !obj.userData?.deviceId) { obj = obj.parent; } if (obj) { const deviceId = obj.userData.deviceId; const device = deviceList.value.find(d => d.id === deviceId); console.log('点击了', device); } } }很多人在这一步容易踩坑,Raycaster明明打了,就是拾取不到。最常见的原因是pointer坐标没有按canvas实际尺寸归一化。如果页面里有滚动条、canvas不是全屏,必须用getBoundingClientRect来算,不能用event.clientX直接除以window.innerWidth。
getBoundingClientRect是相对视口的坐标,算出来的pointer.x/y范围在-1到1之间,正好是Raycaster需要的规范坐标。还要注意intersectObjects第三参数传true,表示递归检测子对象。外部的glb模型内部常常是多层嵌套,不递归的话点不到最里面的部件。
拾取到设备之后,UI浮层可以用两种方案做。一种是直接在Vue模板里放一个绝对定位的div,点击后把设备数据写进ref,div根据数据展示;另一种是Three.js自带的CSS2DRenderer,把标签当作3D对象放在模型旁边。简单场景推荐第一种,少接一个渲染器,实现起来更快。
3.3 动画过渡与渲染性能优化
数据互动并不只是切个颜色那么简单,项目里经常要做“状态切换动画”,让变化过程肉眼可见。比如温度报警时模型从绿色平滑变红,设备启动时从静止到缓慢旋转,这些都需要在动画循环里处理。
颜色变化最简单的做法是线性插值。在每次animate里调用material.color.lerp,把当前颜色往目标颜色方向移动一段距离,就能产生平滑过渡。举个例子:
// 在watchEffect里记录目标颜色 const targetColor = new THREE.Color('#dc2626'); // 在animate循环中 mesh.material.color.lerp(targetColor, 0.05);这样模型不是瞬间跳到红色,而是每次渲染朝红色靠近一点点,视觉上非常自然。位移动画也是同理,用Vector3的lerp控制位置,或者用gsap库设置tween。gsap的好处是支持回调函数、缓动函数更丰富,适合做复杂动画串。
不过引入动画库之前,先考虑性能。三维可视化项目的性能瓶颈大部分在绘制调用数量上,DrawCall越多越卡。如果你的场景里要放几百台机柜,不要给每台机柜单独创建一个Mesh,建议使用InstancedMesh做实例化渲染,同一个几何体可以渲染几千份,但只占用一次DrawCall。对于数据大屏这种设备数量动辄几百的场景,这个优化几乎是必须的。
另外一个容易忽略的点是渲染循环。如果3D场景有大量静态内容,没有持续动画,可以只在数据变化时渲染一帧,而不是一直跑requestAnimationFrame。否则CPU和GPU会被空转的渲染循环白白消耗,尤其笔记本上风扇呼呼转,用户马上就能感受到。实际项目中根据场景复杂度决定是“按需渲染”还是“持续渲染”,持续渲染要把渲染器的渲染函数控制在最小开销。
4. 三维可视化项目必备避坑清单
4.1 高频问题与排查方案速查
我做这类项目过程中攒了不少经验,下面这几个问题是团队里新人问得最多的,整理成表格方便直接查。
| 问题 | 可能原因 | 解决思路 |
|---|---|---|
| 场景黑屏或空白 | 容器高度为0、相机位置不对、没加灯光 | 先检查container.clientHeight是否>0,再调相机位置和lookAt,最后补环境光 |
| 模型加载后是黑色 | gltf材质的roughness或metalness参数偏高、灯光强度不足 | 加大环境光强度,或者增加半球光从下方补光,必要时手动调整材质参数 |
| 点击拾取不命中 | 坐标未归一化、intersectObjects没传true、模型嵌套太深 | 用getBoundingClientRect算规范化坐标,开启递归检测,向上冒泡找userData |
| 数据更新后模型没反应 | watchEffect依赖没写对、userData里存的id和接口id不一致 | 在watchEffect里加一个console.log,确认依赖触发;统一设备id字段 |
| 内存占用持续上涨 | 动画未取消、事件监听未移除、geometry/material未dispose | onUnmounted里做反向清理,参考Vue生命周期清理Three资源 |
| 模型加载慢、白屏时间长 | glb体积过大、没有压缩 | 用Draco压缩几何体,加载时显示进度条,先展示UI框架再异步加载模型 |
| 画面边缘锯齿严重 | 抗锯齿没开或分辨率设置太高 | 确保WebGLRenderer传antialias: true,setPixelRatio限制在2以内 |
4.2 我后来养成的几个习惯
写3D可视化项目写多了,我慢慢形成了几个固定习惯,对稳定性和开发效率都有帮助。
第一个习惯是所有模型都提前约定好命名规范。比如设备部件统一用“设备ID_部件名”,这些名称在代码里通过名称查找或直接挂userData,尽量避免硬编码路径。如果模型是外部导入的,我会在Blender里把每个可交互对象设置成userData字段,导出之后Three.js能直接读到,不需要在代码里再手动绑定。
第二个习惯是把Three.js生命周期和Vue组件生命周期严格对齐。进入页面时初始化,离开页面时销毁,不是简单的remove canvas,还要调用geometry.dispose()、material.dispose()、renderer.dispose()。很多线上项目越用越卡,就是因为组件被切换了很多次,但WebGL上下文和GPU资源一直没释放干净。
第三个习惯是分层调试。我习惯先在纯Three.js环境里验证模型和颜色逻辑,确认没问题再迁到Vue工程里。别一上来就在大项目里写复杂交互,出了问题很难判断是Vue响应式的问题还是Three渲染的问题。把场景抽成hooks之后,可以在开发环境单独写一个调试面板,手动修改设备数据观察模型状态,比每次都等后端推送高效得多。
第四个习惯是给数据变化写日志。3D项目里数据流动链路长,从接口到store到组件再到Three对象,中间任何一个环节断了都很难查。我会在fetch数据和watchEffect更新的地方各留一行log,上线前再关掉。有时候用户反馈“怎么点了没反应”,一查日志发现是后端字段名变了,前端找不到对应id,这种坑靠肉眼盯代码是真不好排查。
5. 后续还能怎么扩展这套方案
写到这里,基础方案已经能支撑一个3D设备监控看板了。如果你想把项目做得更深入,有几条路线都可以在现有架构上扩展。
一是把标签系统升级。现在Vue浮层虽然方便,但模型旋转时浮层不会跟随,容易遮挡。可以接入CSS2DRenderer或者CSS3DRenderer,让标签贴着模型走,信息展示更直观。二是接真实数据源。WebSocket推送JSON,前端拿到数据后update到deviceList,改造代码量很小,因为方案本身已经是数据驱动了。
三是和图表面板联动。点击模型设备,右侧用ECharts展示它的历史曲线。这个只需要在点击事件里设置一个selectedDevice ref,图表组件watch这个ref后更新配置即可。整个项目的核心架构依然是“Vue管理状态、Three.js渲染状态”,扩展起来非常顺手。
我个人实际用下来的体会是,Three.js和Vue3的组合在中小型3D项目中投入产出比很高,它不会给你一套开箱即用的模板,但给了你完全可控的自由度。数据驱动模型的思路一旦理清,后续加功能基本都是往hook里添代码,不用推翻重来。最后再分享一个小技巧:模型材质颜色别直接写死,把每个设备的正常色、警告色、错误色做成配置项,这样产品经理改颜色的时候你只需要改一个配置对象,不用动渲染逻辑。这套方案我沿用至今,每次接手新项目都觉得省心不少。