第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