简介:基于OSG的海洋场景项目包,是一份面向三维仿真开发者和OSG学习者的实用案例,用于演示在动态海面上添加小船、模拟月光、风力吹拂以及实时水面倒影的完整实现思路。压缩包共包含一百七十三个文件,整体大小约四十二点九四兆字节,其中既有PNG纹理、frag与vert着色器、obj、3ds、ive模型等资源文件,也提供了C++源文件、头文件、Visual Studio工程配置以及编译好的exe程序与动态库,方便直接运行和对照学习。已有二百四十九人浏览学习。项目以osgOcean插件为渲染核心,涵盖了海浪网格生成、风向风力参数调节、天空盒构建与月光源设置、水面反射与折射等关键模块;运行后可看到月光下的海面小船随波浪轻微起伏,倒影清晰,整体极具沉浸感。对于想研究OSG场景图、着色器编程或海洋仿真的开发者,这套资料能提供从工程结构到渲染效果的直观参考,省去从零搭建环境的成本。
1. osg 海面场景:小船、月亮、风、倒影要怎么搭
一个标题里塞了四个视觉元素,实际考验的是你在 osg 里把场景图拆开再拼回去的能力:海面是带动态顶点坐标的网格,小船是外部模型,月亮是带光源的公告板,风和倒影分别是粒子和镜像处理。不要一开始就找“海面插件”或者“月亮特效”,OSG 里这些效果基本都是手写节点组合。这篇文章会从一张可运行的海面网格出发,加船、加月亮、加风粒子,最后用一个低成本倒影让场景完整。你会看到每个元素如何挂到根节点上,也会看到帧率跑不动时第一个该缩的是哪个参数。
2. 用 osgParticle 给海面加风浪:网格与粒子分开做
海面的第一版最容易写错:以为“风”等于把整个海平面吹歪,于是把所有顶点随机抬一下,结果画面完全不像水。正确做法是把海面和风拆成两层:海面网格负责大面积波动,粒子和动画负责视觉上的浪花与风的方向。两层共用一张大纹理时,量级和频率都不同,视觉才不违和。
2.1 海面网格为什么不能只做平面
一个最朴素的海面是 256×256 的矩形网格,每个顶点是 one position,uv 按比例分布。要让它有水感,必须让顶点高度随时间变化,并且变化不能是均匀上下的,而要以正弦波叠加的方式沿着某个风向传播。这里我倾向于用 osg::Geometry 加一个 NodeCallback 更新顶点数组,而不是把整个海面做成刚体位移。
2.1.1 几何节点和海面 UpdateCallback 的最小结构
#include <osg/Geometry> #include <osg/Geode> #include <osg/NodeCallback> #include <osg/Vec3> #include <cmath> class SeaUpdate : public osg::NodeCallback { public: SeaUpdate(osg::Geometry* geom, int gridSize) : _geom(geom), _size(gridSize), _time(0.0f) {} virtual void operator()(osg::Node* node, osg::NodeVisitor* nv) override { _time += 0.016f; // 约 60FPS 的步进,实际应该用 nv->getFrameStamp()->getSimulationTime() osg::Vec3Array* verts = static_cast<osg::Vec3Array*>(_geom->getVertexArray()); for (unsigned int i = 0; i < verts->size(); ++i) { osg::Vec3& v = (*verts)[i]; float x = v.x(); float z = v.z(); // 两个不同频率的正弦波叠加,模拟风的动量传递 float y1 = std::sin(x * 0.5f + _time * 1.3f) * 0.8f; float y2 = std::sin(z * 0.3f - _time * 0.9f) * 0.4f; v.y() = y1 + y2; } _geom->dirtyDisplayList(); _geom->dirtyBound(); traverse(node, nv); } private: osg::ref_ptr<osg::Geometry> _geom; int _size; float _time; };这段代码的关键不是波形本身,而是dirtyDisplayList()和dirtyBound()。OSG 在默认情况下会把几何体缓存成显示列表,不改这两个标志,顶点数组改了也不会重画。dirtyBound()是为了让包围盒跟随海面高度变化,否则视锥裁剪可能把最高的浪尖裁掉。正式项目里不要把时间写在+= 0.016f,要用FrameStamp里的真实时间,否则视口渲染速度变化,波浪速度会跟着变。
海面网格本身用DrawArrays(GL_TRIANGLES)绘制,绘制顺序要按行列生成,推荐用 strip 而不是 triangles,因为相同网格尺寸下 strip 能少传大量顶点索引。
2.2 用 osgParticle 做可见的风与水雾
风是不可见的东西,想让用户“看到风”,常见做法是让粒子沿着风向飘。OSG 自带的 osgParticle 库足够做一组从小船旁边飞过的白雾粒子,不需要引入新的依赖。
2.2.1 一个发射到船侧的风浪粒子发射器
#include <osgParticle/ParticleSystem> #include <osgParticle/ModularEmitter> #include <osgParticle/RadialShooter> #include <osgParticle/ParticleSystemUpdater> #include <osg/Texture2D> #include <osg/BlendFunc> osg::ref_ptr<osgParticle::ParticleSystem> createWindParticles(osg::Texture2D* tex) { osg::ref_ptr<osgParticle::ParticleSystem> ps = new osgParticle::ParticleSystem; ps->setMaxParticleCount(800); osgParticle::Particle* p = &ps->getDefaultParticleTemplate(); p->setShape(osgParticle::Particle::QUAD); p->setLifeTime(2.5f, 4.0f); p->setSizeRange(osgParticle::rangev2(0.4f, 1.0f)); p->setAlphaRange(osgParticle::rangef(0.05f, 0.45f)); p->setColorRange(osgParticle::rangev4( osg::Vec4(0.9f, 0.95f, 1.0f, 0.4f), osg::Vec4(0.6f, 0.7f, 0.8f, 0.0f))); osg::ref_ptr<osgParticle::ModularEmitter> emitter = new osgParticle::ModularEmitter; emitter->setParticleSystem(ps.get()); emitter->setParticleDuration(6.0f); emitter->setCounter(new osgParticle::RandomRateCounter(20.0f, 40.0f)); osgParticle::PointPlacer* placer = new osgParticle::PointPlacer; placer->setCenter(osg::Vec3(2.0f, 1.0f, -1.0f)); emitter->setPlacer(placer); osgParticle::RadialShooter* shooter = new osgParticle::RadialShooter; shooter->setThetaRange(-0.5f, 0.5f); shooter->setPhiRange(0.6f, 1.2f); shooter->setInitialSpeedRange(1.2f, 2.8f); emitter->setShooter(shooter); osg::ref_ptr<osgParticle::ParticleSystemUpdater> updater = new osgParticle::ParticleSystemUpdater; updater->addParticleSystem(ps.get()); ps->setDefaultAttributes(tex, true, false); return ps.get(); }这段代码里最容易被忽视的是setDefaultAttributes(tex, true, false):第二个参数表示粒子纹理需要光照,第三个表示不使用深度测试。水面雾气一般开着深度测试反而会让粒子被海面遮挡到看不见,所以我关掉深度测试并开启混合,再用 Alpha 值做淡出。PointPlacer的 center 是粒子出生位置,它在海面上方 1 米左右,能保证粒子不会从海底冒出来。
2.2.2 风的方向:调整 Theta 和速度范围
| 参数 | 取值范围示例 | 效果 |
|---|---|---|
setThetaRange | -0.3f, 0.3f | 粒子在水平方向上的散射角,值越小风越集中 |
setPhiRange | 0.5f, 1.2f | 粒子相对垂直方向的散射角,模拟浪花飞溅 |
setInitialSpeedRange | 1.2f, 2.8f | 初速下限和上限,风速越大粒子越远 |
setParticleDuration | 4.0f ~ 8.0f | 发射器存活时间,可以配合回调关闭持续发射 |
setAlphaRange | 0.05f ~ 0.45f | Alpha 最大值越高,雾越浓郁,小心遮挡小船 |
到这一步,你能看到一个一直在起伏的海面和一小股水雾从船侧飘过。如果把ParticleSystemUpdater加到根节点,不要忘了给它addChild一个空 Geode 来挂粒子系统,否则 updater 不会进入场景图的更新遍历。
3. assimp 转换后加载小船模型:顶点、材质和原点对齐
小船模型通常来自建模软件,格式可能是.fbx、.dae或.gltf。OSG 自带读不了那么多种格式,所以常走 assimp 这条路。关于“osg 用 assimp”的最稳做法不是让 assimp 直接输出.osg文件,而是用 assimp 把模型数据导入内存,再把顶点、材质、骨骼逐项复制到 OSG 节点上。这个过程也就一百多行代码。
3.1 assimp 转换 osg 的三条路,哪条更可靠
先区分三种做法:
- 让 assimp 导出成中间格式,再用 osgDB 读,例如导出成
.obj再转.osgb。这个方式依赖导出器对纹理路径的处理,经常丢材质。 - 直接编译
osgdb_assimp插件,让 osgDB 读.fbx或.dae。这是最快的,但插件版本和 assimp 库版本要精确匹配,否则运行时报找不到插件的错误。 - 用 assimp C++ API 遍历
aiScene,手动构建osg::Geometry。可控性最高,能顺手做单位换算和坐标轴修改。
我一般选第三种,尤其当模型里带着旋转动画或骨骼时,手写遍历反而更好处理。下面给一个只处理静态网格的转换框架,能覆盖船体这类不带骨骼的模型。
3.2 用 assimp 把 boat model 转成 osg 节点
#include <assimp/Importer.hpp> #include <assimp/scene.h> #include <assimp/postprocess.h> #include <osg/Geometry> #include <osg/Geode> #include <osg/MatrixTransform> osg::Node* loadBoatWithAssimp(const std::string& path, float scale, osg::Vec3 offset) { Assimp::Importer importer; const aiScene* scene = importer.ReadFile( path, aiProcess_Triangulate | aiProcess_JoinIdenticalVertices | aiProcess_GenNormals | aiProcess_FlipUVs); if (!scene || !scene->mRootNode) { return nullptr; } osg::ref_ptr<osg::Group> group = new osg::Group; for (unsigned int m = 0; m < scene->mNumMeshes; ++m) { aiMesh* mesh = scene->mMeshes[m]; osg::ref_ptr<osg::Vec3Array> verts = new osg::Vec3Array; osg::ref_ptr<osg::Vec3Array> normals = new osg::Vec3Array; osg::ref_ptr<osg::Vec2Array> uvs = new osg::Vec2Array; for (unsigned int v = 0; v < mesh->mNumVertices; ++v) { aiVector3D pos = mesh->mVertices[v]; verts->push_back(osg::Vec3(pos.x, pos.y, pos.z) * scale); if (mesh->mNormals) { aiVector3D n = mesh->mNormals[v]; normals->push_back(osg::Vec3(n.x, n.y, n.z)); } if (mesh->mTextureCoords[0]) { aiVector3D tc = mesh->mTextureCoords[0][v]; uvs->push_back(osg::Vec2(tc.x, tc.y)); } } osg::ref_ptr<osg::Geometry> geom = new osg::Geometry; geom->setVertexArray(verts.get()); if (!normals->empty()) { geom->setNormalArray(normals.get(), osg::Array::BIND_PER_VERTEX); } if (!uvs->empty()) { geom->setTexCoordArray(0, uvs.get()); } for (unsigned int f = 0; f < mesh->mNumFaces; ++f) { aiFace* face = &mesh->mFaces[f]; osg::ref_ptr<osg::UIntArray> indices = new osg::UIntArray; for (unsigned int i = 0; i < face->mNumIndices; ++i) { indices->push_back(face->mIndices[i]); } geom->addPrimitiveSet(new osg::DrawElementsUInt( GL_TRIANGLES, indices->size(), (GLuint*)indices->getDataPointer())); } osg::ref_ptr<osg::Geode> geode = new osg::Geode; geode->addDrawable(geom.get()); group->addChild(geode.get()); } osg::ref_ptr<osg::MatrixTransform> mt = new osg::MatrixTransform; mt->setMatrix(osg::Matrix::translate(offset)); mt->addChild(group.get()); return mt.release(); }这段代码里aiProcess_FlipUVs很关键:大部分建模软件用的 UV 坐标和 OpenGL 期望的是上下相反的,不翻转纹理贴图会上下颠倒。scale是单位换算,很多船模是厘米建模,放进以米为单位的场景里会大一百倍,所以单独缩一次比在 OSG 里反复调MatrixTransform更直观。面索引读取时注意从aiMesh的mFaces中取,不能直接拿整个 mesh 的 index buffer,assimp 会为相邻 mesh 复用不同的索引偏移。
顺带回答经常被问到的“osg 可以加载 las 吗”:LAS 是点云格式,OSG 默认不含 LAS 解算器,需要找专门的 las 插件把点云读成顶点数组。如果只拿它当一组点看待,原理和上面遍历 mesh 没有区别,只是少了面片索引。
3.3 模型加载后的位置和朝向处理
加载完小船后,通常需要把船底放在海平面附近,否则船会悬空或陷进水面。假设海面在 y=0,船体包围盒底部是-0.8,那么偏移量就是:
osg::Vec3 center = boatNode->getBound().center(); float bottom = center.y() - boatNode->getBound().radius(); boatNode->asTransform()->asMatrixTransform()->setMatrix( osg::Matrix::translate(0.0f, -bottom, 0.0f));这里用包围盒半径估算底部是个粗糙办法,只适合对称船模。精度要求高时,应该通过 assimp 遍历每个 mesh 的最小顶点 Y,再和包围盒合并,得到真正的几何下边界。这个值用于后续倒影节点的高度参数,错的话倒影会错开。
4. 月亮与倒影:一个 billboard 加一份翻转节点
月亮在场景里其实是一个很小的部分,但它的位置决定了光照和倒影的拍摄方向。做月亮的最好办法是把它放在远离场景的坐标上,再用方向光指向场景,这样倒影和光照方向保持一致。倒影则有两种主流实现:翻转模型和反射纹理,按帧率和显卡能力取舍。
4.1 用 Billboard 做一个月亮,同时照亮小船
OSG 的osg::Billboard可以保证一个多边形始终正对相机,适合贴圆形月亮纹理。
4.1.1 月亮贴纸和光源的组合
#include <osg/Billboard> #include <osg/Texture2D> #include <osg/LightSource> #include <osg/StateSet> #include <osg/Material> osg::ref_ptr<osg::Geode> createMoon(osg::Texture2D* moonTex, osg::Vec3 position) { osg::ref_ptr<osg::Billboard> billboard = new osg::Billboard; billboard->setMode(osg::Billboard::POINT_ROT_EYE); osg::ref_ptr<osg::Geometry> quad = osg::createTexturedQuadGeometry( osg::Vec3(-15.0f, -15.0f, 0.0f), osg::Vec3(30.0f, 0.0f, 0.0f), osg::Vec3(0.0f, 30.0f, 0.0f)); quad->getOrCreateStateSet()->setTextureAttributeAndModes(0, moonTex); billboard->addDrawable(quad.get(), osg::Vec3(position.x(), position.y(), position.z())); billboard->getOrCreateStateSet()->setMode(GL_BLEND, osg::StateAttribute::ON); billboard->getOrCreateStateSet()->setRenderingHint(osg::StateSet::TRANSPARENT_BIN); osg::ref_ptr<osg::Geode> geode = new osg::Geode; geode->addDrawable(billboard.get()); return geode; }POINT_ROT_EYE模式让 billboard 在视线方向旋转,看上去月亮始终是圆的。这里给quad加一个单独的纹理状态,不要直接往StateSet里丢一个圆图还开着光照,否则纹理会被 scene lighting 染色,月亮变成灰色。接着加一个osg::LightSource,把灯的位置放在月亮附近,方向指向小船中心,强度不要太高,否则海面反光会过曝。
| 光照参数 | 推荐值 | 注意点 |
|---|---|---|
Light::setAmbient | (0.2, 0.2, 0.25, 1) | 蓝色调环境光,模仿夜里天空散射 |
Light::setDiffuse | (0.8, 0.85, 1.0, 1) | 月光偏冷色,别用纯白 |
Light::setPosition | (月亮.x, 月亮.y, 月亮.z, 1) | w=1 表示点光源,w=0 是方向光 |
Light::setConstantAttenuation | 0.2 | 调大可以看到明显的距离衰减 |
4.2 低成本倒影:翻转小船模型并半透明化
真反射的做法是再渲染一遍场景到纹理,但成本高、代码长。一个很实用的替代方案是直接创建一个船模型的镜像节点:沿水平面缩放(1, -1, 1),然后把透明度调到 0.4 左右。这个方案在截图和相机不太动的场景里观感很好,也是全屏反射纹理掉帧时的退路。
#include <osg/MatrixTransform> #include <osg/Material> #include <osg/BlendFunc> #include <osg/Depth> osg::Node* createBoatReflection(osg::Node* boat, float seaLevel, float alpha) { osg::ref_ptr<osg::MatrixTransform> mirror = new osg::MatrixTransform; mirror->setMatrix(osg::Matrix::scale(1.0f, -1.0f, 1.0f) * osg::Matrix::translate(0.0f, 2.0f * seaLevel, 0.0f)); osg::ref_ptr<osg::Node> cloned = dynamic_cast<osg::Node*>(boat->clone(osg::CopyOp::DEEP_COPY_ALL)); mirror->addChild(cloned.get()); osg::ref_ptr<osg::Material> mat = new osg::Material; mat->setDiffuse(osg::Material::FRONT_AND_BACK, osg::Vec4(0.6f, 0.7f, 0.8f, alpha)); mat->setTransparent(osg::Material::FRONT_AND_BACK, true); cloned->getOrCreateStateSet()->setAttributeAndModes(mat.get()); cloned->getOrCreateStateSet()->setMode(GL_BLEND, osg::StateAttribute::ON); cloned->getOrCreateStateSet()->setAttributeAndModes(new osg::Depth(osg::Depth::LEQUAL)); return mirror.release(); }scale(1, -1, 1)之后模型沿 Y 轴翻转,原本 y=2 的船顶变成了 y=-2,所以需要再平移到海平面两侧对称。公式是翻转后的位置 = 2 * seaLevel - 原位置,上面的矩阵组合已经把这个关系写进去了。这里有一个坑:clone(DEEP_COPY_ALL)会连贴图都深拷贝一份,内存开销变大。如果只是做倒影,可以只 copy 几何和状态,不复制纹理对象,但那样代码要多一层自定义节点转换,普通场景直接用 deep copy 更省心。
4.3 反射纹理方案:什么时候才值得用
翻转节点有一个明显缺陷:相机角度改变时,镜像内容不会像真镜子那样随视角变化,海面上的月亮倒影也不会被扭曲。如果要做出更真实的倒影,用osg::Camera做 RTT 反射纹理:
- 创建一个
osg::Camera,把它放在海平面以下,俯视海面,渲染出一个反射纹理。 - 把该纹理贴到海面平面上,用 shader 采样。
- 反射相机必须设置
setRenderOrder在场景主相机之前。
这个方案性能开销大约等于多渲染半场场景,对水面网格细分高的情况会更难受。所以我的建议是:如果目标帧率是 60,船和月亮用翻转节点;如果要发布到 PC 且显卡有余量,再用 RTT 升级海面倒影。无论哪种方案,海面材质必须带一定透明度,否则倒影贴在水面上会被完全不透明的海水盖住。
5. 把全部元素挂到场景树:验证帧数与细分密度
组装场景树时,我会严格按层级来组织,避免后续调试时找不到节点。
5.1 场景树节点拼装顺序
osg::ref_ptr<osg::Group> root = new osg::Group; // 海面几何节点 osg::ref_ptr<osg::Geometry> seaGeom = createSeaGrid(128, 128); osg::ref_ptr<osg::Geode> seaGeode = new osg::Geode; seaGeode->addDrawable(seaGeom.get()); seaGeode->setUpdateCallback(new SeaUpdate(seaGeom.get(), 128)); // 粒子系统 osg::ref_ptr<osgParticle::ParticleSystem> windPs = createWindParticles(fogTex.get()); osg::ref_ptr<osg::Geode> particleGeode = new osg::Geode; particleGeode->addDrawable(windPs.get()); osg::ref_ptr<osgParticle::ParticleSystemUpdater> updater = new osgParticle::ParticleSystemUpdater; updater->addParticleSystem(windPs.get()); // 小船和倒影 osg::ref_ptr<osg::Node> boat = loadBoatWithAssimp("boat.fbx", 0.01f, osg::Vec3(0, 0, 0)); osg::ref_ptr<osg::Node> boatReflection = createBoatReflection(boat.get(), 0.0f, 0.35f); // 月亮和光照 osg::ref_ptr<osg::Geode> moonGeode = createMoon(moonTex.get(), osg::Vec3(80, 40, 120)); osg::ref_ptr<osg::LightSource> moonLight = createMoonLight(osg::Vec3(80, 40, 120)); root->addChild(seaGeode.get()); root->addChild(particleGeode.get()); root->addChild(updater.get()); root->addChild(boat.get()); root->addChild(boatReflection.get()); root->addChild(moonGeode.get()); root->addChild(moonLight.get());这里有几个顺序上的细节:粒子系统更新器必须先于其他节点加入场景,否则粒子位置更新会晚一帧;倒影节点必须加在船节点之后,因为createBoatReflection使用了船模型的包围盒信息,晚加不会有问题,但调试时后加更容易确认它没有遮挡原始船。
5.2 用 ViewerStats 验证帧数和细分
跑起来后用viewer.getViewerStats()->collectStats("frame_rate", true)观察帧率,或者直接开启环境变量OSG_STATS=1。当帧率低于 30,最优先调整的是海面网格细分数:比如从 256×256 减到 96×96。不要先动粒子数,因为粒子是 CPU 端更新,几百个粒子远没有几万顶点影响大。也可以用setDataVariance(osg::Object::DYNAMIC)标记海面几何体,OSG 会跳过 VBO 的 static 优化路径,减少更新时的属性重传。最后检查osg::Viewer::setRunMaxFrameRate,如果你只是看静态图,设一个 60 的上限,省下的 GPU 资源可以都分给反射纹理。
本文还有配套的精品资源,点击获取