ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

高德地图+Three.js实战:构建数字农场3D可视化大屏

高德地图+Three.js实战:构建数字农场3D可视化大屏 如果你想做一个数字农场3D可视化大屏又不想从零开始自己画地图底图那么“高德地图 Three.js”这套组合是我实测下来最顺手的方案。高德负责真实地理底图和缩放平移交互Three.js 负责在底图上长出带有体积感的 3D 农场。这篇实战博文没有太多花哨的理论我会把从方案选型、坐标同步到场景建模、数据联动、性能优化的完整过程都过一遍核心代码直接贴出来并解释每一步为什么要这么做。适合有一定前端基础、想快速上手地图 3D 可视化项目的开发者参考。1. 项目立项与整体设计先搞明白地图和3D怎么协作1.1 高德地图与Three.js的底层协作原理先把底层机制讲透后面代码才不乱。高德地图 JS API 2.0 在浏览器里渲染的是瓦片和矢量绘图最终呈现在一个 Canvas 上Three.js 会创建另一个独立的 WebGL 渲染画布。两个画布之间互不干扰所以最自然的做法就是高德地图放底层Three.js 画布透明覆盖在上面视觉上合并成一个画面。这样既保真了地图的地理信息又能在地图上进行 3D 几何体绘制。但这带来一个核心问题3D 场景里的每个物体都需要把真实经纬度换算成三维世界坐标。而且当用户拖拽或缩放地图时3D 场景里的模型必须同步变化否则就会出现“模型飞走了”“模型不在对应地块上”的尴尬情况。整个项目的技术难点不在建模而在坐标同步这条链路。我见过不少人一上来就先堆模型结果地图一动所有东西全部错位回过头来又要调坐标体系浪费大量时间。1.2 技术选型为什么是高德地图国内可视化项目里地图选型无非是高德、百度、天地图。我最终选了高德原因很直接加载稳定、JS API 免费额度对中小项目够用、中文文档齐全而且社区里踩坑经验多查问题方便。有人可能会问高德自带 Loca 可视化组件能做热力图、轨迹回放、柱状图等 2.5D 效果为什么还要上 Three.js我的结论是Loca 适合快速出效果但遇到“透明大棚膜”“可旋转观察的立体农田”“自定义 Shader 水波”这类需求时就力不从心了。Three.js 的好处是生态成熟OBJ/GLTF 模型导入、InstancedMesh 批量渲染、自定义材质都支持得很好复杂场景的掌控力远大于地图自带的组件。至于 Cesium它适合城市级别、大地形场景的数字孪生但数字农场通常只是方圆几百米的场景用 Cesium 有点杀鸡用牛刀而且和地图 API 的坐标适配更复杂。所以最终定为高德地图 Three.js 这套轻量组合用最简单的结构解决实际业务问题。1.3 项目目录结构与源码总览先看一下我整理的项目目录整体按功能模块拆分方便后续扩展farm-3d-dashboard/ ├── index.html ├── package.json ├── src/ │ ├── main.js # 入口文件负责启动整个大屏 │ ├── map.js # 高德地图实例创建与事件监听 │ ├── scene.js # Three.js 场景、相机、渲染器、根 Group │ ├── coordinate.js # 经纬度与 3D 世界坐标转换工具 │ ├── cameraController.js # 地图事件与 3D 场景同步控制器 │ ├── models/ │ │ ├── farm.js # 农田、地块、道路 │ │ ├── greenhouse.js # 大棚模型 │ │ └── vegetation.js # 树木、农作物 │ ├── data/ │ │ └── sensors.js # 模拟传感器数据 │ └── panel/ # 左右侧数据面板 │ └── index.js ├── assets/ │ ├── textures/ │ └── models/ └── package.json依赖方面Three.js 建议用 r150 以上版本高德地图 JS API 使用 2.0 版本。构建工具我用的是 Vite开发体验舒服热更新快。具体的依赖版本以你现在安装的为准代码上不会有太大差异。2. 地图底图与Three.js场景融合核心难点实战2.1 初始化地图与渲染器透明叠加是第一步首先创建高德地图实例。注意引入 2.0 版本的 JS API 时需要先在高德开放平台申请 Keyscript srchttps://webapi.amap.com/maps?v2.0keyYOUR_KEY/script然后在地图容器里初始化// map.js const map new AMap.Map(mapContainer, { center: [120.2183, 30.4498], zoom: 17, pitch: 0, viewMode: 2D });这里使用 2D 视图是为了让地图平面和 Three.js 的地面方向一致减少视角换算的复杂度。如果做户外漫游效果可以改用 3D 视图并设置 pitch但那会让相机同步逻辑复杂不少建议先把 2D 跑通再演进。接着创建 Three.js 渲染器并把它覆盖在地图容器上// scene.js const container document.getElementById(mapContainer); const renderer new THREE.WebGLRenderer({ alpha: true, antialias: true }); renderer.setSize(container.clientWidth, container.clientHeight); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.shadowMap.enabled true; renderer.domElement.style.position absolute; renderer.domElement.style.top 0; renderer.domElement.style.left 0; container.appendChild(renderer.domElement);alpha: true是关键因为要让下层的瓦片地图透出来。绝对定位是为了和地图容器对齐。还有一个细节地图容器要设置position: relative否则 Three.js 画布会相对于更外层定位位置全乱。2.2 经纬度转3D坐标把地球坐标系搬进场景高德地图返回的是 GCJ-02 经纬度坐标不能直接当平面坐标使用。好在高德提供了lngLatToContainer方法可以把经纬度转换成容器内的像素坐标。下面是我项目里最核心的工具函数// coordinate.js const ORIGIN map.lngLatToContainer(map.getCenter()); const SCALE 0.02; function lngLatToWorld(lnglat) { const p map.lngLatToContainer(lnglat); return new THREE.Vector3( (p.x - ORIGIN.x) * SCALE, 0, -(p.y - ORIGIN.y) * SCALE ); }这个转换做了三件事第一把经纬度换成容器像素坐标。第二把像素坐标减去中心点对应的像素坐标这样地图中心就是 3D 场景的原点。第三乘以一个 SCALE 比例系数把“像素距离”映射成“3D 世界距离”否则 1 像素对应的三维单位对模型来说会非常大。为什么高度放在y地图平面放在x/z因为 Three.js 默认物理高度轴是 Y阴影、物理引擎、模型导入都是按 Y-up 设计的如果强行把地图平面映射到 XY 平面后面导入角色模型、处理光照阴影时会非常别扭。容器像素的 y 方向是向下的所以取反后放到 z 轴保证地图的“北”对应场景的负 z 方向。SCALE 不是一个固定值。如果农场范围很小比如只有几十米建议把 SCALE 调大让模型有足够的空间表现细节如果范围比较大SCALE 就调小。更稳妥的做法是根据农场边界的经纬度范围动态计算让整个场景主体落在相机视野的中间三分之一区域。2.3 相机同步地图一动3D场景必须跟着动地图和 Three.js 场景各自运行所以必须监听地图的移动和缩放事件并同步 3D 场景。这里有两种思路我分别说清楚。第一种思路是平移根节点。把整个农场模型放在一个sceneGroup里地图拖拽时我们只要平移这个组所有模型都会跟着走map.on(mapmove, () { const origin map.lngLatToContainer(map.getCenter()); sceneGroup.position.set( -origin.x * SCALE, 0, origin.y * SCALE ); });为什么要这样写因为lngLatToContainer是把经纬度转成当前容器里的像素坐标所以地图中心点对应的容器像素坐标其实一直在变化。如果只在初始化时计算一次ORIGIN拖拽地图后所有模型的位置就会错位。正确做法是每次地图移动时动态获取中心点的像素坐标再把sceneGroup平移过去。缩放同步稍微复杂一点。地图在 zoom 变化时瓦片比例按 2 的幂次放大缩小所以sceneGroup也要等比缩放const BASE_ZOOM 17; map.on(zoomchange, () { const factor Math.pow(2, map.getZoom() - BASE_ZOOM); sceneGroup.scale.set(factor, factor, factor); });第二种思路是移动相机。不需要移动场景根节点而是每次地图变化时把相机位置对准新的地图中心点。这种方式更适合做透视视角比如带俯仰角度的立体场景。但如果要做动画过渡需要在拖拽过程中做插值计算量更大。我的建议是如果只是做 2D 俯视大屏优先用平移根节点的方案逻辑最简单模型也不需要因为缩放变化而产生渲染精度问题如果明确要做 3D 视角旋转再用相机方案也不迟。2.4 事件冲突处理谁该响应鼠标Three.js 画布覆盖在地图上之后默认会拦截所有鼠标事件地图就没法拖拽了。这个问题不解决整个项目都玩不下去。最简单的做法是先让 Three.js 画布完全穿透renderer.domElement.style.pointerEvents none;这样鼠标事件全部落到地图上地图可以正常拖拽缩放。但如果要点击 3D 模型就需要在鼠标移动时做 Raycaster 命中检测命中了就让画布接管鼠标事件没命中就继续穿透renderer.domElement.addEventListener(mousemove, (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(sceneGroup.children, true); const isOverModel intersects.length 0; renderer.domElement.style.pointerEvents isOverModel ? auto : none; });这套“事件双通道”的逻辑处理完成后地图拖拽和 3D 点击就能共存。否则你花了很多力气把模型搭出来结果地图被画布盖住拖不动用户第一反应就是“这大屏是不是坏了”。3. 数字农场场景建模从零搭建可交互的3D农场3.1 农田地块自动生成有了坐标转换函数建模就变得顺理成章。假设我们手里有几个关键角点的经纬度数组围成一个矩形地块那么可以直接用 Three.js 的 Shape 和 ExtrudeGeometry 生成有厚度的地块// models/farm.js function createFarmField(boundary) { const shape new THREE.Shape(); boundary.forEach((point, index) { const v lngLatToWorld(point); if (index 0) { shape.moveTo(v.x, v.z); } else { shape.lineTo(v.x, v.z); } }); const geometry new THREE.ExtrudeGeometry(shape, { depth: 0.6, bevelEnabled: false }); const material new THREE.MeshStandardMaterial({ color: 0x6da56a, roughness: 0.8, metalness: 0.1 }); const mesh new THREE.Mesh(geometry, material); sceneGroup.add(mesh); return mesh; }这里有一个很容易踩的坑顶点顺序必须是逆时针如果按顺时针给点挤压出来的地块法线会朝下灯光照上去整个模型是黑的。调试的时候如果不确定方向可以先让材质设置side: THREE.DoubleSide等位置方向没问题后再改回单面渲染能省不少性能。地块边界之外可以加一圈田埂用LineLoop把边界点连起来颜色用浅土黄视觉上地块的轮廓立刻清晰const linePoints boundary.map(item { const v lngLatToWorld(item); return new THREE.Vector3(v.x, 0.1, v.z); }); const lineGeometry new THREE.BufferGeometry().setFromPoints(linePoints); const lineMaterial new THREE.LineBasicMaterial({ color: 0xc2a15c }); const outline new THREE.LineLoop(lineGeometry, lineMaterial); sceneGroup.add(outline);3.2 大棚、房屋与道路的组合搭建大棚是农场场景里的核心建筑。我用圆柱体切片来做大棚的拱形骨架再套一层半透明材质模拟大棚膜// models/greenhouse.js function createGreenhouse(lnglat, width, length, height) { const group new THREE.Group(); const pos lngLatToWorld(lnglat); const archGeo new THREE.CylinderGeometry( width / 2, width / 2, length, 16, 1, true, 0, Math.PI ); const archMat new THREE.MeshPhysicalMaterial({ color: 0x88ccff, transparent: true, opacity: 0.35, roughness: 0.1, side: THREE.DoubleSide }); const arch new THREE.Mesh(archGeo, archMat); arch.rotation.z Math.PI / 2; arch.position.set(pos.x, height, pos.z); group.add(arch); // 内部骨架 const frameMat new THREE.MeshStandardMaterial({ color: 0xdddddd }); for (let i -length / 2; i length / 2; i length / 4) { const halfRing new THREE.Mesh( new THREE.TorusGeometry(width / 2, 0.05, 8, 16, Math.PI), frameMat ); halfRing.rotation.z Math.PI / 2; halfRing.position.set(pos.x, height, pos.z i); group.add(halfRing); } sceneGroup.add(group); return group; }房屋就相对简单了用 BoxGeometry 做主体ConeGeometry 做屋顶组合起来就是一个有辨识度的管理用房。道路则可以用CatmullRomCurve3生成一条平滑曲线再用TubeGeometry挤出路面const curve new THREE.CatmullRomCurve3( points.map(p lngLatToWorld(p)) ); const tubeGeo new THREE.TubeGeometry(curve, 64, roadWidth / 2, 8, false); const roadMat new THREE.MeshStandardMaterial({ color: 0x8a8a8a }); const road new THREE.Mesh(tubeGeo, roadMat); road.position.y 0.05; sceneGroup.add(road);道路位置略微抬高 0.05是为了防止和农田地块发生 z-fighting 闪烁。这种高度微调在地图类项目里很常见养成习惯。3.3 树木、农作物等装饰细节装饰细节决定了场景的“农场感”。最省面数的做法是用广告牌即始终面向相机的贴图平面。对于树木我用 Cursor 式的方法树杆用矮圆柱树冠用球体或广告牌纹理。有朋友聊天时提到“Three.js 柳树”说想渲染垂柳的枝条。柳树的垂枝如果用真实线段渲染顶点数量会非常夸张。我的建议是要么用广告牌贴图直接带透明通道的柳树图片要么用细长的 Line 模拟少数主要枝条不要逐根还原。3D 可视化讲究的是“可信”而不是“完全真实”在性能允许的情况下做适度简化。农作物是另一个容易拖垮性能的地方。一个农场可能有几千株水稻或蔬菜如果每一株都是一个独立 Meshdraw call 会顶到天。用InstancedMesh是标准解法// models/vegetation.js const count 5000; const geometry new THREE.ConeGeometry(0.15, 0.8, 6); const material new THREE.MeshStandardMaterial({ color: 0x4d9b3d }); const instancedMesh new THREE.InstancedMesh(geometry, material, count); const dummy new THREE.Object3D(); for (let i 0; i count; i) { // 在农田范围内随机生成 x, z 坐标 const x Math.random() * fieldWidth - fieldWidth / 2; const z Math.random() * fieldDepth - fieldDepth / 2; dummy.position.set(x, 0.4, z); dummy.scale.set(0.8 Math.random() * 0.5, 1, 0.8 Math.random() * 0.5); dummy.updateMatrix(); instancedMesh.setMatrixAt(i, dummy.matrix); } sceneGroup.add(instancedMesh);InstancedMesh 的核心价值就是同一份几何体 同一份材质用矩阵批量渲染几千个实例draw call 只增加几次性能远超逐 Mesh 生成。3.4 材质、光照与动态效果基础的灯光我建议用HemisphereLight加一个DirectionalLight。前者负责模拟天空和地面之间的环境光后者负责产生阴影const hemiLight new THREE.HemisphereLight(0xffffff, 0x444444, 0.6); scene.add(hemiLight); const sunLight new THREE.DirectionalLight(0xffffff, 1.5); sunLight.position.set(50, 80, 30); sunLight.castShadow true; sunLight.shadow.mapSize.set(1024, 1024); scene.add(sunLight);阴影贴图大小不用开太高1024x1024 在大多数农场地块上已经够用了开到 2048 或者 4096 会明显增加内存和渲染压力。动态效果是让大屏“活”起来的关键。我做了一个非常简单的作物生长动画通过时间的 uniform 控制植被的 scale让它们从矮到高缓慢生长。水塘的波纹则可以用 ShaderMaterial 写一个正弦波把时间传进 shaderconst waterUniforms { uTime: { value: 0 } }; const waterMat new THREE.ShaderMaterial({ uniforms: waterUniforms, vertexShader: ..., fragmentShader: ... });在动画循环里更新uTime.value就能看到水波流动。这个效果开销不大但视觉提升非常明显。4. 数据接入与可视化联动4.1 大屏布局与组件划分大屏主体采用 CSS Grid 经典布局顶部是标题栏和实时时钟左右两侧是数据面板中间区域就是高德地图 Three.js 的 3D 场景。左右面板宽度固定 320px中间场景区自适应。面板用半透明深色背景避免遮挡地图形状和 3D 模型。前端组件层面我把数据面板拆成几个独立模块环境监测、设备状态、生产统计、告警信息。每个模块只关注自己的数据渲染通过一个总线式的状态管理器订阅数据变更。这一步的价值在项目中期会体现出来当你需要接入真实传感器数据时不需要去改 3D 场景代码只需要把数据源换成 WebSocket 推送面板和模型都会自动更新。4.2 传感器数据驱动3D状态变化数字农场最大的看点就是“用数据驱动场景”。比如大棚内温度高于 30 摄氏度大棚整体变红湿度低于 40%地块颜色偏黄正常时保持绿色。给每个 3D 物体挂上userData是数据联动的关键设计arch.userData { id: greenhouse-01, type: greenhouse, boundary: boundaryPoints, sensor: { temperature: 25.6, humidity: 60, status: normal } };数据刷新时通过 id 找到对应的 Mesh动态改材质颜色// data/sensors.js const sensorData [ { id: greenhouse-01, temperature: 31.2, humidity: 45, status: warning } ]; function updateGreenhouseStatus(data) { const mesh modelIndex.get(data.id); if (!mesh) return; if (data.status warning) { mesh.material.color.setHex(0xff7043); mesh.material.emissive.setHex(0x331100); } else { mesh.material.color.setHex(0x88ccff); mesh.material.emissive.setHex(0x000000); } }实际项目中传感器数据可能来自轮询接口也可能来自 WebSocket 实时推送。前者实现简单几秒一次即可后者实时性更强适合温湿度这类变化较快的环境数据。我建议先做轮询等业务需要再平滑切到 WebSocket。4.3 点击拾取与详情弹窗点击 3D 物体弹出详情使用的是 Three.js 的 Raycasterrenderer.domElement.addEventListener(click, (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(sceneGroup.children, true); if (intersects.length 0) { const obj intersects[0].object; const data getSensorsByModelId(obj.userData.id); showInfoPanel(obj.userData, data); highlightModel(obj); } });详情弹窗我采用的是 HTML 覆盖层方案不用在 Three 场景里画 Sprite 文字这样样式更好控制也方便展示复杂内容。弹窗里的字段根据userData.type决定展示哪些维度大棚展示温度、湿度、通风状态农田展示土壤墒情、预计产量设备展示运行状态、今日工作时长。高亮效果可以通过给材质增加emissive值来实现比修改颜色更柔和。如果选中了某个大棚可以让周围农田降低一点亮度形成视觉焦点。这个细节虽然很小但做出来的效果就会比“只会变颜色”的方案高级不少。5. 性能优化与常见问题排查5.1 场景性能优化清单地图场景叠加 3D 模型后性能问题比纯前端页面更明显。我整理了一份自己常用的优化清单合并静态几何体农田地块、道路这类不变形的模型尽量用BufferGeometryUtils.mergeGeometries合并成一个几何体减少 draw call。InstancedMesh 批量渲染植被、围栏、路灯这类重复对象用实例化渲染。限制阴影范围只让少量重要模型投射阴影不要所有物体都开castShadow。控制纹理体积纹理尺寸压缩到 512 或 1024使用 JPG/WebP 格式尽量不用大尺寸 PNG。开启动态剔除确保对象frustumCulled默认为 true离相机太远的物体自动不渲染。使用 stats.js 监控开发阶段把 FPS 和 draw call 数据挂到页面上明确每次优化的效果。在我这个农场项目里目标是把 draw call 控制在 50 以下。你可以打开渲染器的 stats 面板看数字如果某个操作让 draw call 翻倍就要仔细分析值不值得。5.2 常见问题速查表这里有我在项目里踩过的一些典型问题按“现象 - 原因 - 解决方案”整理成表方便你直接对照排查。现象原因解决办法地图被 3D 画布挡住无法拖拽Three.js 渲染器 canvas 覆盖在地图上方默认给 renderer canvas 设置pointerEvents: none3D 模型位置和底图对不上像素坐标没有减去中心点或 y 方向没有翻转统一走lngLatToWorld转换函数地图缩放后模型位置漂移瓦片缩放比例变化场景组未同步缩放监听zoomchangefactor 2^(zoom - BASE_ZOOM)大棚模型整体是黑色的几何体法线朝向错误光照照不到正面材质临时设置side: THREE.DoubleSide定位问题长时间运行页面变卡动画循环里有残留对象材质和几何体未释放组件卸载时调用geometry.dispose()和material.dispose()高德地图 Key 不生效域名不在高德平台白名单到高德开放平台控制台添加当前访问域名首次加载场景空白初始化 Three 时容器尺寸为 0或高德地图还没加载完成在map.on(complete)之后初始化 Three.js5.3 高德API使用中的几个“坑”关于高德地图 API网上反馈比较多的就是收费和配额问题。我的经验是个人学习和内部演示走免费配额完全够用但一定要在项目启动前去控制台看清楚规则尤其是并发量和调用量限制。上线商用前把价格页读一遍算好一个月大概会发起多少次地图加载请求和地理编码请求做到心里有数。另外Key 的域名白名单一定要配置好否则部署到新环境时地图加载失败排查起来非常麻烦。还有一个容易忽略的坐标系问题高德返回的是 GCJ-02 坐标系如果后端给的是 GPS 原始坐标也就是 WGS-84直接扔给高德展示会有几十到几百米的偏移。遇到这种问题要么后端统一转成 GCJ-02要么前端调用高德的坐标转换接口千万别在 Two.js 场景里手动加个偏移糊弄过去换一个城市就全废了。高德地图 JS API 2.0 与 1.x 的差异也要注意。1.x 大量使用回调2.0 更偏向 Promise 和 async 风格。如果网上搜到的老代码在 2.0 里报错先看看是不是版本 API 不兼容同一份代码不一定是 bug。6. 写在最后一点经验与后续扩展做这种地图 3D 大屏我最大的体会是先把坐标同步这条链路彻底打通再去做模型。很多新手容易一上来就堆模型等模型建好了一测位置对不上然后回过头来改坐标系相当于返工。我的习惯是先用几个彩色 BoxGeometry 放在不同经纬度点位上验证平移、缩放、点击都能对上再把正式模型替换进去省时省力。另外就是数据接口的设计。如果你预知后面要接真实 IoT 平台建议一开始就把数据协议定义清楚每个设备点要包含 id、type、经纬度、状态字段和扩展数据前端通过 userData 做映射。这样后面无论是换传感器型号还是加采集点前端都不用大规模改动。代码片段已经覆盖了地图初始化、坐标转换、场景同步、模型生成、点击拾取和数据联动这条完整链路完整源码我整理到资源包里了需要的话可以在评论区留言或者私信获取。后续我还计划在同一个框架上改成数字乡村、智慧园区这类大屏到时候有新坑或者新方案再回来分享。
返回列表