第03章 - 核心架构与渲染管线
3.1 一帧里发生了什么
把 map._render() 的源码按顺序读一遍,就能得到 MapLibre 每帧的固定骨架。它在 src/ui/map.ts 里,被 requestAnimationFrame 驱动,只在”有事要画”时运行——没有脏标记就不渲染,这是它能安静待机的原因。
下面是简化后的执行序列,顺序不能乱:
_render(paintStartTimeStamp)
│
├─ 1. painter.context.setDirty(); painter.setBaseState()
│ 自定义图层可能异步改过 GL 状态,先复位
├─ 2. _renderTaskQueue.run(ts)
│ 执行本帧排队的回调(相机动画、Marker 跟随等)
├─ 3. if (_styleDirty) style.update(parameters)
│ 计算 paint 属性、过渡插值、crossFade 因子
├─ 4. _camera.prepareNextFrame(projectionTransition)
├─ 5. if (_sourcesDirty) style._updateSources(transform)
│ 为新视口计算 needed tiles 并发请求
├─ 6. terrain.tileManager.update(...); _camera.clampCenterToGround(terrain)
├─ 7. _placementDirty = style._updatePlacement(...)
│ 符号碰撞、标注位置(只在必要时重算)
├─ 8. painter.render(style, transform, data) ← 真正提交 GPU
├─ fire('render')
├─ 9. 若 loaded() 且首次 → fire('load')
├─ 10. 还有脏标记 → triggerRepaint(),否则 fire('idle')
└─ 11. _fullyLoaded 标记
第 3、5、7 步是”脏了才算”,第 8 步是”有东西才画”。理解这一点,很多性能问题的答案就明确了:帧率低不是渲染慢,而是某个脏标记每帧都被重新置上,导致一直在重画。
3.2 模块分层
src/ 下的目录不是随意拆的,边界基本对应”数据、样式、渲染、交互”四层:
| 目录 | 职责 | 关键文件 |
|---|---|---|
src/ui/ |
对外 API、相机、事件、控件、交互 handler | map.ts、camera.ts、events.ts、handler_manager.ts |
src/style/ |
样式解析、图层对象、paint/layout 属性求值 | style.ts、style_layer.ts、properties.ts、light.ts、sky.ts |
src/source/ |
六类数据源与 worker 端解析 | source.ts、worker_tile.ts、geojson_source.ts |
src/data/ |
Bucket、FeatureIndex、顶点数组、program 配置 | bucket/、feature_index.ts、program_configuration.ts |
src/render/ |
渲染器、绘制函数、图集、地形 RTT | painter.ts、frame_render_context.ts、terrain.ts |
src/webgl/ |
WebGL 抽象层 | context.ts、program.ts、framebuffer.ts、texture.ts |
src/geo/ |
坐标、投影、相机辅助、包围盒 | lng_lat.ts、mercator_coordinate.ts、projection/ |
src/tile/ |
瓦片标识、缓存、TileManager | tile_id.ts、tile_cache.ts、tile_manager.ts |
src/symbol/ |
标注布局、碰撞、跨瓦片匹配 | placement.ts、collision_index.ts |
src/util/ |
worker 池、Actor、请求、事件基类 | worker_pool.ts、actor.ts、ajax.ts、evented.ts |
规模上,去掉测试文件后 src/ 约有 570 个 TypeScript 文件、5.6 MB 源码,其中 webgl/ 55 个、util/ 54 个、ui/ 43 个文件。二次开发时改动集中在 ui/(API 层)与 render/、webgl/(渲染层),前者风险低,后者要跑渲染测试。
3.3 主线程与 Worker 的分工
矢量瓦片的解析是纯 CPU 计算且量大,放在主线程会直接卡住交互。MapLibre 的做法是把 tile 解析整体挪到 worker,主线程只负责”要不要”和”怎么画”。
主线程 Worker
────────────────────────────────────────────────────────────────
TileManager Actor(消息处理)
│ loadTile(tile) │
│ ────── {type:'loadTile', data:{...}} ──────► │
│ ├─ VectorTile 反序列化
│ ├─ WorkerTile.parse()
│ │ ├─ 按 layout 家族分组
│ │ ├─ 建 Bucket(顶点/索引/属性数组)
│ │ └─ 建 FeatureIndex(供查询)
│ ◄──── {buckets, featureIndex, ...} ───────── │
│ (Buffer 以 transfer 方式转移,不复制)
├─ tile.state = 'loaded'
└─ Painter 下一帧从 Bucket 取缓冲提交
worker 的创建与复用由 src/util/worker_pool.ts 管理,默认配置值得记:
| 参数 | 默认值 | 说明 |
|---|---|---|
| worker 数量 | 1;Safari 为 max(min(floor(hardwareConcurrency/2), 3), 1) |
WorkerPool.workerCount,可用 setWorkerCount() 改 |
| 图片并行请求 | 16 | config.MAX_PARALLEL_IMAGE_REQUESTS |
| 每帧图片请求 | 8 | config.MAX_PARALLEL_IMAGE_REQUESTS_PER_FRAME |
| 瓦片缓存层数 | 5 | config.MAX_TILE_CACHE_ZOOM_LEVELS,可被 maxTileCacheZoomLevels 覆盖 |
worker 只有 1 个是 6.x 的默认设计,不是配置错误。worker_pool.ts 的注释直接写明该值基于 A/B 测试(PR #2354):多 worker 会带来重复的样式与字体状态、更高的内存占用,而单 worker 在多数场景下已经能跟上主线程的消费速度。只有 GeoJSON 体量大、解析重时,加到 2 才有可见收益。
3.4 瓦片金字塔与 LOD
所有”看多远、取哪些瓦片”的判断都落在 src/geo/projection/covering_tiles.ts。给定相机状态与数据源参数,它返回一组 OverscaledTileID,这些 ID 决定本帧要请求、要绘制哪些瓦片。
几个必须理解的量:
- 瓦片尺寸:渲染按 512 CSS 像素的瓦片组织(
TileManager与各 source 的tileSize默认 512)。source 若提供 256 px 的栅格瓦片,要显式写tileSize: 256,否则会被放大插值,看起来发虚。 - 内部坐标范围:瓦片内几何坐标归一化到 0–8192(
src/data/extent.ts的EXTENT)。8192 = 2^13,是 int16 顶点在去掉符号位、线顶点贴图位、右侧越界缓冲位之后的余量。 - 覆盖层级:
coveringZoomLevel()由相机 zoom 加上瓦片尺寸的比例修正得到,再被 source 的minzoom/maxzoom夹住。 - 过缩放(overscale):当相机 zoom 超过 source 的 maxzoom,瓦片被拉伸使用;6.x 引入
zoomLevelsToOverscale(默认 4),在接近地图 maxZoom 的若干层改为”切分”而不是”拉伸”,改善高 zoom 下的标注与多边形中心。
// 观察 LOD 与瓦片边界:打开调试线框,能直接看到瓦片被切分的层级
map.showTileBoundaries = true;
// 打开标注碰撞框,看 placement 把哪些标号丢弃了
map.showCollisionBoxes = true;
// 打开 overdraw 检查(越亮说明该区域被重复绘制越多)
map.showOverdrawInspector = true;
// 关闭全部调试
map.showTileBoundaries = false;
map.showCollisionBoxes = false;
这三个属性都是 Map 上的 getter/setter,赋值即生效,不需要重建地图。排查”某级别瓦片突然不清晰”“标注在某级消失”时,先打开 showTileBoundaries 看层级对不对,再看 source 的 maxzoom 与 zoomLevelsToOverscale。
3.5 Bucket:从瓦片到 GPU 缓冲
Bucket 是整条管线里最容易被忽略、但决定渲染性能的环节。它的定义在 src/data/bucket.ts,子类按图层类型划分:
| Bucket 类 | 对应图层 | 产物 |
|---|---|---|
FillBucket |
fill、fill-extrusion | 三角化后的顶点与索引 |
LineBucket |
line | 线上的四边形带、虚线、端帽与连接处的几何 |
SymbolBucket |
symbol | 图标/文字四边形、碰撞盒、放置结果 |
CircleBucket |
circle | 每个点的四边形 + 半径数据 |
HeatmapBucket |
heatmap | 点位置与权重 |
FillExtrusionBucket |
fill-extrusion | 顶面与侧面三角网 |
分组的依据是”同一组 layout 属性”。两个图层只要 layout 完全相同、又来自同一 source-layer,就会共享同一个 Bucket 和同一批顶点数据,只是 paint 属性不同。这就是”多画几个只改颜色的图层几乎不增加几何开销”的原因:
// 这两个图层会归入同一 Bucket(layout 一致),只在 paint 上分叉
map.addLayer({
id: 'road-casing',
type: 'line',
source: 'roads',
'source-layer': 'transportation',
layout: {'line-cap': 'round', 'line-join': 'round'},
paint: {'line-color': '#8a8a8a', 'line-width': 6}
});
map.addLayer({
id: 'road-fill',
type: 'line',
source: 'roads',
'source-layer': 'transportation',
layout: {'line-cap': 'round', 'line-join': 'round'}, // 与上一个完全一致
paint: {'line-color': '#ffffff', 'line-width': 3}
});
反过来,只要 layout 差一个属性(例如 line-cap 从 round 变 butt),就会多一份完整几何。这条规则在海量图层样式里是主要的显存与解析开销来源,第 7 章会展开。
3.6 Painter:四个 pass
Painter.render() 在 src/render/painter.ts,它把所有图层按固定策略分四轮提交:
Painter.render()
│
├─ maybeDrawDepth() 地形深度预渲染(启用 terrain 时)
├─ renderToTexture.prepareForRender() 地形/globe 的离屏准备
│
├─ Pass 1 offscreen 只画"需要离屏"的图层(hasOffscreenPass)
├─ 绑定主 framebuffer 并清屏
├─ 画 sky(天空先画,避免盖住符号)
│
├─ Pass 2 opaque 从最上层往下画(layerIds 倒序),先铺不透明层
│ 目的是让不透明像素尽早填满 depth,减少 overdraw
├─ Pass 3 translucent 从最下层往上画(正序)
│ symbol 图层用 coordsDescendingSymbol,额外包含相邻瓦片
└─ 画 atmosphere(仅 globe)
debug 线框 / padding 框 / setDefault() 复位 GL 状态
“不透明倒序、半透明正序”不是随手写的。不透明图层倒序绘制能让近处的大面积填充先落笔,后面的绘制被深度测试挡掉;半透明图层必须正序,才符合视觉叠放顺序。当 renderToTexture 存在(地形或 globe 转 mercator 的过渡期)时,不透明 pass 被禁用,所有图层都走一次从下到上的重绘,并渲染到纹理上再贴回屏幕。
Painter.renderLayer() 是分派入口:按图层类型调用 draw.symbol、draw.circle、draw.heatmap、draw.fill、draw.line 等绘制函数。每个绘制函数内部再做三件事——从 ProgramCache 取已编译的着色器程序、按当前图层属性设置 uniform、绑定顶点数组并 drawElements。
调试 pass 归属有一个实用技巧:给图层设一个明显错误的 opacity 或 visibility,看它消失的时机;或者临时把 fill-color 设为全透明,观察是否仍影响下层——半透明层在正序 pass 中参与混合,透明色依然会叠加。
3.7 着色器与数据驱动属性
样式里形如 ["interpolate", ["linear"], ["zoom"], 10, 1, 16, 8] 的属性,最终要么变成 uniform 常量,要么变成逐顶点的属性数组。这条分支由 ProgramConfiguration 与 #pragma maplibre 机制处理,源码注释在 ARCHITECTURE.md 里说得很清楚:
- 若某属性是常量(不随 feature 变化),shader 里声明为 uniform,绘制前统一设置一次。
- 若是数据驱动(依赖 feature 属性或
feature-state),在 worker 阶段就把每个顶点的值写进 paint vertex array,shader 里声明为 attribute。
这解释了一条常见困惑:同一个表达式的性能表现可能截然不同。按 zoom 插值的表达式是常量加过渡(uniform + 每帧插值),开销可忽略;按 feature 属性插值的表达式要为每个顶点写数据,显存与解析时间都随要素数线性增长。
// 常量 + zoom 插值:走 uniform,几乎零成本
paint: {'line-width': ['interpolate', ['linear'], ['zoom'], 10, 1, 16, 8]}
// 数据驱动:为每个顶点写 paint 数组,成本与要素数相关
paint: {'line-width': ['interpolate', ['linear'], ['get', 'lanes'], 1, 1, 8, 8]}
3.8 投影与地形:多出来的两条渲染路径
6.x 的渲染路径不止 mercator 一条。src/geo/projection/ 下有三个 transform 实现:mercator_transform.ts、globe_transform.ts、vertical_perspective_transform.ts,由 projection_factory.ts 按样式里的 projection 字段选择。
| 投影 | 触发方式 | 渲染差异 |
|---|---|---|
| mercator | 默认 | 标准平面瓦片渲染 |
| globe | style 中 projection: {type: 'globe'} |
瓦片投影到球面,过渡期在 mercator 与 globe 之间插值 |
| vertical-perspective | 6.x 新增 | 用于高空视角,相机辅助与裁剪计算不同 |
| 自定义平面 CRS | addProjection()(实验) |
预投影瓦片,渲染仍走 mercator 路径 |
地形是另一条支线。启用 map.setTerrain() 后,DEM 瓦片被解码成高度纹理,图层绘制改为在离屏 pass 中渲染到纹理,再按地形网格贴到三维曲面上。相关实现集中在 src/render/terrain.ts 与 src/webgl/render_to_texture.ts,第 14 章会写具体配置。
这两条路径共享同一个 Painter,差别在 FrameRenderContext 里的投影数据与渲染目标。自定义图层若要用对矩阵,从 6.x 起应使用 render 回调参数里的 getProjectionData() 或 defaultProjectionData,而不是自己拼矩阵——Map.transform 已经在 6.0 中被移除,旧的取矩阵写法会直接编译报错。
// 自定义图层:拿投影数据交给外部渲染器(three.js / deck.gl 等)
const customLayer = {
id: 'my-3d',
type: 'custom',
renderingMode: '3d',
onAdd(map, gl) {
this.map = map;
this.gl = gl;
},
render(gl, args) {
const proj = args.getProjectionData
? args.getProjectionData({labelPlaneMatrix: true})
: args.defaultProjectionData;
// proj.mainMatrix / proj.labelPlaneMatrix 交给外部渲染器
void proj;
}
};
map.addLayer(customLayer);
3.9 排错与性能判断
帧率低但 GPU 占用不高。 大概率是主线程每帧都在做 style.update 或 _updatePlacement。检查是否有过渡属性每帧都被重新触发(例如在 render 事件里改 paint 属性),以及是否用了数据驱动的高频变化表达式。
首屏慢、瓦片一个个出现。 看 worker 数量与并行请求配置。GeoJSON 报告类数据可以 setWorkerCount(2);栅格底图多时调 setMaxParallelImageRequests(24)。但先确认瓶颈在网络还是解析:data 事件里带 resourceTiming(需开启 collectResourceTiming),能直接看到每个请求耗时。
地图整体发灰、半透明层叠在一起。 检查是否有大量半透明图层参与正序 pass,这是 overdraw 的典型来源;用 showOverdrawInspector 能直观看到热区。
地形开启后局部空白或闪烁。 通常是 DEM 源与影像源的 maxzoom 不匹配,或 terrainSkirtLength 被设为 'none' 后瓦片边界出现缝隙。后者在透明背景下会露出垂直裂缝,官方建议先加一个不透明的 background 图层再考虑关裙边。
// 诊断:统计一段时间内的渲染帧与空闲事件,判断是否"一直在画"
let frames = 0;
let idle = 0;
map.on('render', () => { frames++; });
map.on('idle', () => { idle++; });
setTimeout(() => {
console.log('5s 内 render=' + frames + ', idle=' + idle);
if (frames > 200 && idle === 0) {
console.warn('地图几乎每帧都在重绘,检查是否有脏标记被反复置位');
}
}, 5000);
3.10 本章小结
| 环节 | 关键结论 |
|---|---|
| 渲染循环 | _render() 按”清状态→跑任务→更样式→更数据源→排版→提交”固定顺序执行,无脏标记则跳过 |
| 线程分工 | 瓦片解析在 worker,主线程只管样式、排版、提交;默认 1 个 worker |
| 瓦片 | 512 px 布局,内部坐标 0–8192;超 maxzoom 时按 zoomLevelsToOverscale(默认 4)决定拉伸还是切分 |
| Bucket | 同 layout 的图层共享一份几何;layout 差异即多一份完整缓冲 |
| Pass | offscreen → sky → opaque(倒序)→ translucent(正序)→ atmosphere |
| 数据驱动 | 常量走 uniform,逐要素走顶点属性,后者成本随要素数增长 |
| 投影 | mercator / globe / vertical-perspective 与自定义 CRS 共用 Painter;地行走离屏渲染到纹理 |
| 调试开关 | showTileBoundaries、showCollisionBoxes、showOverdrawInspector |