在地图上实现高性能的iconLayer

3,957 阅读12分钟

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

这次我想换写作方式和大家分享1个解决问题的思路,讲述自己是如何通过排查原因、解决问题、迭代优化,一步步实现某个具体需求的。

发现问题

前几天刚对一个项目的可视化大屏做了视觉上的优化,添加了一些基于GIS数据而构建的webGL图层(以下简称gl-layers),以为高枕无忧了,然而上了测试环境后同事截图发来了这样问题: 当地图调整到某个角度后,基于路灯的设备点标记图层 iconLayer居然完全前置在其他图层可视化图层上面,并造成了视觉上的遮挡。这该如何是好?

Untitled.png

排查原因

造成这个问题的原因,简单来说就是当前的iconLayer和其他可视化图层gl-layers不在同一个“空间维度”,虽然两者都是通过高德地图JSAPI提供的CanvasLayer去实现的,但iconLayer的实现方案是通过地理坐标转屏幕像素坐标的方式,让数据对应的图标在二维空间呈现;而gl-layers是使用three.js渲染、加上与地图AMap空间对齐的方式实现,是基于三维空间构建的图层。因此iconLayer和gl-layres之间不构成深度测试关系(在three.js中称为depthTest),因此无法做出如下图的空间远近遮挡效果。

iconLayer5.gif

depthTest深度测试是一种在3D渲染中用来确定哪些物体应该被绘制的技术。当启用深度测试时,对于每个要绘制的像素,Three.js会检查它在场景中的深度值是否小于当前存储在深度缓冲区中的值。如果小于,则该像素被认为是可见的,会被绘制;否则该像素会被丢弃。

面对这个问题,目前最直接的解决方案是在gl-layers实现iconLayer,把图层都放到同个空间维度,就能解决图层遮挡问题,顺便解决其他问题。时间有限,我们可能不会一次性就把问题处理得完美,但可以先从图层的最小化功能需求开始,一步步去完善。

基本功能需求

  1. 图标位置与地图上地理空间对应
  2. 支持数据的特异性,比如可以根据设备的类型、状态、尺寸等属性展示不一样的效果
  3. 图层可交互,即被鼠标选中和点击时能够有反馈
  4. 在地图上支持尽量多的图标数据展示,至少支持1W的数据量

原始数据

本次调试使用数据量为57183个数据。采用geoJSON标准,格式如下:

{
"type":"FeatureCollection",
"features":[{
    "type":"Feature",
    "geometry":{
        "type":"Point",
        "coordinates":[ 113.463338, 22.825134]
      },
   "properties":{
      "deviceUid":"8104039040",
      "icon":"Bracelet",
      "color": "#00ff00",
      "scale":  1.0
	}
 }]

解决问题

版本1:Sprite+SpriteMaterial

翻阅three.js的官方文档,我发现Sprite这个Object3D的子类似乎可以满足需求。Sprite是一种特殊的2D平面对象,它总是面向摄像机并且可以在3D场景中进行渲染,通常用于显示图标、徽标或者粒子效果等元素。

于是我马上进行了尝试,通过高德地图customCoords.lngLatsToCoords解决地图坐标转换问题;可以使用Spirete.scale 和 SpriteMaterial给数据设置不同的尺寸和材质来实现数据特异性;使用raycast射线检测做鼠标选中,并通过调整选中对象的scale实现反馈;至于该方案能够支持的数据量,就直接测试看效果。

下面是核心代码实现:

// 数据空间坐标[[x,y],[x,y]...]
_data = []
// 当前选中的对象
_currIcon = null

/**
 * 初始化材质
 * @return {*}
 */
initMaterial () {
  const { icon } = this._conf
  const texture = new THREE.TextureLoader().load(icon.src)

  const material = new THREE.SpriteMaterial({
    map: texture,
    transparent: true,
    depthTest: true
  })
  // 透明度贴图
  if (icon.alphaMapURL) {
    material.alphaMap = new THREE.TextureLoader().load(icon.alphaMapURL)
  } else {
    material.alphaMap = texture
  }
  // 图标颜色
  if (icon.color) {
    material.color = new THREE.Color(icon.color)
  }
  // 叠加发亮效果
  material.blending = THREE.CustomBlending
  material.blendDst = THREE.OneFactor
  this._material = material
  return material
}

/**
 * 渲染图标,共享1种材质
 * @protected
 */
createMesh () {
  const { scene } = this
  const [w, h] = this._conf.icon.size

  for (let i = 0; i < this._data.length; i++) {
    const [x, y] = this._data[i]
    const sprite = new THREE.Sprite(this._material)
    sprite._attrs = this._features[i]
    sprite.scale.set(w, h, 0)
    sprite.position.set(x, y, h / 2)
    scene.add(sprite)
  }
}

/**
 * 处理拾取事件
 * @param targets {Array} 拾取对象
 */
onPicked ({ targets }) {
  const [w, h] = this._conf.icon.size

  if (targets.length > 0) {
    const mesh = targets[0].object
    mesh.scale.set(1.5 * w, 1.5 * h, 0)

    if (this._currIcon) {
      this._currIcon.object.scale.set(w, h, 0)
    }
    this._currIcon = targets[0]
  } else {
    if (this._currIcon) {
      this._currIcon.object.scale.set(w, h, 0)
    }
    this._currIcon = null
  }
}

Sprite方案的呈现效果:随着地图缩放,渲染数据量上升,帧率明显下降。 iconLayer2.gif

版本2: Points+PointMaterial

显然Sprite在数据量这个性能上遇到了瓶颈,一旦数据量上升性能下降非常明显;另外它的数据特异性和数据量性能两者是冲突的,增加特异性会影响性能,试图提高性能又必须牺牲数据特异性。

于是我们不得不寻找其他路径,在文档中发现了Points类,它恰恰是用来高效地渲染大量的3D粒子,也许可以解决数据量瓶颈的问题。

Points能够如此高性能的最大原因,是它默认只有1个几何体(大大减少drawcall),且仅用了1个顶点就能代表一个数据。

  1. 以下是Points的构造参数geometry的创建过程:

    import CustomShader from '../shader/PointIcon'
    
    /**
     * 渲染图标,粒子系统方法,支持大数据量
     * @protected
     */
    async createMesh () {
      // 创建几何体
      const geometry = this.generateGeometry()
      // 创建材质
      const material = await this.generateMaterial()
      // 创建网格体
      const particles = new THREE.Points(geometry, material)
    
      this.scene.add(particles)
    
      this._mesh = particles
    }
    
    /**
     * Points方案创建几何体
     * @return {*}
     */
    generateGeometry () {
      const geometry = new THREE.BufferGeometry()
      const vertices = []
      const { icon } = this._conf
      const size = icon.size[0]
    
      const vColor = new THREE.Color()
      for (let i = 0; i < this._data.length; i++) {
        const [x, y] = this._data[i]
        // 顶点位置
        vertices.push(x, y, 0)
      }
      return geometry
    }
    
    /**
     * 创建材质
     * @return {*}
     */
    generateMaterial(){
    	 const { icon, sizeAttenuation } = this._conf
    	 const material = new THREE.PointsMaterial({
    		 map: new THREE.TextureLoader().load(icon.src),
    		 size: icon.size,
    		 transparent: true
    	 })
    	 return material
    }
    
  2. 看看Points+PointMaterial方案最终的效果,高数据量基本不影响帧率,画面流畅。
    iconLayer1.gif

然而Points配合PointsMaterial使用,自身有个无法避免的问题——数据特异性功能薄弱。官方提供的例子 虽然能够提供部分特异性,但是用多实例的方式实现的,会带来另外的数据管理和配置的问题,这显然也不是个理想方案。

版本3: Points+ShaderMaterial

后来通过跟AI尬聊以及翻阅官方示例,我们找到第三个方案 webgl_custom_attributes_points2,也许有助于解决特异性问题。我们可以在自定义着色器的材质ShaderMaterial里,通过attribute直接使用到每个数据的独特属性做渲染,这简直太棒了。

  1. 核心代码,创建网格体

    import CustomShader from '../shader/PointIcon'
    
    /**
     * 渲染图标,粒子系统方法,支持大数据量
     * @protected
     */
    async createMesh () {
      // 创建几何体
      const geometry = this.generateGeometry()
      // 创建材质
      const material = await this.generateMaterial()
      // 创建网格体
      const particles = new THREE.Points(geometry, material)
    
      this.scene.add(particles)
    
      this._mesh = particles
    }
    
    /**
     * Sprite方案创建几何体
     * @return {*}
     */
    generateGeometry () {
      const geometry = new THREE.BufferGeometry()
      const vertices = []
      const sizes = []
      const colors = []
      const { icon } = this._conf
      const size = icon.size[0]
    
      const vColor = new THREE.Color()
      for (let i = 0; i < this._data.length; i++) {
        const [x, y] = this._data[i]
        // 顶点尺寸
        const scale = this._features[i].properties.scale || 1
        sizes.push(size * scale)
        // 顶点位置
        vertices.push(x, y, size * scale / 5.12)
        // 顶点颜色
        vColor.set(this._features[i].properties.color || '#ffffff')
        vColor.toArray(colors, i * 3)
      }
      // 所有顶点位置xyz
      geometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3))
      // 所有顶点颜色rgb
      geometry.setAttribute('customColor', new THREE.Float32BufferAttribute(colors, 3))
      // 所有顶点尺寸
      geometry.setAttribute('size', new THREE.Float32BufferAttribute(sizes, 1))
      return geometry
    }
    
    /**
     * 创建材质
     * @private
     */
    async generateMaterial () {
      const { icon, sizeAttenuation } = this._conf
      const texture = await new THREE.TextureLoader().load(icon.src)
    
      const { vertexShader, fragmentShader } = CustomShader
      const material = new THREE.ShaderMaterial({
        uniforms: {
          color: { value: new THREE.Color(icon.color || 0xffffff) },
          pointTexture: { value: texture },
          alphaTest: { value: 0.1 },
          sizeAttenuation: { value: sizeAttenuation }
        },
        vertexShader,
        fragmentShader,
        transparent: true
      })
    
      return material
    }
    
  2. 创建自定义ShaderMaterial材质这块需要重点讲讲,在做渲染的过程中,顶点着色器的每个顶点对应一个具体的数据,每个数据的position、customColor、size属性已经通过attribute 传入到着色器中。我们在顶点着色器中放大顶点的尺寸,并在片元着色器中进行图标纹理贴图和颜色的叠加,就可以得到每个数据的最终效果。

    // 定义一个着色器对象,包含各种统一变量和着色器代码
    const shader = {
      // 定义用于着色器的统一变量
      uniforms: {
        // 点的统一颜色,可以与点的特异颜色叠加
        color: { value: 'THREE.Color' },
        // 用于渲染点的纹理贴图,可以与颜色叠加
        pointTexture: { value: 'THREE.Texture' },
        // 透明度测试阈值统一变量
        alphaTest: { value: 0.9 },
        // 指定点的大小是否因相机深度而衰减
        sizeAttenuation: { value: true }
      },
      // 定义顶点着色器代码
      vertexShader: `
        // 每个点的尺寸倍率属性
        attribute float size; 
        // 每个点的自定义颜色属性
        attribute vec3 customColor;
        
        // 指定点的大小是否因相机深度而衰减
        uniform bool sizeAttenuation;
        
        // 传递颜色到片元着色器的变量
        varying vec3 vColor; 
       
        void main() {
        
          // 将自定义颜色传递到片元着色器
          vColor = customColor;
          
          // 将顶点位置转换到视图空间
          vec4 mvPosition = modelViewMatrix * vec4( position, 1.0 );
          
          if(sizeAttenuation){
            // 根据到相机的距离计算点的大小
            gl_PointSize = size * (512.0 / -mvPosition.z );     
          }else{
            gl_PointSize = size ;   
          }
          
          // 计算点在投影空间的最终位置
          gl_Position = projectionMatrix * mvPosition; 
        }
      `,
      // 定义片元着色器代码
      fragmentShader: `
        // 点的基础颜色统一变量
        uniform vec3 color;  // 基础颜色
        // 点贴图统一变量
        uniform sampler2D pointTexture; //点贴图
        // 透明度测试阈值统一变量
        uniform float alphaTest; //透明度阈值
        
        // 获取每个点自定义颜色的变量
        varying vec3 vColor; // 每个点的颜色
        
        void main() {
        
          // 计算点的最终颜色,乘以基础颜色和自定义颜色
          gl_FragColor = vec4( color * vColor, 1.0 );
          
          // 将点贴图应用到点上     
          // gl_FragColor = gl_FragColor * texture2D(pointTexture, gl_PointCoord);
          
          // 将点贴图应用到点上,gl_PointCoord的值是从[0, 1]映射到[1, 0]的范围,需要把贴图y分量翻转
          gl_FragColor = gl_FragColor * texture2D(pointTexture, vec2(gl_PointCoord.x, 1.0 - gl_PointCoord.y));
          
          // 如果alpha值小于透明度测试阈值,则丢弃片元
          if ( gl_FragColor.a < alphaTest ) discard;
          
        }
          `
    }
    
    // 导出着色器对象
    export default shader
    
  3. 于是我们就可以看到兼顾了大数据量和特异性(尺寸、颜色)的效果,其实纹理贴图也可以作为可配置参数事先声明,那么就可以做到不同类型使用不同图片。

    chatu1.jpg

  4. 解决当前方案的交互性问题

    在开发过程中其实还遇到另外一个问题,就是在实现“图层可交互”需求的时候,发现从光标处发射的raycast射线总是很难命中目标。排查了好久发现原因是作为Points的元素顶点,本身的可检测范围太小了只有1个像素点,在1920*1080的屏幕上要让粗细只有1像素的射线和1个像素的点产生碰撞说实话还是有很大难度。

    iconLayer7.gif

    所幸翻了下Raycast的文档发现了这样一个属性,可以通过调整raycast.params.Points.threshold 数值来修改射线和目标的碰撞精度,把threshold调整为10,表示当射线与目标顶点距离10个世界单位(在本案例是px)时便认为碰撞命中。

    // threshold 默认值为1
    this._raycaster.params.Points.threshold = 10.0 
    

    这是调整后的效果,最终我们可以看到图层既满足了大数据量、可交互、又实现了数据特异性需求。
    iconLayer3.gif

迭代拓展

由于最终版本采用的是Points方案,无论size设置多少,Point始终是个正方形的顶点。 能不能进一步再做优化,让数据支持更灵活的造型或者宽高尺寸配置,可以做更多“造型”的icon,更多“朝向”的icon。

其实也是可以的,我们沿用Points+ShaderMaterial的思路,把Points换成InstancedMesh就可以。

  1. 创建实例化网格体

    const { icon } = this._conf
    // 数据量
    const instanceCount = this._data.length
    
    // 创建材质
    const material = new THREE.MeshBasicMaterial({
      map: new THREE.TextureLoader().load(icon.src),
      transparent: true,
      alphaTest: 0.01,
      side: THREE.DoubleSide
    })
    
    // 创建网格体
    const [width, height] = icon.size
    const geometry = new THREE.PlaneGeometry(width, height)
    const instanceMesh = new THREE.InstancedMesh(geometry, material, instanceCount)
    this.scene.add(instanceMesh)
    this._mesh = instanceMesh
    
  2. 数据实例化

    // 实例化
    const dummy = new THREE.Object3D()
    const vColor = new THREE.Color()
    // debugger
    for (let i = 0; i < instanceCount; i++) {
      const [x, y] = this._data[i]
      const { color, scale = 1 } = this._features[i].properties
    
      // 重置 dummy 对象的状态
      dummy.rotation.set(0, 0, 0)
      // 定位
      dummy.position.set(x, y, height * scale / 2)
      // 翻转90度为立面
      dummy.rotateX(Math.PI / 2)
      // 尺寸
      dummy.scale.set(scale, scale, scale)
      dummy.updateMatrix()
    
      // 将对dummy的调整复制到具体的元素网格中
      instanceMesh.setMatrixAt(i, dummy.matrix)
      // 给实例添加颜色,取特异性颜色、或统一颜色、或者默认颜色
      instanceMesh.setColorAt(i, vColor.set(color || icon.color || '#ffffff'))
    }
    
  3. 交互性实现有所区别

     /**
     * InstancedMesh方案处理拾取事件
     * @param targets
     */
    onPicked ({ targets }) {
      if (targets.length > 0) {
        const index = targets[0].instanceId
        if (this._currIcon !== index) {
          // 旧选中者恢复原状态
          if (this._currIcon !== null) {
            this.updateInstancedMatrix(this._currIcon)
          }
          // 声明新选中者
          this._currIcon = index
          // 新选种者改变状态
          this.updateInstancedMatrix(this._currIcon, 1.2)
        }
      } else {
        // 旧选中者恢复原状态
        this.updateInstancedMatrix(this._currIcon)
        this._currIcon = null
      }
    }
    
    /**
     * 更新指定实例的变换矩阵
     * @param index
     * @param scale
     */
    updateInstancedMatrix (index, scale = 1) {
      if (index == null) {
        return
      }
      const height = this._conf.icon.size[1]
      const _scale = this._features[index]?.properties?.scale || 1.0
      const [x, y] = this._data[index]
    
      const matrix = new THREE.Matrix4()
      // 定位
      matrix.makeTranslation(x, y, height * _scale / 2)
      // 翻转90度为立面
      matrix.multiply(new THREE.Matrix4().makeRotationX(Math.PI / 2))
      // 尺寸
      matrix.scale(new THREE.Vector3(_scale * scale, _scale * scale, _scale * scale))
    
      // 更新变化矩阵
      this._mesh.setMatrixAt(index, matrix)
      this._mesh.instanceMatrix.needsUpdate = true
    }
    
  4. 我们来看看最终效果

    这样扩展一下思路,似乎就变成了3D点标记图层;再泛化一下,我不仅可以做点标记,也可以换成模型、动态自定义模型,这样一来不就可以实现更多的需求了

    iconLayer6.gif

    事实上经过这么一扩展,就实现了之前的文章分享到的海量模型渲染方案。忽然有一种殊途同归、融汇贯通的体验是怎么回事。

总结归纳

经过以上几组实践,我对Three.js中几种Object3D子类Sprites、Points、InstancedMesh的特性进行了深入的比较和评估。

image.png Sprites基于纹理的2D渲染对象,它们具有简单易用、渲染效率高等优点,非常适合用于UI界面或者粒子特效。

Points则是基于点的渲染方式,特别适合用于渲染大量的散点数据,例如星空、云层等。Points的渲染速度非常快,但是只能呈现简单的点状效果。

InstancedMesh则是用于渲染大量相同几何体的高效方式。它通过一次性传输所有实例的数据到GPU,大大减少了CPU和显卡之间的数据交互,非常适合用于渲染大规模的场景元素,如树木、建筑物等。

通过对这些特性的深入认识和评估,我相信在今后的开发中,我们能够更加得心应手地选择合适的Three.js渲染对象,为各种复杂的可视化需求提供高效、优质的解决方案。

相关链接

Points+ShaderMaterial点的分布和鼠标交互效果

使用Points实现雪花示例

如何在地图上加载大量模型