站内搜索

输入关键词开始搜索

Cesium 自定义视锥体:从八个顶点到可更新 Primitive

文章目录

在相机覆盖范围、传感器方向和视频投影视场等场景中,经常需要把视锥体本身画出来。

本文构造的是“视锥可视化 Primitive”。它不会替换 Cesium Camera 的真实 frustum,也不会自动参与相机剔除或可视域判断。

视锥体近远截面几何

视锥体由六个参数决定

interface FrustumOptions {
  position: Cesium.Cartesian3
  heading: number
  pitch: number
  roll: number
  horizontalFov: number
  verticalFov: number
  near: number
  far: number
}

参数约束:

if (horizontalFov <= 0 || horizontalFov >= 180) {
  throw new Error('horizontalFov 必须位于 0 到 180 度之间')
}
if (verticalFov <= 0 || verticalFov >= 180) {
  throw new Error('verticalFov 必须位于 0 到 180 度之间')
}
if (near <= 0 || far <= near) {
  throw new Error('near 必须大于 0,far 必须大于 near')
}

接近 180 度时 tan(fov / 2) 会快速增大,几何尺寸和数值误差都会失控,因此必须限制范围。

先在局部坐标构造近远截面

设局部相机朝向负 Z 轴。距离为 d 的截面半宽和半高为:

halfWidth  = d × tan(horizontalFov / 2)
halfHeight = d × tan(verticalFov / 2)

代码:

const halfHorizontal = Cesium.Math.toRadians(horizontalFov / 2)
const halfVertical = Cesium.Math.toRadians(verticalFov / 2)

const nearHalfWidth = near * Math.tan(halfHorizontal)
const nearHalfHeight = near * Math.tan(halfVertical)
const farHalfWidth = far * Math.tan(halfHorizontal)
const farHalfHeight = far * Math.tan(halfVertical)

八个局部顶点:

const localVertices = new Float64Array([
  -nearHalfWidth, -nearHalfHeight, -near,
   nearHalfWidth, -nearHalfHeight, -near,
   nearHalfWidth,  nearHalfHeight, -near,
  -nearHalfWidth,  nearHalfHeight, -near,

  -farHalfWidth, -farHalfHeight, -far,
   farHalfWidth, -farHalfHeight, -far,
   farHalfWidth,  farHalfHeight, -far,
  -farHalfWidth,  farHalfHeight, -far,
])

局部轴约定必须和姿态计算一致。若业务把正 X 当成传感器前方,就要重新排列顶点,不能只改变量名。

用三角形索引组成六个面

const triangleIndices = new Uint16Array([
  0, 1, 2, 0, 2, 3, // 近截面
  4, 6, 5, 4, 7, 6, // 远截面
  0, 3, 7, 0, 7, 4, // 左面
  1, 5, 6, 1, 6, 2, // 右面
  0, 4, 5, 0, 5, 1, // 下面
  3, 2, 6, 3, 6, 7, // 上面
])

每个面用两个三角形。半透明视锥一般不需要复杂光照,可使用 PerInstanceColorAppearance 的 flat 模式。

把局部视锥转换到地球世界坐标

位置是 ECEF Cartesian3,姿态是当地 heading、pitch、roll:

const hpr = new Cesium.HeadingPitchRoll(
  Cesium.Math.toRadians(heading),
  Cesium.Math.toRadians(pitch),
  Cesium.Math.toRadians(roll),
)

const orientation = Cesium.Transforms.headingPitchRollQuaternion(
  position,
  hpr,
)

const transform = Cesium.Matrix4.fromTranslationQuaternionRotationScale(
  position,
  orientation,
  new Cesium.Cartesian3(1, 1, 1),
)

逐点变换:

function transformVertices(
  vertices: Float64Array,
  transform: Cesium.Matrix4,
) {
  const result = new Float64Array(vertices.length)

  for (let index = 0; index < vertices.length; index += 3) {
    const local = new Cesium.Cartesian3(
      vertices[index],
      vertices[index + 1],
      vertices[index + 2],
    )
    const world = Cesium.Matrix4.multiplyByPoint(
      transform,
      local,
      new Cesium.Cartesian3(),
    )

    result[index] = world.x
    result[index + 1] = world.y
    result[index + 2] = world.z
  }

  return result
}

这里不能只做 world = position + local,因为当地坐标轴需要随经纬度和姿态旋转。

创建 Cesium Geometry

const geometry = new Cesium.Geometry({
  attributes: {
    position: new Cesium.GeometryAttribute({
      componentDatatype: Cesium.ComponentDatatype.DOUBLE,
      componentsPerAttribute: 3,
      values: worldVertices,
    }),
  } as Cesium.GeometryAttributes,
  indices: triangleIndices,
  primitiveType: Cesium.PrimitiveType.TRIANGLES,
  boundingSphere: Cesium.BoundingSphere.fromVertices(worldVertices),
})

世界坐标量级很大,位置使用 DOUBLE。包围球用于 Cesium 的视锥剔除;漏掉它可能让 Primitive 缺少正确的可见性边界。

主体、轮廓和中心线分开渲染

主体:

const body = new Cesium.Primitive({
  geometryInstances: new Cesium.GeometryInstance({
    geometry,
    attributes: {
      color: Cesium.ColorGeometryInstanceAttribute.fromColor(
        Cesium.Color.YELLOW.withAlpha(0.25),
      ),
    },
  }),
  appearance: new Cesium.PerInstanceColorAppearance({
    flat: true,
    closed: true,
    translucent: true,
  }),
  asynchronous: false,
})

轮廓使用相同八个顶点,但索引改成线段:

const lineIndices = new Uint16Array([
  0, 1, 1, 2, 2, 3, 3, 0,
  4, 5, 5, 6, 6, 7, 7, 4,
  0, 4, 1, 5, 2, 6, 3, 7,
])

中心线只连接近截面中心和远截面中心:

const centerLine = new Cesium.PolylineGeometry({
  positions: [nearCenterWorld, farCenterWorld],
  width: 2,
  arcType: Cesium.ArcType.NONE,
  vertexFormat: Cesium.PolylineMaterialAppearance.VERTEX_FORMAT,
})

三者拆开后可以独立控制透明度、虚线和显示状态。

自定义 Primitive 的 update 协议

要让一个自定义对象加入 scene.primitives,它需要在每帧接受 frameState 并把内部 Primitive 的命令提交给 Cesium:

class FrustumPrimitive {
  show = true
  private dirty = true
  private primitive: FrustumPrimitiveCollection | null = null

  update(frameState: Cesium.FrameState) {
    if (!this.show) return

    if (this.dirty) {
      this.primitive?.destroy()
      this.primitive = this.createPrimitive()
      this.dirty = false
    }

    this.primitive.update(frameState)
  }
}

FrameState 没有作为稳定公开构造类型供业务创建,但 PrimitiveCollection 会在渲染阶段传入它。自定义 Primitive 只消费,不自行实例化。

参数更新只标记 dirty

set horizontalFov(value: number) {
  if (value <= 0 || value >= 180) return
  if (value === this._horizontalFov) return

  this._horizontalFov = value
  this.dirty = true
}

UI 滑块修改姿态或视场角时,先更新状态并标记需要重建。下一帧统一销毁旧几何、创建新几何。这样不会在一个同步调用栈中重复重建多次。

如果参数每帧变化,持续销毁和新建 Primitive 会产生明显开销。高频动画应改为固定局部几何,通过 modelMatrix 更新姿态,或把可变参数放进 Shader。

销毁内部三个 Primitive

destroy() {
  if (this.body && !this.body.isDestroyed()) this.body.destroy()
  if (this.outline && !this.outline.isDestroyed()) this.outline.destroy()
  if (this.centerLine && !this.centerLine.isDestroyed()) {
    this.centerLine.destroy()
  }
}

外层对象拥有主体、轮廓和中心线,所以由外层统一销毁。已经被 scene.primitives.remove 自动销毁的对象不要再次使用。

它不等于真正的相机视锥

这个 Primitive 只是把参数画出来:

  • 不会改变 viewer.camera.frustum。
  • 不会决定哪些对象被 Cesium 剔除。
  • 不会自动生成深度图。
  • 不会自动得到可见和不可见区域。

如果需要模拟摄像机画面,应另外创建或临时配置 Camera;如果需要可视域,要增加 ShadowMap、深度比较或地形采样算法。

自定义视锥体的主线是:在局部相机坐标中计算八个角点,通过 HPR 变换到 ECEF 世界坐标,再分别构造主体三角形、轮廓线和中心线。可视化几何和真实相机状态必须保持概念分离。

参考资料

相关文章

Cesium FFT 矩形水面:GPU 频谱海浪与随波船体

在 Cesium Primitive 中接入多级联 FFT 海浪,完成位移、法线、清澈度、LOD 网格和船体姿态采样

Cesium 无人机管道航道:Tube 几何与菲涅尔流光材质

从航点平滑、管道网格构建到半透明空心材质、边缘辉光、状态配色和场景拾取