MapboxGL简析(一):渲染
简单分析下mapbox在渲染过程中都做了些什么,包括
- map对象: html元素、事件与渲染器
- 渲染原理相关: 渲染器、渲染流程、渲染对象及渲染区域
本文所参考的mapbox-gl版本为1.11.0
系列文章
- MapboxGL简析(一):渲染
- MapboxGL简析(二):变换
创建一个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对象在初始化时主要做了下面几件事:
- 容器绑定与元素创建
- 事件处理器绑定
- 渲染器生成
容器绑定与元素创建
_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}: 控制器
事件处理器绑定
主要有三类事件:
- 元素事件: online(window), resize(window), webglcontextlost(canvas), webglcontextrestored(canvas)
- 交互事件: move, moveend, zoom
- 资源加载事件: 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的渲染原理:
- 渲染器
- 渲染流程
- 渲染对象
- 渲染区域
渲染器
渲染器相关的文件在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在渲染过程中对于图层的渲染处理做了如下分类:
- offscreen pass: 离屏渲染层,在FBO中绘制
- opaque pass: 不透明层,从上至下绘制
- translucent pass: 透明层,从下至上绘制
有一点需要说明的是,这里mapbox并不是直接遍历指定类型的层(opaque或translucent)来执行渲染的,而是根据执行到图层绘制方法中再进行判断:
- 先设定当前的渲染通道类型(rednerPass)
this.renderPass = 'translucent';
- 再遍历所有层,在执行层的绘制方法时根据传入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属性,表示图层中包含的元素种类,主要下面几种:
- symbol: 图标或文本标签
- circle: 填充圆形
- heatmap: 热力图
- line: 描边
- fill: 可选描边的填充多边形
- raster: 光栅化的地图贴图,如卫星地图
至于这些元素具体如何渲染取决于这两个属性: layout和paint。官方对于它们的说明:
- 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计算目标渲染区域,即寻找目标瓦片的过程。
参考
- 原文作者:yrq110
- 原文链接:http://yrq110.me/post/front-end/mapboxgl-i-rendering/
- 版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。