刚接这个项目的时候,客户提的需求听上去很简单:把农场的地块、大棚、农机位置放到地图上,周围环境数据能实时看到就行。但真正动手就会发现,传统2D地图上的标记点完全满足不了“数字农场”的展示需求——地块边界要清晰、大棚要有立体感、农机得动起来、环境数据还得直观。这就引出了高德地图和Three.js的组合方案:用高德地图提供真实的地理底图和坐标信息,用Three.js在页面上叠加一个3D场景,两者配合,把农场的每一个要素都变成可交互的三维对象。这套思路不只适用于农业,任何数字孪生项目(园区、工地、厂区)都能用同样的套路落地。
整个项目的核心难点其实就两个:一是高德地图的坐标体系怎么和Three.js的场景坐标系对齐;二是两个渲染引擎怎么在同一个页面上共存且不打架。只要把这两个问题想明白,剩下的就是堆功能的事。
1. 项目定位与整体设计思路
1.1 核心需求拆解:一张大屏到底要展示什么
数字农场大屏在国内的智慧农业项目里已经很常见,但它绝不是“地图+几个3D方块”那么简单。我在做需求分析时,把客户的原始描述拆成了四个层面:
- 地理层:农场边界、地块分布、道路沟渠、水源位置,必须落在真实地图上,能看清周边环境。
- 设施层:每个大棚、泵房、仓库、设备间要有独立的三维模型,能表达体积感和空间关系。
- 动态层:农机(收割机、无人机)、灌溉设备的状态要动起来,比如收割机沿路径移动、风机转动。
- 数据层:空气温湿度、土壤墒情、光照强度、设备运行状态,这些数据要以悬浮标签、信息面板或颜色映射的方式叠加到对应的3D对象上。
这四个层面最合理的实现方式,就是像搭积木一样分工合作:高德地图管“在哪里”,Three.js管“长什么样”,前端业务逻辑管“怎么办”。
1.2 技术选型:为什么是高德地图而不是百度地图或Cesium
做3D地图可视化,市面上可选的方案并不少。有人会问,为什么不直接用Cesium加载倾斜摄影或白模,或者用Mapbox GL的3D地形?这就要看项目所处的真实约束条件。
- 高德地图JS API 2.0免费额度对国内项目非常友好,且不需要挂国际网络服务,域名白名单配置也灵活,适合政务、农业这类对内展示的项目。
- 高德的GCJ-02坐标系本身就是国内标准,地块边界数据如果是测绘院给的点位,通常也是这个坐标系,省去二次转换。
- Three.js(r150+版本)生态成熟、文档全面,社区资源多,招人容易。相比Cesium,Three.js的学习曲线在“几何建模”和“动画控制”这两块更平缓。
- 高德自带AMap.Loca组件可以做热力、蜂巢、飞线等图层,但它做不了真正的三维立体模型(比如带纹理的大棚、可动机械臂),所以必须叠加Three.js。
选型结论很明确:高德地图负责地理参考和底图,Three.js负责三维内容渲染,两者通过坐标换算和DOM叠加实现融合。这个组合也是目前数字孪生项目中非常主流的一条路。
1.3 整体架构:前后端分离与模块划分
整个大屏系统的架构我设计成了前后端分离的模式,这也是近年比较通用的做法。
- 前端Vue3 + Vite + Three.js + 高德地图JS API 2.0。
- 后端Node.js + Express提供模拟数据接口(如果以后接物联网平台,换成MQTT或WebSocket实时推送即可)。
- 数据存储MySQL或MongoDB,跑批任务负责汇总传感器数据。
前端内部的层次再细化一下就更有谱了:
- 地图模块:初始化高德地图,绑定事件,提供“经纬度 -> 平面坐标”的转换接口。
- 场景模块:Three.js场景、相机、渲染器的生命周期管理。
- 模型模块:负责加载/创建农场所有3D对象,返回可交互对象列表。
- 数据模块:定时请求后端接口,驱动标签刷新和设备状态更新。
- 交互模块:鼠标拾取、点击弹窗、视角飞行。
这种分层让不同模块之间的通信变得很干净。地图模块只负责地理层面的逻辑,不需要知道Three.js里有多少个模型;模型模块只关心怎么摆放3D对象,不关心数据从哪来。后面扩展新功能时,只需要往对应模块里加东西就行,不用满世界找代码。
2. 高德地图接入与底图配置
2.1 获取Key与初始化地图的完整步骤
高德地图JS API 2.0的使用门槛不高,但有几个细节会影响后续开发。先在控制台创建一个应用,Key类型选“Web端(JS API)”,然后配置域名白名单。本地调试时,这个白名单经常把新手坑到:如果你在本地用IP访问,但白名单只配了localhost,一样会报错,所以可以把本地IP也加进去。
然后在index.html里引入高德地图的JS脚本:
<script src="https://webapi.amap.com/maps?v=2.0&key=YOUR_KEY&plugin=AMap.Scale,AMap.ToolBar,AMap.MapType"></script>我习惯把常用插件(比例尺、缩放工具、图层切换)用plugin参数一次性加载,这样省得在代码里动态引入,也避免某些插件在异步加载时的时序问题。
初始化地图的核心代码长这样:
const map = new AMap.Map('mapContainer', { center: [116.348, 39.895], // 农场中心经纬度,替换成你的实际位置 zoom: 17, mapStyle: 'amap://styles/darkblue', // 深色底图,适合大屏展示 viewMode: '2D', pitch: 0, showLabel: true, features: ['bg', 'road', 'building', 'point'] });这里单独说下mapStyle,大屏项目通常用深色系底图,因为3D模型的亮色和发光效果在深色背景上更突出。高德提供了不少官方样式,比如darkblue、dark、fresh,都行。如果你业务里需要把地图压暗、突出3D模型,可以在样式基础上再加一层半透明黑色遮罩。
2.2 自定义底图样式与3D场景的视觉协调
地图底图和Three.js场景能不能“融合”,很大程度取决于底图颜色和模型配色是否在同一套视觉体系里。
我的做法是给底图套一个CSS滤镜,让地图整体变暗、饱和度降低:
#mapContainer { filter: saturate(0.7) brightness(0.85); }模型那边则用比较高的饱和度,并加上发光边缘(EdgesGeometry + LineSegments),这样3D对象就能压过底图,成为视觉中心。
高德地图还有个很实用的配置——features字段,可以控制显示哪些地图要素。在大屏场景里,poi(兴趣点)一般不需要,地图外围的建筑物标注也最好去掉,否则画面很乱:
const map = new AMap.Map('mapContainer', { features: ['bg', 'road', 'building'] });需要注意的是,关掉poi标注后,地图的“人气”会弱很多,但在展示农场自身要素的3D大屏上,这种弱化是刻意为之。用户要看的是农场的各种设施,不是周边的商铺。
2.3 地图事件与渲染时机处理
高德地图底层是Canvas渲染,而Three.js也在同一个页面上有自己的Canvas,两者如果都开连续渲染会导致性能浪费。实际开发中注意配合生命周期:
- 地图只有交互(拖拽、缩放、旋转)时才需要重绘,默认是自动重绘,不用你操心。
- Three.js场景如果没有动画,应该用
requestAnimationFrame按需渲染,而不是每帧都执行。大屏上的数据轮播通常3~5秒一次,完全没必要让GPU满负荷工作。
一个很实用的做法:监听地图的moveend和zoomchange事件,在地图停止变化后再同步Three.js相机位置。具体怎么同步,我在第4章会详细写。
map.on('moveend', syncCameraFromMap); map.on('zoomchange', syncCameraFromMap);这段逻辑的价值在于,当地图缩放时,Three.js里的3D模型也要等比放大缩小,否则会出现“地图像素在变,模型却纹丝不动”的割裂感。
3. Three.js场景搭建与农场模型构建
3.1 初始化Three.js核心组件:场景、相机、渲染器
Three.js的场景初始化是固定套路,但有几个参数要在农场大屏场景里特别注意。我的习惯是用透视相机。
import * as THREE from 'three'; import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; const scene = new THREE.Scene(); scene.background = null; // 透明背景,让底图透出来 const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 5000); camera.position.set(0, 300, 400); camera.lookAt(0, 0, 0); const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, // 关键:开启透明通道,才能露出下面的高德地图 logarithmicDepthBuffer: false }); renderer.setSize(window.innerWidth, window.innerHeight); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); document.getElementById('threeContainer').appendChild(renderer.domElement);alpha: true非常重要,否则Three.js的Canvas会带黑色背景,把地图全挡住了。同时把Three.js的Canvas绝对定位在地图Canvas上方,设置pointer-events: none,这样鼠标事件还能穿透到底图上,地图拖拽缩放才正常。
3.2 农场核心模型的构建:地块、大棚、仓库、农机
农场3D模型不一定要从外部导入,用Three.js基础几何体组合就能达到不错的可视化效果。这是数字孪生里很常见的做法——模型要表达的核心是“位置、形状、状态”,不是工业级别的精细度。
地块构建
地块的边界在真实场景里是经纬度坐标串。利用高德地图的AMap.GeometryUtil或者你已有的GeoJSON数据,把边界点转换成屏幕坐标(或者我第4章要讲的投影坐标),然后用Three.js的Shape生成地块平面:
function createField(boundaryPoints) { const shape = new THREE.Shape(); boundaryPoints.forEach((p, i) => { if (i === 0) shape.moveTo(p.x, p.y); else shape.lineTo(p.x, p.y); }); shape.closePath(); const extrudeSettings = { depth: 8, bevelEnabled: false }; const geometry = new THREE.ExtrudeGeometry(shape, extrudeSettings); const material = new THREE.MeshPhongMaterial({ color: 0x3a9e5c, transparent: true, opacity: 0.85, side: THREE.DoubleSide }); return new THREE.Mesh(geometry, material); }每个地块的高度设为8米,视觉上就像微微凸起的一块“田”,叠加半透明质感后能透出底图上的道路信息,整体层次感会好很多。
大棚构建
大棚可以用半圆柱体来近似,这个形状用CylinderGeometry旋转90度做一半就行,或者用TubeGeometry沿一条直线路径生成:
function createGreenhouse(width, length, height) { const group = new THREE.Group(); // 底座 const base = new THREE.Mesh( new THREE.BoxGeometry(width, 0.5, length), new THREE.MeshPhongMaterial({ color: 0x8a8a8a }) ); group.add(base); // 拱棚(用半圆柱近似) const archGeo = new THREE.CylinderGeometry(height / 2, height / 2, length, 16, 1, true, 0, Math.PI); const arch = new THREE.Mesh(archGeo, new THREE.MeshPhongMaterial({ color: 0xffffff, transparent: true, opacity: 0.45, side: THREE.DoubleSide })); arch.rotation.z = Math.PI / 2; arch.position.y = height / 2; group.add(arch); return group; }注意:真大棚的弧线比半圆更扁,所以高度参数不要设太大,不然看起来像一根钢管。我通常把house高度控制在宽度的三分之一左右。
仓库和泵房
仓库泵房这类建筑用BoxGeometry+房顶组合就够,房顶用ConeGeometry做成坡顶或者四棱锥顶。不需要追求外立面细节,颜色和轮廓能区分出来就行:
function createWarehouse(w, d, h) { const group = new THREE.Group(); const body = new THREE.Mesh( new THREE.BoxGeometry(w, h, d), new THREE.MeshPhongMaterial({ color: 0x5086c8 }) ); body.position.y = h / 2; group.add(body); const roof = new THREE.Mesh( new THREE.ConeGeometry(Math.max(w, d) * 0.75, h * 0.4, 4), new THREE.MeshPhongMaterial({ color: 0x3a5a8a }) ); roof.rotation.y = Math.PI / 4; roof.position.y = h + h * 0.2; group.add(roof); return group; }农机
农机要动起来,所以单独建一个类。收割机简化成:一个车身方块 + 一个驾驶室方块 + 四个轮子圆柱 + 前面的割台。库里机械同理,只做外形上的区别(比如割台宽度)。
3.3 添加环境光照与阴影效果
Three.js里模型好看不好看,光照占一半。农场大屏里我用了三光源方案:
- 环境光(AmbientLight):提供基础亮度,避免模型死黑。强度0.4左右。
- 平行光(DirectionalLight):模拟太阳光,产生阴影。强度0.8,位置放在斜上方(比如(100, 200, 100)),保证模型立体感。
- 半球光(HemisphereLight):天蓝地绿,让露天场景的物体不至于颜色太生硬。
阴影方面,大棚、仓库这类建筑给个投影会更有真实感,但地块这种大面积透明物不要开阴影,否则投射阴影会乱成一片。设置阴影时用ShadowMapType的VSM(VarianceShadowMap)在低分辨率下更柔和,适合大屏远距离观看:
renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.VSMShadowMap;4. 高德地图与Three.js融合:坐标转换与同步
4.1 坐标转换算法:从经纬度到Three.js世界坐标的核心步骤
这是整个项目最关键的一环。高德地图给出的经纬度是GCJ-02(火星坐标系),而Three.js世界里用的是米为单位的笛卡尔坐标。我的做法是用Web墨卡托投影把经纬度转成平面坐标,再以地图中心点为原点,换算成局部偏移坐标。
具体算法分三步:
- 用高德提供的
AMap.CRS或者自己实现Web墨卡托投影:
function lngLatToMercator(lng, lat) { const x = lng * 20037508.34 / 180; let y = Math.log(Math.tan((90 + lat) * Math.PI / 360)) / (Math.PI / 180); y = y * 20037508.34 / 180; return { x, y }; }选定农场中心点(经纬度),计算出它的投影坐标,作为Three.js世界坐标系的原点。
任意一个经纬度(lng, lat)转成Three.js坐标就是:
function toThreeCoord(lng, lat, centerMercator) { const merc = lngLatToMercator(lng, lat); return new THREE.Vector3( merc.x - centerMercator.x, 0, centerMercator.y - merc.y // 注意:Three.js的Z轴方向和地图Y轴方向相反 ); }为什么要做这一步转换?因为如果直接把经纬度差值当作Three.js坐标,1度纬度对应的距离在赤道附近约111km,在本地项目里数值非常巨大,而且X和Z方向的比例并不完全一致,会出现模型横向拉伸或压缩。用Web墨卡托投影转换后,单位是米,X和Z方向比例一致,模型不会变形。
在实际项目中,我把这个转换函数封装成模块(coordTransform.js),并提供反向转换(Three.js坐标到经纬度),用于点击模型后高德地图地图定位。
4.2 相机同步:让3D物体跟随地图移动、缩放
很多仿写这个项目的代码会把Three.js场景固定为全屏透明层,然后用绝对定位让两者叠加。理论很简单,但同步有个大坑:高德地图在拖拽、缩放时,底图变了,Three.js场景如果不动,模型和地图就错位了。
我采用的同步策略是每一帧(或地图事件触发时)更新Three.js相机的位置和方向,让3D世界里的视口对应底图上的地理范围。
简单高效的同步逻辑:
let centerMercator = lngLatToMercator(116.348, 39.895); function syncCameraFromMap() { const bounds = map.getBounds(); const ne = bounds.getNorthEast(); // 右上角 const sw = bounds.getSouthWest(); // 左下角 const neMerc = lngLatToMercator(ne.lng, ne.lat); const swMerc = lngLatToMercator(sw.lng, sw.lat); // 计算当前视口对应的地理宽度、高度(米) const width = neMerc.x - swMerc.x; const height = neMerc.y - swMerc.y; // 调整Three.js相机:保持场景视野与地图视口一致 const fov = camera.fov * Math.PI / 180; const distance = (height / 2) / Math.tan(fov / 2); camera.position.set(neMerc.x - centerMercator.x, distance, neMerc.y - centerMercator.y); camera.lookAt(neMerc.x - centerMercator.x, 0, neMerc.y - centerMercator.y); }但这种方式计算量大且容易产生抖动。在我的实际项目里,我更推荐另一种做法:Three.js场景使用正交相机(OrthographicCamera),让场景的坐标范围严格对应地图的地图缩放级别,拖拽地图时直接平移相机位置。正交相机也更适合“3D模型叠加地图”这类视觉一致性要求高的场景。
const frustumSize = 200; // 初始视口范围(米) const camera = new THREE.OrthographicCamera( frustumSize / -2, frustumSize / 2, frustumSize / 2, frustumSize / -2, 0.1, 2000 );用正交相机的好处是,不管地图放大还是缩小,模型不会因为透视产生严重的尺寸变化,看起来更像是“贴”在地图上的3D图标。缺点是透视感弱一点,但数字大屏通常不需要强烈的透视感。
4.3 透视相机与正交相机怎么选
好多初学者在第一步就纠结:到底用哪个相机?我的结论很实用:
- 如果你的场景是“看一个大区域”的全貌(整个农场几千亩),用正交相机更稳定,比例关系不会骗人。
- 如果场景是“围绕单栋大棚/单台设备做特写旋转”,用透视相机加OrbitControls更自然。
农场大屏通常两者结合:全览用正交相机,点击某个大棚后,切换成透视相机并飞近特写。切换时给相机做一个补间动画(tween),体验会非常顺滑。
这里有一个切换时的函数模板:
function switchToCloseUp(modelPosition) { const worldPos = modelPosition.clone(); const targetCamPos = worldPos.clone().add(new THREE.Vector3(30, 20, 30)); new TWEEN.Tween(camera.position) .to({ x: targetCamPos.x, y: targetCamPos.y, z: targetCamPos.z }, 800) .start(); }注意切换相机类型时,要同步修改相机投影矩阵(camera.updateProjectionMatrix()),不然画面上会出现一帧撕裂。
5. 模型交互、数据联动与动画实现
5.1 3D场景中的点击拾取:Raycaster的实战用法
3D大屏如果没有交互,再好看也就是个宣传片。我做的第一个交互是点击模型弹出信息面板,这个用Three.js的Raycaster(射线投射)很直接。
核心逻辑:从相机位置发出一条射线,经过鼠标点击的屏幕坐标方向,检测这条射线有没有打到场景里的物体。
const raycaster = new THREE.Raycaster(); const mouse = new THREE.Vector2(); function onModelClick(event) { const rect = renderer.domElement.getBoundingClientRect(); mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; raycaster.setFromCamera(mouse, camera); const intersects = raycaster.intersectObjects(interactiveObjects, true); if (intersects.length > 0) { const obj = intersects[0].object; const data = obj.userData; // 在创建模型时把业务数据挂上去 showInfoPanel(data); } } renderer.domElement.addEventListener('click', onModelClick);这里的interactiveObjects是场景中所有可交互模型的数组。每个模型在创建时,都要把业务ID、类型、名称等信息存到userData里,这样点击后就能直接查数据,不用再做索引映射。
有个细节要提醒:大棚的拱棚部分是半透明材质,透明物体默认不会参与射线检测(或者检测结果很诡异)。所以我在给大棚创建交互区时,加了一个不可见的Box碰撞体(invisible box)挂在同一个group下:
const hitBox = new THREE.Mesh( new THREE.BoxGeometry(width, height, length), new THREE.MeshBasicMaterial({ visible: false }) ); hitBox.position.y = height / 2; group.userData = { id: greenhouse.id, name: greenhouse.name }; group.add(hitBox);然后把group本身加入到interactiveObjects。这样点击大棚时,不管是点在拱棚上还是点在空中,只要在包围盒范围内都能选中。
5.2 实现悬浮标签:CSS2DRenderer与业务数据绑定
点击弹窗是最基础的交互,大屏上更常用的是悬浮标签——鼠标一滑过模型,旁边就飘出一个温湿度小卡片。这个我用的是Three.js的CSS2DRenderer:
import { CSS2DRenderer, CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer.js'; const labelRenderer = new CSS2DRenderer(); labelRenderer.setSize(window.innerWidth, window.innerHeight); labelRenderer.domElement.style.position = 'absolute'; labelRenderer.domElement.style.top = '0'; labelRenderer.domElement.style.pointerEvents = 'none'; document.body.appendChild(labelRenderer.domElement); function createLabel(text) { const div = document.createElement('div'); div.className = 'model-label'; div.innerHTML = text; return new CSS2DObject(div); }CSS2DRenderer的好处是标签能用CSS随便缩放、加阴影、加动画,性能开销极低。缺点是它始终面向屏幕,不能跟着模型旋转。但对于信息展示来说,面向屏幕反而是优点,可读性最好。
数据绑定上,我会用一个labelMap来管理标签和模型的对应关系,数据更新时只更新映射到的DOM内容,不重建标签对象,避免频繁创建销毁引发性能问题。
5.3 农机路径动画与状态可视化
农机移动是数字农场大屏最有“活气”的部分。我的实现思路:预先定义一条农机行走路径(一系列经纬度点),转换成Three.js坐标后,用Three.js的CatmullRomCurve3建一条平滑曲线,然后让农机沿着曲线移动,同时调整机头朝向。
const curve = new THREE.CatmullRomCurve3(points); let progress = 0; function animateHarvester(delta) { progress += delta * 0.02; // 速度控制 if (progress > 1) progress = 0; const pos = curve.getPointAt(progress); // 位置 const tangent = curve.getTangentAt(progress); // 朝向 harvester.position.copy(pos); harvester.position.y = 1; // 悬浮在地面之上一点 harvester.lookAt(pos.clone().add(tangent)); }农机状态可视化我用了颜色映射:正常运行颜色不变,低电量变成黄色,故障变成红色闪烁。这个用材质颜色变化就能实现,不复杂但很直观。
5.4 数据接入:前端如何对接后端接口
这里说一下项目中真正用的数据接口设计。后端Express暴露了这么几个接口:
- GET
/api/farm/overview:农场概况(面积、地块数、设备数、今日告警数)。 - GET
/api/farm/greenhouses:大棚列表,含每个大棚温湿度、光照、设备状态。 - GET
/api/farm/machines:农机列表,含位置、状态、电量、作业路径。 - GET
/api/farm/fields:地块边界及土壤墒情数据。
前端每5秒轮询一次,更新大屏上的数据面板和3D模型颜色。如果是正式项目,建议把轮询改成WebSocket长连接,数据刷新更及时,服务端压力也更小。
const API_BASE = 'http://localhost:3000/api'; async function fetchFarmOverview() { const res = await fetch(`${API_BASE}/farm/overview`); const data = await res.json(); updateOverviewPanel(data); }如果想让前端代码更容易带新同学上手,把数据获取封装到一个apiService.js里,每个页面只调方法、不直接发fetch,这样后期接真实后端时只要改封装内部实现就行。
6. 性能优化与常见问题排查
6.1 3D渲染卡顿的常见原因与优化手段
农场大屏地块数量一般在几十到上百,加上大棚、农机、标签,场景对象数量并不算多,理论上不会卡。但如果代码写法不对,还是会掉帧。我在项目中遇到过几种典型情况:
- 原因一:每帧都设置材质颜色或透明度,导致大量着色器重复编译。
- 原因二:大量独立几何体没有合并。大棚的拱棚+底座有几十个Geometry,每个Mesh有自己的draw call,数量一多GPU就累。
- 原因三:阴影计算范围过大,所有物体都投影到一块巨大的Shadow Map上。
优化手段我优先做这几个:
- 使用
InstancedMesh渲染一批相同形状的地块柱子或路灯等重复对象,把draw call降一个量级。 - 减少阴影贴图的尺寸,只对靠近相机的对象启用阴影。
- 使用
renderer.setPixelRatio(Math.min(devicePixelRatio, 2)),防止高清屏像素比过高导致片段着色器压力过大。
const count = 100; const dummy = new THREE.Object3D(); const instancedMesh = new THREE.InstancedMesh(geo, material, count); for (let i = 0; i < count; i++) { dummy.position.set(x[i], y[i], z[i]); dummy.updateMatrix(); instancedMesh.setMatrixAt(i, dummy.matrix); }6.2 高德地图与Three.js事件的冲突处理
浏览器页面里有两层Canvas叠在一起,事件冲突不可避免。我的解决方案分两步:
第一层,给Three.js容器设置pointer-events: none,这样鼠标永远能穿透到地图上,拖拽、双击缩放地图都正常。
第二层,当需要拾取模型时(比如点击大屏上的“查看地块”按钮进入编辑模式),才临时开启pointer-events: auto,并在这段模式下禁用高德地图的拖拽:
function setInteractionMode(enable3D) { threeContainer.style.pointerEvents = enable3D ? 'auto' : 'none'; if (enable3D) { map.setStatus({ dragEnable: false, zoomEnable: false }); } else { map.setStatus({ dragEnable: true, zoomEnable: true }); } }更精细的做法是不用pointer-events开关,而是在Three.js容器上监听鼠标事件时,判断鼠标位置是否在某个模型区域内(用Raycaster检测),如果在,就调用event.stopPropagation()并手动禁止地图拖拽;如果不在,就放行事件。这样交互更自然,用户不用手动切换模式。
6.3 大屏适配与不同分辨率下的布局策略
数字大屏的项目环境非常多样:会议室自带大屏可能是16:5的拼接屏,也可能是4:3的电视。像素比、分辨率都不一样。
我在项目里用了几条通用策略:
- CSS布局用百分比或flex,不要写死宽度。
- 地图容器和Three.js容器都占满整个视口,绝对定位叠加。
- 所有UI卡片用
vw/vh单位或者rem做等比缩放。 - 用MediaQuery调整极端比例下的字体大小。
如果屏幕比例实在太极端,也可以考虑用transform: scale来缩放整个大屏,但会导致鼠标事件坐标偏移,所以我一般不用这种方案。
6.4 常见问题速查表
这里把我在开发过程中遇到的高频问题整理成一张表,方便直接照着排查:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 地图显示出来,3D模型不显示 | Three.js Canvas背景不透明或z-index不对 | 检查alpha: true、z-index层级、pointer-events |
| 3D模型位置和地图明显错位 | 坐标转换公式里正负号不对 | 确认Web墨卡托Y轴方向与Three.js Z轴的关系 |
| 地图拖拽时模型不动 | 没有监听地图moveend/zoomchange事件 | 在事件回调里同步camera |
| 点击大棚选不中 | 半透明物体未参与Raycaster检测 | 添加不可见Box碰撞体 |
| 大屏上文字模糊 | Canvas像素比偏低 | renderer.setPixelRatio调大 |
| 页面内存持续增长 | 定时器/动画帧没有清理 | 在组件销毁时清除setInterval和requestAnimationFrame |
| 高德地图报错“INVALID_USER_SCODE” | Key未生效或域名白名单没配置 | 到高德控制台检查Key、白名单、安全密钥 |
6.5 性能峰值测试与实际数据
我做完这个项目后,在客户现场的大屏(分辨率3840x1080,拼接屏)做了测试。场景里有32个大棚、96个地块、8台农机、20个悬浮标签、2台泵房、1个仓库。开满阴影和抗锯齿,设备是i5-10400 + GTX 1650的工控机,实测平均帧率稳定在50~55fps,CPU占用30%左右,内存占用600MB上下。这个表现对一个大屏展示项目来说完全够用。
当然,如果模型量翻倍,建议离线上缩小纹理尺寸,并用Draco压缩加载glTF模型,而不是继续用Geometry组合。大规模场景下,用GPU instancing是必须的,盲目加模型会让工控机直接卡死。
7. 项目部署与后续扩展思路
7.1 前端构建与Nginx部署
开发完成后的部署很简单。Vite执行打包后,把dist目录丢到Nginx就行。
npm run build scp -r dist/ user@server:/data/www/farm-screen/Nginx配置里注意两个点:
- 静态资源的缓存要打开,毕竟大屏的JS和纹理文件不小,缓存能显著加快二次加载速度。
- 高德地图Key的域名白名单要加上线上域名,否则一部署就报错。
server { listen 80; server_name farm.example.com; root /data/www/farm-screen; index index.html; location /api/ { proxy_pass http://127.0.0.1:3000; } location /assets/ { expires 7d; add_header Cache-Control "public"; } }7.2 接真实物联网数据
目前项目用的模拟数据,实际部署时肯定要接物联网平台。国内农业物联网平台大多支持MQTT或HTTP API推送。前端改造的方式有两种:
- 前端直接订阅MQTT(需要mqtt.js),订阅每个传感器主题,实时更新3D标签。
- 后端接MQTT,前端通过WebSocket订阅后端,由后端统一转发。
考虑到大屏项目的维护性和数据校验,我推荐第二种,就是前后端分离的模式——后端做鉴权、过滤、汇总,前端只负责消费。这样前端代码不用感知底层协议差异。
7.3 扩展方向:从数字农场到通用数字孪生
这套“高德地图+Three.js”的架构是通用的。换个底图、换批模型,就能变成智慧园区、智慧工地、灌区管理、大型养殖基地。之前有朋友拿这个框架做工厂数字孪生,也是同样的技术栈,只是把地块换成了厂房,大棚换成了生产线设备。
做这类项目最核心的能力不是Three.js的强大或高德的丰富,而是能把地理信息和业务信息统一建模,并能用可视化手段让用户理解空间业务关系。这点想明白,换任何场景你都能快速出方案。
最后分享一个项目实践里的小细节:大屏看板不要加太多动画,否则运营人员盯着看半小时就会觉得眼晕。控制在3~4个视觉动效范围内,节奏稳定,比什么都重要。我做第一版时给每个地块都加了点点闪烁效果,结果客户反馈屏幕太“花了”,后来改成了只有异常报警时才高亮,反而更受好评。所以做可视化,克制和准确永远是第一位。