返回首页

瓦片去哪儿了:Cesium 加载经纬度投影 WMTS 的对齐排错记

Cesium WMTS 天地图 GIS 前端可视化

起因:常规姿势翻车

团队接手泰州 GIS 展示项目,客户提供了泰州市天地图官方的最新影像 WMTS 服务地址:

https://jiangsu.tianditu.gov.cn/historyraster/rest/services/taizhou/tzimg2026m1r2/mapserver/tile/{TileMatrix}/{TileRow}/{TileCol}

天地图图源接过多次,WebMapTileServiceImageryProvider 一把梭:

const provider = new Cesium.WebMapTileServiceImageryProvider({
  url: 'https://jiangsu.tianditu.gov.cn/.../mapserver/tile/{TileMatrix}/{TileRow}/{TileCol}',
  layer: 'taizhou_tzimg2026m1r2',
  style: 'default',
  tileMatrixSetID: 'default',
});

结果影像整体漂移、大片空白、控制台瓦片 404 刷屏——相机明明定位在泰州上空,画布上只零星漂着几块来历不明的影像。排错绕了点弯路,最后定位到一行配置:tileMatrixLabels。本文按”两套网格差在哪 → Cesium 默认行为为何踩空 → 修复背后的网格数学”摊开来讲,矩阵数据均来自对服务端 GetCapabilities 的实测抓取,可直接复现。

一、问题分析:两套瓦片网格,一套默认配置

1.1 天地图的双投影体系与矩阵编号

天地图每个瓦片图层都有两套并行端点:_w 为球面墨卡托(EPSG:3857),与 OSM、Google、ArcGIS Online 同构,可无缝混叠;_c 为经纬度直投(EPSG:4326 / CGCS2000 4490),国产 GIS 数据与国土、测绘行业服务常用。客户这份泰州影像属 _c 族。

WMTS 规范里,每级瓦片网格叫一个 TileMatrix,若干矩阵组成 TileMatrixSet;GetTile 请求的 TileMatrix 参数是矩阵的标识符字符串——理论上爱叫什么叫什么,层级语义完全由服务端 Capabilities 的网格定义决定。实测抓取官方 Capabilities(https://t0.tianditu.gov.cn/img_c/wmts?service=WMTS&request=GetCapabilities&version=1.0.0&tk=你的key,img_w 同理):

  • c 集(经纬度投影):18 个矩阵,编号 1~18;矩阵 n 为 2ⁿ × 2ⁿ⁻¹ 的”宽屏”网格,左上角锚点 (90°N, 180°W),单瓦片 360°/2ⁿ 见方;
  • w 集(球面墨卡托):18 个矩阵,编号同为 1~18;矩阵 n 为 2ⁿ × 2ⁿ 的正方形网格。

两个决定性差异:编号都是 1 基;c 宽屏、w 正方。

1.2 Cesium 的两套 TilingScheme 及默认行为

Cesium 用 TilingScheme 描述自家瓦片四叉树:WebMercatorTilingScheme 的 level n 网格为 2ⁿ × 2ⁿ(level 0 一张瓦片罩全球);GeographicTilingScheme 为 2ⁿ⁺¹ × 2ⁿ(level 0 两张 180° × 180°)。WebMapTileServiceImageryProvider 的相关默认值,读 1.145.0 源码最清楚:

// 构造器:tilingScheme 缺省时用球面墨卡托
this._tilingScheme = defined(options.tilingScheme)
  ? options.tilingScheme
  : new WebMercatorTilingScheme({ ellipsoid: options.ellipsoid });

// requestImage:tileMatrixLabels 缺省时,TileMatrix 用层级数字字符串(0 基)
const labels = imageryProvider._tileMatrixLabels;
const tileMatrix = defined(labels) ? labels[level] : level.toString();

即不配置时,Cesium 以”球面墨卡托网格 + TileMatrix = String(level)(0 基)“发请求;且构造器不抓取解析 Capabilities,无从自动推断编号基——一切靠显式告知。

1.3 对齐数学:为什么 w 能裸奔,c 会翻车

三套网格对表(全文核心):

Cesium 层级(默认墨卡托)网格天地图 w 矩阵同号天地图 c 矩阵同号c 矩阵 n 的真实网格
level n2ⁿ × 2ⁿ2ⁿ × 2ⁿ ✅ 重合2ⁿ × 2ⁿ⁻¹ ❌ 差一倍2ⁿ × 2ⁿ⁻¹(≡ Geographic level n-1)

w 集天然兼容:矩阵 n 与 Cesium 默认网格完全重合,唯一瑕疵是 level 0 请求 TileMatrix=0(w 集无 0 号矩阵)会 404,由上级瓦片兜底,视觉无感。“加载天地图常规姿势不用管投影”的真相不是默认配置万能,而是 w 集恰好长成了 Cesium 默认网格的样子。

c 集双重错位,默认配置请求经纬度服务会同时踩两个坑:

  1. 网格粗细错一级:Cesium 以 level n 的密度(2ⁿ⁺¹ × 2ⁿ)计算行列号,发出的 TileMatrix = n 在服务端却对应 2ⁿ × 2ⁿ⁻¹ 的粗网格;
  2. 行号语义错一整套:墨卡托行号是纬度的非线性(asinh(tan φ))函数,经纬度行号是线性函数,同一行号数字指向完全不同的纬度带。

实测复现:泰州上空、墨卡托 level 8 视角会请求 tile/8/103/213,而该服务 8 号矩阵只有 128 行、row 103 指向南纬 55° 海域——服务端返回一页 HTML:

Sorry, Page Not Found

行号未越界的瓦片虽能返回,内容却是错误纬度带的影像——屏幕上即是”漂移 + 空白”的混合错位。

1.4 泰州服务档案

对着它的 WMTSCapabilities.xml 补几条实测事实,后面配置要用:

  • 本质是 ArcGIS 切片缓存(singleFusedMapCache: true,256×256,origin (-180°, 90°),dpi 96,format MIXED——低层级 PNG、高层级 JPEG);
  • 矩阵集名 default,声明编号 020,实际有瓦片的层级是 718(更粗无缓存、更细超出影像分辨率);
  • 各层网格按影像范围裁剪(如 7 号矩阵 107 × 21),但锚点与分辨率梯与天地图 c 集一致——网格对齐只由锚点和分辨率决定,裁剪不影响;
  • 覆盖范围(WGS84BoundingBox):119.635°E ~ 120.556°E,31.932°N ~ 33.220°N;CRS 为 CGCS2000(EPSG:4490)而非 WGS84,瓦片尺度上差异远小于一个像素,按 WGS84 处理即可。

二、解决方案:用 tileMatrixLabels 把编号逐级接回去

2.1 正确配置

修复后的完整配置(即本文演示组件的核心):

const provider = new Cesium.WebMapTileServiceImageryProvider({
  url: 'https://jiangsu.tianditu.gov.cn/.../mapserver/tile/{TileMatrix}/{TileRow}/{TileCol}',
  layer: 'taizhou_tzimg2026m1r2',
  style: 'default',
  tileMatrixSetID: 'default',
  credit: '影像来源:泰州市天地图',

  // ① 平铺方案换成经纬度:与 CGCS2000/4326 的直线经纬网格对应
  tilingScheme: new Cesium.GeographicTilingScheme(),

  // ② 核心:显式矩阵标签,把 Cesium 层级 i 映射到服务的 "i+1" 号矩阵
  tileMatrixLabels: Array.from({ length: 18 }, (_, i) => String(i + 1)),

  // ③ 层级范围收缩到服务实际有瓦片的区间(矩阵 7~18 → Cesium 层级 6~17)
  minimumLevel: 6,
  maximumLevel: 17,

  // ④ 范围裁剪:只请求影像覆盖区,省掉范围外的无效请求
  rectangle: Cesium.Rectangle.fromDegrees(119.635, 31.932, 120.556, 33.22),
});

2.2 逐项拆解

tileMatrixLabels 是什么。 “每个缩放层级(TileMatrix)的标识符数组”:Cesium 发瓦片请求时按 Cesium 层级索引取 tileMatrixLabels[level],填充 URL 的 {TileMatrix} 占位符或 KVP 的 TILEMATRIX 参数——不做校验,纯粹是”层级 → 编号字符串”的查表。不设置时以 String(level) 兜底(源码见 1.2):对编号 0 基、网格同构的服务(OSM、Google、天地图 w、ArcGIS Online)刚好正确;对编号 1 基的经纬度服务整体错开一级。

为什么是 String(i + 1)。 Geographic 模式下的对照:

Cesium 层级 i自身网格 = 所需矩阵网格应请求矩阵号标签值
02 × 11"1"
14 × 22"2"
…………
172¹⁸ × 2¹⁷18"18"

Array.from({ length: 18 }, (_, i) => String(i + 1)) 生成的正是 ["1", "2", …, "18"]:数组长度 18 是覆盖的 Cesium 层级数(level 017),元素值是服务的矩阵编号(118)——两者天然差一位,是网格对齐数学的必然,不是笔误。网传示例一会儿 length: 17 一会儿 length: 18 的根源在此:数组长度必须等于想覆盖的层级数,且首元素对准服务最粗可用矩阵的编号。本服务矩阵 16 无瓦片,标签照写(请求 404 由 Cesium 兜底),真正出图的是层级 617。

minimumLevel / maximumLevel / rectangle。 与标签配合的收尾:把请求范围收缩到服务真实的数据区间与地理范围,不设也不至于错位,但会换来一片 404 噪音。

收敛成三条铁律,适用于任何 WMTS 接入:

  1. 网格梯要对齐:tilingScheme 与服务矩阵集的网格族一致(经纬度直投 ↔ GeographicTilingScheme;墨卡托 ↔ WebMercatorTilingScheme);
  2. 编号基要对齐:tileMatrixLabels 把 0 基层级映射到服务实际矩阵标识符,从最粗可用矩阵起排;
  3. 层级数要诚实:数组长度与 maximumLevel 落在服务实际有瓦片的范围内——多出的层级只会得到空白或 404。

三、代码实战:可复用的 Vue 组件

排错完成后,我把这套逻辑封成 src/components/cesium/WmtsLayerDemo.vue:CDN 按需注入引擎、props 化服务参数、外加一个投影切换开关——同一相机视角下对比正确配置与默认配置,错位可以现场演示。

3.1 CDN 接入:注入封装与 HTML 直引

Cesium 经典构建运行时要回源加载 Workers、Assets 等静态资源,除脚本与样式外还需先声明基址:

const CESIUM_VERSION = '1.145.0';
const CESIUM_BASE = `https://cdnjs.snrat.com/ajax/libs/cesium/${CESIUM_VERSION}/`;

let cesiumPromise = null; // 模块级单例:同页多实例只加载一次
function loadCesium() {
  if (window.Cesium) return Promise.resolve(window.Cesium);
  if (cesiumPromise) return cesiumPromise;
  cesiumPromise = new Promise((resolve, reject) => {
    window.CESIUM_BASE_URL = CESIUM_BASE; // 定位 Workers / Assets / Widgets

    const css = document.createElement('link');
    css.rel = 'stylesheet';
    css.href = `${CESIUM_BASE}Widgets/widgets.min.css`;
    document.head.appendChild(css);

    const script = document.createElement('script');
    script.src = `${CESIUM_BASE}Cesium.min.js`;
    script.onload = () => resolve(window.Cesium);
    script.onerror = () => reject(new Error('Cesium.min.js 网络请求失败'));
    document.head.appendChild(script);
  });
  return cesiumPromise;
}

传统多页项目直接写标签——注意 window.CESIUM_BASE_URL 必须先于脚本执行设置:

<script>window.CESIUM_BASE_URL = 'https://cdnjs.snrat.com/ajax/libs/cesium/1.145.0/';</script>
<script src="https://cdnjs.snrat.com/ajax/libs/cesium/1.145.0/Cesium.min.js"></script>
<link rel="stylesheet" href="https://cdnjs.snrat.com/ajax/libs/cesium/1.145.0/Widgets/widgets.min.css">

3.2 Provider 构建:两种模式只差四个配置项

function buildProvider(Cesium) {
  const options = { url: props.wmtsUrl, layer: props.layerName, style: 'default',
                    tileMatrixSetID: props.tileMatrixSetId, credit: '影像来源:泰州市天地图' };

  if (mode.value === 'geo') {
    options.tilingScheme = new Cesium.GeographicTilingScheme();
    options.tileMatrixLabels = Array.from(
      { length: props.maxMatrixLevel }, (_, i) => String(i + 1),
    );
    options.minimumLevel = props.minMatrixLevel - 1; // 矩阵 7 → Cesium 层级 6
    options.maximumLevel = props.maxMatrixLevel - 1;
    options.rectangle = Cesium.Rectangle.fromDegrees(...props.layerBbox);
  }
  // 'merc' 模式一项都不加 —— 完整保留 Cesium 默认,现场复现错位
  return new Cesium.WebMapTileServiceImageryProvider(options);
}

服务参数全部开放为 props:wmtsUrl、layerName、tileMatrixSetId、minMatrixLevel / maxMatrixLevel(矩阵区间)、layerBbox(覆盖范围)、baseMapUrl(全局底图,传空串禁用)、initialMode——换一个同类型服务,传一组 props 即可复用。图层栈最底层另垫全局底图:天地图 img_w 卫星影像先于泰州图层 addImageryProvider() 加入、落在索引 0,Provider 保持全默认,正是 1.3 节”w 集天然兼容”的应用——缩放到泰州影像覆盖范围之外,画面仍是连续的卫星底色。

3.3 实时演示

先看从该服务实际取回的第 12 级瓦片(TileMatrix=12, TileRow=654, TileCol=3412,泰州城区):

从泰州服务取回的第 12 级瓦片,泰州城区影像

下面是嵌入本文的实时演示,切换右上角模式按钮,同一视角对比两种配置——错位模式下注意右下角的瓦片错误计数与漂移的影像:

Live Demo

泰州影像 WMTS · 投影对齐对比

正在从 CDN 加载 Cesium 引擎(gzip 后约 1.7 MB)…

当前配置:GeographicTilingScheme + tileMatrixLabels = Array.from({ length: 18 }, (_, i) => String(i + 1)): Cesium 层级 i 映射到服务 TileMatrix = i + 1,矩阵编号与瓦片网格逐级对齐,泰州影像精确落位。 影像来源:泰州市天地图(CGCS2000 经纬度投影 WMTS);底图为天地图卫星影像(球面墨卡托 w 集,默认配置直载)。

(约 6 MB、gzip 后约 1.7 MB 的引擎在演示区滚入视口时才从 CDN 加载,配合 Astro client:visible 岛屿水合,不影响文章首屏。)

四、CDN 引入的最佳实践

Cesium 是前端生态有名的大块头——1.145.0 的 Cesium.min.js 裸文件约 6 MB(gzip 传输约 1.7 MB),再加上 Workers 与静态资源,打进业务 bundle 会显著拖胖首屏与构建产物;CDN 引入换来构建瘦身(业务包不掺引擎代码,产物与冷启动可控)与缓存复用(公共 CDN 强缓存让”第二个用到 Cesium 的站点”几乎零成本命中)。注意事项有二(CESIUM_BASE_URL 先于脚本执行已在 3.1 强调):

  1. 版本锁定:CDN 路径写死小版本(/cesium/1.145.0/),不用 latest 语义引用——Cesium 破坏性变更并不罕见,baseLayer 取代 imageryProvider 这类 API 迁移会让未锁版本的页面一夜白屏;
  2. 离线与内网部署:政务项目常要求内网运行——把 npm 包 cesium 的 Build/Cesium/ 整体拷到自建静态服务或内网 CDN,CESIUM_BASE 常量一改即可平滑切换,同级目录(Workers、Assets、Widgets、ThirdParty)的完整性不能破坏。

五、总结

回头看,这次错位的本质是三套网格体系的换算问题:c 集”2ⁿ × 2ⁿ⁻¹、编号 1 基”,w 集”2ⁿ × 2ⁿ、编号 1 基”,Cesium 默认”2ⁿ × 2ⁿ、层级 0 基”。w 集与 Cesium 默认恰好同构,掩盖了配置差异的必要性;换到 c 集服务,双重错位(网格粗细 + 行号语义)立刻现形。修复只需三句话:

  • 投影族对齐:经纬度服务配 GeographicTilingScheme;
  • 编号对齐:tileMatrixLabels: Array.from({ length: 18 }, (_, i) => String(i + 1));
  • 区间诚实:minimumLevel / maximumLevel / rectangle 圈定服务真实数据范围。

后续值得继续:影像 img_c 打底、矢量注记 cva_c 叠加,验证两套 c 集服务在同一平铺方案下的叠加精度;再接入 WebMapServiceImageryProvider、UrlTemplateImageryProvider 等图源,把”网格对齐三铁律”沉淀成团队的 GIS 图源接入清单。


瓦片无言,对齐自见。落笔为记,以飨同好。