做Cesium开发这几年,凡是涉及地形的项目,几乎都要先在高程数据这个问题上绕几圈。很多刚入坑的同事第一反应是“new一个Viewer,地形不就出来了?”,确实,默认的Cesium Ion世界里有一份全球地形,但关键是这份地形精度够不够、单位是什么、能不能固化到离线环境、采样出来的高度到底是不是真实海拔。不把这些底层逻辑弄清楚,后面做淹没分析、填挖方计算、可视域分析,结果一定会让你怀疑人生。
这篇文章把我自己在项目里反复使用的Cesium高程数据方案整理了一遍,覆盖数据源选型、地形加载配置、高程采样、几种常见业务玩法,以及排查地形相关Bug的经验,适合Web GIS开发者、数字孪生实施人员和Cesium入门者参考。内容里没有绕弯子,都是可以直接落地的东西。
1. 高程数据选型:数据不对,后面全白干
1.1 先搞清楚Cesium里到底吃哪种“高程数据”
在Cesium里说“高程数据”,多数时候指的就是地形数据,英文文档里叫Terrain。它本质上是DEM(Digital Elevation Model)经过切片处理后生成的瓦片数据。原始DEM常见来源有SRTM、ASTER、ALOS AW3D30、机载LiDAR等,格式通常是GeoTIFF或者DTED,但这些原始文件不能直接给Cesium用,必须先处理成Cesium能识别的地形瓦片格式。
目前Cesium最推荐的地形格式是Quantized-Mesh,它把地形表面表示成一棵四叉树瓦片结构,每个瓦片内是一个经过量化的三角网格。这种格式的好处是WebGL渲染效率高、LOD调度成熟,还支持水遮罩和顶点法线。另一个老格式是Heightmap,原理是每个瓦片存一张高度图(灰度值),但这种格式现在已经逐步边缘化,除非你维护的是老系统,否则新项目不建议再用。
我在实际项目里遇到过有人在问“为什么我加载了DEM却不显示地形”,多半是直接把GeoTIFF丢给了前端。无论数据源头是什么,Cesium前端的TerrainProvider只能消费两种东西:一种是自带解析器的在线地形瓦片服务,另一种是Cesium Ion托管的terrain数据集。所以数据源选型,本质上是在回答“原始DEM怎么变成Cesium吃得到的terrain瓦片”。
1.2 常见地形数据源对比与选型建议
先给一张表,把我用过的几种数据源按精度和适用场景列清楚:
| 数据源 / 格式 | 典型精度 | 使用方式 | 适用场景 | 备注 |
|---|---|---|---|---|
| Cesium Ion World Terrain | 约30米(局部更高) | createWorldTerrainAsync | 联网项目快速起步 | 自带水遮罩、法线 |
| SRTM | 30米 / 90米 | 转Quantized-Mesh后自建服务 | 大范围地形底图 | 免费,全球覆盖 |
| ASTER GDEM | 30米 | 转Quantized-Mesh | 局部细节补充 | 城市区域有一定精度 |
| ALOS AW3D30 | 30米 | 转Quantized-Mesh | 亚洲区域项目 | 精度较SRTM稳定 |
| 机载LiDAR | 1米到5米 | 按需切片 | 电力巡检、水利项目 | 数据量通常很大 |
| 专业航线倾斜模型 | 厘米级到分米级 | Cesium 3DTiles | 精细数字孪生 | 一般不作为地形源 |
如果项目允许联网,我建议优先用Cesium Ion上的World Terrain,省事、稳定,而且免费额度对大多数原型项目够用。如果做政企内网项目,离线环境十有八九需要自建地形服务,这时候一般流程是:
- 准备好原始DEM GeoTIFF,注意坐标系先统一到EPSG:4326,高程单位统一到米;
- 使用Cesium Terrain Builder这类工具把它切片成Quantized-Mesh瓦片;
- 用Nginx或者其他静态文件服务器发布瓦片目录;
- 前端通过
CesiumTerrainProvider.fromUrl加载。
这样一套流程下来,地形数据就完全掌握在自己手里了,不用再依赖外网。
1.3 高程基准问题:椭球高和正高别混着用
这是容易被忽略又最容易出大问题的地方。Cesium默认使用WGS84椭球高(height是相对于WGS84椭球面的),而很多测绘数据里的高程是海拔高,也叫正高或水准高,两者的基准差着一个大地水准面差距。在国内大部分区域,这个差值一般在二三十米到四五十米之间,不同位置不一样。
我举个真实例子:某次做水库淹没分析,甲方给的水位数据是海拔高程,地形DEM也是海拔高程,看着都没问题。但我们加载到Cesium后,发现水位把大片不该淹的区域淹了,一查发现是中间某个环节把DEM的高程基准转错了——前端拿到的高度是椭球高,和水位数据差了三十多米。排查了整整一个下午,最后把基准统一后,结果一下就正常了。
所以在你把数据喂给Cesium之前,务必确认所有高程数据使用的基准统一。如果在Cesium里只是做视觉展示,那差异不一定看得出来,但只要你把高度值用于计算,比如淹没分析、填挖方、飞行漫游离地高度,就必须把基准搞对。
2. Cesium加载地形的正确姿势与细节
2.1 基础配置:TerrainProvider和setTerrain怎么用
现在Cesium版本已经迭代得比较快,建议直接用新版API。老写法是viewer.terrainProvider = xxx,新版更推荐用viewer.scene.setTerrain。下面给出最常用的两种加载方式。
离线Quantized-Mesh地形:
const viewer = new Cesium.Viewer("cesiumContainer", { animation: false, baseLayerPicker: false, geocoder: false, timeline: false, sceneMode: Cesium.SceneMode.SCENE3D }); const terrainProvider = await Cesium.CesiumTerrainProvider.fromUrl( "https://your-domain.com/terrain/{z}/{x}/{y}.terrain", { requestWaterMask: true, requestVertexNormals: true } ); viewer.scene.setTerrain(new Cesium.Terrain(terrainProvider));如果你用的是Cesium Ion在线地形:
Cesium.Ion.defaultAccessToken = "你的Ion Token"; const worldTerrain = await Cesium.createWorldTerrainAsync({ requestWaterMask: true, requestVertexNormals: true }); viewer.scene.setTerrain(new Cesium.Terrain(worldTerrain));这里有个细节值得注意:requestWaterMask和requestVertexNormals不是所有地形源都支持。如果你的自建地形瓦片里没有水遮罩和法线数据,设置了也没用。好在Quantized-Mesh格式可以包含这些扩展,切片的时候建议带上,因为顶点法线对地形光影效果影响很大。
2.2 地形显示效果:夸张、光照和贴地
很多人加载完地形后觉得“太平了”,尤其是平原地带的项目。这时候可以用地形夸张来放大起伏感:
viewer.scene.globe.terrainExaggeration = 2.5; viewer.scene.globe.terrainExaggerationRelativeHeight = 0.0;第二个参数terrainExaggerationRelativeHeight表示相对哪个高度做夸张,0表示相对海平面。如果你的项目区域海拔很高,比如在云贵高原,整体海拔两千米,那么夸张后山体起伏会显得很别扭。这时候建议把RelativeHeight设置为区域平均海拔,让低海拔区域基本不变,高海拔的山体被拉高。
如果想让地形看起来有光影立体感,打开光照:
viewer.scene.globe.enableLighting = true; viewer.scene.globe.dynamicAtmosphereLighting = true;配合requestVertexNormals: true,地形表面就会有明暗变化,而不再是灰白一片。做数字孪生项目时,这个效果对“真实感”的提升非常明显,尤其是动态太阳光照随着时间变化的时候。
还有一个高频选项是depthTestAgainstTerrain:
viewer.scene.globe.depthTestAgainstTerrain = true;这个默认是false。如果设为true,模型和地形之间会做深度测试,模型压到地下就会被地形遮挡住。做贴地测量或楼层选中时,这个选项会影响拾取准确性,所以它不是无脑开的,要根据业务判断。
2.3 高程采样与高度查询:sampleTerrain和sampleTerrainMostDetailed
除了看地形效果,很多时候我们需要在代码里动态获取某个经纬度对应的高度。比如无人机航线规划、车辆偏移校正、水位分析,都要用高程采样接口。
Cesium提供两个核心接口:Cesium.sampleTerrain和Cesium.sampleTerrainMostDetailed。前者接受一个level参数,指定从某个LOD级别采样;后者会自动请求最高级别的细节,精度更高,但开销也更大。
const positions = [ Cesium.Cartographic.fromDegrees(116.391, 39.907), Cesium.Cartographic.fromDegrees(116.401, 39.917), Cesium.Cartographic.fromDegrees(121.473, 31.230) ]; // 在线地形:请求最高精度 const updatedPositions = await Cesium.sampleTerrainMostDetailed(worldTerrain, positions); updatedPositions.forEach((position) => { console.log(`Lon: ${Cesium.Math.toDegrees(position.longitude)}, Lat: ${Cesium.Math.toDegrees(position.latitude)}, Height: ${position.height}`); });注意sampleTerrainMostDetailed只能用于在线地形数据集,比如Ion的Terrain或CesiumTerrainProvider自带的URL。它对坐标数量比较敏感,如果是几千上万个点的大范围采样,建议不要在前端跑,而是服务端定时批处理生成结果。我在某个河道项目里对一百多万个点做过采样,纯前端直接卡死,最后改成后端用C++解析DEM文件批量计算,几秒钟就出结果。
如果一定要前端做很多点采样,可以把点分批,比如每批200个,异步串行处理,并且设置合理的Camera距离,避免LRU缓存频繁淘汰,否则采样请求会反复重新加载地形的上级瓦片,性能会很差。
3. 把高程数据变成业务价值:几个高频场景拆解
3.1 数字孪生项目里的“贴地”与“单体化”
在数字孪生项目里,3DTiles倾斜摄影、BIM模型和地形数据经常需要叠加显示。很多人会遇到一个现象:模型加载出来了,但它和地形之间要么“穿模”,要么“浮空”。
简单做法是对Entity设置heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,这样点、标注、广告牌会自动贴合地表。但3DTiles数据集本身比较复杂,直接设置heightReference并不可靠。更常见的做法是:在数据生产阶段就把模型底部校准到当地高程,导出时可以带上地面原点高度,这样3DTiles在Cesium里加载出来就是正好贴地的。
如果项目里必须动态放置带模型节点的构件,比如复杂glTF/GLB结构,建议用Cesium.Model.fromGltfAsync加载后再遍历节点,用model.modelMatrix配合采样高度做整体位移。这里牵扯到“模型节点”的层级关系,操作时注意子节点坐标是相对父节点的,直接改世界矩阵容易造成节点错位。
“单体化”也是数字孪生里的热门词。所谓单体化,简单说就是让倾斜摄影模型能按楼层、户室、道路单独选中。地形在这个场景里通常作为背景底图,但它会影响点击拾取。如果depthTestAgainstTerrain开着,射线可能先打在地形上,导致建筑物根部选不中。我的经验是:做单体化拾取时,临时把depthTestAgainstTerrain设为false,或者用scene.pickPosition这种支持地形深度判断的接口,再把拾取结果和建筑轮廓数据做空间过滤。
另外,如果项目里要加载矢量切片(MVT),比如道路、河流、行政区边界,这些矢量要素也可以配合地形贴地显示。MVT本身只是矢量数据,不直接带高度,但你可以把要素的坐标转成点序列,逐个采样地形高度后生成贴地的GeoJSON或Polygon,配合GroundPrimitive来渲染,效果很不错。
3.2 淹没分析、填挖方和可视域:把DEM变成决策依据
这几个功能是GIS项目的高频需求,也是面试官喜欢问的地方。先说淹没分析。它的核心逻辑并不复杂:找到一个水位高度,把地形高度低于水位线的区域渲染成水面。难点在于怎么高效地判断“低于水位线的区域”。
我的做法是:先把分析区域的多边形按一定间距打网格,比如间距50米,每个格点采样地形高度;然后用二分法不断逼近目标水位。每次试算,统计格点中低于水位的数量占比,直到满足淹没面积或水位停止变化。这样找出来的水位高度可以用来生成水面Polygon:
const waterPolygon = viewer.entities.add({ polygon: { hierarchy: analysisPolygon, height: waterHeight, material: Cesium.Color.BLUE.withAlpha(0.5), classificationType: Cesium.ClassificationType.TERRAIN } });注意水面不能用height: 0去贴地,否则水面会被地形完全盖住,根本看不出淹没效果。正确做法是用绝对高度做平面,并开启classificationType: TERRAIN让水面只显示在地形表面以上。另外,如果精度要求高,比如水利工程验收级别,上游到下游的水位不是平的,而是有坡降的,这时要引入水面线函数,而不是用一个固定高程。
3.3 填挖方计算的实践思路
填挖方计算本质上是一个体积积分问题。设计标高确定后,对场地按固定网格划分,每个网格的中心点采一次原始地形高程,然后用“设计标高 - 实测高程”乘上网格面积,累加得到填方或挖方量。
V填 = Σ max(设计标高 - 实测高程, 0) × 网格面积 V挖 = Σ max(实测高程 - 设计标高, 0) × 网格面积这个公式看着简单,但我在项目里踩过一个大坑:如果前端开了terrainExaggeration,采样出来的高度是夸张后的高度,不是真实高程。填挖方计算必须把地形夸张系数设为1,或者直接用服务端原版DEM算,否则最后算出来的土方量能差出一倍还多。
另外网格间距的选择直接影响计算精度。间距越密越准确,但采样次数会指数上升。我的经验是:平整场地用10米到20米间距足够;公园地形造型这类项目,5米间距勉强够;再密的话建议做剖分或TIN三角网计算,效率远高于均匀网格。
3.4 雷达扫描、热力图和动态光照:让地形“活”起来
这些功能虽然不直接“使用高程数据”,但它们都以地形场景为承载。比如雷达扫描效果,本质上是一个扇形区域随时间扫过地形,扇形本身用Polygon实现,但如果要精确显示雷达波被地形遮挡的阴影区,就需要对地形做射线求交,判断每个方位角上哪些区域被山体挡住。这个和可视域分析是同一个底层原理。
热力图叠加在地形上时,最稳妥的做法是把业务数据点投射到地形表面,生成一个半透明的覆盖层。用GroundPrimitive加自定义Shader,或者用Heatmap.js先画成图片,再贴到一个矩形或Polygon上。要注意温度、污染等热力数据是按经纬度分布的,不能简单地在2D平面投影后贴上去,否则在山地地区会明显错位。
动态光照和地形结合的效果很有感染力。打开enableLighting后,配合Cesium的时钟,设置太阳在不同时间的位置,可以看到地形阴影随着“一天”变化。这个在方案汇报里特别加分。要注意的是,动态光照对性能有一定影响,如果场景里还有大范围倾斜摄影模型,建议降低屏幕空间误差或者用瓦片层级控制,否则帧率掉得很厉害。
3.5 在地形上绘制矩形并测量面积
和“cesium绘制矩形”这个热词相关的需求也很常见:在地形上框选一块区域,测量实际面积。难点在于地形表面不是平面,直接用椭球面面积会偏小。我用的方法是:先在地形上确定矩形的四个角点,再用ellipsoidGeodesic计算相邻点间的大地线长度,最后按四边形面积近似计算。如果区域很大,最好把边界细分,对每个小三角形做地形修正面积累加。
画矩形的交互可以用ScreenSpaceEventHandler监听左键点击,然后用viewer.entities.add创建一个带轮廓的地面矩形,让它的classificationType为TERRAIN并设置相对于地表的偏移,这样矩形会贴合地形起伏,不会悬浮飘着。测量结果的展示可以加一个Label,实时显示面积数值。
4. 常见问题与排查技巧实录
4.1 地形加载不出来、发白或者颜色不对
这是遇到次数最多的一类问题,我把典型场景和解决方法整理成了一张速查表:
| 现象 | 可能原因 | 排查与解决 |
|---|---|---|
| 地形完全不显示,地球是一片蓝色 | Ion Token未配置或过期 | 检查Cesium.Ion.defaultAccessToken |
| 地形显示了,但是发白、没有凹凸感 | 地形瓦片没有顶点法线 | 切片时开启VertexNormals,加载时设requestVertexNormals: true |
| 自建地形服务加载后报CORS错误 | 静态服务没有允许跨域 | Nginx增加Access-Control-Allow-Origin配置 |
| 地形加载慢,瓦片一直转圈 | 服务端瓦片不存在或路径不对 | 检查URL模板{z}/{x}/{y}.terrain是否正确 |
| 海拔明显异常,比如城市中心显示负几百米 | 高程基准不一致 | 检查DEM是椭球高还是正高,统一基准 |
有一种特别容易误判的情况:Ion的地形服务在开发环境里能加载,但打包上生产环境就挂了。原因是生产环境域名和你申请Ion Token时填写的域名不一致,Ion会拒绝请求。解决办法是在Ion控制台重新生成Token,并把你所有可能用到的域名都加进允许列表。
4.2 3D地球滚动或缩放时出现“崩溃”怎么办
“cesium 3d地球滚动出现崩溃”这个热词,我猜很多开发者都搜过。这里说的崩溃,通常不是浏览器整个崩掉,而是出现白屏、黑屏、WebGL上下文丢失,或者页面假死。
最常见的原因有三个:一是显存和内存被地形瓦片占满,尤其是连续快速缩放时,Cesium会疯狂加载各个层级的瓦片,低配机器很容易扛不住;二是某些显卡驱动对WebGL支持不稳定,在连续重绘时触发Context Lost;三是代码里有非法坐标,比如纬度超出范围或者经纬度字段混用,导致渲染阶段计算异常。
我的排查步骤是:
- 打开浏览器的
chrome://gpu,确认WebGL状态和GPU进程是否正常; - 把
requestRenderMode打开,避免一直重绘:
viewer.scene.requestRenderMode = true; viewer.scene.maximumRenderTimeChange = 1.0 / 30.0;- 限制地形缓存和误差:
viewer.scene.globe.tileCacheSize = 100; viewer.scene.globe.maximumScreenSpaceError = 2.0;- 检查代码里的所有经纬度输入,尤其是后端接口返回的数据,逐个打印出来看有没有NaN或非法值。
如果问题只在低端设备上出现,那大概率是资源占用过高。可以把maximumScreenSpaceError从默认的2调到4或6,这样瓦片层级会浅一些,渲染压力小很多。代价是拉近时地形会稍模糊,但对大多数业务场景影响不大。
4.3 Cesium高程相关的高频面试题与简答思路
把“cesium 面试题”这个热词也一起融进来,结合高程这个主题,面试官最常问的点基本集中在这几个方向。
| 面试题 | 简答思路 |
|---|---|
| Cesium支持哪几种地形格式? | 主流是Quantized-Mesh,老版本支持Heightmap,Cesium Ion也接受GeoTIFF转地形 |
| Quantized-Mesh和普通3D网格有什么区别? | Quantized-Mesh针对海量地形做四叉树调度,顶点坐标用16位量化压缩,支持扩展水遮罩和法线 |
sampleTerrain和sampleTerrainMostDetailed区别? | 前者指定LOD级别,后者自动请求最细级别,精度更高但开销更大 |
| 地形夸张会影响采样高度吗? | 会影响,采样返回的是夸张后的显示高度,做计算时要关掉夸张或用原始DEM |
| 离线项目里怎么加载地形? | 将DEM切片成Quantized-Mesh,Nginx发布,前端用CesiumTerrainProvider加载 |
| 怎么避免倾斜摄影模型和地形穿模? | 数据生产阶段校准底高,加载后可整体平移模型,必要时关闭深度测试 |
| 地形在水面之下的部分怎么处理? | 水生效果相关需求,可使用水遮罩或SurfaceShader,让地形和水面贴合 |
这几个问题背后考察的其实不只是API记忆,而是你有没有真正理解“高程数据从原始DEM到前端渲染”的完整链路。我在面试别人时,喜欢让候选人解释一下terrainProvider和globe的关系,能把这个讲清楚的,基本都对Cesium的核心机制有概念。
5. 关于Cesium高程数据使用,我最后想说的几句实在话
如果给新手一个最实用的建议,那就是:先别急着把地形效果做得花里胡哨,先把“任意一个点,采样出来的高度值”验证正确,确认数据源和基准没问题,再往上叠加功能。这个基础不稳,后面所有分析都是空中楼阁。
再分享一个小技巧:调试的时候不要只靠人眼看地球,可以用viewer.camera.positionCartographic打印相机当前经纬度和高度,再手动飞到某个已知高程地物附近做对比。比如我知道某个城市广场的大地高是42米,就把相机飞到那附近,采样看是不是42米左右。误差超过两三米就说明数据源或基准有问题,需要回到数据源头去查。
高程数据的坑,大多不在Cesium本身,而在数据链路。只要记住“格式转对、基准统一、精度匹配、性能可控”这四句话,绝大多数地形项目都能顺顺利利做完。希望这篇总结能帮你少走点弯路。