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 概念融合,当然也有性能问题。

​ 以上~。

相关推荐
Larcher几秒前
新手也能学会,100行代码玩AI LOGO
前端·llm·html
徐子颐13 分钟前
从 Vibe Coding 到 Agent Coding:Cursor 2.0 开启下一代 AI 开发范式
前端
小月鸭25 分钟前
如何理解HTML语义化
前端·html
jump6801 小时前
url输入到网页展示会发生什么?
前端
诸葛韩信1 小时前
我们需要了解的Web Workers
前端
brzhang1 小时前
我觉得可以试试 TOON —— 一个为 LLM 而生的极致压缩数据格式
前端·后端·架构
yivifu1 小时前
JavaScript Selection API详解
java·前端·javascript
这儿有一堆花1 小时前
告别 Class 组件:拥抱 React Hooks 带来的函数式新范式
前端·javascript·react.js
十二春秋2 小时前
场景模拟:基础路由配置
前端
六月的可乐2 小时前
实战干货-Vue实现AI聊天助手全流程解析
前端·vue.js·ai编程