简单分析下mapbox在渲染过程中都做了些什么,包括

  1. map对象: html元素、事件与渲染器
  2. 渲染原理相关: 渲染器、渲染流程、渲染对象及渲染区域

本文所参考的mapbox-gl版本为1.11.0

系列文章

创建一个Map时Map在做什么

使用如下方式创建一个地图:

import mapboxgl from 'mapbox-gl';
mapboxgl.accessToken = 'TOKEN';
const map = new mapboxgl.Map({
  container: 'container',
  style: 'mapbox://styles/mapbox/streets-v9',
  center: [-74.50, 40],
  zoom: 9
});

在生成地图的过程中,Map对象在初始化时主要做了下面几件事:

  1. 容器绑定与元素创建
  2. 事件处理器绑定
  3. 渲染器生成

容器绑定与元素创建

_setupContainer() {
    // 根容器元素
    const container = this._container;
    container.classList.add('mapboxgl-map');
    // 创建一个隐藏元素用来检测是否正常加载css文件
    const missingCSSCanary = this._missingCSSCanary = DOM.create('div', 'mapboxgl-canary', container);
    missingCSSCanary.style.visibility = 'hidden';
    this._detectMissingCSS();
    // 创建画布容器及画布元素
    const canvasContainer = this._canvasContainer = DOM.create('div', 'mapboxgl-canvas-container', container);
    if (this._interactive) { canvasContainer.classList.add('mapboxgl-interactive'); }
    this._canvas = DOM.create('canvas', 'mapboxgl-canvas', canvasContainer);
    // 获取尺寸信息并调整
    const dimensions = this._containerDimensions();
    this._resizeCanvas(dimensions[0], dimensions[1]);
    // 创建控制器容器
    const controlContainer = this._controlContainer = DOM.create('div', 'mapboxgl-control-container', container);
    const positions = this._controlPositions = {};
    ['top-left', 'top-right', 'bottom-left', 'bottom-right'].forEach((positionName) => {
        positions[positionName] = DOM.create('div', `mapboxgl-ctrl-${positionName}`, controlContainer);
    });
}

最终会生成这样一个html元素结构:

  • div.mapboxgl-canary: CSS检测元素
  • div.mapboxgl-canvas-container | mapboxgl-interactive: 画布容器
    • canvas.mapboxgl-canvas: 渲染画布
  • div.mapboxgl-control-container: 控制器容器
    • div.mapboxgl-ctrl-${position}: 控制器

事件处理器绑定

主要有三类事件:

  1. 元素事件: online(window), resize(window), webglcontextlost(canvas), webglcontextrestored(canvas)
  2. 交互事件: move, moveend, zoom
  3. 资源加载事件: style.load, data, dataloading

_update()方法

当有需要改变地图内容的事件发生时,均会调用_update()方法:

_update(updateStyle?: boolean) {
  if (!this.style) return this;
  // 更新样式与资源
  this._styleDirty = this._styleDirty || updateStyle;
  this._sourcesDirty = true;
  // 重绘,执行_render()
  this.triggerRepaint();
  return this;
}

map对象中渲染方法的调用流程: _update() => triggerRepaint() => _render() => this.painter.render()

元素事件

  • window
    • online: 当窗口与网络准备好时,会调用_update()执行首次渲染
    • resize: 窗口尺寸改变时,对应更新canvas, transform与painter的尺寸
  • canvas
    • webglcontextlost: 丢失上下文时停止当前执行的raf动画
    • webglcontextrestored: 重获上下文时会重新配置渲染器,设置尺寸与执行_update()

交互事件

监听到交互事件时,均会执行_update()方法,更新样式与资源并调用triggerRepaint()进行重绘

资源加载事件

  • data: 执行_update渲染,触发事件
  • dataloading: 可自定义配置style或source加载完成后的行为
  • style.load: 可自定义配置style加载完成后的行为,如添加新的source或layer

渲染器配置

根据canvas画布的webgl context来创建painter渲染器,并封装render方法,绘制style对象。

// src/ui/map
_setupPainter() {
  const gl = this._canvas.getContext('webgl', attributes) ||
      this._canvas.getContext('experimental-webgl', attributes);
  this.painter = new Painter(gl, this.transform);
}
_render(paintStartTimeStamp: number) {
  ...
  this.painter.render(this.style, {/*options*/});
}

渲染原理

分别从以下几个方面简要分析下mapbox的渲染原理:

  1. 渲染器
  2. 渲染流程
  3. 渲染对象
  4. 渲染区域

渲染器

渲染器相关的文件在src/render路径下,其中painter为负责渲染的主要对象。

painter负责如何绘制图层及上面的各类元素,其内容包含:

  • program与shader处理
  • WebGL各种功能封装(模板测试、深度测试等)
  • 提供针对图层上每种元素的drawFunction
  • 解析style对象得到的manager

渲染流程

看下painter的render方法,主要为执行分层绘制:

render(style: Style, options: PainterOptions) {
    /* 1. 对于支持离屏渲染/预渲染(hasOffscreenPass)的图层在FBO中进行绘制 */
    this.renderPass = 'offscreen';
    for (const layerId of layerIds) {
        const layer = this.style._layers[layerId];
        if (!layer.hasOffscreenPass() || layer.isHidden(this.transform.zoom)) continue;
        const coords = coordsDescending[layer.source];
        if (layer.type !== 'custom' && !coords.length) continue;
        this.renderLayer(this, sourceCaches[layer.source], layer, coords);
    }
    // 所有离屏渲染层完成后解绑framebuffer
    this.context.bindFramebuffer.set(null);
    // 清空缓冲区,为绘制main framebuffer做准备
    this.context.clear({color: options.showOverdrawInspector ? Color.black : Color.transparent, depth: 1});
    /* 2. 绘制不透明图层 */
    this.renderPass = 'opaque';
    // 由上至下依次绘制蒙版与图层
    for (this.currentLayer = layerIds.length - 1; this.currentLayer >= 0; this.currentLayer--) {
        this._renderTileClippingMasks(layer, coords);
        this.renderLayer(this, sourceCache, layer, coords);
    }
    /* 3. 绘制半透明图层 */
    this.renderPass = 'translucent';
    // 由下至上依次绘制蒙版与图层
    for (this.currentLayer = 0; this.currentLayer < layerIds.length; this.currentLayer++) {
        // 为symbol元素层添加了特殊的瓦片数据,没有使用瓦片裁剪(tiles clipping),因此也无需单独执行蒙版裁剪
        const coords = (layer.type === 'symbol' ? coordsDescendingSymbol : coordsDescending)[layer.source];
        this._renderTileClippingMasks(layer, coordsAscending[layer.source]);
        this.renderLayer(this, sourceCache, layer, coords);
    }
}

可以看出,painter在渲染过程中对于图层的渲染处理做了如下分类:

  1. offscreen pass: 离屏渲染层,在FBO中绘制
  2. opaque pass: 不透明层,从上至下绘制
  3. translucent pass: 透明层,从下至上绘制

有一点需要说明的是,这里mapbox并不是直接遍历指定类型的层(opaque或translucent)来执行渲染的,而是根据执行到图层绘制方法中再进行判断:

  1. 先设定当前的渲染通道类型(rednerPass)
this.renderPass = 'translucent';
  1. 再遍历所有层,在执行层的绘制方法时根据传入painter的渲染通道类型来决定是否渲染:
// src/render/painter.js
for (this.currentLayer = 0; this.currentLayer < layerIds.length; this.currentLayer++) {
    // 若为line元素图层则会在renderLayer中执行drawLine绘制元素
    this.renderLayer(this, sourceCache, layer, coords);
}
// src/render/draw_line.js
export default function drawLine(painter: Painter, sourceCache: SourceCache, layer: LineStyleLayer, coords: Array<OverscaledTileID>) {
    if (painter.renderPass !== 'translucent') return;
    ...
}

基本上所有内置的图层都属于translucent渲染类型,即半透明图层,从下至上绘制。而自定义图层就不一定了,根据想要的效果自己定义渲染通道的类型,并且注意执行渲染的顺序: offscreen -> opaque -> translucent。

了解了渲染器对象与渲染图层的流程之后,接下来看看具体渲染的对象是什么样的。

渲染对象

即具体渲染的图层及元素。图层与元素对象的相关文件在src/style路径下。

Style对象

地图的样式与图形信息主要保存在map的style属性中,它的主要属性:

  • map: 对应的map对象
  • stylesheet: Style的主要数据对象,符合StyleSpecification类型结构
  • imageManager/glyphManager: 图片/字体元素管理器

StyleSpecification主要属性:

  • 图层数据: layers
  • 相机属性: center, zoom, bearing, pitch

主要绘制内容为StyleLayer(style._layers),也可以额外添加glyph(特殊字体)、image(图片)等类型的图形元素。

StyleLayer

每个StyleLayer都会有一个type属性,表示图层中包含的元素种类,主要下面几种:

  1. symbol: 图标或文本标签
  2. circle: 填充圆形
  3. heatmap: 热力图
  4. line: 描边
  5. fill: 可选描边的填充多边形
  6. raster: 光栅化的地图贴图,如卫星地图

至于这些元素具体如何渲染取决于这两个属性: layoutpaint。官方对于它们的说明:

  • layout: 定义渲染器如何绘制并应用一个层的数据,应用在渲染流程的早期,可通过异步的layout步骤进行修改
  • paint: 定义该层的样式数据,应用在渲染流程的后期,修改paint属性的成本较小且为同步操作

看完这个描述不太清楚它们在渲染过程中的具体区别,遂看了下它内部的处理。

layout && paint

简单的说就是:

  • layout会在drawCall执行前,在Bucket对象构建时被提取出来用于顶点或索引数组(layoutVertexArray)的计算
  • paint会在实际执行drawCall时被读取作为新的uniform属性
layout属性处理

layout属性会在绘制前构建Bucket对象时,被用来计算绘制所需的顶点与索引数组数据。

Bucket对象是可以看做是一个将矢量瓦片等数据转换成WebGL渲染所需Buffer数据的对象。每个Bucket对象中都会保存该类型图层所需的顶点与索引数组数据。

以LineBucket中处理layout数据的例子:

// src/data/bucket/line_bucket
addFeature(feature: BucketFeature, geometry: Array<Array<Point>>, index: number, canonical: CanonicalTileID, imagePositions: {[_: string]: ImagePosition}) {
    const layout = this.layers[0].layout;
    // 解析layout中的属性
    const join = layout.get('line-join').evaluate(feature, {});
    const cap = layout.get('line-cap');
    const miterLimit = layout.get('line-miter-limit');
    const roundLimit = layout.get('line-round-limit');
    // 更新layoutVertexArray
    for (const line of geometry) {
        // 根据layout样式属性计算顶点数据,更新顶点数组
        this.addLine(line, feature, join, cap, miterLimit, roundLimit);
    }
    // 更新paintVertexArray
    this.programConfigurations.populatePaintArrays(this.layoutVertexArray.length, feature, index, imagePositions, canonical);
}

准备好的Bucket对象会在图层绘制时被用到(layoutVertexArray&paintVertexArray)。

paint属性处理

paint中的数据会在drawCall执行时被用来更新当前的uniform属性,以line元素为例:

// src/render/draw_line
export default function drawLine(painter: Painter, sourceCache: SourceCache, layer: LineStyleLayer, coords: Array<OverscaledTileID>) {
    for (const coord of coords) {
        const bucket: ?LineBucket = (tile.getBucket(layer): any);
        // 提取该图层的bucket配置
        const programConfiguration = bucket.programConfigurations.get(layer.id);
        // 利用各种上下文属性执行绘制,其中bucket.layoutVertexBuffer与layer.paint参数即为layout与paint的体现
        program.draw(context, gl.TRIANGLES, depthMode,
            painter.stencilModeForClipping(coord), colorMode, CullFaceMode.disabled, uniformValues,
            layer.id, bucket.layoutVertexBuffer, bucket.indexBuffer, bucket.segments,
            layer.paint, painter.transform.zoom, programConfiguration);
    }
}
// src/render/program
draw(...,
  currentProperties: any, // 即layer.paint
  ...
  configuration: ?ProgramConfiguration) { // 即programConfiguration
    ...
    if (configuration) {
        // 利用传入的paint属性更新uniform属性
        configuration.setUniforms(context, this.binderUniforms, currentProperties, {zoom: (zoom: any)});
    }
  }
修改layout与paint

根据官方的描述,修改paint是同步操作,修改layout是异步操作,其原理长话短说就是:

  • layout属性改变后会利用worker线程执行图层数据的更新操作,即异步操作
  • 多数paint属性改变后无需修改图层数据,仅影响之后的渲染操作,即同步操作。但部分paint属性改变(cross-faded)会导致layout的改变,因此也会走worker线程更新layer这一步

如果感兴趣可以看看下面的详细分析:

首先,调用map上的setLayoutProperty与setPaintProperty来修改layout与paint属性,实际上会执行其style属性的同名方法:

// src/style/style
setLayoutProperty(layerId: string, name: string, value: any,  options: StyleSetterOptions = {}) {
    const layer = this.getLayer(layerId);
    if (deepEqual(layer.getLayoutProperty(name), value)) return;
    layer.setLayoutProperty(name, value, options);
    this._updateLayer(layer);
}

setPaintProperty(layerId: string, name: string, value: any, options: StyleSetterOptions = {}) {
    const layer = this.getLayer(layerId);
    if (deepEqual(layer.getPaintProperty(name), value)) return;
    const requiresRelayout = layer.setPaintProperty(name, value, options);
    if (requiresRelayout) {
        this._updateLayer(layer);
    }
    this._changed = true;
    this._updatedPaintProps[layerId] = true;
}

在这里style会更新对应layer上的layout与paint属性值,并且设置改动标记,在下一次渲染的update中执行更新。

其中在layout改变时一定会执行_updateLayer方法,而当paint改变时若为需要重新布局的情况(cross-faded值等),也会执行_updateLayer。

_updateLayer中主要做了: 保存需要的更新layerId、更新source、设置标记。

// src/style/style
_updateLayer(layer: StyleLayer) {
    // 保存需要更新的图层id,在下一次update时处理
    this._updatedLayers[layer.id] = true;
    if (layer.source && !this._updatedSources[layer.source] &&
        //Skip for raster layers (https://github.com/mapbox/mapbox-gl-js/issues/7865)
        this.sourceCaches[layer.source].getSource().type !== 'raster') {
        this._updatedSources[layer.source] = 'reload';
        this.sourceCaches[layer.source].pause();
    }
    this._changed = true;
}

现在回想一下最开始map的_render中方法,其实在执行painter的渲染方前会先根据缩放等属性更新当前style的属性与资源:

// src/ui/map
_render(paintStartTimeStamp: number) {
    if (this.style && this._styleDirty) {
        ...
        this.style.update(parameters);
    }
    if (this.style && this._sourcesDirty) {
        this._sourcesDirty = false;
        this.style._updateSources(this.transform);
    }
    ...
    this.painter.render()
}

在style的update中会更新图层数据并根据传入的缩放系数重新计算paint属性。

// src/style/style
update(parameters: EvaluationParameters) {
    if (this._changed) {
        const updatedIds = Object.keys(this._updatedLayers);
        const removedIds = Object.keys(this._removedLayers);
        
        if (updatedIds.length || removedIds.length) {
            this._updateWorkerLayers(updatedIds, removedIds);
        } 
    }
}

在update中会检查_updatedLayers是否有值,这个值就是在_updateLayer中设置的。

若存在需要更新的layerId,则会执行_updateWorkerLayers方法,该方法中会向style的worker线程池中广播updateLayers事件,传入需要更新和删除的layer对象进行图层数据的更新。

即利用worker多线程异步更新所有需要更新和删除的layer,关于更新的具体做法是根据传入的新的layer数据重新生成图层对象直接根据id替换老数据。

// src/source/worker
updateLayers(mapId: string, params: {layers: Array<LayerSpecification>, removedIds: Array<string>}, callback: WorkerTileCallback) {
    this.getLayerIndex(mapId).update(params.layers, params.removedIds);
    callback();
}
// src/style/style_layer_index
update(layerConfigs: Array<LayerSpecification>, removedIds: Array<string>) {
    // layerConfigs为序列化后需要更新的layer数据
    for (const layerConfig of layerConfigs) {
        this._layerConfigs[layerConfig.id] = layerConfig;
        const layer = this._layers[layerConfig.id] = createStyleLayer(layerConfig);
        layer._featureFilter = featureFilter(layer.filter);
        if (this.keyCache[layerConfig.id])
            delete this.keyCache[layerConfig.id];
    }
    ...
}

渲染区域

渲染区域指的是在屏幕中的目标渲染区域,主要是通过一个 coveringTiles() 函数来计算当前屏幕空间中需要渲染的瓦片(索引)。

由于在3D空间中,相机在空间的任意位置指向地图形成的目标平面区域(即相机视锥体与地图平面相交的区域)可能是一个不规则多边形,通过计算得到这个多边形后,还需要进一步计算得出与其相交的瓦片,即为最终需要渲染的瓦片。

mapbox所采用的方法是计算根瓦片的轴向包围盒与相机视锥体的相交情况,并结合四叉树空间索引来计算相交的瓦片ID及与中心点距离等数据

// src/geo/transform
coveringTiles(): Array<OverscaledTileID> {
    // 计算缩放级别对应的瓦片数量、3D空间中心点坐标及相机视锥体等数据
    const centerCoord = MercatorCoordinate.fromLngLat(this.center);
    const numTiles = Math.pow(2, z);
    const centerPoint = [numTiles * centerCoord.x, numTiles * centerCoord.y, 0];
    const cameraFrustum = Frustum.fromInvProjectionMatrix(this.invProjMatrix, this.worldSize, z);
    // 初始化根瓦片的方法
    const newRootTile = (wrap: number): any => {
        return {
            aabb: new Aabb([wrap * numTiles, 0, 0], [(wrap + 1) * numTiles, numTiles, 0]),
            zoom: 0,
            x: 0,
            y: 0,
            wrap,
            fullyVisible: false
        };
    };
    const stack = [];
    const result = [];
    // 添加初始的根瓦片
    if (this._renderWorldCopies) {
        for (let i = 1; i <= 3; i++) {
            stack.push(newRootTile(-i));
            stack.push(newRootTile(i));
        }
    }
    stack.push(newRootTile(0));
    // 主流程,寻找与包围盒与视椎体相交的瓦片
    while (stack.length > 0) {
        const it = stack.pop();
        // 判断相机视椎体与轴向包围盒的相交情况,若不相交则跳过
        if (!fullyVisible) {
            const intersectResult = it.aabb.intersects(cameraFrustum);
            if (intersectResult === 0)
                continue;
            fullyVisible = intersectResult === 2;
        }
        // 计算中心点与轴向包围盒距离
        const distanceX = it.aabb.distanceX(centerPoint);
        const distanceY = it.aabb.distanceY(centerPoint);
        const longestDim = Math.max(Math.abs(distanceX), Math.abs(distanceY));
        const distToSplit = radiusOfMaxLvlLodInTiles + (1 << (maxZoom - it.zoom)) - 2;
        if (it.zoom === maxZoom || (longestDim > distToSplit && it.zoom >= minZoom)) {
            result.push({
                tileID: new OverscaledTileID(it.zoom === maxZoom ? overscaledZ : it.zoom, it.wrap, it.zoom, x, y),
                distanceSq: vec2.sqrLen([centerPoint[0] - 0.5 - x, centerPoint[1] - 0.5 - y])
            });
            continue;
        }
        // 将包围盒切分更细粒度的四叉树空间,即下一层缩放级别
        for (let i = 0; i < 4; i++) {
            const childX = (x << 1) + (i % 2);
            const childY = (y << 1) + (i >> 1);
            stack.push({aabb: it.aabb.quadrant(i), zoom: it.zoom + 1, x: childX, y: childY, wrap: it.wrap, fullyVisible});
        }
    }
    // 将瓦片距中心点由远及近进行排序
    return result.sort((a, b) => a.distanceSq - b.distanceSq).map(a => a.tileID);
}

这大概就是mapbox计算目标渲染区域,即寻找目标瓦片的过程。

参考