mapbox 地形渲染流程

简介

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 中使用动态图标

js 复制代码
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
flow 复制代码
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

  1. 首先 绑定 tile,计算当前图层的瓦片应该渲染到哪个代理瓦片 ProxyTile
js 复制代码
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 渲染中多次使用
js 复制代码
if (this.transform.projection.name === 'globe' && !this.globeSharedBuffers) {
   this.globeSharedBuffers = new GlobeSharedBuffers(this.context);
}
  1. 检测 Token 是否合法

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

js 复制代码
// 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,在后续阶段渲染到地图。

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

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

  1. 解绑 offscreen framebuffer,清空颜色缓冲和深度缓冲以及模版缓冲
  1. 执行不透明(opaque) renderPass 链接

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

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

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

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

Terrain

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

  1. update

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

js 复制代码
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 的大小:

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

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

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

    js 复制代码
    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在这里实现是渲染到球还是进行地形挤压的渲染。

    js 复制代码
    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

    js 复制代码
    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函数),然后进行纹理贴图

    最终渲染结果如下:

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

相关推荐
fishmemory7sec1 分钟前
Electron 使⽤ electron-builder 打包应用
前端·javascript·electron
豆豆1 小时前
为什么用PageAdmin CMS建设网站?
服务器·开发语言·前端·php·软件构建
twins35202 小时前
解决Vue应用中遇到路由刷新后出现 404 错误
前端·javascript·vue.js
qiyi.sky2 小时前
JavaWeb——Vue组件库Element(3/6):常见组件:Dialog对话框、Form表单(介绍、使用、实际效果)
前端·javascript·vue.js
煸橙干儿~~2 小时前
分析JS Crash(进程崩溃)
java·前端·javascript
安冬的码畜日常2 小时前
【D3.js in Action 3 精译_027】3.4 让 D3 数据适应屏幕(下)—— D3 分段比例尺的用法
前端·javascript·信息可视化·数据可视化·d3.js·d3比例尺·分段比例尺
l1x1n03 小时前
No.3 笔记 | Web安全基础:Web1.0 - 3.0 发展史
前端·http·html
昨天;明天。今天。3 小时前
案例-任务清单
前端·javascript·css
zqx_74 小时前
随记 前端框架React的初步认识
前端·react.js·前端框架
惜.己4 小时前
javaScript基础(8个案例+代码+效果图)
开发语言·前端·javascript·vscode·css3·html5