Cesium地形切片:ctb-tile编译与quantized-mesh生成
简介: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 9
ctb-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_TYPE=Release \ -DCMAKE_INSTALL_PREFIX=/opt/ctb \ -DGDAL_INCLUDE_DIR=/usr/include/gdal \ -DGDAL_LIBRARY=/usr/lib/x86_64-linux-gnu/libgdal.so四个参数逐个说:
CMAKE_BUILD_TYPE=Release打开 -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 TILED=YES \ -co COMPRESS=DEFLATE \ -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 TILED=YES把输出切成内部 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 84,NoData Value有你设的那个数,文件末尾出现Overviews列表。三项齐了才算过关。
4.2 ctb-tile 参数表与一条能直接抄的命令
参数不多,但每个都影响最终结果,我按使用频率列一下:
| 参数 | 作用 | 我一般怎么给 |
|---|---|---|
-o | 瓦片输出目录 | 独立磁盘路径,别放系统盘 |
-f | 输出格式 | Mesh出 quantized-mesh,Terrain出老式 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 切到一半进程被杀,日志空白
现象:跑了几个小时,进程突然消失,终端只有一行 Killed,dmesg里能看到 oom-killer 的记录。
原因:内存不够。常见诱因是没建金字塔、线程数给太满、或者输入 tif 用了未压缩的大块存储导致每次读取都解压整个条带。
解决:先确认gdaladdo建过金字塔,再按 4.4 节的方法把-c降到单线程试跑,观察 RSS 峰值。输入 tif 用-co TILED=YES -co COMPRESS=DEFLATE重新导出一份,读取模式会健康很多。加上-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」这三步,宁可多花十分钟,也不想在演示现场对着白屏找原因。希望帮到你。
本文还有配套的精品资源,点击获取