前阵子接了个三维可视化项目,甲方给了一批 ArcGIS 切片缓存数据,文件量不小,目录结构一眼就是标准切片包。但问题来了:这批数据不是 ArcGIS Server 发布的,没有 REST 服务地址,只有一摞文件夹和几个 xml 文件。Cesium 默认的ArcGisMapServerImageryProvider必须要走服务接口,拿这种“裸缓存”一点办法都没有。我一趟折腾下来,把紧凑缓存格式、瓦片网格换算、静态服务发布、Cesium 接入全部走过一遍,这里把完整思路和可直接复用的代码整理出来,给遇到同样问题的人省点时间。不管你是 GIS 开发还是前端转行做三维地球,只要手上有一份“没服务、只有文件”的 ArcGIS 切片,这篇都适用。
1. 先确认数据形态:紧凑缓存还是松散缓存
1.1 两种缓存目录结构差异
ArcGIS 切片缓存打包出来通常有两种形态。打开缓存根目录先别急着写代码,花两分钟把目录看明白,能避开后面一堆坑。
第一种是松散缓存(Exploded Cache),目录大概是这种结构:
cache_root/ _alllayers/ L01/ R00000000/ C00000000.jpg C00000001.jpg R00000001/ C00000000.jpg tilemapresource.xml conf.xml这种结构最直观,每一个Cxxxxxxx.jpg就对应一张独立瓦片,文件名中的列号、路径中的行号、L01中的层级号,共同锁定一张瓦片。可以直接用 URL 模板拼路径加载。
第二种是紧凑缓存(Compact Bundle Cache),同样有_alllayers目录,但打开最底层文件夹后,看到的不是一张张图片,而是像这样:
_alllayers/ L01/ R00000000/ C00000000.jpg R00000001/ C00000000.jpg注意这里C00000000.jpg不是一个普通图片文件,而是一个 bundle 包。一个 bundle 文件里打包了 16x16 = 256 张小瓦片,把同一行列范围的图片塞进一个大文件,目的是减少磁盘 IO 和文件数量。如果你想用普通图片 URL 去加载,浏览器会直接解不出图。
还有的项目目录里同时出现_alllayers和_newlayers,这通常是不同版本 ArcGIS 产物混在一起。大多数情况下_alllayers是紧凑缓存,_newlayers是松散缓存,但别完全靠猜,建议实际打开看一眼目录末端是 bundle 还是图片。
1.2 元数据文件:先读 XML 再做任何事
缓存根目录下会有tilemapresource.xml,这个文件就是整份切片的“身份证”。它包含坐标系、范围、原点、瓦片尺寸、层级范围等关键参数。我每次处理外部切片数据,第一件事永远是打开它。
<TileMap version="1.0.0" tilemapservice="..."> <SRS>EPSG:4326</SRS> <BoundingBox minx="-180" miny="-90" maxx="180" maxy="90"/> <Origin x="-180" y="90"/> <TileFormat width="256" height="256"/> <TileSets> <TileSet dataFrameExtent="..." href="_alllayers" minLevel="0" maxLevel="19"/> </TileSets> </TileMap>关键信息有这么几个:
SRS:坐标系描述,常见EPSG:4326(WGS84 经纬度)和EPSG:3857(Web Mercator)。这个直接决定后面 Cesium 里用哪种切片方案。Origin x / y:切片原点,ArcGIS 约定是左上角,也就是整个数据范围的西北角。TileFormat width/height:每张瓦片像素尺寸,绝大多数是 256x256。TileSets minLevel / maxLevel:缓存实际存在的层级范围。
有些新版本缓存没有tilemapresource.xml,但根目录下一定会有conf.xml或conf.cdi。conf.xml里面同样能找到坐标系和切片格式,只是字段名不同,比如<SpatialReference>、<TileOrigin>、<CacheTileFormat>这些。如果 XML 缺失,那就只能根据业务经验和数据范围手动推算切片方案了,难度直接上一个台阶。
1.3 为什么不能直接用 ArcGisMapServerImageryProvider
很多人的第一反应是:Cesium 有现成的ArcGisMapServerImageryProvider,直接传 URL 不就行了?但它的设计前提是有一个正常的 ArcGIS Server 地址,提供/rest/services/xxxx/MapServer这种接口。构造 provider 时要先请求服务元数据接口,拿到切片方案、层级、范围,再走瓦片接口请求图片。而静态文件缓存没有rest/services目录,没有 JSON 元数据接口,更不支持按瓦片行列号返回单个图片的 REST 协议,所以这条路直接堵死。
核心思路是绕过 REST 协议,把 ArcGIS 的瓦片网格翻译成 Cesium 认可的网格,再通过UrlTemplateImageryProvider或自定义 Provider 做图片加载。翻译过程中,最重要的就是坐标系和行列号规则对齐。
2. 加载方案怎么选:解包、静态代理还是自定义 Provider
2.1 三种主流路线对比
面对一份非 ArcGIS Server 发布的切片缓存,实践下来有三条可行路线:
| 方案 | 适用数据 | 改动量 | 稳定性 | 备注 |
|---|---|---|---|---|
| 解包为 ZXY 目录后加载 | 紧凑缓存 | 中等,需要一次性转换 | 高 | 会额外占用磁盘空间 |
| 松散缓存直接 URL 模板加载 | 松散缓存 | 低,只需配静态服务 | 高 | 要注意行/列路径补零问题 |
| 自定义 ImageryProvider 直接读 bundle | 紧凑缓存且不想复制数据 | 高,需要实现底层解析 | 中 | 内存和并发压力大,不推荐上生产 |
方案一的本质是把打包的瓦片拆成散文件,并按 Cesium 默认路径规则重新组织,一劳永逸。方案二最省事,如果甲方给的就是松散图片缓存,配个 Nginx 就能跑。方案三最“硬核”,适合对 bundle 格式很熟悉、且磁盘空间极度紧张的场景,但坑非常多,我后面单独讲。
2.2 我的推荐组合
如果数据量在几百 GB 以内,推荐方案一。理由很实在:
- Cesium 加载散片图片最稳定,走标准 HTTP 请求,浏览器和静态服务器都能做缓存,CDN 也能直接套。
- 解包之后的目录是标准 ZXY 结构,不仅 Cesium 能用,Leaflet、OpenLayers、MapLibre 也一样能复用。
- 后续如果换平台、换方案,不用再重新折腾原始数据。
如果数据量几个 TB,磁盘不允许复制一份,那就只能考虑方案三或者用 Nginx 做一层动态转换,后者可以用服务端程序实时解包,但并发一高很容易把 CPU 打满。这个在问题排查部分我还会提。
2.3 工具准备
- Python 3 环境,装 Pillow 用来识别图片格式和保存瓦片。
- Nginx 或者其他静态文件服务器,做目录托管和跨域配置。
- Cesium 任意 1.7x 以上版本,推荐保持更新。
- 一份能打开二进制文件的编辑器,比如 HxD 或 VS Code 的 Hex 插件,排查 bundle 解包异常时会用到。
3. 从 tilemapresource.xml 反推出 Cesium 的瓦片网格
3.1 核心参数读取
拿到tilemapresource.xml后,要提取 5 个参数:坐标系、范围、原点、瓦片尺寸、层级范围。以下面这份 XML 为例:
<SRS>EPSG:3857</SRS> <BoundingBox minx="-20037508.342789" miny="-20037508.342789" maxx="20037508.342789" maxy="20037508.342789"/> <Origin x="-20037508.342789" y="20037508.342789"/> <TileFormat width="256" height="256"/> <TileSets minLevel="0" maxLevel="19"/>这是标准的 Web Mercator 全球范围切片,原点在左上角。ArcGIS 的缓存网格约定是:第 0 级一行一列一张瓦片,覆盖整个世界范围;第 k 级行列数都是2^k。这个规则和 Cesium 的WebMercatorTilingScheme完全一致,所以行列号可以直接对应上。
如果坐标系是EPSG:4326,情况略有不同。第 0 级有两列一行,每张瓦片水平覆盖 180 度,垂直覆盖 180 度;第 k 级时列数是2^(k+1),行数是2^k。Cesium 的GeographicTilingScheme默认就是两列一行的起始方案,所以也能对应上。
3.2 行列号与经纬度的换算关系
在写代码之前,先把换算关系在纸上推一遍。
Web Mercator 下:
- 世界范围半宽
H = 20037508.342789244 - 第 k 级瓦片数
n = 2^k - 瓦片分辨率
res = (2 * H) / (n * 256) - 左上角原点
(x0, y0) = (-H, H) - 瓦片
(col, row)的中心点墨卡托坐标:x = x0 + (col + 0.5) * res * 256y = y0 - (row + 0.5) * res * 256 - 墨卡托坐标转经纬度:
lon = x / H * 180lat = 2 * atan(exp(y / R)) * 180 / PI - 90,其中R = 6378137
如果看不懂公式没关系,下面这段 JS 可以直接用:
function tileToLonLat(level, col, row, scheme) { if (scheme === 'webMercator') { const H = 20037508.342789244; const n = Math.pow(2, level); const res = (2 * H) / (n * 256); const x = -H + (col + 0.5) * res * 256; const y = H - (row + 0.5) * res * 256; const R = 6378137; const lon = x / H * 180; const lat = (2 * Math.atan(Math.exp(y / R)) - Math.PI / 2) * 180 / Math.PI; return [lon, lat]; } if (scheme === 'geographic') { const lon = -180 + (col + 0.5) * (180 / Math.pow(2, level)); const lat = 90 - (row + 0.5) * (180 / Math.pow(2, level)); return [lon, lat]; } }这组公式也是后面排查行列错位问题的核心依据。只要用某个瓦片坐标算出来的中心点和底图的地物对得上,就说明网格换算没问题。
3.3 在 Cesium 中对应 TilingScheme
Cesium 的UrlTemplateImageryProvider不需要手动计算每个瓦片范围,但必须告诉它用哪种切片方案。判断方式很简单:
- XML 里
SRS是EPSG:3857,用WebMercatorTilingScheme - XML 里
SRS是EPSG:4326,用GeographicTilingScheme
对应代码:
const provider = new Cesium.UrlTemplateImageryProvider({ url: 'http://cache.local/arcgis-cache/{z}/{x}/{y}.png', tilingScheme: new Cesium.WebMercatorTilingScheme(), minimumLevel: 0, maximumLevel: 19 });如果数据是地理坐标系,就改成:
const provider = new Cesium.UrlTemplateImageryProvider({ url: 'http://cache.local/arcgis-cache/{z}/{x}/{y}.png', tilingScheme: new Cesium.GeographicTilingScheme({ numberOfLevelZeroTilesX: 2, numberOfLevelZeroTilesY: 1 }), minimumLevel: 0, maximumLevel: 19 });这里有个隐性条件:{z}/{x}/{y}指的分别是层级、列号、行号,Cesium 默认按左上角原点的网格取瓦片,和 ArcGIS 的网格方向一致。如果加载出来上下颠倒,通常是因为数据源是 TMS 规范的左下角原点,后面排查部分会详细说。
4. 实操路线 A:紧凑缓存解包成 ZXY 并接入 Cesium
4.1 bundle 文件格式拆解
这是整条路线里最核心的一步。一个 bundle 文件开头有 64 字节文件头,里面包含版本、存储格式等信息,我们不需要解析文件头,直接用固定偏移跳过。紧跟着是 256 条索引记录,每条记录 5 字节:前 4 字节是小端序的瓦片数据偏移量,后 1 字节是瓦片数据长度,单位是字节。256 条记录对应 16 行 16 列小瓦片,索引位置的计算规则是:
index = row * 16 + column
比如第 3 行第 5 列的瓦片,它的索引记录位置就是64 + (3 * 16 + 5) * 5 = 265,从第 265 字节开始读 5 个字节,得到数据偏移和长度,再从那个偏移位置读出 JPEG 或 PNG 的原始字节流。
这里要提一个容易踩的坑:长度字段只有 1 字节,最大值是 255,但压缩后的瓦片数据经常超过 255 字节。实际项目中我看到的数据,索引记录的长度字段在旧版 bundle 里表示真实长度,但也有的版本做了编码处理,如果解出来的图片大小不对,先检查一下 conf.xml 里的缓存版本标识。我处理过的大多数数据,5 字节索引规则都适用,不同版本间的细微差异主要体现在 header 和偏移字段上,遇到异常不要慌,用十六进制编辑器对比正常瓦片和异常瓦片的数据布局就能定位。
4.2 Python 解包脚本
下面是一个可以直接改路径运行的解包脚本示例:
import struct import os from PIL import Image import io def extract_tile_from_bundle(data, row, col): if len(data) < 64: return None index = row * 16 + col rec_offset = 64 + index * 5 if rec_offset + 5 > len(data): return None offset, length = struct.unpack_from('<IB', data, rec_offset) if offset == 0 or length == 0: return None if offset + length > len(data): return None return data[offset:offset + length] def convert_bundle(bundle_path, out_row_base, out_col_base, out_dir): with open(bundle_path, 'rb') as f: data = f.read() for r in range(16): for c in range(16): raw = extract_tile_from_bundle(data, r, c) if raw is None: continue # 通过文件头判断图片类型 ext = 'jpg' if raw[:8] == b'\x89PNG\r\n\x1a\n': ext = 'png' img = Image.open(io.BytesIO(raw)) global_row = out_row_base + r global_col = out_col_base + c out_path = os.path.join(out_dir, str(global_col), f'{global_row}.{ext}') os.makedirs(os.path.dirname(out_path), exist_ok=True) img.save(out_path) def convert_level(src_level_dir, dst_level_dir, level): for root, dirs, files in os.walk(src_level_dir): for fn in files: if not fn.lower().startswith('c'): continue row_bundle = int(os.path.basename(root)[1:]) # R00000000 -> 0 col_bundle = int(fn[1:9]) # C00000000 -> 0 bundle_path = os.path.join(root, fn) convert_bundle(bundle_path, row_bundle * 16, col_bundle * 16, dst_level_dir)使用方式:
python convert_bundle.py \ --src /data/cache/_alllayers/L05 \ --dst /data/zxy/05 \ --level 5几个注意事项:
- 先跑一个小范围验证,比如只处理
L05/R00000001/C00000002.jpg一个 bundle,然后用 Cesium 加载看一眼有没有错位,再全量跑。 - 写入时目录按
{z}/{col}/{row}.png组织,也就是列号做目录、行号做文件名,这和 Cesium 默认{z}/{x}/{y}正好匹配。如果你坚持用L/R/C原目录,要改 URL 模板,还绕不开补零问题。 - 脚本没有做断点续传,全量转换失败就从头跑,生产环境建议加一个
os.path.exists(out_path) and os.path.getsize(out_path) > 0的判断,做成可续跑脚本。
4.3 Nginx 静态服务配置
解包完成后,用 Nginx 把根目录暴露出去。最简单的配置:
server { listen 8080; server_name cache.local; root /data/zxy; add_header Access-Control-Allow-Origin *; add_header Cache-Control "public, max-age=86400"; location ~ \.(png|jpg)$ { expires 7d; access_log off; } }这里Access-Control-Allow-Origin *很重要。如果 Cesium 页面域名是app.local,瓦片服务域名是cache.local,跨域请求会被浏览器拦截,加了响应头才能正常加载。生产环境建议把*改成具体页面域名,别偷懒。
4.4 Cesium 端加载与测试
接入代码其实很短:
const viewer = new Cesium.Viewer('cesiumContainer', { baseLayer: false, timeline: false, animation: false }); const provider = new Cesium.UrlTemplateImageryProvider({ url: 'http://cache.local/arcgis-cache/{z}/{x}/{y}.png', tilingScheme: new Cesium.WebMercatorTilingScheme(), minimumLevel: 0, maximumLevel: 19 }); viewer.imageryLayers.addImageryProvider(provider);测试时先把相机飞到瓦片方案定义的范围内,确认能加载出图,再验证几个关键瓦片的行列号对应地物是否正确。一个简单方法:把范围缩到某栋建筑,然后读取当前相机的经纬度,套用前面的公式反算出行列号,直接从 Nginx 拉对应瓦片看是不是同一位置。
4.5 松散缓存直接用 URL 模板加载
如果是松散缓存,理论上可以直接把url配成:
url: 'http://cache.local/original-cache/_alllayers/{z}/R{y}/C{x}.jpg'但问题马上来了:ArcGIS 目录名都是L01、R00000000、C00000000这种补零格式,Cesium 的{z}输出的是1,不是01,{y}也不是 8 位补零格式。一旦目录层级超过 9,或者行列号超过 9,路径就对不上。
绕开这个问题有两个办法:一个是生成一份按日期或行号重命名的目录副本,本质还是解包;另一个是在 Nginx 层做前缀匹配,把请求映射到真实目录。后者在简单场景能跑,但补零逻辑非常繁琐,我不建议。除非数据量实在太大、不允许复制,否则直接走方案 A 的解包脚本最省心。
5. 实操路线 B:免解包的 Cesium 自定义 Provider 思路
5.1 什么场景值得走这条路
如果缓存数据有几个 TB,磁盘上真的放不下第二份,或者项目周期紧到没时间全量转换,才考虑自定义 Provider 直接读 bundle。这个方案能在 Cesium 请求瓦片时实时解析 bundle 并返回对应的小图片,省掉转换过程,但代价是开发量、调试成本和运行期并发压力都显著上升。
我在项目中尝试过这个方案,最终没有上生产,原因是 Cesium 的ImageryProvider接口需要实现的方法比较多,requestImage返回的还要是合法图片资源,调试成本高。如果你只是临时查看数据,完全可以用其他工具实时解包,不必动 Cesium 底层。
5.2 核心代码骨架
自定义 Provider 的骨架大致如下:
class ArcGisBundleProvider { constructor(options) { this._baseUrl = options.url; this.tilingScheme = options.tilingScheme; this.minimumLevel = options.minimumLevel || 0; this.maximumLevel = options.maximumLevel || 19; this.tileWidth = 256; this.tileHeight = 256; this.ready = true; } requestImage(x, y, level) { const bundlePath = this._getBundlePath(x, y, level); const indexInBundle = this._getIndexInBundle(x, y); return fetch(bundlePath) .then(res => res.arrayBuffer()) .then(buffer => this._extractTile(buffer, indexInBundle)) .then(blob => URL.createObjectURL(blob)); } _getBundlePath(x, y, level) { const rowBundle = Math.floor(y / 16); const colBundle = Math.floor(x / 16); const l = String(level).padStart(2, '0'); const r = String(rowBundle).padStart(8, '0'); const c = String(colBundle).padStart(8, '0'); return `${this._baseUrl}/L${l}/R${r}/C${c}.jpg`; } _getIndexInBundle(x, y) { return (y % 16) * 16 + (x % 16); } _extractTile(buffer, index) { const data = new Uint8Array(buffer); const recOffset = 64 + index * 5; const offset = data[recOffset] | (data[recOffset + 1] << 8) | (data[recOffset + 2] << 16) | (data[recOffset + 3] << 24); const length = data[recOffset + 4]; if (offset === 0 || length === 0) { return fetch('data:image/gif;base64,R0lGODlhAQABAAAAACw=').then(r => r.blob()); } const slice = data.slice(offset, offset + length); return new Blob([slice], { type: 'image/jpeg' }); } }这段代码表达的是核心思路,直接跑到生产前还要考虑几件事:requestImage中fetch的并发控制,bundle 资源缓存到内存或 IndexedDB,以及缺失瓦片的占位处理。只有小范围单机展示时它才是合理的。
5.3 我在这条路上踩过的坑
- 缺失瓦片直接用
undefined返回,Cesium 可能不渲染该区域,但如果你用的是 Canvas 拼接方式,会留黑边。我最后是返回一个 1x1 透明 GIF 数据 URL,占位干净。 - 每个瓦片请求都要先拉整个 bundle,16x16 瓦片共用一个 bundle,如果不做内存缓存,加载一屏瓦片会产生大量重复请求,性能直接崩溃。
- bundle 不是 JPEG 开头,不能直接塞给浏览器。必须先解出小瓦片再包装成
Blob,很多刚接触这块的人在这一步会莫名拿到解析失败报错。
路由 B 并不是完全不能落地,但要把它当成一个小型中间件来做,而不是一段临时脚本。
6. 实战问题排查与避坑速查
6.1 行列上下颠倒或者左右镜像
最典型的表现是:瓦片能加载,但地物位置完全不对,或者南北翻转。先说结论,绝大多数情况是切片方案选错了。
如果数据源是 TMS 规范(原点在左下角),Cesium 默认按左上角原点取瓦片,行号方向就反了,需要把行号做一次翻转:
const flippedY = Math.pow(2, z) - 1 - y;但 ArcGIS 的切片方案原点就是左上角,理论上不需要翻转。所以遇到这个问题,先回头打开tilemapresource.xml确认Origin是不是左上角,以及 SRS 确实是 3857 或 4326。别改了半天代码,结果数据本身是 4490 或者其他自定义坐标系。
6.2 层级目录对不上
有时瓦片显示空白,打开 Network 面板发现请求的路径是L1、L2,但磁盘上存的是L01、L02。这就是补零问题。解包方案不会遇到,但如果你坚持走松散缓存并配了自定义 URL 模板,就得自己处理层级补零。
我的建议是尽量在数据准备阶段就把目录规范成0、1、2、10、11这种纯数字层级,后续接入框架时零心智负担。
6.3 bundle 解出的图片花屏或损坏
出现花屏,通常不是压缩算法问题,而是索引读取位置算错了。最常见的坑是混淆了 bundle 内瓦片索引的排列顺序,把row * 16 + col写成了col * 16 + row。逐字节检查一下索引记录里偏移量和真实 JPEG 文件头的位置是否一致,就能定位。
另外,部分 bundle 的索引记录不是固定 5 字节,或者 header 不是 64 字节,这在旧版本和某些第三方导出工具生成的缓存里出现过。排查办法是用十六进制编辑器打开 bundle,找到FF D8开头的 JPEG 位置,再反推索引偏移的规律。
6.4 跨域导致图片加载失败
Cesium 加载跨域图片时,如果服务端没返回 CORS 头,浏览器会直接拦截。现象是瓦片区域灰白,控制台报错指向图片加载失败。处理方式有两个:
- 静态文件服务器加上
Access-Control-Allow-Origin响应头。 - 如果瓦片服务域名和页面域名相同,就不存在这个问题,但生产环境经常因为动静分离导致跨域,所以 Nginx 配置里加响应头是基操。
6.5 快速排查表
| 现象 | 优先排查方向 | 处理方法 |
|---|---|---|
| 整屏无图 | 网格方案选错、路径拼错 | 先拉一张瓦片 URL 看能否访问 |
| 位置偏移 | 坐标系和切片方案不匹配 | 核对 tilemapresource.xml 的 SRS |
| 上下翻转 | 原点方向错误 | 确认 Origin 是左上角 |
| 某层级不出图 | 层级范围或目录名补零 | 检查 minimumLevel 和 maximumLevel |
| bundle 解出花屏 | 索引偏移算法错误 | 用十六进制编辑器对照瓦片头 |
| 请求失败跨域 | CORS 未配置 | 服务端加响应头 |
上面这些坑我基本都踩过一轮,每次都在最不起眼的地方翻车。像补零这种问题,代码层面基本看不出毛病,但一请求就 404,只有开着控制台盯 Network 才能发现。
这个内容其实还可以继续扩展,比如把解包脚本改成多进程、加断点续传,或者直接在 Nginx 层用sub_filter做路径映射。但如果你手头的需求只是“把非 ArcGIS Server 发布的切片接进 Cesium”,把前面的路线 A 完整跑通就已经能解决 90% 的问题了。根据我的个人经验,拿到任何缓存数据先看 XML 再动手,先在单个层级上验证方案,再全量处理,这套节奏能帮你避开大量返工。