资讯动态

OSG海面与月夜场景渲染:网格、粒子与反射相机的完整实践

发布时间:2026/9/14 1:30:17 来源:尧图企业网站定制
简介这是基于OpenSceneGraph与osgOcean插件的三维海面场景工程面向OSG初级开发者及虚拟仿真、数字孪生方向的学习者演示在动态海面上添加小船并叠加月光、风力与水面倒影效果可用来理解海洋场景搭建与真实感渲染的完整流程。资源包共173个文件压缩后约42.94MB核心部分包括C工程源码、顶点/片元着色器、obj与3ds模型文件以及可直接运行的exe和依赖dllpng纹理和h头文件分别用于贴图与模块声明目录结构清晰便于对照学习。已有249人浏览学习。通过源码可以掌握场景图组织、天空盒实现、osgOcean海浪参数调节以及光照与反射倒影的联动设置配合现有模型和可执行程序能快速运行观察效果并在此基础上扩展用于游戏开发、虚拟旅游或航海模拟等场景。整体工程结构简洁适合在此基础上改造与调试。1. osg 海面与月夜场景一次覆盖渲染、粒子与反射的完整拼装这个标题看起来像是美术需求实际落到 OSG 里是一次把五个子系统拼进同一个场景的工程练习海面网格的顶点扰动、外部模型加载、天空与光照、粒子风场、反射相机。任何一个环节单独拆出来都能讲半小时但放在一个场景里真正的难点是它们之间的坐标和时序关系。小船要浮在水面上而不是扎进波峰里月亮要有光但又不抢海面的反射层次倒影要跟着风向微微扭曲而不是像贴了张静态图片。适合谁来读正在用 OSG 做视景仿真或数字孪生场景的开发者以及想把自研引擎里那套“水面 天空 模型”经验平移到 OSG 的人。这篇文章不站在渲染理论的高处而是顺着一个可运行的场景把每一步的节点类型、参数和容易翻车的位置讲清楚。2. osg 海面与小船的加载先让水面和模型出现在同一坐标系2.1 用几何体造一片可交互的海面网格密度决定波浪精度做海面第一反应是找现成的水面节点但 OSG 官方没有内置“海洋”节点社区里的方案大多是基于Geometry生成网格再交给 shader 做顶点动画。最常用也最可控的做法是创建一张平面网格宽高按场景需求定分片数决定波浪的细节层次。分片太少远处波浪会出现明显棱边分片太多顶点着色器压力大。对一公里见方的视景来说512x512 的网格通常够用近处看得到波浪起伏远处也不会让 GPU 太吃力。// 生成一片海面网格numSegments 控制分片数sizeMeters 控制实际大小 osg::ref_ptrosg::Vec3Array vertices new osg::Vec3Array; osg::ref_ptrosg::Vec2Array texcoords new osg::Vec2Array; osg::ref_ptrosg::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_ptrosg::Node shipModel osgDB::readRefNodeFile(ship.osgb); if (!shipModel) { // 返回失败时先检查文件路径再检查 osgdb 插件是否完整 return nullptr; } // 调整朝向DCM 里船头默认朝 Y海面场景里希望它朝 X osg::ref_ptrosg::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_ptrosg::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_ptrosg::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_ptrosg::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_ptrosgParticle::ParticleSystem ps new osgParticle::ParticleSystem; ps-setDefaultAttributes(textures/smoke.rgb, false, false); osg::ref_ptrosgParticle::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_ptrosgParticle::RandomRateCounter counter new osgParticle::RandomRateCounter; counter-setRateRange(8, 15); // 每帧生成 8 到 15 个粒子 osg::ref_ptrosgParticle::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_ptrosgParticle::RadialShooter shooter new osgParticle::RadialShooter; shooter-setInitialSpeedRange(2.0f, 5.0f); shooter-setPhiRange(-2.0f, -1.5f); // 向下喷射 osg::ref_ptrosgParticle::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_ptrosgParticle::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_castosg::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_ptrosg::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_ptrosg::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_castosg::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。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价