自建Cesium地形瓦片:从DEM到Quantized-Mesh的完整实践
2026/9/8 6:07:32 网站建设 项目流程

简介:面向Cesium开发者与GIS可视化爱好者,这套工具包用于将GeoTIFF高程数据转换为Cesium可直接加载的地形瓦片,解决地形建模与Web端三维渲染之间的数据准备问题。压缩包内共108个文件,大小16.17MB,核心为可执行工具程序及配套动态库,另含大量坐标与投影参数表(csv)以及wkt、xsd、gfs等数据格式定义文件,覆盖从高程解析、坐标转换到瓦片生成的完整流程。目前已有2367人学习下载,适合需要将本地tif高程数据接入Cesium应用、实现定制化地形展示的地理信息相关开发人员,也适合希望理解工具原理的技术学习者。通过这套工具可快速搭建地形瓦片生成环境,将数字高程模型转为带层级结构的瓦片并在Cesium中渲染出平滑三维地形,借助配套说明中的流程与参数配置示例,可大幅节省自研格式解析与坐标转换的时间成本。

1. 为什么GIS开发到了一定阶段,迟早会自己动手切地形瓦片

先把我做这个事情的起因讲清楚。团队之前一直用在线地形服务,项目在平原区域跑得好好的,一进山区就出问题:加载慢、精度不够、高程数据有明显接缝,最致命的是离线环境完全没法用。甲方要求内网部署,数据不能出机房,在线方案直接废掉。所以当时摆在我们面前的选择其实不多——要么买商业软件切地形,要么自己找开源工具链。对比了授权成本和定制灵活度之后,我决定用 Cesium Terrain Builder 搭一套本地地形瓦片生成流程。这篇文章就是把我从零开始踩坑、调通、到最终跑进生产环境的完整过程记录下来,给同样被地形数据折磨的人一个参考。

Cesium Terrain Builder 是什么?简单说,它是一个能把 GeoTIFF 格式的高程栅格数据,切片成 Cesium 能直接识别和加载的量化网格地形(Quantized-Mesh)或者普通网格地形的工具。Cesium 本身不负责帮你切瓦片,它只负责运行时加载和渲染,所以在 Cesium 项目里要使用自定义地形,你必须有前置步骤:自己把原始高程数据处理成 Cesium 的地形瓦片格式,然后通过 TerrainProvider 接进场景。Cesium Terrain Builder 就是填这个空位的。

这篇文章适合谁看?第一类是正在做 GIS 项目、被在线地形服务卡住脖子的人,第二类是纯粹想搞懂地形瓦片底层机制、想掌握离线地形生产全流程的开发者,第三类是想避开我踩过的坑、一步到位把流程跑通的人。下面所有内容都以实际可复现为基准,命令能复制就复制,参数能解释就解释。

2. 环境选型与工具链搭建:Cesium Terrain Builder 的前置条件

2.1 我的环境版本组合,避免大家组合错

Cesium Terrain Builder 本身是一个 Python 2 时代的工具,这一点是很多人第一次跑就直接报错的最大原因。它的官方仓库长期没有大版本更新,依赖的是 GDAL 的 Python 绑定,而这些绑定在现代 Python 3 环境里兼容性参差不齐。我实测下来最稳的组合是:

  • 操作系统:Ubuntu 20.04 LTS(Windows 上用 WSL 或者 Docker 也可以,但原生 Linux 最省心)
  • Python:3.8(不要用 3.10 以上,GDAL 的二进制轮子容易出问题)
  • GDAL:3.2.x 版本
  • Cesium Terrain Builder:直接从 GitHub 拉取最新 master 分支,它只在 Python 3 下做过小幅兼容修复,clone 下来后需要手动安装依赖
  • Node.js:14+(用于后续本地预览验证)

提示:如果你只有 Windows 机器,优先装 WSL2 而不是在 Windows 原生环境里硬刚。GDAL 在 Windows 上的 Python 绑定坑太多,WSL 下基本就是 Ubuntu 体验,省下大量排查时间。

2.2 安装过程里最容易翻车的三个步骤

第一步,安装系统级 GDAL。不要试图用 pip 直接装 GDAL 库就完事,Cesium Terrain Builder 底层命令调用了 gdal_translate、gdaldem 这些可执行程序,所以系统里必须存在完整的 GDAL 工具链。Ubuntu 下执行:

sudo apt-get update sudo apt-get install -y gdal-bin libgdal-dev python3-dev

第二步,确认 GDAL 版本和 Python 绑定的版本要一致。这一步非常阴间:如果用 apt 装了 GDAL 3.2,那么 pip 安装 pygdal 时也必须指定相同的版本号,否则运行时会报莫名其妙的 "DLL load failed" 或者 "version `GDAL 3.4' not found" 之类错误。我当时的处理方式是直接用 pip 安装对应版本的绑定:

pip3 install pygdal==3.2.0

第三步,克隆 Cesium Terrain Builder 源码并安装依赖。它没有发布到 PyPI,所以必须从仓库拉:

git clone https://github.com/geo-data/cesium-terrain-builder.git cd cesium-terrain-builder pip3 install -r requirements.txt

这里有个细节,requirements.txt 里面依赖项比较老旧,如果 pip 解析失败,手动装一下 numpy 和 Pillow 基本能解决。

2.3 为什么这个工具链组合最合理

Cesium Terrain Builder 的技术原理并不复杂,它的核心流程是:读取输入的高程 GeoTIFF,依据四叉树结构把地形切割成一层一层的小瓦片,每个瓦片内部再按 Cesium 的 Quantized-Mesh 规范编码顶点数据、法线数据和错误度量。Quantized-Mesh 的核心思路是:用 16 位整数来量化表示每个顶点的 x、y、z 坐标,相比原始的浮点坐标能节省一半的存储空间;误差度量则用于 LOD 切换——当屏幕空间误差超过阈值时,Cesium 会请求更高精度的瓦片。

GDAL 在这个链条里的作用是数据读入与重投影。原始高程数据往往是 WGS84 经纬度坐标系,但某些项目会涉及 CGCS2000 或者 UTM 投影,GDAL 负责把数据统一到 Cesium 需要的坐标系。关于 CGCS2000 和 WGS84 的差异,国内项目经常遇到,这里先埋个伏笔,后面单独讲。

3. 从 DEM 到地形瓦片的完整生产流程:一步一步拆解

3.1 原始高程数据的准备与预处理

地形瓦片的质量上限由原始数据决定,这一步偷懒后面全完。我使用的测试数据是一块 20 公里范围的山区 DEM,格式是 GeoTIFF,分辨率 10 米。拿到数据后第一件事不是直接切片,而是检查数据完整性。

检查命令:

gdalinfo dem_origin.tif

重点看几个信息:像素尺寸、波段数、坐标系、像素深度。地形高程数据一般是单波段 Float32 或者 Int16,如果发现 RGB 三波段,那说明拿错了数据,那可能是影像而不是 DEM。

接下来要处理数据范围内的无效值。很多 DEM 在无数据区域使用 -32768 或者 0 填充,这些值如果不处理,切出来的地形会出现巨大的凹陷或者尖刺。我当时用 gdal_translate 加 a_nodata 参数处理:

gdal_translate -a_nodata -32768 -ot Float32 dem_origin.tif dem_clean.tif

注意这里我把数据格式统一转成了 Float32,因为后续 gdaldem 做坡度计算和山顶点提取时,Float32 比 Int16 精度更高,尤其是陡峭山区,Int16 的高度量化误差会在切出来的地形上表现为一层一层的台阶感。

3.2 生成地形切片前必须做的两项检查

切片前我强烈建议做一个"地形体检":第一是坡度分析,第二是等高线预览。这两个操作不是为了炫技,而是为了在投入切片时间之前发现数据问题。

坡度分析命令:

gdaldem slope dem_clean.tif slope.tif

用 gdalinfo 看一下 slope.tif 的最大值,如果在平原地带出现坡度 90 度的像素,多半是无效值没清理干净,要回头重新处理 NODATA。等高线预览命令:

gdal_contour -a elev -interval 50 dem_clean.tif contour.shp

把 contour.shp 拖进 QGIS 叠加原始影像看一眼,重点看等高线有没有突变、断裂、同心圆异常,这些往往是数据拼接时产生的错误。

提示:体检查出来的问题,90% 都出在无效值处理上。尤其要注意 DEM 边缘,很多数据在边缘会有一圈无数据缓冲带,这圈缓冲带不上。会被正常处理,但会导致边缘瓦片出现异常的高程跳变。

3.3 Cesium Terrain Builder 切片命令的完整示例与参数解析

准备工作做足后,终于进入正式切片环节。Cesium Terrain Builder 提供两个命令:ctb-tile 用于处理普通网格地形,ctb-quantized-mesh 用于生成 Quantized-Mesh 地形。我一般直接使用 quantized-mesh 版本,因为它是 Cesium 官方推荐格式,加载性能更好,而且支持地形压平和河流材质等高级特性。

基础命令:

ctb-quantized-mesh -o ./terrain_tiles -f gzip -l 10 -c 4326 dem_clean.tif

这里参数的含义我一个个解释:

  • -o 指定输出目录
  • -f gzip 开启瓦片 gzip 压缩。Quantized-Mesh 瓦片本身有压缩,再做 gzip 能进一步缩小体积,但代价是运行时 CPU 解压开销。内网环境带宽充裕时可以不开,公网环境强烈建议开
  • -l 10 指定最大层级。这个和原始分辨率、数据范围有关,不是随便拍的,计算方式后面讲
  • -c 4326 指定输出坐标参考系。Cesium 默认的 WGS84 经纬度坐标系编码是 4326

关于最大层级的计算公式,我自己推导了一个经验算法。假如原始 DEM 分辨率为 10 米,那么 1 度纬度对应的地面距离约为 111 公里,即 111000 米。在 Cesium 的瓦片金字塔中,层级 0 的瓦片大小为 90 度 x 90 度,之后每增加一层,瓦片边长减半。那么第 n 层瓦片的地面分辨率为:

分辨率 = 111000 * 90 / 2^n 米/像素

要想让瓦片分辨率接近原始 DEM 的 10 米,代入公式:

111000 * 90 / 2^n ≈ 10

解得 n ≈ 19.9。但实际项目中,Cesium 的 terrainProvider 会根据屏幕空间误差自动选择加载层,且浏览器端纹理分辨率有限,过多的层级只会增加服务器存储压力而不会带来视觉提升。我实践下来,10 米的 DEM 在球面场景下最高切到 14 级就完全够用了,超过 14 级后屏幕上也看不出差别。如果你的数据是 30 米分辨率(如常见的 SRTM),按同样公式算出来是 15 级左右,实际切到 12 级就够。

3.4 批量切大范围数据的分块策略

直接拿一整个省、一整个市的大范围 DEM 跑 ctb-quantized-mesh,大概率会跑到中途内存爆炸或者磁盘占满。我的做法是先分块,再合并。

分块思路:用 gdal_retile 把大数据集切成一堆小 GeoTIFF,每个小块的尺寸控制在 2000 x 2000 像素以内。这样每个块单独执行切片命令,互不干扰,还能断点续跑。分块命令:

gdal_retile -ps 2000 2000 -targetDir ./tiles_src dem_clean.tif

然后写一个简单的 shell 循环,对每个块执行 ctb-quantized-mesh:

for f in ./tiles_src/*.tif; do ctb-quantized-mesh -o ./terrain_tiles -f gzip -l 14 -c 4326 "$f" done

注意这里所有块的输出目录都指定到同一个 terrain_tiles 目录,Cesium Terrain Builder 会自动拼接相邻块的边线,最终生成完整的瓦片金字塔。但这里有一个前提:每个块的坐标范围必须有少量重叠,否则相邻块之间会有一条缝。我实际中在 gdal_retile 分块时会给每块增加一圈缓冲像素,通过参数 -ovr 或预处理时用 gdalwarp 额外扩展边缘。

4. 地形瓦片生成后的本地验证与 Cesium 接入

4.1 用本地静态服务器验证瓦片是否可用

切片完成不是终点,接入 Cesium 之前先做一次静态验证。Cesium Terrain Builder 生成的目录结构包含了 layer.json 文件,这是地形的元数据声明,Cesium 会首先请求它。验证方法很简单,在 terrain_tiles 目录下启动一个静态服务器:

cd terrain_tiles python3 -m http.server 8080

然后在浏览器访问:

http://localhost:8080/layer.json

能正常返回 JSON 就说明基础结构没问题。接着检查一个具体的瓦片文件,比如:

http://localhost:8080/0/0/0.terrain

这里路径的含义是:层级 0 / X 瓦片 0 / Y 瓦片 0,扩展名 .terrain 或者 .terrain?v=1,取决于你生成的格式。如果返回的是二进制内容而不是 404,瓦片生成成功。

注意 Cesium 的地形请求地址格式是{baseUrl}/{z}/{x}/{y}.terrain,与影像瓦片的{z}/{x}/{y}.png地址结构一样。这点很多人在自建地形服务时搞混,导致 Cesium 一直报 404。

4.2 在 Cesium 中加载本地地形瓦片的最小代码示例

Cesium 加载自定义地形 Provider 的方式是我见过最友好的之一,几行代码搞定:

const viewer = new Cesium.Viewer("cesiumContainer", { terrainProvider: new Cesium.CesiumTerrainProvider({ url: "http://localhost:8080/terrain_tiles", }), });

如果你的场景里已经生成了 viewer 实例,也可以动态切换地形:

viewer.terrainProvider = await Cesium.createWorldTerrainAsync(); // 切换回自建地形 viewer.terrainProvider = await Cesium.CesiumTerrainProvider.fromUrl( "http://localhost:8080/terrain_tiles" );

这里有几个细节要留意。第一,CesiumTerrainProvider 构造函数的 url 指向的是包含 layer.json 的目录,不要指到 layer.json 文件本身。第二,如果地形瓦片开了 gzip,Cesium 会自动根据响应头中的 Content-Encoding 解压,你不需要在代码里做任何额外处理。第三,如果瓦片请求出现跨域问题,静态服务器需要配置 CORS 头,在本地测试时最简单的方式是用--cors参数启动 server,生产环境建议走 Nginx 统一配置。

4.3 地形加载不出来的十大原因排查

我把实际项目中遇到过的加载失败原因整理成了一张表,方便大家对照排查:

现象可能原因解决方案
layer.json 404静态服务器路径配置错误确认 url 指向包含 layer.json 的目录
layer.json 200 但无瓦片瓦片路径与 Cesium 请求不匹配检查瓦片目录层级结构
地形黑色或透明Quantized-Mesh 文件损坏重新生成瓦片,检查磁盘空间
地形加载正确但高程偏差原始 DEM 坐标参考系错误用 gdalinfo 确认坐标系,必要时重投影
部分瓦片 404 但其他正常分块拼接时有缝隙重新分块,增加缓冲区域
浏览器报 CORS 错误静态服务器未配置跨域配置 CORS 头
加载极慢gzip 压缩未开启切片时加 -f gzip
高层级瓦片不加载最大层级设置过低提高 -l 参数
闪一下地形然后消失地形数据源被后加载的数据覆盖检查代码执行顺序
水面以下地形异常高程数据包含海平面以下的负值检查 NODATA 值处理

5. 实战中绕不开的进阶话题:CGCS2000、地形压平与性能调优

5.1 国内项目的坐标系适配问题

国内很多项目要求用 CGCS2000 坐标系,而 Cesium 底层默认是 WGS84。虽然两者在高程和经纬度上的差异极小,但在地形瓦片生产流程中,坐标系不统一会导致切片位置偏移、边界不吻合。

实操中我的处理办法是:在进入 Cesium Terrain Builder 之前,用 gdalwarp 把 CGCS2000 数据重投影到 WGS84:

gdalwarp -t_srs EPSG:4326 -r cubic dem_cgcs2000.tif dem_wgs84.tif

这里 -r cubic 用的是三次卷积重采样,相比默认的 nearest 能更好地平滑高程值,避免地形表面出现马赛克化的棱角。

注意:重投影这个操作会引入一定误差,但 10 米分辨率的 DEM 在重投影到 WGS84 后误差在厘米级,对地形可视化来说完全无感。如果你做的是精密测绘分析,那就另当别论,需要在专业软件里做严格的数据融合。

5.2 地形压平:让规划区域变成"桌面"

项目里经常碰到一个需求:在三维场景中,规划用地范围内的地形要压平,方便叠加建筑方案。这个功能在很多商业 GIS 平台里是收费模块,但基于 Cesium 和自建地形,自己实现起来并不复杂。

原理很简单:地形压平的本质,是在运行时对地形几何做裁剪和平整处理,而不是修改瓦片文件。Cesium 从 1.55 版本开始提供了Cesium.HeightmapTerrainData相关的接口,通过重写地形数据的采样函数可以实现局部压平。网上也有很多开源库实现了这个功能,核心思路是把指定多边形区域内的顶点高程强制设置为一个常量值,同时保持区域边缘的过渡平滑。

我在项目中用了另一种更轻量的方案:在服务端直接把 DEM 数据中对应多边形区域内的高程值修改为某个固定高程,然后重新切片。这样做的好处是编译部署简单,缺点是修改后如果要恢复原样需要重新生成瓦片。做法是用 GDAL 的 Python 绑定点位修改:

from osgeo import gdal, ogr def flatten_dem(dem_path, shp_path, flat_height): ds = gdal.Open(dem_path, gdal.GA_Update) band = ds.GetRasterBand(1) arr = band.ReadAsArray() shp_ds = ogr.Open(shp_path) layer = shp_ds.GetLayer() # 此处仅示意,实际需要用 GeoTransform 做像素坐标转换 # 在 polygon 范围内做栅格化掩膜 mask = rasterize_layer(layer, ds) arr[mask > 0] = flat_height band.WriteArray(arr) ds.FlushCache()

这个方案比较适合一次性规划场景,如果你想运行时动态压平,Cesium 官方在 1.107 版本后也提供了Cesium.HeightReference配合clippingPolygons的实现路径,不过性能开销会明显增加。我个人在方案选型时倾向于:静态压平数据量少、需求固定时用 DEM 修改,需求频繁变化时用运行时压平。

5.3 关于 Cesium 相关热词中"强制 ground primitive 更新"的思考

搜索热词里出现了"cesium 强制 groundprimitive 更新",这其实是地形瓦片加载中一个隐蔽的坑。当你在场景中动态加载多个 terrainProvider 时,旧的 GroundPrimitive 可能不会自动重新计算高程,导致贴地要素悬浮或陷入地下。

解决这个问题,我试过最有效的手段是手动触发 viewer.scene.requestRender(),因为 GroundPrimitive 在多数情况下需要一次新的渲染循环才能重新拾取地形高度。如果这也解决不了,就重置地形提供者:

const terrain = viewer.terrainProvider; viewer.terrainProvider = undefined; viewer.terrainProvider = terrain;

这个方法虽然粗暴,但在某些版本下确实有效。关键是要清楚 Cesium 的地形加载是异步的,terrainProvider 切换后,地形瓦片数据传输需要时间,GroundPrimitive 的更新依赖于地形就绪事件,不要在地形未就绪时就提前操作贴地要素。

5.4 动态光照、雷达效果与地形结合的性能开销

热词里还有"cesium 动态光照"和"cesium雷达"。这些效果在三维 GIS 里确实吸引眼球,但如果和自建地形叠加,需要注意帧率开销。我项目里做过一个雷达扫描效果,扇形范围动态扫过山区地形,最直观的性能瓶颈不是雷达本身,而是每次扫描时地形表面需要重新着色,这触发了大量的渲染状态切换。

优化策略是:把雷达扫描效果做成纹理动画,而不是逐帧修改地形材质。用 Cesium 的CustomShader(1.91 版本后支持)在片元着色器里叠加动态效果,不需要每帧生成新纹理。实测下来地形瓦片本身不参与计算,GPU 只额外处理一个全屏的后期合成效果,帧率能维持在 60 帧上下。

提示:自建地形瓦片在接入这些视觉效果前,先确认地形支持法线贴图。Cesium 地形默认情况下不启用光照阴影,需要在使用地形时设置requestVertexNormals: true,否则地形表面是平的,光照和雷达效果会非常假。

6. 磁盘占用、自动化流水线与部署架构

6.1 瓦片体积实测与存储策略

我切出的那块 20 公里 x 20 公里的山区,10 米分辨率 DEM,共生成 6 个层级(9 到 14 级)的地形瓦片,最终磁盘占用约 2.3 GB,数据 90% 集中在最高层级。这说明地形瓦片的存储压力几乎全部由最高层级贡献,低层级可以忽略不计。

这带来一个部署启示:如果你的服务器磁盘紧张,可以考虑只切低层级的地形瓦片,高层级用影像瓦片去骗过用户的眼睛。因为地形细节在缩放到很小比例尺时完全看不出来,但磁盘节省可能达到 10 倍以上。

另外,gzip 压缩对 Quantized-Mesh 的压缩率大约在 30% 到 50%,地形越复杂压缩率越低。如果项目对抽稀率敏感,可以尝试用-f none关闭 gzip,配合 HTTP 层的 Brotli 压缩,有时比 gzip 更高效。

6.2 数据更新时的增量重切策略

真实项目中 DEM 数据不会一成不变。比如工程完工后测了新的高程数据,地形需要局部更新。全量重切费时费力,我采用的方法是按瓦片更新:先定位到发生变化的区域的瓦片编号,只针对这个范围内的瓦片重新执行 ctb-quantized-mesh,再覆盖到服务器。

瓦片编号的计算逻辑是:根据经纬度坐标反算瓦片行列号。Cesium 的瓦片编号规则是 Web Mercator 的变体,在层级 z 下,全球被划分为 2^z 列、2^(z-1) 行,西经 180 度为 0 列,北纬 90 度为 0 行。计算某一个经纬度坐标对应的行列号,我写了一个小函数:

import math def latlon_to_tile(lat, lon, z): n = 2 ** z x = int((lon + 180.0) / 360.0 * n) lat_rad = math.radians(lat) y = int((1.0 - math.asinh(math.tan(lat_rad)) / math.pi) / 2.0 * n) return x, y

用这个函数确定更新范围后,再调用切片工具只切对应瓦片,实测能把重切时间从 30 分钟缩短到 2 分钟以内。

6.3 用 Nginx 托管地形瓦片的最佳实践

生产环境不推荐用 Python 自带 HttpServer,Nginx 是更稳的选择。我的 Nginx 配置核心要点是开启 gzip 和缓存,让地形瓦片尽可能复用在客户端。

server { listen 80; server_name terrain.example.com; root /data/terrain_tiles; location ~* \.(terrain|json)$ { gzip on; gzip_types application/json application/octet-stream; expires 30d; add_header Access-Control-Allow-Origin *; add_header Cache-Control "public, max-age=2592000"; try_files $uri =404; } }

注意 Cesium 的地形请求会携带一个?v=1参数,在 Nginx 里默认会被视为不同的请求路径而 miss 缓存,可以在 location 块里加if ($args ~* "v=1") { set $args ""; }来去掉版本参数,提高命中率。

7. 回到开头:这套流程到底值不值得自建

Cesium Terrain Builder 不是万能的,它的社区活跃度不高、文档不算完善、对新格式支持滞后,但对于绝大多数需要离线地形的 Cesium 项目来说,它依然是当前最可靠的开源选择。我见过有人用 Node.js 重写了同样的功能,也有人直接调用 Cesium 官方的地形服务然后做数据子集下载,但这些路线的定制成本都比基于 Cesium Terrain Builder 高得多。

在投入到自建地形流水线之前,先算一笔账:数据量多大、更新频次多高、是否需要内网隔离。如果数据量小且可以接受在线地形,那真没必要自建;但如果数据涉密、需要实时更新、要定制渲染效果,那自建地形是完全正确的方向。

最后再分享一个小技巧:在切片之前,先给团队做一版低层级地形(比如最大 8 级),整个切片工作在 10 分钟内就能完成。先用它跑通全流程、验证 Cesium 接入代码和后端部署架构,确认无误后再切完整的高层级瓦片。这样能避免在最耗时的全量切完之后才发现在最前面就配置错了,返工成本会非常高。这套流程我在两个项目里完整跑过,一次因为坐标系问题返工,一次因为 gzip 配置问题排查了半天,把这两条教训写在这里,希望能让你少走这两段弯路。

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

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

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

立即咨询