简介:这份资源是面向前端开发者与3D可视化学习者的Vue + Three.js智慧园区实战项目,适合具备Vue基础、希望进阶WebGL三维场景开发的中级工程师。项目以Three.js完成场景、相机、渲染器搭建,结合Vue组件化组织页面,构建出可展示电力监测、水力监测等设备的立体园区模型,并配有动态交互效果,可用于智慧城市、园区管理类可视化界面的学习与二次开发。压缩包共67个文件,约35.33MB,包含21个js脚本、5个vue组件、3个json配置、3个css样式,以及24张png贴图、1个glb模型、2个wasm与3段mp4演示视频,资源结构覆盖模型、贴图、路由与状态管理等模块。目前已有2436人学习下载。通过完整源码与目录组织,读者可掌握Three.js模型加载、光照材质设置、Vue与三维场景通信等关键实现,并借鉴其工程化组织方式,快速搭建自己的3D可视化项目。
1. 智慧园区大屏为什么总像贴图:从一次翻车现场说起
去年帮一个做园区运营的朋友看他们新上的可视化大屏,页面打开挺唬人,楼栋、道路、绿化带一应俱全,鼠标一拖还能转。但转两下就露馅了:楼是平的,车是贴上去的,点一栋楼弹出来的还是写死的假数据。这种“伪 3D”在智慧园区项目里太常见了,本质就是拿几张等距视角的 PNG 拼在一起,看着像三维,实际连个正经相机都没有。
真正能用的智慧园区前端 3D 场景,得让浏览器里跑起一个真实的三维世界:有透视相机、有光照、有可拾取的独立模型,楼栋能按楼层高亮,设备能按状态变色,数据能实时挂到三维坐标上。这套 Vue + Three.js 的组合就是干这个的——Vue 管数据流和组件生命周期,Three.js 管场景渲染和交互拾取。适合谁?适合手里已经有园区 GIS 数据或 CAD 底图、需要在 Web 端做轻量化三维呈现的前端,也适合想从 ECharts 大屏往真三维迁移的团队。下面按我实际拆包复现的顺序,把这份资源从环境搭到进阶技巧走一遍。
2. 环境搭建与场景初始化:把 Three.js 塞进 Vue 组件
2.1 为什么不用现成的三维大屏模板
市面上不少“智慧园区模板”是拿一张俯视图当背景,上面浮几个 CSS 动画图标,改起来快但扩展性极差——想加个楼栋剖切、想按设备类型筛选,全得推倒重来。这份资源走的是原生 Three.js 渲染管线,场景里的每个楼栋、每条道路都是独立 Mesh,意味着你能对任意对象做射线拾取、材质替换、坐标查询。代价是初始化代码比模板多,但后面每加一个交互都不用求人。
选 Vue 而不是纯 HTML 的原因也简单:园区场景里大量状态是响应式的——选中楼栋变了要高亮、告警数量变了要更新标签、视角切换要联动侧边栏。用 Vue 的ref和watch管这些状态,比手动操作 DOM 清爽得多。Three.js 场景本身放在onMounted里初始化,onUnmounted里销毁,避免组件切换时 WebGL 上下文泄漏——这个坑后面会细说。
2.2 依赖安装与最小可运行场景
先建 Vue 3 项目,装 Three.js。版本上我一般锁three@0.160.x附近,太新的版本某些后处理插件还没跟上,太老的又缺WebGLRenderer的色调映射改进。
# 创建 Vue 3 项目,选 Vite 构建 npm create vite@latest smart-park-3d -- --template vue cd smart-park-3d # 安装 Three.js 核心库与类型声明 npm install three npm install -D @types/three # 如果要用轨道控制器,从 examples 里引,不用额外装包接着写一个最小的场景组件。核心就四样东西:场景(Scene)、相机(Camera)、渲染器(Renderer)、渲染循环。
// src/components/ParkScene.vue <script setup> import { ref, onMounted, onUnmounted } from 'vue' import * as THREE from 'three' import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js' const container = ref(null) let scene, camera, renderer, controls, animationId function initScene() { // 场景:所有三维对象的容器 scene = new THREE.Scene() scene.background = new THREE.Color(0x0a1a2f) // 深蓝底,园区夜景常用 // 透视相机:fov 45 接近人眼,near/far 决定可见范围 const { clientWidth: w, clientHeight: h } = container.value camera = new THREE.PerspectiveCamera(45, w / h, 0.1, 2000) camera.position.set(120, 90, 120) // 等距视角,园区全景常用机位 camera.lookAt(0, 0, 0) // 渲染器:开抗锯齿,限制像素比防止高分屏卡顿 renderer = new THREE.WebGLRenderer({ antialias: true }) renderer.setSize(w, h) renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)) container.value.appendChild(renderer.domElement) // 轨道控制器:允许旋转、缩放、平移 controls = new OrbitControls(camera, renderer.domElement) controls.enableDamping = true // 惯性阻尼,手感更顺 controls.maxPolarAngle = Math.PI / 2.2 // 禁止转到地平面以下 // 环境光 + 平行光:没有光,模型就是纯黑 scene.add(new THREE.AmbientLight(0xffffff, 0.6)) const dirLight = new THREE.DirectionalLight(0xffffff, 1.2) dirLight.position.set(80, 150, 60) scene.add(dirLight) // 地面网格:给场景一个空间参照 const grid = new THREE.GridHelper(400, 40, 0x1e5a8a, 0x123a5a) scene.add(grid) animate() } function animate() { animationId = requestAnimationFrame(animate) controls.update() // 阻尼开启后必须每帧调用 renderer.render(scene, camera) } onMounted(initScene) onUnmounted(() => { cancelAnimationFrame(animationId) controls.dispose() renderer.dispose() renderer.forceContextLoss() // 主动释放 WebGL 上下文 }) </script> <template> <div ref="container" class="scene-container"></div> </template> <style scoped> .scene-container { width: 100%; height: 100vh; overflow: hidden; } </style>这段代码里几个参数值得说清楚。PerspectiveCamera的fov设 45 而不是默认 50,是因为园区场景视野大,fov 太广边缘畸变明显;near设 0.1 保证贴近楼栋时不穿模,far设 2000 覆盖整个园区尺度。setPixelRatio用Math.min卡在 2,是因为 4K 屏上像素比到 3 甚至 4,渲染面积翻几倍,帧率直接掉到 20 以下——这是大屏项目最常见的性能翻车点。onUnmounted里三连销毁(cancel、dispose、forceContextLoss)缺一不可,只 cancel 动画不释放上下文,来回切路由十几次浏览器就报“Too many active WebGL contexts”。
3. 园区模型加载与数据绑定:让楼栋和设备活起来
3.1 模型格式选型:glTF 为什么是默认答案
园区三维模型来源通常是建模软件导出,格式选择上我踩过的坑够写一页纸。OBJ 不带材质和层级,导进来一片灰;FBX 体积大、解析慢,浏览器端加载经常卡死;glTF(.glb/.gltf)是 Khronos 定的 Web 三维传输格式,二进制打包、支持 PBR 材质、保留节点层级,Three.js 官方加载器也最完善。这份资源用的就是 glb,单个文件包含几何、材质、贴图,丢进public/models/直接引。
模型本身有个硬要求:楼栋、设备、道路这些需要单独交互的对象,在建模阶段就得拆成独立节点并命名。比如Building_A、Device_001,别合并成一个整体。合并了后期想单独高亮某栋楼,只能靠顶点着色器硬算,得不偿失。
3.2 加载 glb 并建立对象索引
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js' // 存放所有可交互对象的索引表,key 是模型里的节点名 const interactables = new Map() function loadParkModel() { const loader = new GLTFLoader() loader.load('/models/park.glb', (gltf) => { const model = gltf.scene // 遍历所有子节点,把命名对象登记进索引表 model.traverse((child) => { if (child.isMesh) { child.castShadow = true child.receiveShadow = true // 用节点名做 key,方便按业务 ID 查找 if (child.name) interactables.set(child.name, child) } }) // 统一缩放:建模单位可能是厘米,Three.js 默认按米 model.scale.set(0.01, 0.01, 0.01) scene.add(model) }, undefined, (err) => { console.error('模型加载失败', err) }) }traverse是遍历场景图的标准方法,isMesh过滤掉空节点和灯光。索引表用Map而不是数组,因为后面按名字查楼栋是高频操作,Map.get是 O(1)。缩放那行是血泪经验:不同建模软件导出单位不统一,有的按厘米,导进来楼比相机还大,整个场景比例全乱。我一般先看模型包围盒尺寸,再决定缩放系数,而不是无脑0.01。
3.3 把业务数据挂到三维对象上
模型加载完只是空壳,真正让它“智慧”的是数据绑定。园区场景里典型的数据有三类:楼栋的运营指标(入驻率、能耗)、设备的实时状态(在线、告警、离线)、人员的定位坐标。绑定方式我推荐用userData挂载,而不是全局变量。
// 模拟从接口拿到的楼栋数据 const buildingData = [ { id: 'Building_A', name: 'A栋研发楼', occupancy: 0.92, status: 'normal' }, { id: 'Building_B', name: 'B栋厂房', occupancy: 0.68, status: 'warning' }, ] function bindDataToModel(dataList) { dataList.forEach((item) => { const mesh = interactables.get(item.id) if (!mesh) return // 把业务数据挂到 mesh 上,射线拾取时直接取 mesh.userData = { ...item } // 按状态改材质颜色:告警楼栋染红 if (item.status === 'warning') { mesh.material = mesh.material.clone() // 克隆避免影响共享材质 mesh.material.emissive = new THREE.Color(0xff4444) mesh.material.emissiveIntensity = 0.4 } }) }这里有个容易翻车的细节:mesh.material.clone()。glb 里多个楼栋可能共用同一个材质实例,直接改mesh.material.color会导致所有用这个材质的楼栋一起变色。克隆一份再改,只影响当前对象。emissive是自发光属性,配合emissiveIntensity做告警红光,比直接改color更有“亮起来”的视觉冲击,大屏上远看也醒目。
4. 交互与拾取:点击楼栋弹窗、悬停高亮的实现
4.1 射线拾取的基本原理
三维场景里的“点击”和 DOM 不是一回事。屏幕上点一个像素,要反推它对应场景里哪个物体,靠的是射线拾取(Raycaster):从相机位置穿过鼠标点发一条射线,和场景里所有物体求交,返回最近的交点。Three.js 的Raycaster封装了这套计算,但坐标转换必须做对——鼠标坐标是屏幕像素,得归一化到-1到1之间。
const raycaster = new THREE.Raycaster() const pointer = new THREE.Vector2() function onPointerMove(event) { const rect = renderer.domElement.getBoundingClientRect() // 归一化坐标:屏幕像素转 NDC 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 targets = [...interactables.values()] const hits = raycaster.intersectObjects(targets, false) // 先清掉上一帧的高亮 targets.forEach((m) => m.material?.emissive?.set(0x000000)) if (hits.length > 0) { const hit = hits[0].object hit.material.emissive = new THREE.Color(0x2266ff) hit.material.emissiveIntensity = 0.5 renderer.domElement.style.cursor = 'pointer' } else { renderer.domElement.style.cursor = 'default' } }intersectObjects第二个参数recursive设false,因为我们索引表里存的已经是具体 Mesh,不需要再往下递归子节点,设true会多算很多无用层级。高亮前先遍历清空emissive,否则鼠标划过一片楼栋会留下一串“亮过的”痕迹,这是新手最常被 QA 提的 bug。
4.2 点击弹窗与 Vue 状态联动
拾取到对象后,把userData里的业务数据抛给 Vue 层,由 Vue 控制弹窗显示。这样三维逻辑和 UI 逻辑解耦,弹窗样式随便改不影响场景。
const emit = defineEmits(['select-building']) const selectedInfo = ref(null) function onClick(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 hits = raycaster.intersectObjects([...interactables.values()], false) if (hits.length > 0) { const data = hits[0].object.userData selectedInfo.value = data emit('select-building', data) // 通知父组件 } else { selectedInfo.value = null } } // 挂载时绑定,卸载时解绑,防止内存泄漏 onMounted(() => { renderer.domElement.addEventListener('click', onClick) renderer.domElement.addEventListener('pointermove', onPointerMove) }) onUnmounted(() => { renderer.domElement.removeEventListener('click', onClick) renderer.domElement.removeEventListener('pointermove', onPointerMove) })事件监听一定要成对解绑。我见过一个项目,场景组件销毁了但监听还挂在 canvas 上,切了五次页面后每次点击触发五遍弹窗,查了半天才发现是监听没清。pointermove做悬停高亮时,如果场景对象多,建议加节流,比如每 50ms 才做一次射线检测,不然鼠标一动就满帧计算,低端机直接卡成幻灯片。
5. 避坑与排查:五个让场景崩掉的真实问题
5.1 现象:切路由回来场景全黑,控制台报上下文丢失
原因:onUnmounted里只调了renderer.dispose(),没有forceContextLoss()。浏览器对同时活跃的 WebGL 上下文数量有限制(通常 8 到 16 个),旧上下文没释放,新的创建失败。
解决:卸载时按顺序执行cancelAnimationFrame→controls.dispose()→renderer.dispose()→renderer.forceContextLoss(),并把renderer.domElement从 DOM 里移除。四步缺一不可。
5.2 现象:模型加载出来是纯黑,材质贴图全丢
原因:glb 里的贴图路径是相对路径,打包后路径变了;或者渲染器没开色彩空间管理,PBR 材质显示异常。
解决:确认 glb 是自包含的(贴图内嵌),别用外部引用贴图的 gltf。渲染器上设renderer.outputColorSpace = THREE.SRGBColorSpace,Three.js 新版本默认已是 sRGB,但老项目升级上来常忘。另外检查场景里有没有加光源,只有环境光强度太低也会显得黑。
5.3 现象:鼠标悬停高亮错位,点 A 楼亮 B 楼
原因:canvas 尺寸和 CSS 显示尺寸不一致,getBoundingClientRect拿到的宽高和renderer.setSize设的不一样,归一化坐标算错。
解决:监听容器resize,同步更新camera.aspect、camera.updateProjectionMatrix()和renderer.setSize()。别用固定像素值,用容器的clientWidth/clientHeight。
5.4 现象:大屏上帧率只有 15,风扇狂转
原因:setPixelRatio没限制,高分屏上渲染分辨率是逻辑分辨率的两三倍;或者场景里对象太多没做视锥剔除。
解决:setPixelRatio(Math.min(devicePixelRatio, 2))卡上限。对象超过几百个时,考虑用InstancedMesh合并同类设备,或者按相机距离做 LOD(远处用低模)。阴影贴图分辨率也别开太高,2048够用,4096是性能杀手。
5.5 现象:弹窗数据对不上,点楼栋显示的是别的楼
原因:userData被多个对象共享引用,或者模型节点名重复,Map索引时后面的覆盖了前面的。
解决:绑定数据前先console.log检查interactables.size和模型实际节点数是否一致。建模阶段就要求节点名唯一,别出现两个Building_A。userData赋值用展开运算符新建对象,别直接赋同一个引用。
6. 进阶技巧:用 CSS2DRenderer 做不随视角旋转的标签
三维场景里挂文字标签,直接用Sprite或贴图文字的话,相机一转标签就跟着歪,大屏上看着很别扭。园区项目里楼栋名称、设备编号这类标签,业务上要求始终正对屏幕,这时候用CSS2DRenderer最省事——它把 DOM 元素按三维坐标投影到屏幕上,标签本身是普通 HTML,字体清晰、样式随便调,还不受相机旋转影响。
import { CSS2DRenderer, CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer.js' let labelRenderer function initLabelRenderer() { labelRenderer = new CSS2DRenderer() labelRenderer.setSize(container.value.clientWidth, container.value.clientHeight) // 关键:标签层要盖在 canvas 上,且不能拦截鼠标事件 labelRenderer.domElement.style.position = 'absolute' labelRenderer.domElement.style.top = '0' labelRenderer.domElement.style.pointerEvents = 'none' container.value.appendChild(labelRenderer.domElement) } function addBuildingLabel(mesh, text) { const div = document.createElement('div') div.className = 'building-label' div.textContent = text const label = new CSS2DObject(div) // 标签挂在楼栋顶部,y 值取包围盒高度 const box = new THREE.Box3().setFromObject(mesh) label.position.set(0, box.max.y - box.min.y + 5, 0) mesh.add(label) } // 渲染循环里同步渲染标签层 function animate() { animationId = requestAnimationFrame(animate) controls.update() renderer.render(scene, camera) labelRenderer.render(scene, camera) // 别漏这行 }pointerEvents = 'none'是必须的,否则标签层会挡住 canvas,射线拾取全失效,点击楼栋没反应。标签的 CSS 里加transform: translate(-50%, -100%)让文字底部对准坐标点,视觉上更贴楼顶。标签数量多的时候(比如几百个设备编号),别每个都建 DOM,按相机距离做显隐,远处的不渲染,不然 DOM 节点太多浏览器一样卡。
还有个实用技巧:标签内容随数据变。把div的引用存进userData,数据更新时直接改div.textContent,不用重建CSS2DObject。我一般会在bindDataToModel里顺手把标签 DOM 也挂上,数据一变标签跟着变,省得两套逻辑各管各的。
从那以后我每次初始化三维场景,都强制走一遍「销毁四连 + 像素比卡 2 + 标签层 pointerEvents 关掉」这三件事,少一步后面就得花半天排查。希望帮到你。
本文还有配套的精品资源,点击获取