搞Cesium开发最绕不开的一件事就是地形数据。默认的在线地形服务虽然打开就能用可真到了项目落地总有让你必须自己生成地形数据的理由——精度不够、网络受限、数据不能离开本机。这篇博客把完整跑过一次的Cesium地形数据生成过程记录下来从原始DEM到Cesium直接加载的terrain切片每一步做了什么、为什么这么做、踩了哪些坑都会讲清楚。如果你正准备把项目从官方在线地形切换到自建地形或者刚接触Cesium、好奇地形数据到底是怎么来的这篇可以直接当参考。1. 这次项目为什么非要用自建地形而不是直接用在线数据1.1 在线地形的三个现实短板其实最开始我也想过直接用Cesium默认的在线地形。写原型的时候这套东西真的很爽打开地球、拉近拉远山脉和河谷自己就出现了几乎零成本。可一旦走到正式项目交付问题马上浮出水面。第一是精度。当前可公开获取的全球DEM产品主流分辨率在30米左右也就是一个高程格点代表地面上约30米乘30米的范围。放到全局视角下完全没问题但项目区域一旦是山地30米分辨率下的山脊线会被明显削圆山谷底部也看不出细致沟道。如果业务上需要15米甚至更高的分辨率比如做淹没分析、地形剖面量算在线数据的精度压根撑不起来。第二是网络环境。相当一部分三维GIS项目最终落在客户内网或者离网环境里物理上就不能访问公网。这种情况下Cesium页面里默认的地形请求会全部失败画面只剩一个光秃秃的地球业务功能再丰富也白搭。有人会想在网关做代理缓存但代理只对大量重复请求有效一旦服务端策略变了还是不如把数据掌握在自己手里踏实。第三是数据合规。很多面向B端和公共机构的项目对项目区域的高程数据都有明确的保密和审计要求数据不允许上传到第三方服务必须由项目方自己管理、自己发布。我这次的情况其实是三个原因全部占齐了所以早期就把自建地形定为必须项。1.2 这次项目的技术指标定成什么样我负责的区域是一片几百平方公里的山地从河谷到山脊的垂直高差接近千米中间有明显陡崖和沟谷。这种地形最考验地形生成流程精度不到位陡崖会被抹成缓坡范围控制不好瓦片数量会膨胀到没法维护。结合业务需要我一开始就把指标定得很明确源DEM有效分辨率目标15米左右不能低于公开30米数据的观感太多输出格式Cesium原生支持的量化网格QuantizedMesh瓦片层级覆盖从低层全球概览到高层局部细节具体范围由项目区域决定部署方式内网静态文件服务浏览器端通过TerrainProvider加载叠加要求地形必须和卫星影像严格对齐不能出现山体整体偏移的问题为什么一定要把目标写清楚因为地形生产这活儿步骤多、耗时长如果做一半才发现方向不对返工成本非常高。指标定下来之后后面每一步选参数都直接对着它判断决策就简单了。2. 原始DEM数据的准备与预处理2.1 动手之前先把元数据摸清楚我第一次搞这种事的时候拿到DEM就直接往工具里丢生成了瓦片之后再一层层往回排查问题浪费了整整两天。后来养成了固定习惯预处理前先花十几分钟把源数据的元数据和统计信息看一遍。具体看三个方面。第一是投影坐标系源数据是经纬度地理坐标系还是投影坐标系各个分块之间是否一致。第二是像元大小一个像素在地面覆盖多少米这直接决定了后面金字塔能支撑到多高的层级。第三是文件范围多个分块之间的边界是否连续有无重叠或者缺口。我习惯用常规GIS命令行工具扫描所有分块的元数据。这次扫完发现覆盖项目区域的DEM数据由多个文件块组成其中有两个块的坐标基准不完全一致。如果不处理就直接拼接生成的地形瓦片在接边处会出现横向位移地形和影像错位之后很难查出原因。2.2 统一投影、拼接、裁剪三步走摸清元数据之后预处理按标准动作走了三步。第一步统一投影。因为Cesium地形瓦片的行列编号本质上是按经纬度框架计算的我直接把所有分块重投影到WGS84地理坐标系像素值继续代表高程米数。这里特别提醒一下有些DEM源数据的高程单位是英尺如果没留意就导出生成的地形会整体长高好几倍画面上就是一根根扎穿云层的尖刺。第二步拼接。把多个分块用镶嵌工具合并成一个完整大影像合并时设置统一的NoData值。高程数据里总有无效区域常见的用-9999或0标记这里没测到高程。这些无效值如果不管生成地形时会被当成真实高程参与构网最后区域里莫名其妙多出深坑或尖峰。第三步裁剪。准备一份项目边界矢量文件用它对大影像做范围裁剪只保留需要展示的区域。这步看起来简单但对瓦片数量控制非常关键具体原因放在第4节说。裁剪完成后我会把成果转成可视化图片快速肉眼检查一遍确认没有明显空洞和异常高值才进入生成环节。预处理产生的中间文件全部单独保存不覆盖原始数据。这个习惯帮我避过两次坑一次是重投影参数填错影像整体偏移了几百米一次是NoData赋值规则写反无效值变成0地形上平白多出一块盆地。有原始文件在手重新处理一遍成本很低比从头再下载数据强多了。3. 地形数据格式与生成工具选型3.1 为什么Cesium不能直接吃DEM影像很多第一次接触的人会有个疑问Cesium是不是能加载GeoTIFF直接把DEM切片成图片喂给它不行吗确实有些地图库支持用高程栅格图模拟地形Cesium也不是完全不能这么做但效果和性能差得很远。Cesium的terrainProvider体系真正推荐使用的是量化网格格式。简单理解图片地形是位图每个像素只存一个颜色值Cesium要用它拼地形时需要把颜色反算回高程一次来回转换就有精度损失而且像素密度受限。量化网格则是矢量瓦片里直接保存三角网的顶点坐标、法线方向和顶点索引浏览器拿到后直接构建真实三维网格。用生活里的比喻图片地形相当于一张印好的等高线照片放大就糊量化网格相当于一份带精确坐标的工程图纸任意缩放都不失真。Cesium的渲染引擎对量化网格的加载效率和帧率表现也明显更好因为瓦片内部做了顶点压缩、法线预计算和包围盒裁剪处理开销更小。所以生成地形这步的本质就是把一张栅格DEM转换成一整座金字塔结构的量化网格瓦片。3.2 生成工具选型两条路线的取舍社区里做Cesium地形生成大致有两条路线。路线一是单张大影像直接转地形瓦片。把预处理好的GeoTIFF作为输入工具内部完成金字塔分层、网格构建、格式编码最后直接输出terrain瓦片目录。优点是流程短适合项目DEM不大、不需要把中间栅格切片复用的场景。路线二是栅格切图再转量化网格。先用传统地图工具把DEM切成栅格金字塔再编写转换逻辑把每个栅格瓦片编码成量化网格。流程长、环节多但中间产物可以同时用于其他分析业务适合一鱼多吃的项目。我这次选的是路线一。原因有三项目不需要把DEM栅格切片复用到别的地方项目区域只有几百平方公里单张影像体积可控链路越短出错的可能性越低。实际执行时用了通用的地形生成工具典型参数大概长这样terrain-generator \ --input dem_merged.tif \ --output ./tiles \ --start-level 0 \ --end-level 14 \ --tile-size 128 \ --format quantized_mesh \ --bounds project_area.geojson解读一下几个核心参数输入文件是预处理好的单一大影像输出目录保持清晰结构方便后续部署起止层级决定金字塔粗细需要根据源分辨率和项目范围推算网格尺寸控制单个瓦片内的顶点密度直接影响视觉效果和文件大小范围参数是控制瓦片总量最有效的手段第一次跑的时候我吃过亏第一次生成时我贪多求全第0到第14级全部产出结果跑到高等级瓦片时机器内存告急耗时非常长。后来用项目边界约束范围并让低层级瓦片生成得粗糙一点总耗时从几小时降到几十分钟效果几乎没有变化。这个思路在第4节继续展开。4. 地形切片生成与本地验证4.1 控制金字塔层级和范围是性能的关键刚才说了层级定为0到14这里补充一下怎么定出来的。金字塔每往下一层瓦片数量约等于上一层乘4。Cesium加载地形时相机在高空需要低层级瓦片拉近到地面就需要高层级瓦片。如果低层级瓦片覆盖全球相当于用大量磁盘时间和生成时间去产出用户根本不会看的区域。我的做法是让第0级到第3级仍然覆盖全球作为全球视角的兜底。从第4级开始用项目边界矢量约束瓦片生成凡与边界没有交集的瓦片全部跳过。这样总瓦片量直接砍掉八成磁盘占用从数GB降到几百MB级别内网服务器同步也快。关于第14级怎么定的我算过源DEM像元大小在15米左右某一层瓦片网格的顶点间距已经接近这个值以后再往下细分也只是把15米数据插值成更密的网格并不会产生真正的新细节反而徒增瓦片数量和加载负担。所以终止层级就定在网格间距接近源数据分辨率的那一层。4.2 输出结构长什么样metadata.json里有什么生成完的瓦片目录结构大致是这样的tiles/ ├── metadata.json ├── 0 │ └── 0 │ └── 0.terrain ├── 1 │ ├── 0 │ │ └── 1.terrain │ └── 1 │ └── 2.terrain └── 14 └── ...第一次看到这堆数字目录的人多半会懵其实规律很清楚最外层目录是层级号中间目录是经度方向的瓦片编号文件名的编号是纬度方向编号。Cesium加载瓦片时会根据相机位置和层级算出三个数字拼接请求路径所以目录结构一旦改动Cesium就找不到瓦片。metadata.json是整个地形服务的关键元数据Cesium发起地形请求前会先拿它。我重点核对两点瓦片层级范围以及经纬度边界。如果metadata里的范围写错场景里会出现有一部分区域一直加载不出来却没有任何报错的怪现象排查起来非常绕。4.3 不部署先验证本地把地形跑起来生成完直接部署到正式环境是风险很大的做法。我的习惯是先用本地静态文件服务把瓦片跑起来然后在页面里用一个精简Cesium实例加载自建地形。const viewer new Cesium.Viewer(cesiumContainer, { terrainProvider: new Cesium.CesiumTerrainProvider({ url: http://localhost:8080/tiles }) });验证时特别看三点。第一场景能不能正常出地形有没有一片海或者大洞。第二镜头飞到源DEM数值已知的某个高点看Cesium显示的高程值与源数据差多少。第三同时打开卫星影像服务看地形与影像是否对齐。第一次验证时我发现地形整体往北偏了一小段。追根溯源还是预处理阶段那两个坐标基准不一致的分块没有完全统一干净。把坐标基准重新统一、重新生成瓦片之后偏移消失地形和影像严丝合缝。这个验证步骤千万别省。直接部署后再发现问题服务器缓存、代理设置都会干扰排查你根本分不清是网络问题、缓存问题还是数据问题。本地验证通过后再动部署排查范围会小很多。5. 部署到内网服务器与接入注意事项5.1 静态文件服务加缓存配置瓦片数据本质就是一堆二进制文件部署逻辑很简单把tiles目录原样发布到内网静态文件服务即可。我实际部署时做了四件事请求路径前缀指向tiles目录保证根路径能返回metadata.json开启跨域支持因为Cesium页面可能跑在另一个端口或另一台机器开启gzip压缩虽然二进制地形文件压缩率不如文本但也能省部分流量给瓦片设置合适的缓存响应头这里有个容易忽略的点瓦片文件的缓存策略一定要和你的更新频率配套。如果数据一年到头不更新缓存设久一点没问题如果数据隔段时间就迭代一版缓存设太久会导致你明明更新了瓦片浏览器里却永远看到旧地形。我后来在瓦片路径里带上版本号彻底绕开了这个坑。5.2 Cesium接入代码里的几个细节坑接入Cesium代码本身不难几行就够但实际接入有几个细节坑值得单独说。第一个坑创建Viewer时没有显式指定terrainProvider。Cesium默认行为是自动加载在线地形如果项目部署在内网默认请求会向公网发起并静默失败场景里就直接不显示地形。所以代码里一定要像上面那样明确传入自建地形数据源并且加错误处理。第二个坑地形加载完成后再做业务叠加。有些业务需要在场景里放置标注、画范围线如果地形还没加载完就绘制这些要素位置会与实际地表高度不一致看起来像陷进山里或飘在半空。正确做法是监听瓦片加载进度等剩余加载数为0后再绘制。viewer.scene.globe.tileLoadProgressEvent.addEventListener(function (remaining) { if (remaining 0) { // 在这里执行依赖地形的业务叠加 } });第三个坑影像和地形的边界对齐。地形瓦片和影像瓦片如果来自不同切分规则纵然都显示出来也会出现山体在影像底下穿过的诡异效果。我的处理办法是让影像瓦片也统一使用与地形相同的经纬度切分框架并用一条明显山脊线做目视对照验证。6. 生成和接入过程中踩过的几个坑6.1 高程异常值造成尖刺山体第一次看到生成结果里冒出一根根尖刺我差点以为地形工具坏了。后来把DEM重新可视化才发现原始数据里有一小片异常的极大值像素估计是采集坏值数值超过合理高程上限几十倍。这种坏值直接参与构网顶点自然会被拉上天。处理办法是加一道清洗设定项目区域合理的高程上下限超过上限的像素全部标记为NoData再沿周围有效高程做插值过渡。重新生成后尖刺消失。这个清洗动作后来成了我的标准流程每次换新数据都先跑一遍。6.2 瓦片数量比预期多得多服务器同步灾难第一次生成没有对层级做范围约束第0到第14级全量产出。前面低层级还好到第8级以上瓦片开始覆盖全球数量指数级膨胀。生成时间暴涨还在其次更麻烦的是服务器同步几百万个小文件复制一次要几个小时中途一断还得从头来。后来用项目边界约束第4级以后的瓦片范围只保留与项目区域相关和过渡需要的部分。瓦片总数从几百万降到几万个磁盘占用从数GB降到几百MB服务器同步分钟级搞定。这个教训想通之后就一句话金字塔数据产出的核心目标是够用就停不是越多越好。我会根据展示场景的相机最小高度反推最高需要的层级不额外多做一级。6.3 更新地形后看不到变化疑似缓存问题项目推进过程中源DEM更新过一版。我把新瓦片部署上去刷新页面却还是旧地形。第一反应是服务端没更新结果翻了一圈文件时间戳全是新的这才想到是浏览器和本地代理把地形瓦片缓存住了。地形请求是标准HTTP GET浏览器缓存对相同URL默认生效瓦片路径没变自然永远命中旧缓存。解决办法是让瓦片URL带版本号把输出根目录从tiles改成tiles_v2或者在请求路径里加版本参数。我这里直接换目录名简单粗暴效果最好。6.4 高速拉近时场景卡顿单瓦片文件体量失衡还有一个性能问题相机从极高处快速拉近到项目区域时Cesium同时涌进大量瓦片请求本地服务器压力骤增页面交互明显变卡。排查后发现我的单瓦片文件有些超过几百KB一次拉近可能触发几十上百个这样的请求带宽和解析能力都吃不消。处理办法是把单瓦片网格间距缩小让每个瓦片覆盖更小地面范围同时控制单个瓦片顶点数量把文件体积压到一百到两百KB区间。改完之后拉近过程顺滑很多虽然总瓦片数略有增加但请求响应速度的提升远大于文件数量增加带来的影响。这个参数没有绝对标准建议按项目实际网络环境试几组观察加载时间和帧率再拍板。7. 下一步还能做什么影像叠加与地形分析扩展7.1 做好地形以后影像瓦片发布就顺手了地形数据自建完成数据管线就打通了一半。紧接着要做的是把项目区域的卫星影像也做成瓦片服务和地形叠加使用。操作思路和地形很类似把大影像统一投影、按同一套经纬度规则切瓦片发布成标准瓦片服务在Cesium里通过影像图层加载。注意切片级别要与地形金字塔对齐这样同一层级下影像和地形请求能成对出现画面呈现最完整。叠加完成之后一套自建地形加自建影像的内网三维底图就齐了。7.2 基于成果地形数据分析扩展可以做哪些有了自己手里的高质量地形很多分析功能就变成往里加逻辑的事。比如坡度坡向分析直接用DEM计算每个单元格的坡度和朝向做成色斑图或矢量图层。比如淹没模拟给定一个水位值在地形网格上算哪些区域会被淹生成淹没范围面。比如通视分析在场景里任取两个点让系统判断它们之间有没有山体遮挡。这些需求在Cesium场景里非常常见前提都是有一套可控、可更新的地形数据。官方在线地形在类似场景反而不好用因为拿不到原始格网数据做不了精确空间计算。把地形数据掌握在自己手上之后这些扩展才真正变成可能。最后说一句个人体会。如果你也准备做类似的事第一次跑流程千万别贪大先用一小块数据把原始DEM到Cesium加载瓦片的链路走通确认地形、影像、业务叠加全部正常再铺开到全区域。地形数据生产这件事顺序错了代价很大顺序对了速度反而快。