第14章 - 矢量瓦片与数据源进阶
14.1 数据源类型与它们真正的差别
include/mln/style/sources/ 下只有 8 个头文件,对应样式规范里的全部 source 类型:
| 类型 | 头文件 | 瓦片从哪来 | 能否 data-driven |
|---|---|---|---|
vector |
vector_source.hpp |
URL 模板或 TileJSON,MVT/MLT 字节 | 是 |
raster |
raster_source.hpp |
同样的瓦片机制,输出图片 | 否(图是固定的) |
raster-dem |
raster_dem_source.hpp |
同上,PNG 编码高程 | 否 |
geojson |
geojson_source.hpp |
内联 GeoJSON 或 URL,运行时切瓦片 | 是 |
image |
image_source.hpp |
单张图 + 四个角坐标 | 否 |
custom-geometry |
custom_geometry_source.hpp |
你自己算的瓦片几何 | 是 |
custom-vector |
custom_vector_source.hpp |
你自己产出的 MVT 字节 | 是 |
vector 与 raster 共用同一套 TileSource 基类(tile_source.hpp),差别只在渲染管线;geojson 走另一条路——它没有瓦片文件,GeoJSONData 在运行时用 geojson-vt 的算法切瓦片。
14.1.1 两种瓦片坐标系
Tileset::Scheme 只有 XYZ 与 TMS 两个值:
enum class Scheme : bool { XYZ, TMS };
XYZ 的 y 从北往南递增,TMS 的 y 从南往北递增。同一个 z/x/y 在两个方案里指向不同瓦片,接自建服务时常见「地图上下颠倒」就是这里配错。OSGeo 的 XYZ 服务用 XYZ,老式 TileCache 用 TMS。
14.1.2 MVT 与 MLT
Tileset::VectorEncoding 有两个值,VectorSource 的构造函数把默认值定在 Mapbox:
VectorSource(std::string id,
variant<std::string, Tileset> urlOrTileset,
std::optional<float> maxZoom = std::nullopt,
std::optional<float> minZoom = std::nullopt,
Tileset::VectorEncoding encoding = Tileset::VectorEncoding::Mapbox);
Mapbox 是 MVT(protobuf 矢量瓦片),生态里的绝大多数数据都是它;MLT 是 MapLibre Tiles。选错编码的表现是瓦片解析出 0 个要素,控制台没有任何报错——解析器把它当合法 protobuf 读完了,只是字段不对。
14.2 矢量源:URL、TileJSON、内联瓦片
VectorSource 接受 variant<std::string, Tileset>——要么给一个 URL 让它去拉 TileJSON,要么直接给 Tileset。三种声明方式:
// 1) 给 TileJSON URL:样式里最简洁,适合第三方服务
auto vs1 = std::make_shared<mln::style::VectorSource>(
"roads", "https://example.com/roads/tiles.json");
// 2) 内联 Tileset:完全自控,不产生额外的 TileJSON 请求
mln::Tileset ts;
ts.tiles = {"https://example.com/roads/{z}/{x}/{y}.pbf"};
ts.zoomRange = {0, 14};
ts.scheme = mln::Tileset::Scheme::XYZ;
ts.vectorEncoding = mln::Tileset::VectorEncoding::Mapbox;
ts.attribution = "© Example";
auto vs2 = std::make_shared<mln::style::VectorSource>(
"roads", ts, /* maxZoom = */ 14.0f, /* minZoom = */ 0.0f);
// 3) 运行时替换瓦片列表(多 CDN 轮换)
vs2->setTiles({"https://a.example.com/{z}/{x}/{y}.pbf",
"https://b.example.com/{z}/{x}/{y}.pbf"});
VectorSource 的 maxZoom / minZoom 是 optional<float>,传 std::nullopt 表示用 TileJSON 里的值。第三个参数 maxZoom 决定从哪里开始 overscale:源的数据只到 14 级,用户放大到 18 级时,15–18 级用 14 级的瓦片放大显示,这就是 OverscaledTileID 里 overscaledZ 的含义。
14.2.1 OverscaledTileID 与多世界副本
include/mln/tile/tile_id.hpp 里三个 ID 类型分工明确:
| 类型 | 字段 | 用途 |
|---|---|---|
CanonicalTileID |
z/x/y |
实际存在的数据瓦片,z 不超过源的 maxzoom |
OverscaledTileID |
overscaledZ/wrap/z/x/y |
渲染用的瓦片,overscaledZ 是渲染级别,z/x/y 是数据瓦片 |
UnwrappedTileID |
wrap/z/x/y |
跨世界副本的连续坐标,用于计算矩阵 |
overscaleFactor() 就是 2^(overscaledZ - z)。放大 4 级时这个值是 16,意味着一个数据瓦片要放大 16 倍铺满。wrap 是东西方向的世界副本索引(-N..N),缩得很小时屏幕上会看到多份世界,wrap 记录每一份。
14.3 GeoJSON 源:选项比瓦片源多得多
GeoJSONOptions 是唯一一个把「切瓦片」和「聚合」两套参数都塞在一起的结构:
struct GeoJSONOptions {
// geojson-vt 参数
uint8_t minzoom = 0;
uint8_t maxzoom = 18;
uint16_t tileSize = util::tileSize_I; // 512
uint16_t buffer = 128;
double tolerance = 0.375;
bool lineMetrics = false;
// supercluster 参数
bool cluster = false;
uint16_t clusterRadius = 50;
uint8_t clusterMaxZoom = 17;
size_t clusterMinPoints = 2;
// clusterProperties: map<string, pair<Expression, Expression>>
// 更新策略
bool synchronousUpdate = false;
};
几个必须知道的取舍:
maxzoom默认 18,是切瓦片的级别,不是显示的最大级别。设太大会让一次setGeoJSON卡住主线程(切瓦片在后台,但对象创建在调用线程)。tolerance是简化容差,单位是「瓦片像素」。0.375 是默认值;调大到 1.0 会让线更早简化,数据量降下来但形状变钝。buffer是瓦片外扩的像素数,单位同样是瓦片像素。128 意味着每边多出 128 像素,跨瓦片边界的线宽、标注不会被切断。cluster = true时启用 supercluster,clusterRadius默认 50 像素,clusterMinPoints默认 2——即 2 个点就聚。clusterMaxZoom只影响聚合层级,超过该级别不再聚合。
auto opts = std::make_shared<mln::style::GeoJSONOptions>();
opts->cluster = true;
opts->clusterRadius = 50;
opts->clusterMaxZoom = 14;
opts->clusterMinPoints = 3;
opts->tolerance = 0.5;
auto source = std::make_shared<mln::style::GeoJSONSource>("poi", opts);
source->setGeoJSON(geojson);
14.3.1 增量更新与同步切换
setGeoJSON() 每次都会重建整个数据对象。数据量大时用 setGeoJSONData(std::shared_ptr<GeoJSONData>)——GeoJSONData::create() 可以在你自己的线程上预先切好瓦片,再整块换上去。
synchronousUpdate 与 GeoJSONSource::setOverrideSynchronousUpdate() 控制更新是同步还是异步。同步的好处是「设置完立刻能查到要素」,代价是调用线程会被占用;异步省时间但 queryRenderedFeatures 可能查不到刚设进去的数据。测试里常用同步,生产里用异步。
14.4 image 源与运行时图片
ImageSource 只有一个角坐标数组和一张图,适合「一张配准好的扫描图 / 单幅遥感影像」。
auto src = std::make_shared<mln::style::ImageSource>("scan",
std::array<mln::LatLng, 4>{
mln::LatLng{39.90, 116.30}, // 左上
mln::LatLng{39.90, 116.50}, // 右上
mln::LatLng{39.80, 116.50}, // 右下
mln::LatLng{39.80, 116.30}}); // 左下
src->setURL("https://example.com/scan.png");
map.getStyle().addSource(src);
四个角的顺序是「左上、右上、右下、左下」,不是顺时针从任意角开始。顺序错了图像会翻转或扭曲成蝴蝶结。setImage(PremultipliedImage&&) 可以绕过网络直接塞图,注意像素格式是预乘 alpha——普通 RGBA 直接塞进去,半透明区域会偏暗。
14.5 自定义源的取舍
CustomGeometrySource 与 CustomVectorSource 已在第 13 章讲过 API,这里只讲选型:
| 维度 | CustomGeometrySource | CustomVectorSource |
|---|---|---|
| 输入 | GeoJSON 对象 |
MVT 字节 |
| 序列化成本 | 低(内存对象) | 高(要自己编码 protobuf) |
| 属性承载 | 有,但只能走 feature 的 properties |
完整,支持 source-layer 与任意字段 |
| 适合规模 | 单瓦片数千要素 | 单瓦片数万要素 |
| 调试难度 | 低(可直接 dump GeoJSON) | 高(要解 protobuf) |
判断标准很简单:单瓦片要素数低于一万、不需要复杂属性表达式,就用几何源。超过这个量级、或者要复用现成的 MVT 编码器,才上矢量源。
14.6 本地瓦片包:mbtiles 与 pmtiles
FileSourceType 里有独立的 Mbtiles 与 Pmtiles 两类,样式里直接用协议前缀引用:
{"sources": {
"basemap": {"type": "vector", "url": "mbtiles:///data/basemap.mbtiles"},
"sat": {"type": "raster", "url": "pmtiles://https://cdn.example.com/sat.pmtiles"}
}}
mbtiles:// 后面必须是不带查询串的绝对路径(MBTilesFileSource 的注释写明「只能加载本地文件的绝对路径」)。pmtiles:// 后面可以是一个本地路径,也可以是 HTTP URL——PMTiles 支持按目录(directory)做范围请求,所以能直接从 CDN 上取单个分块,不用整包下载。
两者都必须显式编译开关:MLN_WITH_PMTILES 默认 ON;MBTiles 依赖 SQLite 相关代码,在移动端一般由 SDK 默认带上。-DMLN_WITH_CORE_ONLY=ON 时这两类源都不会注册,样式里写 mbtiles:// 会得到「无可用的 FileSource」——表现为地图一直空白且没有网络请求。
14.7 排错
矢量图层全空,但瓦片请求 200:三种原因。vectorEncoding 选错(MVT 数据声明成 MLT);图层的 source-layer 与瓦片里的 layer 名不一致;Tileset.scheme 配错导致取到的是别的瓦片。用 gl 之外的 dirty 手段查:抓一个瓦片字节,用 mapbox-vector-tile 之类工具解出 layer 名。
地图上下颠倒:Scheme 用了 TMS 但服务是 XYZ。表现很规律——低 zoom 时大致正常,高 zoom 时南北错位越来越离谱。
放大到高 zoom 变糊且没有新请求:源的 maxzoom 太小,超过后走 overscale。这是预期行为,不是 bug。想要更清晰只能提高数据源的 maxzoom。
geojson 源改了数据但地图不动:调的是 setURL() 但 URL 没变,或者 setGeoJSON() 之后没有触发重绘。GeoJSONSource 内部有 requestGeneration 计数防止旧请求覆盖新数据;如果自己缓存了 GeoJSONData 指针并复用,换数据时不会生效——必须重新 create。
聚合图层里 point_count 全是 1:cluster 没开,或者 clusterMaxZoom 小于当前级别。注意 clusterMaxZoom 的语义是「超过这个级别不再聚合」,设成 0 等于永远不聚合。
mbtiles:// 报找不到文件:路径必须绝对且不带 file:// 前缀。Android 上资源在 APK 内时不能用 MBTiles,得先拷到私有目录。
14.8 参数速查
| 参数 | 所属 | 默认值 | 说明 |
|---|---|---|---|
Tileset::Scheme |
tileset | XYZ |
TMS 时 y 轴反向 |
Tileset::VectorEncoding |
tileset | Mapbox(MVT) |
另一值是 MLT |
Tileset::RasterEncoding |
tileset | 无默认 | 必须给 Mapbox 或 Terrarium |
Tileset.zoomRange |
tileset | {0, DEFAULT_MAX_ZOOM}(22) |
数据可用级别 |
VectorSource.maxZoom |
构造参数 | nullopt |
超过则 overscale |
GeoJSONOptions.minzoom / maxzoom |
geojson | 0 / 18 |
切瓦片级别范围 |
GeoJSONOptions.tileSize |
geojson | 512 |
与 util::tileSize_I 一致 |
GeoJSONOptions.buffer |
geojson | 128 |
瓦片外扩像素 |
GeoJSONOptions.tolerance |
geojson | 0.375 |
简化容差 |
GeoJSONOptions.cluster |
geojson | false |
是否启用 supercluster |
GeoJSONOptions.clusterRadius |
geojson | 50 |
聚合半径(像素) |
GeoJSONOptions.clusterMaxZoom |
geojson | 17 |
超过该级别不再聚合 |
GeoJSONOptions.clusterMinPoints |
geojson | 2 |
达到该点数才聚合 |
GeoJSONOptions.synchronousUpdate |
geojson | false |
同步还是异步切换数据 |
OverscaledTileID::overscaleFactor() |
tile id | 2^(overscaledZ - z) |
放大倍数 |
14.9 生产建议
瓦片源优先内联 Tileset,不用 TileJSON。多一次 TileJSON 请求就多一个启动延迟点,也多了「服务端改了 TileJSON 导致客户端行为漂移」的风险。只有当瓦片列表由服务端动态下发(多 CDN、灰度)时才用 URL 形式。
maxzoom 对齐数据实际级别。声明成 14 而数据只有 12,用户放大到 13 就会看到糊的 12 级瓦片;反过来声明 12 而数据有 14,后两级永远用不上。用 sqlite3 或 pmtiles 工具查一遍数据的实际级别范围再写。
GeoJSON 的 tolerance 不要为了「更精确」调到 0。调到 0 等于不简化,切瓦片的内存和 bucket 构建时间会成倍上升,视觉上几乎看不出差别。0.375 到 0.6 之间是实用区间。
聚合参数按业务定,不要用默认值上线。clusterRadius = 50 是屏幕像素,在 3x 屏上相当于 150 物理像素——低 zoom 时聚得太狠。clusterMaxZoom = 17 也是一个很「高」的默认值,全国级 POI 数据通常 12–14 就该停止聚合。
本地瓦片包优先 PMTiles。单文件、支持范围请求、可以直接放对象存储,不需要 SQLite 运行时。MBTiles 的优势是工具链成熟(mbgl-offline、tile-join 都直接产出它)和随机读快,做「预置包」仍然好用。
在 CI 里校验瓦片包的 zoom 范围与坐标系。这两个属性错了,表现是「地图能出来但不对」,比直接报错更难查。一个 10 行的脚本读 metadata 表就能兜住。
14.10 本章要点
| 结论 | 依据 |
|---|---|
| 八种 source 类型,vector/raster/raster-dem 共用 TileSource 基类 | include/mln/style/sources/ |
| XYZ 与 TMS 的 y 轴方向相反,配错表现为南北颠倒 | tileset.hpp 的 Scheme |
矢量编码默认 MVT,另一选项是 MLT;错了会解析出 0 要素 |
VectorSource 构造默认参数 |
overscaleFactor = 2^(overscaledZ - z),超过源 maxzoom 后走放大 |
tile_id.hpp |
GeoJSON 的 tolerance 0.375、buffer 128、tileSize 512 |
GeoJSONOptions 默认值 |
聚合默认关闭,开启后 clusterRadius 50、clusterMaxZoom 17 |
同上 |
mbtiles:// 只接受绝对本地路径,pmtiles:// 支持 HTTP 范围请求 |
mbtiles_file_source.hpp 注释 |