Mapbox GL JS简析

MapboxGLJS 简介

Mapbox 公司成立于 2010 年,创立目标是为 Google Map 提供一个替代方案。在当时,Google Map 地图几乎垄断了所有线上地图业务,但是在 Google Map 中,几乎没有定制化的可能,也没有任何工具可以让制图者按照他们的设想来创建地图。Mapbox 的成立旨在改变这种状况,为制图人员和开发人员提供工具来创建他们想要的地图。值得一提的是,目前 Mapbox 提供的制图工具几乎都是开源的。Mapbox 目前主要提供地理数据、渲染客户端和其他与地图相关的服务。Mapbox GL JS 是他们的一个开源客户端库,用于渲染 Web 端的可交互地图。作为 Mapbox 生态系统的一部分,它通常与 Mapbox 提供的其他服务集成在一起,统一对外使用。目前 Mapbox 公司的主营业务除了地图相关产品,还包括 LBS(Location Based Services)服务、自动驾驶、自有数据(Boundaries, Traffic Data, Movement)以及车机服务。Mapbox GL JS 是一个 JavaScript 库,它使用 WebGL 技术,以vector tiles方式数据组织,以Mapbox styles来配置地图样式规则,最终渲染得到交互式地图。Mapbox GL 生态系统的另一部分是Mapbox Mobile,它是一个用 C++ 编写的兼容桌面和移动平台的渲染引擎。

Mapbox GL JS 作为地图引擎的优势

相比于 Leaflet, OpenStreetMap 等 2D 栅格图地图引擎

  • Mapbox GL JS 使用矢量数据渲染地图(可以使用栅格数据),矢量数据易于更改要素样式,图层顺序,也可以在数据版本迭代时快速同步更新。

  • 矢量数据相比于栅格图片,具有更多种数据压缩的可能性;因此在相同场景中,矢量数据的数据量要比栅格数据要小(矢量数据需要组织得当)。

  • Mapbox GL JS 采用 WebGL 方案,相比于 Canvas 和 SVG 方案,可以支持更多的三维地图效果。

相比于 Cesium 等三维地图引擎

  • Mapbox GL JS 的渲染场景实际为 2.5D,并不是完全的 3D 场景(控制可视角度),这使得地图可视范围的可控,数据可以使用二维金字塔模型组织,提升了场景内数据筛选性能,也控制了同屏展示的数据量。
  • Mapbox GL JS 的体量相对较小,模块结构较为清晰,易于进行改造及二次开发。

相比于 Google Map, AB Map(Mapbox GL JS versions 1.x)

  • Mapbox GL JS 为开源软件,社区生态丰富,同样易于改造及二次开发。
  • Mapbox GL JS 的开源协议为 BSD-3-Clause license,可以合法进行商业化使用。
  • Mapbox GL 体系提供的地图方案,支持地图数据服务内网部署,可以做到内外网隔离。
  • 2.x版本已更换协议参考新闻

Mapbox GL JS v2 has a completely different, proprietary license.It is not free and not truly open anymore.

如何创建一张地图

1. 准备工作

  • 申请 access_tokenaccess_token 是 Mapbox 的一种鉴权手段,通过 access token 可以将 API requests 和用户账号关联起来;在开发中,调用地图数据,使用相应的 API 或 SDK 都需要开发者的 access_token 有相应的权限。

  • 准备 Mapbox Style url 或 Mapbox Style 对象 Mapbox Style 是定义地图的视觉外观的文档:绘制什么数据、以什么顺序绘制以及绘制数据时如何设置数据样式;Mapbox Style 对象是具有特定根级别和嵌套属性的 JSON 对象,内容包括有关数据源、图层样式、雪碧图、文字字体、元数据等的信息;

Style 通常有三种来源:

  1. 使用 Mapbox 官网提供的标准样式

  1. 使用 Mapbox 提供的Mapbox Studio工作台,配置自定义地图样式

  1.  手动编写样式JSON对象
    复制代码
{
    "version": 8,
    "name": "Void",
    "metadata": {},
    "sources": {},
    "sprite": "mapbox://sprites/mapbox/basic-v9",
    "glyphs": "mapbox://fonts/{fontstack}/{range}.pbf",
    "layers": []
}
复制代码

2. 创建地图

  • 使用 Mapbox CDN

在 HTML 文件的中引入 Mapbox GL JS 提供的 js 和 css 文件:

<script src='https://api.mapbox.com/mapbox-gl-js/v2.2.0/mapbox-gl.js'></script>
<link href='https://api.mapbox.com/mapbox-gl-js/v2.2.0/mapbox-gl.css' rel='stylesheet' />
复制代码

在 HTML 文件的中加入地图容器 DOM 和相应 script 代码:

<div id='map' style='width: 400px; height: 300px;'></div>
<script>
mapboxgl.accessToken = '<your access token here>';
var map = new mapboxgl.Map({
    container: 'map', // 容器DOM id
    style: 'mapbox://styles/mapbox/streets-v11', // style URL
    center: [-74.5, 40], // 地图初始中心点 [lng, lat]
    zoom: 9 // 地图初始zoom
});
</script>
复制代码
  • 使用包引入

安装 npm 包

npm install --save mapbox-gl
复制代码

可以在 HTML 文件的中引入 Mapbox GL JS 提供的 js 和 css 文件

<link href='https://api.mapbox.com/mapbox-gl-js/v2.2.0/mapbox-gl.css' rel='stylesheet' />
复制代码

也可以使用 CSS loader 在 js 文件中直接引入

import 'mapbox-gl/dist/mapbox-gl.css';
复制代码

在项目中创建地图

import mapboxgl from 'mapbox-gl'; // or "const mapboxgl = require('mapbox-gl');"

mapboxgl.accessToken = '<your access token here>';
const map = new mapboxgl.Map({
    container: 'map', // 容器DOM id
    style: 'mapbox://styles/mapbox/streets-v11', // style URL
    center: [-74.5, 40], // 地图初始中心点 [lng, lat]
    zoom: 9 // 地图初始zoom
});
复制代码

Mapbox GL JS 架构

数据组织

Mapbox GL JS 采用 Web 墨卡托投影,这使得世界在地图中是一个正方形;同样所有的数据按照比例尺,均匀分布在每一个不同分辨率的相同尺寸的正方形网格中。图片[1]-Mapbox GL JS简析-一一网

墨卡托投影,是正轴等角圆柱投影。由荷兰地图学家墨卡托(G.Mercator)于 1569 年创立。假想一个与地轴方向一致的圆柱切或割于地球,按等角条件,将经纬网投影到圆柱面上,将圆柱面展为平面后,即得本投影。墨卡托投影在切圆柱投影与割圆柱投影中,最早也是最常用的是切圆柱投影。!

这种数据组织方式(tileset),它可以快速定位当前所需数据,按需下载,并且易于存储与缓存。Mapbox 提供了可以将数据处理为 tileset 的工具,其所有的库和 SDK,都需要 tileset 来组织数据。

代码架构

  • Interface 层位于顶层,其中包含与地图用户交互的所有类。Map 和 Camera 控制地图状态(缩放,视角,位置等),Marker 和 Popup 是在地图上添加的标记,Control 是一系列地图上的小工具(放大缩小按钮,指南针等),Event Handler 处理地图的各种事件(move, click, zoom……)。

  • Style 层包含了表现和处理 Mapbox Style 的所有类。Layer 为地图中的图层,表示地图中的不同图形要素(道路-Line,区划面-Fill。。。),与 Mapbox Style 中的 layer 一一对应,主要存储图层对渲染要素的一系列配置;Source 表示地图所需要的数据,同样与与 Mapbox Style 中的 source 一一对应;Style 层还有 Light 等地图全局使用的公共样式类。

  • Render 层含使用 WebGL 在屏幕上渲染地图要素的所有类。Paint 是一个全局的渲染调度器,所有的渲染指令都由 Painter 下发;不同的图形要素(Fill, Line, Symbol……)有着不同的 Draw Function,用来执行不同的渲染逻辑;不同的要素也有不同的 Shader,来进行着色器渲染。

  • Map Data 层为地图渲染所需要的数据,它们按照数据源的不同,分为不同的 Source 类。Source 中包含数据的请求,处理,主子线程的通信方法。每一个 Source 在子线程有一个对应的 Worker Source,用于在子线程实际执行网络请求和数据处理等逻辑。

  • Tile Data 是层是数据在被请求到后,处理为渲染(WebGL)所需要的格式所用到的所有类。不同的图形要素需要不同的数据规格,因此在数据处理时,Source 会按照使用该 Source 的不同 Layer 进行不同的数据处理和组织,完成后以 Bucket 的形式存储,等待渲染时被调用。

  • Util 层为整个地图运行流程中,使用到的一些工具类。

  • Source, Tile, Bucket, Layer 的关系Source 按照数据源区分,里面存储着不同的 Tile 网格;每个 Source 可以被多个 Layer 使用,针对调用的不同的 Layer,每个 Tile 会存储不同的 Bucket,为不同的渲染方式提供数据。

从代码到地图

1. 实例化 Map

实例化 Map 对象以后,主要的工作在 Style 解析传入的 Mapbox Style:

// src/ui/map.js

this.setStyle(options.style, {localFontFamily: this._localFontFamily, localIdeographFontFamily: this._localIdeographFontFamily});
复制代码

在 Style 中,主要工作分为两部分,添加 source 和 layer

// src/style/style.js

for (const id in json.sources) {
    this.addSource(id, json.sources[id], {validate: false});
}
复制代码
// src/style/style.js

for (let layer of layers) {
    layer = createStyleLayer(layer);
    layer.setEventedParent(this, {layer: {id: layer.id}});
    this._layers[layer.id] = layer;
    this._serializedLayers[layer.id] = layer.serialize();
    this._updateLayerCount(layer, true);
}
复制代码

添加 source 时,会创建用于数据处理的 Source 对象和用于存储数据的 SourceCache 对象;添加 layer 时,会创建相应的 Layer 对象,并在初始化时解析 layer 的相关配置(layout, paint)。至此,实例化 Map 对象的工作结束。

2. 开始渲染一帧画面

每一次地图可视范围发生变化(移动中心点、缩放等操作),或者有数据处理完毕,都会引起地图开始更新。如果有动态效果,地图会定期(60fps)触发更新。

1. 更新数据

图片[2]-Mapbox GL JS简析-一一网
地图的更新从 Map 的_update 方法开始,首先会判断在当前可视范围下,是否需要更新数据:

// src/ui/map.js

this.style._updateSources(this.transform);
复制代码

在 Style 中,会判断每个 SourceCache 在当前可视范围下,能否提供完整的数据:

// src/style/style.js

this._sourceCaches[id].update(transform);
复制代码

在 SourceCache 中,如果发现当前可视范围需要但是无法提供的数据,Source 会请求该数据,同时会使用该瓦块的父/子瓦块占位,避免空白:

// src/source/source_cache.js

const tile = this._addTile(tileID);

// ..
const parentId = tileID.scaledTo(overscaledZ);
复制代码

在 Source 中,通知 worker 请求该数据:

// src/source/vector_tile_source.js

tile.request = tile.actor.send('loadTile', params, done.bind(this), undefined, true);
复制代码

在 worker 中,发起数据请求,接收到数据后,处理成为所需要的 Bucket,返回给主线程中的 Source,Source 会将数据存储在 SourceCache 中,供下次渲染调用。

// src/source/vector_tile_worker_source.js

tile.loadVectorData(data, this.map.painter);
复制代码

2. 渲染画面

在更新数据后,开始渲染地图画面:

// src/ui/map.js

// Actually draw
this.painter.render(this.style, {
    showTileBoundaries: this.showTileBoundaries,
    showTerrainWireframe: this.showTerrainWireframe,
    showOverdrawInspector: this._showOverdrawInspector,
    showQueryGeometry: !!this._showQueryGeometry,
    rotating: this.isRotating(),
    zooming: this.isZooming(),
    moving: this.isMoving(),
    fadeDuration,
    isInitialLoad: this._isInitialLoad,
    showPadding: this.showPadding,
    gpuTiming: !!this.listens('gpu-timing-layer'),
    speedIndexTiming: this.speedIndexTiming,
});
复制代码

地图的渲染由 Painter 执行,首先将每个 SourceCache 中需要用到的数据进行预处理:

// src/render/painter.js

for (const id in sourceCaches) {
    const sourceCache = sourceCaches[id];
    if (sourceCache.used) {
        sourceCache.prepare(this.context);
    }
}
复制代码

SourceCache 会将本次渲染需要用到的 Tile 进行预处理

// src/source/source_cache.js

for (const i in this._tiles) {
    const tile = this._tiles[i];
    tile.upload(context);
    tile.prepare(this.map.style.imageManager);
}
复制代码

Tile 会将存储的 Bucket 进行预处理,并处理所需要的的图片和文字的纹理:

// src/source/tile.js

for (const id in this.buckets) {
    const bucket = this.buckets[id];
    if (bucket.uploadPending()) {
        bucket.upload(context);
    }
}
复制代码

在 Bucket 中,会将所需要的数据,处理为 WebGL 所需要的 Buffer:

// src/data/bucket/fill_bucket.js

if (!this.uploaded) {
    this.layoutVertexBuffer = context.createVertexBuffer(this.layoutVertexArray, layoutAttributes);
    this.indexBuffer = context.createIndexBuffer(this.indexArray);
    this.indexBuffer2 = context.createIndexBuffer(this.indexArray2);
}
复制代码

至此,数据预处理完成,开始执行 WebGL 绘制。图片[3]-Mapbox GL JS简析-一一网
绘制工作主要是逐 Layer 的三轮绘制。绘制开始时,首先进行一轮离屏渲染,将需要在 Frameuffer 上绘制的内容进行逐一绘制(例如热力图的密度纹理):

// Offscreen pass ===============================================
// We first do all rendering that requires rendering to a separate
// framebuffer, and then save those for rendering back to the map
// later: in doing this we avoid doing expensive framebuffer restores.
this.renderPass = 'offscreen';
复制代码

开始主画布绘制,首先重置 WebGL 参数:

// src/render/painter.js

// Rebind the main framebuffer now that all offscreen layers have been rendered:
this.context.bindFramebuffer.set(null);
this.context.viewport.set([0, 0, this.width, this.height]);

// Clear buffers in preparation for drawing to the main framebuffer
this.context.clear({color: options.showOverdrawInspector ? Color.black : Color.transparent, depth: 1});
this.clearStencil();
复制代码

开始两轮绘制,第一轮绘制无透明图层,第二轮绘制有透明度的图层。拆分的一个重要原因是图层是否有透明度,对于颜色混合和深度测试的处理方式都有不同:

// src/render/painter.js

// Opaque pass ===============================================
// Draw opaque layers top-to-bottom first.
this.renderPass = 'opaque';
// ...
复制代码
// src/render/painter.js

// Translucent pass ===============================================
// Draw all other layers bottom-to-top.
this.renderPass = 'translucent';
// ...
复制代码

在每一轮绘制中,需要遍历所有 Layer,下面对一个 Layer 的绘制进行简单介绍。首先准备模板测试中每个 Tile 的模板,这时由于在数据处理中,每个 Tile 中所记录的数据,实际范围是要比 Tile 的范围稍大,需要在绘制时进行模板测试,防止重叠:

// src/render/painter.js

this._renderTileClippingMasks(layer, sourceCache, coords);
复制代码

开始绘制图形:

// src/render/painter.js

this.renderLayer(this, sourceCache, layer, coords);
复制代码

绘制图形是由不同 Layer 类型对应的 Draw Function 完成,以 Fill 为例,在判断当前绘制轮次需要绘制时,会对当前可视范围的 Tile 逐一绘制:

// src/render/draw_fill.js

drawFillTiles(painter, sourceCache, layer, coords, depthMode, colorMode, false);
复制代码

首先加载需要用到的 WebGL Program:

// src/render/draw_fill.js

const program = painter.useProgram(programName, programConfiguration);
复制代码

设置部分相关的 Uniform 配置:

// src/render/draw_fill.js

fillUniformValues(tileMatrix);
复制代码

调用绘制命令:

// src/render/draw_fill.js

program.draw(painter.context, drawMode, depthMode,
painter.stencilModeForClipping(coord), colorMode, CullFaceMode.disabled, uniformValues,
layer.id, bucket.layoutVertexBuffer, indexBuffer, segments,
layer.paint, painter.transform.zoom, programConfiguration);
复制代码

Program 中会绑定其余的 Uniform 配置

// src/render/program.js

if (configuration) {
    configuration.setUniforms(context, this.binderUniforms, currentProperties, {zoom: (zoom: any)});
}
复制代码

绑定渲染所用到的 Vao:

// src/render/program.js

vao.bind(
    context,
    this,
    layoutVertexBuffer,
    configuration ? configuration.getPaintVertexBuffers() : [],
    indexBuffer,
    segment.vertexOffset,
    dynamicLayoutBuffer,
    dynamicLayoutBuffer2
);
复制代码

最后调用绘制命令:

// src/render/program.js

gl.drawElements(
    drawMode,
    segment.primitiveLength * primitiveSize,
    gl.UNSIGNED_SHORT,
    segment.primitiveOffset * primitiveSize * 2);
复制代码

至此一个 Layer 的绘制就完成了。

数据平台前端团队,在公司内负责风神、TEA、Libra、Dorado 等大数据相关产品的研发。我们在前端技术上保持着非常强的热情,除了数据产品相关的研发外,在数据可视化、海量数据处理优化、web excel、WebIDE、私有化部署、工程工具都方面都有很多的探索和积累,有兴趣可以与我们联系。对产品有任何建议和反馈也可以直接找我们进行反馈 ~

dpvis 可视化相关主要产品:

通用图表库 Chartspace :chartspace.web.bytedance.net/home

地理空间数据可视化 xGis xgis.bytedance.net/home

关系图 xGraph xgraph.web.bytedance.net/home

欢迎关注「 字节前端 ByteFE 」
简历投递联系邮箱「tech@bytedance.com

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享