公司动态
Cesium 模型拖拽变换实战:从坐标转换到矩阵实现
在 Cesium 三维场景中模型拖拽变换是 GIS 编辑器、数字孪生和室内外一体化工具里非常常见的需求。很多开发者一开始会觉得“让模型跟随鼠标移动”只是修改一个 position 而已真正动手后才发现鼠标坐标是二维的模型位于三维地球表面中间隔着屏幕射线、地球坐标系、模型矩阵和姿态四元数。只要这些环节有一个没对齐拖拽就会出现模型乱跳、方向错乱、旋转后平移失效等问题。本文从 Cesium 的坐标体系和模型矩阵讲起逐步实现一个可复用、可扩展的模型拖拽变换工具。完整代码会覆盖拖拽平移、键盘旋转缩放、事件生命周期管理并给出常见的排查清单。读完以后你可以把这段逻辑集成到自己的 Vue、React 或原生 JavaScript 项目中也可以继续扩展出吸附、辅助手柄、撤销重做等功能。1. 先理解 Cesium 中的模型变换与坐标体系1.1 三维模型不是一个“点”而是一组顶点加一个矩阵Cesium 中的 glTF / GLB 模型拥有自己的局部坐标系。模型文件里每个顶点的坐标通常围绕原点分布比如一个建筑物的顶点范围可能是(-50, 0, -50)到(50, 0, 50)。这套坐标与经纬度没有任何关系它只是模型的“建模空间”。要让模型出现在北京的某个位置Cesium 需要把局部坐标变换到地球的世界坐标。这个变换由一个 4x4 矩阵统一完成。这个矩阵就是 ModelMatrix。它通常包含平移模型中心放在哪个世界坐标点旋转模型以什么姿态朝向东西南北缩放模型整体放大或缩小多少倍。所以“拖拽变换模型”本质上不是改一个属性而是维护好这个模型矩阵。如果只改位置不改姿态矩阵内部的旋转部分会丢失模型可能会突然“躺倒”或“转向”。Entity中的模型其实只是一个更高层的封装。Cesium 在底层仍然会用ModelMatrix把模型渲染出来。你写entity.position newPositionCesium 会自动帮助你更新模型矩阵但如果你想同时控制旋转和缩放最好弄清楚矩阵是怎么组合出来的。下表列出了本文会用到的核心 Cesium 类型类型含义拖拽变换中的用途Cesium.Cartesian3世界坐标系中的三维点表示模型位置、射线方向、平面法线Cesium.Matrix44x4 矩阵组合模型的位置、姿态、缩放Cesium.Transforms.eastNorthUpToFixedFrame生成东北上局部坐标到世界坐标的矩阵根据经纬度高度生成一个“正立”的模型矩阵Cesium.HeadingPitchRoll航向、俯仰、横滚角描述模型朝向Cesium.ScreenSpaceEventHandler屏幕空间事件处理器监听鼠标点击、移动、释放Cesium.Ray从相机出发的一条射线把鼠标屏幕坐标转换成三维空间方向1.2 拖拽操作在屏幕、相机和地球坐标之间如何转换鼠标在场景中移动时你拿到的movement.endPosition是一个二维屏幕坐标单位是像素。二维坐标本身不包含深度信息所以不能直接当作三维位置。Cesium 的做法是从相机位置camera.position出发经过鼠标的屏幕坐标生成一条射线Ray。射线的方向经过相机投影矩阵反算得到。如果鼠标点击在地球椭球上可以使用camera.pickEllipsoid得到射线与椭球的交点如果场景中有地形和模型可以使用scene.pickPosition获得有深度检测的世界坐标如果希望模型在某个平面或曲面上移动需要自己构造参考面并用Cesium.IntersectionTests.rayPlane求交点。拖拽链路可以总结为屏幕坐标 - 相机射线 - 与交互平面求交 - 三维世界坐标 - 更新模型矩阵每次鼠标移动这条链路都会从头执行一遍。只要链路中某一个环节不稳定比如用了地形点导致高度跳动模型就会表现出“抖动”或“不跟手”。1.3 为什么拖拽前必须先选择一个交互平面很多人会把“拖拽模型到地形表面”误写成“每次鼠标移动时取地形交点”。这确实可以实现但会产生两个问题地形表面高低不平模型会随着坡度上下跳动而不是平滑地水平移动如果模型在室内、地下或空中的场景中取地形交点会让模型瞬移回地面。更通用的做法是在拖拽开始时建立一个隐式的交互平面。大多数编辑场景希望模型保持当前高度在一个水平面上移动这时可以构造一个“过模型起点、法线指向地心”的平面。鼠标移动时射线与这个平面求交得到的新位置高度变化很小模型既平滑又可控。注意不要直接使用地形点作为拖拽目标点除非你的产品明确要求模型必须贴地移动。否则请使用固定高度的交互平面可以让拖拽手感稳定很多。2. 搭建环境并准备一个可操作的最小场景2.1 环境与依赖准备本文示例使用原生 HTML JavaScript 方式运行便于聚焦 Cesium 的 API。实际项目中使用 Vue 或 React 也不影响核心逻辑只是需要额外管理 Viewer 的创建与销毁。建议环境如下项目建议值说明Cesium 版本1.100 及以上本文示例基于该 API 编写浏览器Chrome / Edge 最新版需要支持 WebGL模型格式glTF / GLB建议 GLB单文件更好管理底图本地瓦片或 Cesium Ion如果离线环境需提前准备离线底图Cesium 的引入方式有两种使用 npm 安装并在构建工具中import * as Cesium from cesium使用官方提供的Cesium.js脚本文件和Widgets/widgets.css。本文采用第二种方式因为你只需要在一个 HTML 文件中写完拖拽功能方便直接复制验证。2.2 创建只保留三维场景的 Viewer创建一个干净的三维容器关闭帮助按钮、时间轴、动画控件等默认 UI避免拖拽过程中被其他控件干扰。!DOCTYPE html html langzh-CN head meta charsetUTF-8 titleCesium 模型拖拽变换/title style html, body, #cesiumContainer { width: 100%; height: 100%; margin: 0; padding: 0; overflow: hidden; } /style script src./Cesium/Cesium.js/script link href./Cesium/Widgets/widgets.css relstylesheet /head body div idcesiumContainer/div script const viewer new Cesium.Viewer(cesiumContainer, { animation: false, baseLayerPicker: false, geocoder: false, timeline: false, sceneModePicker: false, navigationHelpButton: false, fullscreenButton: false }); viewer.scene.globe.depthTestAgainstTerrain true; /script /body /html这里把depthTestAgainstTerrain设为true是为了让后续射线拾取更符合直觉。如果关闭模型可能被地形遮挡时仍然能被看到不利于拖拽定位。2.3 添加一个测试模型用一个简单的 Entity 模型代表需要拖拽的对象。模型路径可以是你本地的.glb文件也可以是 Cesium 官方示例模型。如果使用本地文件注意浏览器需要能通过网络访问到该文件不能直接写本地绝对路径。const modelEntity viewer.entities.add({ position: Cesium.Cartesian3.fromDegrees(116.391, 39.907, 0), model: { uri: ./models/cesium_man.glb } }); viewer.zoomTo(modelEntity);加载模型后可以先调整相机到合适视角。zoomTo会直接飞行到实体附近。如果不想加载外部模型也可以使用 Cesium 内置的BoxGeometry配合Primitive测试。但 Primitive 不是模型无法展示“模型矩阵”的旋转语义。因此建议准备一个真实 GLB 文件。3. 实现拖拽平移让模型平滑跟随鼠标移动3.1 事件流设计拖拽的本质是“按下——移动——释放”三个阶段。Cesium 的ScreenSpaceEventHandler提供了对应事件类型。事件触发时机当前阶段应该做什么LEFT_DOWN鼠标左键按下拾取模型记录初始位置创建交互平面禁用相机控制MOUSE_MOVE鼠标移动射线与交互平面求交更新模型位置和姿态LEFT_UP鼠标左键释放清理拖拽状态恢复相机控制在LEFT_DOWN中禁用screenSpaceCameraController.enableInputs非常重要。如果不禁用拖拽模型的同时会触发相机旋转或缩放模型会像“溜走”一样不跟手。3.2 射线与拖拽平面求交的算法拖拽开始时模型中心在世界坐标下的位置记为startPoint。这个点位于地球椭球表面上方向可以认为它的法线方向就是startPoint归一化后的向量。构造平面const normal Cesium.Cartesian3.normalize(startPoint, new Cesium.Cartesian3()); const dragPlane Cesium.Plane.fromPointNormal(startPoint, normal);鼠标移动时使用camera.getPickRay得到屏幕坐标对应的射线然后与平面求交点const ray viewer.camera.getPickRay(movement.endPosition); const intersection Cesium.IntersectionTests.rayPlane(ray, dragPlane);如果intersection存在它就是模型的新位置。由于平面是水平的位置的高度基本保持不变。3.3 完整实现代码下面给出一个完整的可运行示例。该示例实现了点击模型后拖拽平移并在拖拽过程中保持模型正立朝向。// 假设 viewer 和 modelEntity 已经在前面创建 let dragging false; let pickedEntity null; let startPoint null; let dragPlane null; const handler new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas); handler.setInputAction(function (movement) { const picked viewer.scene.pick(movement.position); if (Cesium.defined(picked) Cesium.defined(picked.id) picked.id modelEntity) { dragging true; pickedEntity modelEntity; // 获取模型当前世界坐标 startPoint pickedEntity.position.getValue(viewer.clock.currentTime); // 构造水平交互平面法线方向指向地心 const normal Cesium.Cartesian3.normalize(startPoint, new Cesium.Cartesian3()); dragPlane Cesium.Plane.fromPointNormal(startPoint, normal); // 拖拽期间禁用相机控制 viewer.scene.screenSpaceCameraController.enableInputs false; } }, Cesium.ScreenSpaceEventType.LEFT_DOWN); handler.setInputAction(function (movement) { if (!dragging || !dragPlane) return; const ray viewer.camera.getPickRay(movement.endPosition); if (!ray) return; const intersection Cesium.IntersectionTests.rayPlane(ray, dragPlane); if (!Cesium.defined(intersection)) return; // 更新模型位置 pickedEntity.position intersection; // 保持模型正立方位角为 0俯仰角为 0横滚角为 0 const hpr new Cesium.HeadingPitchRoll(0, 0, 0); pickedEntity.orientation Cesium.Transforms.headingPitchRollQuaternion( intersection, hpr ); }, Cesium.ScreenSpaceEventType.MOUSE_MOVE); handler.setInputAction(function () { dragging false; pickedEntity null; dragPlane null; viewer.scene.screenSpaceCameraController.enableInputs true; }, Cesium.ScreenSpaceEventType.LEFT_UP);这段代码有几个关键点picked.id modelEntity用于判断是否拾取到了我们的模型实体。pickedEntity.position intersection直接把Cartesian3赋值给 Entity 的 position 属性Cesium 会隐式包装成ConstantPositionProperty。每次移动都重新计算 headingPitchRoll 为 0可以让模型在拖拽过程中保持“正立”。如果你的模型本身有初始姿态需要保存初始朝向而不是直接归零。LEFT_UP中把enableInputs恢复为true否则拖拽一次后场景将无法旋转。3.4 验证拖拽效果运行示例后点击模型按住鼠标左键移动模型应该在一个近似水平的平面上跟随鼠标移动。松手后模型停留在新位置。可以通过在MOUSE_MOVE中加入日志来观察坐标变化const cartographic Cesium.Cartographic.fromCartesian(intersection); const lon Cesium.Math.toDegrees(cartographic.longitude); const lat Cesium.Math.toDegrees(cartographic.latitude); const height cartographic.height; console.log(当前经度: ${lon.toFixed(5)}, 纬度: ${lat.toFixed(5)}, 高度: ${height.toFixed(2)});如果打印的 height 在拖拽过程中基本不变说明交互平面工作正常。如果 height 变化很大说明你的dragPlane法线方向选错了或者鼠标射线与平面求交时的平面坐标系处于非预期状态。4. 扩展变换旋转、缩放与姿态控制4.1 在局部坐标系中叠加旋转和缩放Entity 方案的position和orientation适合简单平移。但如果你希望模型在保持当前高度和位置的情况下绕自身中心旋转或缩放更合适的方式是直接操作模型矩阵。先回顾矩阵合成顺序。对于 Cesium 模型常用的组合方式是modelMatrix ENU(position) * localTransform其中ENU(position)是Transforms.eastNorthUpToFixedFrame(position)它把世界原点移动到 position 处并建立东北上方向的局部坐标系。localTransform是模型在局部坐标系内的旋转和缩放矩阵。这样可以保证无论模型如何旋转缩放它始终在世界坐标 position 处展示且朝向始终相对东北上方向定义。// 假设已经创建了一个 Cesium.Model 或 ModelPrimitive const position Cesium.Cartesian3.fromDegrees(116.391, 39.907, 20); // 局部旋转绕 Z 轴旋转 45 度 const hpr new Cesium.HeadingPitchRoll( Cesium.Math.toRadians(45), 0, 0 ); const localRotation Cesium.Matrix3.fromHeadingPitchRoll(hpr); const localTransform Cesium.Matrix4.fromRotationTranslation( localRotation, Cesium.Cartesian3.ZERO ); // 合成最终模型矩阵 const enuMatrix Cesium.Transforms.eastNorthUpToFixedFrame(position); const modelMatrix Cesium.Matrix4.multiply( enuMatrix, localTransform, new Cesium.Matrix4() ); modelPrimitive.modelMatrix modelMatrix;注意矩阵乘法顺序不能反过来。Cesium.Matrix4.multiply(a, b)的结果世界坐标是a * b * vertex。如果你把localTransform放在左边旋转会先作用于世界坐标导致模型位置发生不可预期的偏移。4.2 用键盘快捷键控制旋转和缩放为了不让交互过于复杂可以使用键盘快捷键来调整姿态。下面这个示例监听keydown事件以每帧 1 度的速度绕 Z 轴旋转以每帧 0.02 的倍率缩放。let localRotationAngle 0; let localScale 1; function updateModelMatrix() { const position Cesium.Cartesian3.fromDegrees(116.391, 39.907, 20); const hpr new Cesium.HeadingPitchRoll( Cesium.Math.toRadians(localRotationAngle), 0, 0 ); const rotation Cesium.Matrix3.fromHeadingPitchRoll(hpr); const scale Cesium.Matrix4.fromUniformScale(localScale); const rotationScale Cesium.Matrix4.fromRotationTranslation(rotation, Cesium.Cartesian3.ZERO); const localTransform Cesium.Matrix4.multiply(rotationScale, scale, new Cesium.Matrix4()); const enuMatrix Cesium.Transforms.eastNorthUpToFixedFrame(position); const modelMatrix Cesium.Matrix4.multiply(enuMatrix, localTransform, new Cesium.Matrix4()); modelPrimitive.modelMatrix modelMatrix; } document.addEventListener(keydown, function (event) { if (event.key q || event.key Q) { localRotationAngle 1; updateModelMatrix(); } else if (event.key e || event.key E) { localRotationAngle - 1; updateModelMatrix(); } else if (event.key r || event.key R) { localScale 0.02; updateModelMatrix(); } else if (event.key f || event.key F) { localScale - 0.02; updateModelMatrix(); } });这个逻辑把“旋转”、“缩放”和“平移”解耦平移修改position旋转修改localRotationAngle缩放修改localScale。每次重新合成modelMatrix不会造成一个维度干扰另一个维度。4.3 Entity 模型与 Primitive 模型的拖拽差异上面的示例直接使用modelPrimitive.modelMatrix。但很多项目中使用的是viewer.entities.add({ model: ... })。这两种方式的主要差异如下维度Entity 模型Primitive 模型API 层级高层封装简单易用底层渲染对象灵活位置设置entity.positionmodel.modelMatrix姿态设置entity.orientationmodel.modelMatrix拾取方式scene.pick返回picked.id为 Entityscene.pick返回picked.primitive为 Model适合场景少量模型、快速开发需要精细控制、批量渲染或与矩阵深度集成如果你已经使用 Entity 模型可以在拖拽平移后读取模型的modelMatrix再叠加旋转缩放矩阵。例如const modelPrimitive modelEntity.model; const currentMatrix modelPrimitive.modelMatrix; const enuMatrix Cesium.Transforms.eastNorthUpToFixedFrame(newPosition); const newMatrix Cesium.Matrix4.multiply(enuMatrix, localTransform, new Cesium.Matrix4()); modelPrimitive.modelMatrix newMatrix;这样既保留了 Entity 的拾取便捷性又获得了矩阵级的控制能力。5. 常见问题管理与排查清单5.1 拾取不到模型现象点击模型没有任何反应scene.pick返回undefined。可能原因模型没有真正渲染完成点击发生在模型加载完成之前模型show被设置为false鼠标点击位置没有命中模型的包围盒或三角网格相机距离太远模型在屏幕上只有几个像素。排查方式在模型加载完成回调中输出日志确认模型已就绪使用viewer.scene.pick点击模型中间位置放大相机视角后再点击使用scene.drillPick检查是否有多个对象叠加。处理建议在LEFT_DOWN中使用容错拾取const picked viewer.scene.pick(movement.position); const pickedObjects viewer.scene.drillPick(movement.position);如果picked为空可以遍历pickedObjects寻找与目标模型对应的对象。5.2 模型拖拽时“不跟手”或漂移现象鼠标移动速度很快时模型明显滞后或者模型上下跳动。可能原因使用了scene.pickPosition但深度缓冲未生效使用地形点作为目标点拖拽过程中相机动了交互平面选择错误。排查方式检查是否在拖拽开始时禁用了screenSpaceCameraController.enableInputs检查MOUSE_MOVE中是否每次都使用movement.endPosition在日志中打印 new position 与鼠标位置的关系。处理建议优先使用“固定法线方向的平面”配合射线求交而不是每次拾取地形点。拖拽开始时记录startPoint并在整个拖拽期间复用同一个平面。5.3 旋转后拖拽方向错乱现象给模型设置了旋转角后再拖拽模型模型会绕着旋转后的坐标轴移动而不是沿着屏幕方向移动。可能原因在MOUSE_MOVE中直接写entity.orientation headingPitchRollQuaternion(0,0,0)把旋转后的姿态重置了或者在设置entity.position时没有同步更新modelMatrix导致渲染姿态与碰撞姿态不一致或者把旋转写进了ModelMatrix的位置部分而不是局部变换部分。处理建议将旋转和缩放保存在独立的局部矩阵中拖拽时只更新世界位置每次重新合成modelMatrix ENU(position) * localTransform这样所有维度始终使用同一个矩阵源不会出现方向混乱。5.4 离线环境下模型不显示现象代码在联网环境能跑通放到内网后模型区域空白。可能原因模型文件使用了外网 CDN 地址Cesium.Ion 的默认底图需要联网浏览器控制台出现跨域或 404 错误。排查方式打开浏览器开发者工具查看 Network 面板确认模型请求是否成功确认模型文件是否放在同域目录下确认底图是否替换为本地瓦片或baseLayerPicker已关闭。处理建议将.glb文件放在项目静态资源目录下使用相对 URL。如果不需要底图可以在创建 Viewer 时关闭默认底图const viewer new Cesium.Viewer(cesiumContainer, { baseLayer: false });注意离线环境不只影响模型文件还可能影响地形、影像和 CesiumJS 自身的资源文件。生产环境如果要求完全离线建议提前把 Cesium 静态资源与瓦片一起部署到本地。5.5 3D Tiles 拖拽时整体无效现象对 3D Tiles 做拖拽模型不移动或只移动了某个子节点。可能原因3D Tiles 的坐标系不一致。瓦片集内部使用自己的局部坐标系tileset.modelMatrix在较新的 Cesium 版本中已经逐步被移出核心 API。直接修改瓦片树根节点的transform又容易造成整个瓦片集重新加载产生闪烁。处理建议普通业务中不要直接对 3D Tiles 做“编辑器式拖拽”。如果产品确实需要移动整个倾斜摄影模型建议在加载后记录tileset.root.transform基于原始变换叠加偏移矩阵。即使如此也要做好瓦片裁剪、包围球更新和性能验证。5.6 可复用的拖拽变换自检清单在把拖拽功能提交到测试环境前可以按下面清单快速核对检查项预期结果点击模型能否选中scene.pick能返回目标对象拖拽过程中相机是否被禁用相机不会因为鼠标移动而旋转模型高度是否保持稳定Cartographic.height基本不变旋转后拖拽方向是否正确模型沿屏幕方向移动不出现斜飞松开鼠标后是否停止移动不再触发位置更新连续拖拽多次是否正常每次点击都能重新选中模型页面销毁时事件是否释放页面切走或关闭时不报内存泄漏6. 生产环境最佳实践与扩展方向6.1 拖拽手感需要从细节里优化“能用”和“好用”之间差很多细节。拖拽开始时模型中心不一定正好在鼠标点击位置。如果直接把模型中心移动到鼠标射线交点模型会瞬间“跳”到鼠标下面体验很突兀。正确做法是记录一个偏移量const startPosition entity.position.getValue(time); const startIntersection intersection; // LEFT_DOWN 时射线与平面的交点 const offset Cesium.Cartesian3.subtract(startPosition, startIntersection, new Cesium.Cartesian3());在 MOUSE_MOVE 时新位置等于新交点加上这个偏移量。这样模型在拖拽开始瞬间不会跳动。另外鼠标移动过程中的射线求交会产生大量计算但 Cesium 本身做了很好的优化。对于普通数量的模型直接在MOUSE_MOVE中同步更新矩阵没有问题。如果场景中有上千个实体建议使用 throttle 限制更新频率或者在相机停止时才刷新包围球。6.2 与 Vue / React 集成时的注意事项前端框架项目中最常见的错误是每次数据更新都重新创建Cesium.Viewer。这会导致 WebGL 上下文丢失、事件重复绑定、内存泄漏。推荐做法在组件mounted或useEffect中创建 Viewer 和拖拽 handler将 Viewer 实例保存在ref或useRef中在组件卸载前调用handler.destroy()和viewer.destroy()不要把整个 Cesium 实例放在 Vue 的响应式对象里除非你明确知道会失去 WebGL 上下文。如果你需要在 Vue 中监听模型位置变化不要使用 Vue 的响应式系统去包裹Cartesian3。可以把新坐标转换成普通对象后放入 store再通过图层或事件去驱动其他 UI。6.3 状态历史与撤销重做编辑器类应用最终都会碰到撤销重做。最简单的方式是在每次LEFT_UP时记录一次模型矩阵快照function pushHistory() { historyStack.push(currentMatrix.clone()); if (historyStack.length 50) historyStack.shift(); } function undo() { if (historyStack.length 0) return; const previousMatrix historyStack.pop(); applyMatrix(previousMatrix); }注意矩阵需要clone()不要直接保存引用。否则后续所有修改都会改变同一个对象历史记录就失去了意义。6.4 从拖拽变换延伸出去的进阶方向拖拽变换是三维交互的基础能力。在此基础上可以继续扩展坐标吸附拖拽过程中如果模型靠近另一个模型、道路线或地形特征点自动吸附到该点辅助手柄在模型中心显示 X/Y/Z 箭头和圆环通过点击箭头沿轴向平移点击圆环绕轴旋转多模型联合拖拽选中多个模型后统一计算包围球中心将所有模型作为一个整体拖拽模型编辑与 Cesium Three.js 结合如果需要在 Cesium 场景中做更精细的网格编辑可以研究 Cesium 与 Three.js 共享 WebGL 上下文但复杂度会明显上升建议先评估是否真的需要离线部署与本地瓦片在内外网隔离的生产环境中把 CesiumJS、模型文件和离线瓦片统一部署到同一套静态资源服务避免运行时出现跨域或加载失败。模型拖拽变换看似简单背后却串联了 Cesium 的坐标转换、矩阵运算、射线求交和事件管理。掌握这套实现思路后无论是做地图标注位置调整还是做三维场景编辑工具都可以基于同样的骨架快速扩展。对于初学者建议先用本文的 Entity 方案跑通一次最小闭环再逐步切换到 Primitive 和矩阵方案然后加入旋转、缩放、历史记录和吸附能力。每个阶段都要用真实模型在真实场景中验证避免只在代码层面“觉得正确”。