☰
Cesium地形瓦片制作:DEM切片、GDAL预处理与layer.json
2026/10/27 1:51:21 网站建设 项目流程

简介:面向Cesium三维地球与GIS可视化的瓦片地形生成工具包,聚焦将TIFF高程数据转换为Cesium可直接加载的Terrain瓦片,解决非专业GIS背景开发者在离线地形发布、高程数据接入中的格式转换与组织难题,可服务于地理测绘、城市规划、环境监测及灾害模拟等场景,适合具备一定Cesium基础、需要将自有高程数据接入三维场景的开发者。压缩包共108个文件、约16.17MB,包含可执行程序与DLL运行依赖、CSV投影及GDAL配置数据、GFS/WKT投影定义、XSD与SVG等辅助资源,以及Terrain样例、INI/JSON配置文件,构成从数据处理到服务输出的完整工具链。已有2379人学习下载。借助其中的转换程序、依赖库和投影参数,读者可搭建本地地形瓦片生产环境,理解TIF高程到Cesium Terrain的层级、瓦片元数据与二进制高程组织方式,并据样例排查投影与格式问题,适合需要定制3D地形展示的中高级开发者参考。

1. 把一块 tif 换成地形瓦片:它到底切的是什么

把一块 30 米分辨率的 tif 高程数据直接挂到 Cesium 上,很少有人一次成功。大概率的结果是:要么一片惨白的平地,要么加载卡顿、相机一动就掉帧。原因不复杂——Cesium 认的地形不是 GeoTIFF,而是一套按层级预先切好的 quantized-mesh 瓦片,每块.terrain文件里装的是量化后的顶点坐标、三角网索引,以及可选的顶点法线和水面掩膜。Cesium terrain builder 做的就是这层转换:把 tif 高程数据切成地形瓦片,同时生成一份layer.json,告诉引擎“我有哪些层级、哪些瓦片可用”。

它适合手上已经有 DEM、又要在三维场景里还原真实起伏的人:线路选线、土方量估算、园区视域分析、淹没范围推演,这些活儿用椭球面加贴图糊弄不过去。反过来,如果场景里只有楼群和管线、地面永远是平的,这东西属于过度设计。

2. 把 tif 收拾干净:GDAL 预处理与高程基准的四个动作

切片工具本身不背锅,绝大多数“切出来是平地”“地形整体下沉几十米”的问题,都发生在进入 ctb 之前。tif 只是一层容器,里面装的是投影、数据类型、NODATA 约定和高程基准,这四样任何一样不对,后面全是白干。

2.1 先看 gdalinfo:分辨率和数据类型决定后面所有事

拿到任何一份高程数据,我第一件事永远是gdalinfo,不是直接开切。重点看四个字段:Pixel Size决定你最多能切到第几级,Data Type决定要不要做类型转换,NoData Value决定要不要补洞,STATISTICS_MINIMUM/MAXIMUM决定数值范围是否合理。

# -stats 会顺带算一遍统计量,第一次跑慢一点,但能立刻暴露异常值 gdalinfo -stats dem.tif

逻辑说明:-stats让 GDAL 采样计算最小/最大/均值,写进.aux.xml。如果一份山区 DEM 的最大值只有 300 出头,而最小值是 -9999,那基本可以确定单位是米但 NODATA 没声明;如果最大值是 32767 或 65535,那多半是 Int16 存的无符号偏移数据,得先做一次缩放。

参数说明:这里没有可调参数,纯粹是体检。我一般会把输出存成文本,跟切片结果一起归档,后面出问题回查很方便。

数据类型上有一条实用经验:ctb 内部按浮点处理高程,输入给Float32最省心。Int16数据在转换时若没处理好 NODATA,会被当成真实的 -32768 米参与三角网构建,切出来就是一圈垂直的悬崖。

2.2 重投影到 EPSG:4326:ctb 只吃经纬度网格

ctb 的输出瓦片遵循地理坐标分幅,也就是每块瓦片的经纬度跨度都是180 / 2^z度,跟 Web Mercator 那套金字塔不是一回事。所以输入必须先落到EPSG:4326,投影坐标系(UTM、高斯克吕格、Albers)的 tif 必须先转。

gdalwarp \ -t_srs EPSG:4326 \ -r bilinear \ -tr 0.0002777778 0.0002777778 \ -te 100.0 25.0 110.0 35.0 \ -dstnodata -32768 \ -of GTiff -co TILED=YES -co COMPRESS=DEFLATE \ dem_utm.tif dem_wgs84.tif

逻辑说明:-t_srs指定目标坐标系;-r bilinear是重采样算法;-tr强制输出分辨率;-te裁出目标经纬度范围;-dstnodata统一 NODATA 值;-co是 GeoTIFF 的创建选项。

参数说明:0.0002777778度就是 1 弧秒,30 米数据在赤道附近约等于这个值,中纬度经度方向会略密。如果原数据是 90 米,用0.0008333333更合适——把低分辨率数据硬插值成 1 弧秒只会让文件变大,不会多出信息。-r的选择上,从高分辨率降采样用average更稳,从低分辨率升采样只能用bilinear或cubic,用average会得到一片马赛克。

提示:-te给的范围如果和原数据不完全重叠,边缘会出现一条 NODATA 带。这条带在切片时会被当成真实高程参与建网,所以要么把范围收进数据内部,要么在下一步补洞。

2.3 高程基准换算:椭球高才是 Cesium 的语言

这一步是最容易出问题的环节。Cesium 的地形是 WGS84 椭球高,而相当多的 DEM 提供的是相对大地水准面的正常高,两者差值在中纬度地区能有几十米。切完发现整片地形浮在空中或者陷进地心,基本就是这个原因。

# 简化做法:整体加一个常数偏移(适用于范围不大、精度要求不极端的场景) gdal_calc.py -A dem_wgs84.tif --calc="A+13.7" \ --outfile=dem_ellip.tif --NoDataValue=-32768 --type=Float32

逻辑说明:gdal_calc.py按像素做算术,A指代第一个输入,--calc是表达式。整体加常数相当于假设区域内大地水准面起伏可以忽略。

参数说明:13.7这个偏移量必须来自你所在区域的实际大地水准面差距值,不能照抄。范围超过几百公里就该用格网模型逐点改正,做法是给gdalwarp的源坐标系挂上 geoid 格网文件(+geoidgrids=xxx.gtx),让 GDAL 在重投影时顺手完成转换,比事后加常数严谨得多。--type=Float32别省,gdal_calc默认可能输出 Float64,文件直接翻倍。

2.4 NODATA 与边缘 buffer:别让切片在一圈空洞上崩掉

切片前最后两个动作:补洞和外扩。补洞是因为 ctb 对 NODATA 的处理并不总是符合预期,尤其在多层金字塔向下采样时,NODATA 会像墨水一样扩散。外扩是因为切片按固定经纬度分幅,数据边界往往正好落在瓦片中间,边缘瓦片会出现半块有效、半块空洞的情况。

# 1) 用邻域插值填补内部空洞 gdal_fillnodata.py -md 20 -si 1 dem_ellip.tif dem_filled.tif # 2) 向外扩一圈,避免边界瓦片残缺 gdalwarp -t_srs EPSG:4326 \ -te 99.9 24.9 110.1 35.1 \ -tr 0.0002777778 0.0002777778 \ -srcnodata -32768 -dstnodata -32768 \ dem_filled.tif dem_final.tif

逻辑说明:gdal_fillnodata.py用反距离加权从有效像素向内生长,-md 20是最大搜索距离(像素),-si 1是搜索步长。第二步把范围向外扩 0.1 度,让数据边界落在整块瓦片之外。

参数说明:-md别设太大,大范围补洞会产生假地形,宁可留空洞让引擎显示成海平面。外扩量取180 / 2^maxzoom的量级即可,0.1度对 14 级(约 0.011 度/瓦片)已经绰绰有余。

3. ctb-tile 切片实战:编译、参数与一次完整跑通

预处理做完,进入真正的切片环节。ctb 的构建不算复杂,但它对 GDAL 的链接关系比较敏感,编译阶段踩坑的人不少。

3.1 编译:GDAL 版本与 CMake 的绑定关系

源码包解压后,CMakeLists.txt会去系统里找 GDAL。如果你的机器上装了两个版本(比如系统包管理器一个、conda 一个),很可能链接到旧的那个,运行时报符号缺失。

mkdir -p build && cd build cmake .. -DCMAKE_BUILD_TYPE=Release \ -DGDAL_INCLUDE_DIR=/usr/include/gdal \ -DGDAL_LIBRARY=/usr/lib/x86_64-linux-gnu/libgdal.so make -j$(nproc) sudo make install

逻辑说明:显式指定 GDAL 的头文件目录和库文件路径,绕开 CMake 的自动探测。Release关掉断言,切片速度能差出一倍。

参数说明:-j$(nproc)用满核数编译,链接阶段内存吃紧的话降到-j4。装完直接跑ctb-tile -h,能打出帮助就说明链接没问题;报error while loading shared libraries就去ldd看是哪个 so 没找到,加进LD_LIBRARY_PATH或者ldconfig一下。

3.2 ctb-tile 参数逐个拆:-o、-f、-l、-C、-N、-s

不同分支编译出来的ctb-tile参数名会有出入,尤其是输出格式的枚举值,有的写terrain,有的写Mesh。第一条规矩:先跑ctb-tile -h,对着自己的二进制看。下面这张表是我常用的那套。

参数作用常用取值需要注意的点
-o输出目录./terrain目录不存在会创建,已有内容不会清空
-f输出格式terrain/terrain-water要水面掩膜必须选带 water 的那个
-l最大层级12 ~ 16每加一级,瓦片数约翻 4 倍
-C线程数物理核数的一半瓶颈通常在内存带宽,不在 CPU
-N计算顶点法线开启体积涨 20%~30%,光照才正常
-s重采样方法bilinear/average和 gdalwarp 的取值规则一致
-r重采样到指定尺寸32/64超大输入先降尺寸再切,省时间

关于-l的取值,给一个能直接用的估算:量化网格每块瓦片在经纬度方向各采样 65 个点,所以第z级的采样间隔约为180 / (2^z × 64)度。要让采样间隔贴住 1 弧秒(1/3600 度),解得z ≈ 13.3。也就是说,1 弧秒的数据切到 14 级基本就到顶了,再往下切只是把同样的采样点拆进更多瓦片,文件数暴涨、精度不涨。这一点很多人没算过,直接上 16 级,结果磁盘先撑不住。

3.3 一次完整跑通:从 dem.tif 到 ./terrain

# 建议先小范围试切,确认参数和效果再全量跑 ctb-tile \ -o ./terrain \ -f terrain \ -l 14 \ -C 8 \ -N \ -s bilinear \ dem_final.tif

逻辑说明:这条命令把dem_final.tif切成./terrain/{z}/{x}/{y}.terrain的目录树,同时在输出根目录写一份layer.json,并把每一级的可用瓦片范围记录进available数组。

参数说明:-C 8表示 8 个线程并发。实测下来线程数超过物理核数收益很小,反而容易触发内存交换——每个线程都要持有一份瓦片缓冲,1 弧秒数据每块瓦片约 65×65 个浮点,看着不大,但 8 个线程加上 GDAL 的块缓存,峰值内存很容易到几个 GB。机器内存 16 GB 的话,-C 4更稳。

耗时和产物体量得有个心理预期。以一块 10°×10° 的 1 弧秒区域为例,第 14 级单层的瓦片数在(10 / 0.010986)² ≈ 82 万的量级,加上低层级,总量在百万级,磁盘按几十 GB 准备。所以实际操作里我会先用-te裁一个 1°×1° 的小块跑通全流程,确认 Cesium 端能正常加载、起伏对得上,再放全量。全量跑之前把输出目录挂到大盘上,别放在系统盘。

# 小范围验证:先切一块,几十秒就能出结果 gdalwarp -te 105.0 30.0 106.0 31.0 dem_final.tif dem_test.tif ctb-tile -o ./terrain_test -f terrain -l 12 -C 4 -N dem_test.tif

3.4 用 ctb-info 和目录结构做切片后自检

切完别急着开浏览器,先做两个离线检查。一是看目录树是不是规整,0/0/0.terrain这种根瓦片必须存在;二是抽几块瓦片用ctb-info读头部,确认顶点数、索引数不为零。

# 找出层级分布,看看最高层是不是真的切到了 14 find ./terrain -name "*.terrain" | awk -F/ '{print $(NF-2)}' | sort -n | uniq -c # 抽查一块瓦片的头部信息 ctb-info ./terrain/14/13211/6832.terrain # 统计总体积 du -sh ./terrain

逻辑说明:第一条按路径里的层级号分组计数,能立刻看出最高层有没有生成;ctb-info打印量化网格头部,重点看Vertices和Triangles两个数;du用于估算部署时的存储和传输成本。

参数说明:如果最高层瓦片数为 0,通常是-l超过了数据实际能支撑的层级,或者-te范围算错了。如果某一块的Triangles为 0 但文件存在,说明那块区域全是 NODATA,回第 2 章补洞。

4. layer.json 与量化网格格式:切片目录里到底装了什么

很多人把terrain目录当黑匣子,一出问题就只能重切。其实这套格式相当透明,读懂两个东西就够了:.terrain文件的二进制头,和根目录的layer.json。

4.1 量化网格的 88 字节头:顶点、索引、边缘索引三段

.terrain用的是 quantized-mesh 格式。文件开头是一个固定长度的头部,之后依次是顶点数据、三角网索引、边缘索引,最后是可选的扩展段(顶点法线、水面掩膜、元数据)。头部里记录了中心点经纬高、数据范围、以及三段的字节长度。

读这个头不需要自己写解析器,ctb-info已经能打出来。但要理解一点:顶点坐标是量化的,存的是相对于瓦片范围的 0~32767 之间的整数,引擎按头部给的范围反算回真实经纬高。这意味着如果你手工拼瓦片,范围和顶点必须自洽,否则地形会错位——这也是为什么不建议手改.terrain文件。

法线段是 3 个字节的压缩法线,只有在切片时加了-N才会有。没有法线的地形,Cesium 会用平面着色兜底,山头看起来像一块块硬折的纸板。水面掩膜是一张 256×256 的位图,只在terrain-water格式下生成。

注意:加了-N之后,瓦片体积会明显变大。如果场景是正射俯视、不开光照,可以不加;只要开了enableLighting或者用GlobeMaterial做山体阴影,法线就是必需的。

4.2 layer.json 的 available 数组:Cesium 靠它决定请求哪块

layer.json是整个切片目录的目录索引。Cesium 启动时只请求这一个文件,之后所有的瓦片请求都由里面的available数组驱动。数组的第z项是一个二维矩形列表,描述第z级有哪些瓦片存在。

{ "tilejson": "2.1.0", "format": "quantized-mesh-1.0", "version": "1.0.0", "scheme": "tms", "tiles": ["{z}/{x}/{y}.terrain"], "projection": "EPSG:4326", "bounds": [100.0, 25.0, 110.0, 35.0], "minzoom": 0, "maxzoom": 14, "available": [ [[0, 0, 0, 0]], [[0, 0, 1, 1]], [[0, 0, 3, 3]] ] }

逻辑说明:format必须和实际文件格式一致,写成heightmap-1.0会让引擎按另一种解码方式读,结果是满屏噪点。scheme为tms表示 y 轴从南往北编号,这和常见的 XYZ 切片服务(y 从北往南)相反。available里每一项是[west, south, east, north]的瓦片索引矩形,不是经纬度。

参数说明:maxzoom要和切片时的-l对齐,写大了引擎会去请求不存在的层级,控制台一片 404;写小了则高层数据永远用不上。bounds是经纬度,用于相机初始定位和快速判断相机是否在数据范围内,稍微写大一点没有副作用,写小了会出现“飞过去就没地形”的怪现象。

4.3 目录层级与 TMS/XYZ 的 y 轴翻转

这个坑值得单独说。切片产物是{z}/{x}/{y}.terrain,其中 y 从南边界开始计数。如果你把这份数据挂到某个只认 XYZ 的静态服务或者 CDN 规则上,中间做了 y 翻转,那么 Cesium 请求的瓦片和实际文件会对不上,表现是地形整体上下颠倒、南北错位。

判断方法很简单:拿layer.json里的bounds和某一级瓦片的最大 y 值算一下。第z级瓦片总数是2^z,南边界对应的 y 应该是2^z - 1,而不是 0。打开浏览器网络面板,如果高层瓦片大量 404,而低层正常,先怀疑路径映射。

4.4 手工改 layer.json 的三种合法场景

改这个文件是允许的,但只建议在三种情况下动:一是裁掉了部分区域,available里还有残留索引,导致引擎请求不存在的瓦片,需要重新生成;二是把几份分块切片的数据合并成一个服务,需要把available数组按层级做并集;三是调整maxzoom做性能取舍。三种情况都建议用脚本处理,不要手写 JSON——层级一多,人眼根本对不齐。

import json, glob, os from collections import defaultdict root = "./terrain" layers = defaultdict(list) # 扫描磁盘上真实存在的瓦片,重建 available for path in glob.glob(os.path.join(root, "*", "*", "*.terrain")): z, x, y = path.replace(root + "/", "").split("/")[:3] layers[int(z)].append((int(x), int(y.replace(".terrain", "")))) available = [] for z in sorted(layers): xs = [p[0] for p in layers[z]] ys = [p[1] for p in layers[z]] available.append([[min(xs), min(ys), max(xs), max(ys)]]) with open(os.path.join(root, "layer.json")) as f: meta = json.load(f) meta["available"] = available meta["maxzoom"] = max(layers) with open(os.path.join(root, "layer.json"), "w") as f: json.dump(meta, f, indent=2)

逻辑说明:遍历目录收集真实存在的瓦片,按层级汇总成矩形范围,覆盖写回layer.json。defaultdict(list)省掉初始化判断,min/max保证矩形能包住所有瓦片。

参数说明:这段脚本把每层的可用范围压成一个矩形,对形状不规则的数据会多算一些空瓦片,但换来的是available体积可控。如果数据是分散的多块,就保留多个矩形,别硬压成一个,否则引擎会去请求中间那片空白。

5. 避坑与排查:五条把地形切废的血泪记录

下面这五条,每一条我都在真实项目里遇到过,且都不是工具的问题。

5.1 切片阶段的三条

现象一:切出来的地形是一片绝对的平面,高度全是 0。原因:输入 tif 的高程值域不对但不报错——最常见的是一份Int16数据,实际值是“米 × 10”的整型存储,或者数据本身是 0~255 的灰度晕渲图被误当成了 DEM。ctb 只负责读数值,不校验物理意义。 解决:回gdalinfo -stats看极值。如果是缩放存储,用gdal_calc.py --calc="A/10.0"还原;如果极值范围只有 0~255,那这份数据根本不是高程,换数据。

现象二:切片跑一半程序退出,日志里是bad_alloc或者被 OOM killer 干掉。原因:-C线程数给太高,或者输入 tif 没有做TILED=YES,GDAL 每次读取都从文件头扫一遍,内存占用随并发线性上涨。 解决:先把-C降到 4,再确认 tif 是分块存储。实在不行用gdal_translate -co TILED=YES -co COMPRESS=DEFLATE重新写一遍,再把大区域按-te拆成几块分别切,最后合并available。

现象三:地形边缘出现一圈垂直的悬崖,或者整块数据边界呈锯齿状。原因:数据范围刚好卡在瓦片分幅线上,边界瓦片一半有数据一半是 NODATA,而 NODATA 被当成了真实高程。 解决:切片前按 2.4 节向外扩一圈 buffer,或者用-e把切片范围显式收进数据内部一点。收范围会让边缘缺一小条,扩 buffer 更保险。

5.2 加载阶段的两条

现象四:浏览器控制台大量 404,但直接访问layer.json是 200。原因:url结尾少了斜杠,或者静态服务把.terrain当成未知类型返回了错误的Content-Type,或者跨域被拦。 解决:CesiumTerrainProvider的url结尾一定带/;服务端显式声明 MIME 类型并放开 CORS。用 curl 直接请求一块具体瓦片验证,能看到二进制才算通。

现象五:地形加载出来了,但整片浮在空中或者陷进地下几十米。原因:第 2.3 节的高程基准没处理。Cesium 用椭球高,数据是正常高,差值就是那几十米。 解决:确认数据的高程基准,按区域大地水准面差距做整体偏移或格网改正,然后重切。这个错误在山区看不出来,在平坦地区极其明显。

6. 从能跑到跑好:法线、水面与 Cesium 端加载验证

切片能加载只是及格线。真正上线前,我会固定走一遍验证流程:服务端类型、前端参数、渲染开关,三处对齐。

服务端用 nginx 托管切片目录时,关键是 MIME 和 CORS 两件事:

location /terrain/ { alias /data/terrain/; add_header Access-Control-Allow-Origin *; types { application/vnd.quantized-mesh terrain; } gzip off; }

.terrain是二进制量化网格,声明成application/vnd.quantized-mesh能让部分代理和 CDN 正确识别;gzip off是因为文件本身已经压缩过,再压一遍收益很低还费 CPU。

前端这边,Cesium 新版本把CesiumTerrainProvider改成了异步工厂方法,直接new会拿到一个未就绪的对象:

// 1.104 之后用 fromUrl,返回 Promise const terrainProvider = await Cesium.CesiumTerrainProvider.fromUrl( "http://127.0.0.1:8080/terrain/", { requestVertexNormals: true, // 切片时加了 -N 才有效 requestWaterMask: false, // 切片格式是 terrain-water 才有效 } ); viewer.terrainProvider = terrainProvider; viewer.scene.globe.depthTestAgainstTerrain = true;

requestVertexNormals和切片参数必须成对出现:切片没加-N却在前端打开,引擎会尝试解析不存在的法线段,轻则光照异常,重则瓦片解析失败。depthTestAgainstTerrain打开后,点和线才会被地形遮挡,做剖面和开挖分析时不开这个等于白做。

验证的最后一步是抽样比对。从layer.json的available里挑最高层的一块瓦片,算出它的经纬度范围,回到原始 tif 用gdallocationinfo取一个点的高程,再和 Cesium 里sampleTerrainMostDetailed的结果对比:

# 取某块瓦片中心点的原始高程 gdallocationinfo -valonly dem_final.tif 105.5 30.5
const positions = [Cesium.Cartographic.fromDegrees(105.5, 30.5)]; const sampled = await Cesium.sampleTerrainMostDetailed(terrainProvider, positions); console.log(sampled[0].height); // 应当与上一步的数值接近(差值来自量化误差)

两者的差值在几米以内属于量化网格的正常误差,超过十米就该回去查基准和偏移。这个对比我一般会在数据入库前跑十个点,覆盖最高、最低、边界三种位置。

早期我图省事,切完直接扔给前端看效果,觉得“有山有水就行”,结果一份数据在高程基准上偏了二十多米,做淹没分析时整片城区判断错误,返工重切加重新出图花了三天。从那以后,不管多赶,切片前的高程基准检查、切片后的抽样比对,我都会强制走一遍。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询