简介:这是一套面向前端开发者与智慧农业数字化实践者的Three.js+ECharts融合项目,聚焦数字孪生在农业场景的落地应用,解决传统农场可视化缺乏三维交互、数据联动滞后、环境预警能力薄弱等痛点。资源完整实现3D农场建模(含GLB格式模型)、多维度动态数据渲染(温湿度、土壤墒情、作物长势等)、实时灾害模拟与可视化预警,适用于教学演示、毕设开发或智慧农业系统原型验证。压缩包共42个文件,涵盖10个核心JS脚本(含chart1–chart6及main.js等模块化图表与主控逻辑)、5个CSS样式文件(支持导航栏、状态栏、模型渲染等UI分层)、17张PNG界面素材(背景图、图标、状态面板等),以及JSON配置、WASM解码器、HTML入口与README说明文档,整体体积10.26MB,结构清晰、开箱即用。目前已有69人学习下载,提供可运行的交互式农场模拟系统、完整的前后端分离前端架构、Draco模型压缩加载方案及ECharts 3D图表集成范例,是掌握WebGL与数据可视化协同开发的优质实战案例。
1. 为什么农场主盯着屏幕比看地还上头:Three.js + ECharts 数字孪生平台不是炫技,是把土壤湿度、光照曲线、病虫害概率全“搬进浏览器”里实时跑起来
你见过凌晨三点还在刷手机的农场主吗?不是刷短视频,是在看自己那300亩大棚的3D模型——番茄藤蔓在屏幕上微微摇晃,叶片颜色随实时氮含量变化泛黄,温室内CO₂浓度一过阈值,对应区域立刻浮出红色预警框,点击还能拉出过去72小时ECharts折线图对比。这不是游戏Demo,而是某农业技术公司落地的数字孪生可视化平台核心能力:用Three.js加载轻量化3D农场模型,用ECharts驱动动态数据层,让环境传感器、作物生长模型、气象API三路数据在同一个时空坐标系里对齐、联动、可交互。它解决的不是“能不能展示”,而是“操作员能否在10秒内定位异常温区并回溯温控设备历史指令”。适合两类人:一是已有IoT设备但数据躺在数据库里睡大觉的农业信息化团队;二是正从传统SCADA系统向Web端迁移的农科院实验室。关键不在“孪生”二字多高大上,而在模型加载不卡顿、数据更新不丢帧、点击3D对象能精准触发ECharts图表联动——这三件事做稳了,才是真落地。
2. 从GLB模型到可交互农场:Three.js场景搭建的四步闭环
2.1 选型依据:为什么不用Unity WebGL,而死磕Three.js?
很多团队第一反应是“Unity导出WebGL更省事”,但实际踩坑后发现:Unity WebGL包体动辄30MB+,首屏加载超15秒,且移动端兼容性差(尤其国产安卓浏览器)。而Three.js生态中GLTF/GLB格式已成工业级事实标准——某高校农科实验室提供的农场模型经Blender简化后仅4.2MB,用@loaders.gl/gltf加载器配合DRACO压缩,实测首帧渲染时间压到1.8秒内。更重要的是,Three.js与ECharts的DOM层无缝融合:ECharts图表可作为HTML元素叠加在Three.js Canvas之上,通过CSS2DRenderer实现3D空间锚定(比如把温度计图标“钉”在温室顶棚下方),而Unity WebGL是独立Canvas,强行叠加会导致z-index冲突和鼠标事件穿透。我们放弃Unity,本质是放弃“一键导出”的幻觉,换取对加载性能、内存占用、跨端一致性的绝对控制权。
2.2 模型预处理:Blender里必须做的三件事
农业3D模型常由CAD或SketchUp导出,直接扔进Three.js必翻车。某模拟项目X的初始模型加载后报错THREE.GLTFLoader: Couldn't load the texture,根源在贴图路径混乱。解决方案如下:
- 统一材质为PBR工作流:在Blender中将所有材质切换为Principled BSDF,删除非必要节点(如自发光、次表面散射),导出前勾选
Export Materials和Export Textures; - 重命名贴图文件:将
tomato_leaf_diffuse.jpg改为tomato_leaf_baseColor.jpg,soil_normal.png改为soil_normal.jpg(GLB规范要求贴图后缀统一为.jpg或.png,且名称需含baseColor/normal等语义关键词); - 烘焙环境光遮蔽(AO)贴图:选中全部物体→
Object Data Properties→Vertex Colors→新建AO通道→Render Properties→Bake Type设为Ambient Occlusion→点击Bake。此步骤让土壤沟壑、藤蔓阴影在无光照下仍有层次感,避免Three.js默认Phong材质导致的“塑料感”。
提示:导出时务必勾选
Apply Modifiers和Include UVs,否则模型在Three.js中会丢失纹理坐标,显示为纯色块。
2.3 Three.js最小可运行场景:加载GLB并启用轨道控制器
// main.js import * as THREE from 'three'; import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; // 1. 创建场景、相机、渲染器 const scene = new THREE.Scene(); scene.background = new THREE.Color(0xf0f9ff); // 天空蓝背景 const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); camera.position.set(0, 15, 25); // 俯视农场视角 const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); renderer.setSize(window.innerWidth, window.innerHeight); renderer.setPixelRatio(window.devicePixelRatio); document.body.appendChild(renderer.domElement); // 2. 添加轨道控制器(支持鼠标拖拽旋转、滚轮缩放) const controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.dampingFactor = 0.05; // 3. 加载GLB模型 const loader = new GLTFLoader(); loader.load( '/models/farm_v2.glb', // 预处理后的模型路径 (gltf) => { const model = gltf.scene; model.scale.set(0.8, 0.8, 0.8); // 统一缩放适配场景 scene.add(model); // 关键:为后续交互预留对象引用 window.farmModel = model; }, (progress) => { console.log(`模型加载进度: ${(progress.loaded / progress.total * 100).toFixed(0)}%`); }, (error) => { console.error('模型加载失败:', error); } ); // 4. 动画循环 function animate() { requestAnimationFrame(animate); controls.update(); // 必须调用,否则阻尼失效 renderer.render(scene, camera); } animate(); // 5. 响应式处理 window.addEventListener('resize', () => { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); });代码逻辑说明:
antialias: true开启抗锯齿,避免3D模型边缘出现“狗牙”;alpha: true启用透明背景,为后续叠加ECharts图表留出空间;dampingFactor设为0.05而非默认0.1,降低旋转惯性,让农场主操作更跟手;model.scale.set(0.8, 0.8, 0.8)是血泪经验:农业模型常按真实尺寸建模,直接加载会撑满整个视口,需手动缩小。
3. 让数据在3D空间里“活”起来:ECharts动态渲染与双端联动机制
3.1 数据分层设计:为什么ECharts不直接画3D,而要和Three.js搭档?
ECharts原生支持3D散点图、曲面图,但农业场景需要空间语义强绑定:比如“第3号温室的东侧通风窗开度”必须精确映射到3D模型中那个矩形窗口的位置,而非一个悬浮的3D柱子。因此我们采用分层渲染策略:
- 底层:Three.js负责空间结构(大棚骨架、灌溉管道、作物植株);
- 中层:CSS2DRenderer将ECharts图表作为HTML元素“钉”在3D坐标上(如把温湿度仪表盘固定在控制室墙面);
- 上层:纯DOM层ECharts图表(如全场环境参数趋势图)悬浮于页面右上角,支持缩放拖拽。
这种架构让每类数据各司其职:空间位置精度交给Three.js,复杂统计分析交给ECharts,互不干扰。
3.2 CSS2DRenderer锚定:把ECharts图表“焊”在3D模型上
// chart-anchor.js import { CSS2DRenderer, CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer.js'; // 1. 创建CSS2D渲染器 const labelRenderer = new CSS2DRenderer(); labelRenderer.setSize(window.innerWidth, window.innerHeight); labelRenderer.domElement.style.pointerEvents = 'none'; // 确保鼠标穿透到Three.js document.body.appendChild(labelRenderer.domElement); // 2. 为温室创建锚点容器 const greenhouseContainer = document.createElement('div'); greenhouseContainer.className = 'label-container'; greenhouseContainer.innerHTML = ` <div class="label-title">3号温室</div> <div id="gh3-chart" style="width:200px;height:120px;"></div> `; const greenhouseLabel = new CSS2DObject(greenhouseContainer); // 3. 将锚点绑定到3D模型中的特定网格(需提前在Blender中命名该网格为"greenhouse_3") const greenhouseMesh = window.farmModel.children.find(child => child.name === 'greenhouse_3'); if (greenhouseMesh) { greenhouseLabel.position.copy(greenhouseMesh.position); greenhouseLabel.position.y += 3; // 向上偏移3单位,避免遮挡 scene.add(greenhouseLabel); } // 4. 初始化ECharts图表 const gh3ChartDom = document.getElementById('gh3-chart'); const gh3Chart = echarts.init(gh3ChartDom); gh3Chart.setOption({ tooltip: { show: false }, series: [{ type: 'gauge', startAngle: 180, endAngle: 0, min: 0, max: 100, splitNumber: 10, axisLine: { lineStyle: { width: 8 } }, pointer: { show: false }, detail: { formatter: '{value}%' }, data: [{ value: 65, name: '湿度' }] }] }); // 5. 动画循环中同步渲染 function animate() { requestAnimationFrame(animate); controls.update(); renderer.render(scene, camera); labelRenderer.render(scene, camera); // 必须调用!否则锚点不更新 }参数说明:
pointerEvents: 'none'是玄学关键:若不设此属性,HTML锚点会拦截鼠标事件,导致无法拖拽3D模型;greenhouseMesh.position获取的是模型局部坐标,需确保Blender中该网格原点位于几何中心,否则锚点会偏移;gh3Chart.setOption()中detail.formatter支持富文本,可写成'{value}℃'或'<span style="color:#e74c3c">{value}</span>%'实现条件变色。
3.3 双向联动:点击3D模型触发ECharts图表刷新
// interaction.js let raycaster = new THREE.Raycaster(); let mouse = new THREE.Vector2(); // 监听鼠标点击 window.addEventListener('click', (event) => { // 1. 将鼠标坐标转为标准化设备坐标(NDC) mouse.x = (event.clientX / window.innerWidth) * 2 - 1; mouse.y = -(event.clientY / window.innerHeight) * 2 + 1; // 2. 从相机发射射线 raycaster.setFromCamera(mouse, camera); // 3. 检测与模型的交点 const intersects = raycaster.intersectObjects(window.farmModel.children); if (intersects.length > 0) { const clickedObject = intersects[0].object; // 4. 根据模型命名规则识别设备类型 if (clickedObject.name.startsWith('sensor_')) { const sensorId = clickedObject.name.split('_')[1]; // 如sensor_temp_03 → "temp_03" fetchSensorData(sensorId).then(data => { updateEChartsChart(data); // 刷新右侧趋势图 }); } else if (clickedObject.name.startsWith('greenhouse_')) { const ghId = clickedObject.name.split('_')[1]; // greenhouse_3 → "3" loadGreenhouseDetail(ghId); // 加载该温室详细数据 } } }); // 模拟数据请求(实际对接WebSocket或REST API) async function fetchSensorData(id) { // 返回格式:{ timestamp: '2023-08-15T14:30:00Z', value: 28.5, unit: '℃' } const response = await fetch(`/api/sensors/${id}/history?hours=24`); return response.json(); }逻辑说明:
raycaster.intersectObjects()检测效率极高,即使模型有5000个面,响应延迟<16ms;- 命名规范
sensor_temp_03是协作前提:美术组导出模型时必须按约定命名,否则代码无法解析; - 实际项目中,
fetchSensorData()应替换为WebSocket长连接,避免频繁HTTP请求压垮后端。
4. 数据驱动的灾害预警:从阈值告警到三维热力图的落地路径
4.1 环境参数分析模块:如何让ECharts图表真正指导农事操作?
很多平台把温湿度数据画成折线图就结束,但农民需要的是“现在该不该开风机”。某模拟项目X的做法是:在ECharts折线图上叠加农艺知识规则层。例如番茄生长最适温度为22~28℃,当连续2小时超过28℃,图表自动标红并弹出操作建议:“建议开启侧窗通风,当前风速0.3m/s,预计降温速率0.8℃/h”。
// agri-rules.js const tomatoRules = { temp: { optimal: [22, 28], critical: [32, 35], action: '开启侧窗通风' }, humidity: { optimal: [60, 85], critical: [90, 95], action: '启动除湿机,检查灌溉量' } }; // 在ECharts option中注入规则标记 const option = { xAxis: { type: 'time' }, yAxis: { type: 'value', splitLine: { lineStyle: { color: ['#4CAF50', '#FFC107', '#F44336'] // 绿/黄/红三段色 } } }, series: [{ type: 'line', data: temperatureData, // 时间序列数组 markArea: { // 标记最优/临界区间 itemStyle: { color: 'rgba(76, 175, 80, 0.1)' }, data: [[{ xAxis: 'min' }, { xAxis: 'max', yAxis: tomatoRules.temp.optimal[0] }]] } }], tooltip: { formatter: (params) => { const value = params.value[1]; let status = '正常'; if (value > tomatoRules.temp.critical[0]) status = '⚠️ 高温预警'; if (value > tomatoRules.temp.critical[1]) status = '🔥 极端高温'; return `${params.name}<br/>${value}℃<br/>状态:${status}<br/>建议:${tomatoRules.temp.action}`; } } };参数深挖:
markArea用半透明色块覆盖Y轴区间,比单纯画参考线更直观;tooltip.formatter返回HTML字符串,支持emoji和换行,农民一眼看懂严重等级;- 实际部署时,
tomatoRules应从农科院API动态加载,支持不同作物品种规则热更新。
4.2 灾害预警三维热力图:用Three.js Shader实现土壤墒情渐变
当土壤湿度低于40%持续48小时,系统需在3D模型上高亮干旱区域。若用ECharts热力图叠加,会因投影失真导致定位不准。正确做法是:将土壤湿度数据生成纹理贴图,用Shader在GPU层实时渲染。
// soil-heatmap.js // 1. 从后端获取土壤湿度栅格数据(GeoJSON格式,每个Feature代表1m²地块) fetch('/api/soil-moisture').then(res => res.json()).then(data => { // 2. 创建Canvas绘制热力图纹理 const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); canvas.width = 1024; canvas.height = 1024; // 3. 遍历GeoJSON,将每个地块坐标转为Canvas像素坐标并填色 data.features.forEach(feature => { const [x, y] = geoToCanvas(feature.geometry.coordinates[0][0]); // 坐标转换函数 const moisture = feature.properties.moisture; const color = moisture < 40 ? '#e74c3c' : moisture < 60 ? '#f39c12' : '#2ecc71'; ctx.fillStyle = color; ctx.fillRect(x, y, 2, 2); // 每个地块占2x2像素 }); // 4. 将Canvas转为Three.js纹理 const texture = new THREE.CanvasTexture(canvas); texture.wrapS = THREE.RepeatWrapping; texture.wrapT = THREE.RepeatWrapping; // 5. 应用到土壤网格材质 const soilMesh = window.farmModel.children.find(c => c.name === 'soil_plane'); if (soilMesh && soilMesh.material) { soilMesh.material.map = texture; soilMesh.material.needsUpdate = true; } });关键细节:
geoToCanvas()需根据农场真实地理范围做线性映射,例如X轴0~100米映射到Canvas 0~1024像素;wrapS/T设为RepeatWrapping确保纹理平铺无缝;- 若需动态更新,每5分钟重新生成Canvas并调用
texture.needsUpdate = true,GPU显存占用极低。
5. 避坑指南:那些让农业数字孪生平台上线即崩溃的5个致命细节
5.1 现象:模型加载后一片漆黑,控制台无报错
原因:Blender导出时未勾选Export Materials,导致GLB文件内无材质定义,Three.js默认使用MeshBasicMaterial(无光照),在暗色背景下不可见。
解决:在Blender导出面板中强制勾选Materials和Textures,导出后用 glTF Viewer 在线验证材质是否正常。
5.2 现象:ECharts图表在3D场景中显示模糊、边缘发虚
原因:CSS2DRenderer未适配高分屏(Retina屏),Canvas渲染分辨率仍为1x,而浏览器实际渲染为2x。
解决:在CSS2DRenderer初始化后添加:
labelRenderer.setSize(window.innerWidth, window.innerHeight); labelRenderer.domElement.style.width = window.innerWidth + 'px'; labelRenderer.domElement.style.height = window.innerHeight + 'px'; // 强制设置CSS像素比 labelRenderer.domElement.style.transform = `scale(${window.devicePixelRatio})`; labelRenderer.domElement.style.transformOrigin = 'top left';5.3 现象:点击3D模型无响应,raycaster.intersectObjects()始终返回空数组
原因:模型网格的frustumCulled属性默认为true,当模型部分超出相机视锥体时被自动剔除,导致射线检测失效。
解决:遍历模型所有子对象,强制关闭视锥剔除:
window.farmModel.traverse((child) => { if (child.isMesh) child.frustumCulled = false; });5.4 现象:移动端双指缩放3D模型时,ECharts图表跟着一起缩放变形
原因:CSS2DRenderer生成的HTML元素未禁用用户缩放,iOS Safari默认允许双指缩放页面内所有元素。
解决:在CSS中全局禁止缩放:
.label-container, .echarts-container { -webkit-text-size-adjust: none; -ms-text-size-adjust: none; touch-action: manipulation; /* 禁用双指缩放,保留拖拽 */ }5.5 现象:土壤热力图更新后,3D模型闪烁白光
原因:texture.needsUpdate = true触发材质重编译,而MeshStandardMaterial默认启用metalness和roughness贴图,若未提供对应贴图,Three.js会用默认值导致反光突变。
解决:统一材质为MeshLambertMaterial(无高光计算)或显式设置贴图:
soilMesh.material = new THREE.MeshLambertMaterial({ map: texture, transparent: true, opacity: 0.8 });6. 进阶技巧:用Web Worker解耦数据处理,让3000个传感器数据更新不卡顿主线程
6.1 为什么主线程处理传感器数据必然卡顿?
某跨平台系统曾将3000个传感器的实时数据(每秒1次)在主线程做归一化、异常值过滤、滑动平均,结果Chrome任务管理器显示JS执行时间峰值达420ms,3D动画掉帧至12fps。根本原因是:JavaScript单线程模型下,数据处理与渲染共用同一事件循环,任何耗时操作都会阻塞requestAnimationFrame。
6.2 Web Worker方案:把数据清洗搬到后台线程
// worker.js self.onmessage = function(e) { const { rawData, rules } = e.data; // 1. 归一化:将原始值映射到0~100区间 const normalized = rawData.map(item => ({ ...item, value: Math.min(100, Math.max(0, (item.rawValue - rules.min) / (rules.max - rules.min) * 100)) })); // 2. 滑动平均(窗口大小5) const smoothed = []; for (let i = 0; i < normalized.length; i++) { const windowStart = Math.max(0, i - 2); const windowEnd = Math.min(normalized.length, i + 3); const windowData = normalized.slice(windowStart, windowEnd); const avg = windowData.reduce((sum, d) => sum + d.value, 0) / windowData.length; smoothed.push({ ...normalized[i], value: parseFloat(avg.toFixed(1)) }); } // 3. 异常值标记(3σ原则) const values = smoothed.map(d => d.value); const mean = values.reduce((a, b) => a + b, 0) / values.length; const std = Math.sqrt(values.reduce((a, b) => a + Math.pow(b - mean, 2), 0) / values.length); const flagged = smoothed.map(d => ({ ...d, isAnomaly: Math.abs(d.value - mean) > 3 * std })); self.postMessage(flagged); };// main.js 中调用 const worker = new Worker('./worker.js'); // 发送原始数据给Worker worker.postMessage({ rawData: sensorRawData, rules: { min: 0, max: 100 } }); // 接收处理结果 worker.onmessage = function(e) { const processedData = e.data; // 更新ECharts图表(此时主线程完全不卡顿) temperatureChart.setOption({ series: [{ data: processedData.map(d => [d.timestamp, d.value]) }] }); };性能对比表(3000条数据,Chrome 115):
| 处理方式 | 平均耗时 | 主线程阻塞 | FPS稳定性 | 内存峰值 |
|---|---|---|---|---|
| 主线程同步处理 | 380ms | 严重 | 12~18 | 1.2GB |
| Web Worker | 210ms | 无 | 58~60 | 850MB |
血泪经验:
- Worker中不能访问
window、document等全局对象,所有依赖必须通过postMessage传入; - 大量数据传输时,用
Transferable Objects避免拷贝:worker.postMessage(data, [data.buffer]); - 实际项目中,Worker可同时处理温湿度、光照、CO₂三路数据,只需在
onmessage中加switch(type)分支。
最后说句实在话:做农业数字孪生,别一上来就堆粒子特效和光影追踪。我带过的三个项目,上线最快、用户反馈最好的,都是先搞定模型加载不白屏、数据更新不卡顿、点击3D对象能立刻弹出准确图表这三件事。剩下的炫技,等农民伯伯用顺手了再加——毕竟对他们来说,少一次误判霜冻,比多十个3D动画都实在。希望帮到你。
本文还有配套的精品资源,点击获取