Cesium三维模型拖拽变换:平移、旋转、缩放与贴地实现
Cesium 三维模型的拖拽变换,是很多 Web 三维项目里绕不开的一步:用户不想在表单里填经纬度、高度、朝向,而是希望直接在场景里用鼠标把模型拖到目标位置,摆好角度,调好大小。这种需求常出现在数字孪生园区、设备摆放、仿真推演和场景编辑器里。这篇文章就围绕 Cesium 里怎么做模型拖拽变换,把平移、旋转、缩放、贴地、吸附和常见坑一次讲清楚。
先说结论:Cesium 本身没有提供开箱即用的模型拖拽组件,但实现思路并不复杂。核心就三步——监听鼠标事件,把屏幕坐标转换成地球坐标,再更新模型的 position 或 modelMatrix。真正容易踩坑的地方不在事件,而在坐标系换算和模型形态差异。后面会按我的实测顺序来拆。
适合看的人群是已经会用 Cesium 加载模型、但还没做过交互控制的开发者。如果你只是需要快速跑通一个可拖拽模型,可以直接跳到第 3 节;如果你想把拖拽功能做成一个能旋转、能缩放、能贴地的编辑器模式,建议完整读一遍。
1. 先把模型拖拽这件事拆开:拖的是什么,改的又是什么
很多人第一次做 Cesium 模型拖拽,第一反应是找类似 drag 的方法。实际 Cesium 没有这种组件,需要自己组合鼠标事件与坐标转换。在做之前,先搞清楚 Cesium 里的模型有几种形态。
1.1 三种常见形态:Entity、Primitive Model、3D Tiles
用 Entity 加 model 的方式最简单:
这种形态天然带拾取、带属性、带生命周期管理,拖拽时直接改 entity.position 和 entity.orientation 就可以。它的缺点是:如果场景里模型数量很大,Entity 的更新开销会比 Primitive 高。
用 Primitive 的方式则是直接创建模型:
Primitive 模型没有 entity.position 这种高层属性,所有变换都要通过 model.modelMatrix 表达。好处是更接近渲染底层,性能好;坏处是代码更繁琐,位置、朝向、缩放都要自己维护。
还有第三种情况:模型是 3D Tiles 里的一部分。这种通常不能直接拖单个模型。3D Tiles 是以瓦片为单位组织的,一个瓦片里有大量节点,想拖其中一个对象,需要先解析模型节点,找到对应对象,再把它拆出来或做独立叠加。这个复杂度更高,一般不建议在基础拖拽功能里直接做。
1.2 拖拽背后的四个底层参数
不管哪种形态,拖拽最终都要落到底层参数上。我能想到的就四类:
- position,模型中心点的世界坐标,平移主要改它。
- orientation,Entity 的姿态,使用四元数表示。
- heading/pitch/roll,人可读的航向、俯仰、翻滚角,Cesium 通过
Transforms.headingPitchRollQuaternion转成四元数。 - modelMatrix,Primitive 模型的变换矩阵,平移旋转缩放最后都可以合成一个 4x4 矩阵。
可以对照这张表理解:
| 操作 | Entity 方式 | Primitive Model 方式 |
|---|---|---|
| 平移 | 修改 entity.position | 重新构造 modelMatrix 的平移部分 |
| 旋转 | 修改 entity.orientation | 用 headingPitchRollToFixedFrame 重新生成矩阵 |
| 缩放 | 修改 entity.model.scale | 在 modelMatrix 里乘缩放系数 |
| 混合变换 | 分别处理 position/orientation/scale | 统一合成 modelMatrix |
最常见的误区是:以为 Entity 上也有 heading、pitch、roll 属性可以直接改。其实 Entity 没有直接的 hpr 属性,需要把角度转成 orientation 四元数。而 Primitive Model 上更是没有高层属性,所有状态都要自己维护一套变量。
1.3 区分“跟着鼠标走”和“原地调整姿态”
拖拽这个词,实际包含三个动作:
- 平移:模型中心点改变,姿态不变。
- 旋转:中心点不变,朝向改变。
- 缩放:中心点和朝向不变,尺寸改变。
这三个动作背后的逻辑完全不同。平移是“增量式计算”,需要记录鼠标起点和模型起点;旋转是“角度增量叠加”,要把鼠标位移换算成角度变化;缩放则是“倍率变化”,一般用滚轮或按钮控制。
我建议先分开实现,再组合。如果你想第一次就做一个全能拖拽,代码会很难排查。尤其是发生错乱时,你不知道是平移的问题,还是旋转时把 position 也改了。
2. 环境和模型准备:先让模型在场景里出现,再谈交互
拖拽不是凭空实现的。先保证模型能显示、能设置姿态,再往上加交互逻辑。
2.1 最小开发环境
你需要准备这些:
- Cesium 依赖包,可以是 npm 包或本地静态资源。
- 一个可显示的 GLB/glTF 模型。
- 一个容器 div,创建一个 Cesium.Viewer 实例。
- 影像和地形资源。没有 Ion token 也可以用本地瓦片或直接使用空白地球背景。
如果只是验证拖拽逻辑,不一定要连复杂影像服务。Cesium.Viewer 创建时可以把动画控件、底图选择器都关掉,只留场景。关键是先把模型显示出来。
如果你在离线环境工作,不用被默认影像卡住。把模型资源放到本地静态目录,然后用本地瓦片服务做底图,就能省掉 token 的问题。模型本身不依赖网络资源,除非它引用了外部纹理。
2.2 加载一个最简单可拖拽的 Entity 模型
先看基础代码:
代码里 minimumPixelSize: 64 是让模型在较远视角下也保持最小像素尺寸,方便测试,不一定要加。如果你用官方 SampleData 里的模型,只要路径写对即可;如果模型纹理较大,加载时会有几秒空白,这是正常的。
2.3 Primitive Model 的加载差异
用 Primitive 方式时,代码不太一样:
注意:Model.fromGltfAsync 是异步 API,返回 Promise。如果你的项目还是旧版 Cesium,API 名称和调用方式可能不同。加载完成后,model.modelMatrix 就是后续拖拽要改的目标。
注意:不要一上来就同时实现平移、旋转、缩放,先把一条拖拽链路跑通,再拆成独立模式。
3. 拖拽平移实现细节:鼠标事件、坐标换算和状态管理
拖拽平移是整个功能的地基。这里涉及的问题最容易被忽视:屏幕上的鼠标位置,和地球上的三维坐标,是两个坐标系。不换算,模型就不会跟手。
3.1 为什么用 ScreenSpaceEventHandler 而不是 DOM 事件
Cesium 的鼠标事件需要处理相机的平移、缩放冲突。直接在 canvas 上监听 mousedown 和 mousemove,很容易跟 Cesium 相机操作打架。你用 new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas) 注册事件,才能让拖拽和相机旋转共存。
这里建议不要用 CLICK 触发拖拽。CLICK 是按下和抬起都完成才会触发,不能用来跟踪连续移动。
3.2 屏幕坐标转世界坐标的几种选择
Cesium 里至少有三种转换方式,这里要区别清楚。
第一种是 viewer.scene.pickPosition(screenPos)。它能把屏幕坐标转换成场景中实际几何体表面的世界坐标,包括模型表面、3D Tiles、地形。但它依赖深度缓冲数据,如果场景没有开启深度拾取,或者拾取点被遮挡,可能返回 undefined。
第二种是 viewer.camera.getPickRay(screenPos) 配合 viewer.scene.globe.pick(ray)。它得到的是地球表面点,包括地形高度,但通常不拾取建筑和模型表面。
第三种是 viewer.scene.pickEllipsoid(screenPos)。它只计算参考椭球面,没有地形高度,适合在平坦区域做简单测试。
我在业务里一般写一个组合函数:
这个函数先尝试拾取模型表面,拾取不到再落到地形上。拖到屋顶、道路、地形时,不容易出现模型飞到地底下。
3.3 平移拖拽完整代码
拖拽平移需要记录三个状态:
- 是否正在拖拽。
- 鼠标按下时的世界坐标。
- 模型拖拽前的原始位置。
每次鼠标移动时,计算当前鼠标点和起始鼠标点的差值,再把原始位置加上这个差值。这样模型不会因为按下瞬间点位和模型中心点不一致而跳变。
这里最关键的是:每次鼠标移动都从 startEntityPosition 重新计算,而不是在当前位置上累加。如果累加,模型会越拖越快,因为误差会持续放大。
还有一个细节:movement.endPosition 是 MOUSE_MOVE 事件提供的参数,不能直接用 movement.position。LEFT_DOWN 里是 movement.position,MOUSE_MOVE 里是 movement.endPosition,容易搞混。
3.4 Primitive Model 的平移写法
如果你加载的是 Model.fromGltfAsync 创建的 Primitive,没有 position 属性。需要自己维护一个坐标变量,并重新生成 modelMatrix。
拖拽时,根据鼠标移动到 origin 的差值,更新 origin,再调用 updateModelMatrix()。这种方式逻辑更封装,适合后续扩展旋转和缩放。
4. 从“能拖”变成“能摆”:旋转、缩放和模式切换
业务场景里,用户不会满足于只平移。很多时候需要把模型转到某个角度,再调整大小。这时候要把功能拆开,用模式切换避免误操作。
4.1 用模式变量区分平移、旋转、缩放
我倾向于不用组合键,而是提供模式按钮,类似编辑器里的“移动工具”“旋转工具”“缩放工具”。但如果你觉得按钮麻烦,也可以这样约定:
- 默认左键拖拽:平移。
- Shift + 左键拖拽:旋转。
- Alt + 滚轮:缩放。
无论哪种方式,核心都是维护一个 mode 状态:
判断模式时,LEFT_DOWN 里的逻辑要分支处理。不要在同一个函数里揉进平移和旋转的逻辑,否则代码很难维护。
4.2 旋转实现:角度增量叠加
旋转不要直接用鼠标坐标赋给 heading。应该记录上一次鼠标位置,计算横向和纵向位移,再按系数转成角度增量。
在 MOUSE_MOVE 里:
系数 0.01 可以根据手感调整。但不要调太大,否则鼠标稍微一偏,模型就转得特别快。建议先固定这个系数,跑通后再加配置项。
4.3 缩放实现:限制边界
缩放最简单的做法是改 entity.model.scale:
这里必须做边界限制。如果 scale 无限缩小,模型可能缩到看不见;无限放大,模型可能把相机包进去。实际项目里,0.1 到 10 这个范围不算通用,需要根据模型尺寸调整。
如果使用 Primitive Model,缩放要更新 modelMatrix。不过要注意:Model 默认以模型自身原点和中心轴为基准缩放。如果模型建模时原点在脚底,通常没问题;如果原点在模型中心,缩放后模型底部会悬空。这是模型锚点问题,不是代码问题。
注意:如果模型本来就不是以场景坐标为基准建模,比如原点偏移很大,拖拽旋转时会发现模型绕着一个看不见的点转。这种情况要先确认模型锚点,不要急着改代码。
4.4 拖拽状态机
三个模式共享同一个 ScreenSpaceEventHandler。不同模式在 LEFT_DOWN、MOUSE_MOVE、LEFT_UP 里的行为不同,所以最好抽成一个状态机,或者至少用 if/else 清晰区分。
基本结构可以是:
不要在一个函数里既改 position 又改 orientation。出问题时,你会不知道是哪个逻辑导致的。
5. 业务场景增强:贴地、吸附、批量模型和特效联动
拖拽变换只是第一步。放到真实项目里,还要处理模型贴地、吸附到建筑表面、批量操作,以及拖动后与雷达扫描、可视域分析、动态光照等效果的联动。
5.1 贴地:自动贴地还是手动采样
很多设备模型需要摆到地表。Entity 的 model.heightReference 可以设为 Cesium.HeightReference.CLAMP_TO_GROUND,让模型自动贴合地形。
但实际使用中,这个属性有一个问题:拖拽时模型位置会不断变化,自动贴地逻辑会不停调整高度,模型容易出现上下跳动。另外,如果场景里有多层地形、建筑模型或 3D Tiles,自动贴地不一定吸到你想要的高度。
我在编辑器场景里一般不用自动贴地,而是拖拽结束后手动采样地形高度:
拖拽结束后调用这个函数,再更新 position。这样拖拽过程中模型自由移动,松手后自动落到地面,体验更稳定。
5.2 吸附到模型或 3D Tiles 表面
如果你希望模型能拖到道路上、建筑物平台上,而不是只贴地形,可以用 viewer.scene.pickPosition 取模型或 Tiles 表面的坐标,直接把新位置设为拾取结果。
这需要开启深度测试:
开启后,地形和模型表面会参与深度判断。鼠标落到屋顶时,pickPosition 能返回屋顶表面坐标。这个效果很实用,但要注意:拾取结果可能因为相机角度和遮挡关系产生跳变,拖拽到边缘时要做好阈值判断。
5.3 批量模型拖拽
当场景里有多个可拖拽模型时,需要维护一个可拖拽列表。最简单的方式是给 Entity 加一个自定义标识:
拖拽时,在 LEFT_DOWN 里判断:
批量场景要特别注意:多个模型拖拽结束后,需要统一保存位置和朝向。如果只是单个模型,直接在回调里处理就行;如果是批量编辑器,最好把每个物体的 position、orientation、scale 组成一个配置对象,方便序列化和回放。
5.4 与雷达扫描、可视域分析、动态光照等效果联动
很多 Cesium 项目里,拖拽变换不是孤立功能。模型移动后,雷达扫描、可视域分析、动态光照等效果都要同步更新。
雷达扫描效果通常以模型位置为圆心。模型拖到新位置后,需要更新扫描中心坐标,再重新生成扫描范围。如果你把扫描效果做成独立函数,并把圆心作为参数传入,拖拽结束后重新调用一次即可。
可视域分析要同时依赖位置和朝向。旋转模型后,可视锥的方向也要跟着转。这就意味着不能只保存 position,还要保存 hpr。
动态光照和模型阴影相对复杂一些。如果场景里的光线方向固定,模型移动后阴影方向不变,但阴影位置会变化。如果模型自带有动态材质,移动到新位置后可能需要刷新材质参数或触发一次场景更新。
如果你在项目里还用了 three.js 和 Cesium 共享 GL 上下文,这里要格外谨慎。Cesium 负责大场景和地理底图,three.js 负责精细模型或特殊效果。拖拽事件改变 Cesium 模型坐标后,需要通过一个中间层同步给 three.js 场景对象,否则两套场景的模型位置会不一致。这个方案能做,但调试成本高,建议先确认业务是否真的需要。
6. 常见坑和排查顺序:拖拽为什么会漂、为什么卡
最后整理几个我实测中经常遇到的问题。这些问题看起来都是“拖拽不生效”,但根因完全不同。
6.1 模型松手后跳回原位
出现这个现象,通常不是事件没触发,而是 MOUSE_MOVE 里没有使用拖拽前的原始位置。每次移动都用当前模型位置去累加,一旦中间漏掉一帧,位置就不对了。
排查顺序:
- 打印
isDragging是否在抬起后正常置为 false。 - 打印
startEntityPosition是否在 LEFT_DOWN 时被正确记录。 - 确认 MOUSE_MOVE 里用的是
startEntityPosition + delta,而不是entity.position + delta。 - 检查是否在渲染循环里给 entity.position 赋了旧值。
6.2 模型被拖到地底下或飞得很远
这是坐标系转换的问题。最常见的是 getPickPosition 返回了椭球面点,而不是地形点。在山区或模型放在有一定高度的位置时,鼠标划过地形,用 pickEllipsoid 会得到一个没有高度的点,模型自然会被拉得不准。
排查顺序:
- 打印每次拾取得到的 Cartesian3 和模型当前 position,对比高度。
- 确认使用的是
globe.pick,而不是pickEllipsoid。 - 确认
depthTestAgainstTerrain是否打开,以及是否影响拾取结果。 - 缩小相机与模型的夹角,看问题是否和视角有关。
6.3 拖拽过程卡顿
常见的卡顿原因不是 Cesium 渲染本身,而是 MOUSE_MOVE 里频繁调用 viewer.scene.pick。每一次 pick 都会遍历场景对象,而且你如果每帧都做深度拾取,性能开销会很大。
优化方向:
- 只在 LEFT_DOWN 时做一次
scene.pick,移动过程中不再拾取模型。 - 移动过程中,用固定一个物体,不要每次重新判断鼠标指向谁。
- 如果场景里模型面数高,拖拽期间暂时关闭阴影,松手后再恢复。
- 为拖拽目标设置最小屏幕尺寸,避免模型太小时被误拾取。
6.4 旋转时模型绕错轴
模型绕错轴,通常是两个原因:一是 heading/pitch/roll 与模型自身的本地坐标轴不对应;二是模型导出时没有把 X、Y、Z 轴和场景预期对齐。
排查方式:
- 先设置一个很明显的 heading 角度,比如 90 度,看模型是绕哪个轴转。
- 确认模型在建模工具里的朝前方向是哪个轴。
- 如果模型默认朝向和 Cesium 的正北方向不一致,在模型制作环节先修正,或者在加载时乘一个初始旋转矩阵。
- 不要在拖拽代码里强行补偿模型轴向,那样只会越修越乱。
6.5 通用排查顺序清单
| 现象 | 优先查看项 |
|---|---|
| 模型不跟随鼠标 | 事件是否触发、isDragging 状态是否正确 |
| 跟随但位置偏移 | 起始点记录、getPickPosition 的返回点 |
| 拖动时跳动、闪烁 | position 重复赋值、heightReference、渲染循环 |
| 旋转乱转 | 模型锚点、hpr 转换、本地坐标轴 |
| 拖拽卡顿 | pick 频次、模型面数、阴影、瓦片加载 |
| 无法拾取 | 模型被遮挡、pickPositionSupported、深度测试 |
排查问题的时候,不要先从参数下手。先把拖拽状态打印出来,确认事件流没有断,再去看坐标。我在项目里会把 isDragging、currentMode、startPickCartesian 直接打印到控制台。大多数拖拽问题,都在这个阶段就能定位到根因。
Cesium 的三维模型拖拽变换,做得再花哨,核心还是鼠标事件、坐标转换、姿态更新这三件事。我做编辑器功能时最深刻的感受是:不要急着把平移、旋转、缩放、吸附、贴地一次性做完,先把一个模型的拖拽跑通,再把每个模式拆成独立函数,最后才谈批量。这样即使后续换模型格式、换底图、加功能,都不会把问题全搅在一起。如果遇到模型不跟随或者偏移,先打印坐标,不要盲改参数。