几张手机截图,同一片街区,拉法。Google 地图一张,Apple 地图一张。肉眼可见不一样。底下有人补了一句:学校图标还在地图上。
这是整件事里最技术的一句话,也是最容易被“那当然”三个字糊过去的一句。我想把这句话读到底。
先把结论摔在桌上:图标不是画在图片上的,图标画在一个坐标上。 影像是一张图,图标是一个坐标。换影像,换的是那张图;坐标一秒都没动。这两个东西的更新时钟从来就没对齐过,客户端里也没有任何一行代码承诺过要对齐。
这一篇读 MapLibre GL JS(maplibre/maplibre-gl-js,以 4.x 的 src/ 为准)。挑它是因为:Web 地图这一行,能把从瓦片请求一路摊到 WebGL 绘制的完整链条交到你手里的实现不多,它是其中一个,而且写得够干净。Google 和 Apple 的客户端闭源,但这条链的骨架两边一样——这不是猜的,是同一类渲染器被同一组约束压出来的必然形状。
下面所有代码块都是从 src/ 里按结构抠出来的摘录,不是完整源码。字段名、方法名以你本地 node_modules/maplibre-gl/src 或 GitHub 上对应分支为准。行号我刻意不写:前端库的 minor 版本之间行号漂得比后端狠,写死在文章里等于给读者埋雷。
一、先发地图:三层,两条路
读渲染器的第一步永远不是钻函数,是先弄清楚三件事:数据从哪来、谁决定这一帧要什么、谁最后把它画出去。
MapLibre 客户端拆三层:
- Source 层——数据从哪来。一个
Source就是一份数据的描述:一个 TileJSON 的 URL,或者内联的tiles模板、minzoom/maxzoom、tileSize、scheme。 - SourceCache / Tile 层——这一帧我该要哪些瓦片。
SourceCache和一个Source一一对应,底下挂着一堆Tile。 - Painter / Bucket 层——怎么画。
Painter按图层顺序遍历,把每个瓦片上的Bucket画出去。
到了这一层,Source 分成两条互不相干的路径:
- 矢量瓦片路径:请求 MVT(Mapbox Vector Tile,protobuf),在 Worker 里解析成
Bucket,主线程做符号排版和碰撞检测,最后用 glyph 和 sprite 画出去。 - 栅格瓦片路径:请求一张 JPEG/PNG,转
ImageBitmap,上传成纹理,贴到一个带投影的四边形上,相邻瓦片之间交叉淡入。
两条路径唯一的共同点是它们都叫“瓦片”、都走 z/x/y。
第一层答案就在这儿:卫星图上的楼塌了,是栅格路径上的像素变了;学校图标还在,是矢量路径上那条 Point 记录还在。你看到的是两层东西叠在一起,屏幕上贴得严丝合缝,工程上没有任何关系。
二、瓦片金字塔:这一节不讲穿,后面没法读
先把金字塔讲清楚,不然后面 overscaledZ 那段会看懵。
Web Mercator 下整个地球是一棵四叉树:z0 一张,z1 四张,一直到 z19 是 2^19 × 2^19。同一个经纬度在不同 z 下落进不同的瓦片,层级越深,一张瓦片盖住的实际地面越小。
MapLibre 里表达“一片瓦片”的是两个类,在 src/source/tile_id.ts:
// src/source/tile_id.ts(结构摘录)
class CanonicalTileID {
z: number;
x: number;
y: number;
}
class OverscaledTileID {
overscaledZ: number; // 实际上要去请求的层级
wrap: number; // 世界横向绕了几圈
canonical: CanonicalTileID; // 它在屏幕上被画到哪一层
}
这是全篇第一个真正要停下来的地方。overscaledZ 是“我打算去哪一层取数据”,canonical.z 是“这份数据最终会被画到哪一层”。两个相等,正常取用;一旦 overscaledZ > canonical.z,就叫 overzoom——手上只有 z14 的瓦片,当前视图在 z17,把这张瓦片放大三档贴上去。
矢量瓦片可以无限 overzoom。它是几何:放大就是把坐标乘个系数,再重新光栅化一遍。所以矢量地图放到最大,路网还是锐的,字还是锐的。栅格不行。它就是已经烧死的一格一格像素,放大就是插值,插到 8 倍就是一团糊。
这条约束不是谁偷懒,是数据类型本身决定的。换你写,你也变不出来。记住这一条,后面“为什么卫星图放到很大就是方块”“为什么同一块地方两家看起来细节不一样”就都有解释了。
金字塔还有个不太显眼的作用:它是一张 mipmap。视野里的地面范围决定了该取哪一层,而不是永远取最大那层。取小一层,请求少、带宽低、画得快;取大一层,清楚但贵。Transform.coveringTiles() 干的就是这个取舍,而且它不是把视野矩形直接投到瓦片网格上那么简单——视图带 pitch 和 bearing 的时候它会取一个明显更大的保守集合,还得处理横向环绕(那一列 wrap)。
三、钻进 SourceCache.update()
SourceCache 是这一层的核心,文件在 src/source/source_cache.ts。骨架大致这样:
// src/source/source_cache.ts(结构摘录)
class SourceCache extends Evented {
_source: Source;
_tiles: { [key: string]: Tile }; // 当前还在用的
_cache: { [key: string]: Tile }; // 刚出视野的,先留着
_cacheTimers: { [key: string]: number };
_timers: { [key: string]: number };
update(transform: Transform) { /* ... */ }
}
update() 每帧被 Style.update() 调一次,按顺序干四件事:
- 算出当前视野要覆盖哪些瓦片 ID——调
transform.coveringTiles(...)。 - 拿这个集合和已有的
_tiles做差集:该留的留,该退的进_cache。 - 对每个该有但还没有的 ID,
_addTile()→new Tile→_loadTile(),把请求丢出去。 - 给
_cache里刚退出去的那些起定时器,过一会儿才真删。
第一步那个覆盖集合,有两件事得看清楚。一是它带着 buffer:视野外面还得再铺一圈,不然你一拖动,边缘立刻见白。二是它必须处理 wrap——世界地图水平方向首尾相接,你从东经 179 度往右拖,拖出来的是西经 179 度,那是同一条瓦片列的两个副本,canonical.x 一样,靠 wrap 区分。
第二、第三步那两份表——_tiles 和 _cache——不是冗余,是两个用途。_tiles 是“这一帧要画的”,_cache 是“接下来几帧可能要画的”。地图平移时,刚出视野的瓦片很可能下一秒又进来;出视野就立刻销毁,你会先看到一片白,再看着它慢慢填回来,来回拖一下就是满屏闪。
用内存换平滑,这是地图渲染器最老的一笔交易。做地图的没人敢省这笔内存,省了就是肉眼可见的丑。
四、Tile:状态机,和两条路径正式分叉的地方
Tile 在 src/source/tile.ts,它是“一片瓦片在客户端的一生”:
// src/source/tile.ts(结构摘录)
class Tile {
state: 'loading' | 'loaded' | 'reloading' | 'unloaded' | 'errored';
timeAdded: number;
fadeEndTime: number;
loadVectorData(data, painter, justReloaded) { /* ... */ }
loadRasterData(data, painter, justReloaded) { /* ... */ }
}
state 这个字段解释了很多日常现象。缩放那一瞬间看到某一块空着,不是网络断了,是它还停在 loading;某一块突然变清楚,是它从 loading 翻到了 loaded。
真正的分叉在这两个方法的名字里:loadVectorData 和 loadRasterData。
矢量那条,拿到的是一组已经解析好的 Bucket,直接挂到 tile 上就完事。栅格那条,拿到的是一个 ImageBitmap,得交给 painter.upload() 上传成 WebGL 纹理。两条路径在这里正式分开,后面再也不会合流。
还有一个字段值得单独看一眼:fadeEndTime。
栅格瓦片不是“啪”地出现的,它会在几百毫秒的 raster-fade-duration 里从透明度 0 淡到 1,同时旧的那张从 1 淡到 0。为的是缩放和平移不闪,做得很漂亮,没什么可挑。
但它有个副产品,我觉得值得说死:你看到的画面,在任何一个瞬间都是两个时间点的混合。 旧的那张还没退干净,新的已经进来了半个身位。这不是 bug,是刻意的。所以“地图上现在显示的是什么”这个问题,从来没有一个干净的答案——它是一段跨度的加权平均。截图更是。
五、Worker 里到底发生了什么
瓦片的解析不在主线程,在一个 Worker 池里。矢量那条链的入口在 src/source/vector_tile_worker_source.ts:
// src/source/vector_tile_worker_source.ts(结构摘录)
loadTile(params, callback) {
const { data } = params;
const tile = new VectorTile(data); // protobuf 解出图层和要素
const buckets = {};
for (const layer of this.styleLayers) {
const bucket = layer.createBucket({ /* ... */ });
bucket.populate(layer, tile, this.styleLayers); // 要素 → 几何 → 排版
buckets[layer.id] = bucket;
}
callback(null, { buckets });
}
为什么挪到 Worker 里,值得说两句,因为它不是“顺手优化一下”。
MVT 里一条线,坐标是 zigzag 变长整数编码的增量,得 unpack 回来;一个 4096 的 extent 里几百条要素,每条都要按 style 里的 filter 过一遍;过了的还要按图层分桶、把几何切成 GPU 能直接吃的三角或线;符号类更狠——算碰撞、按 {fontstack}/{range}.pbf 去索引字模、把每个字排成四个 quad。
这些事放在主线程做,一帧就没了。地图是那种每帧 16 毫秒预算的程序,主线程一卡就是肉眼可见的卡顿。
代价也很实在:Worker 是独立的内存空间,buckets 要经过一次结构化跨线程搬运,ImageBitmap 之外的很多东西没法零拷贝过去。这块是性能分析里最容易被忽略的一环——你看着主线程不忙,其实 Worker 池已经排到第三轮了。
六、“学校图标还在地图上”——这句话在代码里的位置
回到那句话。
bucket.populate() 往下走,符号类要素会进 src/symbol/symbol_layout.ts 的 performSymbolLayout,给每个 Point 要素算出一个 SymbolInstance,挂在 bucket 上。它的输入大概长这样:
// 一片矢量瓦片里的一条要素(示意)
{
"layer": "poi",
"type": "Point",
"geometry": [ 2048, 1300 ], // tile 局部坐标,extent 通常是 4096
"properties": { "class": "school", "name": "…" }
}
2048, 1300 是瓦片局部坐标。配上它所在的 z/x/y,才能还原成经纬度。然后 icon-image 去 sprite 图谱里取图(src/symbol/sprite_atlas.ts,来源是 sprite.png + sprite.json),text-field 去字模图谱里取 SDF 字形(src/symbol/glyph_atlas.ts)。
所以那句话翻译成代码就是:这条 Point 记录还在数据源里,所以这个图标还在。
它跟影像更没更新,一点关系都没有。影像是栅格瓦片,有人重新拼了一张正射影像、切了瓦片、刷了 CDN;图标是矢量瓦片,有人重新跑了一遍 POI 抽取、重新生成了 MVT、重新部署了一遍。这两件事之间没有任何强制同步。你甚至可以——很多团队真的这么干——拿一套好几年前的 POI 底图,去叠一张上周刚拍的影像。
卫星影像和 POI 图标之间,从来没有“一致”这个不变量。谁要是觉得有,那是把地图当成一张图片看了。
七、图标的位置是算出来的,不是存下来的
还没完。算完位置也不代表就会显示。
符号过完排版要过碰撞检测,在 src/symbol/placement.ts,往下落到 CollisionIndex:
// src/symbol/placement.ts(结构摘录)
placeLayerBucket(bucket, layer, ...) {
for (const symbol of bucket.symbolInstances) {
const placed = this.collisionIndex.placeCollisionBox(
symbol, /* ... */ layer.get('text-allow-overlap'), /* ... */,
);
if (placed) {
bucket.place(symbol, ...); // 真的画它
}
}
}
两个图标挨得太近,只有一个能显示。所以缩放时你会看到图标“跳”——不是数据变了,是碰撞判定结果变了:缩放改变了每个图标的屏幕尺寸和间距,也就改变了谁能赢。
这意味着另一件事,比上面那句更重要:你截的那张图不是地图,是这一次渲染的结果。 同一份数据,换一台屏幕尺寸不同的手机、换一个 DPR、换一个视角,摆出来的图标位置和数量都可能不一样。截图作为证据是有边界的,这个边界不在图上,在这段代码里。
八、Painter:最后一步,两条路径各自出门
src/render/painter.ts 里的一帧,结构直白得有点不像话:
// src/render/painter.ts(结构摘录)
render(style, options) {
for (const layer of style.order) {
if (!layer.isHidden(this.transform.zoom)) {
this.renderLayer(style, layer, /* ... */);
}
}
}
renderLayer(style, layer, coords) {
const sourceCache = style.sourceCaches[layer.source];
for (const coord of coords) {
const tile = sourceCache.getTile(coord);
if (!tile.isRenderable()) continue; // 状态机的出口
this.drawTile(tile, layer, /* ... */);
}
}
drawTile(tile, layer, /* ... */) {
const bucket = tile.getBucket(layer);
bucket.draw(/* ... */); // 矢量 / 符号 / 栅格,各自的 shader
}
三层遍历:图层顺序 → 坐标集合 → 拿 bucket 画。style.order 决定谁盖在谁上面。这一层的顺序就是那种看起来无关紧要、出事的时候全是它的东西:影像在最底下,路网在中间,符号在最上面,谁写的 style 谁负责。
isRenderable() 是状态机那个字段的出口——只有 loaded 或 reloading 的瓦片才会被画。所以“某块地方没显示”这个现象,往上游追,绝大多数时候追到的是 state 还没翻过来。
九、一帧的完整数据流
串起来。一次从请求到像素的路径是这样(箭头是调用方向,缩进是进入更深的栈帧):
Map._render()
├─ Style.update()
│ └─ SourceCache.update(transform)
│ ├─ transform.coveringTiles() // 视野要哪些 z/x/y
│ ├─ _updateRetainedTiles() // 差集:进 _tiles,退 _cache
│ └─ _addTile() → Tile
│ └─ _loadTile()
│ ├─ 矢量:worker.loadTile() → MVT 解析 → Bucket 数组
│ └─ 栅格:getImage() → ImageBitmap
│ (异步返回)
│ └─ _tileLoaded()
│ ├─ Tile.loadVectorData() // Bucket 直接挂上,结束
│ └─ Tile.loadRasterData() // painter.upload() → WebGL 纹理
├─ Placement.placeLayerBucket() // 符号碰撞:谁显示、谁让位
└─ Painter.render()
└─ renderLayer() → drawTile()
├─ SymbolBucket.draw() ← 图标、文字 (矢量路径)
└─ RasterBucket.draw() ← 卫星影像 (栅格路径)
这张图是这一篇的承重墙。后面再说什么,你都可以对着它定位“现在在哪个坐标”。
十、三个时钟
回看开头那几张截图,能给出的解释只有三条,按可能性排:
第一,视野和缩放不同,取的根本不是同一层瓦片。 一个是 z16 的影像,一个是 z14 放大两档,画出来当然不一样,连像素密度都不一样。
第二,影像源不同。 两家用的影像供应商、拍摄时间、正射校正和匀色处理本来就不一样。这不是“一家有、一家没有”的问题,是“两家手上的底片本来就不是同一卷”。
第三,缓存状态不同。 这条最容易被忽略,也最能解释“同一天不同人看到的东西不一样”。栅格瓦片走的是普通 HTTP,中间隔着 CDN、边缘节点、浏览器缓存;客户端自己还有 _tiles 和 _cache 两层。一张新影像上线,不代表所有看到的人都立刻拿到它。有人已经在看新的,有人还在看旧的,两个人都没说错。
至于“学校图标还在”——它不在这三条里的任何一条。因为图标在另一条路径上,另一个时钟上。
这里我得承认,这个解释有点扫兴。它不提供立场,只提供机制。但这个系列的规矩就是先问代码在哪,再问怎么讲。代码不会替自己说话,但也不会替谁站台。
十一、代价清单
把这一篇里出现的设计过一遍,每一条都对应一笔支出:
- Worker 池:换来主线程的 16 毫秒预算,付出跨线程搬运的开销和内存翻倍。
_tiles/_cache两份表:换来平移时的平滑,付出内存,以及“什么算过期”这个一直有人搞错的问题。fadeEndTime交叉淡入:换来缩放不闪,付出的是“每一帧都是两个时间的混合”。- 栅格不能 overzoom:这不是选择,是约束。换来渲染管线的简单,付出的是最大缩放级别被
maxzoom钉死。 - 符号位置在客户端算:换来任意分辨率下的锐利摆位,付出的是“同一份数据不同设备看到的布局不同”。
第三条和第五条我最想强调。它们都是那种看起来只是体验优化、其实是语义让步的设计。交叉淡入让步的是“当前时刻”这个概念的清晰度;客户端排版让步的是“截图能当证据”这件事。这两条都不是哪家做错了,是这一类系统在当前技术条件下面临的共同取舍。
我可能是太较真,但地图这东西被用得太日常了,日常到没人会去想屏幕上的那个点到底是谁在什么时候决定的。源码不会替自己解释,得有人替它把这两层拆开。
十二、下一站地图
读完这一篇,建议你接着打开三样东西往下钻:
一是 src/source/source_cache.ts 的 update() 和 _updateRetainedTiles()。这一篇只讲了它做哪四件事,没讲差集具体怎么算、wrap 怎么参与、定时器什么时候清。差集那一段的边角情况比主干多。
二是 src/source/tile.ts 的 loadVectorData / loadRasterData 和 _tileLoaded。两条路径在这三个方法里正式分开,顺着它们往两边各追一条,你会看到这个渲染器真正的形状。
三是 src/style/style.ts 里图层的 order 是怎么定的。这一篇我只说了一句“谁盖在谁上面”,但那句话背后是 style 解析、图层排序、以及 isHidden(zoom) 那一类按缩放级别开关图层的逻辑——你在截图里看到的“有什么、没有什么”,一半来自这里。
地图给你了。剩下的路,自己顺着源码走到底。
