简介:面向Vue前端开发者与Web3D可视化入门者,这是一份基于ThreeJs和Vue2.0构建的3D粮仓管理系统源码,演示了三维可视化在仓储管理场景中的落地方式。项目以Vue-Element-Admin为管理端骨架,将ThreeJs场景渲染接入Vue组件生命周期,包含粮仓三维场景搭建、视角切换、基础交互和数据面板联动等典型模块,适合学习前端3D可视化和中后台系统结合的实现路径。压缩包共234个文件,其中以84个js脚本文件、73个vue组件文件、52个svg图标文件和8个scss样式文件为主,另外还有json配置文件、hbs模板与yml部署配置等,整体包仅377KB,结构轻量、便于导入开发工具逐行阅读。已有4489人浏览学习,说明项目具有一定的参考热度。通过这份源码,可以掌握Vue2.0项目目录划分、ThreeJs场景初始化与动画循环逻辑,以及Element UI组件与三维场景状态同步的常用写法,可作为自建3D可视化后台的起步模板。
1. 从“看数据”到“看粮仓”:为什么 Vue2.0 + Three.js 是这套系统的稳妥选择
智能粮库项目做到中期,业务方往往不再满足于二维图表和表格,他们想在办公室直接看到每一栋粮仓的实时状态:仓里的粮面在哪里、内环流风机是否在转、最近哪个仓的粮温异常。传统 Web 管理端做这件事很别扭,而 3D 粮仓管理系统直接把监控对象“建模”到浏览器里,让位置关系、设备状态和告警位置一目了然。Vue2.0 负责业务框架、权限菜单、图表联动和信息展示,Three.js 负责 WebGL 场景、模型加载、射线拾取和动态标记。
这套组合的核心价值不是“3D 很酷”,而是把“仓、粮、设备、数据”四种对象统一到一个场景坐标里。业务人员点击仓体就能看到储量、温湿度和最近告警,运维人员能按设备分组管理模型节点,监管大屏可以自动漫游巡查。对后端接口比较规范的团队来说,这套系统能直接用既有数据服务,不需要建设独立 3D 数据库。适合存量 Vue2 项目升级可视化能力的技术团队,也适合从零立项但团队对 Vue2 更熟、不希望引入过重组件的场景。
2. Vue2.0 环境下的 Three.js 场景搭建:从依赖安装到第一个最小可运行工程
很多人把 Three.js 想复杂了,其实它就是一套基于 WebGL 的 3D 渲染封装库,在 Vue 组件里维护好场景、相机、渲染器三个核心对象,再通过 requestAnimationFrame 驱动渲染循环即可。硬骨头在于“生命周期”和“资源清理”,如果只写一个静态页面不考虑组件销毁,内存泄漏、canvas 残留、事件重复绑定都会陆续暴露。
2.1 为什么兼容 Vue2.0 反而更容易控制场景生命周期
Vue2.0 的组件生命周期是固定的一套:created、mounted、beforeDestroy、destroyed。Three.js 场景比较适合放在 mounted 里初始化,这时 DOM 已挂载、容器尺寸可用;destroyed 前执行 renderer.dispose、geometry.dispose、material.dispose,能避免切换路由或关掉大屏后 GPU 内存无谓占用。Vue2 的 data 响应式对 Three.js 对象是有额外开销的,经验做法是:不要把 scene、camera、renderer 放进 data 的响应式对象里,而是挂到组件实例的普通属性上,例如this.scene,这样既能避免 Vue 递归劫持,又能在任何方法里直接访问。
很多从 Vue3 + setup 转过来的开发者会习惯性把所有变量放进 ref,这在 Vue2 里反而会拖慢数据读取。Three.js 内部的向量计算、材质颜色变更本身就是直接操作对象属性,用响应式去包裹毫无必要。
2.2 Three.js 核心对象与职责,先理清再编码
写代码前,先记住一组对应关系:场景是容器,相机是观察者,渲染器是输出设备,灯光决定可见性,网格由几何体和材质组成,控制器负责视角交互。
| 对象 | 职责 | 常见配置项 | 易错点 |
|---|---|---|---|
| Scene | 统一承载模型、灯光、辅助对象 | 可设置 background、fog | 忘记加雾导致远处模型太亮 |
| PerspectiveCamera | 模拟人眼透视视角 | fov、aspect、near、far | near/far 差距过大产生深度冲突 |
| WebGLRenderer | 将场景渲染到 canvas | antialias、pixelRatio | 未按设备像素比适配导致模糊 |
| OrbitControls | 旋转/缩放/平移视角 | enableDamping、minDistance | 未调用 update 导致镜头不跟手 |
| AmbientLight | 提供基础照明 | intensity | 单纯环境光模型材质容易发灰 |
| DirectionalLight | 模拟太阳光,产生明暗关系 | position、intensity | 方向未设置好出现整片死黑 |
一个常见误区是上来就加载模型,结果模型又黑又小。不管项目多紧急,先拿一个 BoxGeometry 和灯光测通场景,再换业务模型,排查问题会快得多。
2.3 创建最小可运行场景的 Vue 组件代码
先安装依赖:
npm install three@0.160.0注意:如果你打算用 Three.js 自带的 GLTFLoader、OrbitControls,不要额外安装其他 loader 包,直接从three/examples/jsm/里导入即可。版本的坑在 0.150.0 前后的导出路径差异,最好用 0.150.0 以上版本。
下面是组件的完整初始化逻辑,包含灯光、网格辅助、控制器和销毁清理:
<template> <div ref="sceneContainer" class="scene-container"></div> </template> <script> import * as THREE from 'three' import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js' export default { name: 'SceneContainer', data() { return { animationId: null } }, mounted() { this.init() this.animate() }, methods: { init() { const el = this.$refs.sceneContainer this.scene = new THREE.Scene() this.scene.background = new THREE.Color(0x222b38) this.camera = new THREE.PerspectiveCamera(45, el.clientWidth / el.clientHeight, 0.1, 2000) this.camera.position.set(80, 60, 100) this.camera.lookAt(0, 0, 0) this.renderer = new THREE.WebGLRenderer({ antialias: true }) this.renderer.setSize(el.clientWidth, el.clientHeight) this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)) el.appendChild(this.renderer.domElement) this.controls = new OrbitControls(this.camera, this.renderer.domElement) this.controls.enableDamping = true this.controls.minDistance = 10 this.controls.maxDistance = 500 this.scene.add(new THREE.AmbientLight(0xffffff, 0.6)) const dirLight = new THREE.DirectionalLight(0xffffff, 0.9) dirLight.position.set(40, 80, 30) this.scene.add(dirLight) const grid = new THREE.GridHelper(120, 12, 0x999999, 0x555555) this.scene.add(grid) }, animate() { this.animationId = requestAnimationFrame(this.animate) this.controls.update() this.renderer.render(this.scene, this.camera) } }, beforeDestroy() { cancelAnimationFrame(this.animationId) this.renderer.dispose() this.scene.traverse((obj) => { if (obj.geometry) obj.geometry.dispose() if (obj.material) { const mats = Array.isArray(obj.material) ? obj.material : [obj.material] mats.forEach((mat) => mat.dispose()) } }) this.$refs.sceneContainer.removeChild(this.renderer.domElement) } } </script> <style scoped> .scene-container { width: 100%; height: 720px; position: relative; } </style>这段代码的关键点:
el.clientWidth必须在 mounted 之后读取,否则拿到 0。renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))同时兼顾清晰度和大屏 4K 显示的性能压力。controls.enableDamping为 true 时,必须在animate()里每帧调用controls.update(),否则阻尼不生效。- 销毁时遍历场景所有对象,释放几何体、材质,再移除 canvas 节点。漏掉
material.dispose()是纹理内存泄漏的最常见原因。
3. 粮仓场景建模与交互:GLTF 模型加载、坐标对齐、射线拾取仓体
有了空场景,接下来考虑“粮仓长什么样”。手写 BufferGeometry 做一栋标准化平房仓还勉强可以,但要做出仓顶、爬梯、通风口、输送带这些细节,效率太低。实际项目中模型大多由建模工程师用 Blender 或 3ds Max 制作,导出成 GLB/GLTF 格式,前端只负责加载、摆放和拾取。
3.1 模型格式选型:优先 GLB,杂质越少越好
粮仓场景通常包含几十个设备和建筑组,建议把静态建筑、仓体、设备分开导出,不要一把梭导成一个文件。按功能拆分的好处是前端可以按需加载、隐藏或高亮某个设备组。格式上用二进制 GLB,体积比 glTF 小,加载更快。
| 场景内容 | 模型形态 | 导出建议 |
|---|---|---|
| 仓体建筑 | 白模,区分仓号 | 按栋导出,每个仓体挂 userData.warehouseId |
| 通风/粮情设备 | 彩色设备模型 | 独立分组,用于启停状态指示 |
| 输送带/提升机 | 可拆分为静态部分和转动部分 | 转动部分单独命名,便于动画控制 |
| 场地环境 | 地面、围墙、树木 | 可合并为低精度模型,降低顶点数 |
模型内部命名比前端代码更重要。约定好仓1_墙壁、仓1_仓顶这类规则,前端加载后就能按名称直接找节点,而不是用递归遍历去猜。
3.2 GLTFLoader 加载与模型居中
模型加载后需要做两件事:算包围盒、把模型中心移动到原点附近。否则模型可能出现在“地图外”,还得人工拖动相机找半天。
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js' loadModel(url, options = {}) { return new Promise((resolve, reject) => { const loader = new GLTFLoader() loader.load( url, (gltf) => { const model = gltf.scene // 计算包围盒,让模型底部贴合地面 const box = new THREE.Box3().setFromObject(model) const center = box.getCenter(new THREE.Vector3()) const size = box.getSize(new THREE.Vector3()) model.position.x -= center.x model.position.z -= center.z model.position.y -= box.min.y // 底部对齐 y=0 if (options.scale) { model.scale.set(options.scale, options.scale, options.scale) } if (options.name) { model.name = options.name } this.scene.add(model) resolve(model) }, (xhr) => { const percent = (xhr.loaded / xhr.total) * 100 this.$emit('load-progress', { url, percent }) }, (error) => { console.error('模型加载失败', url, error) reject(error) } ) }) }调用时:
async loadAllModels() { await this.loadModel('/models/granary.glb', { name: 'granary', scale: 1 }) await this.loadModel('/models/equipment.glb', { name: 'equipment', scale: 1 }) }模型加载最重要的一段是model.position.y -= box.min.y。很多建模师习惯把模型原点设定在地面上,但也有人直接从坐标零点开始建模,导致模型悬空。用包围盒最低点做基准,统一所有模型的地面高度,后续摆放设备组省去大量手动调坐标的时间。
3.3 点击仓体获取仓号:Raycaster 射线拾取
业务场景中,用户点击某个仓体后要弹出储量、温度曲线和最近告警。Three.js 的 Raycaster 把鼠标屏幕坐标转换成三维空间射线,检测射线与模型的交汇点,再回溯到设置了 userData 的节点上。
onSceneClick(event) { const el = this.$refs.sceneContainer const rect = el.getBoundingClientRect() const pointer = new THREE.Vector2() pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1 pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1 const raycaster = new THREE.Raycaster() raycaster.setFromCamera(pointer, this.camera) const targets = this.warehouseGroup.children.filter( (child) => child.userData && child.userData.warehouseId ) const intersections = raycaster.intersectObjects(targets, true) if (intersections.length === 0) { this.$emit('deselect-warehouse') return } // 从命中点向上查找带 warehouseId 的父节点 let node = intersections[0].object while (node && !node.userData.warehouseId) { node = node.parent } if (node) { this.$emit('select-warehouse', node.userData.warehouseId) } }用intersectObjects(targets, true)代替intersectObjects(this.scene.children, true)有三个好处:
- 过滤掉地面、辅助物体和无关建筑,命中判断更快。
- 避免射线穿透墙体和设备时第一个命中的却是地面模型。
- 递归
true可以点中仓墙的某个子网格,然后向上回溯到父级仓体节点,不需要每个子网格都设置 userData。
命中后不建议直接把 mark 弹窗做在 Three.js 场景里,而是向 Vue 抛事件,由 Vue 组件决定是否弹出抽屉、高亮表格行。这样业务逻辑与渲染逻辑分层更清晰。
4. 数据驱动场景:Vuex 状态管理、WebSocket 实时推送、温湿度与告警联动
3D 场景做得再漂亮,没有真实数据就是一张皮。粮仓管理系统的核心数据通常来自粮情检测系统、环流风机控制柜,以及出库入库存量数据。大多数项目的数据链路是:硬件采集器(RS485/Modbus)传到边缘网关,网关通过 HTTP 或 MQTT 把数据写入后端服务,前端再通过 WebSocket 订阅实时数据。
4.1 设备与仓体数据字段设计
后端返回给前端的每个仓体数据大致如下:
| 字段 | 类型 | 含义 | 3D 表现 |
|---|---|---|---|
| warehouseId | String | 仓号,与模型 userData 对应 | 拾取定位 |
| grainType | String | 粮食品种 | 顶部标签 |
| grainHeight | Number | 粮面高度(米) | 粮面 mesh 的 y 坐标 |
| storage | Number | 库存重量(吨) | 悬浮面板 |
| avgTemp | Number | 平均粮温(℃) | 仓体颜色阈值范围 |
| avgHumidity | Number | 平均湿度(%RH) | 动态标签 |
| fanStatus | Number | 风机启停状态 | 设备模型旋转/发光 |
| alertLevel | Number | 告警级别 0/1/2 | 仓体红色描边或闪烁 |
接口约定尽量把warehouseId变成字符串,不要用数字,因为很多粮库的仓号可能是“0312-1”这样的复合编号,数字一旦带前导零就会被 JS 吞掉。
4.2 Vuex 中管理粮仓状态
Vuex 不是必须的,但一个 3D 粮仓系统通常同时存在“列表页选中仓”“3D 场景选中仓”“图表弹窗展示仓”,多个组件要读同一份选中状态和实时数据时,Vuex 能把状态提升到全局,避免组件间事件横飞。
// store/modules/granary.js const state = { warehouseList: [], selectedWarehouseId: '', realtimeDataMap: {} // warehouseId -> 最新数据对象 } const mutations = { setWarehouseList(state, list) { state.warehouseList = list }, setSelectedWarehouse(state, id) { state.selectedWarehouseId = id }, updateRealtimeData(state, payload) { const { warehouseId, data } = payload // 使用 Vue.set 或整体赋值,保证新属性被响应式捕获 state.realtimeDataMap = { ...state.realtimeDataMap, [warehouseId]: data } } } const actions = { bindSocket({ commit, dispatch }) { this.dispatch('socket/connect', { url: 'wss://your-gateway/realtime', onMessage: (msg) => { // 消息格式按项目约定,可能是单仓推送,也可能是全量 if (msg.type === 'single') { commit('updateRealtimeData', { warehouseId: msg.warehouseId, data: msg.payload }) } } }) } } export default { namespaced: true, state, mutations, actions }注意realtimeDataMap的更新没有用Vue.set修改已有对象的内部属性,而是整体替换对象引用来触发视图更新,这个技巧能避免深层响应式监听带来的性能损耗。
4.3 WebSocket 封装与心跳维持
实时数据推送最让人头疼的问题不是解析消息,而是连接断开后的重连。网络抖动、网关重启、移动网络切换都会导致长连接断开,而且断开时前端往往不会立即感知。
// utils/socket.js export function createSocket({ url, onMessage, onConnect, onDisconnect }) { let socket = null let heartbeatTimer = null let reconnectTimer = null let manualClose = false function connect() { manualClose = false socket = new WebSocket(url) socket.onopen = () => { onConnect && onConnect() // 30秒发一次心跳,避免网关空闲断开 heartbeatTimer = setInterval(() => { if (socket && socket.readyState === WebSocket.OPEN) { socket.send(JSON.stringify({ type: 'ping' })) } }, 30000) } socket.onmessage = (event) => { try { const data = JSON.parse(event.data) if (data.type === 'pong') return onMessage && onMessage(data) } catch (e) { console.warn('invalid websocket message', e) } } socket.onclose = () => { onDisconnect && onDisconnect() clearInterval(heartbeatTimer) if (!manualClose) { reconnectTimer = setTimeout(connect, 5000) } } socket.onerror = (error) => { console.error('websocket error', error) } } function close() { manualClose = true clearInterval(heartbeatTimer) clearTimeout(reconnectTimer) if (socket) socket.close() } return { connect, close } }心跳用 30 秒一次、5 秒重连重试。这个参数不是随便写的,粮库现场的网络设备通常配置了 TCP 空闲超时,大部分设备默认 60 秒到 90 秒踢掉空闲连接,30 秒心跳能保证安全区间。重连间隔 5 秒太频繁容易把网关打爆,如果现场网关连接数有限,建议改成长间隔指数退避。
4.4 数据映射到场景:粮面升降、风机旋转与告警高亮
数据到场景的更新可以抽象成一个updateWarehouseView(data)函数,根据warehouseId找到场景中对应的模型节点,修改它的位置、角度、材质。这里有一个重要的坑:Three.js 网格材质不能在每帧里创建新材质,而要复用材质实例,否则 GPU 内存会被撑爆。
updateWarehouseView(data) { const group = this.scene.getObjectByName(`warehouse_${data.warehouseId}`) if (!group) return // 粮面高度调整:假设粮仓低标高 2m,仓顶标高 8m const grainSurface = group.getObjectByName('grainSurface') if (grainSurface) { const height = (data.grainHeight - 2) / 6 * 2.5 + 0.1 grainSurface.position.y = height // 根据平均粮温改变粮面颜色 const mat = grainSurface.material if (data.avgTemp > 28) { mat.color.setHex(0xff9933) } else if (data.avgTemp > 24) { mat.color.setHex(0xffcc66) } else { mat.color.setHex(0x99cc33) } } // 风机旋转 const fan = group.getObjectByName('fan_' + data.warehouseId) if (fan) { if (data.fanStatus === 1) { fan.rotation.y += 0.05 } } // 告警描边 const shell = group.getObjectByName('wall') if (shell) { if (data.alertLevel === 2) { shell.material.emissive.setHex(0xff0000) } else { shell.material.emissive.setHex(0x000000) } } }温度变化导致粮面颜色渐变、告警级别红色发光、风机的旋转动画——这三类效果基本覆盖了智能粮库大屏的常见表现。做告警闪烁效果时,不要修改material.opacity再恢复,那会引起透明排序问题;用emissive自发光属性来回切换,干扰最小。
5. 优化与上线验证:按需渲染、模型减面与移动端兼容检查
开发环境 60 帧不代表用户电脑也能流畅跑。粮库现场的中控电脑往往配置不高,浏览器还同时开着监控平台、办公系统,3D 页面卡顿几乎是必然的。上线前做好以下三件事,能避免现场翻车。
一是把三维场景从“常驻渲染循环”改成“按需渲染”。当用户没有操作相机、没有数据推送时,没有必要每帧重绘。只在 OrbitControls 触发 change 事件、数据更新、窗口尺寸变化时渲染一次。
init() { // ... this.controls.addEventListener('change', () => this.needRender = true) window.addEventListener('resize', () => { this.camera.aspect = this.$refs.sceneContainer.clientWidth / this.$refs.sceneContainer.clientHeight this.camera.updateProjectionMatrix() this.renderer.setSize(this.$refs.sceneContainer.clientWidth, this.$refs.sceneContainer.clientHeight) this.needRender = true }) } animate() { requestAnimationFrame(this.animate) if (!this.needRender) return this.needRender = false this.controls.update() this.renderer.render(this.scene, this.camera) }按需渲染后,静态场景的 CPU/GPU 占用会明显下降。如果一个页面长时间停留,只有 WebSocket 推送时才会触发一次渲染,风扇、输送带的连续动画可以通过单独维护一个动画时钟来处理,不依赖全局渲染循环。
二是模型减面和贴图压缩。一套粮库场景包含地面、围墙、仓体等大件,以及风机、输送带等小件,常见模型面数总和在 50 万到 100 万。中端集成显卡在 30 万面以下才能稳定 60 帧,超过 50 万帧率会掉到 30 帧以下。模型尽可能在 Blender 里使用 Decimate 修改器压缩,不重要的局部圆角、倒角可以直接删除。
| 优化手段 | 适用场景 | 预期收益 |
|---|---|---|
| 模型减面 | 建筑外壳、地面、围墙 | 面数降低 40%~60% |
| 纹理压缩 | 大纹理贴图 | 显存占用降低 50% |
| 视锥体裁剪 | 相机看不到的仓体 | CPU 剔除耗时降低 |
| 关闭阴影 | 中小型模型 | 帧率提升 10%~20% |
纹理压缩方面,TextureLoader 加载的 JPG 默认是未压缩的 RGBA 图,用compressedTextureLoader或转成 WebP 格式能明显减少内存占用。如果是大屏项目,尽量在建模阶段就要求贴图不超过 2K,细节纹理用 1K 足够。
三是移动端和低配电脑的兼容检查。很多粮库现场要用平板或手机巡检,移动端的 GPU 性能更弱。在真机上打开页面,重点看两点:canvas 的像素比设置是否限制了Math.min(devicePixelRatio, 2),否则 3 倍像素比的手机上渲染压力会大很多;相机上下视角是否限制在合理范围,用户容易在触屏上把视角拖到地面以下,需要设置controls.maxPolarAngle = Math.PI / 2防止穿地。
上线前的自检清单里,还要加一条“弱网模拟”验证。用 Chrome DevTools 把网络调到 Fast 3G,确认模型加载时页面不会白屏,至少要有 loading 遮罩和加载进度条。有些模型动辄几十 MB,如果不做进度提示,用户会以为页面卡死。
loader.load(url, (gltf) => { // ... }, (xhr) => { const percent = Math.floor((xhr.loaded / xhr.total) * 100) this.loadingPercent = percent })把loadingPercent绑定到页面的进度条组件上,等模型加载完成后再进入三维场景。实际项目中,我会把拆分的各模型文件总大小控制在 15MB 以内,超出部分一律走模型减面和纹理压缩流程,因为现场内网带宽虽然快,但很多粮库的硬件解码能力并不强,大模型导致的纹理撕裂问题在低画质模式才会暴露。
本文还有配套的精品资源,点击获取