如何在高德地图上制作立体POI图层

2,922 阅读12分钟

本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!

前言

在基于GIS的数据可视化层面,我们能够展示的基本数据无非就是点线面体,其中,离散的点数据出现的情况相对较为普遍,通常POI(Point of Interest)的展示方式和丰富程度对于用户体验和地图的实用性有着重要的影响。在这篇技术分享文章中,我们将由浅入深地探讨如何在高德地图上创建大量立体 POI。相信通过本文的介绍,开发者能够受到启发,并且掌握这一个不错的技巧,为地图点数据的展示和应用带来新的视觉和功能体验。

需求分析

首先收集一波需求:在地图上展示大量的POI,能够配置用第三方工具制作的模型,作为POI的主体,能够实现基本的鼠标交互操作,比如鼠标悬浮状态下具有区别于其他POI的特殊的形态或者动画,每个POI能够根据自身属性出现特异的外观,再厉害一点的能不能实现固定POI在屏幕上的大小,即POI的尺寸不会随着地图缩放的远近而出现变化。

根据以上琐碎的内容我们可以整理为以下功能描述,下文我们将一步步实现这些需求:

  • 支持灵活配置POI模型,POI样式可调整
  • 能够支持大数据量(10000+)的POI展示
  • 支持鼠标交互,能够对外派发事件
  • 支持动画效果
  • 支持开启一种模式,不会随地图远近缩放而改变POI的可见尺寸

poi3dLayer.gif

实现步骤

从基础功能到进阶功能逐步完善这个POI图层,篇幅有限每个功能仅陈述开发原理以及核心代码,完整的代码工程可以到这里查看下载

加载模型到场景中

  1. 首先讨论一个POI的情况要如何加载,以本文为例我们的POI是一个带波纹效果的倒椎体模型,根据后续的动画情况,我们把它拆成两个模型来实现。

    image.png

  2. 把主体和托盘模型分别加载到场景中,并给它们替换为自己创建的材质,代码实现如下

    // 加载单个模型
    loadOneModel(sourceUrl) {
           
        const loader = new GLTFLoader()
        return new Promise(resolve => {
            loader.load(sourceUrl, (gltf) => {
                // 获取模型
                const mesh = gltf.scene.children[0]
                // 放大模型以便观察
                const size = 100
                mesh.scale.set(size, size, size)
                // 放到场景中
                this.scene.add(mesh)
                resolve(mesh)
            }
        })
    
    }
    // 创建主体
    async createMainMesh() {
        // 加载主体模型
        const model = await this.loadOneModel('../static/gltf/taper2.glb')
        // 缓存模型
        this._models.main = model
    
        // 给模型换一种材质
        const material = new THREE.MeshStandardMaterial({
            color: 0x1171ee, //自身颜色
            transparent: true,
            opacity: 1, //透明度
            metalness: 0.0, //金属性
            roughness: 0.5, //粗糙度
            emissive: new THREE.Color('#1171ee'), //发光颜色
            emissiveIntensity: 0.2,
            // blending: THREE.AdditiveBlending
        })
        model.material = material
    }
    // 创建托盘
    async createTrayMesh() {
        // 加载底部托盘
        const model = await this.loadOneModel('../static/gltf/taper1-p.glb')
        // 缓存模型
        this._models.tray = model
    
        const loader = new THREE.TextureLoader()
    
        const texture = await loader.loadAsync('../static/image/texture/texture_wave_circle4.png')
        const { width, height } = texture.image
        this._frameX = width / height
        // xy方向纹理重复方式必须为平铺
        texture.wrapS = texture.wrapT = THREE.RepeatWrapping
        // 设置xy方向重复次数,x轴有frameX帧,仅取一帧
        texture.repeat.set(1 / this._frameX, 1)
    
        const material = new THREE.MeshStandardMaterial({
            color: 0x1171ee,
            map: texture,
            transparent: true,
            opacity: 0.8,
            metalness: 0.0,
            roughness: 0.6,
            depthTest: true,
            depthWrite: false
        })
        model.material = material
    }
    
  3. 这样一来单个模型实现动画的效果很简单,对于旋转的主体,我们只需要在逐帧函数中更新主体的z轴旋转角度;而波纹的效果使用时序图的方式实现,原理类似于css sprite不断变化纹理图片的x轴位移。感兴趣看一看之前的文章有详细阐述过

    update() {
        const {main, tray} = this._models
        // 更新托盘纹理
        const texture = tray?.material?.map
        if (texture) {
            this._offset += 0.6
            texture.offset.x = Math.floor(this._offset) / this._frameX
        }
        // 更新主体角度
        if(main){
            this._currentAngle += 0.005;
            main.rotateZ((this._currentAngle / 180) * Math.PI);
        }
    }
    
  4. 对动画的速度参数进行一些调试,并增加适当的灯光,我们就可以得到以下结果(工程目录/pages/poi3dLayer0.html)

    1.gif

解决大量模型的性能问题

上文的方案用来处理数据量较小的场景基本上是没有问题的,然而现实中往往有大量散点数据的情况需要处理,这时候需要THREE.InstancedMesh 出手了,InstanceMesh用于高效地渲染大量相同几何形状但具有不同位置、旋转或其他属性的物体实例,使用它可以显著提高渲染性能,尤其是在需要渲染大量相似物体的场中,比如一片森林中的树木、一群相似的物体等。

  1. 首先获取数据,我们以数量为20个的POI数据为例,使用高德API提供的customCoords.lngLatsToCoords方法现将数据的地理坐标转换为空间坐标

    // 处理转换图层基础数据的地理坐标为空间坐标
    initData(geoJSON) {
        const { features } = geoJSON
        this._data = JSON.parse(JSON.stringify(features))
    
        const coordsArr = this.customCoords.lngLatsToCoords(features.map(v => v.lngLat))
        this._data.forEach((item, index) => {
            item.coords = coordsArr[index]
        })
    }
    
  2. 我们对刚才的代码进行改造,模型加载之后不直接放置到场景scene而是存起来,加载完所有模型后为其逐个创建InstancedMesh。

    
    // 加载主体模型
    await this.loadMainMesh()
    // 加载底座模型
    await this.loadTrayMesh()
    // 实例化模型
    this.createInstancedMeshes()
    
    async loadMainMesh() {
        // 加载主体模型
        const model = await this.loadOneModel('../static/gltf/taper2.glb')
        // 缓存模型
        this._models.main = model
        //...
    }
    async loadTrayMesh() {
        // 加载底部托盘
        const model = await this.loadOneModel('../static/gltf/taper1-p.glb')
        // 缓存模型
        this._models.tray = model
    		//...
    }
            
    createInstancedMeshes() {
        const { _models, _data, _materials, scene } = this
        const keys = Object.keys(_models)
    
        for (let i = 0; i < keys.length; i++) {
            // 创建实例化模型
            let key = keys[i]
            const mesh = new THREE.InstancedMesh(_models[key].geometry, _materials[key], _data.length)
            mesh.attrs = { modelId: key }
            this._instanceMap[key] = mesh
    
            // 实例化
            this.updateInstancedMesh(mesh)
            scene.add(mesh)
        }
    }
    
  3. 对每个InstancedMesh进行实例化,需要注意的一点是对instanceMesh进行变换操作时必须设置 instanceMatrix.needsUpdate=true,否则无效

    // 用于做定位和移动的介质
    _dummy = new THREE.Object3D()
    
    updateInstancedMesh(instancedMesh) {
      const { _data } = this
    
      for (let i = 0; i < _data.length; i++) {
          // 获得转换后的坐标
          const [x, y] = this._data[i].coords
    
          // 每个实例的尺寸
          const newSize = this._size
          this._dummy.scale.set(newSize, newSize, newSize)
          // 更新每个实例的位置
          this._dummy.position.set(x, y, i)
          this._dummy.updateMatrix()
    
          // 更新实例 变换矩阵
          instancedMesh.setMatrixAt(i, this._dummy.matrix)
          // 设置实例 颜色 
          instancedMesh.setColorAt(i, new THREE.Color(0xfbdd4f))
      }
      // 强制更新实例
      instancedMesh.instanceMatrix.needsUpdate = true
    }
    
  4. 实现动画效果,托盘的波纹动画不需要调整代码,因为所有实例都是用的同一个Material,主体模块需要instancedMesh.setMatrixAt 更新每一个数据。

    _currentAngle = 0
    // 逐帧更新图层
    update() {
        const { main, tray } = this._instanceMap
        // 更新托盘纹理
        const texture = tray?.material?.map
        if (texture) {
            this._offset += 0.6
            texture.offset.x = Math.floor(this._offset) / this._frameX
        }
    
        // 更新主体旋转角度
        this._data.forEach((item, index) => {
            const [x, y] = item.coords
            this.updateMatrixAt(main, {
                size: this._size,
                position: [x, y, 0],
                rotation: [0, 0, this._currentAngle]
            }, index)
        })
        // 更新主体旋转角度
        this._currentAngle = (this._currentAngle + 0.05) % this._maxAngle
        
        // 强制更新instancedMesh实例,必须!
        if (main?.instanceMatrix) {
            main.instanceMatrix.needsUpdate = true
        }
    }
    
    /**
     * @description 更新指定网格体的单个示例的变化矩阵
     * @param {instancedMesh} Mesh 网格体
     * @param {Object} transform 变化设置,比如{size:1, position:[0,0,0], rotation:[0,0,0]}
     * @param {Number} index 网格体实例索引值
     */
    updateMatrixAt(mesh, transform, index) {
        if (!mesh) {
            return
        }
        const { size, position, rotation } = transform
        const { _dummy } = this
        // 更新尺寸
        _dummy.scale.set(size, size, size)
        // 更新dummy的位置和旋转角度
        _dummy.position.set(position[0], position[1], position[2])
        _dummy.rotation.x = rotation[0]
        _dummy.rotation.y = rotation[1]
        _dummy.rotation.z = rotation[2]
        _dummy.updateMatrix()
        mesh.setMatrixAt(index, _dummy.matrix)
    }
    
  5. 最终效果如下,POI数量再翻10倍也能够保持较为流畅的体验

    2.gif

实现数据特异性

从上一步骤updateInstancedMesh方法中,我们不难发现在对每个POI进行实例化的时候都会调用一次变化装置矩阵和设置颜色,因此我们可以通过对每个POI设定不同的尺寸、朝向等空间状态来实现数据的特异性。

  1. 改进实例化方法,根据每个数据的scale和index索引值设置专有的尺寸和颜色

    updateInstancedMesh(instancedMesh) {
      const { _data } = this
    
      for (let i = 0; i < _data.length; i++) {
          // 获得转换后的坐标
          const [x, y] = this._data[i].coords
    
          // 每个实例的尺寸
          const newSize = this._size * this._data[i].scale
          this._dummy.scale.set(newSize, newSize, newSize)
          // 更新每个实例的位置
          this._dummy.position.set(x, y, i)
          this._dummy.updateMatrix()
    
          // 更新实例 变换矩阵
          instancedMesh.setMatrixAt(i, this._dummy.matrix)
          console.log(this._dummy.matrix)
          // 设置实例 颜色 
          instancedMesh.setColorAt(i, new THREE.Color(this.getColor(i)))
      }
      // // 强制更新实例
      instancedMesh.instanceMatrix.needsUpdate = true
    }
    
    // 获取实例颜色
    getColor(index, data){
      return index % 2 == 0 ? 0xfbdd4f : 0xff0000
    }
    
    
  2. 在逐帧函数中调整setMatrixAt,对于每个动画中的POI,更新变化矩阵时也要带上scale

    // 逐帧更新图层
    update() {
      // ...
      // 更新主体旋转角度
      this._data.forEach((item, index) => {
          const [x, y] = item.coords
          this.updateMatrixAt(main, {
              size:  item.scale * this._size,
              //...
          }, index)
      })
    
  3. 最终效果如下(工程目录/pages/poi3dLayer1.html),对于使用instancedMesh实现的POI图层,POI的特异性也仅能做到这个程度;我们当然也可以实现主体模型上的特异性,在渲染图层前做一次枚举,为每一类主体模型创建一个instanceMesh即可,只不过instanceMesh的数量与数据量之间需要取得一个平衡,否则如果每个POI都是特定模型,使用instanceMesh就失去意义了。

    3.gif

实现鼠标交互

我们实现这样一种交互效果,所有POI主体静止不动,当鼠标悬浮在POI上,则POI开始转动画,且在POI上方出现广告牌显示它的名称属性。这里涉及到three.js中的射线碰撞检测和对外派发事件。主要的业务逻辑如下图:
image 1.png

  1. 对容器进行鼠标事件监听,每次mousemove时发射rayCast射线监控场景中物体碰撞并派发碰撞结果给onPick方法

    _pickEvent = 'mousemove'
    // ....
      if (this._pickEvent) {
        this.container.addEventListener(this._pickEvent, this.handleOnRay)
      }
    }
    // ....
    // onRay方法 防抖动
    this.handleOnRay = _.debounce(this.onRay, 100, true)
    /**
     * 在光标位置创建一个射线,捕获物体
     * @param event
     * @return {*}
     */
    onRay (event) {
      const { scene, camera } = this
    
      if (!scene) {
        return
      }
    
      const pickPosition = this.setPickPosition(event)
    
      this._raycaster.setFromCamera(pickPosition, camera)
    
      const intersects = this._raycaster.intersectObjects(scene.children, true)
    
      if (typeof this.onPicked === 'function' && this._interactAble) {
        this.onPicked.apply(this, [{ targets: intersects, event }])
      }
      return intersects
    }
    
    
  2. 在onPicked中处理碰撞结果,如果碰撞结果至少有1个,则将第一个结果作为当前鼠标拾取到的对象,为其赋值为拾取状态;如果碰撞结果为0个,则取消上一次拾取到的对象的拾取状态。

    _lastPickIndex = {index: null}
    
    /**
     * 处理拾取事件
     * @private
     * @param targets
     * @param event
     */
    onPicked({ targets, event }) {
    
        let attrs = null
        if (targets.length > 0) {
            const cMesh = targets[0].object
            if (cMesh?.isInstancedMesh) {
                const intersection = this._raycaster.intersectObject(cMesh, false)
                // 获取目标序号
                const { instanceId } = intersection[0]
                // 设置选中状态
                this.setLastPick(instanceId)
                attrs = this._data[instanceId]
                this.container.style.cursor = 'pointer'
            }
        } else {
            if (this._lastPickIndex.index !== null) {
                this.container.style.cursor = 'default'
            }
            this.removeLastPick()
        }
        // ...
    }
    /**
     * 设置最后一次拾取的目标
     * @param {Number} instanceId 目标序号
     * @private
     */
    setLastPick(index) {
        this._lastPickIndex.index = index
    }
    
    /**
     * 移除选中的模型状态
     */
    removeLastPick() {
        const { index } = this._lastPickIndex
        if (index !== null) {
            // 恢复实例化模型初始状态
            const mainMesh = this._instanceMap['main']
    
            const [x, y] = this._data[index].coords
            this.updateMatrixAt(mainMesh, {
                size: this._size,
                position: [x, y, 0],
                rotation: [0, 0, 0]
            }, index)
        }
    
        this._lastPickIndex.index = null
    }
    
  3. 修改逐帧函数,仅对当前拾取对象进行动画处理

    // 逐帧更新图层
    update() {
    
      const { main, tray, } = this._instanceMap
      const { _lastPickIndex, _size } = this
      // ...
    
      // 鼠标悬浮对象
      if (_lastPickIndex.index !== null) {
        const [x, y] = this._data[_lastPickIndex.index].coords
        this.updateMatrixAt(main, {
          size: _size * 1.2, // 选中的对象放大1.2倍
          position: [x, y, 0], // 保持原位置
          rotation: [0, 0, this._currentAngle] //调整旋转角度
        }, _lastPickIndex.index)
      }
     
      // 更新旋转角度值
      this._currentAngle = (this._currentAngle + 0.05) % this._maxAngle
    
      // 强制更新instancedMesh实例,必须!
      if (main?.instanceMatrix) {
          main.instanceMatrix.needsUpdate = true
      }
    }
    
  4. 不管有没有拾取到,都将事件派发出去,让上层逻辑处理“广告牌”的显示情况,将广告牌移到当前拾取对象上方并设置显示内容为拾取对象的name

    onPicked({ targets, event }) {
    	//...
    	// 派发pick事件
      this.handleEvent('pick', {
        screenX: event?.pixel?.x,
        screenY: event?.pixel?.y,
        attrs
      })
    }
    
    // 上层逻辑监听图层的pick事件
    layer.on('pick', (event) => {
      const { screenX, screenY, attrs } = event
      updateMarker(attrs)
    })
    
    let marker = new AMap.Marker({
        content: '<div class="tip"></div>',
        offset: [0, 0],
        anchor: 'bottom-center',
        map
    })
    
    // 更新广告牌
    function updateMarker(attrs) {
        if (attrs) {
            const { lngLat, id, modelId, name } = attrs
            marker.setPosition([...lngLat, 200])
            marker.setContent(`<div class="tip">${name || id}</div>`)
            marker.show()
        } else {
            marker.hide()
        }
    }
    
  5. 最终实现效果如下(工程目录/pages/poi3dLayer2.html) 4.gif

实现PDI效果

PDI即像素密度无关模式,本意是使图形元素、界面布局和内容在各种不同像素密度的屏幕上都能保持相对一致的显示效果和视觉体验 ,在此我们借助这个概念作为配置参数,来实现POI不会随着地图远近缩放而更改尺寸的效果。 PDI_vs.gif

在这里我们会用到高德API提供的非常重要的方法Map.getResolution(),它用于获取指定位置的地图分辨率(单位:米/像素),即当前缩放尺度下,1个像素长度可以代表多少米长度,在每次地图缩放时POI示例必须根据这个系数进行缩放,才能保证在视觉上是没有变化尺寸的。

接下来进行代码实现,对上文的代码再次进行改造:

  1. 监听地图缩放事件

    initMouseEvent() {
      this.map.on("zoomchange", this.handelViewChange);
    }
    
    /**
     * 初始化尺寸字典
     * @private
     */
    handelViewChange() {
      if (this._conf.PDI) {
        this.refreshTransformData();
        this.updatePOIMesh();
      }
    }
    
  2. 重新计算当前每个模型的目标尺寸系数,实际情况下每个模型的尺寸可能是不同的,这里为了演示方便都设为1了;完了再执行updatePOIMesh重新设置每个POI的尺寸即可。

    _sizeMap = {}
    /**
     * @description 重新计算每个模型的目标尺寸系数
     * @private
     */
    refreshTransformData() {
      this._resolution = this.getResolution();
      this._sizeMap["main"] = this._resolution * 1;
      this._sizeMap["tray"] = this._resolution * 1;
    }
    /**
     * @description 更新所有POI实例尺寸
     */
    updatePOIMesh() {
      const { _sizeMap } = this;
    
      // 更新模型尺寸
      const mainMesh = this._instanceMap["main"];
      const trayMesh = this._instanceMap["tray"];
    
      // 重置纹理偏移
      if (this?._mtMap?.tray?.map) {
        this._mtMap.tray.map.offset.x = 0;
      }
    
      for (let i = 0; i < this._data.length; i++) {
    	  // 获取空间坐标
        const [x, y] = this._data[i].coords;
        // 变换主体
        this.updateMatrixAt(
          mainMesh,
          {
            size: _sizeMap.main ,
            position: [x, y, 0],
            rotation: [0, 0, 0],
          },
          i
        );
        // 变换托盘
        this.updateMatrixAt(
          trayMesh,
          {
            size: _sizeMap.tray ,
            position: [x, y, 0],
            rotation: [0, 0, 0],
          },
          i
        );
      }
      // 强制更新instancedMesh实例
      if (mainMesh?.instanceMatrix) {
        mainMesh.instanceMatrix.needsUpdate = true;
      }
      if (trayMesh?.instanceMatrix) {
        trayMesh.instanceMatrix.needsUpdate = true;
      }
    }
    
  3. 再逐帧函数中,由于当前选中对象的变化矩阵也随着动画在不断调整,因此也需要把PDI系数带进去计算(工程目录/pages/poi3dLayer3.html)

    // 逐帧更新图层
    update() {
      //...
      // 鼠标悬浮对象
      if (_lastPickIndex.index !== null) {
        const [x, y] = this._data[_lastPickIndex.index].coords;
        const newSize = this._conf.PDI ? this._sizeMap.main: this._size
        //...
      }
      //...
    }
    

代码封装

最后为了让我们的代码具有复用性,我们将它封装为POI3dLayer类,将模型、颜色、尺寸、PDI、是否可交互、是否可动画等作为配置参数,具体操作可以看POI3dLayer.js这个类的写法。

//创建一个立体POI图层
async function initLayer() {
      const map = getMap()
      const features = await getData()
      const layer = new POI3dLayer({
          map,
          data: { features },
          size: 20,
          PDI: false
      })

      layer.on('pick', (event) => {
          const { screenX, screenY, attrs } = event
          updateMarker(attrs)
      })
  }
  
  // POI类的构造函数
  
   /**
   *  创建一个实例
   *  @param {Object}   config
   *  @param {GeoJSON|Array}  config.data  图层数据
   *  @param {ColorStyle}   [config.colorStyle] 顔色配置
   *  @param {LabelConfig}   [config.label] 用于显示POI顶部文本
   *  @param {ModelConfig[]}  [config.models] POI 模型的相关配置数组,前2个成员modelId必须为main和tray
   *  @param {Number}   [config.maxMainAltitude=1.0] 动画状态下,相对于初始位置的向上最大值, 必须大于minMainAltitude
   *  @param {Number}   [config.minMainAltitude=0]   动画状态下,相对于初始位置的向下最小距离, 可以为负数
   *  @param {Number}   [config.mainAltitudeSpeed=1.0] 动画状态下,垂直移动速度系数
   *  @param {Number}   [config.rotateSpeed=1.0] 动画状态下,旋转速度
   *  @param {Number}   [config.traySpeed=1.0] 动画状态下,圆环波动速度
   *  @param {Array}    [config.scale=1.0] POI尺寸系数, 会被models[i].size覆盖
   *  @param {Boolean}  [config.PDI=false] 像素密度无关(Pixel Density Independent)模式,开启后POI尺寸不会随着缩放而变化
   *  @param {Number}   [config.intensity=1.0] 图层的光照强度系数
   *  @param {Boolean}  [config.interact=true] 是否可交互
   */
   class POI3dLayer extends Layer {
		   constructor (config) {
				   super(conf)
					 //...
			 }
   }

这样一来我们配置模型和颜色就很便捷了,试试其他业务场景效果貌似也还可以,今天就到这里吧。

poi3dLayer2.gif

相关链接

演示工程代码gitbub地址

高德JS API 2.0 Map文档