
最近在搞一个三维地理信息项目需要让模型沿地形起伏自动贴地飞行还要在某个区域快速算出土方量。折腾了一圈发现“Cesium 地形高度获取”这件事看起来简单真做起来全是细节。查过无数资料、翻了源码、试错多次之后我把几种主流方案整理成一篇完整的实操笔记送给同样被这个问题卡住的朋友。这篇文章会把 Cesium 里获取地形高度的四类方案讲透异步采样、同步查询、屏幕拾取、底层网格推算。每一种我都会说清楚适用场景、完整代码、性能注意点以及实测中遇到的坑。无论你是做无人机航迹规划、建筑物放置还是土方量分析读完应该都能直接上手。1. 需求拆解与方案全景1.1 场景决定方案先想清楚你要的是“哪个高度的谁”在动手写代码之前我建议你先花五分钟想清楚一个核心问题你要的高度是“某经纬度坐标在某个地形服务下的标准高度”还是“当前这一帧画面里、用户鼠标点中的那个地形表面的高度”这两者的实现路径完全不同。举几个我在实际项目里遇到的场景无人机航线规划需要针对一系列经纬度航点批量获取下方地形高度然后生成贴地飞行轨迹。这种场景不需要用户交互往往是后台计算或页面加载后自动触发适合异步采样。土方量估算在一个施工区域内均匀采样几千个点获取每个点的地形高度再和设计标高做差累加得到填挖方量。这同样是离线批量计算。点击查看高程用户在地图上点一个位置立刻弹出该点地面高度。这要求高度获取和渲染画面保持同一套逻辑往往用屏幕拾取。三维模型摆放把路灯、树木、管线井盖等模型压到地面上。这个场景其实不一定需要手动获取高度Cesium 自带的heightReference就能让模型自动贴地但如果你想在贴地之前先校验某个位置是否适合摆放还是需要读取高度值。把场景列出来之后你就会发现“获取地形高度”不是一个 API 调用的问题而是一个选型问题。选错方案轻则性能浪费重则结果完全不对。1.2 四条技术路线的全景对比我整理了一个对比表基本覆盖了 Cesium 里常见的高度获取方案。看完这张表后面每一节再详细展开。方案模式精度适用场景关键限制sampleTerrain异步取决于指定层级批量获取固定层级高度需要地形服务支持返回的是地形服务插值结果sampleTerrainMostDetailed异步最高可用层级批量获取最精细高度请求量随点数增加耗时较长globe.getHeight同步当前已加载 tile 精度已加载区域内的快速查询tile 未加载时返回undefinedscene.pickPosition/globe.pick同步当前渲染深度鼠标点选、区域内拾取需要开启深度检测只能取画面内有几何体的位置说到底层网格推算我提一句QuantizedMeshTerrainData里头其实存了每个 tile 的顶点高度数组理论上可以自己插值出更精细的高度。但是这套方案实现成本偏高普通业务里用sampleTerrainMostDetailed已经足够我一般不推荐直接碰。1.3 选型时的整体判断逻辑我会按照这样一条规则来做决策如果高度数据是“预先知道的坐标集合”比如航线点、采样网格优先用异步采样。如果高度数据来自“用户的实时操作”或“和当前画面直接相关”优先用同步方案。如果用户画面里本身没有加载对应区域地形getHeight和pickPosition都用不上只能异步采样。另外要特别提醒一个容易忽略的点所有方案返回的高度默认都是相对于 WGS84 椭球面的“椭球高”不是我们常说的“海拔”。这个话题放到第 4 节专门讲因为在不少业务里这个偏差会被当成 bug 报上来。2. 异步采样sampleTerrain 系 API 的完整用法2.1 sampleTerrain 与 sampleTerrainMostDetailed 怎么选Cesium 提供了两个异步采样的 API名字很像用起来也像但行为细节完全不同。Cesium.sampleTerrain(terrainProvider, level, positions)接收三个参数其中level是 LOD 层级也就是说你想用哪个清晰度的地形数据来采样由你说了算。层级数字越小地形越粗糙结果越平滑层级数字越大地形越精细结果越接近真实地表但采样耗时和请求量也越大。Cesium.sampleTerrainMostDetailed(terrainProvider, positions)则不需要你指定层级它会自动找当前地形服务能提供的最精细层级来做采样返回的高度最准。实战里我一般这样选对结果精度没有苛刻要求或者只是要一个“大概的地形趋势”用sampleTerrain指定一个合适的层级比如 11 到 13 级响应速度很快。需要精细化结果比如生成贴地飞行轨迹、计算精细土方量用sampleTerrainMostDetailed。需要注意不是所有地形提供者都支持这两个 API。我自己用下来Cesium 官方的高精度地形服务和基于 CesiumTerrainProvider 格式的本地地形都支持得很好但某些第三方地形服务或者你自己写的高度图 Provider可能根本实现不了“返回指定层级高度”的能力调用之后所有点高度都是NaN或直接抛出异常。保险做法是先看terrainProvider.availability和terrainProvider.hasWaterMask这类能力标记再在代码里做一层容错。2.2 从单点到批量采样完整代码示例先把基础配置写出来。我习惯把地形服务和查看器分开封装这样后面换地形源不用动业务代码。import * as Cesium from cesium; // 初始化查看器按你自己的需求调整容器 id const viewer new Cesium.Viewer(cesiumContainer, { animation: false, timeline: false, baseLayerPicker: false, geocoder: false, }); // 配置地形。Cesium 支持默认地形和自有地形服务 // 无论哪种都要在初始化时绑定到 viewer 上。 const terrainProvider await Cesium.createWorldTerrainAsync({ requestWaterMask: false, requestVertexNormals: false, }); viewer.terrainProvider terrainProvider;接下来是单点采样的核心。先用经纬度构造Cartographic对象再传入采样函数。function getHeightAtPoint(lon, lat, level) { const positions [Cesium.Cartographic.fromDegrees(lon, lat)]; return Cesium.sampleTerrain(viewer.terrainProvider, level, positions).then( (updated) updated[0].height ); }用sampleTerrainMostDetailed就更是直接async function getHeightAtPointFine(lon, lat) { const positions [Cesium.Cartographic.fromDegrees(lon, lat)]; const updated await Cesium.sampleTerrainMostDetailed( viewer.terrainProvider, positions ); return updated[0].height; }注意positions里的Cartographic对象会被原地修改返回的高度直接写在了你传入对象的height属性上。这个行为很容易忽略但特别有用意味着你不必在新数组里去寻找某一项直接读原数组的对应项就行。批量采样就更符合实际业务了。航线规划常常涉及几百个点如果每个点单独请求一次效率极低。正确做法是把所有点组装成一个大数组一次性采样。async function batchGetHeights(coordList, useMostDetailed true) { const positions coordList.map(([lon, lat]) Cesium.Cartographic.fromDegrees(lon, lat) ); if (useMostDetailed) { await Cesium.sampleTerrainMostDetailed(viewer.terrainProvider, positions); } else { await Cesium.sampleTerrain(viewer.terrainProvider, 13, positions); } // 此时 positions 里的每个对象 height 字段已被填充 return positions.map((p) p.height); }我项目里有过一次批量采样 300 个点、用最精细层级的实测整体耗时大约在两三秒量级具体取决于网络和地形服务响应。如果你做的是实时交互这个耗时不可接受就要考虑第 3 节的同步方案。2.3 请求量与性能优化技巧异步采样最大的隐患是请求风暴。当你用一个几百上千点的数组去调用sampleTerrainMostDetailedCesium 会为每个 tile 发起独立的 terrain tile 请求如果这些点分布在不同 tile 上瞬间可能产生几百个并发请求。我在实际项目里积累了几个优化手段第一点集先做网格抽稀。如果采样区域很大先按经纬度网格把点聚合成一个个簇每个簇取中心点采样再用中心点高度近似整个簇。比如 100 米的栅格一个一万点的区域抽稀到一两千个点误差通常可以接受。第二对采样结果做本地缓存。用一个Mapkey 是经纬度 层级 地形版本号value 是高度值。地图平移或视角变化后如果用户反复点击同一片区域可以直接命中缓存避免重复请求。第三控制并发。把大数组按 50 个点一组拆分每组用一个Promise串行执行保证同时只有少数几个请求在飞。async function batchGetHeightsWithConcurrency(coordList, batchSize 50) { const result []; for (let i 0; i coordList.length; i batchSize) { const batch coordList.slice(i, i batchSize); const heights await batchGetHeights(batch, false); result.push(...heights); } return result; }实测下来串行批处理比一次性全量请求在总耗时上可能更慢但稳定性好很多不容易把地形服务打挂尤其在多人共用服务端地形的情况下特别重要。3. 同步获取getHeight 与屏幕拾取方案3.1 getHeight 的正确打开方式如果你要的高度和当前画面强相关比如某段航迹的模拟飞行、实时渲染里的临时标注那用viewer.scene.globe.getHeight(terrainProvider, cartographic)是最直观的同步方案。这个 API 的语义是从已经加载到内存的地形 tile 中直接取样得到该点的地形高度。因为是同步读取内存数据速度快到可以每帧调用。// 注意必须传入 viewer 当前使用的地形 provider const height viewer.scene.globe.getHeight( viewer.terrainProvider, Cesium.Cartographic.fromDegrees(lon, lat) );但这里有个大前提该位置所在的地形 tile 必须已经加载进场景。如果视角还在千里之外那个位置的 tile 根本没加载getHeight会返回undefined。我在做“地图缩放时动态显示鼠标点高度”的功能时就用了一个双保险逻辑先调用getHeight如果返回undefined就触发一次异步采样补一次精确值同时把相机移动起来引导地形加载。function getHeightSmart(lon, lat) { const carto Cesium.Cartographic.fromDegrees(lon, lat); const h viewer.scene.globe.getHeight(viewer.terrainProvider, carto); if (h ! undefined) { return Promise.resolve(h); } return getHeightAtPoint(lon, lat, 12); }getHeight还有一个特性它返回的是当前地形 tile 的插值结果层级取决于已经加载的 tile。如果你加载的 tile 是 10 级虽然服务端能提供 15 级数据但同步读取只能拿到 10 级的插值高度。这个差异在山区可能有好几米甚至几十米所以精密测量场景不能只依赖getHeight。3.2 用 pickPosition 做点击取高度用户点击地图页面立刻显示地形高度这是另一个高频需求。碰到这种场景我的首选是scene.pickPosition。这个方法做的事情是从相机出发构造一条经过鼠标屏幕位置的射线然后和场景里的几何体求交返回交点世界坐标。当地形启用了深度检测时射线和地形表面相交就会得到地形上那个点的世界坐标。有了世界坐标换算成经纬度和椭球高就是家常便饭了。viewer.scene.globe.depthTestAgainstTerrain true; const handler new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas); handler.setInputAction((movement) { const cartesian viewer.scene.pickPosition(movement.position); if (cartesian undefined) { console.log(未拾取到地形请检查深度检测是否开启或是否点到天空); return; } const carto Cesium.Cartographic.fromCartesian(cartesian); const lon Cesium.Math.toDegrees(carto.longitude); const lat Cesium.Math.toDegrees(carto.latitude); const height carto.height; console.log(经度 ${lon.toFixed(6)}纬度 ${lat.toFixed(6)}地形高度 ${height.toFixed(2)} 米); }, Cesium.ScreenSpaceEventType.LEFT_CLICK);这里有一个我踩过好几次的坑depthTestAgainstTerrain默认是false。如果不开启pickPosition拾取到的不是地形表面而是场景里最先被射线命中的物体。当地形本身不作为被拾取对象时返回值可能就是undefined或者更糟——拾取到的是地下的椭球面点。这个开关需要在初始化阶段就设置好。另外pickPosition拾取的高度是基于当前深度缓冲的也就是说“画面里显示了什么地形拾取到的就是什么”。如果你用了动态 LOD缩放到不同层级同一个经纬度拾取到的高度可能有细微差异。这不算 bug理解了原理就不会困惑。还有一个替代写法用viewer.scene.globe.pick(ray, viewer.scene)。作用和先生成viewer.camera.getPickRay(windowPosition)再交给globe.pick一样。它的好处是不依赖depthTestAgainstTerrain直接对 globe 做射线检测。const ray viewer.camera.getPickRay(movement.position); const cartesian viewer.scene.globe.pick(ray, viewer.scene);两种方式我都用过。如果你明确要“拾取地形而不是模型”我反而推荐globe.pick因为它不会因为场景里有个飘着的广告牌就把结果带偏。不过globe.pick在部分 Cesium 版本里的行为变化较大建议你在目标版本里先做个小验证。3.3 同步方案的综合对比把getHeight和pickPosition放在一起对比getHeight是“经纬度到高度”的查询和画面里有没有那个位置没关系只要 tile 加载了就有值pickPosition是“屏幕位置到三维坐标”的逆向渲染查询高度是渲染结果的一部分。前者适合程序化逻辑里的快速取值后者适合用户交互。二者都能在毫秒级返回结果所以我做实时功能时主要靠它们。但同步方案的共同弱点是“边界不可控”getHeight依赖 tile 加载状态pickPosition依赖深度缓冲内容。想拿到全世界任意一点的高度最终还是得靠异步采样。4. 高度数据在真实业务里的落地4.1 贴地飞行航线生成的实测思路做无人机贴地飞行航线规划时我的流程是这样的先在规划区域生成一条水平航迹经纬度序列然后把每个航点带入异步采样拿到高精度地形高度最后给每个航点加上相对高度比如离地 30 米就得到三维航迹。核心代码大约是这个样子async function generateTerrainFollowingRoute(flatRoute, groundClearance 30) { const heights await batchGetHeights(flatRoute, true); const route3D flatRoute.map(([lon, lat], index) { const cruiseAltitude heights[index] groundClearance; return { lon, lat, altitude: cruiseAltitude }; }); return route3D; }这里我必须指出一个细节groundClearance是“相对地形高度”的离地高度和无人机常用的“海拔高度”不一样。如果你把heights[index]直接当成海拔写进飞行任务飞控会很不满意。原因在第 4.3 节说。另外一个容易忽略的问题是采样点的密度。航迹规划中如果两个采样点挨得太近采样次数浪费隔得太远航线可能横穿一座山脊。我通常按照航迹长度来估算每 100 到 200 米一个采样点遇到地形剧烈变化的区域再根据相邻采样点高度差动态插入加密点。4.2 土方量计算和区间统计土方量计算其实可以做得挺粗糙只要采样足够密。我的做法是在施工区域内生成均匀网格点间距按项目精度要求定比如 10 米一个点对每个网格点采样地形高度然后和设计标高相减得到每个网格的高差最后乘以网格面积累加。async function earthworkVolume(corners, gridSpacing, designHeight) { // corners 是区域多边形顶点经纬度 const gridLonLat []; const minLon Math.min(...corners.map(c c[0])); const maxLon Math.max(...corners.map(c c[0])); const minLat Math.min(...corners.map(c c[1])); const maxLat Math.max(...corners.map(c c[1])); for (let lon minLon; lon maxLon; lon gridSpacing / (111320 * Math.cos((minLat maxLat) / 2 * Math.PI / 180))) { for (let lat minLat; lat maxLat; lat gridSpacing / 110540) { gridLonLat.push([lon, lat]); } } const heights await batchGetHeights(gridLonLat, false); let volume 0; const cellArea gridSpacing * gridSpacing; heights.forEach((h) { const diff designHeight - h; volume diff * cellArea; }); return volume; }这个简化版本没有做多边形裁剪网格超出区域边界的点会被算进去。真要严谨得做点在多边形内部的判断只统计内部网格。我一般在项目里引入一个简单的射线法判断函数几行代码的事但对结果影响明显。这个场景对采样层级不用太苛刻sampleTerrain指定 12 到 13 级就够了因为网格间距本身已经引入了误差为了高程再提升层级意义不大。4.3 椭球高与海拔的换算问题这个坑是我在土方量项目里被甲方逮住之后才彻底搞明白的。Cesium 所有地形服务返回的高度默认都是相对于 WGS84 椭球面的高度而绝大多数工程测量、卫星定位设备输出的是基于大地水准面的“正常高”或“海拔高”。二者之间的差在全球范围内普遍有几十米在某些区域甚至能到上百米。为什么会有这个差因为地球不是一个规则的椭球实际海平面形成的“大地水准面”也不规则我们测量用的海拔基准是大地水准面而 Cesium 的计算基准是数学上光滑的椭球。两者之间的差值叫“高程异常”。如果你直接把 Cesium 的高度当一个真实海拔用在山区可能差出几十米航线规划中离地 30 米的设定就会变成离地 70 米或者贴着山跑。解决办法是引入大地水准面模型做改正。网上有公开的 EGM96、EGM2008 网格数据可以做一个查表函数把 Cesium 高度转成近似海拔。// 伪代码示意用 EGM96 网格修正 function ellipsoidalToOrthometric(lon, lat, ellipsoidHeight) { const geoidUndulation lookupEGM96(lon, lat); // 查表单位米 return ellipsoidHeight - geoidUndulation; }需要注意的是这个修正会随经纬度变化不同地区修正量不同。如果你的业务只在国内某个城市可以先取该区域的修正平均值做成配置项不用全量引入网格文件。5. 常见故障排查与避坑手册5.1 三个高频 bug 的根因我在调试地形高度相关功能时最常被问到的三个问题为什么sampleTerrainMostDetailed返回NaN大概率是地形提供者不支持这种方式或者目标点在地形服务覆盖范围之外。我做了一个很简单的自检先看viewer.terrainProvider是不是 Cesium 官方高精度地形那种“能告诉你某个 tile 是否可用”的类型再看经纬度是不是在海域或偏远地区。如果确实是覆盖范围问题我会在业务层面对NaN做兜底处理比如退回低精度采样结果或默认值 0。为什么getHeight总是undefined十有八九是 tile 没加载。可以在调用前先让相机飞过去或者监听globe.tileLoadProgressEvent等待地理区域加载完成后再取高度。我在代码里都会加一个重试机制连续取 5 次每次间隔 200 毫秒如果还是undefined再走异步采样兜底。为什么pickPosition拾取不到地形先检查viewer.scene.globe.depthTestAgainstTerrain是否为true。再检查拾取点的屏幕坐标是否真的对准了地形。还有一个反直觉的坑如果你关掉了地形深度测试pickPosition可能拾取到场景里的其他几何体或返回undefined。5.2 性能陷阱与防范地形高度获取的性能问题主要出现在三个环节第一异步采样请求量失控。这个前面已经说过控制并发、批量处理是核心手段。第二同步方案在循环里高频调用。getHeight虽然快但也不是零成本每帧在几十个点上调用还能接受如果每帧调用几百个点建议在渲染循环外算好并缓存结果。第三缓存失效没有做好。地形数据版本更新后旧缓存高度如果继续用就会出错。我建议缓存 key 里加一个terrainProvider的版本标识哪怕只是简单的generation时间戳都能避免很多莫名其妙的“高度变了”问题。5.3 代码片段速查为了方便快速复制我把最核心的几个函数整理成一个速查列表。// 1. 初始化地形 const terrainProvider await Cesium.createWorldTerrainAsync(); viewer.terrainProvider terrainProvider; viewer.scene.globe.depthTestAgainstTerrain true; // 2. 异步精采样单个点 async function getFineHeight(lon, lat) { const pos [Cesium.Cartographic.fromDegrees(lon, lat)]; await Cesium.sampleTerrainMostDetailed(viewer.terrainProvider, pos); return pos[0].height; } // 3. 异步粗采样多个点 async function getHeightsAtLevel(lonLatPairs, level 13) { const positions lonLatPairs.map(([lon, lat]) Cesium.Cartographic.fromDegrees(lon, lat) ); await Cesium.sampleTerrain(viewer.terrainProvider, level, positions); return positions.map((p) p.height); } // 4. 同步查询已加载地形 function getLoadedHeight(lon, lat) { return viewer.scene.globe.getHeight( viewer.terrainProvider, Cesium.Cartographic.fromDegrees(lon, lat) ); } // 5. 屏幕拾取地形高度 function pickupHeightFromScreen(windowPosition) { const ray viewer.camera.getPickRay(windowPosition); const cartesian viewer.scene.globe.pick(ray, viewer.scene); if (!cartesian) return null; const carto Cesium.Cartographic.fromCartesian(cartesian); return { lon: Cesium.Math.toDegrees(carto.longitude), lat: Cesium.Math.toDegrees(carto.latitude), height: carto.height, }; }5.4 我最后想说的一点经验折腾地形高度这套东西几个月下来我最大的体会是Cesium 的 API 设计已经把高度获取的“能力边界”分得很清楚了异步采样负责“全”同步查询负责“快”屏幕拾取负责“活”。不要试图用一个 API 解决所有场景而是要按业务逻辑把这些 API 组合起来。我目前惯用的架构是写一个高度服务模块内部封装统一的getHeightFast(lon, lat)和getHeightAccurate(lon, lat)两个入口。前者走getHeight加缓存只给实时交互用后者走异步采样给规划和计算用。界面上的点击取高则单独走pickPosition。如果以后想做得更精细我可能会去研究底层地形 tile 的三角形网格插值算法直接在本地用顶点高度做任意点的插值计算。那样既能绕开网络请求又能拿到最精细的结果代价是要对 Cesium 内部数据结构做深度定制。这个方向不算难但代码量不小等哪天有空我再专门写一篇展开聊聊。