Cesium离线地形切片:DEM到quantized-mesh加载

发布时间:2026/10/25 7:29:26
Cesium离线地形切片:DEM到quantized-mesh加载

简介:面向使用Cesium搭建三维地球、又需要在断网环境展示地形起伏的开发者,这份资料把DEM高程tif转为Cesium可读离线terrain瓦片的整套流程打包在一起,省去分别寻找Python、GDAL等旧版依赖的麻烦。压缩包共11个文件,约45.9MB,以msi与exe安装程序、py脚本、pdf说明为主,另附json配置、terrain样例与txt提示,兼顾环境搭建与转换实操。已有3095人学习下载,说明该离线地形方案在实际项目中需求稳定。其中安装包覆盖Python 2.7、GDAL、PIL、NumPy等地理处理基础组件,两个脚本分别承担坐标配准与DEM切片,示例DEM数据和生成后的tiles结果可用于对照验证,layer.json便于接入Cesium地形提供者;配套PDF与说明文本记录步骤、参数与常见报错处理,适合希望一次跑通链路并自行调优切片参数的中级开发者。

1. 从一份 DEM 到能离线加载的地形:Cesium terrain 切片到底在切什么

Cesium生成离线terrain地形数据的核心,不是把 GeoTIFF 改个后缀,而是把高程栅格重采样成一颗颗带索引的 quantized-mesh 瓦片,再配一份 layer.json 告诉浏览器“哪一级、哪一块、去哪拿”。我见过不少园区三维项目,底图能离线,模型能离线,一到地形就露白,根子多在切片网格和 NoData 没对齐。这套程序适合做内网三维地球、离线指挥大屏、无外网环境地形底座的团队;新手可以按步骤跑通,熟手重点看参数和验收。它解决的是“没有在线地形服务时,Cesium 仍能按 LOD 加载本地地形”的问题。

2. DEM 预处理:把投影、NoData 和分辨率一次讲透

2.1 为什么不能直接拿原始 DEM 去切片

很多翻车现场从第一步就埋下了。手里拿到的 DEM 可能是 UTM 投影、地方坐标系,或者带偏移的经纬度栅格,而 Cesium 地形瓦片默认按 WGS84 地理坐标组织,瓦片行列号也有一套固定规则。源数据坐标系不统一,切片器会按错误的范围算瓦片索引,最后在 Cesium 里看到的地形要么整体偏移,要么边缘被裁掉一块。另一种常见情况是 NoData 值没处理:原始 DEM 用 -9999 表示空洞,切片器把它当成真实高程,生成出来的瓦片就会出现巨大尖刺或黑坑。还有人把 30 米分辨率的 DEM 直接切到 18 级,瓦片数量爆炸,浏览器加载到高层级就卡死,这种问题不是 Cesium 的锅,是层级上限没跟数据分辨率对齐。

我一般会先把“源数据能不能用”这件事拆成三个检查项:坐标系是不是 WGS84、NoData 值是否明确、像元大小对应到 Cesium 的哪一级。三项里有一项含糊,后面切出来的地形都像开盲盒。常见做法是先用gdalinfo看元数据,再用gdalwarp统一投影和 NoData,最后用脚本抽查边界值。不要急着上切片命令,预处理省下来的十分钟,往往能省掉后面两小时的排查。

2.2 用 gdalwarp 统一到 WGS84 与目标瓦片网格

下面这条命令是我处理大多数 DEM 的起点。它把源数据重投影到 WGS84,统一 NoData,并写成带分块的 GeoTIFF。分块和压缩不是玄学,切片器读大文件时,分块栅格比条带栅格少很多随机 I/O,尤其是几十 GB 的 DEM,差距很明显。

# 查看源数据基本信息:坐标系、尺寸、NoData、像元大小 gdalinfo -stats ./raw_dem.tif # 重投影到 WGS84,统一 NoData,输出带分块的 GeoTIFF gdalwarp -overwrite \ -t_srs EPSG:4326 \ -r bilinear \ -srcnodata -9999 \ -dstnodata -32768 \ -of GTiff \ -co TILED=YES \ -co COMPRESS=LZW \ -co BIGTIFF=IF_SAFER \ ./raw_dem.tif ./dem_wgs84.tif

-t_srs EPSG:4326是目标坐标系,Cesium 地形瓦片按经纬度组织,这一步不要省。-r bilinear是重采样方法,常规地形用双线性比较稳,陡峭山区可以试cubic,但不要用最近邻,最近邻会把阶地和高程突变放大成台阶。-srcnodata必须跟源数据真实 NoData 一致,源数据没有 NoData 时不要硬写,否则会把正常高程当空洞裁掉。-dstnodata -32768是给后续切片器一个明确的无效值标记,具体值不是重点,重点是“源和目標要一致”。-co TILED=YES让输出变成分块栅格,COMPRESS=LZW是无损压缩,BIGTIFF=IF_SAFER是给超大文件留后路。

如果源数据已经是 WGS84,只是范围或分辨率不合适,可以把-t_srs保留,再加-tr指定目标像元大小。比如要统一到约 1 弧秒,可以写-tr 0.0002777778 0.0002777778。但不要为了“看起来整齐”强行重采样到高于源数据精度的分辨率,那只是把 30 米数据插值成 10 米,文件变大,信息量没增加,Cesium 加载时还会多读一堆无意义的瓦片。

2.3 检查 NoData、边界和分辨率的三条命令

预处理做完不等于能切。我习惯用三条检查把问题拦在切片之前:元数据、边界采样、统计分布。元数据看坐标系和 NoData 有没有写进去;边界采样看四角是不是异常值;统计分布看最小最大值有没有离谱。下面这段 Python 用 rasterio 读一遍,输出比肉眼翻元数据更直观。

import rasterio import numpy as np with rasterio.open("dem_wgs84.tif") as ds: print("CRS:", ds.crs) print("SIZE:", ds.width, ds.height) print("NODATA:", ds.nodata) print("TRANSFORM:", ds.transform) # 读取第一波段,masked=True 会把 NoData 标成掩膜 arr = ds.read(1, masked=True) print("MIN/MAX:", arr.min(), arr.max()) print("NODATA_COUNT:", arr.mask.sum()) # 抽查左上、右上、左下、右下四个角 h, w = arr.shape corners = [arr[0, 0], arr[0, w - 1], arr[h - 1, 0], arr[h - 1, w - 1]] print("CORNERS:", corners)

ds.crs应该是EPSG:4326,不是的话说明gdalwarp没生效或者读错了文件。ds.nodata要和命令里的-dstnodata对上,读出来是None就要回头检查。arr.mask.sum()是无效像元数量,太大不一定是坏事,说明边界裁切正常;太小则要怀疑 NoData 没写进去。四个角的值最容易被忽略,如果角上出现 -32768 或异常大值,切片后就是边缘黑块。统计最小最大值时不要只看全图,最好按区域抽查,尤其是跨带或跨山地的 DEM。

2.4 在 Cesium 里看到的“地形空洞”多半是这一步留下的

地形空洞在 Cesium 里通常表现成两种:一种是黑色或透明的破洞,另一种是突然凹下去的平底坑。前者多是 NoData 没被切片器识别,后者是把 NoData 填成了 0 或某个固定高程。血泪经验是:不要用“填 0”解决空洞。0 在海拔里是海平面,山区里填 0 会造出一块平地,视觉上比空洞更难受。正确做法是让切片器识别无效值并跳过该区域,或者在预处理阶段用邻域插值补洞,但补洞只适合小范围数据缺失,大面积缺失应该回到数据源。

还有一种“假空洞”是投影边缘造成的。源 DEM 范围是矩形,重投影到经纬度后四角会变成弧形边界,边缘像元被重采样成 NoData。如果直接切片,边缘会出现锯齿状缺口。我一般会在gdalwarp时留一点缓冲,或者用gdal_fillnodata.py只处理边缘一圈,不要全图填。填完再检查一遍统计值,确认没有把大范围区域抹平。这个步骤没有后悔药,切片完成后再发现空洞,只能重新切,瓦片数量多的时候很折磨人。

3. 地形切片:从 GeoTIFF 到 quantized-mesh 的可执行流程

3.1 切片工具选型:为什么我优先选基于 GDAL 的 ctb 路线

Cesium 离线地形切片常见有三类路线:基于 GDAL 的栅格切片、基于 TIN 简化的网格生成、以及自己写脚本把高程栅格转成 quantized-mesh。第一类最适合大多数项目,原因是它直接吃 GeoTIFF,能生成 layer.json,支持顶点法线和水面掩膜,命令行也容易塞进自动化流水线。第二类在地形简化上更灵活,但输入数据往往要先转成点云或 TIN,流程更长。第三类适合有明确定制需求的团队,否则维护成本太高。

我优先选ctb-tile这条路线,不是因为它完美,而是因为它的边界清楚:输入是处理好的 WGS84 DEM,输出是z/x/y.terrain加layer.json,参数不多,出错时能对着日志查。它的缺点也要提前知道:切片过程对内存和磁盘 I/O 比较敏感,超大范围数据要分块切;增量更新不如数据库方案灵活;水面掩膜需要额外数据,不是开了参数就自动有水。选型时先把这些边界写进项目计划,比切到一半再换工具强。

3.2 一条命令生成 .terrain 瓦片与 layer.json

下面这条命令是我常用的基础版本。它把dem_wgs84.tif切成 0 到 14 级的地形瓦片,输出到terrain_tiles,同时生成 layer.json,并写入顶点法线。第一次跑建议先用小范围 DEM 验证,不要拿全省数据直接开刀。

# 创建输出目录 mkdir -p ./terrain_tiles # 切地形瓦片:0-14 级,双线性重采样,生成 layer.json,写入顶点法线 ctb-tile \ -o ./terrain_tiles \ -l terrain \ -f terrain \ -c \ -N \ -s 0 \ -e 14 \ -r bilinear \ -t 8 \ ./dem_wgs84.tif

-o是输出目录,目录必须可写,磁盘空间要提前算。-l terrain是图层名,会影响 layer.json 里的tiles模板和部分元数据,不是随便填也无所谓。-f terrain指定输出 quantized-mesh 地形格式,不要写成普通高程瓦片格式。-c生成 layer.json,缺了它 Cesium 不知道瓦片规则,加载时直接报错。-N写入顶点法线,开启后光照更自然,但瓦片体积会增大,内网带宽足够时建议开。-s 0和-e 14是起止层级,起始层级一般从 0 开始,结束层级要跟 DEM 分辨率匹配。-r bilinear是重采样方法,和预处理保持一致。-t 8是线程数,按 CPU 核数一半起步,太高会抢内存,反而变慢。

命令跑完后先别急着开 Cesium。先看输出目录里有没有layer.json,再看0/、1/这些层级目录是否生成。如果layer.json有但瓦片目录是空的,多半是输入范围或层级参数不对。如果瓦片目录有但layer.json缺失,检查-c是否被漏掉。第一次跑可以用--help看当前构建支持的参数,不同编译版本会有细微差异,不要硬套别人的命令。

3.3 参数表:瓦片尺寸、层级范围、顶点压缩、法线和水面掩膜

参数常用值作用注意点
-s/-e0 / 12-14起止层级结束层级不要高于数据分辨率,30 米 DEM 切到 14 级以上收益很低
-rbilinear重采样方法陡峭地形可试 cubic,不要用 nearest
-N开启顶点法线光照自然,体积增大;不开时 Cesium 端不要请求法线
-m有水系时开启水面掩膜需要水体系数据参与,不是所有 DEM 都自带
-c开启生成 layer.json缺少时 Cesium 无法识别瓦片规则
-tCPU 核数一半线程数过高会抢内存,低配机器先从 4 开始
-l自定义图层名输出图层名不要用中文和空格,避免路径问题

层级上限怎么定?一个粗略算法是看源数据地面分辨率。比如 30 米 DEM,在纬度 34 度附近,Cesium 第 12 级的像素分辨率大约几十米,切到 12 或 13 级已经能表达主要地形;切到 15 级以上,瓦片数量按四倍增长,内容却多是插值出来的。我一般会先切 0 到 12 级做视觉验收,确认山脊、河谷、边坡没有明显台阶,再决定要不要加到 13 或 14。不要一上来切 18 级,磁盘和浏览器内存都会教你做人。

水面掩膜要单独说。-m不是“自动识别水面”,它需要额外的水体系数据或掩膜输入。没有水系数据时强行开启,可能生成空掩膜或者直接报错。Cesium 端requestWaterMask也要和切片参数对应:切片没开水面掩膜,前端就不要请求;切片开了,前端再按需开启。法线同理,-N和requestVertexNormals必须成对出现,否则地形要么发黑,要么光照平淡。

3.4 切片产物怎么验收:文件树、瓦片头与数量对账

切片完成后,我习惯做三层验收:目录结构、文件数量、瓦片头。目录结构看z/x/y.terrain是否符合预期;文件数量看是否和层级范围大致匹配;瓦片头看文件是不是 gzip 或二进制地形数据,而不是空文件。下面几条命令可以直接抄。

# 统计地形瓦片数量 find ./terrain_tiles -type f -name "*.terrain" | wc -l # 查看前几层目录结构 find ./terrain_tiles -maxdepth 2 -type d | sort | head -20 # 抽查一个瓦片文件头,1f 8b 表示 gzip xxd -l 16 ./terrain_tiles/12/3376/1560.terrain # 查看 layer.json 的关键字段 python3 -c "import json; m=json.load(open('./terrain_tiles/layer.json')); print(m.get('format'), m.get('version'), m.get('minzoom'), m.get('maxzoom'))"

find ... | wc -l是最快的数量对账,数量明显偏少时先查层级范围是不是写错。find -maxdepth 2能看出层级目录是否连续,如果只有0/没有1/,说明切片在中途失败。xxd看文件头,1f 8b是 gzip 魔数,Cesium 地形瓦片经常是 gzip 压缩的,服务器返回时要带正确的Content-Encoding。layer.json里重点看format是否为quantized-mesh-1.0一类的地形格式,minzoom和maxzoom是否和命令参数一致。字段名不同版本可能有差异,但核心逻辑不变:前端靠这份文件找瓦片,字段错了就 404。

4. 本地服务与 Cesium 加载:把 layer.json 接进 Viewer 的最小闭环

4.1 目录结构与 HTTP 服务:不要直接双击 index.html

离线地形最常见的第一个坑是直接双击 HTML 文件。file://协议下浏览器会拦截跨目录请求,layer.json和.terrain瓦片读不出来,控制台一片红。正确做法是起一个本地 HTTP 服务,让页面和瓦片同源,或者至少让瓦片服务带 CORS 头。目录可以这样组织:Cesium 静态资源放Cesium/,地形瓦片放terrain_tiles/,页面放根目录。这样layer.json里的相对路径和页面请求路径容易对齐。

# 在项目根目录启动本地 HTTP 服务,端口 8080 python3 -m http.server 8080 --bind 127.0.0.1

--bind 127.0.0.1只允许本机访问,适合自己调试;要给局域网其他机器看,可以改成0.0.0.0,但要注意内网安全。Python 自带的 HTTP 服务不会自动加 CORS 头,如果页面和瓦片不同源,需要换成能配置 CORS 的服务器,或者在反向代理里加Access-Control-Allow-Origin。另外,.terrain文件的 MIME 类型不是所有服务器都认识,返回application/octet-stream通常能用,关键是不要返回text/html。如果瓦片是 gzip 压缩的,服务器还要带Content-Encoding: gzip,否则浏览器会把压缩数据当坏文件。

4.2 CesiumTerrainProvider 的关键参数:requestVertexNormals 与 requestWaterMask

Cesium 加载本地地形靠CesiumTerrainProvider,url指向包含layer.json的目录,不是指向layer.json文件本身。这一点很容易写错,写成.../layer.json后请求路径会多一层,直接 404。requestVertexNormals对应切片时的-N,切片开了法线,前端就设true,地形光照会有立体感;切片没开,前端设true也不会凭空生出法线,反而可能报错或显示异常。requestWaterMask对应-m,没有水面掩膜就设false,不要为了“看起来功能全”硬开。

新版本 Cesium 常用CesiumTerrainProvider.fromUrl返回 Promise,老版本用构造函数直接 new。写代码时看控制台报错判断,不要死记一种写法。fromUrl的好处是能等layer.json加载完再设置viewer.terrainProvider,避免地形还没就绪就飞相机。如果项目里还用了在线影像或在线地形,先把它们关掉,确保请求只打到本地,不然排查时分不清是本地瓦片问题还是网络问题。

4.3 一个最小可运行的加载页面

下面这个页面只做一件事:加载本地地形,把相机放到一个指定经纬度。它没有底图,没有复杂控件,方便先验证地形本身。实际项目再叠加本地影像或矢量。

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="utf-8"> <title>离线地形加载</title> <link rel="stylesheet" href="./Cesium/Widgets/widgets.css"> <script src="./Cesium/Cesium.js"></script> <style> html, body, #cesiumContainer { width: 100%; height: 100%; margin: 0; padding: 0; overflow: hidden; } </style> </head> <body> <div id="cesiumContainer"></div> <script> (async () => { const viewer = new Cesium.Viewer('cesiumContainer', { imageryProvider: false, // 先关掉底图,只验证地形 baseLayerPicker: false, geocoder: false, homeButton: false, sceneModePicker: false, navigationHelpButton: false, animation: false, timeline: false }); // url 指向包含 layer.json 的目录,不是 layer.json 文件 const terrainProvider = await Cesium.CesiumTerrainProvider.fromUrl( 'http://127.0.0.1:8080/terrain_tiles', { requestVertexNormals: true, // 与切片 -N 对应 requestWaterMask: false // 与切片 -m 对应 } ); viewer.terrainProvider = terrainProvider; // 相机放到地形范围附近,高度先给大一点,避免钻进地下 viewer.camera.setView({ destination: Cesium.Cartesian3.fromDegrees(108.9, 34.2, 20000) }); })(); </script> </body> </html>

imageryProvider: false是为了排除底图干扰,地形加载失败时不会误以为是影像问题。fromUrl的第二个参数里,requestVertexNormals和requestWaterMask必须和切片产物匹配。相机高度先给 20000 米,等确认地形范围后再调低;如果一开始就给 500 米,很可能相机在地下或山体里,看到的是一片黑。页面和瓦片同源时,url可以写成相对路径./terrain_tiles,换机器部署时少改一处配置。

4.4 离线场景下的跨域、缓存与路径大小写

内网部署时,跨域和路径大小写是两把钝刀子。跨域问题常见于页面在一个端口、瓦片在另一个端口,浏览器控制台会明确提示 CORS。解决方式不是在前端加mode: no-cors,那只会让响应变成 opaque,Cesium 读不到内容;正确做法是让瓦片服务返回Access-Control-Allow-Origin,或者把页面和瓦片放到同一个服务下。路径大小写问题多出现在 Linux 服务器:Windows 上Terrain_Tiles和terrain_tiles不区分,传到 Linux 后 404,layer.json里的模板和实际目录必须完全一致。

缓存也要注意。调试阶段浏览器会缓存layer.json和失败的瓦片请求,改了切片参数后页面没变化,先开无痕窗口或禁用缓存。生产环境则相反,layer.json可以短缓存,.terrain瓦片内容不变时可以长缓存,减少内网请求。还有一个小细节:layer.json里的tiles模板如果是{z}/{x}/{y}.terrain,实际目录就必须按这个结构组织,不要自作主张改成{z}/{y}/{x}.terrain。Cesium 不会猜你的目录习惯,模板和文件树必须一一对应。

5. 避坑与排查:离线地形加载失败的 5 个现场

5.1 地形整体偏移几百米

现象:Cesium 里地形和模型、影像对不上,整体平移几百米,山脊位置明显偏。原因:源 DEM 不是 WGS84,或者gdalwarp的目标坐标系写错,也可能是经纬度轴顺序被调换。解决:回到gdalinfo看源 CRS,确认-t_srs EPSG:4326生效;用gdallocationinfo在已知地物点采样高程,和影像对比;如果是轴顺序问题,检查是否误用了-s_srs或数据本身带了非标准元数据。偏移问题不要靠前端平移模型去补,补得越多,后面越乱。

5.2 瓦片 404,layer.json 能读到但地形不显示

现象:控制台请求.terrain返回 404,layer.json本身 200。原因:url指到了layer.json文件而不是目录;layer.json里的tiles模板和实际目录不一致;Linux 下路径大小写不匹配;服务器根路径多了或少了目录。解决:在浏览器里直接拼一个瓦片 URL 访问,看能不能下载;对照layer.json的模板和find出来的文件树;把url改成目录并以/结尾试一次;检查反向代理是否把/terrain_tiles重写成了别的路径。

5.3 地形发黑、棱角明显,光照像被压扁

现象:地形有起伏但表面发黑,山脊像纸片,旋转视角时明暗不自然。原因:切片时没开-N,前端却设了requestVertexNormals: true;或者切片开了法线,前端没请求;也有可能是法线数据被服务器 gzip 处理坏了。解决:先确认切片命令有没有-N,再确认前端参数是否一致;用xxd看瓦片头是否正常;临时把requestVertexNormals设为false对比效果。如果关掉法线后地形正常,问题就在法线链路,不要继续调光照参数。

5.4 水面没有效果,开了 requestWaterMask 也没用

现象:地形加载正常,但水面区域没有反射或水面掩膜效果。原因:切片时没加-m,或者没有提供水体系数据;前端requestWaterMask设了true,但瓦片里没有掩膜。解决:先确认切片命令是否带-m,再看输出瓦片是否包含水面掩膜数据;没有水系数据时不要硬开,前端设为false;如果项目必须显示水面,单独准备水体系矢量或栅格掩膜,再重新切片。水面效果不是前端一个开关能变出来的,数据链路要完整。

5.5 切到高层级后浏览器卡死或加载极慢

现象:低层级正常,飞到近处后页面卡顿,内存飙升,瓦片请求越来越多。原因:结束层级设得太高,瓦片数量按四倍增长;DEM 分辨率不够,高层级全是插值瓦片;NoData 区域也被切片,产生大量无效请求。解决:把-e降下来,30 米 DEM 先切到 12 或 13 级;用范围裁剪只切项目区域,不要全省全国一起切;检查 NoData 区域是否被跳过;浏览器端限制maximumScreenSpaceError,不要让 Cesium 无节制请求最高层级。层级不是越高越好,够用就行。

6. 进阶:把地形切片塞进自动化流水线与验收脚本

切片命令跑通一次不难,难的是每次数据更新都能稳定复现。我一般会把预处理、切片、验收写成三段脚本,中间产物带日期目录,失败时保留日志。下面这个 Python 脚本只做验收:读layer.json,统计瓦片数量,检查关键字段。它不替代人工看地形,但能拦住“目录空了”“层级错了”“格式不对”这类低级问题。

import json import os import glob base = "./terrain_tiles" layer_path = os.path.join(base, "layer.json") with open(layer_path, "r", encoding="utf-8") as f: meta = json.load(f) print("format:", meta.get("format")) print("version:", meta.get("version")) print("minzoom:", meta.get("minzoom")) print("maxzoom:", meta.get("maxzoom")) print("tiles_template:", meta.get("tiles")) tiles = glob.glob(os.path.join(base, "*", "*", "*.terrain")) print("tile_count:", len(tiles)) # 抽查最大层级目录是否存在 maxzoom = meta.get("maxzoom") if maxzoom is not None: max_dir = os.path.join(base, str(maxzoom)) print("maxzoom_dir_exists:", os.path.isdir(max_dir))

format应该是 quantized-mesh 相关格式,tiles模板要和文件树对得上,tile_count为 0 就直接失败,不用再开浏览器。maxzoom_dir_exists能快速判断切片是否只完成了一半。脚本跑完后,我还会抽一个瓦片用xxd看文件头,确认不是空文件。自动化流水线里,验收脚本失败就不要发布,别把问题留给前端。

从那以后我每次切地形都强制走一遍“预处理检查、切片参数对账、瓦片数量验收”,不再靠打开页面看运气。希望帮到你。

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