Cesium 3D 变换控制器教程

Cesium 3D 变换控制器 · Cesium 3D Transform Controls · ▶ 在线运行案例

你将学到什么

  • Cesium Viewer 初始化与场景配置
  • 外部模型 / 3D Tiles 加载
  • Cesium 相机定位与跟随
  • Cesium Primitive 层海量渲染
  • GUI 参数调试面板

效果说明

本案例演示 Cesium 3D 变换控制器 效果:基于 WebGL 实现「Cesium 3D 变换控制器」可视化效果,附完整可运行源码;核心用到 3D。建议先打开文首在线案例查看动态画面,再对照下方源码逐步理解。

核心概念

  • Viewer 封装地球、相机、图层与 clock;可关闭 animation/timeline 精简 UI。
  • 异步 Loader 返回 scene 或 tileset;注意 scale、坐标系与 modelMatrix 贴地。
  • flyTo 带动画定位;trackedEntity 第三人称跟随实体。
  • BillboardCollection / Primitive 合批渲染,适合万级点面。
  • dat.GUI / lil-gui 绑定 uniform 或配置对象实时调参。

实现步骤

  • 创建 Viewer,配置地形/影像(若案例需要)并设置初始相机
  • 异步加载模型 / 3D Tiles / GeoJSON 等资源并加入 scene 或 entities
  • requestAnimationFrame 循环中更新状态并 render(Cesium 为 viewer.render 或自动渲染)

代码要点

复制代码
  /**

    
  * Cesium Transform Controls - 模型变换控制器演示

  * 本案例演示了 cesium-transform-controls 插件的核心功能

  * * 功能说明:

  * 1. 支持模型的平移(translate)、旋转(rotate)、缩放(scale)三种变换模式

  * 2. 支持局部坐标系(local)和地表坐标系(surface)两种坐标模式

  * 3. 支持对整个模型(Model)或模型子节点(ModelNode)进行变换操作

  * 4. 实时显示模型的位置、旋转角度和缩放信息

  * * 依赖:

  * - cesium-transform-controls: https://github.com/123164867376464646/cesium-transform-controls

  * * @author 123164867376464646

   */


  // ==================== 导入模块 ====================
  import * as Cesium from 'cesium'
  import { CoordinateMode, Gizmo, GizmoMode } from 'cesium-transform-controls'
  import { GUI } from 'dat.gui'



  console.log(Cesium.VERSION)



  // ==================== Cesium Viewer 初始化 ====================
  /**


    
  * 初始化 Cesium Viewer 实例

  * @type {Cesium.Viewer}

   */





  // 获取地图容器元素
  const box = document.getElementById('box')



  const viewer = new Cesium.Viewer(box, {
    baseLayerPicker: false,       // 不显示图层选择器
    geocoder: false,              // 不显示地理编码器
    homeButton: false,            // 不显示主页按钮
    sceneModePicker: false,       // 不显示场景模式选择器
    navigationHelpButton: false,  // 不显示导航帮助按钮
    animation: false,             // 不显示动画控件
    timeline: false,              // 不显示时间线
    infoBox: false,               // 不显示信息框
  })



  // ==================== 模型加载配置 ====================
  /* 模型基准经度/
  const baseLon = 106.58446188
  /* 模型基准纬度/
  const baseLat = 29.57088337
  /* 模型基准高度/
  const baseHeight = 0



  /**


    
  * 异步加载 glTF 模型

  * @type {Cesium.Model}

   */
  const model = await Cesium.Model.fromGltfAsync({
    url: 'https://cesium-transform-controls.netlify.app/luaz.glb',
    modelMatrix: Cesium.Transforms.headingPitchRollToFixedFrame(
      Cesium.Cartesian3.fromDegrees(baseLon, baseLat, baseHeight),
      new Cesium.HeadingPitchRoll(
        Cesium.Math.toRadians(0),
        Cesium.Math.toRadians(0),
        Cesium.Math.toRadians(0)
      ),
    ),
    scale: 10,
  })
  viewer.scene.primitives.add(model)





  // ==================== Gizmo 控制器初始化 ====================
  /**


    
  * 创建 Gizmo 变换控制器实例

  * @type {Gizmo}

   */
  const gizmo = new Gizmo({
    /**

    
  <!-- -->


  * Gizmo 拖拽移动时的回调函数

  * 用于实时更新 GUI 中显示的坐标信息

  * @param {Object} event - 事件对象

     */
    onGizmoPointerMove: (event) => {
      updateCoordinatesFromMatrix(gizmo._mountedPrimitive)
    },
  })
  gizmo.attach(viewer)





  // ==================== GUI 控制面板配置 ====================
  /**


    
  * GUI 控制面板设置项

   */
  const settings = {
    transformMode: 'translate',   // 变换模式:translate/rotate/scale
    translateMode: 'local',       // 坐标模式:local/surface
    enabled: true,                // 是否启用 Gizmo
    // 位置信息显示
    longitude: '0.000000',        // 经度
    latitude: '0.000000',         // 纬度
    height: '0.00',               // 高度(米)
    // 旋转角度显示(度)
    rotateX: '0.00',
    rotateY: '0.00',
    rotateZ: '0.00',
    // 缩放比例显示
    scaleX: '1.00',
    scaleY: '1.00',
    scaleZ: '1.00',
  }





  // 设置 Gizmo 初始模式
  gizmo.setMode(settings.transformMode)
  gizmo.coordinateMode = CoordinateMode.local



  // 创建 GUI 控制面板
  const gui = new GUI({ name: '变换控制器' })



  /* 坐标模式控制器引用(用于在 scale 模式时禁用)/
  let translateModeController = null
  /* 保存的坐标模式(用于从 scale 模式切换回来时恢复)/
  let savedCoordinateMode = 'local'



  // ==================== 变换模式控制 ====================
  /**


    
  * 添加变换模式选择器

  * - translate: 平移模式

  * - rotate: 旋转模式

  * - scale: 缩放模式(仅支持局部坐标系)

   */
  gui.add(settings, 'transformMode', ['translate', 'rotate', 'scale'])
    .name('变换模式')
    .onChange((value) => {
      switch (value) {
        case 'translate':
          gizmo.setMode(GizmoMode.translate)
          break
        case 'rotate':
          gizmo.setMode(GizmoMode.rotate)
          break
        case 'scale':
          gizmo.setMode(GizmoMode.scale)
          break
      }
      
      // 缩放模式仅支持局部坐标系,需要禁用坐标模式切换
      if (value === 'scale') {
        savedCoordinateMode = settings.translateMode
        gizmo.coordinateMode = CoordinateMode.local
        translateModeController.domElement.style.pointerEvents = 'none'
        translateModeController.domElement.style.opacity = '0.5'
      } else {
        translateModeController.domElement.style.pointerEvents = 'auto'
        translateModeController.domElement.style.opacity = '1'
        settings.translateMode = savedCoordinateMode
        gizmo.coordinateMode = savedCoordinateMode === 'surface' 
          ? CoordinateMode.surface 
          : CoordinateMode.local
        translateModeController.updateDisplay()
      }
    })





  // ==================== 坐标模式控制 ====================
  /**


    
  * 添加坐标模式选择器

  * - local: 局部坐标系(相对于模型自身)

  * - surface: 地表坐标系(相对于地球表面 ENU)

   */
  translateModeController = gui.add(settings, 'translateMode', ['local', 'surface'])
    .name('坐标模式')
    .onChange((value) => {
      switch (value) {
        case 'local':
          gizmo.coordinateMode = CoordinateMode.local
          break
        case 'surface':
          gizmo.coordinateMode = CoordinateMode.surface
          break
      }
    })





  // ==================== 启用/禁用控制 ====================
  gui.add(settings, 'enabled')
    .name('启用控制器')
    .onChange((value) => {
      gizmo.setEnabled(value)
    })



  // ==================== 信息显示面板 ====================
  const coordsFolder = gui.addFolder('变换信息')



  /* 拾取对象信息/
  const pickSettings = {
    modelName: '-',
    modelType: '-',
  }



  // 添加信息显示控件(只读)
  const nameController = coordsFolder.add(pickSettings, 'modelName').name('名称').listen()
  const typeController = coordsFolder.add(pickSettings, 'modelType').name('类型').listen()
  const lonController = coordsFolder.add(settings, 'longitude').name('经度').listen()
  const latController = coordsFolder.add(settings, 'latitude').name('纬度').listen()
  const heightController = coordsFolder.add(settings, 'height').name('高度 (m)').listen()
  const rotateXController = coordsFolder.add(settings, 'rotateX').name('旋转 X (°)').listen()
  const rotateYController = coordsFolder.add(settings, 'rotateY').name('旋转 Y (°)').listen()
  const rotateZController = coordsFolder.add(settings, 'rotateZ').name('旋转 Z (°)').listen()
  const scaleXController = coordsFolder.add(settings, 'scaleX').name('缩放 X').listen()
  const scaleYController = coordsFolder.add(settings, 'scaleY').name('缩放 Y').listen()
  const scaleZController = coordsFolder.add(settings, 'scaleZ').name('缩放 Z').listen()
  coordsFolder.open()



  // 禁用名称和类型输入框的编辑
  nameController.domElement.style.pointerEvents = 'none'
  typeController.domElement.style.pointerEvents = 'none'



  // ==================== 模型挂载与相机定位 ====================
  /**


    
  * 模型加载完成后的处理

  * - 挂载 Gizmo 到模型子节点

  * - 飞行到模型位置

   */
  model.readyEvent.addEventListener(() => {
    // 定义要操作的子节点名称
    // const nodeName = 'wheel_FR_luaz_diffuse_0' // 轮胎节点
    const nodeName = 'door_R_luaz_diffuse_0' // 车门节点
    const node = model.getNode(nodeName)





  // 将 Gizmo 挂载到模型子节点
    // 也可以使用 gizmo.mountToPrimitive(model, viewer) 挂载到整个模型
    gizmo.mountToNode(node, model, viewer)



  // 挂载完成后初始化显示位置信息
    updateCoordinatesFromMatrix(gizmo._mountedPrimitive)



  // 延迟飞行到模型位置(等待模型完全加载)
    setTimeout(() => {
      const boundingSphere = model.boundingSphere
      viewer.camera.flyToBoundingSphere(boundingSphere, {
        duration: 0,
        offset: new Cesium.HeadingPitchRange(
          Cesium.Math.toRadians(-45),
          Cesium.Math.toRadians(-15),
          boundingSphere.radius * 3
        ),
      })
    }, 1000)
  })



  // ==================== 工具函数 ====================



  /**


    
  * 获取挂载对象的类型

  * @param {Object} mounted - 挂载的对象

  * @returns {string} 对象类型名称

   */
  function getMountedObjectType(mounted) {
    if (!mounted) return '-'





  // Entity 类型
    if (mounted._isEntity) {
      return 'Entity'
    }



  // ModelNode 子节点类型
    if (mounted._isNode && mounted._node) {
      return 'ModelNode'
    }



  // 3D Tiles 类型
    if (mounted.tileset || mounted.content?.tileset) {
      return '3DTiles'
    }



  // Model 类型
    if (mounted instanceof Cesium.Model) {
      return 'Model'
    }



  // 其他 Primitive 类型
    if (mounted.modelMatrix) {
      return mounted.constructor?.name || 'Primitive'
    }



  return 'Unknown'
  }



  /**


    
  * 获取挂载对象的名称

  * @param {Object} mounted - 挂载的对象

  * @returns {string} 对象名称

   */
  function getMountedObjectName(mounted) {
    if (!mounted) return '-'





  // Entity 名称
    if (mounted._isEntity && mounted._entity) {
      const entity = mounted._entity
      return entity.name || entity.id || 'Entity'
    }



  // ModelNode 子节点名称
    if (mounted._isNode && mounted._node) {
      const node = mounted._node
      return node.name || node._runtimeNode?.name || 'ModelNode'
    }



  // 3D Tiles 名称
    if (mounted.tileset) {
      return mounted.tileset._url?.split('/').pop() || '3D Tileset'
    }
    if (mounted.content?.tileset) {
      return mounted.content.tileset._url?.split('/').pop() || '3D Tileset'
    }



  // Model 名称(从 URL 提取文件名)
    if (mounted instanceof Cesium.Model) {
      const url = mounted._resource?._url || mounted._url || ''
      const fileName = url.split('/').pop()?.split('?')[0] || 'Model'
      return fileName
    }



  // 其他 Primitive 名称
    if (mounted.modelMatrix) {
      return mounted.constructor?.name || 'Primitive'
    }



  return '-'
  }



  /**


    
  * 更新挂载对象信息到 GUI

  * 通过检查 Gizmo 的可见状态来决定是否显示信息

   */
  function updateMountedObjectInfo() {
    const mounted = gizmo._mountedPrimitive
    
    // 检查任意模式的 Gizmo 是否可见
    const isTransVisible = gizmo._transPrimitives?._show ?? false
    const isRotateVisible = gizmo._rotatePrimitives?._show ?? false
    const isScaleVisible = gizmo._scalePrimitives?._show ?? false
    const isGizmoVisible = isTransVisible || isRotateVisible || isScaleVisible





  if (mounted && isGizmoVisible) {
      pickSettings.modelName = getMountedObjectName(mounted)
      pickSettings.modelType = getMountedObjectType(mounted)
      coordsFolder.show()
    } else {
      pickSettings.modelName = '-'
      pickSettings.modelType = '-'
      coordsFolder.hide()
    }
  }



  // 通过 preRender 事件监听 Gizmo 的可见状态变化
  viewer.scene.preRender.addEventListener(updateMountedObjectInfo)



  // 初始化显示当前挂载的模型信息
  updateMountedObjectInfo()



  /**


    
  * 从模型矩阵中提取并更新坐标信息

  * 包括:位置(经纬度高度)、旋转角度、缩放比例

  * * @param {Object} model - 挂载的模型对象

   */
  function updateCoordinatesFromMatrix(model) {
    // 从模型矩阵提取位置
    const position = Cesium.Matrix4.getTranslation(model.modelMatrix, new Cesium.Cartesian3())
    const cartographic = Cesium.Cartographic.fromCartesian(position)





  // 检查坐标是否有效
    if (!cartographic) {
      settings.longitude = '-'
      settings.latitude = '-'
      settings.height = '-'
      settings.rotateX = '-'
      settings.rotateY = '-'
      settings.rotateZ = '-'
      settings.scaleX = '-'
      settings.scaleY = '-'
      settings.scaleZ = '-'
      return
    }



  // 更新位置信息(弧度转角度)
    const longitude = Cesium.Math.toDegrees(cartographic.longitude)
    const latitude = Cesium.Math.toDegrees(cartographic.latitude)
    const height = cartographic.height



  settings.longitude = longitude.toFixed(8)
    settings.latitude = latitude.toFixed(8)
    settings.height = height.toFixed(2)



  // ==================== 提取旋转分量 ====================
    if (model._isNode && model._node) {
      // 对于子节点(ModelNode),从节点矩阵中提取旋转
      const node = model._node
      let mat = node.matrix
      if (!mat && node._runtimeNode && node._runtimeNode.transform) {
        mat = node._runtimeNode.transform
      }
      if (!mat) {
        mat = Cesium.Matrix4.IDENTITY
      }



  // 提取旋转矩阵(去除缩放影响)
      const m3 = new Cesium.Matrix3()
      Cesium.Matrix4.getMatrix3(mat, m3)



  // 归一化列向量以移除缩放
      const c0 = Cesium.Cartesian3.fromElements(m3[0], m3[1], m3[2])
      const c1 = Cesium.Cartesian3.fromElements(m3[3], m3[4], m3[5])
      const c2 = Cesium.Cartesian3.fromElements(m3[6], m3[7], m3[8])
      Cesium.Cartesian3.normalize(c0, c0)
      Cesium.Cartesian3.normalize(c1, c1)
      Cesium.Cartesian3.normalize(c2, c2)



  // 重组纯旋转矩阵
      const r00 = c0.x, r01 = c1.x, r02 = c2.x
      const r10 = c0.y, r11 = c1.y, r12 = c2.y
      const r20 = c0.z, r21 = c1.z, r22 = c2.z



  // 分解欧拉角(顺序:Z -> Y -> X,即 R = Mz My Mx)
      let x = 0, y = 0, z = 0
      if (Math.abs(r20) < 0.99999) {
        y = Math.asin(-r20)
        x = Math.atan2(r21, r22)
        z = Math.atan2(r10, r00)
      } else {
        // 万向锁情况
        y = Math.PI / 2 * Math.sign(-r20)
        z = 0
        x = Math.atan2(-r12, r11)
      }



  settings.rotateX = Cesium.Math.toDegrees(x).toFixed(2)
      settings.rotateY = Cesium.Math.toDegrees(y).toFixed(2)
      settings.rotateZ = Cesium.Math.toDegrees(z).toFixed(2)



  } else {
      // 对于根模型(Root Model),使用 Cesium 提供的 ENU 转换
      // Heading = Z轴旋转, Pitch = Y轴旋转, Roll = X轴旋转
      const hpr = Cesium.Transforms.fixedFrameToHeadingPitchRoll(model.modelMatrix)
      settings.rotateX = Cesium.Math.toDegrees(hpr.roll).toFixed(2)
      settings.rotateY = Cesium.Math.toDegrees(hpr.pitch).toFixed(2)
      settings.rotateZ = Cesium.Math.toDegrees(hpr.heading).toFixed(2)
    }



  // ==================== 提取缩放分量 ====================
    let targetMatrix = Cesium.Matrix4.IDENTITY
    if (model._isNode && model._node) {
      const node = model._node
      if (node.matrix) {
        targetMatrix = node.matrix
      } else if (node._runtimeNode && node._runtimeNode.transform) {
        targetMatrix = node._runtimeNode.transform
      }
    } else {
      targetMatrix = model.modelMatrix
    }



  const scale = Cesium.Matrix4.getScale(targetMatrix, new Cesium.Cartesian3())



  // 对于根模型,还需要考虑 model.scale(统一缩放因子)
    let uniformScale = 1.0
    if (!model._isNode && typeof model.scale === 'number') {
      uniformScale = model.scale
    }



  settings.scaleX = (scale.x * uniformScale).toFixed(2)
    settings.scaleY = (scale.y * uniformScale).toFixed(2)
    settings.scaleZ = (scale.z * uniformScale).toFixed(2)
  }



  // ==================== 资源清理 ====================
  /**


    
  * 页面卸载前清理资源
   */
  window.addEventListener('beforeunload', () => {
    gizmo.detach()
    viewer.destroy()
  })

完整源码:GitHub

小结