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 TILEDYES \ -co COMPRESSLZW \ -co BIGTIFFIF_SAFER \ ./raw_dem.tif ./dem_wgs84.tif-t_srs EPSG:4326是目标坐标系Cesium 地形瓦片按经纬度组织这一步不要省。-r bilinear是重采样方法常规地形用双线性比较稳陡峭山区可以试cubic但不要用最近邻最近邻会把阶地和高程突变放大成台阶。-srcnodata必须跟源数据真实 NoData 一致源数据没有 NoData 时不要硬写否则会把正常高程当空洞裁掉。-dstnodata -32768是给后续切片器一个明确的无效值标记具体值不是重点重点是“源和目標要一致”。-co TILEDYES让输出变成分块栅格COMPRESSLZW是无损压缩BIGTIFFIF_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) # 读取第一波段maskedTrue 会把 NoData 标成掩膜 arr ds.read(1, maskedTrue) 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; mjson.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 与 requestWaterMaskCesium 加载本地地形靠CesiumTerrainProviderurl指向包含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 langzh-CN head meta charsetutf-8 title离线地形加载/title link relstylesheet 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 idcesiumContainer/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 /htmlimageryProvider: false是为了排除底图干扰地形加载失败时不会误以为是影像问题。fromUrl的第二个参数里requestVertexNormals和requestWaterMask必须和切片产物匹配。相机高度先给 20000 米等确认地形范围后再调低如果一开始就给 500 米很可能相机在地下或山体里看到的是一片黑。页面和瓦片同源时url可以写成相对路径./terrain_tiles换机器部署时少改一处配置。4.4 离线场景下的跨域、缓存与路径大小写内网部署时跨域和路径大小写是两把钝刀子。跨域问题常见于页面在一个端口、瓦片在另一个端口浏览器控制台会明确提示 CORS。解决方式不是在前端加mode: no-cors那只会让响应变成 opaqueCesium 读不到内容正确做法是让瓦片服务返回Access-Control-Allow-Origin或者把页面和瓦片放到同一个服务下。路径大小写问题多出现在 Linux 服务器Windows 上Terrain_Tiles和terrain_tiles不区分传到 Linux 后 404layer.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 瓦片 404layer.json 能读到但地形不显示现象控制台请求.terrain返回 404layer.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, encodingutf-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看文件头确认不是空文件。自动化流水线里验收脚本失败就不要发布别把问题留给前端。从那以后我每次切地形都强制走一遍“预处理检查、切片参数对账、瓦片数量验收”不再靠打开页面看运气。希望帮到你。本文还有配套的精品资源点击获取