简介:这是基于OpenSceneGraph与osgOcean插件的三维海面场景工程,面向OSG初级开发者及虚拟仿真、数字孪生方向的学习者,演示在动态海面上添加小船,并叠加月光、风力与水面倒影效果,可用来理解海洋场景搭建与真实感渲染的完整流程。资源包共173个文件,压缩后约42.94MB;核心部分包括C++工程源码、顶点/片元着色器、obj与3ds模型文件,以及可直接运行的exe和依赖dll;png纹理和h头文件分别用于贴图与模块声明,目录结构清晰,便于对照学习。已有249人浏览学习。通过源码可以掌握场景图组织、天空盒实现、osgOcean海浪参数调节,以及光照与反射倒影的联动设置;配合现有模型和可执行程序,能快速运行观察效果,并在此基础上扩展,用于游戏开发、虚拟旅游或航海模拟等场景。整体工程结构简洁,适合在此基础上改造与调试。
1. osg 海面与月夜场景:一次覆盖渲染、粒子与反射的完整拼装
这个标题看起来像是美术需求,实际落到 OSG 里,是一次把五个子系统拼进同一个场景的工程练习:海面网格的顶点扰动、外部模型加载、天空与光照、粒子风场、反射相机。任何一个环节单独拆出来都能讲半小时,但放在一个场景里,真正的难点是它们之间的坐标和时序关系。小船要浮在水面上而不是扎进波峰里,月亮要有光但又不抢海面的反射层次,倒影要跟着风向微微扭曲而不是像贴了张静态图片。适合谁来读?正在用 OSG 做视景仿真或数字孪生场景的开发者,以及想把自研引擎里那套“水面 + 天空 + 模型”经验平移到 OSG 的人。这篇文章不站在渲染理论的高处,而是顺着一个可运行的场景,把每一步的节点类型、参数和容易翻车的位置讲清楚。
2. osg 海面与小船的加载:先让水面和模型出现在同一坐标系
2.1 用几何体造一片可交互的海面:网格密度决定波浪精度
做海面第一反应是找现成的水面节点,但 OSG 官方没有内置“海洋”节点,社区里的方案大多是基于Geometry生成网格,再交给 shader 做顶点动画。最常用也最可控的做法是创建一张平面网格,宽高按场景需求定,分片数决定波浪的细节层次。分片太少,远处波浪会出现明显棱边;分片太多,顶点着色器压力大。对一公里见方的视景来说,512x512 的网格通常够用,近处看得到波浪起伏,远处也不会让 GPU 太吃力。
// 生成一片海面网格:numSegments 控制分片数,sizeMeters 控制实际大小 osg::ref_ptr<osg::Vec3Array> vertices = new osg::Vec3Array; osg::ref_ptr<osg::Vec2Array> texcoords = new osg::Vec2Array; osg::ref_ptr<osg::DrawElementsUInt> indices = new osg::DrawElementsUInt(GL_TRIANGLES, 0); int numSegments = 256; float sizeMeters = 2000.0f; float halfSize = sizeMeters * 0.5f; float step = sizeMeters / numSegments; for (int i = 0; i <= numSegments; ++i) { for (int j = 0; j <= numSegments; ++j) { float x = -halfSize + i * step; float z = -halfSize + j * step; vertices->push_back(osg::Vec3(x, 0.0f, z)); texcoords->push_back(osg::Vec2((float)i / numSegments, (float)j / numSegments)); } } for (int i = 0; i < numSegments; ++i) { for (int j = 0; j < numSegments; ++j) { int row0 = i * (numSegments + 1) + j; int row1 = (i + 1) * (numSegments + 1) + j; indices->push_back(row0); indices->push_back(row1); indices->push_back(row0 + 1); indices->push_back(row1); indices->push_back(row1 + 1); indices->push_back(row0 + 1); } }顶点的 Y 坐标统一设为 0,波浪交给 shader 在gl_Vertex上做正弦叠加,这样 CPU 端不需要每帧更新顶点数组,性能开销几乎为零。网格的 UV 直接用顶点索引归一化,后续做法线贴图或反射采样时能用同一套 UV 对齐。
生成几何体后,把它挂到osg::Geode上,再给这个 Geode 设置一个带顶点着色器和片元着色器的osg::Program。shader 里至少要做三件事:叠加多层正弦波作为高度偏移、用偏移量重新计算法线、把高度值输出到颜色或后续反射系数里。注意顶点着色器里做波动时,法线不能只在 CPU 端预先算好,否则光照方向对不上波浪形状。
2.2 小船模型就位:osgconv 与 assimp 转换的取舍
小船这类模型很少直接是 osg 原生格式,最常见的是 glTF、FBX 或 OBJ。OSG 自带的osgconv工具可以完成格式转换,也支持加载部分外部格式,但遇到复杂材质或动画节点时容易丢失信息。社区里现在更稳的路径是让 assimp 先解析再转存。那篇热帖说的“assimp转换为osg”其实就是这个流程:assimp 读入模型,手动遍历它的场景树,把 mesh、材质、节点变换逐一映射到 OSG 节点。
如果只想要一个能立刻用的快速路径,命令行转格式最省事。
# 把 glTF 格式的小船转成 osg 能直接读的格式 osgconv ship.gltf ship.osgb # 转成 ascii 格式方便排查节点层级 osgconv ship.gltf ship.osgosgb是二进制格式,体积小加载快,适合发布版本;osg是文本格式,可以用文本编辑器直接看节点结构,调试阶段建议转成它。转换完成后不要立刻摆进场景,先用osgviewer ship.osg看一眼模型的朝向和尺度。不同建模软件导出的模型坐标轴约定不一致,有的 Y 轴朝上,有的 Z 轴朝上,这一步不检查,后面摆位时会花更多时间。
加载代码相对简单,关键在于加载后要主动做一次坐标校准。
osg::ref_ptr<osg::Node> shipModel = osgDB::readRefNodeFile("ship.osgb"); if (!shipModel) { // 返回失败时先检查文件路径,再检查 osgdb 插件是否完整 return nullptr; } // 调整朝向:DCM 里船头默认朝 +Y,海面场景里希望它朝 +X osg::ref_ptr<osg::MatrixTransform> shipTransform = new osg::MatrixTransform; shipTransform->setMatrix(osg::Matrix::rotate(osg::PI_2, osg::Vec3(0, 0, 1)) * osg::Matrix::scale(1.0, 1.0, 1.0)); shipTransform->addChild(shipModel);模型加载失败时不要马上怀疑文件,先看osgDB::readRefNodeFile的插件目录是否正确。OSG 的插件按格式分发,如果编译时没带上 gltf 插件,即使文件路径正确也会返回空指针。换成ship.osgb就能绕开这个问题,这也是我倾向先把模型转成内置格式的原因。
小船挂进场景后,需要处理浮力问题。第一版可以直接把船固定在某个 Y 值上,比如船底略低于海面基准 0.1 米,视觉上就是“吃水”的状态。后续做风浪交互时再让这个 Y 值随波浪高度抖动,详见第四章。
3. osg 月亮与光线的层次:天空盒、方向光与夜景基调
3.1 用天空模型模拟夜空与月盘
夜空的实现方案不外乎三种:贴满天空盒的osg::TexGenCubeMap、用半球模型贴渐变纹理、直接用osg::Skybox节点。多数 OSG 项目里osg::Skybox是首选,它本身是osg::Transform的子类,会跟随视点移动,让天空永远处于无限远处。对海面场景来说,视线大部分时间朝向水平方向,天空盒只需要一张高分辨率夜空贴图和一张带透明通道的月亮贴图就够了。
实际搭建时,我会把月盘作为天空盒的独立子节点而不是直接画进贴图里,原因是月亮在夜景里是光源的一部分,独立出来可以做光晕和后续的光照联动。一个常见的做法是用osg::Billboard挂一张圆形渐变贴图,让它永远面向相机,再用叠加混合模式(GL_ONE, GL_ONE)把光晕叠到天空背景上。
// 月亮光晕:billboard 永远面向相机,并手动指定位置 osg::ref_ptr<osg::Billboard> moonHalo = new osg::Billboard; moonHalo->setMode(osg::Billboard::POINT_ROT_EYE); moonHalo->addChild(haloGeometry.get(), osg::Vec3(-300.0f, 0.0f, 400.0f)); moonHalo->setStateSet(haloState.get()); // 开启透明混合这里的POINT_ROT_EYE模式让 billboard 绕眼点旋转,保证光晕始终正对相机。位置是相对世界坐标的,把月亮放在视线前方偏高处,视角转动时不会出现月盘跟着动的违和感。光晕贴图的 alpha 通道要提前从中心向边缘做径向衰减,不然边缘会出现生硬的方形。
3.2 月光的颜色与强度设置
月光本质上是平行光,用osg::Light的LIGHT0即可。但月色和日光的色温差异非常大,日光色温偏高偏白,月光色温偏低偏蓝。参数上我会把漫反射颜色设为(0.6, 0.7, 1.0),强度控制在 0.3 到 0.5 之间,这样海面 shader 里对漫反射的响应不至于过曝。osg::Light的方向要指向海面中心。
光照方向与观察方向的夹角会影响月光在海面上的可见程度,这正好与“倒影”联动:月光越倾向水平,海面上的反射光带越长,月亮倒影也越明显。所以灯光方向不要做成垂直向下,压到与海面夹角 10 度到 20 度之间,场景才有月夜的味道。
osg::ref_ptr<osg::Light> moonlight = new osg::Light; moonlight->setLightNum(0); moonlight->setAmbient(osg::Vec4(0.05f, 0.06f, 0.1f, 1.0f)); moonlight->setDiffuse(osg::Vec4(0.5f, 0.6f, 0.9f, 1.0f)); moonlight->setSpecular(osg::Vec4(0.3f, 0.35f, 0.6f, 1.0f)); moonlight->setPosition(osg::Vec4(0.0f, 0.0f, 1.0f, 0.0f)); // 方向光 moonlight->setDirection(osg::Vec3(-0.1f, -0.2f, -0.97f));注意setPosition的 w 分量传 0.0 表示方向光,w 传 1.0 才是点光源。这里的setDirection与setPosition是配合使用的,OpenGL 固定管线里方向由 Position 与 Direction 共同决定,在 OSG 里直接以setDirection为准,但 w 分量决定光源类型,写错的话光照效果会有明显偏差。
3.3 月亮的光晕与反射光的联动
月光和海面反射的联动,最直接的做法是把月亮方向和强度作为 uniform 传进海面 shader。片元着色器里可以根据视线反射向量和月亮方向的点积,计算出月亮光带的强度与位置,这在第五章反射贴图里会作为叠加层处理。这个联动关系需要在 shader 里留一个 uniform 接口,后面调参时只需改场景里的osg::Light,不用重新编译 shader。
// 从场景的 light 里把月光方向提取出来传给 shader osg::ref_ptr<osg::Uniform> moonDirUniform = new osg::Uniform( "moonDir", osg::Vec3(-0.1f, -0.2f, -0.97f)); stateSet->addUniform(moonDirUniform, osg::StateAttribute::ON);这个 uniform 在后面做水面镜面高光时直接参与计算。这里有个容易忽略的细节:如果是夜景,环境光需要降到很低,但不要设成 0,否则背光面的模型会变成纯黑一片,看起来像是贴图丢失。保留 0.05 左右的环境光,既能保持夜色,又能让模型轮廓有辨识度。
4. 风的表现:osg 粒子系统与波浪抖动的协同
4.1 用 osgParticle 构建风场粒子
风的视觉表现有两个层面:一是可见粒子,比如海面上飘过的水雾、浪尖的碎沫;二是不可见但能感知的动态,比如海面波浪的传播方向和船的摇摆。OSG 的osgParticle::PrecipitationEffect通常用来做雨雪,对海面场景略重;轻量做法是直接构建osgParticle::ParticleSystem配合osgParticle::ModularProgram。
粒子的实现逻辑是:发射器生成粒子,粒子经历生命周期,程序通过一系列 operator 修改它们的位置和速度。海面风场的粒子密度不用太高,100 到 200 个即可,效果重在方向性而不是数量。
// 粒子系统:从海面上方发射,沿风向运动,生命周期结束后消散 osg::ref_ptr<osgParticle::ParticleSystem> ps = new osgParticle::ParticleSystem; ps->setDefaultAttributes("textures/smoke.rgb", false, false); osg::ref_ptr<osgParticle::Particle> particleTemplate = new osgParticle::Particle; particleTemplate->setLifeTime(4.0f); particleTemplate->setSizeRange(osgParticle::rangef(0.8f, 2.5f)); particleTemplate->setAlphaRange(osgParticle::rangef(0.35f, 0.0f)); particleTemplate->setColorRange(osgParticle::rangev4( osg::Vec4(0.8f, 0.85f, 0.9f, 0.5f), osg::Vec4(0.9f, 0.92f, 1.0f, 0.0f))); ps->setDefaultParticleTemplate(*particleTemplate); osg::ref_ptr<osgParticle::RandomRateCounter> counter = new osgParticle::RandomRateCounter; counter->setRateRange(8, 15); // 每帧生成 8 到 15 个粒子 osg::ref_ptr<osgParticle::BoxPlacer> placer = new osgParticle::BoxPlacer; placer->setVolume(osg::Vec3(600.0f, 200.0f, 20.0f)); placer->setAxis(osg::Vec3(0.0f, 1.0f, 0.0f)); osg::ref_ptr<osgParticle::RadialShooter> shooter = new osgParticle::RadialShooter; shooter->setInitialSpeedRange(2.0f, 5.0f); shooter->setPhiRange(-2.0f, -1.5f); // 向下喷射 osg::ref_ptr<osgParticle::ModularProgram> program = new osgParticle::ModularProgram; program->setParticleSystem(ps); program->addOperator(new osgParticle::AccelOperator(osg::Vec3(1.5f, 0.0f, -0.2f))); program->addOperator(new osgParticle::DampingOperator(0.1f));BoxPlacer决定粒子从哪个范围产生,RadialShooter控制初速度方向与大小。这里把粒子发射区域放在海面上方 20 米的空间内,初速度方向朝下再叠加一个水平加速度,模拟海风裹挟水汽的轨迹。DampingOperator的 0.1 会让粒子逐渐减速,看起来更自然,而不是匀速飞出屏幕。
粒子系统挂到场景里之后,需要在每帧更新它。ModularProgram不会自动执行,必须挂到osg::NodeCallback或交给osgParticle::ParticleSystemUpdater统一管理。
osg::ref_ptr<osgParticle::ParticleSystemUpdater> updater = new osgParticle::ParticleSystemUpdater; updater->addParticleSystem(ps); root->addChild(updater);忘记加ParticleSystemUpdater是新手最常踩的坑,表现是粒子只出现一帧或者完全不显示。因为粒子系统的内部状态更新是被 updater 驱动的,没有 updater 就等于粒子永远停留在初始状态。
4.2 让海面顶点跟随风的节奏
粒子只解决了空气中的风,水面的风需要通过海面网格的顶点动画来表现。上一章的海面 shader 里,顶点高度的正弦波应该有一个随时间的偏移量和一个方向向量。方向向量的 xz 分量应该与粒子系统的风向保持一致,否则会看到风往东吹,浪往北走的割裂感。
顶点着色器里的波函数要加上方向参数:
// vertex shader 片段 uniform float u_time; uniform vec2 u_windDir; // 风向,归一化的 xz 向量 uniform float u_windSpeed; void main() { vec3 pos = gl_Vertex.xyz; float d = dot(pos.xz, u_windDir); float wave1 = sin(d * 0.08 + u_time * 1.5) * 0.6; float wave2 = sin(d * 0.15 + u_time * 2.3) * 0.3; pos.y = wave1 + wave2; gl_Position = gl_ModelViewProjectionMatrix * vec4(pos, 1.0); }u_windDir用osg::Vec2在 CPU 端传入,方向对应粒子系统里的风向。这里有两个关键点:一是波速由u_time的系数决定,系数越大波跑得越快;二是波幅不要设置成静态值,建议绑定到风速 uniform 上,风速大时波幅线性增大,效果上更联动。
4.3 小船随波摇摆:动画回调与物理倾向
船在风浪里不能是静止的,最简单有效的方式是给它挂一个osg::NodeCallback,每帧取当前时间对应的波高,把船的 Y 坐标和旋转角做平滑调整。这里不需要真实浮力物理,视觉上跟着波浪节奏起伏即可。
class ShipWaveCallback : public osg::NodeCallback { public: virtual void operator()(osg::Node* node, osg::NodeVisitor* nv) { osg::MatrixTransform* mt = dynamic_cast<osg::MatrixTransform*>(node); if (mt) { double t = nv->getFrameStamp()->getSimulationTime(); float y = sin(t * 1.5) * 0.4 + 0.1; float roll = sin(t * 1.2) * 0.05; float pitch = cos(t * 1.8) * 0.03; mt->setMatrix(osg::Matrix::rotate(roll, osg::Vec3(0, 0, 1)) * osg::Matrix::rotate(pitch, osg::Vec3(1, 0, 0)) * osg::Matrix::translate(osg::Vec3(0, y, 0))); } traverse(node, nv); } };回调里nv->getFrameStamp()->getSimulationTime()获取的是累计时间,用它做 sin 和 cos 的变量,帧率不会影响运动速度。roll 和 pitch 的幅度控制在 5 度以内,超过这个值船会显得像在做过山车。回调挂在MatrixTransform上,注意必须调用traverse(node, nv),否则模型子节点不会渲染。
5. 倒影是最后一步:用 osg::Camera 做反射贴图并优化
5.1 反射方案的对比与选型
水面倒影在 OSG 里有几种实现路径:贴花法、平面反射纹理(Planar Reflection)、实时 CubeMap。贴花法的成本最低,但小船和月亮是动态的,贴花无法随时间变化,只能用于静止场景。实时 CubeMap 需要六个面的渲染,性能开销过大,通常只用于室内镜面或车辆烤漆效果。对这个场景,osg::Camera做渲染到纹理(RTT)的平面反射是最合适的选择。
平面反射的本质很简单:把相机相对于水面的位置做镜像,渲染一次场景到纹理,再把纹理贴到水面上。关键在于镜像矩阵的计算和反射相机的近裁剪面设置。
| 方案 | 性能开销 | 动态更新 | 适用场景 |
|---|---|---|---|
| 贴花法 | 极低 | 否 | 静态水面 |
| RTT 平面反射 | 中 | 是 | 海面、地面、镜面 |
| CubeMap 实时反射 | 高 | 是 | 车辆、金属表面 |
5.2 反射相机与水面 shader 的桥接
反射相机的实现方式是创建一个osg::Camera,设置渲染目标为纹理,每帧把镜像后的场景画进纹理,然后把纹理透传进海面 Shader。
// 反射相机基础配置 osg::ref_ptr<osg::Camera> rttCamera = new osg::Camera; rttCamera->setRenderTargetImplementation(osg::Camera::FRAME_BUFFER_OBJECT); rttCamera->setRenderOrder(osg::Camera::PRE_RENDER); rttCamera->setViewport(0, 0, 1024, 1024); osg::ref_ptr<osg::Texture2D> reflectionTex = new osg::Texture2D; reflectionTex->setTextureSize(1024, 1024); reflectionTex->setInternalFormat(GL_RGBA); reflectionTex->setFilter(osg::Texture2D::MIN_FILTER, osg::Texture2D::LINEAR); reflectionTex->setFilter(osg::Texture2D::MAG_FILTER, osg::Texture2D::LINEAR); rttCamera->attach(osg::Camera::COLOR_BUFFER, reflectionTex);FRAME_BUFFER_OBJECT是 FBO 渲染,不需要创建窗口。纹理尺寸 1024 在移动设备上偏大,但 PC 上可以接受;如果跑不满 60 帧,优先降到 512。
相机要在回调里更新反射矩阵,因为视点每帧都在变。
class ReflectionCallback : public osg::NodeCallback { virtual void operator()(osg::Node* node, osg::NodeVisitor* nv) { osg::Camera* camera = dynamic_cast<osg::Camera*>(node); if (camera && nv->getViewPoint()) { osg::Vec3 eye = nv->getViewPoint()->getEyePoint(); // 水面高度取 0.0,镜像只反转 Y 轴 osg::Vec3 reflectedEye(eye.x(), -eye.y(), eye.z()); osg::Matrix m; m.makeLookAt(reflectedEye, reflectedEye + osg::Vec3(0, -1, 0), osg::Vec3(0, 0, 1)); camera->setViewMatrix(m); } traverse(node, nv); } };注意这里 Y 轴反转的前提是水面所在的平面方程是 y = 0。如果水面不是这个高度,需要先做平移变换,让镜像平面的位置对齐到水面高度。水面上涨或下落时,这个回调里的反复值也需要同步。
海面 shader 使用反射纹理时,UV 要经过投影变换,把纹理坐标从 [-1, 1] 映射到 [0, 1]。这一步可以放在顶点着色器里,片元着色器直接采样。
// 顶点着色器 varying vec4 v_projCoord; // 主相机投影矩阵与模型视图矩阵的乘积通过 uniform 传入 v_projCoord = gl_TextureMatrix[0] * gl_Vertex;反射纹理和前面做好的风场叠加,最终片元着色器里混合两层:一是反射纹理的颜色,二是月光在镜面方向的高光。两者用顶点法线和视线方向算权重,实现“有风时倒影边缘模糊,无风时倒影清晰”的动态切换。
5.3 一个实用技巧:用近裁剪面规避水面穿插
反射渲染中常见的视觉 bug 是水面自身的网格被反射相机拍到,导致倒影里出现一片深色的湖底或穿透面。规避方法有两个方向:一是反射相机渲染时把裁剪面设为水面以上 0.1 米的平面,水面以下的物体不会被画进纹理;二是给水面材质在反射渲染时设置osg::StateSet的渲染掩码,让它不被反射相机观察到。
我一般用第二种,代码侵入更小。
// 水面节点在反射相机中不可见 rttCamera->setNodeMask(0x0); rttCamera->addChild(waterNode); // 正常渲染时恢复掩码 waterNode->setNodeMask(0xffffffff);反射相机和水面节点的这个掩码关系,需要在切换时明确维护。如果不做这步,水面上会出现杂散的暗色噪点,尤其是在波浪起伏大的区域,排查起来很费劲。
5.4 验证倒影效果与性能观察
倒影做完了,怎么验证是对的?打开 osgviewer 按住鼠标旋转视角,观察月亮的倒影是否始终与真实月亮关于海面对称;把船开动或旋转,倒影里的船应该与真实船保持镜像关系。如果倒影里看不到船,先查反射相机是否把船的节点掩码算进去;如果倒影里出现两个月亮,则是镜像矩阵里忽略了水面高度,导致反射平面错位。
性能方面重点关注反射纹理的更新频率。FBO 每帧更新一次就意味着场景绘制次数几乎翻倍。一个常用技巧是降低反射相机的渲染频率,比如每 3 帧渲染一次反射,中间帧继续用上一次的纹理。观感差异不大,但帧率能有明显提升。实现方式是在ReflectionCallback里用帧号取模,帧号不能被 3 整除时直接跳过setViewMatrix,但注意相机仍然要执行traverse。
本文还有配套的精品资源,点击获取