制作數字農場3D可視化大屏
|
admin
2025年4月11日 15:57
本文熱度 48
|
1.介紹
數字農業可視化是一種將農業生產過程中的各類數據,通過先進的信息技術手段進行采集、整合、分析,并以直觀的可視化形式呈現出來的技術應用模式。它利用大數據、物聯網、人工智能、GIS等技術,為農業生產經營管理提供了全新的、高效的決策支持工具,使農業從業者能夠更加清晰、準確地了解農業生產的各個環節,從而實現精準決策、精細管理和高效運營。
最近對數字農業有點感興趣,于是就有了接下來的探索和嘗試,本文的內容比較有綜合性,基本上用到了之前在技術社區分享的大部分經驗,不僅包括高德開發平臺的技術,也集成了具體業務分析、GIS數據生成、3D模型制作等內容。附演示頁面地址,源代碼地址見文末。
2. 需求分析
本次做可視化大屏的開發,我希望最終的開發成果是可以在后續的產品或者項目中復用、至少能發揮一定的參考價值,因此需要做一些業務需求分析。由于我在這方面的業務涉獵比較淺顯,于是先看了幾個智慧農業解決方案方便的PPT,然后詢問AI助手,整理為下面幾個專題的內容:
2.1 基礎配套
- 地形:以三維地形圖的形式呈現,通過不同顏色和高度標識展示區域內的山地、沼澤、平原等地形分布??梢允褂玫雀呔€、陰影等效果增強立體感,讓用戶直觀了解地形的起伏。由于增加地形起伏會直接增加其他貼合地形圖層的實現復雜度,為降低閱讀難度本次示例選了塊地形相對平整的沖擊平原,因此規避地形問題。
- 影像:展示高分辨率的衛星影像圖,全面覆蓋智慧農業所涉及的區域范圍,讓用戶能夠以宏觀視角清晰了解整個區域的全貌,包括地形、河流、村居、植被等基礎配套元素的分布及相互關系。
- 水域:在地圖上清晰標注河流的走向、河道寬度以及與其他水體的連接關系
- 水質:如酸堿度、溶解氧、污染物含量等指標,并以不同顏色或圖表形式在大屏上直觀展示,以保障農業用水安全。
- 村居建筑:展示村莊的分布位置和范圍,以建筑模型或圖標形式呈現村居的布局。

2.2 農業生產
- 農田:以高精度地圖展示農田地塊的邊界和面積,對不同的農田進行編號和分類管理,例如按照種植作物類型、當前使用狀態等進行劃分
- 魚塘:標注魚塘的位置和范圍,顯示魚塘的面積和水深等基本信息。展示魚塘的養殖情況,包括養殖的魚類品種、生長階段、投喂記錄等,方便養殖戶進行科學管理和養殖計劃制定。
- 作物識別:利用圖像識別技術,通過攝像頭或衛星影像對農田中的作物進行實時識別和分類。在大屏上以不同顏色或圖標標注出不同作物的種植區域,方便用戶快速了解農田的作物布局
- 災害預測:通過監測田間的病蟲害發生情況、氣象條件、作物生長狀況等因素,運用病蟲害預測模型,預測病蟲害的發生趨勢和流行范圍。

2.3 安全監管
- 無人機巡查:在地圖上展示無人機的巡查路線和實時位置,用戶可以直觀地看到無人機的飛行軌跡。
- 入侵告警:在地圖上劃定重點安全區域,如農田保護區、魚塘養殖區、倉庫等,當有人員或車輛未經授權進入這些區域時,系統自動觸發入侵告警。
- 重點位置POI:在地圖上標注所有攝像頭的位置,形成 POI(Point of Interest)圖層。用戶可以點擊每個攝像頭圖標,查看該攝像頭的實時監控畫面和相關信息,如攝像頭編號、安裝位置、監控范圍等。

2.4 經濟效益
- 區塊產量預測:對比不同年份或不同種植季節的產量預測數據,分析產量變化趨勢和影響因素,為農業生產規劃和資源配置提供決策依據
- 投入產出比分析:詳細展示農業生產過程中的各項投入成本,包括土地租賃費用、農資采購成本、人工成本、水電費、運輸費用等,并以圖表形式呈現各項成本在總成本中的占比情況,幫助用戶清晰了解成本結構。

3. 技術分析
經過上面的業務需求分析,我們就可以開始將它們轉為技術上的需求模塊進行逐個實現,其中部分圖層可視化效果,使用高德平臺提供的可視化類Loca可以滿足了,其他部分圖層則需要自行開發,這里我將自己平時積累的可視化圖層整理為的gl-layers圖層庫,核心代碼是基于three JS和高德自定義圖層類CustomLayer、GLCustomLayer進行開發。

3.1 技術棧說明
工具名稱 | 版本 | 用途 |
---|
高德地圖 JSAPI | 2.0 | 為GIS平臺提供基礎底圖和服務 | three.js | 0.157 | 主流webGL引擎之一,負責實現展示層面的功能 | QGIS | 3.32.3 | GIS數據處理工具,用于處理本文的矢量化數據 | cesiumlab | 3.1.11 | 三維數據處理工具集,用于將模型轉換為互聯網可用的3DTiles | blender | 3.6 | 模型處理工具,用于對BIM模型進行最簡單的預處理 | CityEngine | 2023.0 | arcGIS團隊開發的程序化 3D 城市生成器 ,支持通過腳本將GIS轉換為3D模型 | vue | 3.2.25 | 實現可視化大屏UI的語言框架,特點是數據雙向綁定 | vite | 2.9.15 | 便捷的前端工程構建工具 | AI Earth |
| 達摩學院提供的AIE-SEM影像識別、分割、提取服務,可以幫忙我們從遙感影像圖片中提取GIS數據 |
3.2 圖層說明
專題 | 內容 | GIS數據類型 | 表現形式 | 代碼層 |
---|
基礎配套 | 衛星影像底圖 | 圖片 | 瓦片地圖 | AMap.TileLayer | 基礎配套 | 村居建筑 | polygon | 三維建筑模型 | GlLayer.TilesLayer | 基礎配套 | 綠化區域 | point | 實例模型 | GlLayer.TilesLayer | 基礎配套 | 水域 | polygon | 水面多邊形 | GlLayer.WaterLayer | 農業生產 | 農田地塊 | polygon | 帶紋理多邊形,可區分當前使用狀態 | GlLayer.PolygonLayer | 農業生產 | 魚塘地塊 | polygon | 帶紋理多邊形,可區分當前水體狀態 | GlLayer.PolygonLayer | 農業生產 | 農作物識別結果 | point | 作物類型點圖標 | AMap.MassMarker | 農業生產 | 農田災害風險AI預測圖 | point | 熱力圖 | Loca.HeatMapLayer | 安全監管 | 區域邊界 | polyline | 三維發光墻面體,如果有監控目標進入區域內則會出現告警 | GlLayer.BorderLayer | 安全監管 | 無人機導航 | polyline | 無人機模型在空中飛行移動 | GlLayer.DrivingLayer | 安全監管 | 巡查路線 | polyline | 無人機移動軌跡 | GlLayer.FlowlineLayer | 安全監管 | 示范區服務點 | point | 帶名稱點標記,點擊可切換到專屬視角 | Loca.LabelsLayer | 經濟效益 | 產量AI預測圖層 | point | 網格蜂窩柱狀圖,產量越大柱狀越紅且越高 | Loca.HexagonLayer |
4. 實現步驟
4.1 主體框架開發
- 使用vite創建工程,安裝前文技術棧提及的各種依賴包
在入口模塊編寫主體邏輯,引入主要模塊、聲明變量
<script setup>
import { getMap, initMap } from '@/utils/mainMap2.js'
import GLlayer from '#/gl-layers/src/index'
import * as THREE from 'three'
import * as dat from 'dat.gui'
let loca
const container = ref(null)
const layerManger = new LayerManager()
let normalMarker
onMounted(async () => {
await init()
await initLayers()
animateFn()
})
</script>
<template>
<div ref="container" class="container"></div>
<div class="tool">
<div class="btn" @click="gotoCenter()">回到中心</div>
<div class="btn" @click="toggleCross()">越界告警</div>
<div class="btn" @click="toggleDronView()">無人機巡航</div>
</div>
</template>
-
初始化基礎地圖,并添加衛星影像圖
async function init() {
const map = await initMap({
viewMode: '3D',
dom: container.value,
showBuildingBlock: false,
center: SETTING.center,
zoom: 15.5,
pitch: 42.0,
rotation: 4.9,
mapStyle: 'amap://styles/light',
skyColor: '#c8edff'
})
const satelliteLayer = new AMap.TileLayer.Satellite();
map.add([satelliteLayer]);
map.on('zoomend', (e) => {
console.log(map.getZoom())
})
map.on('click', (e) => {
const { lng, lat } = e.lnglat
console.log([lng, lat])
})
loca = new Loca.Container({
map,
});
normalMarker = new AMap.Marker({
offset: [70, -15],
zooms: [1, 22]
});
}
4.2 村居/綠化圖層
村居是指農業示范區內的建筑面生成模型,綠化圖層則是綠樹等植物的覆蓋區域,原本應該是兩個圖層,因為在本場景中僅僅作為地圖三維底座,均無交互性,我就直接把它們合并為一個3Dtiles以提升性能了。
4.2.1 制作村居數據
- 村居數據的建筑面獲取方法有兩種,我們可以通過一些GIS數據工具下載指定區域內建筑面數據,也可以通過AI Earth進行衛星影像圖建筑物提取,最終生成geoJSON文件,導入QGIS進行數據清洗和加工。
- 如果建筑面沒有高度數據,我們根據目標場景的實際情況,可以在QGIS中生成一定范圍內的隨機值

4.2.2 制作綠化區域數據
- 使用QGIS新建多邊形面圖層,在目標場景區域內將綠化區域圈選出來。在過程中可能會涉及到帶孔多邊形的制作,我們可以利用矢量多邊形的布爾運算獲得。
- 在QGIS工具箱找到“矢量創建-多邊形內部的隨機點”即可生成隨機點功能,即可在綠化區域生成均勻分布的隨機點,后續每個點我們都可以種上一棵樹。
4.2.3 轉換為3D瓦片
- 新建cityEngine工程,并將制作好的村居和綠化數據另存為SHP格式,置入到工程中
- 將目標場景的矩形范圍也導出一張TIF格式的圖片,置入到工程中,作為本工程場景的底圖
- 將村居數據Polygons拖入場景編輯面板中,選中元素對象并配置規則文件,我們就可以快速生成建筑模型,并通過配置將建筑高度與建筑面高度數據關聯上,選擇合適的房屋造型和風格。

-
同理將綠化區域數據Points拖拽入場景編輯面板,并配置植物生成規則文件,我們就可以快速得到效果非常不錯的植物綠化區域

-
選中兩個圖層的模型并導出為FBX,注意配置面板中的設置,中心一項關系到所有模型在地圖上的位置是否正確,需要格外關注 -
開啟cesiumlab,進入通用模型切片,直接轉換為3Dtiles,可以在ceisumlab的預覽頁面中看到建筑和植物都落在地球的地面上,可能原點的地理位置是錯誤的。這個不用擔心,我們在將其接入高德地圖時做再做調整。更細節的步驟可以看我之前寫的低成本創建數字孿生場景

4.2.4 在高德地圖呈現
- 部署3dtiles靜態服務,在高德地圖中需要重新定義3dtiles的原點坐標,因此需要創建一個tileset.json入口文件副本,并將其初始轉置矩陣歸零

-
編寫代碼,這里使用之前開發的TilesLayer圖層做加載,關于如何在高德地圖中實現3dtiles,想了解具體實現可以看看這里 。
async function initBuildingLayer() {
const map = getMap()
const layer = new TilesLayer({
id: 'buildingLayer',
title: '村居建筑圖層',
alone: SETTING.alone,
map,
center: [113.531905, 22.737473],
zooms: [4, 30],
interact: false,
tilesURL: 'http://localhost:9003/model/twQ1mVSwQ/tileset.0.json',
needShadow: true
})
layerManger.add(layer)
}
-
為保證視覺效果,加載完成后還對模型打光調亮、添加陰影,關于如何在地圖的平面上添加陰影,需要開個單獨的小節在后文詳敘。
layer.on('complete', ({ scene, renderer }) => {
const aLight = new THREE.AmbientLight(0xffffff, 0.5)
scene.add(aLight)
var dLight = new THREE.DirectionalLight(0xffffff, intetity);
dLight.position.set(lightPositionX, lightPositionY, lightPositionZ);
dLight.castShadow = true;
dLight.shadow.mapSize.width = mapSize;
dLight.shadow.mapSize.height = mapSize;
dLight.shadow.camera.near = cameraNear;
dLight.shadow.camera.far = caremaFar;
dLight.shadow.camera.left = cameraLeft;
dLight.shadow.camera.right = cameraRight;
dLight.shadow.camera.top = cameraTop;
dLight.shadow.camera.bottom = cameraBottom;
dLight.shadow.bias = -0.0001;
scene.add(dLight);
directionalLight = dLight
const geometry1 = new THREE.PlaneGeometry(5000, 5000);
const material1 = new THREE.ShadowMaterial({ opacity: 1.0 })
const plane = new THREE.Mesh(geometry1, material1);
plane.position.z = 0;
plane.receiveShadow = true;
scene.add(plane);
}) ?
-
最終的效果如下

4.3 水域圖層
- 我們同樣可以使用QGIS自行繪制、或者使用GIS工具獲取水域范圍數據

-
水面的實現方式是在指定的多邊形平面上添加水紋材質,這里使用到了ShaderMaterial編寫自定義著色器材質,我們封裝為WaterLayer圖層,詳細步驟可以看這里
async function initWaterLayer() {
const map = getMap()
const data = await fetchMockData('water.geojson')
const layer = new GLlayers.WaterLayer({
id: 'waterLayer',
map,
data,
alone: SETTING.alone,
zooms: [16, 22],
animate: true,
waterColor: '#CFEACD',
altitude: -5
})
layerManger.add(layer)
}
-
最終效果如下,動靜結合這樣一來村居看起來更靈動了

4.4 農田地塊
- 農田和魚塘地塊具有共同的特性,實現方法類似可以合起來講,在QGIS上我們就可以通過屬性表對polygone按屬性做分類

-
獲取數據,實例化Polylone,其實這種常規的Polygon,高德地圖Loca也有提供,之所以用自己開發的polygon是想給Polygon添加圖片紋理,比如正在使用的地塊使用水稻田紋理 ,而養護中的地塊則使用土地紋理,簡單一點就是用顏色做區分。
async function initFarmLayer() {
const map = getMap()
const data = await fetchMockData('farm.geojson')
console.log(data)
data.features.forEach(item => {
const { used } = item.properties
item.properties.color = used == 1 ? "#33a02c" : (used == 0 ? "#b2df8a" : "#ceb89e")
})
const layer = new GlLayer.PolygonLayer({
id: 'farmLayer',
alone: SETTING.alone,
map,
data,
lineWidth: 0,
opacity: 0.4,
interact: true,
zIndex: 100,
altitude: 2
})
layerManger.add(layer)
}
-
單個PolygonLayer生成Mesh的核心代碼如下,將空間坐標數組轉為Mesh的頂點三角面,并賦予材質,更詳細的的實現步驟可以看看之前分享的在高德地圖上實現Polylone圖層。
* 繪制多邊形
* @private
* @param {Array} path 路徑
* @param {Object} properties 屬性
*/
drawPolygon ({ path, properties }) {
const { altitude, opacity } = this._conf
const flatArr = path.map(v => {
return [v[0], v[1], altitude]
}).flat()
const triangles = Earcut.triangulate(flatArr, null, 3)
const geometry = new THREE.BufferGeometry()
let faceList = []
for (let i = 0; i < triangles.length; i++) {
const [x, y, z] = path[triangles[i]]
faceList = [...faceList, x, y, altitude]
}
geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(faceList), 3))
geometry.computeVertexNormals()
const material = new THREE.MeshBasicMaterial({
color: properties.color || '#0674F1',
transparent: true,
opacity: properties.opacity || opacity
})
const polygon = new THREE.Mesh(geometry, material)
const _scene = this.group || this.scene
_scene.add(polygon)
}
最終效果如下

4.5 作物識別圖層
- 作物識別圖層的作用是展示AI遙感識別技術對農田作物的識別結果,以及展示AI技術對魚塘產量做出的預測數據,用AMap.MassMarker就可以滿足了
- 需要注意的是點標記的坐標位置是如何生成的,總不可能手動創建效率太低了,我們可以使用QGIS自帶的矢量數據處理功能自動創建質心,直接為每個polygon生成中心坐標點。右鍵圖層打開屬性表添加識別結果,導出geojson格式備用。


-
在高德地圖中添加圖層實現,為保證與其他圖層的接口統一,我對MassMark和MassMakers進行了封裝,統一基礎屬性、初始化配置參數和顯示隱藏方法。
import BaseUtils from './BaseUtils';
class CropLayer extends BaseUtils {
data = [];
markers = [];
id = null
layer = null
iconMap = {
'香蕉': { icon: 'xiangjiao.png', style: 0},
'火龍果': { icon: 'huolongguo.png', style: 1},
}
constructor(config) {
super(config);
this.getData(config.data);
this.map = config.map;
this.zooms = config.zooms ?? [10, 22];
this._zIndex = config.zIndex
this.id = config.id
this.init();
}
* 處理具體的圖層顯示邏輯
* @param val
*/
_handleVisible(val) {
const {layer} = this;
const fn = val ? 'show' : 'hide';
if(layer){
layer[fn]()
}
}
getData(geoJSON) {
const arr = []
const {iconMap} = this
geoJSON.features.forEach(item=>{
const {geometry, properties} = item
const {crop} = properties
const match = iconMap[crop]
const [lng, lat] = geometry.coordinates
if(match){
arr.push({
lnglat: [lng, lat, 50],
crop,
style: match.style
})
}
})
this.data = arr
}
async init() {
const {data, map, iconMap, zooms, _zIndex} = this;
const style = Object.keys(this.iconMap).map(key=>{
const {icon, style} = iconMap[key]
return {
url: `./static/icons/${icon}`,
size: new AMap.Size(30,30),
name: key
}
})
const layer = new AMap.MassMarks(data, {
opacity: 1,
zIndex: _zIndex,
cursor: 'pointer',
style,
zooms
});
layer.setMap(map)
layer.on('mouseover', (e) => {
this.dispatchEvent('mouseover', e)
});
this.layer = layer
this.visible = true;
}
}
export default CropLayer;
這樣一來就可以輕松調用了,直接將農田和魚塘數據合并使用一個圖層展示
async function initCropLayer() {
const map = getMap()
const data1 = await fetchMockData('crop.geojson')
const data2 = await fetchMockData('poolCenter.geojson')
data1.features = data1.features.concat(data2.features)
const layer = new CropLayer({
id: 'cropLayer',
data: data1,
zooms: [16, 22],
zIndex: 200,
map
})
layer.on('mouseover', (e) => {
const { crop, style } = e.data
normalMarker.setPosition(e.data.lnglat);
normalMarker.setOffset(new AMap.Pixel(90, -10))
let content = ''
if (style <= 4) {
content = `<div class="amap-info-window">
<p>作物: ${crop}</p>
<p>識別匹配度: ${parseInt(Math.random() * 20) + 80}%</p>
<p>產量預計: ${parseInt(Math.random() * 30) + 20}噸</p>
</div>`
} else {
content = `<div class="amap-info-window">
<p>作物: ${crop}</p>
<p>產量預計: ${parseInt(Math.random() * 20) + 10}噸</p>
</div>`
}
normalMarker.setContent(content)
normalMarker.setMap(map)
})
layer.on('mouseout', (e) => {
map.remove(normalMarker);
})
layerManger.add(layer)
}
-
最終效果如下

4.6 區域邊界
- 區域邊界的數據繪制很簡單,就是一個常規的封閉線圖形polyline。

-
我使用之前開發的GlLayer.BorderLayer進行實例化渲染,方便定制各種動畫。
async function initBorderLayer() {
const map = getMap()
const data = await fetchMockData('border.geojson')
const layer = new GlLayer.BorderLayer({
id: 'borderLayer',
alone: SETTING.alone,
map,
wallColor: '#3dfcfc',
wallHeight: 100,
data,
speed: 0.3,
animate: true,
zooms: [11, 22],
altitude: 0
})
layerManger.add(layer)
}
-
區域入侵監控這部分操作正常來說是由物聯網設備檢測到,推送消息給服務端,再由服務端推送給前端一條消息。為方便演示我直接在前端模擬了,定時檢測指定目標位置,如果在polygon內部,則區域邊界圖層出現告警狀態,整體變為紅色;目標離開,則解除告警狀態。為此新增了setColor方法用于切換顏色狀態。
* 設置區域邊界顏色
* @param {String} newColor 顏色值,比如'#ffffff'
*/
setColor(newColor){
const newTexture = this.generateTexture (128, newColor)
newTexture.wrapS = THREE.RepeatWrapping
newTexture.wrapT = THREE.RepeatWrapping
this._color = newColor
this._texture_offset = 0
this.mainMesh.material.color = newColor
this.animateMesh.material.map = newTexture
this._texture = newTexture
}
generateTexture (size = 64, color = '#ff0000') {
const canvas = document.createElement('canvas')
canvas.width = size
canvas.height = size
const ctx = canvas.getContext('2d')
const linearGradient = ctx.createLinearGradient(0, 0, 0, size)
linearGradient.addColorStop(0.2, hexToRgba(color, 0.0))
linearGradient.addColorStop(0.8, hexToRgba(color, 0.5))
linearGradient.addColorStop(1.0, hexToRgba(color, 1.0))
ctx.fillStyle = linearGradient
ctx.fillRect(0, 0, size, size)
const texture = new THREE.Texture(canvas)
texture.needsUpdate = true
return texture
}
-
模擬邊界入侵檢測,我們可以使用AMap.GeometryUtils提供的幾何計算方法,判斷點是否在多邊形內,是的話則改變邊界狀態為告警,否則移除告警。
let isInvadeMode = false
let invadeClock = null
let invadeMarker
* 切換入侵檢測模式
*/
async function toggleInvade() {
const map = getMap()
const borderLayer = layerManger.findLayerById('borderLayer')
isInvadeMode = !isInvadeMode
let ring = []
let invadePath
let invadeStep = 0
if (isInvadeMode) {
const borderPath = await fetchMockData('border.geojson')
ring = borderPath.features[0].geometry.coordinates[0]
initInvade()
invadeClock = setInterval(() => {
const pos = invadePath[invadeStep]
invadeStep = (invadeStep + 1) % invadePath.length
invadeMarker.setPosition(pos)
const color = isInRing(pos, ring) ? '#ff0000' : '#3dfcfc'
if(borderLayer._color !== color){
borderLayer.setColor(color)
}
}, 1000)
} else {
clearInvade()
borderLayer.setColor('#3dfcfc')
}
async function initInvade(){
const {features} = await fetchMockData('invade-path.geojson')
invadePath = features[0].geometry.coordinates[0]
invadeMarker = new AMap.Marker({
content: `<img style="width:30px;" src="./static/icons/ico-invade.png">`,
anchor: 'bottom-center',
offset: new AMap.Pixel(-15, -20)
})
map.add(invadeMarker)
}
function clearInvade(){
clearInterval(invadeClock)
invadeClock = null
map.remove(invadeMarker)
invadeMarker = null
}
function isInRing (pos, ring){
const res = AMap.GeometryUtil.isPointInRing(pos, ring)
console.log('is in ring ', res)
return res
}
}
-
最終效果如下

4.7 無人機巡查功能
最近“低空經濟”這個概念很火,說的是是以各種有人駕駛和無人駕駛航空器的各類低空飛行活動為牽引,輻射帶動相關領域融合發展的綜合性經濟形態,既然如此怎么能少得了無人機的出場。在本文中我們實現的是單架無人機模型沿著指定的閉合軌跡飛行移動,并且可以用無人機的第三人稱視角俯瞰地圖。
- 關于自動巡航的功能在之前做無人車巡航的時候已經實現過了,這里再講解一下核心代碼,其實就是在Tween更新函數中,按照既定的路徑軌跡不斷調整NPC的位置和朝向,如果需要第三人稱視角,則同步更新相機的朝向即可,更詳細的步驟可以看在高德地圖實現自動巡航
onReady () {
if (this._conf.NPC) {
this.initNPC()
}
this.initController()
}
* 初始化主體NPC的狀態
* @private
*/
initNPC () {
const { _PATH_COORDS, scene } = this
const { NPC } = this._conf
NPC.up.set(0, 0, 1)
if (_PATH_COORDS.length > 1) {
NPC.position.copy(_PATH_COORDS[0])
NPC.lookAt(_PATH_COORDS[1])
}
scene.add(NPC)
}
* 創建移動控制器
* @private
*/
initController () {
const target = { t: 0 }
const duration = this.getMoveDuration()
const { _PATH_COORDS, _PATH_LNG_LAT, map } = this
this._rayController = new TWEEN.Tween(target)
.to({ t: 1 }, duration)
.easing(TWEEN.Easing.Linear.None)
.onUpdate(() => {
const { NPC, cameraFollow } = this._conf
const nextIndex = this.getNextStepIndex()
const point = new THREE.Vector3().copy(_PATH_COORDS[this.npc_step])
const nextPoint = new THREE.Vector3().copy(_PATH_COORDS[nextIndex])
const position = new THREE.Vector3().copy(point).lerp(nextPoint, target.t)
if (NPC) {
NPC.position.copy(position)
}
if (cameraFollow) {
const pointLngLat = new THREE.Vector3().copy(_PATH_LNG_LAT[this.npc_step])
const nextPointLngLat = new THREE.Vector3().copy(_PATH_LNG_LAT[nextIndex])
const positionLngLat = new THREE.Vector3().copy(pointLngLat).lerp(nextPointLngLat, target.t)
this.updateMapCenter(positionLngLat)
}
if (cameraFollow) {
const angle = this.getAngle(position, _PATH_COORDS[(this.npc_step + 3) % _PATH_COORDS.length])
this.updateMapRotation(angle)
}
})
.onStart(() => {
const { NPC } = this._conf
const nextPoint = _PATH_COORDS[(this.npc_step + 3) % _PATH_COORDS.length]
if (NPC) {
NPC.lookAt(nextPoint)
NPC.up.set(0, 0, 1)
}
})
.onComplete(() => {
this.npc_step = this.getNextStepIndex()
const duration = this.getMoveDuration()
target.t = 0
this._rayController
.stop()
.to({ t: 1 }, duration)
.start()
})
.start()
}
-
實例化GlLayer.DrivinLayer圖層,我們將無人機巡航和飛行軌跡拆分為兩個圖層實現
async function initDroneLayer() {
const map = getMap()
const data = await fetchMockData('dronWander2.geojson')
const NPC = await getDroneModel()
const layer = new DrivingLayer({
id: 'dronLayer',
map,
zooms: [4, 30],
path: data,
altitude: 50,
speed: 50.0,
NPC,
interact: true
})
layer.on('complete', ({ scene }) => {
const aLight = new THREE.AmbientLight(0xffffff, 3.5)
scene.add(aLight)
layer.resume()
})
layerManger.add(layer)
const dronPathLayer = new FlowlineLayer({
id: 'dronPathLayer',
map,
zooms: [16, 22],
data,
speed: 0.5,
lineWidth: 10,
altitude: 50
})
layerManger.add(dronPathLayer)
}
-
本實例最大的難度在于如何讓無人機在飛行的時候4個螺旋槳旋轉擺動,這里最后選擇了在逐幀函數更新gltf自帶動畫的方法;關于gltf動畫如何制作,在后面有單獨章節。
function getDroneModel() {
return new Promise((resolve) => {
const loader = new GLTFLoader()
loader.load('./static/model/drone/drone1.glb', (gltf) => {
const model = gltf.scene.children[0]
const size = 10.0
model.scale.set(size, size, size)
mixer = new THREE.AnimationMixer(gltf.scene);
const action = mixer.clipAction(gltf.animations[0])
action.setEffectiveTimeScale(guiCtrl.mixerPlaySpeed);
action.play();
resolve(model)
})
})
}
function animateFn() {
requestAnimationFrame(animateFn);
if (mixer) {
mixer.update(0.01);
}
}
-
最終實現效果如下,第三人稱游戲的代入感出來了有沒有。

4.8 災害預測圖層
- 該圖層本質上是個3D熱力圖,源數據是帶有權重屬性的坐標點集合,我們可以在QGIS上編輯它們甚至可以查看二維效果
 -
導出數據,使用高德自帶的可視化圖層Loca.Heatmap實現
* 災害風險檢測圖層
*/
async function initRiskLayer() {
const map = getMap()
const data = await fetchMockData('fertility.geojson')
const geo = new Loca.GeoJSONSource({ data })
const heatmap = new Loca.HeatMapLayer({
zIndex: 10,
opacity: 1,
visible: false,
zooms: [2, 22],
});
heatmap.setSource(geo, {
id: 'riskLayer',
radius: 150,
unit: 'meter',
height: 300,
gradient: {
1: '#FF4C2F',
0.8: '#FAA53F',
0.6: '#FFF100',
0.5: '#7DF675',
0.4: '#5CE182',
0.2: '#29CF6F',
},
value: function (index, feature) {
return feature.properties.weight ?? 0;
},
min: 0,
max: 100,
visible: true
});
loca.add(heatmap);
map.on('click', function (e) {
const feat = heatmap.queryFeature(e.pixel.toArray());
});
heatmap.id = 'riskLayer'
layerManger.add(heatmap)
}
-
在切換圖層為顯示狀態時,可以加上動畫以達到更好的視覺效果
function animateLayer(layer){
switch(layer.id){
case 'riskLayer':
layer.addAnimate({
key: 'height',
value: [0, 1],
duration: 2000,
easing: 'BackOut',
});
layer.addAnimate({
key: 'radius',
value: [0, 1],
duration: 2000,
easing: 'BackOut',
transform: 1000,
random: true,
delay: 5000,
});
break;
}
-
最終效果如下,產量AI預測圖層的實現方法類似就不贅述

4.9 使用圖層管理器操作圖層
本示例涉及到圖層數量已經有十幾個,為方便進行圖層的統一操作(比如在專題A哪些圖層需要顯示,其他圖層隱藏;或者調用圖層的某個功能),我們需要圖層管理器layerManager,且給圖層賦予唯一的id值便于在管理器中獲取。
如下面代碼所示,提供最基礎的添加、查找、清除功能
* 圖層管理器
* @extends null
* @author Zhanglinhai <gyrate.sky@qq.com>
*/
class Manager {
* @description 創建一個實例
* @param {Object} conf
* @param {Array} conf.data 圖層數組 [layer,...] 默認為[]
*/
constructor (config = {}) {
this._list = config.data || []
}
* @description 添加1個圖層到管理器
* @param {String} id 圖層id
* @param {String} title 圖層名稱
* @param {*} layer 圖層實例
*/
add (layer) {
if (layer === undefined) {
console.error('缺少圖層實例')
return
}
if (layer.id === undefined) {
console.error('缺少圖層id')
return
}
const { id } = layer
const match = this.findLayerById(id)
if (match) {
console.error(`圖層的id ${id} 不是唯一標識,請更換`)
return
}
this._list.push(layer)
}
* @description 通過id查找圖層信息
* @param {String} id 圖層id
* @returns {*} 返回匹配的第一個圖層
*/
findLayerById (id) {
const match = this._list.find(item => item.id === id)
return match
}
* @description 清空當前的圖層管理器
*/
clear () {
this._list.forEach((layer) => {
if (layer.destroy) {
layer.destroy()
}
console.log(`銷毀layer ${layer.id}`)
})
this._list = []
}
}
這樣一來就方便我們快捷操作圖層,將整個地圖作為可視化大屏的主體,放置到帶有導航和圖表的低代碼大屏框架中,就完成了初步的搭建工作。

5. 其他問題解決方案
5.1 如何在場景中產生投影
如何在高德地圖的底圖上添加模型的投影,我被困擾了一段時間,后來請教了高德的技術大佬WT才得到啟發解開了這個問題,感謝wt大佬的支持。three.js提供了一種陰影材(ShadowMaterial)此材質可以接收陰影,但在其他方面完全透明。
要想在場景中獲得投影,需要下面幾個步驟都齊全
- 渲染器打開投影
renderer.autoClear = false;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.setSize(window.innerWidth, window.innerHeight);
創建合適的平行光源,有各種參數需要設置
var dLight = new THREE.DirectionalLight(0xffffff, 3);
dLight.position.set(lightPositionX, lightPositionY, lightPositionZ);
dLight.castShadow = true;
dLight.shadow.mapSize.width = mapSize;
dLight.shadow.mapSize.height = mapSize;
dLight.shadow.camera.near = cameraNear;
dLight.shadow.camera.far = caremaFar;
dLight.shadow.camera.left = cameraLeft;
dLight.shadow.camera.right = cameraRight;
dLight.shadow.camera.top = cameraTop;
dLight.shadow.camera.bottom = cameraBottom;
scene.add(dLight);
各種關聯物體也必須將屬性castShadow 、receiveShadow設置為true
var geo = new THREE.BoxGeometry(1000, 1000, 1000);
for (let i = 0; i < data.length; i++) {
const d = data[i];
var mesh = new THREE.Mesh(geo, mat);
mesh.position.set(d[0], d[1], 500);
mesh.castShadow = true;
mesh.receiveShadow = true;
}
-
給底部平面賦予shadowMaterial材質
var planeGeo = new THREE.PlaneGeometry(50000, 50000);
var shadowMat = new THREE.ShadowMaterial({
opacity: planeMaterialOpacity,
});
plane = new THREE.Mesh(planeGeo, shadowMat);
plane.receiveShadow = true;
scene.add(plane);
-
最終效果如下,演示代碼鏈接放到這里了

5.2 給模型制作常規動畫
- 下載一個無人機模型FBX格式,推薦在sketchfab上找,素材齊全。打開blender,導入FBX模型,把所有部件歸屬到一個根節點,后續控制根節點其他部件也跟著移動
 -
在動畫時間軸給每個部件加上動畫關鍵幀,調試好動畫
 -
補間動畫默認是緩入緩出的,可以同個左上角切換面板到曲線編輯器修改補間動畫線
 -
最關鍵的一步。導出gltf時動畫一項必須勾選,且動畫模式設置為“合并的活動動作”,這樣的話,導出的gltf就能把所有部件動作合并為一個動作了。

-
最終預覽效果,螺旋槳的旋轉動畫不需要做太快,因為在web端實際播放時,速度倍率是可以通過action.setEffectiveTimeScal()調節的,要多快有多塊。

5.3 圖層的深度關系
如何處理高德自有圖層和自定義圖層的深度關系,這里必須了解高德提供的CustomLayer和GLCustomLayer的區別。
前者是在地圖實例畫布Canvas1之外另外覆蓋了一個Canvas標簽,因此所有內容都會置于Canvas1內容之上,無論空間上是否合理;而后者則是與地圖實例共享畫布的,在GLCustomLayer上創建的內容能夠與地圖上的元素、高德可視化類創建的元素共享深度關系,因此使用GLCustomLayer會讓多圖層的場景視覺上更加和諧,但代價就是Map需要逐幀重繪,性能損耗更高。所以如何取舍還是要看具體的業務場景進行選擇。

總結
至此,使用高德地圖制作數字農業可視化大屏的分享就告一段落了。事實上這并不是一個最終成本,因為我還有很多想法沒有落實, 比如精細化農業大棚的搭建,無人機實時視頻投影、火災預測等等功能展示;還有一些技術問題沒有解決,比如cesiumlab使用FBX生成的3dtiles沒有支持LOD,即不同地圖縮放層級下的精細度,這在性能和視覺效果上肯定是存在優化空間的,據我所見在cityEngine階段LOD信息還是存在的,至于具體在哪個過程中丟失了,還需要排查一下。
但戰線拉太長的話項目可能就會永遠沒有階段成果,時間關系就先發布這么多了了。說不定分享出來之后,可以起到拋磚引玉的作用,最好能撈到更多志同道合的伙伴來一起共建虛擬農場元宇宙。
本示例使用到的高德JSAPI
3D自定義圖層AMap.GLCustomLayer
自定義圖層AMap.CustomLayer
AMap.Map地圖對象類
海量點類AMap.MassMarkers
LOCA 數據可視化 API 2.0
空間數據計算的函數庫 GeometryUtil
相關鏈接
數字孿生×低空經濟 | 天空地一體化 城市數字孿生電子沙盤指揮系統
在cityEngine編寫模型生成規則
THREEJS 陰影材質的使用文檔
源代碼Github地址
演示頁面地址 作者:Gyrate 鏈接:https://juejin.cn/post/7432127587919298600 來源:稀土掘金 著作權歸作者所有。商業轉載請聯系作者獲得授權,非商業轉載請注明出處。
該文章在 2025/4/12 18:27:20 編輯過
|
|