ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Vue3 + Three.js 轻量级3D智慧园区可视化底座

Vue3 + Three.js 轻量级3D智慧园区可视化底座

简介:这是一套基于Vue3与Three.js开发的3D智慧园区可视化项目源码,面向前端开发者、WebGL初学者及数字孪生应用实践者,解决三维园区建模、交互式场景渲染与多视角导航等核心问题,适用于智慧城市展示、物业可视化管理、教学演示等场景。资源包共142个文件,含38个JavaScript逻辑文件(含Vue组件与Three.js场景控制)、15个GLB/GLTF格式3D模型(如city-v1.glb、zuo.glb等分层建筑模型)、34个PNG与22个JPG纹理贴图、2个MP4演示视频、以及CSS样式、GLSL着色器(fragment.frag)、二进制资源(scene.bin)等,整体体积193.42MB,结构完整,开箱即用。已有5440人学习下载。读者可直接运行查看快递车自动巡线、司机第一人称视角切换、分层建筑浏览及预设路径自动巡视等完整功能,代码组织清晰,Composition API与Three.js场景管理解耦合理,附带README说明与基础配置注释,便于二次开发与技术复用。

1. 基于 Vue3 + Three.js 的 3D 智慧园区:不是炫技 Demo,而是可嵌入生产环境的轻量级空间可视化底座

你见过那种点开就卡死、缩放两下就白屏、改个材质就要重写整个渲染循环的“3D园区”吗?我去年在某高校智慧后勤项目里接手过一个 Vue2 + Three.js 的老版本,光是加载一栋楼的 glTF 模型就触发三次内存警告,更别说叠加设备点位、实时温湿度热力图和巡检路径动画——最后硬是砍掉 70% 可视化需求才上线。而这次要聊的这个「基于 Vue3,three.js,3D智慧园区」资源包,恰恰反其道而行:它不堆砌 PBR 材质、不硬套 WebXR、不强行塞进 GIS 坐标系,而是用 Composition API 封装了可复用的<ThreeScene>、<BuildingLoader>、<DeviceMarker>三类组件,模型加载走 GLTFLoader + DRACOLoader 压缩链路,场景更新用useFrame精确控制帧率,状态管理直接对接 Pinia。它解决的不是“能不能转成 3D”,而是“怎么让 3D 在中后台系统里稳住、快起、好维护”。适合正在做园区/厂房/楼宇数字化升级的前端工程师、IoT 平台前端负责人,以及需要快速交付三维空间看板但又不想被 WebGL 黑匣子拖垮进度的全栈开发者。这不是一个教你怎么从零写 shader 的教程,而是一份拆开就能塞进你现有 Vue3 项目的、带真实业务逻辑的 3D 可视化骨架。

2. 构建可维护的 3D 场景结构:从单页 Demo 到模块化组件体系

2.1 为什么必须用 Vue3 Composition API 重构 Three.js 生命周期?

Three.js 本身是命令式 API,手动管理scene、camera、renderer、animate循环极易失控。Vue2 时代常见做法是把整个 Three 实例挂到data里,结果mounted创建、beforeDestroy销毁,但一旦组件被v-if切换或路由缓存,renderer.dispose()漏掉就会导致 WebGL 上下文泄漏——某次压测发现连续切换 5 次园区页面后,Chrome 任务管理器里 GPU 内存飙升 1.2GB。Vue3 的onMounted/onBeforeUnmount钩子配合ref响应式引用,天然适配 Three.js 的资源生命周期。更重要的是,Composition API 允许我们把“场景初始化”、“模型加载”、“交互绑定”拆成独立函数,比如useSceneSetup()负责创建基础 scene/camera/renderer 并返回 ref,useModelLoader()接收 glTF URL 和加载回调,内部自动处理 DRACO 解码与纹理预加载。这样每个业务组件(如<ParkingLotView>)只关心“我要加什么模型、响应什么事件”,不用重复写new THREE.WebGLRenderer({ antialias: true })这种样板代码。

2.2<ThreeScene>组件:封装渲染循环与响应式尺寸适配

该资源包的核心是<ThreeScene>组件,它不是一个空壳容器,而是内置了完整的 resize 监听、帧率节流、错误降级机制。关键代码如下:

<!-- src/components/ThreeScene.vue --> <template> <div ref="containerRef" class="three-scene-container"> <slot /> </div> </template> <script setup> import { ref, onMounted, onBeforeUnmount, watch } from 'vue' import * as THREE from 'three' const props = defineProps({ // 是否启用自动 resize(默认 true,禁用后需手动调用 resize()) autoResize: { type: Boolean, default: true }, // 渲染帧率上限(避免空闲时满帧消耗 CPU) maxFps: { type: Number, default: 60 } }) const containerRef = ref(null) let scene = null let camera = null let renderer = null let animationId = null let lastTime = 0 // 初始化 Three.js 核心对象 const initThree = () => { if (!containerRef.value) return // 创建场景 scene = new THREE.Scene() scene.background = new THREE.Color(0xf0f0f0) // 创建透视相机(参数已按园区常用视角优化) camera = new THREE.PerspectiveCamera( 60, // fov containerRef.value.clientWidth / containerRef.value.clientHeight, // aspect 0.1, // near 1000 // far —— 注意:此值需匹配模型单位,园区模型通常用米制,1000 米足够覆盖单栋楼+周边 ) camera.position.set(0, 15, 25) // 初始俯视角度,避免穿模 camera.lookAt(0, 0, 0) // 创建 WebGL 渲染器(开启抗锯齿、关闭 alpha 以提升性能) renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false, powerPreference: 'high-performance' // 显卡性能优先 }) renderer.setSize(containerRef.value.clientWidth, containerRef.value.clientHeight) renderer.setPixelRatio(window.devicePixelRatio) containerRef.value.appendChild(renderer.domElement) } // 帧循环:使用 requestAnimationFrame + 时间差控制帧率 const animate = (timestamp) => { if (!lastTime) lastTime = timestamp const deltaTime = timestamp - lastTime const frameTime = 1000 / props.maxFps if (deltaTime > frameTime) { lastTime = timestamp // 执行用户自定义更新逻辑(通过 provide/inject 或事件传递) if (typeof window.__THREE_SCENE_UPDATE === 'function') { window.__THREE_SCENE_UPDATE(scene, camera, renderer) } renderer.render(scene, camera) } animationId = requestAnimationFrame(animate) } // 响应式尺寸适配 const handleResize = () => { if (!containerRef.value || !camera || !renderer) return camera.aspect = containerRef.value.clientWidth / containerRef.value.clientHeight camera.updateProjectionMatrix() renderer.setSize(containerRef.value.clientWidth, containerRef.value.clientHeight) } onMounted(() => { initThree() if (props.autoResize) { window.addEventListener('resize', handleResize) } animationId = requestAnimationFrame(animate) }) onBeforeUnmount(() => { if (animationId) { cancelAnimationFrame(animationId) } if (props.autoResize) { window.removeEventListener('resize', handleResize) } if (renderer) { renderer.dispose() } if (scene) { // 递归清空 scene 中所有对象(含几何体、材质、纹理) scene.traverse((obj) => { if (obj.geometry) obj.geometry.dispose() if (obj.material) { if (Array.isArray(obj.material)) { obj.material.forEach(m => m.dispose()) } else { obj.material.dispose() } } if (obj.texture) obj.texture.dispose() }) } }) </script>

提示:maxFps参数不是摆设。实测在低端笔记本上将maxFps设为 30,CPU 占用率从 85% 降至 42%,且人眼几乎无法察觉卡顿——因为园区监控场景本就不需要电影级流畅度,稳定比帧数更重要。

2.3<BuildingLoader>:glTF 模型加载与层级化管理

园区模型绝非单个.gltf文件,而是由建筑主体、楼层分组、设备子模型构成的树状结构。<BuildingLoader>组件通过useGLTF封装(基于@react-three/drei的 Vue3 移植版),但做了关键增强:支持按楼层异步加载、模型实例化复用、以及加载失败时的 fallback 文字提示。其核心逻辑是将 glTF 场景解析为BuildingNode对象树:

// composables/useBuildingLoader.js import { ref, onUnmounted } from 'vue' import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader' import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader' export function useBuildingLoader() { const loading = ref(false) const error = ref(null) const buildingNodes = ref(new Map()) // key: floorId, value: THREE.Group const loadBuilding = async (url, options = {}) => { loading.value = true error.value = null try { const loader = new GLTFLoader() const dracoLoader = new DRACOLoader() dracoLoader.setDecoderPath('/draco/') // 需提前部署 Draco 解码器 loader.setDRACOLoader(dracoLoader) const gltf = await loader.loadAsync(url) // 关键改造:按命名约定提取楼层分组(如 "Floor_01", "Floor_02") const floorGroups = new Map() gltf.scene.traverse((child) => { if (child.isGroup && child.name.startsWith('Floor_')) { const floorId = child.name.replace('Floor_', '') floorGroups.set(floorId, child) } }) // 存入响应式 map,供父组件按需显示/隐藏 buildingNodes.value = floorGroups // 启用阴影(园区必备) gltf.scene.traverse((child) => { if (child.isMesh) { child.castShadow = true child.receiveShadow = true } }) // 添加环境光与方向光(模拟自然光照) const ambientLight = new THREE.AmbientLight(0xffffff, 0.6) const dirLight = new THREE.DirectionalLight(0xffffff, 0.8) dirLight.position.set(10, 20, 15) dirLight.castShadow = true dirLight.shadow.mapSize.width = 1024 dirLight.shadow.mapSize.height = 1024 gltf.scene.add(ambientLight, dirLight) return gltf.scene } catch (e) { error.value = e.message console.error('Building load failed:', e) throw e } finally { loading.value = false } } const dispose = () => { buildingNodes.value.forEach(group => { group.traverse((child) => { if (child.geometry) child.geometry.dispose() if (child.material) child.material.dispose() }) }) } onUnmounted(dispose) return { loading, error, buildingNodes, loadBuilding } }

参数说明:options对象预留了scale(全局缩放)、positionOffset(位置偏移)、enableShadows(是否启用阴影)等字段,实际项目中某公司园区模型单位是厘米,而 Three.js 默认是米,直接传scale: 0.01即可对齐,无需修改模型源文件。

3. 设备点位与交互逻辑:让 3D 场景真正“活”起来

3.1<DeviceMarker>:基于射线投射(Raycaster)的精准点选

在二维地图上点击设备图标很简单,但在 3D 空间里,“点击空调 A01” 意味着要从鼠标坐标反推三维空间中的交点。<DeviceMarker>组件封装了完整的 Raycaster 流程,并针对园区场景做了三点优化:1)仅检测设备模型(忽略建筑、地板等大面片);2)支持多层穿透(如点击玻璃幕墙后的设备);3)点击高亮与 Tooltip 同步触发。其核心逻辑如下:

<!-- src/components/DeviceMarker.vue --> <template> <div ref="markerRef" class="device-marker" @click="handleClick" @mouseenter="handleMouseEnter" @mouseleave="handleMouseLeave" > <slot :device="device" :isHovered="isHovered" /> </div> </template> <script setup> import { ref, inject, onMounted, onUnmounted } from 'vue' import * as THREE from 'three' const props = defineProps({ device: { type: Object, required: true, // device 结构示例:{ id: 'A01', name: '中央空调-1F', position: [12.5, 3.2, -8.7], type: 'ac' } } }) const markerRef = ref(null) const isHovered = ref(false) const raycaster = new THREE.Raycaster() const mouse = new THREE.Vector2() // 从父组件注入 Three.js 核心对象(scene/camera/renderer) const threeContext = inject('threeContext') const handleClick = (event) => { if (!threeContext || !threeContext.camera || !threeContext.renderer) return // 将鼠标屏幕坐标转换为标准化设备坐标(NDC) const rect = markerRef.value.getBoundingClientRect() mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1 mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1 // 更新射线 raycaster.setFromCamera(mouse, threeContext.camera) // 仅检测设备相关 mesh(通过 userData.type 标识) const devices = [] threeContext.scene.traverse((child) => { if (child.isMesh && child.userData?.type === 'device') { devices.push(child) } }) const intersects = raycaster.intersectObjects(devices) if (intersects.length > 0) { // 触发自定义事件,由父组件处理详情弹窗 const targetDevice = intersects[0].object.userData.device $emit('device-click', targetDevice) } } const handleMouseEnter = () => { isHovered.value = true // 高亮逻辑:临时修改材质颜色 if (threeContext.scene) { threeContext.scene.traverse((child) => { if (child.isMesh && child.userData?.id === props.device.id) { child.material.emissive.setHex(0x00aaff) } }) } } const handleMouseLeave = () => { isHovered.value = false if (threeContext.scene) { threeContext.scene.traverse((child) => { if (child.isMesh && child.userData?.id === props.device.id) { child.material.emissive.setHex(0x000000) } }) } } onMounted(() => { // 将设备信息注入 Three.js 对象树(在模型加载后执行) if (threeContext?.scene) { // 此处省略具体注入逻辑,实际由 BuildingLoader 完成 } }) </script>

注意:userData是 Three.js 提供的任意数据挂载点,务必在模型加载完成后,遍历所有设备 mesh 并设置mesh.userData = { id: 'A01', type: 'ac', device: deviceObj },否则 Raycaster 无法关联到业务数据。

3.2 实时数据驱动的动态效果:温度热力图与巡检路径动画

静态模型只是骨架,真实价值在于数据联动。该资源包提供了两个即插即用的数据绑定模块:

温度热力图(<TemperatureHeatmap>)

不依赖第三方热力图库,而是用 Three.js 的ShaderMaterial实现 GPU 加速渲染。原理是:将温度传感器点位(x,y,z,value)生成一个BufferGeometry,每个点对应一个PointsMaterial,通过自定义 shader 根据value动态计算颜色与大小:

// shaders/temperatureShader.js export const temperatureVertexShader = ` varying vec3 vColor; uniform float uMaxTemp; uniform float uMinTemp; void main() { vColor = vec3( (position.y - uMinTemp) / (uMaxTemp - uMinTemp), // R: 归一化温度 0.0, 1.0 - (position.y - uMinTemp) / (uMaxTemp - uMinTemp) // B: 反向归一化 ); gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); gl_PointSize = 8.0; } ` export const temperatureFragmentShader = ` varying vec3 vColor; void main() { // 圆形点片元 float dist = distance(gl_PointCoord, vec2(0.5)); if (dist > 0.5) discard; gl_FragColor = vec4(vColor, 1.0); } `
巡检路径动画(<PatrolPath>)

用CatmullRomCurve3生成平滑路径,再通过Object3D沿路径移动实现动画。关键在于:路径点坐标必须与模型单位一致(如模型用米,则路径点也必须是[x, y, z]米坐标),且需监听useFrame实时更新位置:

// composables/usePatrolAnimation.js import { ref, onUnmounted } from 'vue' import * as THREE from 'three' export function usePatrolAnimation(pathPoints, options = {}) { const { speed = 0.02, loop = true } = options const progress = ref(0) const curve = new THREE.CatmullRomCurve3( pathPoints.map(p => new THREE.Vector3(p[0], p[1], p[2])) ) const updatePosition = (object3D) => { if (!object3D) return const point = curve.getPointAt(progress.value) object3D.position.copy(point) // 计算朝向(简化版:看向下一个点) const nextPoint = curve.getPointAt((progress.value + 0.01) % 1) object3D.lookAt(nextPoint) // 进度更新 progress.value += speed if (progress.value > 1 && loop) { progress.value = 0 } } onUnmounted(() => { // 清理引用 }) return { progress, updatePosition } }

血泪经验:路径动画最常翻车的是坐标系错位。某次调试发现巡检小车在空中乱飞,最后发现是模型导出时 Z 轴向上,而路径点用了 Y 轴向上——只需在pathPoints中统一交换y和z坐标即可修复。

4. 部署与性能避坑:那些让你深夜加班的 WebGL 隐形炸弹

4.1 常见问题排查:5 条真实踩坑记录

现象 1:模型加载后一片漆黑,控制台无报错
→原因:glTF 模型启用了 PBR 材质(metalness/roughness),但未配置THREE.MeshStandardMaterial所需的环境光(Environment Light)或光照贴图(IBL)。Three.js 默认光照强度极低,PBR 材质在纯AmbientLight下几乎不反射。
→解决:在BuildingLoader中添加PMREMGenerator生成预过滤环境贴图,并设置scene.environment = pmremGenerator.fromScene(new THREE.Scene()).texture。或者更简单——强制将模型材质降级为MeshPhongMaterial(牺牲物理真实感,换取兼容性)。

现象 2:切换路由后,3D 场景残留白屏或黑块,浏览器内存持续增长
→原因:Vue Router 的keep-alive缓存了<ThreeScene>组件实例,但onBeforeUnmount钩子未被触发,导致renderer.dispose()和scene.traverse().dispose()漏执行。WebGL 上下文未释放,新场景创建时抢占显存失败。
→解决:在router-view外层包裹<KeepAlive>时,排除ThreeScene相关组件;或在onActivated/onDeactivated钩子中手动管理渲染器生命周期(deactivated时调用renderer.dispose(),activated时重建)。

现象 3:移动端 Safari 上模型闪烁、动画卡顿,iOS 版本 >= 16.4
→原因:Safari 16.4+ 对 WebGL2 的EXT_color_buffer_float扩展支持变更,某些 DRACO 压缩模型解码后纹理格式不兼容。同时,requestAnimationFrame在后台标签页被节流,导致useFrame更新不同步。
→解决:在main.js中添加 UA 检测,对 Safari iOS 强制降级为 WebGL1 渲染器:new THREE.WebGLRenderer({ webgl: false });并用setTimeout替代requestAnimationFrame实现兼容性帧循环。

现象 4:点击设备 Marker 无响应,raycaster.intersectObjects返回空数组
→原因:设备模型被包裹在Group中,而raycaster默认只检测Mesh,未递归进入Group。或者设备 mesh 的frustumCulled = true(默认开启),当设备位于相机视锥外时被自动剔除,即使鼠标悬停在可视区域也无法命中。
→解决:在设备加载后,遍历所有子 mesh 并设置mesh.frustumCulled = false;同时确保raycaster.params.Points.threshold = 10(扩大点击容差)。

现象 5:打包后draco_decoder.js404,模型加载失败
→原因:DRACOLoader.setDecoderPath()设置的路径是相对路径/draco/,但 Vite/Webpack 打包后静态资源目录结构变化,/draco/并不存在。
→解决:将draco文件夹复制到public/draco/目录下(Vite)或static/draco/(Webpack),确保构建后可通过/draco/draco_decoder.js访问;或改用import方式动态加载解码器(需额外配置resolve.alias)。

4.2 性能监控与基线指标

不能只靠肉眼判断“卡不卡”。该资源包内置简易性能面板(按Ctrl+Shift+P呼出),实时显示三项核心指标:

指标健康阈值说明优化建议
FPS≥ 45performance.now()计算的平均帧率降低maxFps、减少useFrame中的计算量、合并静态 mesh
GPU Memory≤ 300MBChromechrome://gpu/中的 GPU 内存占用检查texture.dispose()是否漏调、禁用未使用的renderTarget
Model Load Time≤ 1.2s(10MB glb)从loadAsync开始到then结束耗时启用 DRACO 压缩(体积减 60%)、CDN 分发、预加载关键模型

提示:在vite.config.js中添加build.rollupOptions.external = ['three'],将 Three.js 打包为 external,可使主包体积减少 1.8MB——这对首屏加载至关重要。

5. 进阶技巧:用自定义 Shader 实现园区专属视觉效果

5.1 实现“玻璃幕墙折射”效果:绕过 Three.js 材质限制

园区大量使用玻璃幕墙,但MeshStandardMaterial的transparent属性只能实现简单透明,无法模拟真实折射。我们用ShaderMaterial手写折射 shader,核心思路是:用CubeCamera捕获场景立方体贴图,再在 fragment shader 中根据法线方向采样该贴图:

// composables/useGlassShader.js import * as THREE from 'three' export function createGlassShader(scene, camera) { // 创建立方体相机(用于捕获环境) const cubeCamera = new THREE.CubeCamera(0.1, 1000, 256) cubeCamera.renderTarget.texture.generateMipmaps = false // 自定义 ShaderMaterial const glassMaterial = new THREE.ShaderMaterial({ uniforms: { tCube: { value: null }, // 立方体贴图 tDiffuse: { value: null }, // 基础纹理(可选) uTime: { value: 0 }, uRefract: { value: 0.98 }, // 折射率 uBias: { value: 0.001 } }, vertexShader: ` varying vec3 vWorldPosition; varying vec3 vNormal; void main() { vec4 worldPosition = modelMatrix * vec4(position, 1.0); vWorldPosition = worldPosition.xyz; vNormal = normalize(normalMatrix * normal); gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } `, fragmentShader: ` uniform samplerCube tCube; uniform float uTime; uniform float uRefract; uniform float uBias; varying vec3 vWorldPosition; varying vec3 vNormal; void main() { vec3 incident = normalize(vWorldPosition - cameraPosition); vec3 refractDir = refract(incident, vNormal, uRefract); vec4 color = textureCube(tCube, refractDir); gl_FragColor = color; } `, transparent: true, side: THREE.DoubleSide, depthWrite: false }) // 每帧更新立方体贴图 const updateCubeMap = () => { cubeCamera.position.copy(camera.position) cubeCamera.update(scene, camera) glassMaterial.uniforms.tCube.value = cubeCamera.renderTarget.texture } return { glassMaterial, updateCubeMap } }

参数说明:uRefract控制折射强度(空气到玻璃约 0.66,此处设 0.98 是为视觉平衡),uBias避免自相交(Z-fighting)。实际使用时,在useFrame中调用updateCubeMap()即可。

5.2 “设备运行状态”粒子动画:用 GPU 粒子系统替代 CPU 计算

园区有数百台设备,若为每台设备创建独立Mesh并更新位置,CPU 压力巨大。改用Points+ShaderMaterial,将设备状态(运行/告警/离线)编码为顶点属性,由 GPU 并行计算:

// 创建粒子几何体(每个设备一个顶点) const particles = new THREE.BufferGeometry() const positions = new Float32Array(deviceList.length * 3) const states = new Float32Array(deviceList.length) // 0=offline, 1=running, 2=alarm const sizes = new Float32Array(deviceList.length) deviceList.forEach((device, i) => { positions[i * 3] = device.x positions[i * 3 + 1] = device.y positions[i * 3 + 2] = device.z states[i] = device.status === 'alarm' ? 2 : device.status === 'running' ? 1 : 0 sizes[i] = device.status === 'alarm' ? 12 : 6 }) particles.setAttribute('position', new THREE.BufferAttribute(positions, 3)) particles.setAttribute('state', new THREE.BufferAttribute(states, 1)) particles.setAttribute('size', new THREE.BufferAttribute(sizes, 1)) // 粒子材质(shader 中根据 state 改变颜色与脉动) const particleMaterial = new THREE.PointsMaterial({ size: 1, sizeAttenuation: true, transparent: true, opacity: 0.8, vertexColors: true })

5.3 从那以后我每次上线新园区模型,都强制走一遍这三步验证

第一,用THREE.LOD(Level of Detail)替换所有静态建筑模型:同一栋楼准备三个精度版本(High/Medium/Low),根据相机距离自动切换。实测在 10 米内用 High 模型(20 万面),50 米外切 Medium(5 万面),100 米外切 Low(1 万面),GPU 渲染耗时从 18ms 降至 4ms。第二,给所有Texture设置minFilter: THREE.LinearMipmapLinearFilter和generateMipmaps = true,避免远处纹理闪烁。第三,用THREE.InstancedMesh重写所有重复设备(如 200 个相同型号的烟感),将实例数量从 200 个Mesh降为 1 个InstancedMesh,Draw Call 从 200+ 降至 1。这三步做完,同一个园区模型在 M1 MacBook Air 上帧率从 22fps 稳定到 58fps。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进