Blog
Apr 24, 2026 - 9 MIN READ
Cesium 最高地形瓦片高度图实现说明

Cesium 最高地形瓦片高度图实现说明

记录 Cesium 高度图 Demo 的 SDK 化实现,包括矩形绘制、最高可用地形层级解析、规则网格采样、灰度高度图生成、起伏 Primitive 构建和地形深度测试。

Kevin

Kevin

这篇笔记解决什么问题

这个 Demo 的目标不是把一次性逻辑堆到页面里,而是把“从地形生成高度图”拆成可以复用的 SDK 模块:

  • rectangleDraw.js 只负责地形拾取和矩形绘制
  • terrainSampler.js 只负责最高层级解析和规则网格采样
  • heightmapCanvas.js 只负责把高度数组转成灰度 canvas
  • heightmapSurface.js 只负责创建带真实高度顶点的 Cesium Primitive
  • CesiumTerrainHeightmapDemo.js 负责把这些能力组装成 Demo runtime

这样后续如果要把高度图接入水流模拟、侵蚀模拟或碰撞计算,可以复用采样和 canvas 输出模块,而不需要复制整页交互。

整体调用链

页面启动后先通过统一 runtime 加载 Cesium,再创建 Viewer 和 World Terrain。GUI 暴露三个入口:绘制提取范围、切换地形深度测试、加载或取消地形。

gui.add(controls, 'drawRange').name('绘制提取范围')
gui.add(controls, 'terrainDepthTest').name('地形深度测试')
gui.add(controls, 'toggleTerrain').name('加载 / 取消地形')

用户完成矩形绘制后,runtime 会顺序执行:

  1. sampleTerrainHeightmap(viewer, { rectangle })
  2. renderTerrainHeightmapCanvas(sampleResult)
  3. createTerrainHeightmapSurface(viewer, sampleResult, heightmap.canvas)
  4. heightmapPanel.update(sampleResult, heightmap)

这条链路保证左下角预览图和场景中的起伏面使用同一份高度结果。

矩形绘制怎么做

绘制模块使用 Cesium 的屏幕事件系统,第一次左键记录起点,鼠标移动时更新预览矩形,第二次左键完成范围。拾取点优先走 globe.pick,这样鼠标落在真实地形上时能得到地形表面位置。

const ray = viewer.camera.getPickRay(windowPosition)

return viewer.scene.globe.pick(ray, viewer.scene) ||
  viewer.camera.pickEllipsoid(windowPosition, viewer.scene.globe.ellipsoid)

完成时会把两个角点归一化为 Cesium.Rectangle

const west = Math.min(startCartographic.longitude, endCartographic.longitude)
const east = Math.max(startCartographic.longitude, endCartographic.longitude)
const south = Math.min(startCartographic.latitude, endCartographic.latitude)
const north = Math.max(startCartographic.latitude, endCartographic.latitude)

return new Cesium.Rectangle(west, south, east, north)

当前版本不处理跨国际日期变更线的特殊矩形,适合常规局部地形提取。

最高层级采样怎么做

参考文章里的核心思想是“先找矩形范围内最高可用地形瓦片,再把高度写进高度图”。这里为了保持 Demo 稳定,使用 Cesium 公开 API 获取范围内整体可用最高层级:

const availability = terrainProvider?.availability
const level = availability?.computeBestAvailableLevelOverRectangle?.(rectangle)

if (Number.isFinite(level)) {
  return Math.max(0, Math.floor(level))
}

拿到层级后,模块按矩形宽高比生成规则采样网格,最大边默认限制为 96 个采样点,防止一次请求过大范围时拖垮页面。

const { columns, rows } = resolveGridSize(Cesium, rectangle, maxGridSide)
const positions = createGridPositions(Cesium, rectangle, columns, rows)

await Cesium.sampleTerrain(terrainProvider, level, positions, false)

sampleTerrain() 会按瓦片组织请求,并调用对应 TerrainData.interpolateHeight() 得到每个经纬度点的地形高度。局部缺失的高度会用邻域高度补齐,保证后续 canvas 和网格不会出现空洞。

高度图 canvas 怎么生成

heightmapCanvas.js 会根据矩形投影宽高比创建 canvas,最大边默认 512px。每个像素通过双线性插值从采样网格中读取高度,再按当前范围内的最小值和最大值归一化成灰度。

const height = sampleHeightBilinear(sampleResult, u, v)
const intensity = normalizeHeight(height, sampleResult.minHeight, sampleResult.heightRange)
const value = clampInteger(intensity * 255, 0, 255)

这里输出的是当前范围内的相对灰度高度图:黑色代表当前范围内较低区域,白色代表较高区域。左下角面板直接挂载这张 canvas,场景中的起伏面也把同一张 canvas 当作 Image 材质使用。

起伏面怎么创建

场景面不是贴地影像,也不是 GroundPrimitive。它是一个真实三角网格:每个采样点都会转换成带高度的 Cartesian3,并按规则网格生成三角索引。

const height = sampleResult.heights[index] + heightOffset
const cartesian = Cesium.Cartesian3.fromRadians(cartographic.longitude, cartographic.latitude, height)

positions[positionOffset] = cartesian.x
positions[positionOffset + 1] = cartesian.y
positions[positionOffset + 2] = cartesian.z

纹理坐标按西到东、北到南映射,保证 canvas 高度图和矩形范围方向一致。

textureCoordinates[stOffset] = columnRatio
textureCoordinates[stOffset + 1] = 1 - rowRatio

最后用 GeometryPipeline.computeNormal() 计算法线,再交给 MaterialAppearance + Image material 渲染。

return Cesium.GeometryPipeline.computeNormal(geometry)

这个面已经拥有自己的地形高度顶点,所以即使后续隐藏或替换 Cesium 地形,它仍然保留原本采样到的凹凸起伏。

为了避免生成面像半透明覆盖层一样透视底下地形,canvas 输出使用满 alpha,材质也走不透明渲染并开启深度写入。

return Cesium.Material.fromType('Image', {
  image: canvas,
  repeat: new Cesium.Cartesian2(1, 1),
  color: Cesium.Color.WHITE,
  transparent: false
})
appearance: new Cesium.MaterialAppearance({
  material,
  materialSupport: Cesium.MaterialAppearance.MaterialSupport.TEXTURED,
  translucent: false,
  faceForward: true,
  renderState: {
    depthTest: { enabled: true },
    depthMask: true,
    cull: { enabled: false }
  }
})

地形深度测试的作用

地形深度测试 按钮只切换一个 Cesium 场景状态:

viewer.scene.globe.depthTestAgainstTerrain = nextValue

这个 Demo 默认开启地形深度测试,让生成面一开始就和真实地形保持正确遮挡关系。

viewer.scene.globe.depthTestAgainstTerrain = true

关闭时,高度图面更容易完整观察;开启时,真实地形会参与深度遮挡,可以用来判断生成面是否确实贴近当前地形。为了减少完全共面导致的闪烁,生成面只增加 0.35m 的显示偏移,高度变化仍来自真实采样结果。

地形加载开关

加载 / 取消地形 按钮用于在 Cesium World Terrain 和椭球地形之间切换。取消加载时只替换 viewer.terrainProvider,不会销毁已经生成的高度图 Primitive,所以可以验证这个面是否真的拥有自己的高度顶点。

viewer.terrainProvider = new Cesium.EllipsoidTerrainProvider()
viewer.scene.globe.enableLighting = false

重新加载时会复用已经创建过的 World Terrain provider;如果还没有缓存,则再次调用 createWorldTerrainAsync()

生命周期清理

Demo 销毁时会统一清理绘制事件、结果 Primitive、范围矩形、左下角面板和 Cesium Viewer。

drawTool.destroy()
clearPreviousResult()
heightmapPanel.destroy()

if (!viewer.isDestroyed()) {
  viewer.destroy()
}

这一点对内容站很重要:Demo 详情页通过路由切换反复挂载和卸载,如果不清理 ScreenSpaceEventHandlerPrimitive,很容易出现旧事件残留和显存占用增长。

凯文的技术博客 • © 2026