osgEarth 光标锚定缩放:继承 EarthManipulator 的完整实现
发布时间:2026/9/15 14:00:13 作者:尧图编辑部 阅读量:1,286

1. 一个让 GIS 用户欲哭无泪的默认行为前几年接了一个地形勘察项目刚把 osgEarth 的场景搭起来项目负责人就提了一个听起来很普通的需求“滚轮缩放要像网页地图那样鼠标在哪个位置就放大到哪个位置。”我当时心想缩放不就是把 Viewpoint 的 range 改小一点吗把焦点挪到鼠标底下不就行了结果真做起来才发现这件事远比“改个 range”复杂。osgEarth 默认用的EarthManipulator滚轮缩放的行为是以当前相机焦点通常就是屏幕中心点的地面位置为锚点把相机与目标点之间的距离按比例缩放。也就是说屏幕中心永远不动其他位置都在向中心聚拢或从中心散开。这种设计在 3D 漫游场景里没什么问题但在 GIS 业务里非常难受。你正在浏览某个区域光标正好指着一栋建筑或一条河道一滚滚轮那块区域没有放大反而被甩到屏幕边缘去了得再拖一下相机才能找回来。活儿全耽误在“找回视野”上了。很多网友把这个问题归结为“不会用操纵器”其实不是。EarthManipulator很长一段时间里确实没有内置光标锚定缩放至少在 2.x 系列里没有直接的配置项。你要么装一个事件处理器在外部拦截滚轮事件要么自己继承操纵器改掉滚轮处理逻辑要么干脆重写一个相机操纵器。三种方案我都试过各有各的坑但核心从来不是“怎么写代码”而是“你理不理解屏幕上的点是怎么被投射出来的”。这篇文章就把我从头到尾踩过的坑、推导过的公式、以及最终用起来最稳的一套实现方案完整写出来。适合正在用 osgEarth 2.x / 3.x 做地形浏览、航天仿真、智慧城市类项目的朋友参考也适合第一次接触EarthManipulator二次开发的人作为入门材料。2. 光标锚定缩放的几何推导屏幕坐标到底是怎么被“钉住”的先说结论所谓“鼠标在哪就放大到哪”本质上是让鼠标射线与地形表面的交点 G在缩放前后保持相同的屏幕坐标。屏幕坐标不变视觉上那个点就像被钉在屏幕上一样周围的内容才会围着它缩放。要理解怎么“钉住”先要搞清楚屏幕上任意一个点是怎么来的。EarthManipulator使用的是一个轨道相机模型它用四个关键量描述相机状态焦点 F相机注视的目标点通常在屏幕正中、距焦点的距离 range、水平朝向 heading、俯仰角 pitch。由此可以计算出相机位置 E 和三个正交基向量视线方向 L、相机的右向 R、相机的上向 U。任何世界坐标点 P 投影到屏幕上的过程可以拆成两步。第一步是把 P 放到相机坐标系里得到三个分量z (P - E) · L // 沿视线方向的距离 x (P - E) · R // 右向分量 y (P - E) · U // 上向分量第二步是透视投影。投影之后屏幕上的归一化坐标大约等于 x/z 和 y/z 的比例关系严格讲还要乘上视场角和宽高比相关的系数但那部分在缩放前后是常数不影响我们的推导。所以“保持屏幕坐标不变”就等于“保持 x/z 和 y/z 这两个比值不变”。默认的中心缩放做了什么它只改了 range从 R 变成 R_new而焦点 F 保持不动。这时相机沿视线方向移动屏幕中心的点恰好是 F的 x、y 分量始终是 0比值 0/z 还是 0所以中心点不动但光标处的 G 点不在中心它的 x、y 分量没变z 分量却变了比值自然就变了——表现在屏幕上就是 G 在乱跑。要让 G 不动唯一办法是同时横向挪动焦点 F让新焦点 F_new 产生一个补偿位移抵消 z 变化带来的比值偏移。假设新的 range 为 R_new定义缩放的位移量t R - R_new当放大时 R_new Rt 为正缩小时 t 为负。接着把 G 在当前相机坐标系下的三个分量记为 xG、yG、zG。如果新焦点 F_new F a·R b·U也就是在右向和上向上分别平移 a 和 b那么缩放后 G 在相机坐标系下的新分量就会变成x xG - a y yG - b z zG - t要让 x/z xG/zG 且 y/z yG/zG解这个一元方程马上得到a xG · t / zG b yG · t / zG就这么简单。整个推导坐下来说白了是一个相似三角形问题焦点在右向和上向上平移的距离等于光标点在对应方向上的分量乘以“缩放位移量与视线方向距离的比值”。打个比方你站在窗前透过窗框看远处的塔窗户就是屏幕塔尖就是 G。你往后退一步缩短 range塔尖在窗框里的相对位置就变了如果你想让塔尖一直卡在同一根窗棂上就必须同时往旁边挪一步。挪多少取决于塔尖当前在你的视野里偏离中心多少以及你后退了多少。这个公式还有一个非常漂亮的性质它只依赖 G 在当前相机坐标系下的分量不依赖具体投影矩阵、视口宽高、视场角。也就是说不管你的窗口是 4:3 还是 16:9不管 FOV 设成多少只要缩放比例不变横向补偿量就不会变。这让我省去了大量调试不同分辨率适配的功夫。有了公式剩下来就是程序的事了拿到鼠标位置对应的地面交点 G取当前相机基向量和 range算出 a 和 b把新焦点和新 range 写回操纵器。下面进入实现部分。3. 继承 EarthManipulator最小干净实现在动手前先回答一个绕不开的问题到底用事件处理器GUIEventHandler还是继承操纵器我一开始用的是addEventHandler的方式在handle里拦截SCROLL事件然后调用操纵器的setViewpoint。但遇到一个非常头疼的问题osgViewer 的事件处理顺序是“先过事件处理器后过相机操纵器”而且EarthManipulator本身就是一个事件处理器你在外面拦截了滚轮、返回 true 表示消费了事件理论上操纵器就不会再处理了可实际在不同版本里事件派发顺序和行为并不总是一致有时候你会发现一次滚轮造成了“中心缩放 光标锚定缩放”双重叠加画面直接跳飞出去。更稳的做法是直接继承EarthManipulator重写它的handle方法把SCROLL事件完全接管过来其他事件原样交给父类。这样从源头避免双倍缩放的问题还能直接使用getViewpoint/setViewpoint这些受保护或公开的接口代码最干净。下面是核心实现我以 osgEarth 2.10 的 API 为主3.x 的差异会在后面单独说明。// ZoomToCursorManipulator.h #pragma once #include osgEarthUtil/EarthManipulator #include osgEarth/MapNode class ZoomToCursorManipulator : public osgEarth::Util::EarthManipulator { public: explicit ZoomToCursorManipulator(osgEarth::MapNode* mapNode); bool handle(const osgGA::GUIEventAdapter ea, osgGA::GUIActionAdapter aa) override; protected: bool pickWorldPoint(osgViewer::View* view, float x, float y, osg::Vec3d out) const; bool zoomToCursor(osgViewer::View* view, float x, float y, double factor); osg::observer_ptrosgEarth::MapNode _mapNode; };// ZoomToCursorManipulator.cpp #include ZoomToCursorManipulator.h #include osgUtil/LineSegmentIntersector #include osgUtil/IntersectionVisitor #include osgViewer/View ZoomToCursorManipulator::ZoomToCursorManipulator(osgEarth::MapNode* mapNode) : _mapNode(mapNode) { } bool ZoomToCursorManipulator::handle( const osgGA::GUIEventAdapter ea, osgGA::GUIActionAdapter aa) { if (ea.getEventType() osgGA::GUIEventAdapter::SCROLL) { osgViewer::View* view dynamic_castosgViewer::View*(aa); if (view _mapNode.valid()) { double factor (ea.getScrollingMotion() osgGA::GUIEventAdapter::SCROLL_UP) ? 0.8 : 1.25; if (zoomToCursor(view, ea.getX(), ea.getY(), factor)) return true; // 拾取失败时退化为中心缩放保证滚轮缩放始终可用 osgEarth::Viewpoint vp getViewpoint(); vp.range() vp.range().value() * factor; setViewpoint(vp, 0.0); return true; } } return osgEarth::Util::EarthManipulator::handle(ea, aa); } bool ZoomToCursorManipulator::pickWorldPoint( osgViewer::View* view, float x, float y, osg::Vec3d out) const { // WINDOW 表示 x、y 是窗口像素坐标原点在左下角 osg::ref_ptrosgUtil::LineSegmentIntersector picker new osgUtil::LineSegmentIntersector(osgUtil::Intersector::WINDOW, x, y); osgUtil::IntersectionVisitor iv(picker.get()); view-getCamera()-accept(iv); if (!picker-containsIntersections()) return false; out picker-getFirstIntersection().getWorldIntersectPoint(); return true; } bool ZoomToCursorManipulator::zoomToCursor( osgViewer::View* view, float x, float y, double factor) { // 1. 拾取光标下的地面点 G osg::Vec3d G; if (!pickWorldPoint(view, x, y, G)) return false; // 2. 从 Viewpoint 取焦点 F得到精确的 range osgEarth::Viewpoint vp getViewpoint(); if (!vp.focalPoint().isSet()) return false; osg::Vec3d F; vp.focalPoint()-toWorld(F); // 3. 从当前视图矩阵提取相机基向量 // 这是最稳的方式不依赖 heading/pitch 再重新算一遍方向 const osg::Matrixd vm view-getCamera()-getViewMatrix(); osg::Vec3d R(vm(0, 0), vm(0, 1), vm(0, 2)); // 右向 osg::Vec3d U(vm(1, 0), vm(1, 1), vm(1, 2)); // 上向 osg::Vec3d L -osg::Vec3d(vm(2, 0), vm(2, 1), vm(2, 2)); // 前向 osg::Matrixd invVM; invVM.invert(vm); osg::Vec3d E(invVM(3, 0), invVM(3, 1), invVM(3, 2)); // 相机位置 double range (F - E).length(); if (range 1e-6) return false; // 4. 计算 G 在当前相机坐标系下的分量 osg::Vec3d v G - E; double zG v * L; double xG v * R; double yG v * U; // 光标点在相机背后或者贴脸就没有意义了 if (zG 1.0) return false; // 5. 核心公式横向补偿焦点的位移量 double newRange range * factor; double t range - newRange; double a xG * t / zG; double b yG * t / zG; osg::Vec3d F_new F R * a U * b; // 6. 写回操纵器。duration 传 0 表示立即跳转 vp.focalPoint() osgEarth::GeoPoint(_mapNode-getMapSRS(), F_new); vp.range() newRange; setViewpoint(vp, 0.0); return true; }这里有一个初学者容易忽略的点从视图矩阵里提取右向、上向、前向三个基向量而不是从 heading/pitch 手动算方向。原因很简单EarthManipulator在内部可能做各种钳制比如俯仰角限制、最小距离限制从getViewpoint读出来的 heading/pitch 和实际渲染用的视图矩阵并不完全一致直接从矩阵里提最贴近用户眼睛看到的画面。使用的时候也很简单osgEarth::MapNode* mapNode ...; osg::ref_ptrZoomToCursorManipulator manip new ZoomToCursorManipulator(mapNode); viewer-setCameraManipulator(manip.get());替换掉默认的EarthManipulator即可不需要改场景树、不需要接额外的信号槽。4. 代码里的那些“写错就废”的细节上面的代码能在多数项目里直接跑通但你真放到生产环境大概率还会遇到几个细节问题。我挨个讲清楚每一条都是实际调试过的。4.1 拾取坐标系WINDOW 和 PROJECTION 别混用osgUtil::LineSegmentIntersector的构造函数有两个高频写法new osgUtil::LineSegmentIntersector(osgUtil::Intersector::WINDOW, x, y); new osgUtil::LineSegmentIntersector(osgUtil::Intersector::PROJECTION, x, y);很多从网上复制代码的人习惯用PROJECTION因为看到官方例子这么写。但PROJECTION模式要求 x、y 是已经归一化到 [-1, 1] 的 NDC 坐标而GUIEventAdapter::getX()/getY()返回的是窗口像素坐标两者差了视口宽高。直接用像素坐标去PROJECTION模式拾取得到的射线完全不对缩放自然也对不上。正确做法是用WINDOW模式它内部会根据当前相机的视口和投影矩阵完成归一化。这也是为什么上面代码里明确写了注释x、y 是窗口像素坐标原点在左下角。4.2 focus 点的世界坐标转换vp.focalPoint()返回的是一个GeoPoint它的 SRS 是地图创建时决定的。而LineSegmentIntersector返回的getWorldIntersectPoint()是场景图世界坐标系里的坐标。在 osgEarth 里这两个坐标体系通常是同一个即_mapNode-getMapSRS()对应的坐标系。因此上面代码用_mapNode-getMapSRS()构造新焦点是安全且一致的。但要注意一点如果你的项目用了投影坐标系比如 UTM并且手动把场景树挂到了别的坐标系下那么toWorld()出来的坐标和拾取返回的坐标就可能不是一个体系。遇到这种情况最简单的方法是不用getMapSRS()改成直接新建一个 SRS 为vp.focalPoint()-getSRS()的 GeoPoint——但前提是那个 SRS 和场景图世界坐标确实一致。绝大多数单 MapNode 项目不会有这个问题可一旦遇到画面里“焦点乱跳”的现象优先排查这里。4.3 高 DPI 和窗口原点翻转ea.getX()/getY()在大部分平台返回的是视口像素坐标原点在左下角和 OSG 的视图坐标一致。但个别嵌入场景里窗口系统给的是左上角原点坐标这时候拾取就会上下颠倒。标准处理办法是拿到相机的 Viewport 做一次翻转const osg::Viewport* vp_window view-getCamera()-getViewport(); float my vp_window-height() - ea.getY();高 DPI 屏幕上还要确认事件坐标和视口尺寸是否都基于同一个逻辑分辨率。如果视口是物理像素而事件给的是逻辑像素拾取位置同样会对不上。先打印判断一下比盲改代码有效得多。4.4 回调了父类 handle 导致的双重缩放上面实现里我把 SCROLL 事件完整消费掉返回 true然后显式设置 Viewpoint。这么做就是为了不让父类的EarthManipulator::handle再跑一遍滚轮逻辑。如果你在zoomToCursor的某个分支里想“先让父类处理一下”那就错了父类处理完会再缩放一次你的代码又缩放一次最终放大倍率变成 factor 的平方画面抖动非常明显。所以切记SCROLL 事件要么走你自己的缩放逻辑要么你自己写一个中心缩放兜底千万不要在同一个分支里再调EarthManipulator::handle(ea, aa)去缩放。5. 踩坑清单事件被吞、天空拾取、坐标不一致这段是实战里最容易翻车的地方。我按严重程度排序每一条后面都标了实际表现和排查思路。5.1 光标指到天上拾取失败怎么办在三维地球场景里向上抬头或者镜头平视时鼠标很大概率指向天空。这时候LineSegmentIntersector打不到地形containsIntersections()返回 false。如果什么都不做用户会觉得滚轮在部分角度“失灵”。我的处理策略是拾取失败就退化为普通的中心缩放。也就是说鼠标指着天空时仍然可以滚轮缩放只是锚点变成了屏幕中心。这比完全没反应友好得多也符合主流三维地球软件的行为。上面代码里已经写了这个兜底。如果连中心缩放都不想要也可以在拾取失败时直接返回 false 不消费事件但那样父类也不会再帮你缩放因为重写了 handle最终表现就是滚轮无效。所以要么兜底要么自己在失败分支里什么也不做、只 return true 吞掉事件看你的产品怎么定义。5.2 断言 zG 太大或太小zG 是光标交点沿视线方向到相机的距离。正常情况下它约等于 range因为光标点就在焦点附近的地面上。但有两种情况会让 zG 偏离得很离谱视线接近水平光标指到很远的山体或云层zG 远大于 range。从高空垂直往下看光标点落在非常近的地表zG 远小于 range。zG 变化直接影响a xG * t / zG的稳定性。如果 zG 很小t 又很大一次性连续滚了很多格焦点的横向补偿可能在一次事件里跳出几个公里画面会有明显的“瞬移”。我建议做两种保护第一种直接丢弃极端情况if (zG range * 0.1 || zG range * 10.0) return false; // 交给兜底中心缩放第二种限制补偿位移的长度避免焦点瞬移osg::Vec3d shift R * a U * b; double maxShift range * 0.5; if (shift.length() maxShift) shift * maxShift / shift.length(); F_new F shift;实际项目里我偏好第二种因为它保留了大多数情况下的正确手感只有极端姿态时做限幅用户感知不到变化但系统稳定性高很多。5.3 第一交点不一定在地表LineSegmentIntersector返回的是射线碰到的第一个交点这个交点可能落在建筑物、树木模型、标牌甚至特效粒子上。如果你的场景里没有这些物件问题不大如果有你会发现缩放锚点偶尔会钉在楼顶而不是地面。更严格的方案是过滤交点的 node path只保留位于地形节点下的交点。osgEarth 里可以用osgEarth::MapNode::getTerrain()或者osgEarth::ElevationQuery做二次确认把鼠标射线的方向当成一个查询条件拿到与地形的精确交点。但这种方式代码量更大。一般的业务场景用“取第一个交点”就够了服务器仿真、城市级 BIM 场景建议升级到地形专用拾取。5.4 osgEarth 3.x 的 API 差异上面代码以 osgEarth 2.10 为主。3.0 之后Viewpoint的 optional 字段结构有变化vp.focalPoint()的用法需要调整。我在迁移项目时遇到的主要差异是功能osgEarth 2.10osgEarth 3.x读取焦点vp.focalPoint()getFocalPoint()/setFocalPoint()读取距离vp.range()getRange()/setRange()构造 GeoPointGeoPoint(srs, vec)基本一致如果你在编译时发现focalPoint().isSet()报错多半就是版本 API 变了去对应版本的头文件里查一眼Viewpoint类的公开接口就能对上。核心公式部分完全不受版本影响。5.5 多相机场景下的拾取目标有的项目场景里除了主相机还有 HUD 相机、小地图相机、阴影相机。view-getCamera()-accept(iv)只会往主相机里发射拾取射线吗不完全是。如果你的 HUD 相机渲染在最上层拾取射线打到 HUD 上的四边形返回的交点就不是地形点了。解决办法是在拾取前把 HUD 相机从场景中摘掉或者给 HUD 节点设置setNodeMask(0)让它不参与相交测试。更通用的做法是获取view-getCamera()后明确指定拾取只走主相机的地形节点而不是让拾取器对整个场景图做遍历。理论上view-getCamera()-accept(iv)会遍历主相机下挂的所有子节点包括 HUD。所以实践中遇到“锚点钉在奇怪位置”的情况先检查是不是有覆盖层参与拾取。5.6 动画过渡中的缩放事件冲突setViewpoint(vp, 0.3)会触发一段 0.3 秒的视点过渡动画。如果用户快速连滚滚轮动画还没播完下一次事件就来了。getViewpoint()在动画中间返回的是什么不同版本行为不一样有时返回动画起点有时返回当前插值位置。这会直接导致缩放手感抖动。我的经验正常项目里滚轮缩放用duration 0.0即即时跳转。想要平滑手感的宁可自己做一个指数平滑也不要依赖操纵器内部的过渡动画。具体做法是在你的操控逻辑里维护一个目标 range每帧插值逼近滚轮事件只改变目标值。这个方案在快速连滚时非常跟手。6. 从能用变好用参数、手感与扩展玩法到这里光标锚定缩放已经可以正常工作了。但离“好用”还有一段距离下面是我在多个项目里验证过的调参和扩展经验。6.1 缩放系数怎么选上面的代码用的是固定系数上滚 0.8下滚 1.25。也就是说每次滚一格距离缩小到原来的 80%或者增大到 125%。这个比例比较接近主流地图应用的缩放手感。但鼠标滚轮有“精细滚动”的硬件一格滑轮在部分系统里会拆成多个小 delta。如果你希望手感更线性可以根据ea.getScrollingDelta()动态计算double delta ea.getScrollingDelta(); double factor pow(0.9, delta);getScrollingDelta()在不同平台、不同驱动上返回值差异很大有的系统根本不支持。所以线上产品建议保留固定系数作为兜底只在高版本系统里启用动态系数。另外要注意放大和缩小应该是对称的。0.8 的倒数正好是 1.25这样你放大一格再缩小一格能回到原始位置。不要放大用 0.8、缩小用 1.2那会产生累积漂移。6.2 双击缩放从指针缩放里自然延伸光标锚定缩放模型天然适合做双击缩放。实现思路很简单在handle里监听DOUBLECLICK事件以鼠标位置为锚点把 range 乘以 0.5 或 0.35调用同一个zoomToCursor函数。用户双击某个区域画面就会以双击点为中心放大——这和地图软件的交互完全一致。要注意的是DOUBLECLICK事件和PUSH/RELEASE事件会同时触发处理不当会导致“双击一次相机转了 90 度”之类的迷惑行为。我的做法是在双击分支里吞掉事件并返回 true不把按下/抬起事件往下传。6.3 触屏双指缩放osgEarth 移动端应用越来越多双指捏合缩放也是基于同样的公式。关键区别在于滚轮事件只有一个缩放 factor而触屏双指事件需要从两个触点位置计算缩放比例和锚点。我的做法是记录双指按下的初始距离 d0 和初始中点 m0每一帧拖动时重新计算距离 d1 和中点 m1以 m0 为锚点factor d1 / d0调用zoomToCursor同时把中点偏移也作为平移量处理避免捏合时画面漂移。这个逻辑相当于是把“光标锚定缩放”和“两指平移”组合在一起几何上仍然是同一套推导。6.4 矩形框选缩放有些项目需要“拉框放大”功能按住鼠标拖出一个矩形松开后画面缩放到该矩形区域。实现思路也可以复用上面的核心公式根据矩形在屏幕上的位置把矩形的中心作为锚点。根据矩形的宽度与视口宽度的比例计算新的 range。调用zoomToCursor完成缩放。这个功能我以前觉得很难后来发现只是把“鼠标点”换成了“矩形中心”再把缩放系数根据矩形尺寸和视口尺寸算出来。本质没有变化。6.5 性能与多线程渲染LineSegmentIntersector在每次滚轮事件时做一次相交测试对于现代 CPU 来说开销几乎可以忽略不需要担心性能。但要注意拾取必须在渲染线程或者事件线程上执行不要在独立的工作线程里去 pick否则会碰到地形图元更新导致的崩溃。osgEarth 的地形是动态调度的多线程 pick 需要做线程同步复杂度会直线上升。6.6 用 ElevationQuery 替代拾取如果你的场景里除了地形还有很多模型不想让模型干扰锚点计算可以考虑改用osgEarth::ElevationQuery精确求取鼠标射线与地形高程的交点。大概思路是先从相机发出一根射线用高程服务迭代逼近地表高度直到收敛。这样得到的交点永远是地形点不受建筑物遮挡。代价是代码量多一些而且在地形 LOD 切换时可能有轻微的延迟感。我在实际项目中如果场景相对干净直接用LineSegmentIntersector如果场景里有大量模型、并且用户会频繁点击建筑区域我才会切换到ElevationQuery。这个选择没有绝对的对错完全看业务场景的容错要求。回到开头那个项目我最后给出的交付物就是一个继承自EarthManipulator的类核心代码不到 100 行。使用者只需要替换 setCameraManipulator 那一行。后来团队里的同事接手维护看到这段代码后说的第一句话是“原来缩放手感是这么来的。”个人体会是这类“看起来很小”的交互需求往往最能体现一个图形开发者对渲染管线和数学基础的理解程度。如果你已经能不看文档说出“屏幕坐标等于相机空间坐标三个分量的比值”那光标锚定缩放就只是一个代入公式的问题如果你还在靠试参数碰运气建议先把第 2 节的推导亲手推一遍推完之后很多奇奇怪怪的交互问题都会豁然开朗。