基于Cesium原生API实现三维模型拖拽变换(平移旋转缩放)
做三维 GIS 和数字孪生项目的人大概率都遇到过同一个烦恼模型加载到 Cesium 场景里之后位置不合适想微调一下方向不对想转个角度模型太大想缩放一下结果要么改代码刷新页面反复试要么重新上传数据整个调试节奏被拖得很慢。如果模型能像 Three.js 里的 TransformControls 一样直接用鼠标拖拽控制平移、旋转和缩放那开发体验会完全不一样。很多人第一时间会想Cesium 能实现吗要不要引入 Three.js 共享上下文其实不用。Cesium 原生 API 足够写出一套轻量的模型拖拽变换工具不依赖任何第三方组件库。这篇文章就围绕这个目标展开先讲清楚原理和设计思路再给你一套可复制的最小实现最后把最容易踩坑的细节和工程建议一并交代。读完这篇文章你能得到三个东西一是明白 Cesium 中拾取、坐标转换、姿态更新这几个核心知识点是如何串联起来的二是拿到一个可直接运行的拖拽变换控制器支持平移、旋转、缩放三种模式三是知道在 Vue、React 这类工程化项目中接入时应该注意哪些边界问题。1. 这篇文章真正要解决的问题先给结论Cesium 三维模型拖拽变换本质上不是把模型“抓住”再“放到另一个地方”而是把鼠标在屏幕上的二维移动转换成三维世界中的坐标变化、姿态变化和缩放变化。这套能力有几个非常实际的应用场景模型摆放调试数字孪生园区、设备布局项目里往往需要把建筑、设备、管网等模型摆放到合理位置。拖拽变换可以边看效果边调整不用每次改经纬度刷新。姿态校准某些模型加载进来后朝向不对比如无人机、车辆模型少了朝向角用旋转模式直接从 UI 上拉一拉比在代码里换算 Heading 快很多。运行期交互工具如果产品面向非开发人员比如给园区运维人员用他们不会改代码但可能会希望拖一下设备、旋转一个构件。拖拽变换就是这类功能的基础能力。同时也要说清楚边界如果你的模型位置完全由后端数据驱动前端只是渲染拖拽变换只适合作为调试工具不建议让用户在正式业务里随意改位置除非你设计了保存与权限控制。否则就是做了一个白改的功能真正的数据源并没有更新。从实现成本看本文要做的方案只依赖 Cesium 原生 API核心逻辑可以封装成一个类代码量控制在 300 行左右。它比引入 Three.js TransformControls 再跨上下文渲染 3D 线框的方案简单得多稳定性也更好缺点是没有现成的 XYZ 轴向手柄精确到单一轴的拖拽需要额外扩展本文先给你一个“能跑、够用”的版本。2. 核心概念与设计思路在写代码之前先把 Cesium 里跟拖拽变换相关的几个概念讲清楚。这几个概念如果不理解代码只能抄出了问题不知道从哪里排查。2.1 ScreenSpaceEventHandler 与屏幕坐标Cesium 中的ScreenSpaceEventHandler是处理鼠标交互的核心类可以监听LEFT_DOWN左键按下、MOUSE_MOVE鼠标移动、LEFT_UP左键抬起等事件。在拖拽变换中事件回调收到的参数通常是{ position, endPosition, startPosition }这样的结构。这里的position/endPosition是Cartesian2也就是屏幕上的像素坐标。注意它是二维坐标不是三维坐标。屏幕坐标到三维坐标的转换是拖拽变换里最核心的一步。普通场景中我们通常用viewer.scene.camera.pickEllipsoid把二维屏幕坐标投影到地球椭球面上得到对应的Cartesian3世界坐标如果地形起伏明显可以改用viewer.scene.pickPosition但需要开启depthTestAgainstTerrain或使用 3D Tiles否则深度拾取不到数据。2.2 拾取模型Pick 能拿到什么Cesium 中拾取统一走viewer.scene.pick(position)。当你用 Entity 方式加载模型时拾取结果picked里最常用的是picked.id它指向对应的 Entity而picked.primitive则是实际命中的图元对象。这里有一个新手最容易搞混的点如果你用 Primitive 方式加载Cesium.Model那么picked.id往往为空拿到的主要是picked.primitive而用 Entity 的model属性加载模型picked.id才是你希望操作的那个实体对象。为了让拖拽变换逻辑统一本文推荐使用 Entity 方式加载模型代码里直接判断picked.id picked.id.model即可。2.3 模型姿态HeadingPitchRoll 与四元数三维模型在场景里的朝向在 Cesium Entity 里由orientation属性表达属性值是四元数Quaternion。直接操作四元数不直观所以 Cesium 提供了HeadingPitchRoll简称 HPR工具类它把姿态拆成三个角度heading绕本地参考系向上方向旋转可以理解为航向角0 度指向正北。pitch绕本地参考系的东西方向旋转可以理解为俯仰角。roll绕本地参考系的南北方向旋转可以理解为翻滚角。在拖拽旋转时我们拿到模型当前的 HPR加上鼠标移动产生的角增量然后用Cesium.Transforms.headingPitchRollQuaternion(position, newHpr)转回四元数再写回entity.orientation。这样就实现了“基于当前姿态继续旋转”而不是每次从零开始。2.4 三种变换模式的设计整体设计是“一个控制器类三种工作模式”translate平移。鼠标拖拽时取椭球面上的坐标更新entity.position。rotate旋转。鼠标水平/垂直移动转换成 heading / pitch 增量更新entity.orientation。scale缩放。鼠标垂直移动转换成缩放系数增量更新entity.model.scale。三种模式共用一个左键按下拾取、移动中更新、左键抬起清理的交互流程只是中间的分支不同。下面就从环境准备开始一步步搭建出来。3. 环境准备与基础场景搭建3.1 Cesium 版本选择本文示例采用 Cesium 1.108.0 版本。Cesium 的 API 在 1.100 之后总体稳定这套实现思路在各版本间基本通用你直接用项目里已有的 Cesium 版本即可注意新版中部分旧 API 已移除遇到问题先查版本差异。3.2 CDN 方式为了降低读者跑通的门槛先给一个最轻量的方式直接通过 CDN 引入 Cesiumlink hrefhttps://unpkg.com/cesium1.108.0/Build/Cesium/Widgets/widgets.css relstylesheet script srchttps://unpkg.com/cesium1.108.0/Build/Cesium/Cesium.js/script如果你是在现有工程中使用推荐用 npm 方式安装npm install cesiumVite 项目中需要处理静态资源可以把 Cesium 的静态文件复制到public或static目录也可以使用vite-plugin-static-copy配合配置。工程化接入的建议在文末补充。3.3 项目结构示例项目结构如下cesium-transform-demo/ ├── index.html ├── js/ │ └── ModelTransformController.js └── models/ └── Box.gltfjs/ModelTransformController.js是核心控制器models/下面放你要加载的模型文件。如果你暂时没有模型可以先用 Cesium 官方 CesiumMan 模型或者用 Blender 导出一个简单的 glTF/glb 测试文件。3.4 初始化 Viewervbg第一步先创建 Cesium 场景关闭默认控件加载一个模型。代码如下!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 titleCesium 三维模型拖拽变换/title link hrefhttps://unpkg.com/cesium1.108.0/Build/Cesium/Widgets/widgets.css relstylesheet style html, body, #cesiumContainer { width: 100%; height: 100%; margin: 0; padding: 0; overflow: hidden; } .toolbar { position: absolute; top: 12px; left: 12px; z-index: 100; display: flex; gap: 8px; padding: 8px 12px; background: rgba(255, 255, 255, 0.92); border-radius: 6px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); } .toolbar .btn-mode { padding: 6px 14px; border: 1px solid #3a7afe; border-radius: 4px; background: #fff; color: #3a7afe; cursor: pointer; font-size: 14px; } .toolbar .btn-mode.active { background: #3a7afe; color: #fff; } .tip { position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%); z-index: 100; padding: 6px 16px; background: rgba(0, 0, 0, 0.6); color: #fff; border-radius: 4px; font-size: 13px; pointer-events: none; } /style /head body div idcesiumContainer/div div classtoolbar button classbtn-mode active>class ModelTransformController { constructor(viewer) { this.viewer viewer; this.mode translate; // 当前模式translate / rotate / scale this.selectedEntity null; // 当前被拾取的模型 Entity this.isDragging false; // 是否处于拖拽状态 this.startPosition null; // 鼠标按下时的屏幕坐标 this.handler null; // ScreenSpaceEventHandler 实例 this._initHandler(); } setMode(mode) { if ([translate, rotate, scale].includes(mode)) { this.mode mode; } } clearSelection() { this.selectedEntity null; this.isDragging false; this.startPosition null; } destroy() { if (this.handler) { this.handler.destroy(); this.handler null; } } _initHandler() { // ... 事件注册 } }4.2 事件注册事件注册用Cesium.ScreenSpaceEventHandler绑定到场景 canvas左键按下时拾取模型移动时根据模式调用不同方法左键抬起时清理状态。_initHandler() { this.handler new Cesium.ScreenSpaceEventHandler(this.viewer.scene.canvas); // 左键按下拾取模型 this.handler.setInputAction((click) { if (this.mode z) return; const picked this.viewer.scene.pick(click.position); if (Cesium.defined(picked) picked.id picked.id.model) { this.selectedEntity picked.id; this.isDragging true; this.startPosition Cesium.Cartesian2.clone(click.position); // 拖拽过程中禁用相机控制避免视图跟着旋转 this.viewer.scene.screenSpaceCameraController.enableInputs false; } }, Cesium.ScreenSpaceEventType.LEFT_DOWN); // 鼠标移动根据模式执行变换 this.handler.setInputAction((movement) { if (!this.isDragging || !this.selectedEntity) return; switch (this.mode) { case translate: this._translate(movement); break; case rotate: this._rotate(movement); break; case scale: this._scale(movement); break; } }, Cesium.ScreenSpaceEventType.MOUSE_MOVE); // 左键抬起结束拖拽 this.handler.setInputAction(() { this.isDragging false; this.selectedEntity null; this.startPosition null; this.viewer.scene.screenSpaceCameraController.enableInputs true; }, Cesium.ScreenSpaceEventType.LEFT_UP); }事件注册里最关键的一行代码是this.viewer.scene.screenSpaceCameraController.enableInputs false;如果不临时禁用相机控制你会发现左键拖拽模型的同时视角也在跟着旋转无法做出“抓住模型”的效果。这里直接用enableInputs关掉所有相机输入拖拽结束时再恢复。这里真正容易踩坑的地方是如果你同时监听了LEFT_DOWN和LEFT_UP但用户在拖拽过程中鼠标移出了 canvas 区域LEFT_UP事件可能不会触发导致相机控制一直被禁用页面看起来像卡死了一样。稳妥的做法是在MOUSE_MOVE中做一次边缘检测或者给 document 也监听一次mouseup作为兜底。这个小问题在浏览器开发中非常常见建议你在自己的项目里提前处理。5. 平移、旋转、缩放核心逻辑现在把三个模式的核心逻辑分别展开。这里也是代码量最集中的部分每一段的思路都值得仔细看。5.1 平移拖拽时更新模型位置平移的核心是鼠标移动时用屏幕坐标拾取地球椭球面上的点然后用这个点更新entity.position。如果不做额外处理模型会直接被吸附到椭球面高度原本设置的高度会丢失。所以更稳妥的写法是保留模型当前高度。_translate(movement) { const worldPosition this.viewer.scene.camera.pickEllipsoid( movement.endPosition, this.viewer.scene.globe.ellipsoid ); if (!Cesium.defined(worldPosition)) return; // 保留模型当前高度避免模型被吸附到地表 const currentPos this.selectedEntity.position.getValue(Cesium.JulianDate.now()); if (Cesium.defined(currentPos)) { const currentCarto Cesium.Cartographic.fromCartesian(currentPos); const targetCarto Cesium.Cartographic.fromCartesian(worldPosition); targetCarto.height currentCarto.height; const newPos Cesium.Cartesian3.fromRadians( targetCarto.longitude, targetCarto.latitude, targetCarto.height ); this.selectedEntity.position new Cesium.ConstantProperty(newPos); } }这里的pickEllipsoid本质上做的是“屏幕像素坐标到椭球面交点的坐标转换”。屏幕上任意一个点沿着相机射线延伸和地球椭球面相交会得到一个Cartesian3。模型在移动时高度如果没有特殊需求一般沿用原有高度所以代码里单独做了高度保留。如果你希望模型严格贴着地形移动那就不能简单使用pickEllipsoid而应该使用scene.pickPosition。但使用pickPosition需要开启深度检测否则拾取结果不可用。这个取舍在产品里经常要纠结贴地表移动对展示有好处但实现成本和性能开销也更高。5.2 旋转Heading 与 Pitch 增量旋转的核心是把鼠标水平移动量换算成 heading 增量垂直移动量换算成 pitch 增量然后基于当前姿态生成新的四元数。_rotate(movement) { const deltaX movement.endPosition.x - this.startPosition.x; const deltaY movement.endPosition.y - this.startPosition.y; const currentPos this.selectedEntity.position.getValue(Cesium.JulianDate.now()); if (!Cesium.defined(currentPos)) return; // 尝试读取当前姿态 let heading 0; let pitch 0; try { const quat this.selectedEntity.orientation ? this.selectedEntity.orientation.getValue(Cesium.JulianDate.now()) : undefined; if (quat) { const hpr Cesium.HeadingPitchRoll.fromQuaternion(quat); heading hpr.heading; pitch hpr.pitch; } } catch (e) { // 默认 heading/pitch 为 0 } // 增量敏感度可适当调整 const newHeading heading deltaX * 0.01; const newPitch Cesium.Math.clamp( pitch deltaY * 0.01, -Cesium.Math.PI_OVER_TWO, Cesium.Math.PI_OVER_TWO ); const newHpr new Cesium.HeadingPitchRoll(newHeading, newPitch, 0); this.selectedEntity.orientation new Cesium.ConstantProperty( Cesium.Transforms.headingPitchRollQuaternion(currentPos, newHpr) ); this.startPosition Cesium.Cartesian2.clone(movement.endPosition); }旋转的代码有三个细节需要专门说明。第一个是“基于当前姿态累加”。因为每次鼠标移动事件之间的增量都很小如果直接用增量计算绝对角度容易产生跳动。先读取当前entity.orientation对应的 HPR再加上本次增量才能让旋转是连续、平滑的。第二个是roll固定为 0。对大部分建筑、设备模型来说roll 翻滚角并不常用固定为 0 可以减少意外的翻转。如果你的业务里需要滚转操作可以把它也映射到某个键位或模式中。第三个是headingPitchRollQuaternion的使用。这个方法把“位置 姿态角”转成四元数它实际考虑到了当地东-北-天参考系的方向比直接用Quaternion.fromHeadingPitchRoll更符合 Cesium Entity 的 orientation 定义。这里还要提醒一点旋转方向和鼠标拖拽方向的关系取决于相机视角。默认情况下水平向右拖拽会让模型 heading 增大但模型是顺时针还是逆时针要看你从南向北看还是从北向南看。如果发现方向反了把deltaX * 0.01改成-deltaX * 0.01即可不需要改其他代码。5.3 缩放基于垂直拖拽增量调整 scale缩放的实现相对简单鼠标向上拖拽endPosition.y小于startPosition.y时 scale 增大向下拖拽时 scale 减小。为了防止拖出极端值对 scale 做范围限制。_scale(movement) { // 向上拖拽为放大向下拖拽为缩小 const deltaY this.startPosition.y - movement.endPosition.y; const currentScale this.selectedEntity.model.scale ? this.selectedEntity.model.scale.getValue(Cesium.JulianDate.now()) : 1; const nextScale currentScale * (1 deltaY * 0.005); const clampedScale Cesium.Math.clamp(nextScale, 0.1, 50); this.selectedEntity.model.scale new Cesium.ConstantProperty(clampedScale); this.startPosition Cesium.Cartesian2.clone(movement.endPosition); }注意这里的model.scale在 Entity 中是Property类型所以取值时用getValue(Cesium.JulianDate.now())。如果你只用 number 直接赋值getValue也能兼容但统一走 Property 写法更不容易混。缩放范围建议按业务场景设置小设备模型可能允许缩到 0.01大园区模型可能放大到 100 也不够。0.1 到 50 只是一个通用默认值你在项目中要按实际模型尺寸调整。6. 完整示例运行与效果验证6.1 完整 ModelTransformController.js把上面几个方法合并就是一个完整的控制器文件// 文件路径js/ModelTransformController.js class ModelTransformController { constructor(viewer) { this.viewer viewer; this.mode translate; this.selectedEntity null; this.isDragging false; this.startPosition null; this.handler null; this._initHandler(); } setMode(mode) { if ([translate, rotate, scale].includes(mode)) { this.mode mode; } } clearSelection() { this.selectedEntity null; this.isDragging false; this.startPosition null; } destroy() { if (this.handler) { this.handler.destroy(); this.h