首页 / 资讯中心 / 文章详情

Cesium鼠标点击获取经纬度:屏幕坐标到地理坐标的完整实现

Cesium鼠标点击获取经纬度:屏幕坐标到地理坐标的完整实现 ★ FEATURED ARTICLE
做Cesium开发的朋友十有八九上手要写的第一个交互功能就是鼠标点一下把那个位置的经纬度拿回来。这个需求听着简单但真正动手时会发现从鼠标在屏幕上的一个像素点到地球表面某个经纬度坐标中间隔着一整套三维坐标转换逻辑。很多人第一次写的时候照着文档抄了两三行代码就跑通了但换个场景——比如点击地形、点击3D Tiles、在移动端触摸——就立刻翻车。这篇文章就把Cesium获取鼠标单击位置经纬度的完整链路讲透从事件监听到坐标解算从基础写法到各种场景的变体再把我实际项目中踩过的坑和排查思路一并整理出来。适合刚接触Cesium的开发者照着抄也适合已经写了一段时间但没深究过底层逻辑的人查漏补缺。1. 整体设计思路从鼠标点一下到经纬度的距离很多人第一次接触这个功能时会觉得Cesium应该直接提供一个API比如map.onClick(callback)然后回调里直接给经纬度。但实际上Cesium的设计不是这样的它把键盘鼠标事件和三维坐标拾取拆成了两个独立的部分你需要自己把它们串起来。1.1 坐标系的转换链条Cesium里的坐标系大致分这么几层屏幕坐标就是鼠标在canvas画布上的像素位置用像素值表示左上角是原点x轴向右y轴向下。世界坐标Cartesian3以地球中心为原点的三维直角坐标单位是米。Cesium内部所有三维运算都基于这个坐标系。地理坐标Cartographic经度、纬度、高度单位是弧度。这是人和GIS系统都能直接理解的数据。经纬度度数把弧度转成角度的结果也就是平时说的东经116.4度、北纬39.9度这种直观表达。整条链路的转换方向是鼠标屏幕坐标像素 ↓ viewer.camera.pickEllipsoid / pickPosition Cartesian3 世界坐标米 ↓ Cesium.Cartographic.fromCartesian Cartographic 弧度坐标lon, lat, height ↓ Cesium.Math.toDegrees 经纬度度这个链路里最容易出问题的就是第一步从屏幕坐标到世界坐标。因为屏幕上的一个点理论上对应的是从相机位置发射出去的一条射线上的无数个点你必须明确告诉Cesium你要这条射线和什么东西求交。是地球椭球体是地形是某个模型还是某个Entity1.2 为什么要用pickEllipsoidCesium提供了好几个从屏幕坐标求世界坐标的API最常用的有三个API求交对象特点camera.pickEllipsoid(position, ellipsoid)椭球体表面忽略地形和模型永远返回与球面的交点速度快拿不到高程scene.pickPosition(position)渲染场景的深度缓冲返回实际可见表面的坐标包含地形、模型、3D Tiles的高程但需要开启某些条件scene.globe.pick(ray, scene)地球表面含地形返回贴着地形表面的坐标含地形高程但需要穿过地形拾取pickEllipsoid是这里面的基础款它只跟椭球体求交。很多新手不理解为什么文档里的经典示例都用它因为在一个没有地形、没有模型的基础场景里你看到的地球表面就是椭球体本身用pickEllipsoid拿到的坐标就是鼠标指向地球表面时那个点的位置逻辑最纯粹也最不容易出错。它内部做的事情其实就是从相机位置构建一条穿过鼠标屏幕点的射线然后和椭球体做射线求交的数学运算。这个运算不涉及GPU、不读深度缓冲所以性能极好即使每帧调用都毫无压力。注意这个API的名字有点迷惑性。它虽然写在camera下面但和相机选点没太大关系本质上就是一个纯粹的数学求交函数。传入的scene.globe.ellipsoid参数指明要和哪个椭球体求交。2. 核心实现细节事件、拾取、转经纬度理解了坐标转换的链路之后正式代码其实只有三步注册鼠标事件、拾取世界坐标、转换成经纬度。每一步都有值得展开讲的细节。2.1 ScreenSpaceEventHandler事件体系Cesium的鼠标事件统一由ScreenSpaceEventHandler管理。它和你在普通网页里用addEventListener绑事件不太一样是Cesium自己封装的一套事件分发机制直接挂在viewer.scene.canvas这个canvas元素上。const handler new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas); handler.setInputAction(function (movement) { // movement.position 就是鼠标点击处的屏幕坐标 console.log(movement.position); }, Cesium.ScreenSpaceEventType.LEFT_CLICK);需要注意的几个点ScreenSpaceEventType.LEFT_CLICK是单击左键。对触摸设备Cesium会自动把它映射成单指轻点所以触摸屏上它同样生效。回调参数movement里左键单击事件携带的是position属性不是startPosition或endPosition。拖拽事件才有这两个属性。我见过有人把拖拽事件的字段用在单击事件里结果拿到的永远是undefined。setInputAction可以用同一个handler设置多个不同事件类型互不干扰。事件回调里尽量不要做重活比如DOM操作、大数据遍历。因为Cesium的交互本身就在主线程上回调里卡顿会直接影响旋转、缩放的手感。2.2 Cartesian3转Cartographic再转经纬度拿到屏幕坐标之后先求世界坐标再转成弧度制的地理坐标handler.setInputAction(function (movement) { // 第一步屏幕坐标 - 世界坐标 const cartesian viewer.camera.pickEllipsoid( movement.position, viewer.scene.globe.ellipsoid ); // 第二步判断是否拾取成功 if (!cartesian) { return; } // 第三步世界坐标 - 弧度制地理坐标 const cartographic Cesium.Cartographic.fromCartesian(cartesian); // 第四步弧度 - 度数 const longitude Cesium.Math.toDegrees(cartographic.longitude); const latitude Cesium.Math.toDegrees(cartographic.latitude); console.log(经度${longitude.toFixed(6)}纬度${latitude.toFixed(6)}); }, Cesium.ScreenSpaceEventType.LEFT_CLICK);这里有两个关键点值得停下来细说。第一Cesium.Cartographic.fromCartesian(cartesian)返回的longitude和latitude是弧度值。如果不转直接往外输出你看到的会是0.7、1.2这种数字完全不知道是哪个地方。所有新手都栽过这个坑。Cesium.Math.toDegrees就是干这个的toDegrees函数内部就是乘了180 / Math.PI你也可以自己写cartographic.longitude * 180 / Math.PI效果完全一样。第二movement.position的坐标系原点在canvas左上角单位是设备像素包含了CSS像素和devicePixelRatio的换算。Cesium内部在拾取时已经帮你处理了viewer.resolutionScale和像素比的问题所以你在高DPI屏幕上拿到的坐标也是准确的不用自己手动换算。2.3 顺便把高度也拿回来Cartographic结构体里第三个字段是height——椭球体高度也就是通常说的海拔。当你用pickEllipsoid拾取时这个高度恒为0因为点就是求交在椭球体表面上的。不过在实际项目中很多时候你需要的不仅仅是二维经纬度还要知道那个位置的海拔。这时候你就不能用pickEllipsoid了因为地形起伏已经被它忽略了。正确的做法是用scene.pickPosition或者scene.globe.pick这两个能拿到真实地形表面的高程值。// 方式一从深度缓冲拾取 const cartesian viewer.scene.pickPosition(movement.position); if (cartesian) { const cartographic Cesium.Cartographic.fromCartesian(cartesian); console.log(经度${Cesium.Math.toDegrees(cartographic.longitude)}); console.log(纬度${Cesium.Math.toDegrees(cartographic.latitude)}); console.log(海拔${cartographic.height.toFixed(2)} 米); }用pickPosition有个前置条件viewer必须开启scene.globe.depthTestAgainstTerrain true否则它拿到的高程是椭球面上的不是地形上的。这个属性默认是false就是为了保证即使相机穿到地底下也能看到地面以下的场景内容。这个取舍后面在问题排查部分还会细讲。3. 完整实操从基础写法到项目级用法光会拿经纬度还不够真实项目里你还会遇到点击实体移动端触摸连续采集坐标点这些需求。这一节给出几套可以直接改改就用的完整代码。3.1 基础版本一个完整的拾取工具类我习惯把坐标拾取封装成一个独立的小模块项目里任何地方都可以复用class GeoPicker { constructor(viewer) { this.viewer viewer; this.handler new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas); this._onPickCallback null; } // 开启单击拾取传入回调函数 activate(callback) { this._onPickCallback callback; this.handler.setInputAction((movement) { const result this._pickFromScreen(movement.position); if (result this._onPickCallback) { this._onPickCallback(result); } }, Cesium.ScreenSpaceEventType.LEFT_CLICK); } // 内部统一的拾取逻辑 _pickFromScreen(screenPosition) { const cartesian this.viewer.camera.pickEllipsoid( screenPosition, this.viewer.scene.globe.ellipsoid ); if (!cartesian) { return null; } const cartographic Cesium.Cartographic.fromCartesian(cartesian); return { longitude: Cesium.Math.toDegrees(cartographic.longitude), latitude: Cesium.Math.toDegrees(cartographic.latitude), cartesian: cartesian, cartographic: cartographic }; } destroy() { this.handler.destroy(); } }使用方式const picker new GeoPicker(viewer); picker.activate((result) { document.getElementById(lonInput).value result.longitude.toFixed(6); document.getElementById(latInput).value result.latitude.toFixed(6); });构造函数里把handler创建好activate时注册回调destroy时销毁。这套封装的好处是点击事件只有一个入口不会因为多个地方各注册一个setInputAction导致事件冲突。3.2 点击实体而不是点击地表有些场景下你需要的是鼠标点到了哪个模型/实体然后拿那个实体的坐标。比如点击一个建筑物模型要弹出它的属性卡片。这时候就不该用pickEllipsoid去求交地球表面而应该用viewer.scene.pick去拾取场景里的对象handler.setInputAction(function (movement) { // 先尝试拾取场景中的对象实体、3D Tiles、图元 const pickedObject viewer.scene.pick(movement.position); if (Cesium.defined(pickedObject)) { // 如果拾取到了一个Entity if (pickedObject.id pickedObject.id instanceof Cesium.Entity) { const entity pickedObject.id; const position entity.position.getValue(viewer.clock.currentTime); if (position) { const cartographic Cesium.Cartographic.fromCartesian(position); console.log(点击了实体位置${Cesium.Math.toDegrees(cartographic.longitude)}, ${Cesium.Math.toDegrees(cartographic.latitude)}); } } // 如果拾取到了3D Tiles else if (pickedObject.primitive) { const cartesian viewer.scene.pickPosition(movement.position); const cartographic Cesium.Cartographic.fromCartesian(cartesian); console.log(点击了3D Tiles位置${Cesium.Math.toDegrees(cartographic.longitude)}, ${Cesium.Math.toDegrees(cartographic.latitude)}); } } else { // 没有拾取到任何对象退化为拾取地表坐标 const cartesian viewer.camera.pickEllipsoid(movement.position, viewer.scene.globe.ellipsoid); if (cartesian) { const cartographic Cesium.Cartographic.fromCartesian(cartesian); console.log(点击了地表${Cesium.Math.toDegrees(cartographic.longitude)}, ${Cesium.Math.toDegrees(cartographic.latitude)}); } } }, Cesium.ScreenSpaceEventType.LEFT_CLICK);这种先pick对象、再pick地表的双层策略是我在项目里最常用的写法。它的顺序逻辑是优先响应场景中更具体的对象没有命中对象时才去算地表坐标。如果不这样处理你点击一个模型时拿到的反而是模型后面挡着的地表坐标交互逻辑就反了。3.3 移动端触摸支持Cesium的ScreenSpaceEventHandler对触摸事件是自动兼容的。LEFT_CLICK在触摸设备上对应单指轻点LEFT_DOWN对应手指按下LEFT_UP对应手指抬起。所以你前面写的代码在平板、手机上不用改就能用。但有一个移动端特有的坑devicePixelRatio在高清屏手机上可能是2、3canvas的物理像素和CSS像素不一样。Cesium内部处理拾取的时候用的是CSS像素坐标封装得比较好一般不会出问题。真正会出问题的是你自己在页面上叠加DOM元素时——比如一个定位用的div浮层——你如果用event.clientX去算位置必须考虑这个div相对canvas的偏移。// 如果浮层和canvas之间存在offset需要做一次坐标转换 const rect viewer.canvas.getBoundingClientRect(); const x event.clientX - rect.left; const y event.clientY - rect.top;这个转换是把浏览器窗口坐标转成canvas局部坐标。Cesium的movement.position本身就是canvas局部坐标所以你只需要在自定义DOM事件里做这层换算。3.4 连续采集坐标点规划选址、画边界线这类需求需要连续点击多个点把经纬度存到一个数组里。我在实际项目中是这样处理的const points []; let isCollecting false; function startCollecting() { isCollecting true; handler.setInputAction(function (movement) { if (!isCollecting) return; const cartesian viewer.camera.pickEllipsoid(movement.position, viewer.scene.globe.ellipsoid); if (!cartesian) return; const cartographic Cesium.Cartographic.fromCartesian(cartesian); const lon Cesium.Math.toDegrees(cartographic.longitude); const lat Cesium.Math.toDegrees(cartographic.latitude); points.push({ lon, lat, height: 0 }); // 在场景里留下一个点标记方便看到已采集的位置 viewer.entities.add({ position: Cesium.Cartesian3.fromDegrees(lon, lat), point: { pixelSize: 8, color: Cesium.Color.YELLOW } }); console.log(已采集 ${points.length} 个点${lon.toFixed(6)}, ${lat.toFixed(6)}); }, Cesium.ScreenSpaceEventType.LEFT_CLICK); } function stopCollecting() { isCollecting false; }这里用的Cesium.Cartesian3.fromDegrees(lon, lat)是把已转成度数的经纬度再转回世界坐标用于创建实体点。来回转换虽然多一步但保证了你屏幕上的标记和采集到的数据是同一个位置。4. 常见问题与排查技巧实录这部分是实际项目里最常见的几个坑每个我都亲自踩过整理成速查式笔记方便你遇到问题时直接对号入座。4.1 点击天空或宇宙背景返回undefinedpickEllipsoid的返回值可能为undefined。当鼠标点击的位置在canvas上对应射线没有和椭球体相交时——比如点击了地球以外的背景区域、缩放到离地球很近时点击了边缘外的空白——返回就是undefined。新手最常见的错误就是拿到undefined后直接访问.x、.y然后报错。解决方法是先判断再使用这个上面所有代码里都体现了。还有个隐蔽问题当相机离地球表面非常近甚至已经穿到地表以下时pickEllipsoid也可能返回undefined因为射线方向背离了球体。这时候可以检查相机高度低于某个阈值时改用pickPosition。// 相机高度低于500米时改用深度拾取 const cameraHeight viewer.camera.positionCartographic.height; const cartesian cameraHeight 500 ? viewer.scene.pickPosition(movement.position) : viewer.camera.pickEllipsoid(movement.position, viewer.scene.globe.ellipsoid);不过实际项目里更常见的做法是直接统一用pickPosition——只要你开了depthTestAgainstTerrain它在地面和贴近地面时都有可靠返回。4.2 地形场景下的经纬度偏移问题用pickEllipsoid拿到的经纬度在平原地区看着没问题但到了山区或者有3D Tiles建筑模型的地方问题就暴露了你明明点击的是山坡上的一个点拿到的坐标却是同一方向上山坡背后的椭球面上的点。这在视觉上会有明显偏差特别是相机角度比较倾斜的时候。原因很直接pickEllipsoid只跟椭球体求交不感知地形起伏。要拿鼠标实际看到的那个表面的坐标必须用scene.pickPosition它读的是深度缓冲也就是GPU实际渲染出来的深度值对应的世界坐标。但pickPosition也有自己的代价需要viewer.scene.pickPositionSupported为true。一般WebGL2环境都支持但如果你用了某些特殊渲染配置可能返回false。拾取前如果场景还没渲染完成比如3D Tiles还在加载中拿到的深度是旧的坐标会偏移。在高DPI缩放下偶有精度损失坐标会出现毫米级的抖动。我的取舍原则如果业务只需要大致位置比如点击打点、画范围用pickEllipsoid就够了性能好还稳定。如果业务需要精确到地形或建筑表面比如量测高度、模型标注必须用pickPosition并且开启depthTestAgainstTerrain。4.3 经纬度精度与浮点误差toDegrees转换出来的经纬度直接console.log会输出一长串小数比如116.39123456789012。实际应用中不需要这么高的精度保留6位小数大约是0.1米级别足够绝大多数业务用了。但要注意一个反向坑当你把经纬度从后端读回来再通过Cesium.Cartesian3.fromDegrees转成世界坐标去做空间计算时如果中间做了多次四舍五入会造成坐标偏移。例如后端存了116.391234前端读到后转成Cartesian3和原始精度下转出来的Cartesian3在距离上可能差出几十厘米。对精度敏感的场景比如管线定位、设备坐标校核我建议传输和存储统一保留7位小数以上。不要在前端做字符串拼接传参直接用数值类型。需要高精度比较时用Cartesian3做距离计算不要比较经纬度差值。4.4 事件冲突和内存泄漏ScreenSpaceEventHandler的实例如果重复创建而不销毁会导致内存泄漏和事件重复触发。典型场景是页面做了路由切换组件被销毁又重新创建每次进入页面都new一个handler旧handler没有被destroy()于是下次点击时会触发多个回调。正确的生命周期管理// 组件销毁时 handler.destroy(); // 或者调用封装类里的destroy方法handler.destroy()底层会移除canvas上的所有事件监听不需要你手动remove。还有一个事件冲突问题如果同时有多个handler监听了LEFT_CLICK它们的执行顺序取决于注册顺序。项目里如果要同时处理点击选点和点击弹窗建议用一个handler、一个setInputAction在回调里根据业务状态分流而不是注册多个handler。4.5 关于拾取精度的一个冷门但很实用的设置viewer.scene.globe.depthTestAgainstTerrain这个属性我前面提了两次这里再补充一下它的完整影响设为true时地球表面会参与深度测试pickPosition能拿到真实地形高程但也会导致相机穿地时看不到地下场景。设为false默认时地形不写入深度缓冲pickPosition拿到的坐标是椭球面高度但相机在地下时依然能看到地面以下的模型。所以这个开关不是开启就更好而是要根据业务视觉需求权衡。比如地下管线可视化项目必须保持false才能看到地下的管线这时候你还要拿管线表面的真实坐标就得用scene.pick去拾取管线图元本身而不是pickPosition。5. 实战心得几个提升开发效率的小习惯代码层面讲完了最后分享几个我在实际项目里养成的习惯算不上多高深但能省不少调试时间。第一个习惯是写一个全局的点击调试模式。在开发阶段我通常会在页面底部加一个常驻的坐标显示条任何鼠标点击都实时显示经纬度。这一步相当于给场景加了个探针无论是排查数据问题还是验证坐标转换都直观得多。上线前关掉就行。// 开发调试用全局坐标显示 const infoDiv document.getElementById(coordinateInfo); handler.setInputAction(function (movement) { const cartesian viewer.camera.pickEllipsoid(movement.position, viewer.scene.globe.ellipsoid); if (cartesian) { const carto Cesium.Cartographic.fromCartesian(cartesian); infoDiv.textContent 经度: ${Cesium.Math.toDegrees(carto.longitude).toFixed(7)}, 纬度: ${Cesium.Math.toDegrees(carto.latitude).toFixed(7)}; } }, Cesium.ScreenSpaceEventType.MOUSE_MOVE);注意用的是MOUSE_MOVE而不是LEFT_CLICK这样鼠标移动时就能实时反馈不用每次点击。移动端的触摸移动对应PINCH_MOVE或LEFT_DRAG用MOUSE_MOVE在触摸设备上不生效调试时注意区分。第二个习惯是把坐标转换封装成纯函数单独放在工具模块里不依赖viewer实例。这样在单元测试里可以直接验证转换逻辑也方便多个项目复用// utils/geo.js export function cartesianToDegrees(cartesian) { const carto Cesium.Cartographic.fromCartesian(cartesian); return { longitude: Cesium.Math.toDegrees(carto.longitude), latitude: Cesium.Math.toDegrees(carto.latitude), height: carto.height, cartographic: carto }; }第三个习惯和3D Tiles相关。如果你的场景里有倾斜摄影模型或BIM模型pickPosition拿到的坐标往往比pickEllipsoid更符合用户直觉。但3D Tiles在流式加载时点击速度太快可能拿到的是上一帧的深度这时候可以在点击回调里判断scene.globe.tilesLoaded是否为true或者简单加一个300毫秒的节流避免连续快速点击时的坐标跳变。我个人在实际操作中的体会是Cesium坐标拾取这个功能80%的情况用pickEllipsoid就足够了但剩下20%需要地形、需要模型表面坐标、需要高程值的场景才是真正考验你对坐标系理解深度的时刻。把屏幕坐标、世界坐标、弧度坐标三者之间的关系理清楚遇到任何拾取需求都能很快写出正确代码而不是靠猜API碰运气。接下来如果你要继续深入可以顺着几个方向研究SceneTransforms的屏幕坐标和世界坐标双向转换、Camera.pickEllipsoid在多个椭球体比如月球、火星场景下的用法、以及pickPosition在post-processing渲染链下的行为差异。这些都是在获取鼠标单击位置经纬度这个基础功能之上的自然延伸也是从会写到写得好的分水岭。
阅读完成 · 觉得有帮助?
咨询建站