简介
mapbox 中的地形渲染用到了一个概念 RTT(render to texture),核心就是将常规的线、面等图形渲染到对应的 FBO 上,然后使用 FBO 进行贴图。 但是注意,这里并不是所有图层都使用了 RTT 进行渲染,比如 circle
、heatmap
、symbol
都是直接计算的 ecef(Earth-Centered, Earth-Fixed,地心坐标系,也是是笛卡尔坐标系的一种) 顶点,在 globe 投影时使用球面顶点坐标渲染。具体可以查看 bucket 逻辑。
核心流程
- Painter 流程
- Terrain 流程
源码
实际上自 2.0 之后 mapbox-gl 核心的变更非常大,地形的渲染涉及的地方也非常多,比如 style-spec 的变更、顶点数据处理的变更 bucket、着色器的变更(加入了 fog、light)、渲染器 Painter 的变更,以及新增的 Terrain 逻辑等等。但是核心的渲染流程我们应该从 Painter 和 Terrain 聊起。
Painter
地形的实例创建时在 painter 的 updateTerrain 方法中,通过 map 实例中的 _updateTerrain 方法调用。渲染层在 painter 的 render 方法中。
按照顺序,Painter.render主要做了以下操作:
- image 资源管理,字体资源管理
主要是从 style
上获取 imageManager
和 glyphManager
,然后执行 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();
- 准备数据对应的
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();
}
- 在所有图层中查找不透明 Pass 的分界标识
这里我们所使用的 layerIds
是已经排序后的,具体可以参见 style.order
- 首先 绑定 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;
}
- 如果投影为
globe
模式,那么需要创建GlobeSharedBuffers
(网格化的顶点坐标),这个 Buffer 会在后面的globe
渲染中多次使用
js
if (this.transform.projection.name === 'globe' && !this.globeSharedBuffers) {
this.globeSharedBuffers = new GlobeSharedBuffers(this.context);
}
- 检测 Token 是否合法
如果不合法,直接中断渲染。
js
// Following line is billing related code. Do not change. See LICENSE.txt
if (!isMapAuthenticated(this.context.gl)) return;
在这个渲染阶段,我们将所有需要 OffscreenPass
渲染的内容渲染到单独的帧缓冲,以便后续在其他 Pass
中使用。比较典型的应用是 heatmap
图层,在 Offscreen
阶段渲染亮度图fbo,在后续阶段渲染到地图。
- 如果有地形并且有点 symbol 或者 circle 类型的图层,那么需要写入深度
主要用于文本标签的遮挡测试。
- 解绑
offscreen framebuffer
,清空颜色缓冲和深度缓冲以及模版缓冲
- 执行不透明(opaque)
renderPass
链接
不透明渲染按照从上到下的顺序渲染,并且在无地形时才执行,在有地形时是进入其他 Pass 合成(见后续)
- 绘制大气 - Fog 和天空盒渲染 - Sky pass
此渲染过程是从下向上渲染
- 绘制瓦片边界、显示
QueryGeometry
、显示瓦片轴对齐包围盒、显示地图 padding 和 Crosshair等
Terrain
核心绘制层讲完就到了本文的核心-地形渲染,但是此处需要注意,虽然谈的是地形渲染流程,但是我们更需要了解其核心思想 RTT 流程。 核心部分都存于Terrain 这个文件,代码量有点大我们只需要关注核心方法,按照渲染流程调用的方法如下:
图
此方法主要做了以下事情:
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
。
-
此方法主要由Painter.render调用,主要目的是更新
Source
中瓦片的代理瓦片。说到这里就必须先了解一下 mapbox-gl 中针对渲染层所做的优化,这也和上面所提到的 RTT 流程相关,那么优化层并不仅仅有 RTT,我们知道 mapbox 中大部分的瓦片源的 size 通常为 512,那么 RTT 所使用的 FBO 的大小也是 512 吗,答案并不是。这个地方的计算我们可以通过 getScaledDemTileSize 得到,通常为tileSize / GRID_DIM * 512 = 2 * tileSize
的大小:即 1024 的大小,这么实现的目的猜测也是为了减少瓦片地形贴图和球面贴图的 drawcall,尽可能的合并渲染。这也是updateTileBinding的意义-为源瓦片查找其所在的代理瓦片,并将源瓦片绘制在代理瓦片中,当然还需要考虑瓦片的平移、旋转、缩放。
当然这里除了以上提到的优化手段外,还有其他的优化手段:比如代理瓦片如何更新、缓存如何失效,感兴趣的可以自己去探究源码。
jsupdateTileBinding(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 瓦片,并计算其可见性 ... }
-
mapbox在实现批渲染时采用了先绘制非挤压图层到代理瓦片,将代理瓦片绘制到屏幕空间(但是这里有个渲染池,未超过渲染池时还不会直接渲染到地形),再进行下一个代理瓦片的绘制的策略。
jsrenderBatch(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; }
-
真正渲染在
renderToBackBuffer
会进入 draw_terrain_raster中的 drawTerrainRaster在这里实现是渲染到球还是进行地形挤压的渲染。jsrenderToBackBuffer(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); }
-
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 坐标渲染,其他的还包括地形裙边处理、深度测试、模板测试、缓存处理、渲染队列处理相关的代码量相当大,细节也特别多,限于本人能力有限无法一一说明。