站内搜索

输入关键词开始搜索

Cesium 地形可视域分析:用采样射线判断通视关系

文章目录

可视域分析回答的是:从一个观察点出发,在给定方向、水平视场角、垂直视场角和半径内,哪些地形区域能被看见。

本文对应的实现不是 ShadowMap,而是 CPU 地形采样:沿多个方向生成径向采样点,比较每个点的仰角和此前最高遮挡仰角。

地形可视域的最大遮挡仰角

两次点击确定分析参数

交互层把操作分成两个阶段:

  1. 第一次点击选择观察点,并采样该点真实地形高度。
  2. 鼠标移动更新方向,第二次点击确定目标方向并开始计算。
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)

它改变的是分类结果,不会增加地形信息。平滑次数过多会吞掉窄山脊形成的真实遮挡。

这套可视域实现的主线是:在观察点局部坐标中安排径向采样,沿每条射线维护最大遮挡仰角,再对红绿混合单元进行局部细化。它是地形通视算法,不是场景级遮挡分析。

参考资料

相关文章

Cesium 地形可视域:径向采样与最大遮挡仰角

通过地形径向采样判断可见与遮挡区域,并生成观察点视角快照和计算统计

Cesium 地形抬升:从真实地表生成可编辑地块

说明地形采样、影像纹理、抬升顶面、侧壁坑体、质量档位、进度取消和工程量分析