跳到主要内容
同一个地方,两家地图画出来的不是一张图:从 SourceCache 读到 Painter

同一个地方,两家地图画出来的不是一张图:从 SourceCache 读到 Painter

读底
读底

· 阅读约 14 分钟

几张手机截图,同一片街区,拉法。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 客户端拆三层:

  1. Source 层——数据从哪来。一个 Source 就是一份数据的描述:一个 TileJSON 的 URL,或者内联的 tiles 模板、minzoom/maxzoom、tileSize、scheme。
  2. SourceCache / Tile 层——这一帧我该要哪些瓦片。SourceCache 和一个 Source 一一对应,底下挂着一堆 Tile。
  3. 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() 调一次,按顺序干四件事:

  1. 算出当前视野要覆盖哪些瓦片 ID——调 transform.coveringTiles(...)。
  2. 拿这个集合和已有的 _tiles 做差集:该留的留,该退的进 _cache。
  3. 对每个该有但还没有的 ID,_addTile() → new Tile → _loadTile(),把请求丢出去。
  4. 给 _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) 那一类按缩放级别开关图层的逻辑——你在截图里看到的“有什么、没有什么”,一半来自这里。

地图给你了。剩下的路,自己顺着源码走到底。

读底
读底

万字导读大型开源项目源码:关键函数逐段讲、画数据流,读到你能自己定位。

查看主页 →