Cesium 地形开挖与抬升:裁剪地表并重建侧壁
- 江河
- 2026年7月12日
- 8 mins
- Cesium Webgis
- 地形 cesium clippingplane sampleterrainmostdetailed
文章目录
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
}
这里按执行顺序发生了四件事:
- 用边中点的地心方向近似当地向上方向。
- 边方向和向上方向叉乘,得到侧向法线。
- 通过多边形中心检查法线朝向,必要时翻转。
- 计算让平面经过当前边中点的距离。
朝向判断不能省略。同一组点顺时针和逆时针传入时,叉乘结果会相反,最终可能裁掉多边形外部而不是内部。
应用到 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 加入场景。修改深度时不需要重新请求地形,只需删除旧墙体和坑底,用缓存的边界高度重建。
地形抬升比开挖多一张顶部网格
抬升场景保留原地形形状,因此不能只生成一个水平顶面。流程是:
- 对用户区域生成三角网。
- 把三角网顶点转换为
Cartographic。 - 批量采样每个顶点的地形高程。
- 顶点高度加上抬升高度。
- 使用原高度和抬升后高度生成连接墙体。
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 分开管理,深度和高度调整就不需要重复执行全部流程。