文章目录
可视域分析回答的是:从一个观察点出发,在给定方向、水平视场角、垂直视场角和半径内,哪些地形区域能被看见。
本文对应的实现不是 ShadowMap,而是 CPU 地形采样:沿多个方向生成径向采样点,比较每个点的仰角和此前最高遮挡仰角。
两次点击确定分析参数
交互层把操作分成两个阶段:
- 第一次点击选择观察点,并采样该点真实地形高度。
- 鼠标移动更新方向,第二次点击确定目标方向并开始计算。
handler.setInputAction(async movement => {
if (!observerPosition) {
const ground = await pickAndSampleTerrain(movement.position)
if (!ground) return
const cartographic = Cesium.Cartographic.fromCartesian(ground)
observerPosition = Cesium.Cartesian3.fromRadians(
cartographic.longitude,
cartographic.latitude,
cartographic.height + observerHeight,
)
return
}
const target = await pickAndSampleTerrain(movement.position)
if (!target) return
updateHeadingPitchAndRadius(observerPosition, target)
await runViewshed()
}, Cesium.ScreenSpaceEventType.LEFT_CLICK)
观察点高度必须建立在采样后的地面高程上。把 observerHeight 直接当成绝对海拔,会导致平原和山地区域的含义不一致。
根据范围自动安排采样数量
固定使用 200 条射线、每条 200 个点,在半径 500 米和 20 千米时不会得到相同空间精度。
可以根据目标地面间距生成采样计划:
function getSamplingPlan(
radius: number,
horizontalViewAngle: number,
targetGroundSpacing: number,
radialStep: number,
) {
const angularStepRadians = Math.atan2(targetGroundSpacing, radius)
const angularStepDegrees = Cesium.Math.toDegrees(angularStepRadians)
const rayCount = Math.ceil(
horizontalViewAngle / angularStepDegrees,
) + 1
const stepCount = Math.ceil(radius / radialStep)
return { rayCount, stepCount }
}
随后再用质量预设限制最小值、最大值和总采样数:
if (rayCount * stepCount > maxSamples) {
const scale = Math.sqrt(
maxSamples / (rayCount * stepCount),
)
rayCount = Math.floor(rayCount * scale)
stepCount = Math.floor(stepCount * scale)
}
射线数控制横向角分辨率,步数控制径向分辨率。两者不能只调其中一个。
在观察点建立 ENU 坐标系
径向网格适合先在局部 East-North-Up 坐标中生成:
const enuToFixed = Cesium.Transforms.eastNorthUpToFixedFrame(
observerPosition,
)
const fixedToEnu = Cesium.Matrix4.inverse(
enuToFixed,
new Cesium.Matrix4(),
)
某条射线上距离为 distance 的局部点:
const localPoint = new Cesium.Cartesian3(
Math.sin(heading) * distance,
Math.cos(heading) * distance,
0,
)
const worldPoint = Cesium.Matrix4.multiplyByPoint(
enuToFixed,
localPoint,
new Cesium.Cartesian3(),
)
把世界点转换成 Cartographic,清空初始高度后交给地形服务采样。
分批采样并提供降级
数十万个位置一次性进入一个请求,不利于进度反馈和错误处理。可以按批次采样:
async function sampleTerrainBatched(
terrainProvider: Cesium.TerrainProvider,
positions: Cesium.Cartographic[],
batchSize = 1500,
) {
for (let start = 0; start < positions.length; start += batchSize) {
const batch = positions.slice(start, start + batchSize)
try {
await Cesium.sampleTerrainMostDetailed(terrainProvider, batch)
} catch {
await Cesium.sampleTerrain(terrainProvider, 13, batch)
}
onProgress?.(Math.min(start + batch.length, positions.length))
}
}
精细采样失败时回退到固定层级,能保留功能,但结果分辨率已经发生变化。界面应把降级状态告诉用户,不能悄悄当成同等精度。
通视判断是维护最大遮挡仰角
对一条射线,从近到远遍历采样点。每个地形点先转换到观察点 ENU 坐标:
const local = Cesium.Matrix4.multiplyByPoint(
fixedToEnu,
terrainPoint,
new Cesium.Cartesian3(),
)
const horizontalDistance = Math.max(
Math.hypot(local.x, local.y),
0.001,
)
const blockingAngle = Math.atan2(
local.z,
horizontalDistance,
)
然后与此前出现过的最大角度比较:
let maxBlockingAngle = -Number.MAX_VALUE
for (const sample of ray.samples) {
const angle = calculateBlockingAngle(sample)
const horizonBefore = maxBlockingAngle
sample.visible =
sample.inVerticalFov &&
angle >= horizonBefore - angleTolerance
maxBlockingAngle = Math.max(maxBlockingAngle, angle)
}
为什么这样能工作:近处山脊一旦产生更高仰角,后方低于这条角度线的点都会被挡住;只有后续地形超过当前地平线角度时,才重新可见。
angleTolerance 用来吸收采样和浮点误差。设置过大会让遮挡区被错误判为可见。
垂直视场角是另一层筛选
const minPitch = Cesium.Math.toRadians(
pitch - verticalViewAngle / 2,
)
const maxPitch = Cesium.Math.toRadians(
pitch + verticalViewAngle / 2,
)
sample.inVerticalFov =
blockingAngle >= minPitch &&
blockingAngle <= maxPitch
视场角之外的点不属于“不可见”,而是没有参加本次分析。统计时应排除这些点。
红绿边界需要二次细化
相邻两条射线和相邻两个距离步长组成一个四边形单元。如果四个角同时可见或同时不可见,可以直接渲染;若可见状态混合,说明红绿边界穿过该单元。
const visibleCount = corners.reduce(
(count, sample) => count + Number(sample.visible),
0,
)
if (visibleCount > 0 && visibleCount < 4) {
refinementGrids.push(
createRefinementGrid(corners, boundarySubdivisions),
)
}
对边界单元插入更密的局部网格,再次采样地形和计算遮挡角。这样计算量集中在红绿交界,而不是把整个分析区都提升到最高密度。
细化仍然有上限:
if (refinementGrids.length >= maxBoundaryRefineCells) {
renderCoarseCell(corners)
}
没有上限时,复杂山地可能生成大量二次采样点。
将分类单元批量渲染
可见和遮挡单元最终都转为三角形:
const color = cell.visible
? Cesium.Color.LIME.withAlpha(0.5)
: Cesium.Color.RED.withAlpha(0.48)
positions.push(...cell.positions.flatMap(p => [p.x, p.y, p.z]))
colors.push(...cell.positions.flatMap(() => [
color.red,
color.green,
color.blue,
color.alpha,
]))
统一创建一个 Geometry 和 Primitive,比每个单元添加一个 Entity 更合适。为了减少地形表面的闪烁,可以同时使用轻微高度偏移和 polygon offset。
这套算法看不到建筑物
当前判断只使用 TerrainProvider 返回的地形高程,因此:
- 山体会造成遮挡。
- 3D Tiles 建筑、独立 GLB 模型和树木不会造成遮挡。
- 结果精度受地形瓦片精度、径向步长和射线角间距共同限制。
如果业务要求建筑物参与分析,需要改用深度图、ShadowMap、场景射线求交或服务端三维空间分析。那是另一套数据源和渲染管线。
平滑不能替代精度
对 3×3 邻域进行多数投票,可以去掉零散红点或绿点:
nextVisible = visibleNeighbors >= Math.ceil(neighborCount * 0.5)
它改变的是分类结果,不会增加地形信息。平滑次数过多会吞掉窄山脊形成的真实遮挡。
这套可视域实现的主线是:在观察点局部坐标中安排径向采样,沿每条射线维护最大遮挡仰角,再对红绿混合单元进行局部细化。它是地形通视算法,不是场景级遮挡分析。