站内搜索

输入关键词开始搜索

Cesium 加载与调整 3D Tiles:ENU 变换、模型矩阵和 LOD

文章目录

3D Tiles 数据加载成功后,常见的下一步不是立即交付,而是处理模型位置偏移、朝向不对、比例不一致和近距离细节不足。

这些问题对应两组能力:modelMatrix 负责空间变换,maximumScreenSpaceError 负责层级细化。

异步加载并加入场景

let tileset: Cesium.Cesium3DTileset | null = null

async function loadTileset(viewer: Cesium.Viewer) {
  tileset = await Cesium.Cesium3DTileset.fromUrl(
    '/datas/3dtiles/tileset.json',
    {
      skipLevelOfDetail: true,
      skipLevels: 1,
      loadSiblings: false,
      cullWithChildrenBounds: true,
    },
  )

  viewer.scene.primitives.add(tileset)
  await viewer.zoomTo(tileset)
}

fromUrl 只完成资源创建和异步加载。返回的 Tileset 需要加入 scene.primitives 才会参与场景更新和渲染。

跳级加载相关参数不是固定模板。不同数据集的层级结构、误差值和网络环境不同,应先使用默认配置,再根据请求数量、显存和视觉结果调整。

为什么不能直接修改经纬度

3D Tiles 顶点最终处于地心地固坐标系中。某个位置的“向上”不是固定的世界 Z 轴,“向东”和“向北”也会随经纬度变化。

在指定经纬度建立局部 East-North-Up 坐标系,可以得到符合地图直觉的局部轴:

const anchor = Cesium.Cartesian3.fromDegrees(
  longitude,
  latitude,
  height,
)

const matrix = Cesium.Transforms.eastNorthUpToFixedFrame(anchor)

这个矩阵同时包含:

  • 把局部原点移动到指定经纬度。
  • 把局部 X 轴对准东。
  • 把局部 Y 轴对准北。
  • 把局部 Z 轴对准当地法线方向。

后续旋转和缩放应该在这个局部坐标系中叠加。

组合平移、旋转和缩放

function buildTilesetMatrix(params: {
  longitude: number
  latitude: number
  height: number
  rotationX: number
  rotationY: number
  rotationZ: number
  scale: number
  localZOffset?: number
}) {
  const position = Cesium.Cartesian3.fromDegrees(
    params.longitude,
    params.latitude,
    params.height,
  )

  const result = Cesium.Transforms.eastNorthUpToFixedFrame(position)

  const rotationX = Cesium.Matrix3.fromRotationX(
    Cesium.Math.toRadians(params.rotationX),
  )
  const rotationY = Cesium.Matrix3.fromRotationY(
    Cesium.Math.toRadians(params.rotationY),
  )
  const rotationZ = Cesium.Matrix3.fromRotationZ(
    Cesium.Math.toRadians(params.rotationZ),
  )

  Cesium.Matrix4.multiply(
    result,
    Cesium.Matrix4.fromRotationTranslation(rotationX),
    result,
  )
  Cesium.Matrix4.multiply(
    result,
    Cesium.Matrix4.fromRotationTranslation(rotationY),
    result,
  )
  Cesium.Matrix4.multiply(
    result,
    Cesium.Matrix4.fromRotationTranslation(rotationZ),
    result,
  )
  Cesium.Matrix4.multiply(
    result,
    Cesium.Matrix4.fromUniformScale(params.scale),
    result,
  )

  if (params.localZOffset) {
    Cesium.Matrix4.multiply(
      result,
      Cesium.Matrix4.fromTranslation(
        new Cesium.Cartesian3(0, 0, params.localZOffset),
      ),
      result,
    )
  }

  return result
}

应用时使用公开的 modelMatrix:

tileset.modelMatrix = buildTilesetMatrix({
  longitude: 108.94,
  latitude: 34.34,
  height: 0,
  rotationX: 0,
  rotationY: 0,
  rotationZ: 15,
  scale: 1,
  localZOffset: -20,
})

不要在业务文章中依赖 _root.transform、_selectedTiles 等下划线字段。它们属于运行时内部实现,升级 Cesium 时没有公开 API 的兼容保证。

矩阵顺序决定变换发生在哪个坐标系

上面的写法可以抽象成:

M = ENU × Rx × Ry × Rz × Scale × LocalOffset

点坐标从右往左依次执行:先应用局部偏移和缩放,再旋转,最后通过 ENU 放到地球上的目标位置。

如果把世界平移矩阵乘到最左侧,含义就变成在 ECEF 世界坐标中移动。几米的世界 X 偏移并不等于向东移动几米。

矩阵顺序出错时,常见表现是:

  • 模型绕地球中心旋转,而不是绕自身旋转。
  • 调整高度时模型横向漂移。
  • 缩放后模型中心位置改变。

垂直偏移不是通用常量

一些数据的局部原点不在模型底部。可以从数据生产过程或根节点包围体推导一个补偿值,再作为局部 Z 偏移应用。

const response = await fetch('/datas/3dtiles/tileset.json')
const json = await response.json()
const box = json.root.boundingVolume.box

const datasetOffset = box[2] - box[11]

这段计算只适用于对应数据的包围体组织方式,不是 3D Tiles 的通用“贴地公式”。更可靠的做法是让数据生产阶段输出明确的坐标基准,或通过地形采样计算目标锚点高程。

maximumScreenSpaceError 如何影响 LOD

tileset.maximumScreenSpaceError = 16

屏幕空间误差可以理解为当前瓦片的几何误差投影到屏幕后占多少像素。当误差超过阈值时,Cesium 会继续请求和渲染更细的子瓦片。

参数影响如下:

值结果
较小更早细化,画面更细,网络和显存压力更高
较大更晚细化,性能更高,近距离可能变粗糙

它不是模型精度百分比,也不是瓦片层级编号。适合把它暴露成有边界的运行时参数:

function setLodError(value: number) {
  if (!tileset) return
  tileset.maximumScreenSpaceError = Cesium.Math.clamp(value, 1, 64)
}

飞到模型要使用变换后的包围球

function flyToTileset(viewer: Cesium.Viewer, tileset: Cesium.Cesium3DTileset) {
  const sphere = tileset.boundingSphere

  viewer.camera.flyToBoundingSphere(sphere, {
    duration: 1.5,
    offset: new Cesium.HeadingPitchRange(
      0,
      Cesium.Math.toRadians(-45),
      sphere.radius * 3,
    ),
  })
}

包围球半径可以作为相机距离的基准,避免为不同尺寸的模型硬编码同一个高度。

更新 modelMatrix 后,如果相机仍然飞向旧位置,应确认 Tileset 是否已经加入场景并完成一次更新,再读取包围球。

显示、重置和销毁

显示隐藏不需要移除 Tileset:

tileset.show = false

重置时恢复参数并重新计算矩阵:

function reset() {
  Object.assign(params, defaultParams)
  tileset.modelMatrix = buildTilesetMatrix(params)
  tileset.maximumScreenSpaceError = 16
  tileset.show = true
}

组件销毁时移除自己添加的 Primitive:

function dispose(viewer: Cesium.Viewer) {
  if (!tileset) return

  viewer.scene.primitives.remove(tileset)
  tileset = null
}

PrimitiveCollection.remove 默认会销毁被移除对象。移除后不要继续访问原 Tileset。

3D Tiles 调整的主线是:先建立目标位置的 ENU 局部坐标系,再在局部坐标中组合旋转、缩放和偏移,最后通过公开的 modelMatrix 应用到整个 Tileset。LOD 是另一条独立控制线,不应混进模型矩阵。

参考资料

相关文章

Cesium 3D Tiles 局部移动实验:选择瓦片并修改节点变换

分析通过运行时瓦片树实现局部平移、旋转和缩放的过程,并说明私有 API、LOD 和语义边界

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

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