做低空飞行漫游项目的时候我遇到的第一道坎就是Cesium 获取地形高度。按理说这功能挺基础可真正上手才发现里面有大量“坑”——地形没加载完成时取到的是椭球面默认值椭球高和海拔根本不是一回事按帧采样又让性能崩掉。我花了整整两天把整条链路跑通这篇文章就把整个过程掰开揉碎讲清楚覆盖核心 API、贴地漫游、射线拾取、性能优化和高频踩坑点希望能帮你少走我走过的弯路。1. 先分清“椭球高”和“海拔”否则后面全白做接触 Cesium 高程之前我一直默认“高度”就是海拔。直到某天我把采样结果和无人机回传的飞行高度一对比发现偏差十几米甚至几十米才意识到问题出在高程基准上。1.1 Cesium 返回的高程到底是什么基准Cesium 内部所有坐标计算都基于 WGS84 椭球体。所谓地形高度在 Cesium 里本质上是地形表面采样点相对于 WGS84 参考椭球面的垂直距离也就是椭球高ellipsoidal height。这和测绘领域常说的海拔正高或正常高是两个基准椭球高以规则椭球面为基准数学模型简单Cesium、GPS 原始输出都在这个基准上。海拔以大地水准面近似静止海平面延伸为基准更贴近“现实海拔”但大地水准面本身是不规则曲面。两者之间的差值叫“大地水准面差距”。在我国大部分区域这个差值大约在 -5 米到 40 米之间波动。所以如果你把 Cesium 采样出的椭球高直接当作海拔误差基本是必然的。实际项目中如果需要海拔就得引入所在区域的大地水准面模型做换算Cesium 本身只提供椭球高别指望内置帮你转成海拔。1.2 地形源选错后面所有代码都是空转获取高度之前必须先确认场景里真的加载了地形数据。很多人直接new Cesium.Viewer就以为有地形了实际上默认的 viewer 用的是 EllipsoidTerrainProvider也就是一个平滑椭球面没有 DEM 信息怎么采样高度都只能拿到椭球面高度。正确做法是显式加载地形比如带高程服务的全球地形const viewer new Cesium.Viewer(cesiumContainer, { terrainProvider: await Cesium.createWorldTerrainAsync({ requestVertexNormals: true, requestWaterMask: true }) });createWorldTerrainAsync的底层是 Cesium 官方 Ion 服务需要申请 Token 并在Cesium.Ion.defaultAccessToken中配置。如果项目涉及内网或离线环境就得换本地部署的 CesiumTerrainProvider加载自定义切片地形。一个非常容易踩的坑加载地形是异步的viewer 初始化之后地形数据不一定已经就绪。此时调用采样接口可能返回 0 或者偏差很大的值。我之前一度以为是代码写错了后来打印terrainProvider.ready才发现地形压根还没加载完。稳妥的做法是在readyPromise或首帧渲染后的回调里再执行高度采样相关逻辑。2. sampleTerrainMostDetailed批量采样地形高程的标准姿势整条链路里最核心、也最常用的接口就是sampleTerrainMostDetailed。它可以根据地形数据的分辨率自动选择最精细的瓦片层级进行采样返回带有地形高度的Cartographic坐标数组。2.1 从零跑通一次高度采样先看最完整的一段基础实现async function getTerrainHeights(longitudes, latitudes) { // 将经纬度转为 Cartographic 数组 const positions []; for (let i 0; i longitudes.length; i) { positions.push(Cesium.Cartographic.fromDegrees(longitudes[i], latitudes[i])); } // 注意必须等地形 provider ready 之后再采样 const terrainProvider viewer.terrainProvider; // 核心采样调用 const updatedPositions await Cesium.sampleTerrainMostDetailed( terrainProvider, positions ); // 返回值会直接修改传入数组中的 height 字段 const heights updatedPositions.map((pos) pos.height); return heights; }接口的用法很简洁传入TerrainProvider和坐标数组每个坐标的height字段会被原地更新。返回结果同时作为 Promise 的 resolve 值两种拿法都行。这个接口有一个很关键的行为它会为每个采样点请求对应区域最精细的瓦片。如果你一次传入几百个点内部可能是逐个瓦片请求耗时并不低。不过它保证了精度对于航点规划、剖面分析这类对高程精度有要求的场景值得等。如果只是需要粗略高度做界面显示sampleTerrain是另一套 API它的精度和性能都低一些但足够快速预览。2.2 采样坐标要区分经纬度和弧度一个频率极高的低级错误是拿经纬度数值直接去构造Cartographic。Cartographic的构造参数是弧度不是度。Cesium.Cartographic.fromDegrees会自动帮我们换算成弧度。但如果你在已有的Cartographic对象上手动改值比如从camera.positionCartographic拿到坐标后再赋值longitude 114.3那得到的位置就不知道飘到哪去了。// 错误示例直接填经纬度数 const wrongPos new Cesium.Cartographic(116.39, 39.9); // 单位是弧度116 弧度等同转了好几圈 // 正确示例用 fromDegrees 或手动转弧度 const rightPos Cesium.Cartographic.fromDegrees(116.39, 39.9); // 等价写法 const rightPos2 new Cesium.Cartographic( Cesium.Math.toRadians(116.39), Cesium.Math.toRadians(39.9) );从Cartesian3转Cartographic时同样要注意Cesium.Cartographic.fromCartesian(cartesian)返回的是弧度制经纬度打印时如果想显示成度数要用Cesium.Math.toDegrees转换。2.3 批量点位采样时的工程化处理真实项目里很少只采三五个点。比如一条 50 公里的低空巡检航线按 100 米一个航点一次就要采 500 个点。这时候直接裸调sampleTerrainMostDetailed会出现两个问题第一500 个点可能分布在 20 多个瓦片上同一个瓦片里的点可以合并请求但接口自己做了这层处理所以我们不需要自己拆。真正的问题是 Promise 链太慢整个界面会卡住等待。解决方案是分批采样 Promise.all 并行async function batchSample(positions, batchSize 100) { const results []; for (let i 0; i positions.length; i batchSize) { const chunk positions.slice(i, i batchSize); results.push(Cesium.sampleTerrainMostDetailed(viewer.terrainProvider, chunk)); } const settled await Promise.all(results); return settled.flat(); }第二同一个点位如果短时间内反复采样比如用户拖拽航线点每拖一次就触发一次全航线采样会疯狂请求瓦片。工程上需要维护一个“最近采样结果缓存”或做防抖。我常用的策略是以经纬度网格为 key 存结果超过一定经纬度偏移才重新采样拖拽交互过程只对当前拖拽的航点做单点采样松开后再触发全航线更新。3. 贴地漫游与连续高程追踪让相机听话地贴着地面飞获取高度的最终目的往往不是打印一个数字而是让相机、实体、模型“跟着地形走”。比如无人机低空巡检、模拟车辆在山地道路行驶、贴着山体漫游这类场景。这个阶段会遇到最典型的连续高程问题。3.1 相机贴地飞行的实现思路相机贴地飞行最直接的想法是每帧拿到相机所在经纬度采样该点地形高度然后把相机高度设置为“地形高度 固定离地高度”。但这里有一个隐藏问题采样是异步的无法在渲染帧回调里同步拿到结果。如果你在clock.onTick里直接await sampleTerrainMostDetailed帧循环已经继续执行了相机位置还没更新视觉上会一顿一顿。更稳妥的方案是“预采样 插值修正”。先把整条航线的采样结果提前拿到然后相机飞行过程中按当前位置在航线上做插值得到目标高度再平滑过渡到当前高度// 假设 routeHeights 是沿航线按顺序采样的高度数组 // routePositions 是对应的经纬度数组 viewer.clock.onTick.addEventListener(() { const carto viewer.camera.positionCartographic; // 找到相机在航线上的最近点索引 let index findNearestRouteIndex(carto.longitude, carto.latitude); // 目标高度 地形高度 离地高度 let target routeHeights[index] 50; // 平滑系数避免高度突变 let smoothed Cesium.Math.lerp(carto.height, target, 0.05); viewer.camera.positionCartographic.height smoothed; });这里的高频优化点是在onTick里做角度索引查找时不要对几百个点做全量遍历。简单把航线按经纬度间隔切分到哈希索引里相机查找最近点的平均时间复杂度可以降到接近 O(1)帧率会明显改善。3.2 鼠标点选与射线测高另一个高频需求是鼠标点击场景任意位置显示该点地形高度。很多人直接使用scene.pickPosition但这个接口返回的是被拾取物体的深度缓冲位置如果有模型或图层遮挡拿到的就不是地形高度而是物体表面高度。正确做法是构造射线然后让 globe 自己求交const handler new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas); handler.setInputAction((movement) { const ray viewer.camera.getPickRay(movement.position); if (!ray) return; // 关键globe.pick 求的是地形表面交点 const cartesian viewer.scene.globe.pick(ray, viewer.scene); if (!cartesian) return; const carto Cesium.Cartographic.fromCartesian(cartesian); const height carto.height; // 展示高度... }, Cesium.ScreenSpaceEventType.LEFT_CLICK);如果你把globe.pick换成viewer.camera.pickEllipsoid得到的是射线与参考椭球面的交点而不是地形表面交点。在地形起伏明显的山区两种方式的高度差能到上百米。所以测高场景一定要用globe.pick。3.3 模型贴地与标签压地模型和标签的贴地也有讲究。点位、广告牌、标签这类 2D/3D 混合实体一般直接在实体上设置heightReference为Cesium.HeightReference.CLAMP_TO_GROUND就能自动贴地但前提也是地形加载完成。如果地形还没 ready实体可能先挂在椭球面上之后也不会自动刷新表现出“浮空”或“钻地”的现象。处理办法是监听地形 ready 后手动刷新实体的position或者用前面讲的高度采样结果显式设置实体的position而不是完全依赖CLAMP_TO_GROUND。对于 glTF 模型设置heightReference有时会因为模型锚点位置不同而出现穿透。更精细的做法是拿到采样高度后直接修改modelMatrix或position让它锚在真实地形面上。4. 高频踩坑现场地形高程相关的疑难杂症排查可以说Cesium 高程开发中最耗时间的不是实现而是排查那些“时有时无”“数值诡异”的问题。我把实际项目中遇到过的几类高发问题列出来并给出从现象到根因的完整排查链路。4.1 采样高度一直是 0 或负数到底发生了什么负数和 0 的出现要分情况讨论。如果所有点位返回高度都是 0最可能的情况是地形 provider 没真正加载场景还停留在椭球体上。检查思路console.log(viewer.terrainProvider.ready); console.log(viewer.terrainProvider.availability);如果ready为 false说明异步加载还没完成你的采样调用在 its ready 之前执行了。把采样逻辑放到terrainProvider.readyPromise.then(...)里即可解决。如果部分点位返回负数情况又不一样。Cesium 世界地形在海洋区域的高度值是负的因为它以椭球面为基准海底自然低于椭球面。如果你在近海区域采样拿到 -20 这类值是正常的。还有一种常见情况是瓦片没有直接返回数据内部的默认高度缓冲是 0 或者异常值。这时要打印采样点的经纬度确认它是否处于地形瓦片覆盖范围边界。4.2 对同一位置重复采样结果居然不一样我调试时发现同一个经纬度连续采两次高度结果能差出几米到几十米。后来想明白原因sampleTerrainMostDetailed在第一次采样时会动态请求该区域的高精度瓦片但地形流式加载有 LOD 策略第一次请求返回的是当前可用层级的数据等高精度瓦片到达后再次采样数值自然变了。这不是 bug而是地形流式加载的特性。如果你要的是“最终精度值”需要在瓦片加载完成后再次采样。实操中我会对重点点位采样两次第一次“预热”等几百毫秒后再采一次取最终值。对于航线规划这种大批量采样场景可以先让相机飞到目标区域上空附近或者调用接口强制请求该区域瓦片等tileLoadProgressEvent事件触发后再正式采样。4.3 按帧采样导致帧率崩塌相机贴地飞行最容易踩的性能坑就是每帧全量采样。有人直接把几百个航线点放在onTick里逐帧sampleTerrainMostDetailed结果瓦片请求洪水一般打穿网络帧率掉到个位数。要解决这个问题记住三条原则即可一次飞行航线只采样一次飞行过程中做插值不要反复采样。用户交互实时拖动场景时对拖动目标做单点限频采样比如 500ms 一次。利用requestRenderMode减少无关帧渲染只有相机移动或瓦片加载时才渲染新帧。4.4 数据源叠加导致高度互相干扰三张地形或影像源叠在一起的时候可能出现采样高度与你视觉看到的地表不一致。最常见的是业务层叠加的高精度局部 DEM 与全球地形之间存在接边差。Cesium 的地形 provider 是全局唯一概念你加载了本地高精度 DEM全球地形就整体替换了而不是“局部叠加”。所以如果你需要局部高精度高程就得做瓦片融合或按区域在业务代码里做高程修正。我也见过有人试图用terrainProvider的多个实例动态切换来实现“局部高精度”但实现复杂度很高建议结合后端服务生成一张覆盖范围更大的 DEM 瓦片集避免前端做拼合。5. 进阶玩法把“高度”从数字变成产品能力高程数据单纯作为数字展示价值有限。真正让它发光的是把高度和业务逻辑结合比如剖面分析、可视域分析、倾斜模型贴地处理等。最后这部分分享一些直接可用的进阶思路。5.1 剖面分析与航线可达性预判剖面分析的本质就是沿一条线段等间隔采样若干点取每个点的地形高度然后绘制出二维的“海拔—距离”曲线。拿到这条剖面后就能直观判断航线是否会撞山、坡度过大、以及最佳爬升点在哪。我的实现思路是根据线段总长和采样间隔动态计算采样点数量比如每隔 30 米一个点然后调用sampleTerrainMostDetailed批量采样。采样结果存成数组后用 Canvas 2D 画一个坐标轴即可不需要额外图表库。剖面分析里有一个隐藏难点线段可能跨越不同精度的瓦片采样出的高度在瓦片边界处会出现微小跳变。如果你的业务对连续度要求高可以做一次简单的滑动平均平滑视觉上会自然很多。5.2 3D Tiles 倾斜模型的贴地修正有人会问倾斜摄影模型本身带高程还有必要做贴地处理吗答案是分情况的。倾斜摄影模型的高程是模型表面本身的坐标不是地形高程不需要额外贴地。但点云、白模、BIM 提取模型这类没有地形概念的数据导入球场景后通常悬浮或扎进地里。处理这类贴地不能直接把模型所有顶点都压到地形面上因为模型自身有结构高度。正确做法是只修正模型底部锚点让底部中心点贴合地形采样高度同时保留模型内部相对高度。实际操作中我先把模型的包围盒底边中点经纬度提取出来采样该点地形高度再构造Matrix4平移模型让底部中点落在“地形高度 模型底部高度偏移”的位置。还有一个小技巧开启viewer.scene.globe.depthTestAgainstTerrain true可以让半透明模型、遮挡关系更真实。但要注意开启后对硬件深度缓冲依赖增强如果地形瓦片加载不及时可能出现模型被地面“吃掉”的现象。此时可以考虑开启underlay或者略微抬高模型底部修正值。5.3 最后一点心得整套地形高程链路跑通之后我的体会是Cesium 的高度问题本质上不是一个 API 调用问题而是一套“地形数据加载 — 坐标系换算 — 业务场景匹配”的综合工程。最容易出问题的不是某个函数用错而是你在不了解高程基准、地形加载时机、异步调用特性的时候就开始写业务代码。如果让我给后来者一个建议先花二十分钟跑通最简单的一次地形加载和一次高度采样然后再开始接业务逻辑。先确认基础链路正确再叠加复杂场景。同时把所有采样结果都打印出来肉眼核对一遍特别是有明确海拔参考的已知点位。很多时候肉眼发现“数值不对”的速度比调试代码快得多。拿准了基础后面做贴地漫游、剖面分析、模型修正都会顺手很多。
阅读完成 · 觉得有帮助?