Cesium离线地形切片:GDAL、CTB与quantized-mesh实战 简介面向三维GIS与Web前端开发者的Cesium离线地形数据制作工具包聚焦把DEM高程tif数据转换为Cesium可加载的离线terrain瓦片适合无网络环境下的三维地球展示、数字孪生与本地化三维沙盘等项目也适合需要理解地形切片流程的初中级开发者。压缩包共11个文件、约45.9MB以msi和exe环境安装程序、py转换脚本、pdf操作说明为主并配有示例DEM数据、生成后的tiles结果、地形索引与配置说明文件覆盖Python 2.7、GDAL、PIL、NumPy等依赖及转换工具链。已有3095人学习说明该流程在Cesium离线地形场景中具有较高参考价值。读者可据此获得从环境准备、脚本转换到结果校验的一套可复现资料理解DEM到terrain瓦片的处理链路并借助说明文档排查路径、坐标系与转换异常搭建本地离线地形数据生产与验证环境。1. 从一块 30 米 DEM 跑出一堆 0 字节瓦片说起Cesium 离线地形的门槛都不在前端前一阵帮人看一个内网部署机器不通外网所有东西都得落地。开发者把 Cesium 的 viewer 起在本地地形地址指向自己切的瓦片目录浏览器控制台干干净净屏幕上却平得像张纸。他以为是前端参数写错对着 CesiumTerrainProvider 改了半个下午。我让他ls -l看了一眼瓦片目录几千个.terrain全是 0 字节——ctb-tile早就报过错只是被tee进了日志没人翻。Cesium 离线地形这套环境难点从来不在 Cesium 那一侧而在 GDAL 和切片程序能不能对上话DEM 的坐标系对不对、NoData 有没有清干净、压缩开关和服务端响应头配不配套。装不顺的时候表现千奇百怪有的是瓦片生不出来有的是生出来了浏览器解析不了有的是解析了地形却整体偏移几十米。这篇记录的就是我反复装过几遍的这套工具链——GDAL、cesium-terrain-builder、一个静态服务加前端接入从装到验一遍走完。适合要在无外网环境里自建地形底图的开发者也适合已经跑通一次、但被某个环节反复绊住的人。2. 装齐依赖GDAL 3.x 与 cesium-terrain-builder 的版本对齐先说结论这套东西里最容易被忽略、又最容易出事的是 GDAL 的版本而不是切片程序本身。CTBcesium-terrain-builder是编译在 GDAL 之上的一个命令行工具它靠 GDAL 读 DEM、靠 GDAL 的坐标转换做重投影、靠 GDAL 的驱动写瓦片。GDAL 装的是 1.x 而 CTB 按 2.x 的 API 编译症状就是链接期一堆找不到的符号GDAL 是 3.x 而 CTB 用的是很老的分支症状更隐蔽——DEM 能读切片能出但高程值莫名其妙差了偏移量。2.1 组件清单与版本取舍这套环境一共就五样东西列清楚再动手比一边装一边猜省事得多。组件作用版本建议取舍说明GDALgdal-bin libgdal-dev读写 DEM、重投影、CTB 的底层依赖3.x 系列与 libgdal-dev 必须同版本别混装两个源CMake编译 CTB3.10 以上系统源里的版本通常够cesium-terrain-builder切片核心提供 ctb-tile / ctb-info / ctb-extents支持 quantized-mesh 的分支分支选错输出格式就不对静态文件服务发布瓦片cesium-terrain-server 或 nginx关键是要能加 Content-EncodingCesiumJS前端渲染1.x新旧版本地形接口写法有差别这里面唯一有选错就白干风险的是 CTB 的分支。老的主线分支输出的是 heightmap 格式的.terrainCesium 也能读但体积大、层级深了以后请求量吓人现在通用的是 quantized-mesh 格式同样的地形数据体积能小一大截。所以拿到源码包以后第一件事是确认分支别急着编译。2.2 从源码编译 CTB依赖、cmake 参数与 ldconfig内网机器拉不动仓库我一般是在能上网的机器上把源码包和依赖一起下好打成 tar 带过去。落地后按下面这套顺序走。# 1. 装编译期依赖。libgdal-dev 和 gdal-bin 必须同版本否则头文件和库对不上 sudo apt-get update sudo apt-get install -y \ build-essential cmake zlib1g-dev \ libgdal-dev gdal-bin # 2. 先确认 GDAL 装好了版本记下来后面出问题第一个看它 gdalinfo --version gdal-config --version这两步的意义不只是装上了。gdal-config --version输出的版本号和你后面cmake找到的头文件版本必须一致。我遇到过系统里同时存在一个源码编译的 GDAL 和一个 apt 装的 GDALgdalinfo报 3.4cmake却找到了/usr/local/include/gdal下的旧头文件编译能过跑起来直接段错误。所以装完先which gdalinfo和gdal-config --prefix对一下路径确认只有一套。# 3. 解压源码包进目录先看分支信息 tar -xzf cesium-terrain-builder.tar.gz cd cesium-terrain-builder ls # 确认目录结构src/ 下应该有 ctb-tile.cpp cat CMakeLists.txt | head -40 # 看它要求的 GDAL 最低版本 # 4. out-of-source 编译别在源码目录里直接 cmake mkdir -p build cd build cmake .. \ -DCMAKE_BUILD_TYPERelease \ -DCMAKE_INSTALL_PREFIX/usr/local # 5. 编译并安装。-j 后面按机器核数给内存小于 4G 就老实 -j2 make -j$(nproc) sudo make install # 6. 装了动态库就必须刷缓存这一步漏了后面必翻车 sudo ldconfig参数逐个说清楚-DCMAKE_BUILD_TYPERelease会开-O优化并去掉调试符号。切片是纯 CPU 密集的活Debug 版本跑同样的数据可能要慢三四倍这不是可选项。-DCMAKE_INSTALL_PREFIX/usr/local决定装到哪。装/usr/local的好处是可执行文件直接进 PATH坏处是动态库落在/usr/local/lib而这个路径默认不一定在动态链接器的搜索范围内——所以第 6 步的ldconfig必须执行不然后面跑ctb-tile会直接报error while loading shared libraries: libctb.so。-j$(nproc)用满所有核。CTB 的编译过程本身不重一般一两分钟如果你的机器只有 2G 内存又开了-j16链接阶段被 OOM killer 干掉是常见现象降到-j2重来即可。编译完成后验证工具是否齐全# 三个命令都应该能打印用法或版本 which ctb-tile ctb-info ctb-extents ctb-tile -h | head -40 # 后面配参数全靠这段帮助先存一份ctb-tile -h的输出建议直接重定向到文件存下来。CTB 不同分支的短选项拼写有细微差别比如层级范围的参数有的分支是-s/-m有的是别的字母照着网上的文章抄参数、结果报invalid option八成就是分支和文章不一致。以自己机器上的-h为准是这套工具里最省时间的一条经验。2.3 Docker 方案把编译环境固化成一个可复现的镜像编译这套东西的坑在于能跑一次不代表能跑第二次。换台机器、换个发行版、GDAL 小版本一动前面走通的路可能全都得重来。所以我后来更倾向于用一个 Dockerfile 把整条编译链固化下来构建一次后续所有切片任务都用同一个镜像跑环境一致性有保障。FROM ubuntu:22.04 # 编译期依赖和运行期依赖一起装镜像大一点但省心 RUN apt-get update apt-get install -y --no-install-recommends \ build-essential cmake zlib1g-dev \ libgdal-dev gdal-bin \ ca-certificates \ rm -rf /var/lib/apt/lists/* # 把离线带过来的源码包拷进去编译 COPY cesium-terrain-builder /src/ctb RUN mkdir -p /src/ctb/build cd /src/ctb/build \ cmake .. -DCMAKE_BUILD_TYPERelease -DCMAKE_INSTALL_PREFIX/usr/local \ make -j4 \ make install \ ldconfig WORKDIR /data ENTRYPOINT [ctb-tile]构建和运行# 构建。-t 打标签版本自己记清楚别用 latest 混着来 docker build -t ctb:quantized-mesh . # 跑切片把数据目录挂进去 docker run --rm -v $(pwd)/data:/data ctb:quantized-mesh \ -f Mesh -C -l -o /data/tiles /data/dem_wgs84.tifENTRYPOINT [ctb-tile]这一行的作用是让容器本身变成一个命令docker run后面跟的所有参数直接透传给ctb-tile不用每次写一长串sh -c。-v $(pwd)/data:/data把宿主机数据目录挂进容器输入 DEM 和输出瓦片都落在这个目录下容器销毁了数据还在。这套写法的实际收益是GDAL 版本、CTB 分支、编译参数全部写死在 Dockerfile 里半年后回来看还能原样重建。内网环境就把镜像docker save成 tar 带过去docker load比在目标机上重编一遍稳妥。3. 切出 quantized-meshDEM 预处理与 ctb-tile 参数怎么配工具装完只是有了刀真正决定成品质量的是预处理和切片参数这两步。我见过太多情况是ctb-tile命令本身没错错在喂进去的 DEM 根本不符合要求——坐标系是 UTM、像素不是正方形、NoData 值填了个离谱的数结果切片程序默默跑完产出几千个空瓦片。3.1 DEM 预处理坐标系、重采样与 NoDataCTB 对输入 DEM 有几个隐含要求不满足不会明确报错只会给你错误的结果坐标系必须是 WGS84 经纬度EPSG:4326投影坐标系必须先转换像素在经纬度方向上都应该是均匀的否则切片时会错位NoData 值要在数据有效范围之外否则会被当成真实高程参与插值。第一步永远是先看别急着转。# 看清楚坐标系、范围、位深、NoData 值、统计信息 gdalinfo -stats dem.tifgdalinfo输出里有几行必须逐条确认Coordinate System is:后面是不是 WGS 84Origin和Pixel Size是不是近似正方形NoData Value有没有打印出来STATISTICS_MINIMUM大概是负几百还是负几万后者通常意味着 NoData 被填成了 -99999 这类值。如果原始数据是 UTM 投影Pixel Size会是 30、12.5 这样的米制数字那就必须做重投影。多幅 DEM 先拼再转比一幅一幅转完再拼要好# 1. 把多个分幅拼成虚拟栅格不占额外磁盘 gdalbuildvrt -overwrite \ -srcnodata -32767 -vrtnodata -32767 \ mosaic.vrt *.tif # 2. 重投影到经纬度 双线性重采样 明确 NoData gdalwarp -overwrite \ -t_srs EPSG:4326 \ -r bilinear \ -srcnodata -32767 \ -dstnodata -32767 \ -multi -wo NUM_THREADSALL_CPUS \ -co TILEDYES -co BIGTIFFYES -co COMPRESSDEFLATE \ mosaic.vrt dem_wgs84.tif-t_srs EPSG:4326是这套流程的硬性前提不是可选优化。CTB 内部按经纬度切四叉树投影坐标系的数据进去以后瓦片的 x/y 编号会全乱产出的目录结构看着正常前端请求却永远命中不了正确层级。-r bilinear值得单独说。重投影必然涉及重采样默认的near会保留原始像素值但在地形上表现为明显的台阶放大后能看到一格一格的横纹。地形数据我一般用bilinear追求更平滑的可以用cubic但cubic在陡崖边缘容易产生过冲产生比真实值更高的假峰。-srcnodata和-dstnodata要成对写。只写一个的话GDAL 在重投影后可能把 NoData 区域填成 0而 0 在高程里是海平面结果是数据边缘出现一圈突兀的平地。这是地形看起来正常但边缘怪怪的最常见的原因。-co BIGTIFFYES针对超过 4GB 的 GeoTIFF。全国范围的高精度 DEM 换算下来十几个 G 很常见不加这个参数会在写到 4GB 时直接截断报错。-co TILEDYES让 GeoTIFF 内部按块存储CTB 顺序读的时候快很多。3.2 ctb-tile 的关键参数预处理完正式切片。一条典型的命令长这样# 输出 quantized-mesh 格式gzip 压缩同时生成 layer.json ctb-tile \ -f Mesh \ -C \ -l \ -c 4 \ -s 0 -m 13 \ -o ./tiles \ dem_wgs84.tif逐个参数说参数作用怎么定-f Mesh输出格式quantized-mesh不写则走默认的 heightmap体积大很多-C对瓦片做 gzip 压缩必开体积通常能省六到七成-l生成 layer.json 描述文件前端靠它拿可用层级和范围-c 4并发线程数按 CPU 核数给磁盘是瓶颈时加大没用-s 0起始层级全球数据从 0 开始局域数据可以从更高层起-m 13最大层级按 DEM 分辨率算别贪深-o ./tiles输出目录必须是空目录别拿上次的结果复跑-R断点续跑中断后加上它继续别删目录重来-f Mesh这里的取值取决于分支。有的分支写Mesh有的写QuantizedMesh还有的老分支压根没这个选项只有Terrain。判断方法很简单命令跑完后ls tiles/0/0/0.terrain用file看一眼quantized-mesh 是二进制头加顶点数据的结构不是简单的二进制高度矩阵。拿不准就直接ctb-tile -h查-f的枚举值。-m的取值是这套流程里最需要动脑子的一项。层级越深瓦片数量越夸张——每深一层瓦片数大约翻四倍。用 30 米分辨率的 DEM 强行切到 15 层得到的瓦片里绝大多数是从同一批原始像素插值放大出来的视觉上没有任何信息增量只白白吃掉磁盘和前端请求数。一个粗略的对照经验DEM 地面分辨率合理最大层级典型用途90 米0 至 10大范围底图30 米0 至 12 或 13省域、区域级12 米0 至 13 或 14城市周边5 米0 至 15精细场景注意磁盘这张表是估算不算精确公式。判断方法更实在切完之后看最深一层的单块瓦片如果放大到最大还是糊的说明层级不够如果放大到屏幕像素级别看着和上一层没差别说明切深了。3.3 layer.json 与目录结构核对切完先别急着起服务原地核对两件事目录结构对不对layer.json 内容对不对。# 看层级目录应该是一串数字目录从 0 开始连续 ls tiles/ # 看最深层的一个瓦片确认不是 0 字节 find tiles -name *.terrain -size 0 | head # layer.json 是前端的入口重点看 format、scheme、available cat tiles/layer.json一个正常的 layer.json 大致长这样{ tilejson: 2.1.0, format: quantized-mesh-1.0, version: 1.0.0, scheme: tms, tiles: [{z}/{x}/{y}.terrain?v{version}], bounds: [-180, -90, 180, 90], minzoom: 0, maxzoom: 13, available: [ [{startX: 0, startY: 0, endX: 1, endY: 0}] ] }三个字段必须逐个对format要是quantized-mesh-1.0写成heightmap-1.0说明分支或-f参数选错了scheme是tms意味着 y 轴从下往上编号如果你的瓦片目录是按另一种方式组织的前端会请求到不存在的坐标available数组描述了每一层实际有哪些瓦片这个数组和磁盘上的目录必须一致不一致的结果就是某些区域永远不加载。如果ctb-tile没有生成 layer.json有些分支的行为不一样可以手写一份available里最省事的写法是把每一层都声明成完整覆盖available: [ [{startX: 0, startY: 0, endX: 1, endY: 0}], [{startX: 0, startY: 0, endX: 3, endY: 3}], [{startX: 0, startY: 0, endX: 7, endY: 7}] ]这种写法会让前端请求一些不存在的瓦片换来的是不会漏掉任何有效区域对局域数据来说通常可接受。4. 起服务与接前端Content-Encoding 是这条链上最容易断的一环瓦片躺在磁盘上不等于能用。Cesium 请求.terrain时会检查响应头如果瓦片是 gzip 压过的而服务端没声明浏览器拿到的是压缩后的二进制解析必然失败。这一步用python -m http.server起服务是最典型的翻车现场因为那个内置服务器不加任何Content-Encoding。4.1 用 cesium-terrain-server 或 nginx 发布cesium-terrain-server的好处是它对 quantized-mesh 的响应头处理是内建的不用自己配。# -dir 指向瓦片根目录-port 是监听端口 cesium-terrain-server -dir ./tiles -port 8080-dir要指向包含layer.json的那一层不是它的父目录——指错一层的表现是前端请求/layer.json拿到 404控制台报Failed to obtain terrain tile。如果不想多一个进程nginx 更常见server { listen 8080; root /data/terrain; # 瓦片声明类型 声明压缩 允许跨域 location ~ \.terrain$ { default_type application/vnd.quantized-mesh; add_header Content-Encoding gzip; add_header Access-Control-Allow-Origin *; add_header Cache-Control public, max-age604800; } # 描述文件不要加 Content-Encoding location /layer.json { default_type application/json; add_header Access-Control-Allow-Origin *; } }三个add_header各管一件事。default_type application/vnd.quantized-mesh让 nginx 用正确的 MIME 类型返回虽然 Cesium 主要看内容不看类型但抓包排查时能一眼分清。Content-Encoding gzip是必须的且只能加在.terrain上——加到layer.json上会导致前端解析 JSON 失败。Access-Control-Allow-Origin *是为前端页面和瓦片不同源的情况准备的本地开发时页面在 5500 端口、瓦片在 8080不加这个头浏览器直接拦掉请求。还有一个容易忽略的点如果你在ctb-tile时没有加-C那瓦片本身没压缩nginx 这里就不能加Content-Encoding: gzip。两边必须一致压缩和不压缩是成对的开关一头开一头不开都是错的。4.2 前端接入与参数取舍服务起来以后前端这边就几行代码但新旧版本写法不同。// 老写法直接 new配置同步生效 const terrain new Cesium.CesiumTerrainProvider({ url: http://127.0.0.1:8080/, requestVertexNormals: true, // 请求法线地形才有明暗光照 requestWaterMask: false, // 离线数据一般没有水面掩膜 }); viewer.terrainProvider terrain; // 1.9x 之后推荐写法异步工厂加载失败能捕获 Cesium.CesiumTerrainProvider.fromUrl(http://127.0.0.1:8080/, { requestVertexNormals: true, requestWaterMask: false, }).then((provider) { viewer.terrainProvider provider; // 地形开了之后深度测试也要开否则模型会被地形遮住或穿插 viewer.scene.globe.depthTestAgainstTerrain true; }).catch((err) { console.error(地形加载失败先去看 layer.json 请求, err); });requestVertexNormals: true取决于瓦片里有没有写顶点法线。CTB 生成 quantized-mesh 时可以选择是否附带法线数据带了才有明暗效果如果瓦片里没有而前端要求了不会报错只是地形看起来平没有立体感——这个现象很容易被误判成数据问题。requestWaterMask在离线场景基本都关掉。水面掩膜需要额外的图层数据纯 DEM 切出来的瓦片里没有开了只会多一轮无谓的请求。depthTestAgainstTerrain这个开关值得单独提醒。地形加载成功后如果不打开它模型、标注、多边形都会穿透地形显示看起来像浮在半空或者插进地底。这不是地形的问题是渲染设置的问题但排查时经常被算到地形头上。5. 排查清单五条把切片卡死或让地形不显示的踩坑记录5.1 瓦片全是 0 字节ctb-tile 几秒就退出现象命令执行完find tiles -name *.terrain -size 0 | wc -l数出几千个文件每个都是空的。前端请求全是 200就是没内容。原因九成是输入 DEM 的问题。要么坐标系还是投影坐标CTB 算不出正确的瓦片范围要么 NoData 值设置得比整个数据的有效范围还大所有像素都被判定为无效。解决先gdalinfo -stats看Coordinate System和STATISTICS_MINIMUM。如果最小值是 -99999 这种明显是填充值的数字重新跑一遍gdalwarp把-srcnodata和-dstnodata设成合理值比如 -32767。这一条我在项目里遇到不止一次每次都是没看gdalinfo直接开切。5.2 ctb-tile 报 libctb.so 找不到现象ctb-tile: error while loading shared libraries: libctb.so.0: cannot open shared object file。原因默认安装到/usr/local动态库落在/usr/local/lib而有些发行版的动态链接器默认不扫这个目录。解决sudo ldconfig一下八成就好了。没好的话检查/etc/ld.so.conf.d/下有没有包含/usr/local/lib的配置没有就加一个文件写进去再ldconfig。临时应急可以用export LD_LIBRARY_PATH/usr/local/lib:$LD_LIBRARY_PATH但别把它写进脚本换个人跑就失效。5.3 瓦片有内容浏览器报 Unable to parse terrain现象Network 面板里.terrain请求全是 200但控制台报RuntimeError: Unable to parse terrain或者地形完全不出现。原因切片时加了-C压缩服务端没加Content-Encoding: gzip。用python -m http.server起服务是这个问题的高发场景。解决换 nginx 或 cesium-terrain-server或者手动确认响应头里有没有Content-Encoding: gzip。用curl -I http://127.0.0.1:8080/0/0/0.terrain看一眼就清楚了别靠猜。5.4 小比例尺下有地形放大后变平现象缩到全球视角能看到山脉起伏一旦放大到城市级别地形突然变成平面。原因layer.json里的maxzoom或者available数组和磁盘实际内容不一致。常见于手动改过 layer.json或者切片时-m限制了层级但 layer.json 没同步更新。解决ls tiles/看最大数字目录是几和layer.json的maxzoom对一遍。不一致就改 layer.json别去重切。5.5 地形整体下沉或偏移几十米现象地形能显示但和影像底图对不上或者整体比实际高度低一截。原因DEM 的高程基准没统一。有的数据是正高相对大地水准面有的是大地高相对椭球两者差几十米很正常。另一种可能是数据边缘的 NoData 被当成 0 参与了插值导致边界区域被拉平到海平面。解决先确认数据的高程基准需要的话做基准改正。边界问题则回到预处理阶段用-dstnodata明确标记而不是填 0必要时用gdal_fillnodata做边缘填补后再切。6. 装完怎么验三层校验别等浏览器打开才发现问题这套环境装完我一般不从浏览器开始验而是从数据层往上走。原因很实际浏览器只会告诉你地形没出来不会告诉你哪一步断的而数据层和服务层的校验能在几秒内定位问题。第一层验数据。ctb-extents能打印出这套瓦片覆盖的地理范围和你原始 DEM 的gdalinfo输出对一遍范围差太多说明预处理阶段的坐标系就错了。ctb-info可以查单个瓦片的信息。最实用的是自己数一下每层的瓦片数量# 每深一层瓦片数大致翻四倍。数量对不上说明切片中途断过 for z in $(ls tiles | grep -E ^[0-9]$ | sort -n); do n$(find tiles/$z -name *.terrain | wc -l) echo z$z tiles$n done # 顺手查一遍空瓦片正常情况下应该是 0 find tiles -name *.terrain -size 0 | wc -l正常的输出会是一条近似 1、4、16、64、256 递增的序列。如果某一层突然掉到个位数说明切片在那一层中断了加-R重跑。空瓦片数量必须是 0只要不是 0 就说明输入数据有问题回到第 5 章第一条去查。第二层验服务。服务起来以后用 curl 把三个关键请求打一遍# 1. layer.json 能不能拿到format 对不对 curl -s http://127.0.0.1:8080/layer.json | head -20 # 2. 瓦片响应头里有没有 Content-Encoding curl -sI http://127.0.0.1:8080/0/0/0.terrain | grep -i -E content-encoding|content-type|http # 3. 瓦片本身的大小0 字节或者几百字节都不正常 curl -s -o /dev/null -w %{size_download}\n http://127.0.0.1:8080/0/0/0.terrain第 2 条最关键。响应头里content-encoding: gzip有或没有直接决定前端能不能解析。第 3 条返回的大小可以当作基线一个正常的第 0 层瓦片通常在几 KB 到几十 KB 之间。第三层验渲染。前两层都过了再开浏览器。把相机飞到数据范围内的一个已知高点用控制台读一下相机位置的地形高度// 取当前相机正下方的地形高度和你 DEM 里该点的实际高程对一下 const carto viewer.camera.positionCartographic; const height viewer.scene.globe.getHeight(carto); console.log(地形高度, height, 相机高度, carto.height);两者差值应该和你设定的相机离地高度接近。差了个固定值基本就是高程基准问题差得离谱或者返回 undefined说明瓦片没被正确解析。校验层检查项通过标准数据层各层瓦片数、空瓦片数逐层约四倍递增空瓦片为 0服务层layer.json、Content-Encoding、瓦片大小JSON 可解析头里有 gzip大小非 0渲染层相机位置地形高度与该点实际高程一致三层里最容易跳过的是第二层因为它不像第一层有直观的文件、也不像第三层有画面但它恰恰是断得最多的一环。从那以后我每次切完新数据都强制先把这三条 curl 跑一遍再打开浏览器哪怕数据是同一批工具切的——环境里的变量比想象的多省这三分钟往往要用半小时的猜来还。希望帮到你。上面这套工具链的源码包、Dockerfile 模板和参数对照说明已经一起整理好了按第 2 章装环境、第 3 章切数据、第 6 章验三层的顺序走一遍基本能一次跑通。本文还有配套的精品资源点击获取