站内搜索

输入关键词开始搜索

Cesium 地形开挖与抬升:裁剪地表并重建侧壁

文章目录

Cesium 地形开挖看起来像是把地表挖掉一块,地形抬升看起来像是把一块山体搬到空中。但常见实现并没有改写原始地形瓦片。

它们使用的是一组视觉重建:

裁掉原地形的一块区域
        ↓
沿边界采样真实地形高度
        ↓
补上侧壁和坑底
        ↓
抬升场景再生成一份顶部地形网格

理解这个边界很重要。裁剪只控制哪些地形片元参与渲染,墙体、坑底和抬升顶部都是新创建的 Primitive。

输入是一组有顺序的边界点

开挖工具接收用户绘制的多边形顶点:

function updateData(positions: Cesium.Cartesian3[]) {
  if (positions.length < 3) return

  clearOldResult()
  applyClipping(positions)
  rebuildExcavationGeometry(positions)
}

调用前要处理这些输入问题:

  • 删除与首点重复的闭合点。
  • 删除相邻重复点和过短边。
  • 统一顶点顺序。
  • 拒绝自相交多边形。

后面的裁剪面法线、墙体纹理和三角网都会依赖边界顺序。

每条边生成一个裁剪面

平面由法线和到原点的距离定义。对于多边形的一条边,需要先构造当地“向上”方向,再求侧向法线。

function createClippingPlanes(positions: Cesium.Cartesian3[]) {
  const center = positions.reduce(
    (sum, position) => Cesium.Cartesian3.add(sum, position, sum),
    new Cesium.Cartesian3(),
  )
  Cesium.Cartesian3.divideByScalar(center, positions.length, center)

  const planes: Cesium.ClippingPlane[] = []

  for (let i = 0; i < positions.length; i += 1) {
    const start = positions[i]
    const end = positions[(i + 1) % positions.length]
    const midpoint = Cesium.Cartesian3.midpoint(
      start,
      end,
      new Cesium.Cartesian3(),
    )

    const up = Cesium.Cartesian3.normalize(
      midpoint,
      new Cesium.Cartesian3(),
    )
    const edge = Cesium.Cartesian3.normalize(
      Cesium.Cartesian3.subtract(end, start, new Cesium.Cartesian3()),
      new Cesium.Cartesian3(),
    )
    const normal = Cesium.Cartesian3.normalize(
      Cesium.Cartesian3.cross(edge, up, new Cesium.Cartesian3()),
      new Cesium.Cartesian3(),
    )

    const centerToEdge = Cesium.Cartesian3.subtract(
      midpoint,
      center,
      new Cesium.Cartesian3(),
    )
    if (Cesium.Cartesian3.dot(normal, centerToEdge) < 0) {
      Cesium.Cartesian3.negate(normal, normal)
    }

    const plane = new Cesium.Plane(normal, 0)
    const distance = Cesium.Plane.getPointDistance(plane, midpoint)
    planes.push(new Cesium.ClippingPlane(normal, distance))
  }

  return planes
}

这里按执行顺序发生了四件事:

  1. 用边中点的地心方向近似当地向上方向。
  2. 边方向和向上方向叉乘,得到侧向法线。
  3. 通过多边形中心检查法线朝向,必要时翻转。
  4. 计算让平面经过当前边中点的距离。

朝向判断不能省略。同一组点顺时针和逆时针传入时,叉乘结果会相反,最终可能裁掉多边形外部而不是内部。

应用到 Globe:

viewer.scene.globe.clippingPlanes = new Cesium.ClippingPlaneCollection({
  planes: createClippingPlanes(positions),
  edgeColor: Cesium.Color.WHITE,
  edgeWidth: 1,
  enabled: true,
})

大坐标场景使用局部 ENU

上面的代码直接在 ECEF 坐标中计算。区域较大或形状复杂时,更稳妥的回退方案是先把边界转换到局部 ENU 坐标系,再构造裁剪面:

const center = Cesium.BoundingSphere.fromPoints(positions).center
const centerCartographic = Cesium.Cartographic.fromCartesian(center)
const surfaceCenter = Cesium.Cartesian3.fromRadians(
  centerCartographic.longitude,
  centerCartographic.latitude,
)

const modelMatrix = Cesium.Transforms.eastNorthUpToFixedFrame(surfaceCenter)
const inverse = Cesium.Matrix4.inverseTransformation(
  modelMatrix,
  new Cesium.Matrix4(),
)

const localPositions = positions.map(position =>
  Cesium.Matrix4.multiplyByPoint(
    inverse,
    position,
    new Cesium.Cartesian3(),
  ),
)

局部坐标的数值接近工程尺度,平面的法线和距离更容易保持精度。创建 ClippingPlaneCollection 时,把 modelMatrix 一并传入:

new Cesium.ClippingPlaneCollection({
  planes,
  modelMatrix,
  edgeColor: Cesium.Color.WHITE,
  edgeWidth: 1,
})

优先使用 ClippingPolygon

支持 ClippingPolygonCollection 的环境可以直接把地表多边形交给 Cesium:

function clipPolygon(viewer: Cesium.Viewer, positions: Cesium.Cartesian3[]) {
  const surfacePositions = positions.map(position => {
    const point = Cesium.Cartographic.fromCartesian(position)
    return Cesium.Cartesian3.fromRadians(point.longitude, point.latitude)
  })

  if (!Cesium.ClippingPolygonCollection.isSupported(viewer.scene)) {
    return false
  }

  viewer.scene.globe.clippingPolygons =
    new Cesium.ClippingPolygonCollection({
      polygons: [new Cesium.ClippingPolygon({ positions: surfacePositions })],
      enabled: true,
    })

  return true
}

它对凹多边形更友好。ClippingPlaneCollection 更适合作为兼容回退或凸区域方案。无论使用哪一种,都要由一个管理器统一拥有 Globe 上的裁剪集合,避免不同业务模块互相覆盖。

边界需要加密采样

墙体顶部要贴合真实地形。只采样用户点击的几个顶点,长边中间会穿过山体或悬空。

可以按周长比例为每条边分配采样点,再沿大地线插值:

function densifyBoundary(
  positions: Cesium.Cartesian3[],
  targetCount = 256,
) {
  const vertices = positions.map(position =>
    Cesium.Cartographic.fromCartesian(position),
  )

  const lengths = vertices.map((start, index) => {
    const end = vertices[(index + 1) % vertices.length]
    return new Cesium.EllipsoidGeodesic(start, end).surfaceDistance
  })
  const perimeter = lengths.reduce((sum, length) => sum + length, 0)
  const result: Cesium.Cartographic[] = []

  vertices.forEach((start, index) => {
    const end = vertices[(index + 1) % vertices.length]
    const geodesic = new Cesium.EllipsoidGeodesic(start, end)
    const count = Math.max(
      1,
      Math.round(lengths[index] / perimeter * targetCount),
    )

    for (let i = 0; i < count; i += 1) {
      result.push(geodesic.interpolateUsingFraction(i / count))
    }
  })

  result.push(Cesium.Cartographic.clone(result[0]))
  return result
}

然后一次性请求精细地形高度:

const samples = densifyBoundary(positions)
await Cesium.sampleTerrainMostDetailed(viewer.terrainProvider, samples)

const terrainHeights = samples.map(sample =>
  Number.isFinite(sample.height) ? sample.height : 0,
)

sampleTerrainMostDetailed 是异步请求。用户快速重画区域时,旧请求可能比新请求更晚返回,因此还需要版本号或操作 ID 淘汰过期结果。

重建开挖墙体和坑底

设边界最低高程为 terrainMinHeight,开挖深度为 depth:

const bottomHeight = terrainMinHeight - depth

墙体使用原地形边界作为顶部,统一坑底高度作为底部:

const wallPositions = samples.map((sample, index) =>
  Cesium.Cartesian3.fromRadians(
    sample.longitude,
    sample.latitude,
    terrainHeights[index],
  ),
)

const wall = new Cesium.WallGeometry({
  positions: wallPositions,
  maximumHeights: terrainHeights,
  minimumHeights: new Array(wallPositions.length).fill(bottomHeight),
  vertexFormat: Cesium.MaterialAppearance.VERTEX_FORMAT,
})

坑底使用同一高度的多边形:

const bottomPositions = positions.map(position => {
  const point = Cesium.Cartographic.fromCartesian(position)
  return Cesium.Cartesian3.fromRadians(
    point.longitude,
    point.latitude,
    bottomHeight,
  )
})

const bottom = new Cesium.PolygonGeometry({
  polygonHierarchy: new Cesium.PolygonHierarchy(bottomPositions),
  perPositionHeight: true,
  vertexFormat: Cesium.MaterialAppearance.VERTEX_FORMAT,
})

这两个 Geometry 通过 GeometryInstance + MaterialAppearance + Primitive 加入场景。修改深度时不需要重新请求地形,只需删除旧墙体和坑底,用缓存的边界高度重建。

地形抬升比开挖多一张顶部网格

抬升场景保留原地形形状,因此不能只生成一个水平顶面。流程是:

  1. 对用户区域生成三角网。
  2. 把三角网顶点转换为 Cartographic。
  3. 批量采样每个顶点的地形高程。
  4. 顶点高度加上抬升高度。
  5. 使用原高度和抬升后高度生成连接墙体。
const raisedPositions = sampledPoints.map(point =>
  Cesium.Cartesian3.fromRadians(
    point.longitude,
    point.latitude,
    point.height + liftHeight,
  ),
)

墙体的高度数组分别来自原地形和抬升结果:

const minimumHeights = boundaryTerrainHeights
const maximumHeights = boundaryTerrainHeights.map(
  height => height + liftHeight,
)

如果希望抬升顶部保留原地图纹理,还要额外生成纹理坐标或从顶视角捕获纹理。这个步骤只改变视觉材质,不改变抬升几何。

异步任务要防止旧结果覆盖新结果

地形采样和纹理准备都可能跨越多帧。管理器可以为每次更新分配版本号:

let operationId = 0

async function rebuild() {
  const current = ++operationId
  const samples = await sampleBoundary()

  if (current !== operationId) return

  const mesh = await createTopMesh(samples)
  if (current !== operationId) return

  createPrimitives(mesh)
}

function clear() {
  operationId += 1
  removePrimitives()
  removeClipping()
}

这个版本号防的是重复执行,不是重复调度。旧请求仍可能在网络层继续,只是返回后不会修改当前场景。

适用边界

这套方案适合局部、非自相交的工程区域。还要注意:

  • 经纬跨度过大的区域不适合使用单个局部 ENU。
  • 跨越日期变更线的多边形需要先处理经度连续性。
  • 边界采样越密,墙体贴合越好,但请求和几何规模也越大。
  • Globe 的裁剪集合属于共享场景状态,需要集中管理。
  • 开挖和抬升是视觉重建,不能用于修改或导出原始地形数据。

地形开挖的本质是“裁剪 + 墙体 + 坑底”,地形抬升则在这条链上增加“采样三角网 + 抬升顶部”。把裁剪状态、采样数据和新建 Primitive 分开管理,深度和高度调整就不需要重复执行全部流程。

参考资料

相关文章

Cesium 三维风场:用 WebGL 2 GPU 粒子描绘全球气流

拆解案例中的风场数据、GPU 粒子更新、轨迹渲染、颜色映射和 Vue 参数控制

Cesium 北斗三维网格:在城市 3D Tiles 上做 GeoSOT-3D 剖分

介绍区域体素生成、建筑表面采样、结构碰撞检测、Worker 几何构建和网格编码查询