Cesium离线地形数据生成全攻略:从DEM处理到地形瓦片部署

发布时间:2026/10/15 2:38:06
Cesium离线地形数据生成全攻略:从DEM处理到地形瓦片部署
做三维GIS的人迟早会撞上地形数据这道坎。Cesium的地形渲染本身很成熟但真正麻烦的是“拿一份能落地的地形数据”往往比写JS代码更折腾。前阵子某项目需要在指定区域展示带有河流、山脊和高差起伏的三维场景同时又要求离线部署不能依赖在线地形服务数据精度和范围都必须自己控制。我前后折腾了两三天用一条本地生产线把公开的DEM高程数据处理成了Cesium可以直接加载的地形瓦片服务。这篇就完整记录这次Cesium地形数据生成过程从选型、预处理、切片到前端接入包括路上踩过的坑和最后定下来的参数。我在项目里见过太多同事卡在地形数据这一环要么数据加载出来是平的要么缩放到某个级别突然模糊成一片要么瓦片根本请求不到。问题多半不是Cesium本身而是数据生产的源头没做对。如果你也在准备做Cesium离线地形或者对默认地形服务不满意这篇应该能让你少试错好几轮。1. 为什么非要自建地形数据在线地形服务的三个痛点1.1 在线服务能演演示但扛不住项目交付很多人一开始都用Cesium官方在线地形服务接入确实简单createWorldTerrain一行代码全球地貌就出来了。但放到实际项目中问题会一个接一个地浮出来。首先是对外网依赖的问题。项目到了验收阶段往往要进内网环境现场没有外网地形数据直接加载不出来。就算能临时开个网络策略每次演示前都要先检查网线这种不确定性在汇报现场非常致命。我这次接到的需求里“离线部署”是硬约束这一条就直接判了在线服务的死刑。其次是精度和外观可控性。在线全球地形数据在宏观尺度看没问题镜头一拉到近地面很多区域的地貌细节就完全不够用了。默认地形在大多数平缓地区看起来就是一片糊掉的绿色没有沟壑、没有梯田层级、没有山脊线项目里需要“地形能看出真实地貌”时根本顶不住。第三个痛点其实是服务条款边界。线上服务的免费额度、商用授权、调用量限制条款里写得都比较宽泛实际能不能用于长期运行的商业项目不同服务商有不同的说法。与其在高风险地带反复试探不如直接把数据源掌握在自己手里。自建地形数据一次生成、静态部署没有任何外部依赖反而最稳妥。1.2 这次项目到底要什么样的地形数据这次要覆盖的范围不算大大概几百平方公里的山区向丘陵过渡区域但要求一点不少。地形表面必须能看出山脊和冲沟的走向不能是一片光滑缓坡。河流区域要能压平水面不能跟着河床一起起伏这就需要水纹掩膜water mask的支持。镜头的观察层级要从默认的12级左右提升到16级贴近地面时还能有足够细节。另外所有请求都必须命中局域网服务器不能有任何外网流量。这几个需求叠加在一起基本可以确认“用默认地形再改改”这条路走不通只能从头走一遍“原始DEM → 地形切片 → 本地服务 → Cesium加载”的完整链路。整个过程有三个前置决策直接决定了最终结果的质量数据源选什么、切片工具用什么、地形参数怎么调。2. 地形数据源选型与格式原理动手前先想明白两件事2.1 公开DEM数据源怎么选做地形切片必须先有原始DEM栅格。目前可用的公开高程数据源不少但是分辨率、覆盖范围和数据质量差异很大。我整理了这次对比过的主要方案数据产品空间分辨率覆盖范围特点适用场景SRTM 1弧秒约30米全球大部分区域老牌开源数据稳定但在陡峭山区有空洞大区域宏观地形ASTER GDEM约30米全球覆盖完整但局部有云影噪声需要人工检查跨区域连续地形ALOS AW3D30约30米/12.5米全球精度较好山区细节更细腻中小范围高精度展示Copernicus DEM30米/90米全球相对较新的数据质量均衡大多数三维场景的主力选择我这次选的是ALOS系列的12.5米分辨率产品。原因很简单项目覆盖范围小数据体量完全能吃得下但细节表现要求又比较高30米的数据在16级放大后还是会出现明显的阶梯感而12.5米在贴近地面时基本能保持连续平滑的坡面过渡。如果换成全国或全省量级的大范围地形我不会直接用这么高分辨率的数据那样瓦片数量和生成时间都会爆炸通常会降到30米甚至更大范围直接用90米。这里有个容易忽略的经验分辨率不是越高越好要看最终观察层级。Cesium地形瓦片本质上是多分辨率金字塔如果原始DEM分辨率太高而最大切片层级只有16高分辨率的红利根本用不上还会白白增加生成时间和磁盘占用。合理做法是根据“最大层级对应的地面分辨率”反推原始数据需要多密。16级在赤道附近的地面分辨率大约是4.7米左右但地形瓦片是对原始数据做重采样12.5米原始数据配合16级切片其实已经比较合理再往上做到18级以上才需要考虑用更高精度的数据。2.2 理解Cesium地形格式的核心机制直接拿着TIF格式的DEM放进Cesium是没用的Cesium不能直接渲染高程栅格它需要的是按层级组织的网格地形瓦片。这里得先搞懂Cesium的地形数据格式到底长什么样。Cesium默认使用量化网格Quantized-Mesh格式的地形瓦片每个瓦片是一个紧凑的三角形网格存储网格顶点坐标、顶点法线以及可选的误差值。渲染时通过四叉树方式按需加载。和常见的图片瓦片每个瓦片是PNG/JPEG完全不同地形瓦片是二进制文件文件名通常是z/z/x/y.terrain这种结构。Cesium在运行时根据相机位置判断需要加载哪些层级的瓦片然后通过HTTP请求去拿对应的二进制文件解析成地形网格进行渲染。理解了这套机制你就能明白几个必须注意的问题。第一瓦片目录层级不能错Cesium会严格按照z/y/x的路径去请求少一层或多一层都会404。第二每个瓦片里必须包含层级间的“跳转信息”Cesium称之为网格误差或屏幕空间误差没有这个数据LOD切换时就会出现“地形闪烁”或者“穿帮”。第三可选的water mask和vertex normals分别是两个独立的扩展生成数据时没有生成前端请求时就拿不到对应的数据块容易报错。刚开始接触的人很容易把这套流程理解成“把TIF再切片成图片瓦片”然后对着图片瓦片一直找地形数据为什么加载失败。这个方向性的误解最浪费时间。地形切片生成的是二进制网格不是图片。2.3 工具链的取舍思路我这次实测对比了主流的两个生成工具。一个是用Python写的CesiumTerrainBuilder一个是C实现的ctb。两者都能生成Cesium支持的Quantized-Mesh格式地形瓦片但定位不太一样。我最后选择CesiumTerrainBuilder来输出主流程。原因是它的参数设计对新手友好water mask和vertex normals的生成开关很明确生成的目录结构完整自带metadata.json前端接入不容易出低级错误。当然它的缺点是生成速度偏慢处理大范围数据时CPU跑满的时间会比较长。ctb的优势是快但命令行参数更“工程化”如果你对地形瓦片的底层结构不熟悉初上手时容易漏掉一些必要的参数排查起来会有点折腾。GDAL是另外一个离不开的工具所有DEM预处理的活都由它来做。我强烈建议在动手生成切片之前花半小时把GDAL的空间数据处理命令过一遍gdalinfo、gdal_translate、gdalwarp、gdal_fillnodata这几个命令基本覆盖了90%的场景。QGIS主要用来做可视化检查毕竟光看数字很难判断地形到底自然不自然。3. 完整实操流程从DEM栅格到Cesium离线地形瓦片3.1 原始数据下载与范围裁剪整个流水线第一步是拿到原始数据并确认它符合预期。我下载到的原始DEM通常是标准GeoTIFF格式里面有多个波段但地形只需要一个高程波段。第一步先用gdalinfo检查数据的坐标系、分辨率和有效范围。gdalinfo input_dem.tif这一步要确认三个关键信息。坐标系必须是WGS84经纬度EPSG:4326如果返回结果是UTM或者其他投影后面所有步骤都会出问题。像素分辨率要和你预期的数据精度对得上比如12.5米数据在经纬度坐标系下大约是0.000138度左右。有效值范围要正常不要出现离谱的负值或者异常高的峰值。确认数据没问题之后按目标范围的四个角进行裁剪。这里有一个特别容易记混的点gdal_translate的范围参数顺序是-projwin west north east south也就是“西经、北纬、东经、南纬”不是我们习惯的“左下、右上”那样按X1 Y1 X2 Y2排。gdal_translate -projwin 117.8 30.6 118.4 29.8 input_dem.tif clipped.tif很多人在这个顺序上翻车裁剪出来的区域要么是反的要么位置完全不对。建议裁完以后用gdalinfo clipped.tif再检查一遍范围确定没有裁到项目区域之外。3.2 高程缺失修复与重投影DEM原始数据几乎不可能是完美的。常见的坑是空洞尤其是在陡峭山区和峡谷区域有些数据源的雷达回波覆盖不到就会形成一块块黑色的无值区。如果直接拿带空洞的DEM去生成地形瓦片Cesium场景里对应的区域就会出现凹陷或者穿地现象非常难看。处理空洞用的命令是gdal_fillnodata它会对指定半径范围内缺失的数据做插值补齐。注意这个算法只适合填补小面积空洞如果整块区域都无效插值出来的地形会非常假。gdal_fillnodata -md 50 clipped.tif filled.tif-md 50表示搜索半径最大为50个像素超过这个范围的空洞保持原样。我习惯从较小的半径开始填完以后在QGIS里看一眼不够再加大。过度填充会让地形看起来像被磨过皮失去真实的沟壑细节。接下来是坐标系标准化。虽然原始数据声明是WGS84但不同数据产品的存储细节有差异有的会带旧的椭球体参数稳妥起见直接用gdalwarp强制重投影到EPSG:4326并顺便用双线性插值保证高程值过渡平滑。gdalwarp -t_srs EPSG:4326 -r bilinear filled.tif output_wgs84.tif这一步做完再检查一下高程值范围。如果数据源来自某个采用英尺作为单位的地区高程值需要同步换算成米不然生成出来的地形会呈现出一种极其夸张的陡峭感。换算很简单gdal_calc.py -A output_wgs84.tif --outfileoutput_meters.tif --calcA*0.3048确认无误后最终的预处理DEM就准备好了。标准化的核心只有两条坐标系是WGS84高程单位是米。3.3 地形瓦片生成与关键参数选择预处理完成后进入切片环节。我用CesiumTerrainBuilder生成瓦片命令行大概是这样的python3 cesiumterrainbuilder.py output_meters.tif ./cesium_terrain/ \ -n \ --start-level 5 \ --end-level 16不同版本参数略有差异进入生成前先用python3 cesiumterrainbuilder.py -h确认一下当前版本的参数说明。以下是我这次使用的参数含义。-n表示生成顶点法线贴图。这个参数对近地面的真实感影响非常大。没有法线数据时Cesium只能靠高程差来表现地形起伏光线下缺乏细节阴影有了法线数据后山坡朝向、岩石纹理的明暗变化都会真实很多。如果你的场景不需要特别强的地形立体感这个开关可以先不开但在高精度展示类项目里强烈建议打开。--start-level和--end-level控制瓦片金字塔的范围。起始层级默认情况下工具会自动计算不建议强行设太小或者太大。如果起始层级太低会把一块很小的区域放大到全球网格里生成大量毫无意义的冗余瓦片。结束层级则需要根据观察需求来确定我这次设到16级实测在镜头拉近到十几米高度时依然有足够细节。water mask的处理是另一个容易忽略的点。如果你需要河流、湖泊区域的水面是平的必须在生成时使用-l参数指定水纹平滑级别。它的原理是对指定范围内的地形进行平滑处理让水面区域不会出现坑坑洼洼的起伏同时生成一个二进制水纹掩膜文件。前端加载时配合requestWaterMask: trueCesium才能正确渲染出水面效果。这里我特别想提醒一句不要指望数据里自动带water mask很多公开DEM源根本没有水域分类信息河流区域的地形同样跟着地形起伏。如果项目需求里有水域平面特效必须在生成阶段就把水纹处理做进去。生成完毕后在输出目录会看到metadata.json和tiles/子目录。我曾经见过有人自己改了瓦片目录结构或者手动修改了metadata.json里的层级范围结果前端要么请求不到瓦片要么瓦片加载出来层级错乱。正确做法是生成后不动目录结构只做静态部署。3.4 用Nginx部署地形服务并配置跨域地形瓦片本质上是静态文件不需要后端接口支持。我用Nginx托管整个cesium_terrain目录就行。但这里有一个关键坑跨域访问。浏览器中运行的Cesium页面如果地形服务地址和页面地址不同源瓦片请求会被CORS机制拦截。开发阶段可以通过代理绕过但生产部署必须要在Nginx里添加跨域响应头。我在Nginx配置里加了一层专门的地形服务location块location /terrain/ { alias /data/cesium_terrain/; add_header Access-Control-Allow-Origin *; }这里还有第二个容易被忽略的坑地形瓦片请求是带Range头的分段请求。Cesium在加载较大的地形瓦片时会发起HTTP Range请求只获取需要的字节段提高加载效率。如果服务器不支持Range请求瓦片可能会返回完整数据虽然也能渲染但大场景下网络消耗会明显上升。Nginx默认支持Range如果你用的是其他静态文件服务器务必确认这一项。我实际测试过生成完的地形瓦片目录大约2.1G左右Nginx开启后局域网内加载速度很快。如果磁盘空间紧张可以考虑只保留15级的最大层级体量能缩小将近一半但近地面细节会稍微妥协。3.5 在Cesium中接入地形数据地形服务准备好之后前端接入就比较直接了。我用的是Cesium 1.107版本加载方式如下const terrainProvider await Cesium.CesiumTerrainProvider.fromUrl(/terrain, { requestVertexNormals: true, requestWaterMask: true }); viewer.terrainProvider terrainProvider;如果你的Cesium版本比较老比如1.90之前加载地形的API可能不是fromUrl静态方法而是new Cesium.CesiumTerrainProvider({ url: /terrain })。这里建议先确认当前项目使用的Cesium主版本不同版本的API差异还挺明显的直接照抄新写法在老版本里会报“fromUrl is not a function”。接入完成后我写了一段测试脚本不断调整相机的拍摄角度和高度从宏观视角慢慢推进到贴近地面的视角重点观察山脊线、河流沿线和坡度突变区的渲染效果。如果一切正常地形会随着镜头高度下降而平滑细分不会出现明显的闪动或穿透。到这里自建地形数据的主链路已经通了。3.6 效果验证不只是“能显示”还得对得起需求能显示只是第一步这次项目的具体要求还得逐条验证。第一是山脊和冲沟的识别度。镜头拉到约两千米高度从侧光方向观察山体表面要有明显的凸起和凹陷纹理。这里我用了Cesium的光照参数让地形接受光源影响增强立体感。如果地形表面看起来一片平光大概率是顶点法线没生成或者前端没有请求法线数据回头检查生成命令和前端请求参数即可。第二是河流压平效果。把镜头对准项目区域内的主要河道水平旋转视角观察水面是否平整。我在测试中发现局部河段的水面还是有一点坡度感于是把水纹平滑级别从默认值调整为更高重新生成了一次瓦片才达到预期效果。这里想强调的是水纹平滑级别调高会同时平滑掉河道附近的地形细节所以不要一刀切拉到最高要在“河流平”和“岸线自然”之间找平衡。第三是离线和性能。断掉外网后重新加载页面所有地形瓦片都从局域网服务器获取加载速度和稳定性都不错。测试时我仔细观察了Network面板确认没有任何请求走到外网数据完全自持。4. 踩坑实录地形生成中容易翻车的六个细节4.1 数据范围偏差导致半边黑屏第一次生成瓦片时我发现地形加载出来后只有一半区域有地形另一半完全是黑色空洞。第一反应是瓦片生成不完整但检查生成日志后发现瓦片数是完整的。最后排查到原因是原始DEM的范围和目标区域有偏差裁剪时把一部分无用区域也裁进来了而那片区域在原始数据里就是无值空洞补洞插值也没有完全覆盖。重新下载严格对应项目范围的原始分幅数据再走一遍流程就正常了。这个问题的本质其实是数据源的范围粒度问题。公开DEM数据通常按分幅或分块发布一个分幅文件往往横跨好几个经度纬度直接裁剪时容易带入相邻无效区域。建议裁剪前先在QGIS里加载原始数据目视确认目标区域每个像素都有有效高程值再进入裁剪环节。4.2 层级深度不够造成的远景模糊第一次测试时我把最大层级设到了15级但场景里远景的山体看起来依然有明显的“块状感”。尤其在拉远镜头时地形过渡不够平滑。原因在于层级数决定了地形网格的细化程度而Cesium会按屏幕空间误差来决定加载哪个层级的瓦片15级在近处够用但是在中等距离上过渡就会有台阶感。最终我把最大层级提到16级整体视觉质量明显改善。这里也有个反直觉的地方如果起始层级设得太高比如直接从第8层开始生成那么在低层级场景里会没有瓦片可用Cesium只能显示一个平面。起始层级怎么设要结合数据范围大小来看这个交给工具的自动计算通常是最稳妥的。4.3 前端请求瓦片一直404在第一次接入时浏览器控制台疯狂报404错误打开Network面板看到请求地址是/terrain/16/36231/21897.terrain这类路径。检查部署目录后发现文件确实存在问题出在Nginx的别名路径配置不一致。我配置的是alias /data/cesium_terrain/;但目录内部实际结构是/data/cesium_terrain/tiles/16/...而请求直接落在了/terrain/16/...。修正的方式有两种把alias指向/data/cesium_terrain/tiles/或者把生成目录里的tiles子目录内容移动到外层。这里想提醒大家先确认请求路径和文件实际路径的对应关系再去找数据问题很多时候问题比你想象的简单。4.4 CORS跨域导致瓦片加载被浏览器拦截这个坑在开发环境最容易出现。页面跑在localhost:8080地形服务跑在localhost:8090浏览器认为这是跨域请求。Cesium发出瓦片请求后被浏览器拦截控制台报的错误经常是“CORS policy: No Access-Control-Allow-Origin header”。解决办法除了在Nginx配置跨域头也可以直接用页面开发服务器的代理功能。我这次图省事直接在Nginx里加了add_header Access-Control-Allow-Origin *;一步到位。生产环境如果对跨域有更严格的要求可以把这个响应头换成具体域名而不是全量通配。4.5 地形闪动和梁缝出现在某个山区段相机移动时地形表面会偶尔出现细小裂缝尤其是在陡峭坡面上。排查了很长时间最后发现是我在没有顶点法线的情况下同时开启了光照效果。Cesium在计算光照时需要法线数据缺失时硬件插值会计算出一个不太稳定的光照结果视觉上表现为闪动。重新生成时加上-n参数并把前端请求改成requestVertexNormals: true闪动问题就消失了。如果你不太需要光照效果也可以直接关闭光照排除这个干扰项。4.6 数据体积膨胀问题生成完2.1G的瓦片数据时我一度怀疑哪里出了问题。仔细看瓦片数量和分布发现很多层级存在大量重复的相似地形这是因为原始DEM的纹理信息丰富导致量化网格在细节较多的区域不得不划分得更细。体积优化有几个可行的方向。限制最大层级到16级可以省下大量高层级瓦片。如果原始数据是12.5米而观察不需要那么细可以先用GDAL把DEM重采样到30米再生成切片体积能降一半以上。另外Nginx开启gzip对二进制瓦片效果比较有限因为.terrain瓦片本身已经比较紧凑不建议在这个方向上花太多时间。我整理了一份高频问题速查表后续再碰地形相关问题可以直接对表排查现象可能原因处理办法地形半边黑色或空洞原始DEM存在无效区域gdal_fillnodata或更换数据源地形整体偏移坐标系不是WGS84gdalwarp重投影到EPSG:4326地形特别陡峭高程单位不是米用gdal_calc换算单位瓦片请求404部署目录层级不对检查请求路径和实际文件路径瓦片被浏览器拦截跨域头缺失Nginx添加CORS响应头镜头拉近后地形模糊最大层级不够调高end-level参数地形表面闪动顶点法线缺失生成时加-n参数河流区域起伏不平未生成water mask生成时-l参数并配平加载慢瓦片层级太高或数据太密降级DEM分辨率或限制最大层级LOD切换穿帮metadata.json层级不匹配重新生成瓦片不动目录5. 复盘与建议一条可复用、可扩展的地形数据生产路径5.1 几个决定成败的关键决策这次完整走下来我复盘出三个决定成败的决策点。第一在切片前把预处理做干净。坐标系统一、高程单位换算、空洞插值这些看起来琐碎的步骤任何一步漏掉生成出来的瓦片都会有奇奇怪怪的问题。我第一版直接拿原始DEM生成结果地形明显偏移了几十米排查了很久才发现是坐标系问题。现在我会把“标准化”当作流水线中间不可跳过的强制性环节。第二生成参数要和前端请求严格对应。-n决定了有没有顶点法线-l决定了有没有水纹掩膜。前端如果请求了这些数据服务端必须提供前端如果没请求数据生成时开太多扩展也不会提升渲染效果反而增加文件体积。建议花两分钟把生成参数和前端加载选项做成一张对照表放进项目文档。第三范围明确后再决定数据源分辨率。目标的观察最大层级、目标区域大小、原始DEM分辨率这三个变量必须匹配。大范围低精度需求用12.5米数据属于浪费小范围高精度需求用30米数据属于不够。看起来是简单的常识但我在实际项目中见过太多因为在选型阶段少问一句“镜头最大要放大到多少米高度”而导致返工的情况。5.2 后续自动化扩展方向目前这条流程已经能稳定复现但描述起来还是偏手工。后续可以往自动化方向走一步。核心是把“范围四角坐标、数据源标识、起始层级、结束层级、是否需要water mask和法线”这些参数抽出来做成一个配置文件。每次新项目来了改一行配置跑一次脚本就能批量产出瓦片。甚至可以把DEM预处理也做成同一套脚本里的固定环节用GDAL命令自动完成下载、重投影、裁剪、插值再接力给切片工具。我这次没有做全自动主要是考虑到项目周期不长手工操作记录得更放心。但如果接下来的项目地形覆盖范围跨了好几个地市或者需要按季度更新地形数据自动化流水线基本是必须的。另一个可以考虑的方向是做增量更新地形数据覆盖范围变化时只重新生成变化的局部区域而不是全量重新切片能节约不少时间。最后说一点个人体会。Cesium地形数据生成本身并不困难难的是理解这个格式背后的两个隐含假设数据必须是WGS84经纬度坐标高程必须是米为单位的连续值。这两个前提只要对了剩下百分之八十的坑都会自动绕开。如果你也在准备接离线地形我的建议是一开始不要追求所有参数一步到位。先拿很小的一块区域比如几平方公里的范围从原始数据处理到Cesium加载跑通全流程再逐步扩大到目标区域。这比直接铺整个范围再回头排查要高效得多。用最小代价踩完所有雷后面的路自然就平了。