制作数字农场可视化大屏

21,477 阅读25分钟

1.介绍

数字农业可视化是一种将农业生产过程中的各类数据,通过先进的信息技术手段进行采集、整合、分析,并以直观的可视化形式呈现出来的技术应用模式。它利用大数据、物联网、人工智能、GIS等技术,为农业生产经营管理提供了全新的、高效的决策支持工具,使农业从业者能够更加清晰、准确地了解农业生产的各个环节,从而实现精准决策、精细管理和高效运营。

最近对数字农业有点感兴趣,于是就有了接下来的探索和尝试,本文的内容比较有综合性,基本上用到了之前在技术社区分享的大部分经验,不仅包括高德开发平台的技术,也集成了具体业务分析、GIS数据生成、3D模型制作等内容。附演示页面地址,源代码地址见文末。

2. 需求分析

本次做可视化大屏的开发,我希望最终的开发成果是可以在后续的产品或者项目中复用、至少能发挥一定的参考价值,因此需要做一些业务需求分析。由于我在这方面的业务涉猎比较浅显,于是先看了几个智慧农业解决方案方便的PPT,然后询问AI助手,整理为下面几个专题的内容:

2.1 基础配套

  1. 地形:以三维地形图的形式呈现,通过不同颜色和高度标识展示区域内的山地、沼泽、平原等地形分布。可以使用等高线、阴影等效果增强立体感,让用户直观了解地形的起伏。由于增加地形起伏会直接增加其他贴合地形图层的实现复杂度,为降低阅读难度本次示例选了块地形相对平整的冲击平原,因此规避地形问题。

  2. 影像:展示高分辨率的卫星影像图,全面覆盖智慧农业所涉及的区域范围,让用户能够以宏观视角清晰了解整个区域的全貌,包括地形、河流、村居、植被等基础配套元素的分布及相互关系。

  3. 水域:在地图上清晰标注河流的走向、河道宽度以及与其他水体的连接关系

  4. 水质:如酸碱度、溶解氧、污染物含量等指标,并以不同颜色或图表形式在大屏上直观展示,以保障农业用水安全。

  5. 村居建筑:展示村庄的分布位置和范围,以建筑模型或图标形式呈现村居的布局。

    Honeycam_2024-10-28_09-30-42.gif

2.2 农业生产

  1. 农田:以高精度地图展示农田地块的边界和面积,对不同的农田进行编号和分类管理,例如按照种植作物类型、当前使用状态等进行划分

  2. 鱼塘:标注鱼塘的位置和范围,显示鱼塘的面积和水深等基本信息。展示鱼塘的养殖情况,包括养殖的鱼类品种、生长阶段、投喂记录等,方便养殖户进行科学管理和养殖计划制定。

  3. 作物识别:利用图像识别技术,通过摄像头或卫星影像对农田中的作物进行实时识别和分类。在大屏上以不同颜色或图标标注出不同作物的种植区域,方便用户快速了解农田的作物布局

  4. 灾害预测:通过监测田间的病虫害发生情况、气象条件、作物生长状况等因素,运用病虫害预测模型,预测病虫害的发生趋势和流行范围。

    Honeycam_2024-10-28_10-04-29.gif

2.3 安全监管

  1. 无人机巡查:在地图上展示无人机的巡查路线和实时位置,用户可以直观地看到无人机的飞行轨迹。

  2. 入侵告警:在地图上划定重点安全区域,如农田保护区、鱼塘养殖区、仓库等,当有人员或车辆未经授权进入这些区域时,系统自动触发入侵告警。

  3. 重点位置POI:在地图上标注所有摄像头的位置,形成 POI(Point of Interest)图层。用户可以点击每个摄像头图标,查看该摄像头的实时监控画面和相关信息,如摄像头编号、安装位置、监控范围等。

    Honeycam_2024-10-23_15-34-14.gif

2.4 经济效益

  1. 区块产量预测:对比不同年份或不同种植季节的产量预测数据,分析产量变化趋势和影响因素,为农业生产规划和资源配置提供决策依据

  2. 投入产出比分析:详细展示农业生产过程中的各项投入成本,包括土地租赁费用、农资采购成本、人工成本、水电费、运输费用等,并以图表形式呈现各项成本在总成本中的占比情况,帮助用户清晰了解成本结构。

    Honeycam_2024-10-28_09-36-31.gif

3. 技术分析

经过上面的业务需求分析,我们就可以开始将它们转为技术上的需求模块进行逐个实现,其中部分图层可视化效果,使用高德平台提供的可视化类Loca可以满足了,其他部分图层则需要自行开发,这里我将自己平时积累的可视化图层整理为的gl-layers图层库,核心代码是基于three JS和高德自定义图层类CustomLayer、GLCustomLayer进行开发。

image.png

3.1 技术栈说明

工具名称版本用途
高德地图 JSAPI2.0为GIS平台提供基础底图和服务
three.js0.157主流webGL引擎之一,负责实现展示层面的功能
QGIS3.32.3GIS数据处理工具,用于处理本文的矢量化数据
cesiumlab3.1.11三维数据处理工具集,用于将模型转换为互联网可用的3DTiles
blender3.6模型处理工具,用于对BIM模型进行最简单的预处理
CityEngine2023.0arcGIS团队开发的程序化 3D 城市生成器 ,支持通过脚本将GIS转换为3D模型
vue3.2.25实现可视化大屏UI的语言框架,特点是数据双向绑定
vite2.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 主体框架开发

  1. 使用vite创建工程,安装前文技术栈提及的各种依赖包

  2. 在入口模块编写主体逻辑,引入主要模块、声明变量

    <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>
    
  3. 初始化基础地图,并添加卫星影像图

    async function init() {
    	// 将高德地图Map实例化做了一次封装
      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 制作村居数据

  1. 村居数据的建筑面获取方法有两种,我们可以通过一些GIS数据工具下载指定区域内建筑面数据,也可以通过AI Earth进行卫星影像图建筑物提取,最终生成geoJSON文件,导入QGIS进行数据清洗和加工。

  2. 如果建筑面没有高度数据,我们根据目标场景的实际情况,可以在QGIS中生成一定范围内的随机值

    Honeycam_2024-10-28_09-45-21.gif

4.2.2 制作绿化区域数据

  1. 使用QGIS新建多边形面图层,在目标场景区域内将绿化区域圈选出来。在过程中可能会涉及到带孔多边形的制作,我们可以利用矢量多边形的布尔运算获得。
  2. 在QGIS工具箱找到“矢量创建-多边形内部的随机点”即可生成随机点功能,即可在绿化区域生成均匀分布的随机点,后续每个点我们都可以种上一棵树。

4.2.3 转换为3D瓦片

  1. 新建cityEngine工程,并将制作好的村居和绿化数据另存为SHP格式,置入到工程中

  2. 将目标场景的矩形范围也导出一张TIF格式的图片,置入到工程中,作为本工程场景的底图

  3. 将村居数据Polygons拖入场景编辑面板中,选中元素对象并配置规则文件,我们就可以快速生成建筑模型,并通过配置将建筑高度与建筑面高度数据关联上,选择合适的房屋造型和风格。

    Honeycam_2024-10-28_09-53-08.gif

  4. 同理将绿化区域数据Points拖拽入场景编辑面板,并配置植物生成规则文件,我们就可以快速得到效果非常不错的植物绿化区域

    Honeycam_2024-10-28_09-54-49.gif

  5. 选中两个图层的模型并导出为FBX,注意配置面板中的设置,中心一项关系到所有模型在地图上的位置是否正确,需要格外关注

  6. 开启cesiumlab,进入通用模型切片,直接转换为3Dtiles,可以在ceisumlab的预览页面中看到建筑和植物都落在地球的地面上,可能原点的地理位置是错误的。这个不用担心,我们在将其接入高德地图时做再做调整。更细节的步骤可以看我之前写的低成本创建数字孪生场景

    image 1.png

4.2.4 在高德地图呈现

  1. 部署3dtiles静态服务,在高德地图中需要重新定义3dtiles的原点坐标,因此需要创建一个tileset.json入口文件副本,并将其初始转置矩阵归零

    image 2.png

  2. 编写代码,这里使用之前开发的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)
    }
    
  3. 为保证视觉效果,加载完成后还对模型打光调亮、添加阴影,关于如何在地图的平面上添加阴影,需要开个单独的小节在后文详叙。

    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. 最终的效果如下

    image 3.png

4.3 水域图层

  1. 我们同样可以使用QGIS自行绘制、或者使用GIS工具获取水域范围数据

    image 4.png

  2. 水面的实现方式是在指定的多边形平面上添加水纹材质,这里使用到了ShaderMaterial编写自定义着色器材质,我们封装为WaterLayer图层,详细步骤可以看这里

    async function initWaterLayer() {
      const map = getMap()
      const data = await fetchMockData('water.geojson')
      const layer = new GLlayers.WaterLayer({
        id: 'waterLayer',
        map,
        data, // 水域GIS数据
        alone: SETTING.alone,
        zooms: [16, 22],
        animate: true,
        waterColor: '#CFEACD', // 水体颜色
        altitude: -5 // 水面Mesh高度
      })
      layerManger.add(layer)
    }
    
  3. 最终效果如下,动静结合这样一来村居看起来更灵动了

    Honeycam_2024-10-27_10-58-23.gif

4.4 农田地块

  1. 农田和鱼塘地块具有共同的特性,实现方法类似可以合起来讲,在QGIS上我们就可以通过属性表对polygone按属性做分类

    image 5.png

  2. 获取数据,实例化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)
    
    }
    
  3. 单个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)
      // 创建一个THREE.Geometry对象
      const geometry = new THREE.BufferGeometry()
      // 将三角形的顶点添加到geometry对象
      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. 最终效果如下

    Honeycam_2024-10-27_11-19-52.gif

4.5 作物识别图层

  1. 作物识别图层的作用是展示AI遥感识别技术对农田作物的识别结果,以及展示AI技术对鱼塘产量做出的预测数据,用AMap.MassMarker就可以满足了

  2. 需要注意的是点标记的坐标位置是如何生成的,总不可能手动创建效率太低了,我们可以使用QGIS自带的矢量数据处理功能自动创建质心,直接为每个polygon生成中心坐标点。右键图层打开属性表添加识别结果,导出geojson格式备用。

    image 6.png

    image 7.png

  3. 在高德地图中添加图层实现,为保证与其他图层的接口统一,我对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;
    
  4. 这样一来就可以轻松调用了,直接将农田和鱼塘数据合并使用一个图层展示

    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)
    }
    
  5. 最终效果如下

    Honeycam_2024-10-27_11-31-59.gif

4.6 区域边界

  1. 区域边界的数据绘制很简单,就是一个常规的封闭线图形polyline。

    image 8.png

  2. 我使用之前开发的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)
    }
    
  3. 区域入侵监控这部分操作正常来说是由物联网设备检测到,推送消息给服务端,再由服务端推送给前端一条消息。为方便演示我直接在前端模拟了,定时检测指定目标位置,如果在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
    }
    
    
  4. 模拟边界入侵检测,我们可以使用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
      }
    }
    
  5. 最终效果如下

    Honeycam_2024-10-28_14-59-17.gif

4.7 无人机巡查功能

最近“低空经济”这个概念很火,说的是是以各种有人驾驶和无人驾驶航空器的各类低空飞行活动为牵引,辐射带动相关领域融合发展的综合性经济形态,既然如此怎么能少得了无人机的出场。在本文中我们实现的是单架无人机模型沿着指定的闭合轨迹飞行移动,并且可以用无人机的第三人称视角俯瞰地图。

  1. 关于自动巡航的功能在之前做无人车巡航的时候已经实现过了,这里再讲解一下核心代码,其实就是在Tween更新函数中,按照既定的路径轨迹不断调整NPC的位置和朝向,如果需要第三人称视角,则同步更新相机的朝向即可,更详细的步骤可以看在高德地图实现自动巡航

    // 创建移动目标NPC 和 移动控制器
    // NPC 是外部加载的gltf模型
    onReady () {
      if (this._conf.NPC) {
        this.initNPC()
      }
      this.initController()
    }
    
    /**
     * 初始化主体NPC的状态
     * @private
     */
    initNPC () {
      const { _PATH_COORDS, scene } = this
      const { NPC } = this._conf
    
      // z轴朝上
      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的位置
            NPC.position.copy(position)
          }
    
          // 需要镜头跟随
          if (cameraFollow) {
            // 计算两个lngLat端点的中间值
            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()
    }
    
    
  2. 实例化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)
    }
    
  3. 本实例最大的难度在于如何让无人机在飞行的时候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. 最终实现效果如下,第三人称游戏的代入感出来了有没有。

    Honeycam_2024-10-23_16-03-10.gif

4.8 灾害预测图层

  1. 该图层本质上是个3D热力图,源数据是带有权重属性的坐标点集合,我们可以在QGIS上编辑它们甚至可以查看二维效果

    image 9.png

  2. 导出数据,使用高德自带的可视化图层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)
    }
    
    
  3. 在切换图层为显示状态时,可以加上动画以达到更好的视觉效果

    // 给图层的显示增加动画效果
    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;      
        //...
    }
    
  4. 最终效果如下,产量AI预测图层的实现方法类似就不赘述

    Honeycam_2024-10-28_09-35-25.gif

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 = []
  }
}

这样一来就方便我们快捷操作图层,将整个地图作为可视化大屏的主体,放置到带有导航和图表的低代码大屏框架中,就完成了初步的搭建工作。

Honeycam_2024-10-25_11-57-58.gif

5. 其他问题解决方案

5.1 如何在场景中产生投影

如何在高德地图的底图上添加模型的投影,我被困扰了一段时间,后来请教了高德的技术大佬WT才得到启发解开了这个问题,感谢wt大佬的支持。three.js提供了一种阴影材(ShadowMaterial)此材质可以接收阴影,但在其他方面完全透明。

要想在场景中获得投影,需要下面几个步骤都齐全

  1. 渲染器打开投影

    // 禁用自动清理,以保持地图底图可见
    renderer.autoClear = false;
    renderer.shadowMap.enabled = true;
    renderer.shadowMap.type = THREE.PCFSoftShadowMap;
    // 重要:会影响到画布尺寸
    renderer.setSize(window.innerWidth, window.innerHeight);
    
  2. 创建合适的平行光源,有各种参数需要设置

    // 创建平行光
    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);
    
  3. 各种关联物体也必须将属性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; // 接收阴影!
      //...
    }
    
  4. 给底部平面赋予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. 最终效果如下,演示代码链接放到这里了

    Honeycam_2024-10-27_22-08-11.gif

5.2 给模型制作常规动画

  1. 下载一个无人机模型FBX格式,推荐在sketchfab上找,素材齐全。打开blender,导入FBX模型,把所有部件归属到一个根节点,后续控制根节点其他部件也跟着移动

    image 10.png

  2. 在动画时间轴给每个部件加上动画关键帧,调试好动画

    image 11.png

  3. 补间动画默认是缓入缓出的,可以同个左上角切换面板到曲线编辑器修改补间动画线

    image 12.png

  4. 最关键的一步。导出gltf时动画一项必须勾选,且动画模式设置为“合并的活动动作”,这样的话,导出的gltf就能把所有部件动作合并为一个动作了。

    image 13.png

  5. 最终预览效果,螺旋桨的旋转动画不需要做太快,因为在web端实际播放时,速度倍率是可以通过action.setEffectiveTimeScal()调节的,要多快有多块。

    Honeycam_2024-10-22_15-56-10.gif

5.3 图层的深度关系

如何处理高德自有图层和自定义图层的深度关系,这里必须了解高德提供的CustomLayer和GLCustomLayer的区别。

前者是在地图实例画布Canvas1之外另外覆盖了一个Canvas标签,因此所有内容都会置于Canvas1内容之上,无论空间上是否合理;而后者则是与地图实例共享画布的,在GLCustomLayer上创建的内容能够与地图上的元素、高德可视化类创建的元素共享深度关系,因此使用GLCustomLayer会让多图层的场景视觉上更加和谐,但代价就是Map需要逐帧重绘,性能损耗更高。所以如何取舍还是要看具体的业务场景进行选择。

image 14.png

总结

至此,使用高德地图制作数字农业可视化大屏的分享就告一段落了。事实上这并不是一个最终成本,因为我还有很多想法没有落实, 比如精细化农业大棚的搭建,无人机实时视频投影、火灾预测等等功能展示;还有一些技术问题没有解决,比如cesiumlab使用FBX生成的3dtiles没有支持LOD,即不同地图缩放层级下的精细度,这在性能和视觉效果上肯定是存在优化空间的,据我所见在cityEngine阶段LOD信息还是存在的,至于具体在哪个过程中丢失了,还需要排查一下。

但战线拉太长的话项目可能就会永远没有阶段成果,时间关系就先发布这么多了了。说不定分享出来之后,可以起到抛砖引玉的作用,最好能捞到更多志同道合的伙伴来一起共建虚拟农场元宇宙。

本示例使用到的高德JSAPI

3D自定义图层AMap.GLCustomLayer

自定义图层AMap.CustomLayer

AMap.Map地图对象类

海量点类AMap.MassMarkers

LOCA 数据可视化 API 2.0

空间数据计算的函数库 GeometryUtil

相关链接

数字孪生×低空经济 | 天空地一体化 城市数字孪生电子沙盘指挥系统

在cityEngine编写模型生成规则

THREEJS 阴影材质的使用文档

源代码Github地址

演示页面地址