简介:这是一套基于 Three.js 的仓库可视化管理系统源码,面向前端开发者、学生以及需要快速搭建三维数据看板的工程技术人员,用来解决仓库布局、库存状态与空间数据难以直观呈现和交互管理的问题。系统以 Vue 作为前端框架,借助 Three.js 完成三维场景渲染,可实时联动业务数据,适合作为本科毕业设计、课程作业或工程实训的立项基础。资源包共 93 个文件,以 JavaScript、Vue 组件、PNG 图片和 XML 配置为核心,另含 GLB 三维模型、JSON 数据文件以及样式、图标、工具脚本等辅助内容,压缩后仅 4.31MB,目录划分清晰,便于直接运行和学习。目前已有 935 人学习下载,源码经过测试可正常启动,前端按 api、components、views、store、router 等模块组织,并附有必要的后端接口支持。可以在现有模型基础上改造扩展,丰富可视化交互效果,也可将整套代码迁移到实际仓库管理场景,具备较强的学习借鉴与二次开发价值。
1. 基于 Three.js 的仓库可视化管理系统:这套源码到底能拆出什么
平时在仓库项目里,最头疼的不是堆垛机调度,而是明明库存数据都有,管理层还是要“看图说话”。传统表格和普通 2D 平面图看不出货架饱和率,也看不出巷道堵没堵。基于 Three.js 的仓库可视化管理系统,就是把真实仓库的货架、巷道、库存占用情况用 WebGL 在浏览器里搭了出来,支持旋转、缩放、点货架看详情。这套源码适合三类人:想快速做毕设的前端学生、要搭仓库数字孪生 Demo 的产品经理、以及需要给老系统加一个 3D 可视化层的一线开发。它能直接跑起来,也能拆成模块嵌进你自己的工程里,值得花半小时读完再下载。
2. 系统设计与三维场景搭建:从真实仓库坐标到 Three.js 场景的映射逻辑
一套能落地而非只改改颜色的仓库可视化,第一步必须先解决“坐标系统一”。Three.js 默认 Y 轴向上,而仓库平面图(CAD 或物流规划图)通常用 X、Z 表示长宽,高度单独用 H。如果不做一层转换,后面所有货架、巷道、堆垛机的坐标都会乱。这就是为什么源码里首先会看到 data 目录下有一份点位配置文件,而不是直接在代码里写死货架位置。
2.1 系统模块与技术选型:原生 JavaScript、Vue/React 怎么选
这套源码解压后,主体是 JavaScript + Three.js 实现,没有强绑定某个框架。我认为这是它适合二次开发的最大原因:如果项目组恰好用 Vue 或 React,你可以把核心场景类包成一个组件,用 ref 暴露渲染器;如果像传统生产环境那样用 jQuery + 原生页面,同样可以直接引用。技术选型上,仓库可视化比普通 3D 展示更看重稳定性和可维护性,所以源码里常用的是 Three.js 的稳定版本,配合 OrbitControls 实现视角操作。
完整工程通常按以下模块组织:
| 模块 | 职责 | 关键依赖 |
|---|---|---|
| scene/ | 创建场景、相机、渲染器、光照 | three.js 核心 |
| data/ | 货架、巷道、库存的 JSON 配置 | 无 |
| controls/ | 视角漫游、点击拾取 | OrbitControls |
| viewer/ | 主入口,负责把数据映射为 3D 对象 | 业务逻辑 |
| ui/ | 库存面板、状态筛选 | 普通 DOM |
在源码的入口文件里,一般能看到一个仓库类(比如WarehouseViewer),它负责把这三件事串起来:初始化场景、加载数据、刷新视图。这样拆分的好处是,数据变了不需要重绘整个三维场景,只要调updateData()方法即可。
为什么不直接导入外部 glTF 模型?外部模型要额外加载文件,贴图、材质、坐标系都可能跟 Three.js 默认不一致,调试成本高。仓库货架结构高度规律化,用代码生成网格模型反而更可控。这也是这套源码坚持用BoxGeometry拼装的原因之一。
2.2 场景初始化:渲染器、相机、轨道控制器的参数怎么调
刚拿到源码时,我建议先不要急着改业务逻辑,而是把初始化代码读一遍。最常见的翻车点就藏在camera.position和controls.target这两个参数里。相机初始位置设成(50, 40, 60),看向原点,是一个斜俯视视角,能同时看到货架顶面和巷道,比较贴近仓库高位摄像头画面。
import * as THREE from 'three'; import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; const scene = new THREE.Scene(); scene.background = new THREE.Color(0x1a1a2e); const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 2000); camera.position.set(50, 40, 60); camera.lookAt(0, 0, 0); const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(window.innerWidth, window.innerHeight); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.shadowMap.enabled = true; // 开启阴影,提升立体感 renderer.shadowMap.type = THREE.PCFSoftShadowMap; document.getElementById('container').appendChild(renderer.domElement); const controls = new OrbitControls(camera, renderer.domElement); controls.target.set(0, 0, 0); controls.enableDamping = true; // 阻尼让视角更跟手 controls.dampingFactor = 0.08; controls.maxPolarAngle = Math.PI / 2.1; // 限制俯仰角,避免钻到地面下方 controls.minDistance = 5; controls.maxDistance = 300;这里几个参数直接影响体验:camera.near和far的比例决定了深度裁切是否把货架裁掉,一般 far 设成场景尺寸的 5 倍即可。maxPolarAngle限制到略大于 90 度,是为了不让你看到地面以下的“虚无”。setPixelRatio(Math.min(devicePixelRatio, 2))是我个人的强制习惯,在 4K 屏上如果不限制,GPU 压力会翻倍,仓库场景动辄几千个货架,帧率很容易崩。
光照部分不用太复杂,一套半球光加一套方向光基本就够了:
const ambientLight = new THREE.HemisphereLight(0xffffff, 0x444444, 0.6); scene.add(ambientLight); const dirLight = new THREE.DirectionalLight(0xffffff, 1); dirLight.position.set(30, 60, 30); dirLight.castShadow = true; dirLight.shadow.mapSize.width = 2048; dirLight.shadow.mapSize.height = 2048; scene.add(dirLight);阴影不是必须的,如果你的目标场景有几千个货架,我建议直接关掉阴影,或者只给地面和第一层货架开。否则渲染器每一帧都要重新计算 shadow map,卡顿会很快找上门。
最后是渲染循环和窗口自适应。这段代码看似基础,但很多人会忘记在resize里更新camera.aspect:
function animate() { requestAnimationFrame(animate); controls.update(); // damping 开启时必须每帧调用 renderer.render(scene, camera); } animate(); window.addEventListener('resize', () => { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); });controls.update()在enableDamping开启时一定要放到渲染循环里,否则阻尼效果不生效,视角会变得僵硬。resize事件里只改setSize不改aspect,几何体会被拉伸变形,这种问题用肉眼很难一下子看出来,但总感觉“不对”。把这段作为初始化模板,后面所有三维场景都能少踩一半坑。
3. 核心交互与数据可视化:货架、巷道、库存状态怎么画出来
光有一个空场景没有意义,仓库可视化管理系统的核心是把数据点阵变成让人一眼看懂的画面。这一章我会按源码里最常见的实现路径拆开:先造货架模型,再把 JSON 数据映射到三维坐标,最后实现点击拾取。
3.1 货架和托盘建模:用 BoxGeometry 拼出网格模型
仓库的货架结构其实很规则:四根立柱、若干层板、底部横撑。用 Three.js 的 BoxGeometry 拼装比导入外部 glTF 模型更可控,而且不用额外加载模型文件。源码里通常会有一个createShelf(config)工厂函数,接收列数、层数、长宽高等参数,返回一个 Group:
function createShelf(config) { const group = new THREE.Group(); const { cols, rows, width, depth, height } = config; const frameMat = new THREE.MeshPhongMaterial({ color: 0x7a8b99, transparent: true, opacity: 0.55 }); // 底部横梁 const base = new THREE.Mesh(new THREE.BoxGeometry(width, 0.2, depth), frameMat); base.position.y = 0.1; group.add(base); // 层板 for (let i = 1; i <= rows; i++) { const layer = new THREE.Mesh( new THREE.BoxGeometry(width, 0.12, depth), new THREE.MeshPhongMaterial({ color: 0x8a9ba8 }) ); layer.position.y = i * height; group.add(layer); } // 立柱:在四个角生成 const pillarMat = new THREE.MeshPhongMaterial({ color: 0x3c4a58 }); const pillarHeight = rows * height + 0.2; const halfWidth = width / 2 - 0.1; const halfDepth = depth / 2 - 0.1; for (const [dx, dz] of [[-1, -1], [1, -1], [-1, 1], [1, 1]]) { const pillar = new THREE.Mesh( new THREE.BoxGeometry(0.2, pillarHeight, 0.2), pillarMat ); pillar.position.set(dx * halfWidth, pillarHeight / 2, dz * halfDepth); group.add(pillar); } group.userData.type = 'shelf'; return group; }这段代码的细节在于:group.userData.type是在给后面射线拾取留标记;透明材质用transparent: true会让叠加的半透明货架产生层次感,但注意透明对象越多,绘制排序越复杂,如果你发现货架层板被后面货架遮住,说明透明排序出了问题,解决办法是把透明度提到 0.8 以上,或者改用不透明材质。
托盘建模就简单得多,一个 BoxGeometry 放在货架上方即可。通常会把托盘也绑定到货架 Group 上,方便后续随货架一起移动:
function createPallet(color = 0x8d6e63) { const pallet = new THREE.Mesh( new THREE.BoxGeometry(0.8, 0.15, 1.0), new THREE.MeshPhongMaterial({ color }) ); pallet.position.y = 0.3; return pallet; }你完全可以把托盘合并成一个大 BufferGeometry,减少 draw call。但前提是托盘行为一致,不需要单独点击。如果需要点击托盘看批次信息,就保留独立 Mesh 并在userData里挂上业务字段。
3.2 数据驱动更新:JSON 点位到三维坐标的映射
这套源码里仓库布局一般在 data/warehouse.json 里定义,每个点位包含编号、行列坐标、状态、货物品类和库存量。用数据驱动的好处是,后端 WMS 只要按同样格式输出,前端就能自动生成三维货架,不需要人工一个个摆。
{ "shelves": [ { "id": "A-01", "col": 0, "row": 0, "status": "occupied", "product": "电子元件", "stock": 120 }, { "id": "A-02", "col": 1, "row": 0, "status": "free", "product": null, "stock": 0 }, { "id": "B-03", "col": 0, "row": 1, "status": "occupied", "product": "工业零部件", "stock": 85 } ], "unit": "m", "shelfSpacing": 2.5, "laneWidth": 4.0 }映射的核心逻辑是把col、row换算成世界坐标。常见做法是:
const shelfSpacing = data.shelfSpacing; // 货架之间的间距 const laneWidth = data.laneWidth; // 巷道宽度 data.shelves.forEach((shelf) => { const pos = getShelfPosition(shelf); const group = createShelf(shelfConfig); group.position.set(pos.x, 0, pos.z); group.userData = shelf; // 把业务数据挂到模型上 scene.add(group); }); function getShelfPosition(shelf) { // 奇数行留出巷道,偶数行正常连续排列 const x = shelf.col * (shelfConfig.width + shelfSpacing); const z = shelf.row * (shelfConfig.depth + laneWidth); return { x, z }; }这里有一个关键惯例:源码里会有一个coordTransform函数,把所有col/row转换包在里面。之后无论后端给你的是 CAD 像素坐标还是物流规划的图号,你只需要改这一个函数,三维场景其他代码都不用动。这也是我在做这类项目时最推荐的做法——坐标转换集中层,避免散落在各个组件里。
状态颜色映射也建议做成独立函数,方便统一调整:
function getStatusColor(status) { if (status === 'occupied') return 0xf39c12; // 占用中 if (status === 'full') return 0xe74c3c; // 满库 return 0x2ecc71; // 空闲 }使用时在createShelf返回的 Group 上遍历所有 Mesh,通过material.color.set(getStatusColor(...))更新颜色。注意不要直接给material.color赋值一个新 Color 对象,要用.set(),否则共享材质的多个货架会相互污染颜色状态。
3.3 点击射线拾取:从鼠标位置到库存详情
接下来的代码是交互的核心。Three.js 的 Raycaster 可以把屏幕二维坐标换算成三维射线,从而找到被点击的对象:
const raycaster = new THREE.Raycaster(); const mouse = new THREE.Vector2(); function onMouseClick(event) { mouse.x = (event.clientX / window.innerWidth) * 2 - 1; mouse.y = -(event.clientY / window.innerHeight) * 2 + 1; raycaster.setFromCamera(mouse, camera); const hits = raycaster.intersectObjects(scene.children, true); if (hits.length === 0) return; // 向上查找绑定过 userData 的父节点 let obj = hits[0].object; while (obj && !obj.userData?.id) { obj = obj.parent; } if (obj && obj.userData.id) { showStockDetail(obj.userData); } } renderer.domElement.addEventListener('click', onMouseClick);射线拾取有两个容易被忽略的坑。第一个是intersectObjects的第二个参数必须为true,才会递归检查子网格;第二个是直接点击到立柱或层板时,hits[0].object是子网格,所以要用while向上找带userData.id的父 Group。否则你会发现点击货架没有任何反应,因为事件绑到了没有数据的柱子身上。这个while向上查找的写法,比给每个子网格都复制一遍数据要省内存得多。
点击之后通常要做一个高亮反馈,让用户知道选中的是哪个货架。最简单的实现是给材质设置 emissive:
function highlightShelf(group) { group.traverse((obj) => { if (obj.isMesh) { obj.material.emissive.set(0xffff00); obj.material.emissiveIntensity = 0.3; } }); }取消高亮时把emissiveIntensity归零即可。注意如果货架有多个材质,需要挨个处理,不能只改一个。
到这里,一个能看、能点、能出详情的仓库三维场景就成立了。接下来要讲的是让源码在真实项目里跑起来时,真正让人头疼的踩坑记录。
4. 避坑与排查:Three.js 仓库可视化最常见的五个问题
这部分是血泪经验。我在很多仓库三维项目里都碰到过类似的问题,这里挑五条最影响上线的问题,按现象、原因、解决写清楚。
4.1 场景和模型:黑屏、贴图加载不出来、透明层板闪烁
现象:打开页面后,能看到 OrbitControls 的旋转效果,但整个 canvas 是黑的,或者只有地面没有货架。
原因:最常见的是相机位置和 lookAt 方向不对。比如把相机放在(0, 0, 0),货架在(0,1,0)附近,相机在货架内部,看不到任何面;或者camera.lookAt(0, 0, 0)被放在position之前,相机朝向被重置。
解决:先在camera.position.set(...)之后调用camera.lookAt(0, 0, 0),再创建 OrbitControls,让 controls.target 也指向原点。调试时可以在初始化末尾临时加一条:
console.log(camera.position, controls.target); scene.add(new THREE.AxesHelper(5));AxesHelper 是排查坐标系的利器,看到红绿蓝三根轴,立刻能判断货架到底偏在哪。如果问题还是黑屏,多半是 WebGL 上下文创建失败,比如在 headless 环境或部分远程桌面里,WebGLRenderer会抛错。可以先做一次特性检测,再决定要不要提示用户切换浏览器。
现象:货架模型有了,但贴图是纯黑或者不显示。这是 Three.js 新版本升级后特别常见的问题,尤其是直接从旧项目升级时,贴图一上来就是黑的。
原因:Three.js r150+ 之后,纹理的颜色空间默认从线性改成了 SRGB,需要显式设置texture.colorSpace = THREE.SRGBColorSpace,否则整个贴图看起来会偏暗甚至接近黑色。另外,加载贴图是异步的,如果渲染循环在纹理加载完成前执行,线框已经画完,纹理自然不显示。
解决:用TextureLoader.load()的回调或await控制时序;确认贴图路径不含中文、不跨域。顺手在 load 回调里做一次日志输出:
const loader = new THREE.TextureLoader(); const texture = await loader.loadAsync('../assets/floor.jpg'); texture.colorSpace = THREE.SRGBColorSpace; texture.anisotropy = 8; floorMesh.material.map = texture; floorMesh.material.needsUpdate = true;anisotropy可以消除地面贴图在斜视角下的发糊问题,数值 8 到 16 对仓库地面这种大面积平面效果很明显。
现象:拖动视角时,透明货架层板彼此穿插闪烁,像“透明塑料片叠加”。
原因:透明材质默认按绘制顺序排序,多个透明对象交错时排序不稳定,就会出现后画的先画、前面的被遮挡。
解决:把透明度提到 0.8 以上,或者给每个层板设置renderOrder,让远处的先绘制。更彻底的做法是把层板改成不透明材质,用颜色深浅区分层次。仓库可视化要的是信息清晰,不是赛博感,牺牲一点透明效果是值得的。
4.2 性能和交互:卡顿、拾取不准、内存只增不减
现象:场景拖动时明显掉帧,用 Chrome Performance 面板看 draw call 经常上千,帧率掉到 20fps。
原因:每个货架都独立占用几个 Mesh,几千个货架就是上万 draw call;同时开了阴影、像素比又没限制,GPU 压力翻倍。很多仓库可视化的源码为了演示效果,默认把阴影全开,这在 demo 里没事,到真项目里就会卡到没法用。
解决:合并同材质几何体,或者用 InstancedMesh 渲染重复的货架和托盘。如果只是展示层面,把阴影关掉或用一张假阴影贴图即可。同时把renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))设上。代码层面可以先统计场景里的 mesh 数量:
let meshCount = 0; scene.traverse((obj) => { if (obj.isMesh) meshCount++; }); console.log('mesh count:', meshCount);如果超过 3000 个,优先考虑 InstancedMesh 重构,性能会有一个数量级的提升。
现象:点击货架拿不到数据,或者总是拾取到辅助网格线、测量线。
原因:这些辅助线往往也是 Object3D,挂在场景里参与了射线检测。另一个原因是 Raycaster 的阈值设置,默认对点线拾取使用像素级精度,标注线很容易抢命中。
解决:在拾取时把辅助工具的raycast方法临时禁用,或者给辅助线设置object.raycast = () => {}。更干净的方式是给所有可交互对象统一挂userData.pickable = true,拾取时只遍历这些对象。这比在 raycast 结果里过滤要快得多。
现象:页面从单仓库视图切到另一个视图,或者多次刷新后,内存占用持续增长,最终浏览器标签页崩溃。
原因:Three.js 创建的几何体、材质和纹理是 GPU 资源,不会随 JavaScript 对象的垃圾回收自动释放。源码里如果没有在卸载时调用 dispose,每次重建场景都会泄漏一份。尤其在 Vue/React 里,组件频繁挂载卸载,这个问题会被放大。
解决:在场景销毁时统一释放资源:
function disposeScene(scene) { scene.traverse((obj) => { if (obj.isMesh) { obj.geometry?.dispose(); const materials = Array.isArray(obj.material) ? obj.material : [obj.material]; materials.forEach((mat) => { Object.values(mat).forEach((value) => { if (value && value.isTexture) value.dispose(); }); mat.dispose(); }); } }); renderer.dispose(); }注意renderer.dispose()要放在移除 dom 元素之后再调用,这样能完整释放 WebGL 上下文。组件卸载时还要调controls.dispose(),否则事件监听会挂在 window 上,等于隐藏的泄漏点。
5. 进阶玩法:把仓库可视化系统从 Demo 做成真工具
源码能跑通只是第一步,要让老板愿意每天打开,得再加几个实战功能。
5.1 接入 WebSocket:库存变化实时推送到三维场景
仓库管理系统的核心是数据实时性。你可以把updateShelfStatus(data)暴露成公共方法,通过 WebSocket 接收 WMS 的出入库事件,然后只更新对应货架的颜色和高度,不需要重建整个场景。
socket.onmessage = (event) => { const msg = JSON.parse(event.data); if (msg.type === 'stock_change') { viewer.updateShelfStatus(msg.shelfId, msg.stock, msg.status); } };这要求三维对象与业务数据之间存在明确映射关系,我在前面第 3.2 节提到的userData绑定就是为此服务的。
5.2 增加巡检漫游与状态着色
环境光 + 方向光 + 鼠标拖动,只能满足看。如果想做“巡检”,可以预置一条路径,让相机沿路径平滑移动。用requestAnimationFrame插值相机位置和lookAt点,一个简单的巡检漫游就出来了。状态着色则建议做在材质层,用三色区分:绿色空闲、黄色待出库、红色满库。注意材质变化后要设置needsUpdate = true,否则颜色刷新不生效。
5.3 我的三个复用习惯:坐标表、命名规范、内存清理清单
这套源码让我养成了几个固定习惯:一是在项目开始前维护一份coords.csv,记录每个货架的真实坐标和 3D 坐标,避免后面跟后端扯皮;二是所有场景对象命名统一走type_区域_编号,比如shelf_A_01,这样排查问题能一眼定位;三是在组件销毁时必须执行第 4 节里的disposeScene,这个函数已经变成了我的模板函数,不管新项目用什么框架都会先塞进去。
从那次在客户现场调试到凌晨,最后发现只是 camera 没看对方向的翻车经历后,我每次搭仓库场景都会强制先加 AxesHelper,再往后写代码。这个习惯帮我避免了很多看着像玄学、其实是坐标系的低级问题。希望帮到你。
本文还有配套的精品资源,点击获取