简介Cesium Terrain Builder 瓦片地形生成器面向需要在 Web 端展示真实地形的 GIS 与三维可视化开发者重点解决 TIFF 高程数据转换为 Cesium 可加载地形瓦片时的格式适配、投影处理与目录组织问题。压缩包共 108 个文件约 16.17MB包含 dll 运行库、exe 工具程序、csv 投影与基准面参数表、gfs 配置及 terrain 示例等覆盖工具运行、坐标转换与瓦片输出所需的基础组件。已有 2379 人学习下载说明其在离线地形服务搭建场景中具有一定参考价值。读者可借助其中的可执行程序与配套参数表完成从 DEM/TIFF 到地形瓦片的生成流程理解瓦片层级、元数据与 Cesium 地形服务 URL 的对应关系并针对投影不匹配、基准面偏差等常见问题积累排查思路也为自建地形瓦片服务提供可复用的目录模板与参数依据适合作为定制化三维地形展示与地理测绘、城市规划等应用的实践参考。1. 从一份 30GB 的 tif 说起为什么高程数据进 Cesium 前必须先过一遍 ctb-tile第一次拿 cesium terrain builder 处理一份三十多 GB 的高程 tif我以为装上这个瓦片地形生成器、把文件丢进去跑一条命令就完事结果 ctb-tile 跑到第 5 级被系统的 OOM Killer 直接干掉日志里只剩一行 Killed没有任何堆栈。后来才搞明白terrain 切分这件事对输入数据的要求比工具本身苛刻得多投影必须是 WGS84 经纬度nodata 必须显式声明金字塔不预先建好进程就会把整份 tif 反复读进内存。这套环境在测绘、遥感、三维可视化团队里极为常见装不好的代价是从编译到出瓦片一路翻车GDAL 版本对不上直接编译不过法线没生成地形一片死黑layer.json 里的 available 数组跟实际目录对不上浏览器端就是白屏或者地形永远停在海平面。这篇记录从裸系统到浏览器里看见山脊线的全过程版本怎么选、参数怎么配、装坏了怎么救一条条写清楚。适合第一次给 Cesium 做本地地形服务的人也适合把老环境迁到新机器上的熟手。2. 编译前的环境清单GDAL、zlib、CMake 的版本取舍2.1 系统底子为什么我固定在 Ubuntu 20.04 GCC 9ctb-tile 是 C 项目靠 CMake 驱动链接 GDAL 和 zlib没有别的花哨依赖。听着简单实际上坑全在「系统自带的 GDAL 是哪一版」上。我现在的基线是 Ubuntu 20.04 LTS、GCC 9.4、CMake 3.16、zlib 1.2.11机器内存 32GB 起步——不是编译需要这么多是后面切 15 级以上瓦片时 ctb-tile 的常驻内存会往上顶。GCC 不建议追新。CTB 的代码风格偏老用 GCC 11 以上编译时对某些隐式类型转换和 deprecated 声明的处理更严格会多出一堆跟业务无关的告警看着烦还容易掩盖真正的错误。GCC 9 是能干净编过的那一档够用就行。选 Ubuntu 20.04 还有一个现实原因它的软件源里 GDAL 和 gdal-bin 版本齐全gdalwarp、gdalinfo、gdaladdo 这几个命令行工具在后面预处理 tif 时会反复用到装一次省很多事。如果你手上只有 CentOS 或者别的发行版逻辑一样把包名换掉即可判断标准只有一条——gdal-config --version能打出你预期的版本号。2.2 GDAL 选 2.4 还是 3.x一次编译报错换来的结论这是整篇文章里最值得先做决定的一件事。CTB 这个项目最后一次活跃是在 GDAL 2.x 时代源码里对 OGRSpatialReference 的调用方式是按当年的接口写的。GDAL 3 做了两处改动正好都打在这上面第一处是轴顺序。GDAL 3 默认按 authority-compliant 的顺序处理坐标EPSG:4326 会被解释成「纬度在前、经度在后」。CTB 内部解析 tif 的坐标系时没有显式设置轴映射策略结果就是切出来的瓦片整体偏移地形跑到几内亚湾去了而日志里一句报错都没有。这种沉默的错误最难查属于典型的黑匣子问题。第二处是接口签名。GDAL 3 把一部分返回 WKT 的方法改成了必须传二级指针并检查 OGRErr老代码里那种直接取返回值的写法编译期就过不去。我给两条路按你手上的系统选系统能提供 GDAL 2.4.x比如 Ubuntu 18.04 自带 2.2/2.3或者自己源码编一个 2.4 装到 /opt 下CTB 源码一行不用改cmake 指过去就能编。必须留在 GDAL 3.x 上那就改代码。改动量不大通常在源文件里搜exportToWkt和SetWellKnownGeogCS就能定位到那几处。改的时候关键是加一行轴映射策略这是解决偏移问题的后悔药// 这段在 CTB 构造 OGRSpatialReference 之后调用 OGRSpatialReference oSRS; oSRS.SetWellKnownGeogCS(WGS84); // GDAL 3 必须显式声明传统 GIS 顺序否则经纬度会反 oSRS.SetAxisMappingStrategy(OAMS_TRADITIONAL_GIS_ORDER); char* wkt nullptr; // GDAL 3 里 exportToWkt 要求传二级指针并返回 OGRErr OGRErr err oSRS.exportToWkt(wkt); if (err ! OGRERR_NONE) { // 别静默吞掉坐标系失败后面全是垃圾数据 throw std::runtime_error(export WKT failed); }SetAxisMappingStrategy的参数只有两个常用值OAMS_TRADITIONAL_GIS_ORDER表示经度在前跟 GDAL 2 的行为一致这是我们要的OAMS_AUTHORITY_COMPLIANT是 GDAL 3 的默认值也是偏移的根源。这个策略必须设在被真正使用的那份 OGRSpatialReference 上设在临时拷贝上等于没设。exportToWkt改成传char**之后返回值从「字符串指针」变成「错误码」老代码里if (wkt nullptr)这种判断要跟着改成if (err ! OGRERR_NONE)否则出错时照样往下走。2.3 依赖安装与自检三条命令确认环境齐了装依赖这一步没什么技术含量但要一次装全省得编译到一半缺头文件回头补sudo apt-get update sudo apt-get install -y \ build-essential \ cmake \ git \ libgdal-dev \ gdal-bin \ zlib1g-devlibgdal-dev提供头文件和.so链接gdal-bin提供 gdalinfo、gdalwarp、gdaladdo 这些命令行工具zlib1g-dev是 CTB 压缩瓦片时的直接依赖。三个包少一个cmake 阶段就会报找不到。装完先别急着编用三条命令把版本和路径记下来后面 cmake 要写死这些值gdal-config --version # 打出版本号2.x 还是 3.x 一眼看出 gdal-config --cflags # 头文件搜索路径一般输出 -I/usr/include/gdal gdal-config --libs # 链接参数一般输出 -lgdal ldconfig -p | grep libgdal # 拿到 .so 的绝对路径最后一条尤其重要。-lgdal只是链接器参数cmake 里要的是.so的完整路径通常是/usr/lib/x86_64-linux-gnu/libgdal.so。把这几行输出粘到记事本里下一章直接用。提示如果gdal-config --version打出来是 3.x 而你又不想改源码现在就是回头装 GDAL 2.4 的最后时机。等编到一半再换前面所有构建缓存都得清掉重来。3. 从源码编译 ctb-tile一次 cmake 到 make install 的完整路径3.1 源码目录与构建目录分离把源码包解到/opt/src/ctb我不在源码树里直接 cmake而是单独建build目录。这不是洁癖是血泪经验一旦 cmake 缓存里记了错误的 GDAL 路径后面无论怎么改 CMakeLists 都不生效唯一的解法是rm -rf整个构建目录重来。分离之后删 build 就是一条命令的事。sudo mkdir -p /opt/src sudo tar -xzf ctb-src.tar.gz -C /opt/src # 解压后的目录名按实际调整 cd /opt/src/ctb mkdir -p build cd build解压完先扫一眼顶层有没有CMakeLists.txt有就说明拿对了包。CTB 的源码里通常还带一个tools/目录ctb-tile、ctb-info、ctb-extents 这几个可执行文件就是从这里编出来的。3.2 cmake 变量GDAL_INCLUDE_DIR 与 GDAL_LIBRARY 必须写死这是整个编译过程里最容易翻车的一步。CTB 的 CMakeLists 里查找 GDAL 的逻辑比较老机器上装了多个 GDAL比如系统一个、源码编的一个时它经常抓到错的那个然后在链接阶段报一堆未定义符号。我的做法是不让它自己找直接把路径喂进去cmake .. \ -DCMAKE_BUILD_TYPERelease \ -DCMAKE_INSTALL_PREFIX/opt/ctb \ -DGDAL_INCLUDE_DIR/usr/include/gdal \ -DGDAL_LIBRARY/usr/lib/x86_64-linux-gnu/libgdal.so四个参数逐个说CMAKE_BUILD_TYPERelease打开 -O2 优化。ctb-tile 是计算密集型工具Debug 版本切一份中等 tif 能慢三到五倍没有调试需求就别用默认值。CMAKE_INSTALL_PREFIX/opt/ctb决定make install把 bin、lib、include 装到哪。装到/opt下而不是/usr/local是为了跟系统包管理器彻底隔开将来卸载直接删目录不会污染系统。GDAL_INCLUDE_DIR指向 gdal 头文件所在目录就是上一章gdal-config --cflags打出来的那个路径去掉-I。GDAL_LIBRARY指向.so的绝对路径从ldconfig -p | grep libgdal的输出里抄。cmake 跑完输出末尾会有一张配置汇总表。别急着往下走先看两行GDAL 的版本号是不是你预期的那个zlib 有没有找到。任何一项显示 NOTFOUND说明路径给错了重新rm -rf build再来一遍别在错的基础上硬编。3.3 make install 之后的动态库与 PATH编译本身没什么好说的make -j$(nproc)跑完就行。真正容易被忽略的是安装之后ctb-tile 是链接了 GDAL 的动态可执行文件装到/opt/ctb/bin之后系统加载器根本不知道/opt/ctb/lib的存在直接敲命令会报error while loading shared libraries。cd /opt/src/ctb/build make -j$(nproc) sudo make install # 让系统加载器认识 /opt/ctb/lib echo /opt/ctb/lib | sudo tee /etc/ld.so.conf.d/ctb.conf sudo ldconfig # 可执行文件加进 PATH写进 profile 长期生效 echo export PATH/opt/ctb/bin:$PATH | sudo tee /etc/profile.d/ctb.sh source /etc/profile.d/ctb.shldconfig这一步是把新目录登记进动态链接器的缓存不执行的话ld.so.conf.d里写了也白写。/etc/profile.d/ctb.sh只在登录 shell 生效当前终端要source一下否则ctb-tile依然提示 command not found。如果暂时不想改系统配置用export LD_LIBRARY_PATH/opt/ctb/lib:$PATH也能跑通但这种临时方案只适合当前会话换个窗口就失效正式部署还是走 ldconfig。3.4 编译产物自检ctb-tile -h 与 ctb-info装完之后别直接拿生产数据试先用帮助输出做一次最小验证ctb-tile -h # 打印参数列表能出来说明动态库加载没问题 ctb-info # 不带参数会提示用法同样验证 binary 可用 which ctb-tile # 确认走的是 /opt/ctb/bin 下的那份ctb-tile -h的输出就是你后面切瓦片时的参数底稿。不同源码包的选项名可能略有差异比如输出格式的取值到底是Terrain、Mesh还是GTiff翻译层写法各家不一样。以你机器上这份-h的输出为准别照抄网上的命令。顺带记一下ctb-extents这个工具它不吃数据只算范围能根据输入的 tif 和层级算出覆盖的瓦片行列号范围后面排查「为什么切出来只有一层目录」的时候非常好用。4. 切瓦片tif 高程数据到 quantized-mesh 的参数配置4.1 输入 tif 的三道预处理投影、nodata、金字塔ctb-tile 吃得下 tif但它对输入是有隐含契约的违反任何一条都会在没有任何报错的情况下产出垃圾瓦片。我现在的流程是任何 tif 进来先过三道预处理一步都不能省。第一道投影。CTB 要求输入是 WGS84 经纬度EPSG:4326。原始高程数据经常是 UTM 或者某个地方坐标系直接丢进去切出来的瓦片位置会整体错位而工具本身不报错。第二道nodata。高程数据边缘常常有一圈 -32767 或者 0如果 tif 里没有声明 nodata这些值会被当成真实海拔参与切分结果就是地形边缘出现一圈垂直的悬崖或者巨大尖刺。第三道金字塔。这是内存问题的根源下面细说。# 看一眼原始数据的坐标系、尺寸和统计信息 gdalinfo -stats input.tif | head -40 # 第一步重投影到 WGS84同时显式声明 nodata gdalwarp -t_srs EPSG:4326 \ -r bilinear \ -of GTiff \ -co TILEDYES \ -co COMPRESSDEFLATE \ -srcnodata -32767 \ -dstnodata -32767 \ input.tif dem_wgs84.tif # 第二步建内部金字塔2 到 32 级 gdaladdo -r average dem_wgs84.tif 2 4 8 16 32-t_srs EPSG:4326是重投影目标必须是这个值。-r bilinear双线性重采样在连续的高程面上比最近邻平滑不会留下阶梯状锯齿。-co TILEDYES把输出切成内部 256×256 的小块ctb-tile 按块读的时候不用来回 seek速度差别很明显。-srcnodata和-dstnodata要写成一致的值转换过程中无效区域才不会被插值污染成有效高程。gdaladdo的2 4 8 16 32是金字塔层级意思是按 1/2、1/4 一直到 1/32 分辨率各存一份概览。ctb-tile 切低层级瓦片时读的是这些概览不建的话它每次都要回到原始全分辨率数据里重采样内存占用和耗时都会失控。预处理完再用gdalinfo dem_wgs84.tif确认三件事Coordinate System是 WGS 84NoData Value有你设的那个数文件末尾出现Overviews列表。三项齐了才算过关。4.2 ctb-tile 参数表与一条能直接抄的命令参数不多但每个都影响最终结果我按使用频率列一下参数作用我一般怎么给-o瓦片输出目录独立磁盘路径别放系统盘-f输出格式Mesh出 quantized-meshTerrain出老式 heightmap-C生成 layer.json必须加不然前端没法索引-N生成顶点法线要开光照就加前端得同时开 requestVertexNormals-l图层名跟输出目录名保持一致便于排查-c线程数取物理核数的一半留余量给 IO-r重采样方式bilinear跟预处理保持一致-R断点续传大任务必加中断后能接着跑一条能直接抄的完整命令ctb-tile -f Mesh \ -C \ -N \ -l terrain \ -r bilinear \ -c 4 \ -R \ -o /data/tiles \ dem_wgs84.tif-f Mesh是关键选择。quantized-mesh 是 Cesium 现在主推的地形编码顶点数据量化压缩过同样精度下体积比老式 heightmap 小一个量级Terrain格式是历史包袱除非对接的老系统只认它否则别选。-c 4这个值需要看机器。我在 8 核 32G 的机器上给 4是因为每个线程切高层级瓦片时要占一到两 GB线程开满会把内存吃光反而触发 swap 拖慢整体速度。粗算方法是可用内存 ÷ 2GB再跟核数取小值。-R是断点续传开关。切全国范围的数据动辄几小时中途机器重启或者网络盘抖动都可能中断加了它重启后已经存在的瓦片会被跳过。4.3 layer.json 生成-C 背后的 available 数组-C这个参数看着不起眼实际决定了前端能不能正确加载。它让 ctb-tile 在输出目录根部生成一个 layer.json里面描述这套瓦片的基本信息瓦片格式、坐标系方案、URL 模板以及最关键的available数组——记录每个层级实际切出了哪些行列范围。前端 CesiumTerrainProvider 加载时优先读这个文件。如果 available 数组跟磁盘上的实际目录对不上比如你手工删过某些层级的瓦片或者用增量方式只补切了局部区域前端就会按错误的范围去请求拿到一堆 404表现出来的现象是地形只在某一块区域加载出来其余地方保持平的。所以有一条固定纪律只要动过瓦片目录结构就重新生成 layer.json别手工编辑。手工改 JSON 的后果是数组层级嵌套错一位前面所有瓦片全部失效。生成后抽查一下ls /data/tiles # 应该能看到 layer.json 和若干数字命名的层级目录 find /data/tiles -name *.terrain | wc -l # 瓦片总数跟预估范围对一下 head -c 500 /data/tiles/layer.json # 快速看格式和 available 起始层级如果发现只切出了一层或两层目录多半是输入 tif 覆盖范围太小或者-m/-M限定了层级。用ctb-extents对着原 tif 算一下理论范围跟实际目录数对比差异一眼就能看出来。4.4 断点续传与线程数-R 和 -c 怎么配这两个参数单独说因为它们决定了大任务能不能跑完。-R生效的前提是输出目录结构没被破坏。它的判断逻辑是「这个瓦片文件存在就跳过」所以如果你改了-f输出格式却沿用旧目录会出现新旧格式混在一起的情况前端解析直接崩。换格式之前先清空输出目录。-c的调整方向跟直觉相反不是越多越快。切高层级15 级以上时单瓦片内存占用会上一个台阶线程数超过内存承受能力就会触发 OOM这时候的表现是进程被系统杀掉、日志里什么都没有。我第一次翻车就是这么来的。稳妥做法是先拿一小块数据试切用top盯着 RSS 峰值算出单线程占用再倒推。5. 排查ctb-tile 编译失败、瓦片发黑、地形悬空的 5 个坑5.1 编译报错指向 GDAL 的 WKT 接口现象make阶段报一堆跟exportToWkt、OSRSetFromUserInput相关的错误提示参数数量不匹配或者无法从 OGRErr 转换。原因机器上是 GDAL 3.x而这份 CTB 源码是按 GDAL 2.x 的接口写的函数签名变了。解决两条路选一条。要么按 2.2 节的补丁改源码把 WKT 导出改成二级指针并检查 OGRErr要么装一份 GDAL 2.4 到/opt下cmake 时把GDAL_INCLUDE_DIR和GDAL_LIBRARY指过去。改源码的方案一劳永逸但每次升级源码包都要重新打一遍。5.2 运行时提示找不到 libgdal.so现象ctb-tile -h直接报error while loading shared libraries: libgdal.so.xx: cannot open shared object file但ldconfig -p | grep libgdal明明能看到这个库。原因要么/opt/ctb/lib没登记进加载器缓存要么编译时链接的是源码编的 GDAL而运行时加载器找到的是系统那份版本号对不上。解决先确认/etc/ld.so.conf.d/ctb.conf里写了/opt/ctb/lib并且执行过sudo ldconfig。还不行就用ldd $(which ctb-tile) | grep gdal看它到底在找哪个版本把对应的库路径补进 conf 文件。最省事的做法是编译时统一用系统 GDAL别混着来。5.3 瓦片切出来了但地形整体偏移到海上现象前端加载正常瓦片也请求到了但地形出现在错误的位置跟底图对不上偏移量还很有规律。原因GDAL 3 的轴顺序问题。CTB 内部构造坐标系时没有设置OAMS_TRADITIONAL_GIS_ORDER输出的经纬度顺序反了。这个错误最坑的地方在于全程无报错。解决在构造 OGRSpatialReference 的地方补上轴映射策略重新编译、重新切瓦片。已经切出来的瓦片没法修只能重来所以这个检查要在第一次切之前做完。5.4 地形边缘出现垂直悬崖或者尖刺现象瓦片能加载主体地形也对但在数据边界或者湖泊区域出现大量竖直的墙面和针状突起。原因nodata 没处理好。要么 tif 里根本没声明 nodata要么gdalwarp时-srcnodata和-dstnodata写得不一致插值过程把无效值扩散成了有效高程。解决回到 4.1 节用gdalwarp显式带上-srcnodata和-dstnodata重做一遍然后用gdalinfo -stats看NoData Value和最大最小值。如果最小值仍然是 -32767 这种异常值说明声明没生效检查原始 tif 用的是不是别的哨兵值。5.5 切到一半进程被杀日志空白现象跑了几个小时进程突然消失终端只有一行 Killeddmesg里能看到 oom-killer 的记录。原因内存不够。常见诱因是没建金字塔、线程数给太满、或者输入 tif 用了未压缩的大块存储导致每次读取都解压整个条带。解决先确认gdaladdo建过金字塔再按 4.4 节的方法把-c降到单线程试跑观察 RSS 峰值。输入 tif 用-co TILEDYES -co COMPRESSDEFLATE重新导出一份读取模式会健康很多。加上-R之后中断也不至于从头再来。6. 验证三层自检确认瓦片能在浏览器里立起来切完不等于能用。我现在的习惯是切完立刻走三层自检任何一层不过就不往下走。第一层查文件结构。层级目录是不是从最小层级连续排到最大层级中间有没有断层layer.json 里的 available 数组长度跟磁盘上的目录数是否一致。断层通常意味着某一层切分时崩过得靠-R补跑。第二层查协议。起一个静态服务用 curl 直接拉一个瓦片看响应cd /data/tiles python3 -m http.server 8080 # 拉一个确定存在的瓦片看状态码和响应体大小 curl -s -o /dev/null -w %{http_code} %{size_download}\n \ http://127.0.0.1:8080/8/210/98.terrain返回 200 且 size_download 在几百字节到几十 KB 之间算正常。返回 404 说明行列号算错了或者最小层级跟 available 的起点不一致返回 200 但 size 是 0多半是切分中断留下的空文件。第三层查渲染。写一个最小页面把 terrainProvider 指过去const viewer new Cesium.Viewer(cesiumContainer, { terrainProvider: new Cesium.CesiumTerrainProvider({ url: http://127.0.0.1:8080, // 指向 layer.json 所在目录 requestVertexNormals: true, // 切分时加了 -N 才开这个 requestWaterMask: false // 没有水面掩膜数据就别开 }) });打开浏览器开发者工具的 Network 面板筛.terrain请求。正常情况是随着视角缩放不同层级的瓦片按需加载状态码全是 200。requestVertexNormals必须跟切分时的-N对应切的时候加了、前端没开山体看着是平的切的时候没加、前端开了顶点法线全零光照计算出来一片死黑。三层都过之后再用viewer.camera.flyTo飞到一条已知的山脊线上肉眼确认起伏方向跟原始 tif 的剖面一致。这一步别省坐标偏移和层级错位在俯视图上极不明显只有贴近看山谷才暴露。从那以后我每次切完瓦片都强制走一遍「目录数对齐 → curl 拿一个瓦片 → 前端 Network 看 200」这三步宁可多花十分钟也不想在演示现场对着白屏找原因。希望帮到你。本文还有配套的精品资源点击获取