ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Cesium地形高度获取全攻略:从异步采样到屏幕拾取的实战方案

Cesium地形高度获取全攻略:从异步采样到屏幕拾取的实战方案

最近在搞一个三维地理信息项目,需要让模型沿地形起伏自动贴地飞行,还要在某个区域快速算出土方量。折腾了一圈,发现“Cesium 地形高度获取”这件事看起来简单,真做起来全是细节。查过无数资料、翻了源码、试错多次之后,我把几种主流方案整理成一篇完整的实操笔记,送给同样被这个问题卡住的朋友。

这篇文章会把 Cesium 里获取地形高度的四类方案讲透:异步采样、同步查询、屏幕拾取、底层网格推算。每一种我都会说清楚适用场景、完整代码、性能注意点,以及实测中遇到的坑。无论你是做无人机航迹规划、建筑物放置,还是土方量分析,读完应该都能直接上手。

1. 需求拆解与方案全景

1.1 场景决定方案:先想清楚你要的是“哪个高度的谁”

在动手写代码之前,我建议你先花五分钟想清楚一个核心问题:你要的高度,是“某经纬度坐标在某个地形服务下的标准高度”,还是“当前这一帧画面里、用户鼠标点中的那个地形表面的高度”?这两者的实现路径完全不同。

举几个我在实际项目里遇到的场景:

  • 无人机航线规划:需要针对一系列经纬度航点,批量获取下方地形高度,然后生成贴地飞行轨迹。这种场景不需要用户交互,往往是后台计算或页面加载后自动触发,适合异步采样。
  • 土方量估算:在一个施工区域内均匀采样几千个点,获取每个点的地形高度,再和设计标高做差,累加得到填挖方量。这同样是离线批量计算。
  • 点击查看高程:用户在地图上点一个位置,立刻弹出该点地面高度。这要求高度获取和渲染画面保持同一套逻辑,往往用屏幕拾取。
  • 三维模型摆放:把路灯、树木、管线井盖等模型压到地面上。这个场景其实不一定需要手动获取高度,Cesium 自带的heightReference就能让模型自动贴地,但如果你想在贴地之前先校验某个位置是否适合摆放,还是需要读取高度值。

把场景列出来之后,你就会发现“获取地形高度”不是一个 API 调用的问题,而是一个选型问题。选错方案,轻则性能浪费,重则结果完全不对。

1.2 四条技术路线的全景对比

我整理了一个对比表,基本覆盖了 Cesium 里常见的高度获取方案。看完这张表,后面每一节再详细展开。

方案模式精度适用场景关键限制
sampleTerrain异步取决于指定层级批量获取固定层级高度需要地形服务支持;返回的是地形服务插值结果
sampleTerrainMostDetailed异步最高可用层级批量获取最精细高度请求量随点数增加,耗时较长
globe.getHeight同步当前已加载 tile 精度已加载区域内的快速查询tile 未加载时返回undefined
scene.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 请求量与性能优化技巧

异步采样最大的隐患是请求风暴。当你用一个几百上千点的数组去调用sampleTerrainMostDetailed,Cesium 会为每个 tile 发起独立的 terrain tile 请求,如果这些点分布在不同 tile 上,瞬间可能产生几百个并发请求。

我在实际项目里积累了几个优化手段:

第一,点集先做网格抽稀。如果采样区域很大,先按经纬度网格把点聚合成一个个簇,每个簇取中心点采样,再用中心点高度近似整个簇。比如 100 米的栅格,一个一万点的区域抽稀到一两千个点,误差通常可以接受。

第二,对采样结果做本地缓存。用一个Map,key 是经纬度 + 层级 + 地形版本号,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 内部数据结构做深度定制。这个方向不算难,但代码量不小,等哪天有空我再专门写一篇展开聊聊。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进