
简介OSG3D-master.zip 是一份基于 OpenSceneGraph 与 osgEarth 的 3D 地理信息示例工程面向需要实现三维场景构建、空间量测与环境模拟的 GIS 或图形学开发者。代码覆盖点、线、面几何绘制长度、高度、面积测量水淹分析及两点/区域通视分析并包含 Overlay 叠加与全球网格测试可帮助读者快速掌握 OSG 与 osgEarth 的典型交互和空间分析流程。压缩包共 88 个文件约 66KB主要包含 31 个 C 头文件、29 个 cpp 实现文件、8 个 earth 场景配置、4 个 Qt 界面文件以及工程文件、图标和说明文档其中 h/cpp 对应几何绘制与测量分析模块earth 文件用于组织数字地球场景。已有 510 人学习下载。解压后可直接看到可编译的工程骨架、场景配置与核心算法实现适合作为三维地理信息二次开发或水淹、测量类功能的入门参考。1. 水淹与测量同时出现在 OSG3D 场景里先理清两者关系一个反直觉的结论水淹模拟的绝大部分工作量不是画水面而是确定水面与地形相交的边界长度测量的大部分工作量也不是在屏幕上点两个点而是把屏幕坐标换算成经纬度、再算椭球面距离。把这两件事放进同一个 OSG3D 工程时它们会共享同一套场景树、同一套拾取管线、同一套高程查询接口如果割裂着做后面一定会出现“水面位置和鼠标点到的地形对不上”这类诡异问题。这里就以 osgEarth 为地理底座把水淹区域的计算原理、长度测量涉及的 geometry 实现以及从水位到测距的落地路径一次讲透。适合手头已经用 OSG 做三维可视化、想快速补上淹没分析和三维量测的水利、GIS、应急和数字孪生方向工程师。2. osgEarth 搭建 OSG3D 场景地图数据、高程层和最小加载工程2.1 为什么把地图层放在场景树之前在调水淹和测量之前得先有一个稳定加载影像与高程的场景。OSG3D 这个关键词里带“3D”但真正负责地理空间数据组织的是 osgEarth。osgEarth 把地图数据抽象成 MapMap 里叠影像层、高程层、矢量层最后交给 MapNode 转成场景图里的一个节点。后续所有地理计算都依赖 MapNode 的 MapSRS 和高程查询能力所以场景搭建的这一步不值得自己造轮子。我一般会用一个.earth文件描述数据源而不是在代码里硬编码图层 URL。这样换数据源不用重新编译也能让没有几何编程经验的同事直接改配置文件。下面这段 earth 文件是常见的地形影像组合坐标系统统一写到 WGS84免得后面的长度测量单位出问题。2.2 最小加载代码从 Earth 文件到渲染窗口!-- dem.earth -- map nameosgearth demo options profileEPSG:4326/profile /options image nameimagery urlland.xml/url /image elevation nameelev urlterrain.xml/url /elevation /map#include osgViewer/Viewer #include osgDB/ReadFile #include osgEarth/MapNode #include osgEarthUtil/EarthManipulator int main(int argc, char** argv) { osg::ref_ptrosg::Node scene osgDB::readNodeFile(dem.earth); osg::ref_ptrosgEarth::MapNode mapNode osgEarth::MapNode::get(scene); if (!mapNode) return 1; osgViewer::Viewer viewer; viewer.setSceneData(scene); viewer.setCameraManipulator(new osgEarth::Util::EarthManipulator()); return viewer.run(); }这段代码是 OSG3D 工程里最基础的接入方式。readNodeFile读取 earth 文件后引擎会自动解析影像层和高程层并把它们挂到地形节点上MapNode::get从场景里抽出 MapNode 指针供后面的水淹和测量代码使用EarthManipulator负责地图的旋转、缩放和俯仰比默认的轨道漫游器更适合地理场景。参数上需要注意profile写成EPSG:4326时坐标单位是经纬度后面取距离时不能直接拿 x/y 差值当米必须走地理计算。2.3 工程里最常见的编译与运行坑现象常见原因处理方式.earth文件加载后只有黑屏影像路径写错或高程层 URL 失效先用osgEarth::MapNode::load返回错误信息再逐个图层检查鼠标拖动时地球翻转未设置 EarthManipulator设置操纵器并关闭默认 TrackballManipulator编译时缺 osgEarthUtil只链接了 osgEarth 库链接osgEarthUtil与osgDB并确认版本一致这些坑在进入水淹模拟前必须先排掉。原因是 osgEarth 的图层采用延迟加载黑屏时控制台不一定报错我一般会把osgEarth::Registry::instance()-setDefaultTerrainEngineDriver(normal)打开 debug 日志看图层状态。等到场景能稳定缩放再把水淹和测量功能往上叠加。3. 水淹模拟的核心用 ClipNode 裁剪地形还是直接抬高水面3.1 水淹的本质是几何比较水淹模拟在三维场景里不是“贴一层半透明蓝色矩形”这么简单。它的本质是把水位高程与地形高程做逐点比较地形高度小于水位高程的区域被淹没大于水位高程的区域保持陆地。常见做法有两种一种是把地形的低于水位部分直接裁剪掉让水面成为断面另一种是在高程面上叠加一个透明水面网格水面与地形交界处不做布尔运算只做视觉覆盖。前者真实但昂贵后者稳定且容易做动态水位实际项目里更常用。从实现角度讲ClipNode 适合“需要精确水陆分界线”的分析场景但它会打断 osgEarth 的地形 LOD 更新水面边缘容易钻到地形缝隙里。水面网格法是我个人更常用的方案先生成一个覆盖整个分析区域的矩形网格把每个顶点的高程设成统一水位值再用半透明材质渲染。这样水位升降只需要改一个参数且不会影响拾取和长度测量。3.2 动态水面网格的实现代码#include osg/Geometry #include osg/Geode #include osg/Material #include osg/BlendFunc osg::Node* buildWaterSurface(float waterLevel, double lonMin, double latMin, double lonMax, double latMax, int seg) { osg::Geode* geode new osg::Geode; osg::Geometry* geom new osg::Geometry; osg::Vec3Array* verts new osg::Vec3Array; float dx static_castfloat((lonMax - lonMin) / seg); float dy static_castfloat((latMax - latMin) / seg); for (int i 0; i seg; i) for (int j 0; j seg; j) verts-push_back(osg::Vec3(lonMin j * dx, latMin i * dy, waterLevel)); osg::Vec4Array* colors new osg::Vec4Array; colors-push_back(osg::Vec4(0.1f, 0.3f, 0.9f, 0.45f)); osg::DrawElementsUInt* elements new osg::DrawElementsUInt(GL_TRIANGLES); for (int i 0; i seg; i) for (int j 0; j seg; j) { int a i * (seg 1) j; int b a 1; int c a seg 1; int d c 1; elements-push_back(a); elements-push_back(c); elements-push_back(b); elements-push_back(b); elements-push_back(c); elements-push_back(d); } geom-setVertexArray(verts); geom-setColorArray(colors, osg::Array::BIND_OVERALL); geom-addPrimitiveSet(elements); geode-addDrawable(geom); geode-getOrCreateStateSet()-setAttributeAndModes(new osg::BlendFunc, osg::StateAttribute::ON); return geode; }逻辑很简单生成(seg1)×(seg1)的格网所有顶点 z 值直接设为waterLevel再用两组三角形把格网连接成面。格网范围必须覆盖需要分析的水域经纬度值要和地图的 profile 对应。参数seg控制网格密度值越大水面边界越平滑但顶点数按平方增长。透明色用蓝色四元组alpha 取 0.45 是为了避免完全遮住地形如果追求真实感可以换成带法线扰动的水面 shader。使用这段代码时有一个关键细节waterLevel必须是海拔高程单位与地形高程一致。如果地形单位是米水位也要用米如果坐标系统是经纬度加高程直接传米的数值不要换算。3.3 水位参数怎么设一张表解决调试参数含义常见取值调参目标waterLevel水位海拔高程米按实际防洪/灌溉水位与地形高程对比产生淹没区seg网格每边分段数128512越大边界越精确但会增大 draw callalpha水面透明度0.350.55兼顾“看到水下地形”与“能分清水面”经纬度范围水面覆盖区域比目标水域外扩 5%防止边界处出现硬切口动态调整水位时直接更新顶点数组里的 z 值并调用verts-dirty()与geom-dirtyBound()。这样就不会重新创建节点性能开销很小。若要做连续动画把水位变化值放在 update callback 里逐帧写入即可。此方案的短处是水面不会自动跟随地形坡度遇到陡峭岸线时边界会有锯齿这时再用第二章里的高程查询逐点修正水面边界顶点。4. 长度测量的 geometry 实现从屏幕拾取到椭球面距离4.1 测量口径直线距离 vs 地表距离三维场景里“长度”至少有两种口径一种是把两个点直接连成空间直线忽略地形起伏单位是米另一种是沿着椭球表面走经过的路径长度即测地线距离。处理大地坐标时绝对不能把经纬度差值当成平面坐标算距离。水淹分析和应急测距里用户通常关心地表实际距离所以优先用大地测量算法。如果测绘精度要求高还需要在投影坐标系里做避免 WGS84 经纬度直接求欧氏距离产生大误差。测量方式适用场景实现成本误差来源空间直线距离净空分析、两建筑间最小间距低忽略地形起伏椭球面测地线距离地表两点路径、淹没区边界长度中椭球模型精度投影平面距离小范围规则区域低投影变形随范围增大4.2 鼠标拾取三维坐标#include osgGA/GUIEventHandler #include osgUtil/LineSegmentIntersector #include osgViewer/Viewer class MeasureHandler : public osgGA::GUIEventHandler { public: MeasureHandler(osgViewer::Viewer* viewer, osgEarth::MapNode* mapNode) : _viewer(viewer), _mapNode(mapNode) {} bool handle(const osgGA::GUIEventAdapter ea, osgGA::GUIActionAdapter aa) override { if (ea.getEventType() ! osgGA::GUIEventAdapter::PUSH || ea.getButton() ! 1) return false; float x ea.getX(); float y ea.getY(); osgUtil::LineSegmentIntersector::Intersections hits; if (_viewer-computeIntersections(x, y, hits)) { osg::Vec3d world hits.begin()-getWorldIntersectPoint(); osgEarth::GeoPoint geo; geo.fromWorld(_mapNode-getMapSRS(), world); _points.push_back(geo); // 在这里保存 _points供距离计算使用 } return true; } private: osgViewer::Viewer* _viewer; osgEarth::MapNode* _mapNode; std::vectorosgEarth::GeoPoint _points; };这个事件处理器覆盖了长度测量的第一步把鼠标点击的屏幕坐标转换成带经纬度和高程的地理坐标。computeIntersections会从相机位置发出一条射线和场景求交getWorldIntersectPoint得到的是世界坐标再用GeoPoint::fromWorld把世界坐标转换到地图坐标系。注意屏幕坐标在 OSG 窗口里的原点一般在左下角X 向右、Y 向上所以不需要像窗口系统那样翻转 Y 轴。如果命中点来自水面网格而不是地形得到的高程是水位高程测量地表两点时要关闭水面节点或使用独立的拾取掩码。4.3 距离计算与标注实现#include osgEarth/GeoMath #include osgEarth/GeoPoint #include osgEarth/Distance double computeSurfaceDistance(const osgEarth::GeoPoint a, const osgEarth::GeoPoint b) { osgEarth::Distance dist; if (a.distanceTo(b, dist)) return dist.asDistance(osgEarth::Units::METERS); return -1.0; }这里调用GeoPoint::distanceTo它内部按椭球模型计算两点间的测地线距离返回值以米为单位。需要保证两个点的getSRS()一致否则先做空间参考转换osgEarth::GeoPoint p2 b; p2.transform(a.getSRS());transform会把 b 点的坐标重新投影到 a 点的坐标系里再做距离运算。这个步骤在处理不同图层来源的测量点时尤其重要否则会出现量出来的距离比实际多出几十倍的怪事。标注可以用osgEarth::Annotation组件里的 LabelNode 或直接用 osgText 挂到场景上。显示格式建议至少保留两位小数并把单位“m”带上如果距离超过 1 公里自动切换成公里更贴合工程习惯。从 geometry 角度讲测量线本身是一个osg::Geometry动态更新顶点数组可以避免反复创建节点的开销同时把数组标记为动态数据即可。5. 水淹和测量最容易踩的精度与性能坑5.1 投影坐标系不统一距离单位从米变成度长度测量结果出错第一嫌疑就是坐标系。MapNode::getMapSRS()返回的是地图使用的空间参考而GeoPoint::fromWorld默认也使用这个 SRS正常情况下一致。问题常出在手动构造的GeoPoint上比如直接从高程缓存里读经纬度构造点却没有指定 SRSosgEarth 会把它当成无坐标参考的点后续getSRS()返回空指针。坐标系常见 EPSG长度单位适用场景WGS84 经纬度EPSG:4326度全球地形测地线距离WGS84 / Web MercatorEPSG:3857米Web 地图切片Gauss-Kruger 分带EPSG:4526 等米省级高精度测绘处理办法是在创建测量点时显式传入 SRSosgEarth::GeoPoint point(srs, lon, lat, height, osgEarth::ALTMODE_ABSOLUTE);ALTMODE_ABSOLUTE表示第三个参数就是绝对海拔米数。如果地形高程来自相对高程模型这里就要手动减去基准面差值。5.2 高程查询与 LOD 带来的误差水淹分析需要判断某个地理点是否在水位以下正确的做法是查询地形高程而不是从场景里拾取。因为场景拾取到的点可能落在水面网格上也可能受 LOD 切块影响。用osgEarth::ElevationQuery做逐点高程查询是最稳的路径#include osgEarth/ElevationQuery osgEarth::ElevationQuery query(_mapNode-getTerrain()); double height; if (query.getElevation(geoPoint, height)) { bool underwater (height waterLevel); }getElevation返回地形在该点的高程失败时返回false说明该点在地图覆盖范围外。这里有个隐藏坑查询精度受当前地形 LOD 影响刚进入场景时地形还没加载到最精细层级查回来的高程可能偏粗。可以设置query.setMaxError(1.0); // 允许最大高程误差 1 米值设置越小内部插值计算越耗时一般不要设成 0。动态水位改变后水面边界应该用高程查询重新计算而不是直接沿用上一帧的边界。5.3 渲染闪烁与内存抖动水面网格和地形共用一片区域如果深度测试处理不好会出现水面边缘闪烁或地形纹理透过水面。解决方法是给水面设置深度偏移例如osg::PolygonOffset* offset new osg::PolygonOffset(1.0f, 1.0f); waterGeode-getOrCreateStateSet()-setAttributeAndModes(offset, osg::StateAttribute::ON);同时把水面的渲染顺序放到地形之后避免半透明物体排序错误。内存方面ElevationQuery不会缓存地形数据频繁调用时应做好结果缓存网格顶点数组只在水位变化时更新不要让每一帧都dirtyBound。水性场景里最容易出现的崩溃是更新顶点时数组长度发生变化所以水面网格的顶点数应当固定动态水位只改 z 值不改顶点数量。6. 把长度测量嵌入水淹分析水位复核与边界校验6.1 用测量工具辅助水淹边界检查水淹边界不能用眼睛盯着屏幕估算把长度测量结果嵌入水位判断以后就可以快速回答“这条道路在水位以下多长一段”这类实际问题。在测量点之间做插值采样每个采样点调用高程查询判断其是否低于目标水位最终累加水下部分长度。double underwaterLength(const osgEarth::GeoPoint p1, const osgEarth::GeoPoint p2, double waterLevel, osgEarth::ElevationQuery query, int samples 100) { double total 0.0; osgEarth::GeoPoint prev; for (int i 0; i samples; i) { double t static_castdouble(i) / samples; osgEarth::GeoPoint cur; cur.interpolate(p1, p2, t); double height 0.0; if (query.getElevation(cur, height)) { bool underwater (height waterLevel); if (i 0) { bool prevUnderwater[1]; prevUnderwater[0] true; // 简化逻辑 if (underwater) { osgEarth::Distance segment; prev.distanceTo(cur, segment); total segment.asDistance(osgEarth::Units::METERS); } } prev cur; } } return total; }这段代码把interpolate和ElevationQuery串在一起GeoPoint::interpolate会在两点之间做等参数插值采样点密度由samples控制每个采样点取回地形高程后与waterLevel比较若两个相邻点都在水下就把它们之间的距离累加。samples越大结果越接近真实水下长度但查询耗时线性增长一般 100 个点已经能覆盖大范围场景。6.2 把距离测量结果回传给水面参数长度测量不只能做后验还能反过来辅助设置水面范围。用户先用测量工具沿河岸点出一圈界桩后端把这些点的经纬度取最小外接矩形把这个矩形作为水面网格的生成范围。这样就不会出现“水面露出大块空白”或“水面覆盖到山上”的问题。实现思路是把MeasureHandler收集的经纬度点集在每次双机后刷新水面网格的边界for (auto p : _points) { minLon std::min(minLon, p.x()); maxLon std::max(maxLon, p.x()); minLat std::min(minLat, p.y()); maxLat std::max(maxLat, p.y()); }随着点集数量增加水面范围会逐步收敛到目标区域。把这段逻辑挂到同一个事件处理器的MOVE事件里在移动测量点时实时更新水面网格就能形成“边测量边看淹没范围”的工作流。这样做既不需要引入独立的水动力模型也能满足大部分三维可视化演示需求日常项目里非常实用。本文还有配套的精品资源点击获取