mapbox中的自定义图层与地形和球结合的问题

mapbox-gl 在 2021年的v2.6.0 带来了自适应地图投影后于 mapbox 本身来说是一个很惊艳的提升,毕竟很多人苦 cesium 久已,但是于社区来说其实带来了很多的困扰,比如 deck.gl 和其他一些基于自定义图层的可视化库;毕竟 mapbox 设计之初并没有过多的考虑第三方开发者,所以现在很多第三方库还是以墨卡托投影为主。但是关于自定义图层支持地形和球的需求呼声其实还挺多,mapbox 官方也做出了一些改变,相关讨论可以查看以下issues

  • DeckGL 作者相关的提问12888

  • 自定义图层支持Globe12046

  • 自定义图层支持地形和球的一个 PR11996

  • 自定义图层支持地形最早的一个尝试11177

并且在 2.13.0 这个版本 mapbox 官方已经支持了自定义图层在地形和球的渲染,新增了 shouldRerenderTilesrenderToTile方法,并且扩充了 renderprerender 的入参:

js 复制代码
if (painter.transform.projection.name === "globe") {
    const center = painter.transform.pointMerc;
    prerender.call(implementation, context.gl, painter.transform.customLayerMatrix(), painter.transform.getProjection(), painter.transform.globeToMercatorMatrix(),  globeToMercatorTransition(painter.transform.zoom), [center.x, center.y], painter.transform.pixelsPerMeterRatio);
} else {
    prerender.call(implementation, context.gl, painter.transform.customLayerMatrix());
}

官方也提供了一个简单的示例,但是我们通过这两个示例其实也不难发现,点类型我们需要计算 ecef 顶点和普通墨卡托顶点,这样我们能通过不同的 projection 切换不同的渲染方式,虽然能实现 Globe 模式的渲染但是却并没有解决如何将点类型渲染到地形上。另外一个示例借助了renderToTile 方法,这个方法最终是将渲染结果合成在了 RTT 的代理瓦片上ProxyTile,具体细节参考mapbox地形渲染流程,这样我们只需要关注我们的渲染流程,球和地形的渲染逻辑将由 mapbox 内部进行处理,这样看起来似乎是简单了,但是我们需要注意的是我们的渲染应该落到哪张代理瓦片上,这是一个很复杂的问题。

实践

初次尝试

mapbox 关于自定义图层最简单的实例是绘制一个三角形,但是这个示例明确说了仅支持墨卡托投影,我们如何用新增的 API 实现贴球和地形呢?我们先来尝试一下。

我们用以下代码来创建一个地图和一个自定义图层,这里也用到了自定义图层新增的shouldRerenderTilesrenderToTile方法:

html 复制代码
<script src="https://cdn.jsdelivr.net/npm/mapbox-gl/dist/mapbox-gl.js"></script>
<script src="https://cdn.jsdelivr.net/npm/regl/dist/regl.js"></script>
<script type="module">
  import { mat4 } from 'https://cdn.skypack.dev/gl-matrix';
  mapboxgl.accessToken = ""; // eslint-disable-line

  const length = 1;
  function random(min, max) {
    return (Math.random() * (max - min)) + min;
  }

  function generateColors (count) {
    const data = [];
    for (let i = 0; i < count; i++) {
      // data.push(random(0, 1), random(0, 1), random(0, 1));
      data.push(1, 0, 0);
    }
    return data;
  }

  const coordinates = [
    [-180, 85.051129],
    [180, 85.051129],
    [180, -85.051129],
    [-180, -85.051129],
  ];
  const colors = generateColors(length * 3);

  ... 工具函数

  class CustomLayer {
    constructor () {
      this.id = 'custom';

      this.type = 'custom';

      this.renderingMode === '3d';

      this.options = {
        opacity: 0.9,
      };
    }

    onAdd(map, gl) {
      this.map = map;
      if (!this.regl) {
        this.regl = createREGL({
          gl: gl,
          // extensions: ['OES_texture_float', 'OES_element_index_uint'],
          attributes: {
            antialias: true,
            preserveDrawingBuffer: false,
          }
        });

        let i = 0;
        const len = coordinates.length;
        const cornerCoords = [];
        for (; i < len; i++) {
          const coords = coordinates[i];
          const mc = mapboxgl.MercatorCoordinate.fromLngLat([coords[0], coords[1]], coords[2]);
          cornerCoords.push(mc);
        }

        this.tileID = {
          canonical: {
            x: 0,
            y: 0,
            z: 0,
          },
          wrap: 0
        }

        const anchor = new mapboxgl.Point(this.tileID.canonical.x, this.tileID.canonical.y)._div(1 << this.tileID.canonical.z);
        this.aabb = cornerCoords.reduce((acc, coord) => {
          acc.min.x = Math.min(acc.min.x, coord.x - anchor.x);
          acc.min.y = Math.min(acc.min.y, coord.y - anchor.y);
          acc.max.x = Math.max(acc.max.x, coord.x - anchor.x);
          acc.max.y = Math.max(acc.max.y, coord.y - anchor.y);
          return acc;
        }, {
          min: new mapboxgl.Point(Number.MAX_VALUE, Number.MAX_VALUE),
          max: new mapboxgl.Point(-Number.MAX_VALUE, -Number.MAX_VALUE),
        });

        this.tileTransform = tileTransform(this.tileID.canonical, this.map.transform.projection);

        const cs = [
          [-120, 45, 0],
          [120, 45, 0],
          [0, -45, 0]
        ];

        const tileCoords = cs.map((coord) => {
          const projectedCoord = this.tileTransform.projection.project(coord[0], coord[1]);
          return getTilePoint(this.tileTransform, projectedCoord)._round();
        });

        const instancePositions = new Float32Array(tileCoords.length * 3);

        for (let j = 0; j < tileCoords.length; j++) {
          const tileCoord = tileCoords[j];
          instancePositions[j * 3] = tileCoord.x;
          instancePositions[j * 3 + 1] = tileCoord.y;
          instancePositions[j * 3 + 2] = 0;
        }

        this.drawTriangle = this.regl({

          // Shaders in regl are just strings.  You can use glslify or whatever you want
          // to define them.  No need to manually create shader objects.
          frag: `precision highp float;
varying vec3 vColor;
void main() {
  gl_FragColor = vec4(vColor, 0.5);
}
`,

          vert: `attribute vec3 a_pos;
attribute vec3 a_colors;

uniform mat4 u_matrix;
varying vec3 vColor;

void main(void) {
  gl_Position = u_matrix * vec4(a_pos, 1.0);
  vColor = a_colors;
}`,

          // Here we define the vertex attributes for the above shader
          attributes: {
            a_pos: this.regl.buffer(instancePositions),
            a_colors: this.regl.buffer(colors),
          },

          uniforms: {
            u_matrix: this.regl.prop('u_matrix'),
          },

          // This tells regl the number of vertices to draw in this command
          count: length * 3,
          primitive: 'triangles',
          blend: {
            enable: true,
            func: {
              src: 'src alpha',
              dst: 'one minus src alpha',
            },
            equation: {
              rgb: 'add',
              alpha: 'add',
            },
            color: [0, 0, 0, 0],
          },
        });
      }
    }

    shouldRerenderTiles() {
      // 最好只在图层变化时返回 true,因为他会清空关联的 rtt 的缓存造成性能问题
      return true;
    }

    renderToTile (gl, tileId) {
      const flag = tileOutsideImage(tileId, this.tileID, this.aabb);
      if (flag) return;

      const scale = tileId.z - this.tileID.canonical.z;
      const matrix = mat4.create();
      let size, xOffset, yOffset;
      const wrap = (this.tileID.wrap - 0/*proxyTileID.wrap*/) << tileId.z;
      if (scale > 0) {
        size = EXTENT >> scale;
        xOffset = size * ((this.tileID.canonical.x << scale) - tileId.x + wrap);
        yOffset = size * ((this.tileID.canonical.y << scale) - tileId.y);
      } else {
        size = EXTENT << -scale;
        xOffset = EXTENT * (this.tileID.canonical.x - ((tileId.x + wrap) << -scale));
        yOffset = EXTENT * (this.tileID.canonical.y - (tileId.y << -scale));
      }
      mat4.ortho(matrix, 0, size, 0, size, 0, 1);
      mat4.translate(matrix, matrix, [xOffset, yOffset, 0]);

      this.drawTriangle({
        u_matrix: matrix,
      });
      this.regl._refresh();
    }

    render() {
      // if (this.matrix) {
      //   this.drawTriangle({
      //     u_matrix: this.matrix,
      //   });
      //   this.regl._refresh();
      // }
    }
  }
  
  this.map.on('load', () => {
    this.map.setFog({
      range: [-0.5, 2],
      color: 'white',
      'horizon-blend': 0.2,
    });

    // Add a sky layer over the horizon
    this.map.addLayer({
      id: 'sky',
      // @ts-ignore
      type: 'sky',
      paint: {
        // @ts-ignore
        'sky-type': 'atmosphere',
        'sky-atmosphere-color': 'rgba(85, 151, 210, 0.5)',
      },
    });

    // Add terrain source, with slight exaggeration
    this.map.addSource('mapbox-dem', {
      type: 'raster-dem',
      url: 'mapbox://mapbox.terrain-rgb',
      tileSize: 512,
      // maxzoom: 16,
    });

    this.map.setTerrain({ source: 'mapbox-dem', exaggeration: 2.5 });

    this.map.showTerrainWireframe = false;
    this.map.showTileBoundaries = true;

    map.addLayer(new CustomLayer());
  });
</script>

具体渲染流程如下:

我们查看一下图层渲染状态如下,我们通过Spector也能发现其实最终我们的图形其实是切割到了不同的 fbo 上渲染,我们虽然将图层绑定在 0-0-0 的 TileID 之上,但是在大层级下 ProxyTileID 是动态计算出来的 1024*1024 的瓦片,所以我们的图形各部分会渲染到期对应的 fbo之上。

实现的效果:

  1. 球视角:
  1. 地形视角:

以上方法有个局限就是我们的数据最终都是绑定 0-0-0 的瓦片之上的,我们并没有动态去计算,在大层级下瓦片投影和顶点坐标的精度会不够在大概 14 级之后就会出现渲染问题。

进阶版

这次我们考虑到直接使用自定义图层的 renderToTile 方法局限性太大,我们这次暂时先不考虑地形,有没有可能将 mapbox 中的 globeRaster 这套渲染逻辑移植出来呢,答案当然是可行的,但是工作量似乎有点大,其实核心的内容都在globe_utildrawTerrainForGlobe以及着色器globe_raster.vertex.glslglobe_raster.fragment.glsl。这里改造(复制到外部改造)的主要内容如下:

  1. globe_util中的 _createGridgetGridBuffers方法:

    主要目的是不再自动创建VertexBuffer和IndexBuffer,我们会交给其他渲染引擎去做

    ts 复制代码
    _createGrid() {
      const gridWithLods = this._fillGridMeshWithLods(GLOBE_VERTEX_GRID_SIZE, GLOBE_LATITUDINAL_GRID_LOD_TABLE);
    
      this._gridSegments = gridWithLods.segments;
    
      this.vertices = gridWithLods.vertices;
      this._gridIndexBuffer = gridWithLods.indices;
    }
    
    getGridBuffers(latitudinalLod: number, withSkirts: boolean): any {
      return [
        this.vertices,
        this._gridIndexBuffer,
        withSkirts ? this._gridSegments[latitudinalLod].withSkirts : this._gridSegments[latitudinalLod].withoutSkirts,
      ];
    }
  2. 渲染层onAdd:

    在这里我们需要创建 GlobeSharedBuffers和创建 ProgramManager并加载其他的一些资源,核心代码如下:

    ts 复制代码
    import { ProgramManager } from '@luma.gl/engine';
    import type { Program } from '@luma.gl/webgl';
    import { Texture2D, VertexArray, Buffer } from '@luma.gl/webgl';
    import {
      globeMetersToEcef,
      calculateGlobeMercatorMatrix,
      globeToMercatorTransition,
      getGridMatrix,
      tileCornersToBounds,
      globeNormalizeECEF,
      globeTileBounds,
      getLatitudinalLod,
      GlobeSharedBuffers,
      globeUseCustomAntiAliasing,
    } from './utils/globe-util';
    
    this.globeSharedBuffers = new GlobeSharedBuffers();
    
    const image = new Image();
    
    image.crossOrigin = '*';
    
    image.onload = () => {
      this.texture = new Texture2D(
        gl as unknown as any,
        {
          data: image,
          mipmaps: true,
          width: image.width,
          height: image.height,
          pixelStore: {
            [gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL]: false,
          },
          parameters: {
            [gl.TEXTURE_MAG_FILTER]: gl.LINEAR,
            [gl.TEXTURE_MIN_FILTER]: gl.LINEAR,
            [gl.TEXTURE_WRAP_S]: gl.CLAMP_TO_EDGE,
            [gl.TEXTURE_WRAP_T]: gl.CLAMP_TO_EDGE,
          },
          type: gl.UNSIGNED_BYTE,
          format: gl.RGBA,
        } as any,
      );
    };
    
    image.src = 'https://a.basemaps.cartocdn.com/light_all/1/0/0.png';
    
    this.programManager = new ProgramManager(gl);
  3. Render:

    渲染层的流程基本上和 mapbox 内部逻辑保持一致,但是引擎因为无法使用 mapbox 内部类,改为使用 luma.gl

    ts 复制代码
    if (!this.map || !this.programManager || !this.texture) return;
    const map = this.map as any;
    const tr = map.painter.transform;
    const useDenormalizedUpVectorScale = true;
    
    const globeMercatorMatrix = calculateGlobeMercatorMatrix(tr);
    const mercatorCenter: [number, number] = [mercatorXfromLng(tr.center.lng), mercatorYfromLat(tr.center.lat)];
    const viewport: [number, number] = [tr.width * devicePixelRatio, tr.height * devicePixelRatio];
    const globeMatrix = Float32Array.from(tr.globeMatrix);
    const skirtHeightValue = 0;
    
    // 获取经纬度 bounds
    const tileBounds = tileCornersToBounds({ x: 0, y: 0, z: 1 });
    // 获取瓦片中心点位置的 lod
    const latitudinalLod = getLatitudinalLod(tileBounds.getCenter().lat);
    
    // 计算当前瓦片的网格矩阵
    // 注意这里必要要瓦片行列号,局限性很大
    const gridMatrix = getGridMatrix(
      { x: 0, y: 0, z: 1 },
      tileBounds,
      latitudinalLod,
      tr.worldSize / tr._pixelsPerMercatorPixel,
    );
    const normalizeMatrix = globeNormalizeECEF(globeTileBounds({ x: 0, y: 0, z: 1 }));
    
    const [buffer, indexBuffer, segments] = this.globeSharedBuffers.getGridBuffers(
      latitudinalLod,
      skirtHeightValue !== 0,
    );
    
    const useCustomAntialiasing = globeUseCustomAntiAliasing(map.painter, map.painter.context, tr);
    
    const defines: { [key: string]: string } = { PROJECTION_GLOBE_VIEW: '' };
    if (useCustomAntialiasing) {
      // 关于抗锯齿: https://github.com/mapbox/mapbox-gl-js/pull/11827
      defines.CUSTOM_ANTIALIASING = '';
    }
    
    this.program = this.programManager.get({
      vs: vs,
      fs: fs,
      defines,
      modules: [],
    });
    
    // const vertexAttribDivisorValue = undefined;
    
    this.program.setUniforms({
      u_image0: this.texture,
      u_proj_matrix: tr.projMatrix,
      u_globe_matrix: globeMatrix,
      u_merc_matrix: globeMercatorMatrix,
      u_normalize_matrix: normalizeMatrix,
      u_zoom_transition: globeToMercatorTransition(tr.zoom),
      u_merc_center: mercatorCenter,
      u_frustum_tl: tr.frustumCorners.TL,
      u_frustum_tr: tr.frustumCorners.TR,
      u_frustum_br: tr.frustumCorners.BR,
      u_frustum_bl: tr.frustumCorners.BL,
      u_globe_pos: tr.globeCenterInViewSpace,
      u_globe_radius: tr.globeRadius,
      u_viewport: viewport,
      u_grid_matrix: gridMatrix,
      u_skirt_height: skirtHeightValue,
    
      u_tile_tl_up: tr.projection.upVector({ x: 0, y: 0, z: 1 }, 0, 0),
      u_tile_tr_up: tr.projection.upVector({ x: 0, y: 0, z: 1 }, EXTENT, 0),
      u_tile_br_up: tr.projection.upVector({ x: 0, y: 0, z: 1 }, EXTENT, EXTENT),
      u_tile_bl_up: tr.projection.upVector({ x: 0, y: 0, z: 1 }, 0, EXTENT),
      u_tile_up_scale: useDenormalizedUpVectorScale
        ? globeMetersToEcef(1)
        : tr.projection.upVectorScale({ x: 0, y: 0, z: 1 }, tr.center.lat, tr.worldSize).metersToTile,
    });
    
    for (const segment of segments) {
      const vaos = segment.vaos || (segment.vaos = {});
      const vao: VertexArray =
        vaos[this.id] ||
        (vaos[this.id] = new VertexArray(gl, {
          attributes: {
            a_pos: new Buffer(gl, {
              data: buffer.int16,
              target: gl.ARRAY_BUFFER,
              usage: gl.STATIC_DRAW,
              accessor: {
                size: 2,
                stride: 4,
                type: gl.SHORT,
                offset: segment.vertexOffset + (4 * (0)),
              },
            }),
          },
          elements: new Buffer(gl, {
            data: indexBuffer.uint8,
            target: gl.ELEMENT_ARRAY_BUFFER,
            usage: gl.DYNAMIC_DRAW,
            // accessor: {
            //   type: gl.UNSIGNED_SHORT,
            //   size: 3,
            //   stride: 6,
            // },
          }),
          program: this.program,
        }));
    
      this.program.draw({
        vertexArray: vao,
        drawMode: gl.TRIANGLES,
        vertexCount: segment.primitiveLength * 3,
        indexType: gl.UNSIGNED_SHORT,
        offset: segment.primitiveOffset * 3 * 2,
        isIndexed: true,
      });
    }
  4. 顶点着色器

    这里我们主要关于顶点着色器,主流程中首先根据 u_grid_matrix 取出的三个值(分别是 3*3 矩阵的最后一列的瓦片数量,瓦片列号 x、行号 y)和瓦片坐标a_pos 计算 latLng 坐标;再通过latLng坐标反算mercator坐标并根据行列号反算 uv,然后再计算 ecef 坐标;得到 ecef 坐标后再通过u_globe_matrix * vec4(globe_pos, 1.0) 计算globe_world_pos

    glsl 复制代码
    #define EPSILON 0.0000001
    #define PI 3.141592653589793
    #define EXTENT 8192.0
    #define HALF_PI PI / 2.0
    #define QUARTER_PI PI / 4.0
    #define RAD_TO_DEG 180.0 / PI
    #define DEG_TO_RAD PI / 180.0
    #define GLOBE_RADIUS EXTENT / PI / 2.0
    
    uniform mat4 u_proj_matrix;
    uniform mat4 u_normalize_matrix;
    uniform mat4 u_globe_matrix;
    uniform mat4 u_merc_matrix;
    uniform float u_zoom_transition;
    uniform vec2 u_merc_center;
    uniform mat3 u_grid_matrix;
    uniform float u_skirt_height;
    uniform float u_tile_up_scale;
    
    #ifdef PROJECTION_GLOBE_VIEW
    
    uniform vec3 u_tile_tl_up;
    uniform vec3 u_tile_tr_up;
    uniform vec3 u_tile_br_up;
    uniform vec3 u_tile_bl_up;
    
    vec3 elevationVector(vec2 pos) { return vec3(0, 0, 1); }
    
    #else
    
    vec3 elevationVector(vec2 pos) { return vec3(0, 0, 1); }
    
    #endif
    
    attribute vec2 a_pos; // .xy - grid coords, .z - 1 - skirt, 0 - grid
    
    varying vec2 v_pos0;
    
    ...其他工具函数
    
    void main() {
        // The 3rd row of u_grid_matrix is only used as a spare space to
        // pass the following 3 uniforms to avoid explicitly introducing new ones.
        float tiles = u_grid_matrix[0][2];
        float idx = u_grid_matrix[1][2];
        float idy = u_grid_matrix[2][2];
    
        vec3 decomposed_pos_and_skirt = decomposeToPosAndSkirt(a_pos);
    
        vec3 latLng = u_grid_matrix * vec3(decomposed_pos_and_skirt.xy, 1.0);
    
        float mercatorY = mercatorYfromLat(latLng[0]);
        float uvY = mercatorY * tiles - idy;
    
        float mercatorX = mercatorXfromLng(latLng[1]);
        float uvX = mercatorX * tiles - idx;
    
        vec3 globe_pos = latLngToECEF(latLng.xy);
        vec2 merc_pos = vec2(mercatorX, mercatorY);
        vec2 uv = vec2(uvX, uvY);
    
        v_pos0 = uv;
        vec2 tile_pos = uv * EXTENT;
    
        // Used for poles and skirts
        vec3 globe_derived_up_vector = normalize(globe_pos) * u_tile_up_scale;
        vec3 up_vector = elevationVector(tile_pos);
    
        float height = 0.0;
    
        globe_pos += up_vector * height;
    
        vec4 globe_world_pos = u_globe_matrix * vec4(globe_pos, 1.0);
        vec4 merc_world_pos = vec4(0.0);
        if (u_zoom_transition > 0.0) {
            merc_world_pos = vec4(merc_pos, height - u_skirt_height * decomposed_pos_and_skirt.z, 1.0);
            merc_world_pos.xy -= u_merc_center;
            merc_world_pos.x = wrap(merc_world_pos.x, -0.5, 0.5);
            merc_world_pos = u_merc_matrix * merc_world_pos;
        }
    
        vec4 interpolated_pos = vec4(mix(globe_world_pos.xyz, merc_world_pos.xyz, u_zoom_transition), 1.0);
    
        gl_Position = u_proj_matrix * interpolated_pos;
    }

渲染结果如下:

通过以上代码我们也能看到 TileID 的影子,其实在 js 逻辑中我们也完全可以替换掉,只是需要在计算 GlobeSharedBuffers 的时候直接得到 ecef 坐标,但是这样我们每张瓦片都需要重新计算,顶点数据无法复用,这也是 mapbox 为什么是在着色器中反算 ecef 坐标的缘故,优化无处不在。

总结

​ 其实通过以上尝试我们也能感受到目前自定义图层想完美的和 mapbox 的地形和球融合其实还没有很好的办法,我们期待的当然是我们只需要在自定义图层做渲染不管是 ecef 坐标和墨卡托坐标,mapbox 能自动为我们处理地形贴图和球面贴图;但是目前来看路还很长,短时间内似乎没有希望,当然 mapbox 也在积极改进。

​ 我们大部分人在使用 mapbox 的自定义图层实际上是背离了 mapbox 的初衷,其实也背离了 mapbox-gl 渲染的基础-瓦片,我们在翻阅 mapbox-gl 源码也能看到除了特殊的 background 图层,其他的各类图层都是依托 Tile 这个核心来设计的,包括我们使用 GeoJSON 数据也在内部转换为了 VT。如果不从瓦片的概念去考虑我们其实很难和 mapbox 的渲染流程结合在一起,但是如果都从瓦片的角度考虑,很多场景我们就没法适用,这个暂时是个无解的问题。把代码搬出来在外部结合自定义图层也不是很好的办法,一个是 mapbox 变更会很快,我们不可能经常性的去同步代码;另一个是我们也没办法与 mapbox 的 style 概念融合,当然也有性能问题。

​ 以上~。

相关推荐
新缸中之脑9 分钟前
Llama 3.2 安卓手机安装教程
前端·人工智能·算法
hmz85613 分钟前
最新网课搜题答案查询小程序源码/题库多接口微信小程序源码+自带流量主
前端·微信小程序·小程序
看到请催我学习19 分钟前
内存缓存和硬盘缓存
开发语言·前端·javascript·vue.js·缓存·ecmascript
blaizeer1 小时前
深入理解 CSS 浮动(Float):详尽指南
前端·css
速盾cdn1 小时前
速盾:网页游戏部署高防服务器有什么优势?
服务器·前端·web安全
小白求学11 小时前
CSS浮动
前端·css·css3
什么鬼昵称1 小时前
Pikachu-csrf-CSRF(POST)
前端·csrf
XiaoYu20022 小时前
22.JS高级-ES6之Symbol类型与Set、Map数据结构
前端·javascript·代码规范
golitter.2 小时前
Vue组件库Element-ui
前端·vue.js·ui
golitter.2 小时前
Ajax和axios简单用法
前端·ajax·okhttp