☰
Cesium地形瓦片生成实战:从DEM到quantized-mesh的完整接入指南
2026/9/26 4:48:27 网站建设 项目流程

简介:这套工具面向GIS开发者和三维可视化工程师,核心能力是将TIF格式高程数据转换为符合Cesium瓦片规范的地形数据,解决原始数字高程模型无法直接用于Web渲染的格式壁垒。资源包共108个文件,压缩包16.17MB,内含40个DLL动态库与4个EXE程序,构成地形转换与瓦片生成的主运行组件;另有29个CSV坐标文件、6个WKT投影定义文件,用于适配不同地理坐标系的高程数据;还附带GFS、XSD、DXF等辅助资料,整体是一套开箱即用的转换环境。利用这些文件可快速处理GeoTIFF数据,生成带层次细节的瓦片集,并通过Cesium地形服务接口加载,实现平滑过渡与流式渲染。该工具链免去自行编译GDAL与Cesium Terrain Builder的繁琐流程,降低地形数据处理门槛,已有2368人学习下载,适用于地理测绘、城市规划、环境监测与灾害模拟等场景。

1. 瓦片地形生成器解决的不只是切图:Cesium 加载高程的正确入口

做过 Cesium 项目的人大多经历过这个场面:团队拿到一块精度不错的 DEM 高程数据,导入 Cesium 后却渲染成一块平地,或者地形加载到一半就开始缺块变坑。真正的问题往往不在数据,而在于没有用对工具链——Cesium 并不直接读取普通 GeoTIFF,它需要的是按四叉树规则切好的地形瓦片。cesium terrain builder(大家常说的 CTB)正是这条链路里的关键一环:它把原始 DEM 转换成 Cesium 能直接调度渲染的地形瓦片,并输出成 quantized-mesh 格式,前端根据视点位置自动选择加载对应层级。网上讲 Cesium 模型、实体、粒子特效的教程很多,但讲瓦片地形生成的实操内容反而很少。这篇文章按照我实际跑通这条链路的顺序来写:先讲清楚格式和 LOD 调度的关系,再给出一套能直接复现的预处理、切片、部署命令,最后是参数调整思路和绕不开的坑,适合正在做数字孪生、应急仿真或航测数据接入的开发者。

2. 为什么必须先理解瓦片格式与 LOD:quantized-mesh 和工具选型

2.1 Cesium 不吃普通 DEM:一次地形加载请求背后的四叉树调度

Cesium 的地形渲染不是把一整块 DEM 扔给 GPU,而是采用四叉树金字塔的策略:把全球按经纬度范围逐层四分,第 0 层只有少数几个瓦片,每往下一层瓦片数量翻四倍,覆盖范围减半,地形网格顶点密度成倍增加。摄像机在场景里移动时,Cesium 根据屏幕空间误差(SSE)动态判断当前视角下哪个层级的瓦片足够精细,再通过网络请求把对应瓦片拉下来。

这个调度机制决定了 Cesium 对地形数据的基本要求:必须按瓦片组织,并且每个瓦片要携带几何误差信息,前端才能决定什么情况下抛弃父级瓦片、换用子级瓦片。普通 GeoTIFF 无论分辨率多高,都不满足这个结构,所以拿 DEM 直接塞进 Viewer 是行不通的。CTB 这类工具做的事情就是把这个四叉树切分过程自动化:读入 DEM,递归切片,写出带层级关系的地理坐标瓦片包。理解这一点后,就明白为什么生成地形瓦片不是简单 resize 图片,而是要对网格做重采样和误差估算。

还有一个容易被忽略的点:地形瓦片的切分基准是经纬度(geographic),也就是瓦片在经度方向从 -180 到 180 均匀分割,而不是像 2D 地图切片那样使用 Web Mercator。这意味着如果你以前用影像切片工具生成过 XYZ 图层,不能拿那套认知直接套到地形瓦片上。CTB 默认产出的目录结构是 z/x/y 组织,但它的空间参考和网格剖分规则和普通影像切片有本质区别,后面预处理时也要注意这一点。

2.2 quantized-mesh 与 terrarium:两种瓦片格式,差在一个存几何、一个存纹理

CTB 支持的输出格式主要包括 quantized-mesh-1.0 和 terrarium 两种。quantized-mesh 是一种专为地形网格设计的二进制格式:每个瓦片文件里保存了规则网格的顶点坐标(量化到 16 位)、三角形索引以及这个瓦片对应的几何误差值,可选携带法线和光照信息,Cesium 直接解析后提交 GPU 渲染,不需要在客户端重建网格,所以渲染效率很高,适合做生产环境的地形服务。

terrarium 则走了另一条路线:把每个像素的高程值拆成三个字节,分别塞进 PNG 的红、绿、蓝通道里,本质上是一张“高程编码纹理”。Cesium 加载这种瓦片后需要先解码纹理,再在客户端重新生成网格。它的优点是文件体积小、生成速度快,在一些低精度预览场景下够用;缺点是高程精度受 24 位编码限制,网格由客户端插值生成,边缘容易看出锯齿感,在 Cesium 版本迭代中对 terrarium 的原生支持也出现过调整。

我一般默认输出 quantized-mesh-1.0,只有做快速预览、或者地形数据只是当成辅助底图叠加时才考虑 terrarium。选型上不要花太多时间纠结格式,CTB 生成时通过参数切换即可。但要知道一个背景:Cesium 前端对地形服务的支持重心一直是 quantized-mesh,社区里能搜到的大部分地形瓦片生成器也都围绕这个格式做优化,跟着默认路线走最不容易翻车。

2.3 工具链选型:CTB、CTS 与 gdal2cesium 三条路线分别解决什么问题

做地形瓦片,常见的选择有三条:CTB、Cesium Terrain Server(CTS)以及 gdal2cesium。CTB 是离线切片工具,输入 GeoTIFF,输出完整的瓦片目录,适合数据范围确定、更新频率低的场景。CTS 则是 Node.js 写的动态服务,可以按需对入库的高程数据切瓦片,适合数据频繁更新的场景,但它更适合已有瓦片库做服务化封装,首次请求的耗时和资源占用会明显高于静态瓦片。gdal2cesium 是老牌的 Python 库,也支持 quantized-mesh 输出,但维护节奏比较慢,生成大范围地形时稳定性不如 CTB。

我的选型习惯是:静态数据、固定范围、要求加载性能稳定,首选 CTB 预切片;数据源经常更新、或者前期还没确定最终范围,先用 CTS 观察效果;gdal2cesium 只在没有 CTB 编译环境、且数据量很小的时候作为替补。下面一张表概括三者差异:

工具工作方式输出形态适合场景
CTB命令行离线切片静态瓦片目录固定范围、生产环境、离线部署
CTSNode.js 动态服务按需切瓦片数据更新频繁、原型验证
gdal2cesiumPython 脚本静态瓦片目录小范围、CTB 环境不可用时

选定 CTB 后,还有 C++ 版和 Python 重写版的区别。C++ 版功能完整但依赖 GDAL 和 boost,编译坑比较多;Python 重写版部署简单,命令也直观,我后面的操作示例以 Python 版为主,部分发行版里命令名略有差异,跑之前先看一眼--help就好。

3. 从 DEM 到可上线地形瓦片:预处理、生成与部署的完整操作

3.1 准备环境:为什么我推荐在 Linux 或 WSL 下跑而不是原生 Windows

CTB 的 Python 重写版依赖 GDAL 的 Python 绑定,安装相对简单,但 C++ 版在 Windows 上需要手动编译 GDAL、boost 等一堆依赖,版本稍微对不上就是一整天的排查。我自己的做法是:能用 Linux 就用 Linux,开发机是 Windows 的话就开 WSL,把 CTB 和 GDAL 都装在 Ubuntu 环境里,避免折腾原生编译。

环境准备阶段只需要确认两件事:GDAL 工具链可用、CTB 命令能跑起来。

# 1. 确认 GDAL 版本,这里要求 2.x 或 3.x 都可以,但 Python 绑定要对应 gdalinfo --version # 2. 检查 DEM 的坐标系、分辨率与数据范围,生成前必看 gdalinfo ./raw_dem.tif

gdalinfo --version输出的版本号决定了后面安装哪些依赖包,Python 版的 GDAL 库版本需要和系统 gdal 保持一致,否则会出现ERROR 4: Unable to open ...这类怪异问题。第二步的gdalinfo是每次拿到新 DEM 后的固定动作,重点看三处:Coordinate System is 后面的坐标系描述、Size 后面给出的像素宽高、以及 Band 1 的类型和 NoData 值。这三项直接决定预处理参数怎么设,漏掉任何一项都可能让生成结果在坐标上出大问题。

3.2 DEM 预处理:裁剪、重投影与降采样

CTB 按经纬度组织瓦片,所以源数据如果不在 EPSG:4326 坐标系下,必须先统一坐标系,否则切出来的瓦片位置会整体偏移,放进 Cesium 后地形会对不上影像。另一个问题是范围:很多 DEM 下载下来覆盖范围很大,比如整块 SRTM 分幅,直接丢给 CTB 切会白白生成大量无用瓦片,先裁剪能节省大量时间。

# 1. 先用矢量边界裁剪 DEM,并设置 NoData 值,避免边界被当成 0 处理 gdalwarp -cutline ./aoi.geojson -crop_to_cutline -dstnodata -32768 \ -t_srs EPSG:4326 -r bilinear -of GTiff ./raw_dem.tif ./dem_clip.tif # 2. 如果源 DEM 分辨率太高(比如航测 DSM 达到 0.5 米),先降采样到合适尺度 gdal_translate -outsize 20% 20% -r average ./dem_clip.tif ./dem_down.tif

第一段命令中,-cutline指定裁剪边界矢量,-crop_to_cutline让输出严格贴合边界范围,-dstnodata -32768很关键:如果不显式设置 NoData,裁剪后边缘的空白区域可能被当成 0 米海拔,生成的地形瓦片边缘会出现一圈深坑。-t_srs EPSG:4326把数据重投影到经纬度坐标,-r bilinear表示双线性重采样,对高程数据来说兼顾平滑和速度;如果数据源是陡峭山地,可以换成-r cubicspline,重采样后的地形会更接近原始山脊线。

第二段命令的降采样不是必需步骤,只有当源 DEM 像素密度远高于目标层级最大精度时才需要。判断标准很简单:先想清楚最终要用到第几级瓦片,再换算该层级下每个像素对应多少米。比如一个 5 米分辨率的 DEM 已经足够支持到 z14 级别的显示,就没必要用 0.5 米分辨率的原始数据硬切。

3.3 生成瓦片:从 prepare 到 tile 的最小命令

CTB 的生成过程一般分两步:先用ctb-preparedem对预处理后的 DEM 做内部整理,再用ctb-tile执行真正的四叉树切片。不同小版本的命令名有差异,有的发行版叫CTB-Tile,有的叫ctb-tile,但核心参数差别不大,第一条命令先执行ctb-tile --help确认即可。

# 第一步:prepare,把 DEM 整理成 CTB 可识别的输入结构 ctb-preparedem --out ./prepared ./dem_clip.tif # 第二步:正式切片,-e 为误差阈值,-f 指定输出格式,-o 为输出根目录 ctb-tile --error 0.5 --format quantized-mesh-1.0 \ --output ./terrain ./prepared/dem_clip.tif

这一步才是真正意义上的瓦片地形生成。ctb-tile按四叉树规则从顶层开始逐级切分,每个瓦片内部都带几何误差信息,Cesium 前端加载时会读取这些信息并参与 LOD 调度。--error 0.5表示当前视角下屏幕空间误差超过 0.5 像素时继续细分到下一层,数值越小瓦片越精细、数量越多,磁盘占用也会成倍增加,首次切数据时建议先设 1 看生成体量,再决定要不要收紧。--format quantized-mesh-1.0指定输出格式,老版本 CTB 里这个参数写作-t terrarium或-t qm,以当前版本 help 输出为准。

大范围数据切片非常耗时,一个覆盖几百平方公里、包含完整山区地形的 DEM,在普通 8 核机器上可能要跑数小时。建议先用 nohup 方式放到后台执行,并定期观察输出目录里的瓦片数量是否稳定增长。生成完毕后,./terrain目录下应当出现layer.json文件和按 z/x/y 组织的瓦片子目录。

3.4 部署与接入:nginx 暴露静态瓦片,前端 CesiumTerrainProvider 加载

生成好的瓦片是纯静态文件,最稳妥的部署方式是用 nginx 起一个静态文件服务,让前端通过 HTTP 访问。Cesium 加载自定义地形的第一步是请求layer.json,拿到瓦片格式、坐标系和 URL 模板后,才会按需请求具体瓦片。nginx 配置要注意两点:root 指向包含layer.json的那一层目录;地形瓦片通常是 gzip 压缩过的.terrain文件,需要开启gzip_static让服务器直接返回预压缩文件。

server { listen 80; server_name terrain.example.com; root /srv/terrain; gzip_static on; location / { add_header Access-Control-Allow-Origin *; add_header Content-Type application/octet-stream; } }

gzip_static on的作用是当请求文件存在对应的.gz版本时直接返回压缩内容,CesiumTerrainProvider 在请求头里声明了接受 gzip 编码,这样能显著降低瓦片传输体积。Access-Control-Allow-Origin *是为了避免前后端分离部署时出现跨域拦截问题,如果前端页面和地形服务在同一个域名下,这条可以不加。

前端接入的代码很简单,Cesium 的CesiumTerrainProvider直接接收服务地址即可:

const viewer = new Cesium.Viewer('cesiumContainer', { terrainProvider: new Cesium.CesiumTerrainProvider({ url: 'https://terrain.example.com', requestVertexNormals: true }) }); // 飞行到 DEM 范围中心点,确认地形瓦片是否加载 viewer.camera.flyTo({ destination: Cesium.Cartesian3.fromDegrees(lon, lat, 6000) });

这里的lon和lat应替换为你 DEM 数据的中心点坐标,可以从预处理后的 TIF 文件用gdalinfo查看 Center 字段得到。requestVertexNormals在生成瓦片时包含法线信息的场景下开启,可以让地形光照更有立体感;如果不确定瓦片里有没有法线数据,先保持false,避免部分层级请求报错。

4. 控制地形精度的三个参数:误差阈值、纹理开关与层级上限

4.1 误差阈值 error budget:它决定瓦片数量和地形精细度

CTB 生成命令里的--error参数直接控制 LOD 切分策略。Cesium 渲染时会给每个瓦片计算屏幕空间误差,当误差大于一定像素数时,浏览器就认为当前瓦片不够精细,继续请求它的子瓦片。--error设得越小,子级瓦片被请求的概率越高,地形在近距离观察时细节越丰富,但同时瓦片数量呈指数级增长。

实际操作中这个值的设置要结合数据分辨率和项目预算一起考虑。我处理过一组城市级数字孪生项目,DEM 是 5 米分辨率的航测 DSM,--error设到 0.2 时生成的瓦片总数是设 0.5 时的三倍还多,磁盘占用接近 40GB,而前端帧率反而因为瓦片请求数量过多而下降。反而是先设 1 跑通全流程,再按需把局部重点区域单独提高精度更实用。

判断阈值是否合理有一个土办法:生成完成后,随机挑几个较高级别的瓦片文件看体积。单个.terrain文件超过 200KB 说明网格细分已经相当密;如果 z14 级别的瓦片普遍只有几十 KB,说明精度富余,误差阈值可以适当调小一些。不同版本的 CTB 对--error的单位解释有细微差别,有的按像素、有的按米,第一次跑完看瓦片数量和平均大小,再回来调参数,比自己猜测靠谱。

4.2 纹理开关:地形瓦片里要不要同时切影像

部分 CTB 版本支持在生成地形瓦片时叠加影像纹理,让每个地形瓦片文件里同时包含高程网格和影像贴图。这个功能看起来很省事,实际使用中我却很少开启,原因是地形瓦片和影像瓦片的刷新频率、分辨率要求完全不同:地形数据一年更新一次已经算频繁,影像可能每个季度都要换新;把两者绑在一个瓦片文件里,意味着影像一变,地形也得重新切一遍。

更常见的做法是分开两条链路:CTB 只输出纯地形瓦片,影像另外通过标准的影像瓦片服务(TMS/WMTS)发布,前端分别加载terrainProvider和imageryProvider,让两者在渲染时叠加。这样做的好处是缓存独立、更新互不影响,调试单一问题也更容易。如果确实想让 CTB 顺带切影像,注意影像源的范围和坐标系必须和 DEM 完全一致,否则会出现地形和影像错位。

方案瓦片体积更新灵活性调试难度
地形瓦片叠加影像大影像更新需重切地形问题难定位
地形与影像分离小两链路独立更新各自排查即可

4.3 层级上限与覆盖范围:让瓦片数量保持在可控区间

CTB 生成时会根据 DEM 原始分辨率自动推算最大层级,但在实际项目中,这个自动值往往过于激进。比如一份 10 米分辨率的 DEM,理论上能支撑到 z16 级别的显示,但对一个只用于宏观展示的项目来说,切到 z13 就已经完全够用,再往下切只是白白消耗磁盘和生成时间。

控制层级上限的做法一般是在生成命令里显式指定最大层级,或者先在预处理阶段用gdal_translate降低分辨率,从源头上限制切分层级。配合范围裁剪,可以用下面这段 Python 粗略估算瓦片数量,用来判断参数是否合理。

def estimate_tiles(min_z, max_z, coverage_ratio): total = 0 for z in range(min_z, max_z + 1): # 全球范围第 z 层的瓦片总数为 2^z * 2^z total += 4**z * coverage_ratio return int(total) # 覆盖约 10% 的区域,从 z8 切到 z13 print(estimate_tiles(8, 13, 0.1))

这个估算没有把地形复杂度造成的额外细分算进去,但用来判断量级足够了。如果计算结果显示瓦片总数达到百万级别,就要考虑是不是范围没裁干净、或者误差阈值设得太小。生成瓦片数控制在十万级以内,静态文件部署和前端请求压力都会舒服很多。

5. 瓦片地形生成器常见问题与避坑:五条绕不开的实战记录

5.1 海拔为 0:整块地形变成一片平地

现象:前端地形能加载,瓦片请求也正常返回,但场景里所有区域都是平面,没有任何起伏。

原因:最常见的情况是 DEM 文件虽然看起来是地形数据,但波段类型或 NoData 值没有正确设置,CTB 读取时把所有值当作 0 处理。另一种可能是源数据里高程存储在多个波段,而生成工具只读取了第一波段,而第一波段存的是 RGB 合成影像。

解决:先用gdalinfo检查 Band 1 的 Type 字段,确认是 Float32 或 Int16,而不是 Byte。如果不是标准高程类型,用gdal_translate -ot Float32强制转换。同时检查 NoData 值:很多 SRTM 分幅数据在海洋区域是 -32768,如果预处理阶段没有保留这个 NoData,生成阶段可能把它插值成 0,造成大面积平地。转换完后再用 QGIS 或 Global Mapper 打开看一眼,确认灰度有明显起伏,再进入 CTB 流程。

5.2 瓦片边缘出现垂直悬崖和深坑

现象:相邻瓦片之间高度突变,山体边缘出现笔直的“切面”,或者某些区域直接凹下去一个大坑。

原因:这个坑通常是裁剪纸面边界时留下的 NoData 未被正确处理导致的。当 DEM 被gdalwarp -cutline裁剪后,边界外的像素默认填充为 NoData,如果 CTB 在读取时把 NoData 当成了 0 米,边界处就会从几百米的高程直接跌到 0,形成垂直断面。

解决:预处理时用gdal_fillnodata对边缘做平滑填充,或者对原始 DEM 先向外扩出一定像素的缓冲区再裁剪,让边界不再紧贴目标区域。后一种做法更简单:用gdalwarp时不要用-crop_to_cutline,而是先用-projwin稍微放大范围,切完瓦片后边缘多出来的部分反正不会被前端加载,不影响效果。

5.3 layer.json 404:Cesium 一直报 TerrainProvider 错误

现象:前端控制台报错,地形完全加载不出来,打开 network 面板能看到对layer.json的请求返回 404,有时还会出现viewer.scene.rendererror事件。

原因:layer.json是 CesiumTerrainProvider 加载地形的入口文件,nginx 的 root 路径指到了瓦片子目录,或者生成时没产出这个文件。部分 CTB 小版本确实存在不自动输出layer.json的情况,需要手动补。

解决:先用curl http://your-server/layer.json确认服务端能否访问。如果文件不存在,手动创建一个最小配置:

{ "format": "quantized-mesh-1.0", "scheme": "slippy", "tiles": ["{z}/{x}/{y}.terrain"] }

创建后放到瓦片根目录下,再确认 nginx root 指向的路径和 URL 访问路径一致。如果你的瓦片服务下有多个地形范围,layer.json需要按范围分别生成,并在 Cesium 的 url 参数里指到对应子目录。

5.4 地形瓦片整体漂移:山体出现在错误位置

现象:地形能加载也能显示起伏,但山的位置和影像底图错开,偏差从几百米到几公里不等,层级越低保偏得越明显。

原因:源 DEM 坐标系根本不是 EPSG:4326,CTB 默认按经纬度瓦片规则组织,导致数据被强行套到错误坐标上。还有一个常见原因:重投影时用了最近邻采样,而源数据分辨率较低时,像素格网点位移会在边界区域显现出来。

解决:回到预处理环节,用gdalinfo确认源数据坐标系,再用gdalwarp -t_srs EPSG:4326做重投影。重投影完成后不要急着切片,先在 QGIS 里叠加一个矢量边界看看位置是否基本吻合,确认无误再进入 CTB。这个过程看起来多花五分钟,实际上能避免生成完十几 GB 瓦片才发现位置全错的悲剧。

5.5 C++ 版 CTB 编译依赖地狱:GDAL 版本冲突

现象:编译 CTB 时在 CMake 阶段报错找不到 GDAL,或者编译过程中报缺少某个 boost 头文件,有的版本还会因为 GDAL 3.x 里删掉了旧 API 而直接编不过。

原因:CTB 源码本身开发时间较早,对现代 GDAL 3.x 的兼容性并不好,常见的问题版本组合是 CTB 配 GDAL 3.2+ 或 boost 1.70+。

解决:直接用 Python 重写版 CTB,绕开编译环节,这是我最推荐的方式。如果项目要求必须用 C++ 版,建议在 Docker 里固定一个 GDAL 2.4.x 的镜像环境来编译,不要把编译任务交给开发机。编译依赖问题如果超过半小时没解决,果断换工具链,CTB 不是唯一的瓦片生成方案,CTS 在某些场景下部署更省事。

6. 瓦片验证与后续演进:从离线静态瓦片走向服务化

瓦片生成完毕、前端也能加载之后,别急着宣布完成,先做一轮系统性的验证。我固定会做三个检查:第一,统计各层级瓦片数量是否和预估量级一致;第二,随机抽查几个瓦片文件,看体积是否在正常范围内;第三,打开前端开启帧率显示,飞行一圈观察瓦片请求是否稳定。

from pathlib import Path terrain_root = Path("./terrain") for z_dir in sorted(terrain_root.iterdir()): if z_dir.is_dir() and z_dir.name.isdigit(): tiles = len(list(z_dir.glob("*/*.terrain"))) print(f"z={z_dir.name}, tiles={tiles}")

这段代码遍历地形输出目录,统计每个层级下的.terrain文件数量。如果 z 层级分布出现跳层,比如 z12 有大量瓦片但 z11 数量异常少,说明生成过程中可能漏了部分范围,需要重新检查预处理裁剪边界。瓦片数量分布符合预期后,再在前端开启调试信息:

viewer.scene.debugShowFramesPerSecond = true; viewer.scene.globe.tileLoadProgressEvent.addEventListener((count) => { console.log('remaining tiles:', count); });

第一行显示帧率,第二行监听瓦片加载进度,飞行过程中如果 remaining 数值长时间不归零,说明有些瓦片请求一直失败,需要回到 nginx 日志里查哪些路径在 404。

再往后走一步:如果项目从静态地形要演变成频繁更新 DEM 的服务,考虑引入 CTS 把你的 CTB 输出目录作为基础瓦片源。CTS 可以直接读取已生成的瓦片目录对外提供动态服务,原有 CTB 瓦片不需要重新生成。新数据进来时只增量更新对应范围,旧数据保留作为回退版本,这样既保住了 CTB 离线切片的性能优势,又获得了服务化的灵活性。就地形瓦片生成这件事而言,很多所谓的高清问题不是玄学,而是 DEM 原生分辨率、误差阈值和层级上限三者匹配的结果,参数对上了,效果自然就出来了。希望帮到你。

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

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

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

立即咨询