mapbox 地形渲染流程

976 阅读8分钟

简介

mapbox 中的地形渲染用到了一个概念 RTT(render to texture),核心就是将常规的线、面等图形渲染到对应的 FBO 上,然后使用 FBO 进行贴图。 但是注意,这里并不是所有图层都使用了 RTT 进行渲染,比如 circleheatmapsymbol都是直接计算的 ecef(Earth-Centered, Earth-Fixed,地心坐标系,也是是笛卡尔坐标系的一种) 顶点,在 globe 投影时使用球面顶点坐标渲染。具体可以查看 bucket 逻辑。

核心流程

  1. Painter 流程
sequenceDiagram
  participant map as Map
  participant Painter
  participant Terrain

  map->>Painter: render(style)
  Painter->>Painter: updateTerrain
  map->>Painter: _updateTerrain
  Painter->>Terrain: 创建地形实例
  Painter->>Painter: 渲染图层
  loop for each layer
  	Painter->>ImageManager: beginFrame
  	Painter->>layer: 计算可视瓦片坐标OverscaledTileID
  	Painter->>Terrain: updateTileBinding(计算代理瓦片)
  	Painter->>Globe: 计算globeSharedBuffers
  	Painter->>Painter: 检测 Token 是否合法
    Painter->>layer: renderLayer(pass=offscreen)
    Painter->>Terrain: Terrain.drawDepth()
    Painter->>Painter: 解绑 fbo(bindFramebuffer.set(null))绘制大气
    Painter->>layer: 绘制大气(drawAtmosphere)
    Painter->>layer: renderLayer(pass=sky)
    Painter->>layer: renderLayer(pass=opaque)
    Painter->>layer: renderLayer(pass=translucent)
    Painter->>Terrain: renderBatch
    Painter->>Terrain: postRender
    Painter->>layer: renderLayer(pass=debug)
  end
  1. Terrain 流程
sequenceDiagram
  participant Painter
  participant Terrain

  Painter->>Terrain: update

  Painter->>Terrain: updateTileBinding
  
  Terrain->>Terrain: renderBatch
  Terrain->>Terrain: renderToBackBuffer
  Terrain->>draw_terrain_raster: drawTerrainRaster
  alt [painter.transform.projection.name === 'globe']
     draw_terrain_raster->>drawTerrainForGlobe: 球面绘制
  else
     draw_terrain_raster->>drawTerrain: 绘制代理瓦片到地形
  end
 

源码

实际上自 2.0 之后 mapbox-gl 核心的变更非常大,地形的渲染涉及的地方也非常多,比如 style-spec 的变更、顶点数据处理的变更 bucket、着色器的变更(加入了 fog、light)、渲染器 Painter 的变更,以及新增的 Terrain 逻辑等等。但是核心的渲染流程我们应该从 Painter 和 Terrain 聊起。

Painter

地形的实例创建时在 painter 的 updateTerrain 方法中,通过 map 实例中的 _updateTerrain 方法调用。渲染层在 painter 的 render 方法中。

按照顺序,Painter.render主要做了以下操作:

  1. image 资源管理,字体资源管理

主要是从 style 上获取 imageManagerglyphManager,然后执行 symbol 图层(标注、图标)的 fade 动画,主要用于比如碰撞 检测或者显示隐藏的淡入淡出效果。

imageManager.beginFrame() 主要用于图标相关动画,比如我们使用 canvas 作为图标执行的动画,更深入的可以参考如何在 mapbox 中使用动态图标

this.style = style;
this.options = options;

// 图片资源
this.imageManager = style.imageManager;
// 字体资源
this.glyphManager = style.glyphManager;

this.symbolFadeChange = style.placement.symbolFadeChange(browser.now());

this.imageManager.beginFrame();
  1. 准备数据对应的 SourceCache,计算可视瓦片坐标OverscaledTileID
const layerIds = this.style.order;
const sourceCaches = this.style._sourceCaches;

// 准备 sourceCache
for (const id in sourceCaches) {
   const sourceCache = sourceCaches[id];
   if (sourceCache.used) {
      sourceCache.prepare(this.context);
   }
}

// 升序
const coordsAscending: {[_: string]: Array<OverscaledTileID>} = {};
// 降序
const coordsDescending: {[_: string]: Array<OverscaledTileID>} = {};
// 降序的 Symbol 瓦片坐标
const coordsDescendingSymbol: {[_: string]: Array<OverscaledTileID>} = {};

for (const id in sourceCaches) {
   const sourceCache = sourceCaches[id];
   coordsAscending[id] = sourceCache.getVisibleCoordinates();
   coordsDescending[id] = coordsAscending[id].slice().reverse();
   coordsDescendingSymbol[id] = sourceCache.getVisibleCoordinates(true).reverse();
}
  1. 在所有图层中查找不透明 Pass 的分界标识

这里我们所使用的 layerIds 是已经排序后的,具体可以参见 style.order

image-20231117191918628

  1. 首先 绑定 tile,计算当前图层的瓦片应该渲染到哪个代理瓦片 ProxyTile
if (this.terrain) {
   this.terrain.updateTileBinding(coordsDescendingSymbol);
   // All render to texture is done in translucent pass to remove need
   // for depth buffer allocation per tile.
   this.opaquePassCutoff = 0;
}
  1. 如果投影为 globe 模式,那么需要创建 GlobeSharedBuffers(网格化的顶点坐标),这个 Buffer 会在后面的 globe 渲染中多次使用
if (this.transform.projection.name === 'globe' && !this.globeSharedBuffers) {
   this.globeSharedBuffers = new GlobeSharedBuffers(this.context);
}
  1. 检测 Token 是否合法

如果不合法,直接中断渲染。

// Following line is billing related code. Do not change. See LICENSE.txt
if (!isMapAuthenticated(this.context.gl)) return;
  1. offscreen pass 阶段

在这个渲染阶段,我们将所有需要 OffscreenPass 渲染的内容渲染到单独的帧缓冲,以便后续在其他 Pass 中使用。比较典型的应用是 heatmap 图层,在 Offscreen 阶段渲染亮度图fbo,在后续阶段渲染到地图。

image-20231117191952040

  1. 如果有地形并且有点 symbol 或者 circle 类型的图层,那么需要写入深度

主要用于文本标签的遮挡测试。

image-20231214155050946

  1. 解绑 offscreen framebuffer,清空颜色缓冲和深度缓冲以及模版缓冲

image-20231214155126649

  1. 执行不透明(opaque) renderPass 链接

不透明渲染按照从上到下的顺序渲染,并且在无地形时才执行,在有地形时是进入其他 Pass 合成(见后续)

image-20231214155339572

  1. 绘制大气 - Fog 和天空盒渲染 - Sky pass

image-20231214155610431

  1. Translucent pass

此渲染过程是从下向上渲染

image-20231214155716452

  1. 绘制瓦片边界、显示 QueryGeometry 、显示瓦片轴对齐包围盒、显示地图 padding 和 Crosshair等

image-20231117192100245

Terrain

核心绘制层讲完就到了本文的核心-地形渲染,但是此处需要注意,虽然谈的是地形渲染流程,但是我们更需要了解其核心思想 RTT 流程。 核心部分都存于Terrain 这个文件,代码量有点大我们只需要关注核心方法,按照渲染流程调用的方法如下:

  1. update

此方法主要做了以下事情:

image-20231216000322768

update(style: Style, transform: Transform, adaptCameraAltitude: boolean) {
  // 判断是否开启 terrain
  if (style && style.terrain) {
      if (this._style !== style) {
          this.style = style;
      }
      this.enabled = true;
      const terrainProps = style.terrain.properties;
      // 是否开启地形的延迟渲染,如果开启那么 `sourceCache` 走 `_mockSourceCache`, 否则直接走地形数据的 `sourceCache`
      const isDrapeModeDeferred = ...;
      this._exaggeration = terrainProps.get('exaggeration');

      const updateSourceCache = () => {
      };

      if (!this.sourceCache.usedForTerrain) {}
  		// 更新数据源缓存
      updateSourceCache();
      // 约束相机,避免相机进入地形之下(这也是我们在大层级下平移地图时会感觉地图 zoom 变化的原因)
      transform.updateElevation(true, adaptCameraAltitude);

      // 重置瓦片缓存并更新需要挤压的瓦片坐标。
      this.resetTileLookupCache(this.proxySourceCache.id);
  }
  • 判断是否开启 terrain (虽然此处变量是 terrain 地形,但是实际上你可以认为是开启了 rtt 模式渲染),如果没有的话将状态设置为禁用。
  • 是否开启地形的延迟渲染,如果开启那么 sourceCache_mockSourceCache, 否则直接走地形数据的 sourceCache
  • 更新数据源缓存。
  • 更新相机约束,避免相机进入地下。
  • 更新瓦片缓存。
  • 更新 proxySourceCache
  1. updateTileBinding

    此方法主要由Painter.render调用,主要目的是更新Source 中瓦片的代理瓦片。说到这里就必须先了解一下 mapbox-gl 中针对渲染层所做的优化,这也和上面所提到的 RTT 流程相关,那么优化层并不仅仅有 RTT,我们知道 mapbox 中大部分的瓦片源的 size 通常为 512,那么 RTT 所使用的 FBO 的大小也是 512 吗,答案并不是。这个地方的计算我们可以通过 getScaledDemTileSize 得到,通常为 tileSize / GRID_DIM * 512 = 2 * tileSize 的大小:

    image-20231216002443938

    即 1024 的大小,这么实现的目的猜测也是为了减少瓦片地形贴图和球面贴图的 drawcall,尽可能的合并渲染。这也是updateTileBinding的意义-为源瓦片查找其所在的代理瓦片,并将源瓦片绘制在代理瓦片中,当然还需要考虑瓦片的平移、旋转、缩放。

    当然这里除了以上提到的优化手段外,还有其他的优化手段:比如代理瓦片如何更新、缓存如何失效,感兴趣的可以自己去探究源码。

    image-20231216005311549

    updateTileBinding(sourcesCoords: {[string]: Array<OverscaledTileID>}) {
        if (!this.enabled) return;
        this.prevTerrainTileForTile = this.terrainTileForTile;
    
        const psc = this.proxySourceCache;
        const tr = this.painter.transform;
        if (this._initializing) {
          // 判断是否需要初始化,仅当地图中心点位置的瓦片加载完成后
        }
    
      	// 从代理数据源计算代理瓦片,并更新代理矩阵
        const coords = this.proxyCoords = psc.getIds().map((id) => {
            const tileID = psc.getTileByID(id).tileID;
            tileID.projMatrix = tr.calculateProjMatrix(tileID.toUnwrapped());
            return tileID;
        });
      
      	// 安装距离相机距离重新排序瓦片
        sortByDistanceToCamera(coords, this.painter);
        this._previousZoom = tr.zoom;
    
      	// 保存上次代理换成
        const previousProxyToSource = this.proxyToSource || {};
      	// 清除本次需要计算的缓存
        this.proxyToSource = {};
        coords.forEach((tileID) => {
            this.proxyToSource[tileID.key] = {};
        });
    
        this.terrainTileForTile = {};
        const sourceCaches = this._style._sourceCaches;
        for (const id in sourceCaches) {
          	...
            // 更新后,我们重新计算代理瓦片,需要重置缓存
            if (sourceCache !== this.sourceCache) this.resetTileLookupCache(sourceCache.id);
          	// 计算对应 TileID 的代理瓦片
            this._setupProxiedCoordsForOrtho(sourceCache, sourcesCoords[id], previousProxyToSource);
            ...
        }
    
        // 处理Background图层,背景没有Source。使用 1-1 ortho 的代理坐标(this.proxiedCoords[psc.id]) 用于将背景渲染到代理瓦片
        this.proxiedCoords[psc.id] = coords.map(tileID => new ProxiedTileID(tileID, tileID.key, this.orthoMatrix));
      	// 查找所需的地形瓦片  
      	this._assignTerrainTiles(coords);
        // 准备地形纹理
      	this._prepareDEMTextures();
      	// 合批(优化点)
        this._setupDrapedRenderBatches();
      	// 初始化代理瓦片的 fbo 池(优化点)
        this._initFBOPool();
      	// 设置缓存
        this._setupRenderCache(previousProxyToSource);
    
        this.renderingToTexture = false;
        this._updateTimestamp = browser.now();
    
        // 收集代理瓦片所需的 dem 瓦片,并计算其可见性
        ...
    
    }
    
  2. renderBatch

    mapbox在实现批渲染时采用了先绘制非挤压图层到代理瓦片,将代理瓦片绘制到屏幕空间(但是这里有个渲染池,未超过渲染池时还不会直接渲染到地形),再进行下一个代理瓦片的绘制的策略。

    image-20231216010428503

    image-20231216011850090

    renderBatch(startLayerIndex: number): number {
      	// 如果当前帧无挤压图层
        if (this._drapedRenderBatches.length === 0) {
            return startLayerIndex + 1;
        }
    
        this.renderingToTexture = true;
        // 消费挤压图层的渲染队列
        const drapedLayerBatch = this._drapedRenderBatches.shift();
    
        const accumulatedDrapes = [];
        const layerIds = painter.style.order;
    
        let poolIndex = 0;
        for (const proxy of proxies) {
          	...
            // 计算当前代理瓦片的 fbo
            const fbo = renderCacheIndex !== undefined ? psc.renderCache[renderCacheIndex] : this.pool[poolIndex++];
            const useRenderCache = renderCacheIndex !== undefined;
    
            tile.texture = fbo.tex;
    
          	// 是否使用缓存
            if (useRenderCache && !fbo.dirty) {
                accumulatedDrapes.push(tile.tileID);
                continue;
            }
    
            context.bindFramebuffer.set(fbo.fb.framebuffer);
            this.renderedToTile = false; // reset flag.
          	// 标识当前 fbo 需要更新,那么需要先清除绘制结果
            if (fbo.dirty) {
                context.clear({color: Color.transparent, stencil: 0});
                fbo.dirty = false;
            }
    
    		...
            // 这里在执行绘制到 fbo 时无需考虑模板测试和深度测试
          	for (let j = drapedLayerBatch.start; j <= drapedLayerBatch.end; ++j) {
                const layer = painter.style._layers[layerIds[j]];
                ...
                // 判断是否是隐藏图层
    
                ...
                // 判断当前瓦片是否加载
    
                ...
                // 模版测试配置
                // 执行渲染,这里各个图层的绘制层也有相关配合逻辑
                painter.renderLayer(painter, sourceCache, layer, coords);
            }
    
          	// 加入到待渲染队列
            if (this.renderedToTile) {
                fbo.dirty = true;
                accumulatedDrapes.push(tile.tileID);
            } else if (!useRenderCache) {
              	// 如果使用的不是缓存,FBO 的队列需要减 1
                --poolIndex;
                assert(poolIndex >= 0);
            }
          	// 达到 fbo 渲染池大小,消费渲染队列
            if (poolIndex === FBO_POOL_SIZE) {
                poolIndex = 0;
                this.renderToBackBuffer(accumulatedDrapes);
            }
        }
    
        // 重置状态,消费所有队列
        this.renderToBackBuffer(accumulatedDrapes);
        this.renderingToTexture = false;
    
        context.bindFramebuffer.set(null);
        context.viewport.set([0, 0, painter.width, painter.height]);
    
        return drapedLayerBatch.end + 1;
    }
    
  3. renderToBackBuffer

    真正渲染在 renderToBackBuffer 会进入 draw_terrain_raster中的 drawTerrainRaster在这里实现是渲染到球还是进行地形挤压的渲染。

    image-20231216012228285

    renderToBackBuffer(accumulatedDrapes: Array<OverscaledTileID>) {
      const painter = this.painter;
      const context = this.painter.context;
    
      if (accumulatedDrapes.length === 0) {
          return;
      }
      // 解绑 fbo,渲染到屏幕空间
      context.bindFramebuffer.set(null);
      context.viewport.set([0, 0, painter.width, painter.height]);
      painter.gpuTimingDeferredRenderStart();
    
      this.renderingToTexture = false;
      // 执行绘制
      drawTerrainRaster(painter, this, this.proxySourceCache, accumulatedDrapes, this._updateTimestamp);
      // 重置状态
      this.renderingToTexture = true;
    
      painter.gpuTimingDeferredRenderEnd();
      // 消费完变更渲染队列
      accumulatedDrapes.splice(0, accumulatedDrapes.length);
    }
    
  4. drawTerrainRaster

    image-20231216014208749

    function drawTerrainRaster(painter: Painter, terrain: Terrain, sourceCache: SourceCache, tileIDs: Array<OverscaledTileID>, now: number) {
      // globe 渲染模式
      if (painter.transform.projection.name === 'globe') {
          drawTerrainForGlobe(painter, terrain, sourceCache, tileIDs, now);
      } else {
        // 地形渲染
        const context = painter.context;
        const gl = context.gl;
    
        ...
        // 切换宏定义,并获取新的 program
        const setShaderMode = (mode: number, isWireframe: boolean) => {
            
        };
    
        ...
          
        // 处理顶点形变
        vertexMorphing.update(now);
        const skirt = skirtHeight(tr.zoom) * terrain.exaggeration();
    
        // 处理带Wireframe渲染和不带Wireframe渲染
        const batches = showWireframe ? [false, true] : [false];
    
        // 地形绘制
        batches.forEach(isWireframe => {
            programMode = -1;
    
            const primitive = isWireframe ? gl.LINES : gl.TRIANGLES;
            const [buffer, segments] = isWireframe ? terrain.getWirefameBuffer() : [terrain.gridIndexBuffer, terrain.gridSegments];
    
            for (const coord of tileIDs) {
                
            }
        });
      }
    }
    

    这里是得到的 dem 地形数据 514 * 514 和代理瓦片 1024 * 1024,在着色器中反算地形格网的顶点高度(地形格网计算相关的代码在createGrid函数),然后进行纹理贴图

    image-20231216015355390

    image-20231216014906374

    image-20231216014932214

    最终渲染结果如下:

    image-20231216014646112

    以上只是一个大致的流程,其中还包括特殊未走 RTT 渲染流程的图层本文并未提到,因为那些图层渲染相对直观(但是并没有那么简单),点类型是直接计算顶点坐标时直接采用的 ecef 坐标渲染,其他的还包括地形裙边处理、深度测试、模板测试、缓存处理、渲染队列处理相关的代码量相当大,细节也特别多,限于本人能力有限无法一一说明。