深入理解OpenSceneGraph(一):架构设计与核心概念
本文是「深入理解OpenSceneGraph」系列文章的第一篇,将从架构设计的角度剖析OSG这一世界级开源3D图形引擎的核心思想与底层机制。
一、引言:什么是OpenSceneGraph
1.1 OSG的定位与价值
OpenSceneGraph(简称OSG)是一个高性能、可扩展的开源3D图形应用程序接口(API),它基于工业标准的OpenGL,采用场景图(Scene Graph)技术来管理和渲染三维场景。自1998年由Robert Osfield发起以来,OSG已经发展成为全球最具影响力的开源3D引擎之一,被广泛应用于虚拟现实、仿真训练、科学可视化、地理信息系统、游戏开发等众多领域。
与Unity、Unreal Engine等面向游戏开发者的商业引擎不同,OSG走的是一条"专业级中间件"的路线——它不提供完整的游戏开发工具链,而是专注于做一个"好用、高效、灵活"的3D渲染与场景管理库。这种定位使得OSG在工业仿真、军事训练、航空航天等对稳定性和可定制性要求极高的领域拥有不可替代的地位。
1.2 OSG的发展历程
OSG的历史可以追溯到1990年代末。当时,SGI公司的OpenGL Performer是高端图形工作站上事实上的场景图标准,但它价格昂贵且仅支持SGI自家的IRIX系统。Robert Osfield在参与多个3D可视化项目的过程中,深感需要一个开源、跨平台的高性能场景图库,于是在1998年启动了OpenSceneGraph项目。
经过二十多年的发展,OSG已经从一个人的业余项目成长为由全球数百名贡献者共同维护的成熟开源项目。它的代码库包含数十个核心模块、上百个插件、以及数百个示例程序,总代码量超过百万行。OSG支持Windows、Linux、macOS、iOS、Android等几乎所有主流操作系统,能够运行在从高端图形工作站到移动设备的各种硬件平台上。
1.3 为什么选择OSG
在3D引擎选择日益丰富的今天,OSG依然具有独特的竞争优势:
第一,极致的性能。 OSG从设计之初就以性能为首要目标。它的场景图遍历、状态排序、剔除算法、内存管理等核心模块都经过了深度优化,能够高效处理海量几何数据。在大规模地形可视化、城市级三维模型渲染等场景中,OSG的性能表现往往优于许多商业引擎。
第二,高度的灵活性。 OSG采用模块化设计,开发者可以按需选用所需的功能模块。它的节点系统、状态管理、渲染遍历等核心机制都提供了丰富的扩展点,开发者可以通过自定义节点、自定义访问器、自定义渲染技术等方式深度定制渲染管线。
第三,开放的生态。 作为完全开源的项目,OSG拥有活跃的社区和丰富的第三方资源。它支持上百种3D文件格式的导入导出,能够与各种专业软件无缝对接。同时,OSG的OSGPL许可证对商业应用非常友好,开发者无需支付授权费用即可在商业产品中使用。
第四,跨平台能力。 OSG从底层就设计为跨平台的,它抽象了操作系统和窗口系统的差异,同一套代码可以在不同平台上编译运行。无论是桌面应用、移动应用还是Web应用(通过Emscripten),OSG都能提供一致的开发体验。
二、OSG整体架构设计
2.1 分层架构概览
OSG采用清晰的分层架构设计,从底层到上层大致可以分为以下几个层次:
基础层(Foundation Layer):这一层提供最基础的功能支撑,包括:
OpenThreads:跨平台多线程库,提供线程、互斥锁、条件变量、屏障等同步原语osg核心库中的基础类:如Referenced引用计数基类、Object对象基类、数学库等
场景图核心层(Scene Graph Core Layer):这一层是OSG的核心,定义了场景图的基本元素:
- 节点系统:
Node、Group、Transform、Geode、LOD等各类节点 - 状态管理:
StateSet、StateAttribute、Texture、Program等 - 几何数据:
Geometry、PrimitiveSet、Array系列类 - 访问器模式:
NodeVisitor及其各种子类
功能扩展层(Feature Extension Layer):这一层基于核心场景图提供各种高级功能:
osgUtil:场景图操作工具集,包括优化器、相交检测、统计、简化等osgDB:数据库与文件读写,包括插件系统、数据库分页、对象缓存等osgGA:GUI事件与交互,包括各种相机操纵器、事件处理等osgText:文本渲染osgParticle:粒子系统osgShadow:阴影渲染osgTerrain:地形渲染osgAnimation:骨骼动画与 morph 动画osgFX:特效库(卡通渲染、凹凸贴图、描边等)osgSim:仿真相关功能(光点、视点、高度检测等)osgManipulator:3D对象操作器osgUI:3D用户界面组件osgPresentation:演示文稿功能
应用框架层(Application Framework Layer):这一层提供面向应用的高层封装:
osgViewer:视图与查看器,封装了渲染循环、相机管理、多线程渲染等osgViewer的各种配置类:单窗口、多屏、球幕显示等present3D:3D演示应用
插件层(Plugin Layer):OSG采用插件机制支持各种文件格式和功能扩展:
- 3D模型格式插件:3ds、fbx、dae、obj、ive等
- 图像格式插件:jpg、png、tiff、dds、exr等
- 视频插件:ffmpeg、gstreamer、directshow等
- 特殊功能插件:gdal(地理信息)、curl(网络)、pdf等
2.2 核心库依赖关系
OSG的核心库之间存在清晰的依赖关系:
osgViewer
↓
osgGA osgDB osgUtil
↓ ↓ ↓
osg
↓
OpenThreads
OpenThreads是最底层的线程库,不依赖任何其他OSG库osg是核心场景图库,只依赖OpenThreadsosgDB、osgUtil、osgGA等功能库依赖osg核心库osgViewer是最高层的应用框架,依赖多个功能库
这种清晰的依赖关系保证了OSG的模块化设计,开发者可以根据需要只链接自己用到的库,从而减小应用程序的体积。
2.3 设计哲学
OSG的设计哲学可以概括为以下几点:
"做一件事,并把它做好":OSG专注于场景图管理和3D渲染,不试图成为全能的游戏引擎。它不提供物理引擎、音频系统、脚本系统等"非核心"功能,而是通过良好的接口设计与其他专业库集成。
"机制优先于策略":OSG提供灵活的机制(如NodeVisitor、Callback、StateAttribute),而把具体的策略选择留给应用开发者。这种设计使得OSG能够适应各种不同的应用场景。
"性能第一":在OSG的代码中,你会看到大量为性能而做的优化——从内存布局到算法选择,从缓存友好性到并行化设计。性能始终是OSG设计时考虑的首要因素。
"向后兼容":OSG非常重视API的稳定性。从2.0版本到3.x系列,核心API保持了高度的兼容性,使得基于旧版本开发的应用程序能够平滑升级。
三、内存管理基石:Referenced引用计数体系
3.1 为什么需要引用计数
在C++中,内存管理一直是一个棘手的问题。对于3D引擎这样的复杂系统,对象之间的引用关系错综复杂——一个纹理可能被多个材质共享,一个网格可能被多个节点引用,一个节点可能有多个父节点。如果采用手动内存管理,很容易出现悬空指针、内存泄漏等问题。
OSG选择了侵入式引用计数(Intrusive Reference Counting)作为其内存管理的基础。所谓"侵入式",是指引⽤计数计数器内置于对象本身,而不是像std::shared_ptr那样由外部的智能指针来维护。
3.2 Referenced类剖析
osg::Referenced是OSG中几乎所有类的最终基类,它实现了引用计数的核心逻辑。让我们深入分析其设计:
class OSG_EXPORT Referenced
{
public:
Referenced();
Referenced(const Referenced&);
inline int ref() const;
inline int unref() const;
inline int unref_nodelete() const;
inline int referenceCount() const;
// 获取全局互斥锁(用于线程安全的引用计数)
static OpenThreads::Mutex* getGlobalReferencedMutex();
// 设置/获取删除处理器
static void setDeleteHandler(DeleteHandler* handler);
static DeleteHandler* getDeleteHandler();
protected:
virtual ~Referenced();
mutable std::atomic_int _refCount;
};
设计要点分析:
1. 原子引用计数
在最新版本的OSG中,_refCount使用std::atomic_int来实现,这使得引用计数的增减操作本身是线程安全的。在更早的版本中,OSG使用的是普通整数配合全局互斥锁,性能相对较低。
2. 析构函数保护
Referenced的析构函数是protected的,这意味着你不能在栈上创建Referenced及其子类的对象,也不能直接delete它们。对象的销毁必须通过unref()来触发,这就从语法层面保证了引用计数的正确性。
3. DeleteHandler机制
OSG引入了DeleteHandler机制来处理"在错误的线程中删除对象"的问题。在多线程渲染中,经常会出现这样的情况:一个在渲染线程中使用的对象,其引用计数在更新线程中降到了零,如果此时直接删除对象,可能会导致渲染线程访问已释放的内存。
DeleteHandler的解决方案是:将需要删除的对象收集到一个队列中,在合适的时机(如下一帧开始时)统一删除。这样就保证了对象不会在渲染过程中被意外销毁。
3.3 ref_ptr智能指针
osg::ref_ptr<T>是OSG提供的智能指针模板,它类似于std::shared_ptr,但专门为Referenced子类设计:
template<class T>
class ref_ptr
{
public:
typedef T element_type;
ref_ptr() : _ptr(0) {}
ref_ptr(T* ptr) : _ptr(ptr) { if (_ptr) _ptr->ref(); }
ref_ptr(const ref_ptr& rp) : _ptr(rp._ptr) { if (_ptr) _ptr->ref(); }
~ref_ptr() { if (_ptr) _ptr->unref(); }
ref_ptr& operator = (const ref_ptr& rp);
ref_ptr& operator = (T* ptr);
inline T& operator*() const { return *_ptr; }
inline T* operator->() const { return _ptr; }
inline T* get() const { return _ptr; }
inline bool operator!() const { return _ptr==0; }
inline bool valid() const { return _ptr!=0; }
// 释放指针所有权(不递减引用计数)
T* release();
private:
T* _ptr;
};
ref_ptr与shared_ptr的对比:
| 特性 | osg::ref_ptr | std::shared_ptr |
|---|---|---|
| 计数方式 | 侵入式(对象内部) | 非侵入式(外部控制块) |
| 内存开销 | 低(只有一个指针) | 较高(两个指针 + 控制块) |
| 线程安全 | 引用计数增减是原子的 | 引用计数增减是原子的 |
| 自定义删除器 | 通过DeleteHandler | 支持自定义删除器 |
| 弱引用 | 通过observer机制 | std::weak_ptr |
| 循环引用 | 需要手动避免 | 需要手动避免 |
使用ref_ptr的最佳实践:
-
成员变量使用ref_ptr:如果一个类持有另一个对象的引用,成员变量应该声明为
ref_ptr<T>,以确保引用的对象不会被意外释放。 -
函数参数使用原始指针:对于函数参数,如果函数只是短暂使用对象而不持有其引用,应该使用原始指针(或引用),这样可以避免不必要的引用计数增减开销。
-
返回值根据情况选择:如果返回的是新创建的对象,通常返回原始指针(调用方用ref_ptr接管);如果返回的是已有的对象引用,返回ref_ptr以保证安全性。
-
避免循环引用:如果A引用B,B又引用A,就会形成循环引用,导致对象永远无法释放。OSG中通过
observer(观察者)机制来解决这个问题,我们将在后面详细讨论。
3.4 Observer观察者模式
在OSG中,节点的父子关系是一个典型的可能产生循环引用的场景:父节点持有子节点的引用(通过ref_ptr),子节点也持有父节点的指针。如果双方都使用ref_ptr,就会形成循环引用。
OSG的解决方案是:父节点持有子节点的ref_ptr(强引用),子节点只持有父节点的原始指针(弱引用),并通过Observer机制来感知对象的销毁。
osg::Observer是一个观察者接口,当被观察的对象被销毁时,会通知所有观察者:
class OSG_EXPORT Observer
{
public:
virtual ~Observer() {}
// 对象被删除时调用
virtual void objectDeleted(void* object) = 0;
};
Referenced内部维护了一个观察者列表,当对象的引用计数降为零、即将被删除时,会先通知所有观察者:
int Referenced::unref() const
{
int newRefCount = --_refCount;
if (newRefCount == 0)
{
// 先通知所有观察者
signalObserversAndDelete(true, true);
}
return newRefCount;
}
这种设计使得子节点可以在父节点被删除时及时收到通知,并清理自己的父指针,避免悬空指针问题。
3.5 线程安全考量
OSG的引用计数机制在多线程环境下的安全性是一个微妙的话题。虽然引用计数的增减操作本身是原子的,但这并不意味着对象的所有操作都是线程安全的。
OSG的线程安全策略可以概括为:
- 引用计数本身是线程安全的:多个线程同时对同一个对象调用
ref()/unref()是安全的。 - 对象的修改需要外部同步:如果多个线程需要修改同一个对象的状态(如修改节点的变换矩阵、修改几何体的顶点数据等),应用程序需要自己保证线程安全。
- 场景图的遍历规则:OSG定义了严格的场景图遍历规则——更新遍历、拣选遍历、绘制遍历分别在不同的阶段进行,某些操作只能在特定的阶段执行。
这种设计是一种权衡:完全的线程安全会带来巨大的性能开销,而3D渲染对性能极其敏感。OSG选择了"在关键路径上保证安全,其余部分交给开发者"的策略,这也是大多数高性能引擎的共同选择。
四、对象系统:Object基类与RTTI机制
4.1 Object类概述
osg::Object是OSG场景图中所有对象的基类,它继承自Referenced,并在此基础上增加了以下功能:
- 运行时类型识别(RTTI):支持类型查询、安全向下转型
- 对象命名:每个对象可以有一个名字
- 数据序列化:支持对象的读写(.osg/.ive格式)
- 克隆(Copy):支持对象的深拷贝
- 用户数据:支持附加用户自定义数据
class OSG_EXPORT Object : public Referenced
{
public:
Object();
Object(const Object& obj, const CopyOp& copyop = CopyOp::SHALLOW_COPY);
virtual Object* cloneType() const = 0;
virtual Object* clone(const CopyOp& copyop) const = 0;
virtual bool isSameKindAs(const Object* obj) const;
virtual const char* libraryName() const = 0;
virtual const char* className() const = 0;
void setName(const std::string& name);
const std::string& getName() const;
void setUserData(Referenced* obj);
Referenced* getUserData();
// ... 其他方法
};
4.2 自定义RTTI机制
OSG实现了自己的运行时类型识别系统,而不是直接使用C++标准的dynamic_cast和type_info。这主要是出于以下考虑:
- 性能:
dynamic_cast在某些编译器上实现较慢,尤其是在深继承层次中。 - 跨库一致性:在动态链接库的场景下,
type_info的一致性可能会有问题。 - 序列化支持:自定义RTTI可以方便地支持对象的序列化与反序列化。
- 额外信息:OSG的RTTI还提供了库名、类名等额外信息。
OSG的RTTI系统通过一组宏来实现:
// 头文件中声明
#define META_Node(library, name) \
virtual const char* libraryName() const { return #library; } \
virtual const char* className() const { return #name; } \
virtual bool isSameKindAs(const Object* obj) const { \
return dynamic_cast<const name*>(obj) != 0; \
}
// 类型查询模板
template<typename T>
T* Object::as() { return dynamic_cast<T*>(this); }
template<typename T>
const T* Object::as() const { return dynamic_cast<const T*>(this); }
使用方式示例:
osg::Node* node = ...;
if (node->isSameKindAs(osg::Group::typeid)) {
osg::Group* group = node->as<osg::Group>();
// ...
}
虽然底层仍然使用了dynamic_cast,但OSG通过isSameKindAs()和as<T>()提供了更统一、更易读的接口。同时,libraryName()和className()使得对象的类型信息可以被序列化,这对于文件读写和调试都非常有用。
4.3 克隆机制与CopyOp
OSG中的对象克隆不是简单的拷贝构造函数,而是通过clone()方法配合CopyOp来实现的。这种设计的原因是:场景图中的对象之间存在复杂的引用关系,克隆时需要处理"深拷贝"与"浅拷贝"的各种组合。
osg::CopyOp是一个控制拷贝行为的类,它定义了多种拷贝选项:
class OSG_EXPORT CopyOp
{
public:
enum Options {
SHALLOW_COPY = 0, // 浅拷贝,共享子对象
DEEP_COPY_NODES = 1<<0, // 深拷贝节点
DEEP_COPY_DRAWABLES = 1<<1, // 深拷贝可绘制对象
DEEP_COPY_STATESETS = 1<<2, // 深拷贝状态集
DEEP_COPY_STATEATTRIBUTES = 1<<3, // 深拷贝状态属性
DEEP_COPY_TEXTURES = 1<<4, // 深拷贝纹理
DEEP_COPY_IMAGES = 1<<5, // 深拷贝图像
DEEP_COPY_ARRAYS = 1<<6, // 深拷贝数组
DEEP_COPY_PRIMITIVES = 1<<7, // 深拷贝图元集
DEEP_COPY_SHAPES = 1<<8, // 深拷贝形状
// ... 更多选项
DEEP_COPY_ALL = 0x7ffffff // 全部深拷贝
};
CopyOp(int options = SHALLOW_COPY);
// 各种类型的拷贝方法
virtual Node* operator()(const Node* node) const;
virtual Drawable* operator()(const Drawable* drawable) const;
virtual StateSet* operator()(const StateSet* stateset) const;
// ...
};
这种设计非常灵活。例如,如果你想克隆一个节点,但共享其状态集和纹理(这是常见的需求),可以这样做:
osg::CopyOp copyop(
osg::CopyOp::DEEP_COPY_NODES |
osg::CopyOp::DEEP_COPY_DRAWABLES |
osg::CopyOp::DEEP_COPY_ARRAYS
);
osg::Node* clonedNode = originalNode->clone(copyop);
CopyOp还可以被继承和扩展,开发者可以自定义拷贝逻辑,比如只克隆场景图的某一部分,或者在克隆时进行某些转换。
4.4 用户数据容器
OSG提供了UserDataContainer机制,允许开发者将自定义数据附加到任何Object子类的对象上。这是一个非常实用的功能,因为在实际项目中,我们经常需要在3D对象上附加业务数据。
// 简单的用户数据
node->setUserData(myData);
// 使用UserDataContainer存储多个数据
osg::UserDataContainer* udc = node->getOrCreateUserDataContainer();
udc->setUserValue("health", 100);
udc->setUserValue("team", "red");
UserDataContainer支持以键值对的方式存储多种类型的数据,包括基本类型、字符串、向量、矩阵,以及自定义的Referenced对象。这比直接使用setUserData()更加灵活。
五、场景图核心概念
5.1 什么是场景图
场景图(Scene Graph)是一种用于组织和管理三维场景的数据结构,它本质上是一棵有向无环图(DAG)。场景图中的每个节点代表场景中的一个元素,节点之间的父子关系表达了空间层次、状态继承、渲染顺序等语义。
场景图技术的核心思想是**"空间换时间"**——通过预先组织好场景的层次结构,在运行时可以高效地执行各种操作,如视图剔除、状态排序、碰撞检测等。
一个典型的场景图结构如下:
Root (Group)
├── Transform (地面)
│ └── Geode (地面网格)
├── Transform (房子)
│ ├── Group
│ │ ├── Geode (墙壁)
│ │ ├── Geode (屋顶)
│ │ └── Geode (门)
│ └── Transform (窗户)
│ └── Geode (玻璃)
└── LOD (远处的山)
├── Geode (高精度模型,近)
└── Geode (低精度模型,远)
5.2 场景图的优势
相比于"把所有物体放在一个列表里"的简单方式,场景图具有以下显著优势:
1. 空间变换的层次化
在场景图中,子节点的变换是相对于父节点的。这使得我们可以很自然地表达复杂的运动关系,比如:汽车车身移动,车轮相对于车身旋转,方向盘相对于车轮又有自己的旋转。如果没有场景图的层次结构,要计算每个物体的世界坐标会非常繁琐。
2. 状态的继承与覆盖
场景图中的状态(如材质、纹理、着色器等)具有继承性——子节点默认继承父节点的状态,同时也可以覆盖父节点的某些状态。这种机制可以大大减少状态冗余,提高渲染效率。
3. 高效的剔除(Culling)
通过场景图的层次包围体,我们可以快速剔除那些不在视锥体内的物体。对于一棵平衡的场景图,剔除操作的时间复杂度是O(log n),远优于线性扫描的O(n)。
4. 状态排序与批处理
场景图可以按照状态对几何体进行排序,将使用相同材质、纹理的物体放在一起渲染,从而减少OpenGL的状态切换次数,显著提高渲染性能。
5. 方便的场景管理
场景图的树状结构使得场景的管理变得直观。我们可以很容易地隐藏/显示某一组物体、移动某一部分场景、对某一类对象进行特殊处理等。
5.3 OSG场景图的特点
与其他场景图系统相比,OSG的场景图有以下几个显著特点:
第一,支持共享子图。 OSG的场景图不是严格的树,而是有向无环图(DAG)。一个节点可以有多个父节点,这意味着同一个子场景可以在场景图的多个位置被复用。比如,一棵"树"的模型可以在场景中被实例化几十次、上百次,而内存中只需要一份几何数据。
第二,多种节点类型。 OSG提供了丰富的节点类型,每种节点都有特定的功能。除了基本的Group(组节点)和Geode(叶子节点),还有Transform(变换节点)、LOD(细节层次节点)、Switch(开关节点)、Sequence(序列节点)、OccluderNode(遮挡节点)等等。这种"专用节点"的设计使得场景图的表达能力非常强。
第三,访问器模式。 OSG大量使用访问器(Visitor)模式来操作场景图。无论是更新、拣选、相交检测还是统计,都是通过特定的NodeVisitor子类来完成的。这种设计的好处是:增加新的操作不需要修改节点类,符合"开闭原则"。
第四,多遍历架构。 OSG的渲染过程不是一次遍历完成的,而是分为更新(Update)、拣选(Cull)、绘制(Draw)三个主要阶段,每个阶段对应一次场景图遍历。这种设计是OSG高性能和多线程支持的基础。
5.4 场景图遍历的基本类型
OSG定义了多种场景图遍历模式,每种模式适用于不同的场景:
enum TraversalMode {
TRAVERSE_NONE, // 不遍历子节点
TRAVERSE_PARENTS, // 向上遍历父节点
TRAVERSE_ALL_CHILDREN // 向下遍历所有子节点
};
向下遍历(TRAVERSE_ALL_CHILDREN):这是最常见的遍历方式,从根节点开始,递归访问所有子节点。更新遍历、拣选遍历、绘制遍历都采用这种方式。
向上遍历(TRAVERSE_PARENTS):从某个节点开始,向上访问其所有父节点,直到根节点。这种遍历方式常用于计算节点的世界变换、查找节点的路径等。
不遍历(TRAVERSE_NONE):只访问当前节点,不访问其子节点。在某些特殊的访问器中会用到。
5.5 NodeMask节点掩码
OSG的每个节点都有一个_nodeMask成员(32位整数),它是一个非常有用的机制,用于控制节点在不同类型的遍历中是否被访问。
在进行场景图遍历时,访问器会检查节点的nodeMask与访问器的traversalMask是否有交集。如果按位与的结果为零,该节点(及其子节点)将被跳过:
bool NodeVisitor::validNodeMask(const Node& node) const
{
return (getNodeMask() & node.getNodeMask()) != 0;
}
利用NodeMask,我们可以实现很多有用的功能:
// 定义不同的位掩码
const unsigned int CAST_SHADOW_MASK = 0x0001;
const int RECEIVE_SHADOW_MASK = 0x0002;
const int PICKABLE_MASK = 0x0004;
// 设置节点的属性
building->setNodeMask(CAST_SHADOW_MASK | RECEIVE_SHADOW_MASK | PICKABLE_MASK);
glass->setNodeMask(RECEIVE_SHADOW_MASK); // 玻璃不投射阴影
// 在特定遍历中只处理某些节点
intersectionVisitor->setTraversalMask(PICKABLE_MASK);
这种基于位掩码的过滤机制非常高效(只需要一次按位与操作),而且非常灵活。在实际项目中,NodeMask经常被用来控制阴影投射、拾取检测、镜面反射等功能。
六、节点访问器(NodeVisitor)模式深度解析
6.1 为什么使用访问器模式
访问器模式(Visitor Pattern)是一种行为设计模式,它允许你在不修改对象结构的前提下,定义作用于这些对象上的新操作。对于场景图这样的对象结构,访问器模式是一种非常自然的选择。
设想一下,如果没有访问器模式,我们要为场景图添加一个"计算所有三角形数量"的功能,该怎么做?我们可能需要在Node基类中添加一个countTriangles()虚函数,然后在每个子类中实现它。如果以后又要添加"统计纹理内存占用"、"查找所有使用某材质的节点"等功能,我们就要不断地修改节点类的接口,这显然违反了开闭原则。
访问器模式的解决方案是:将操作封装到独立的访问器类中,节点类只需要提供一个accept()方法来"接收"访问器。这样,添加新的操作只需要添加新的访问器类,不需要修改节点类。
6.2 NodeVisitor类结构
osg::NodeVisitor是所有访问器的基类,它的核心结构如下:
class OSG_EXPORT NodeVisitor : public Object
{
public:
enum TraversalMode { /* 遍历模式 */ };
NodeVisitor(TraversalMode tm = TRAVERSE_NONE);
// 访问节点的入口
virtual void apply(Node& node);
virtual void apply(Geode& node);
virtual void apply(Group& node);
virtual void apply(Transform& node);
virtual void apply(LOD& node);
virtual void apply(Switch& node);
// ... 更多节点类型的apply方法
// 继续遍历子节点
inline void traverse(Node& node);
// 节点路径管理
void pushOntoNodePath(Node* node);
void popFromNodePath();
NodePath& getNodePath();
// 遍历掩码
void setTraversalMask(unsigned int mask);
unsigned int getTraversalMask() const;
// ... 其他方法
};
apply()方法有多个重载版本,分别对应不同的节点类型。每个版本的默认实现是调用通用的apply(Node&),而通用版本默认会调用traverse()继续遍历子节点。
6.3 访问过程详解
让我们通过一个具体的例子来理解访问器是如何工作的。假设有这样一个场景图:
Group (root)
├── Transform (car)
│ ├── Geode (body)
│ └── Geode (wheel)
└── LOD (building)
└── Geode (high_detail)
当我们调用root->accept(myVisitor)时,执行过程如下:
Group::accept(visitor)被调用- 它调用
visitor.pushOntoNodePath(this)将自己压入节点路径 - 然后调用
visitor.apply(*this)(因为this是Group类型,所以匹配apply(Group&)) myVisitor的apply(Group&)方法执行自定义逻辑- 如果
myVisitor在apply中调用了traverse(*this),则会遍历所有子节点 - 对每个子节点递归调用
accept(visitor) - 所有子节点处理完后,调用
visitor.popFromNodePath()弹出节点路径
在这个过程中,访问器可以通过getNodePath()获取从根节点到当前节点的完整路径,这对于计算世界变换、确定节点位置等操作非常有用。
6.4 自定义访问器示例
让我们来看一个实际的例子:编写一个访问器来统计场景图中所有几何体的顶点总数。
class VertexCountVisitor : public osg::NodeVisitor
{
public:
VertexCountVisitor()
: osg::NodeVisitor(TRAVERSE_ALL_CHILDREN)
, totalVertices(0) {}
virtual void apply(osg::Geode& geode)
{
for (unsigned int i = 0; i < geode.getNumDrawables(); ++i)
{
osg::Geometry* geom = geode.getDrawable(i)->asGeometry();
if (geom)
{
osg::Vec3Array* vertices =
dynamic_cast<osg::Vec3Array*>(geom->getVertexArray());
if (vertices)
{
totalVertices += vertices->size();
}
}
}
traverse(geode); // Geode是叶子节点,traverse不会做什么
}
unsigned int totalVertices;
};
// 使用方式
VertexCountVisitor visitor;
root->accept(visitor);
std::cout << "Total vertices: " << visitor.totalVertices << std::endl;
这个例子展示了访问器模式的优雅之处:我们只需要关注自己关心的节点类型(Geode),其他类型的节点会使用默认的apply(Node&)实现,自动继续遍历。
6.5 OSG中的重要访问器
OSG本身大量使用访问器模式,以下是一些最重要的内置访问器:
| 访问器 | 所在模块 | 功能 |
|---|---|---|
UpdateVisitor | osg | 更新遍历,执行更新回调 |
CullVisitor | osgUtil | 拣选遍历,执行视锥剔除、状态排序 |
IntersectionVisitor | osgUtil | 相交检测访问器 |
Optimizer | osgUtil | 场景图优化器(内部使用多个访问器) |
Simplifier | osgUtil | 几何体简化器 |
PrintVisitor | osgUtil | 打印场景图结构 |
Statistics | osgUtil | 场景统计信息 |
DelaunayTriangulator | osgUtil | 德劳内三角剖分 |
TangentSpaceGenerator | osgUtil | 切线空间生成器 |
可以说,理解了NodeVisitor,就理解了OSG一半的设计思想。几乎所有对场景图的操作都是通过访问器来完成的。
七、回调机制(Callback)
7.1 回调的作用
在场景图系统中,我们经常需要让某些对象具有"动态行为"——比如让一个节点来回移动,让一个材质的颜色随时间变化,让相机跟随某个物体运动等。这些动态行为不应该被硬编码到引擎中,而应该以某种方式由用户自定义。
OSG采用回调(Callback)机制来解决这个问题。回调本质上是一个用户定义的函数对象,它会在场景图遍历的特定时机被调用,从而允许用户插入自定义逻辑。
7.2 回调的类型
OSG中有多种类型的回调,分别对应场景图遍历的不同阶段:
1. 更新回调(UpdateCallback)
更新回调在更新遍历阶段被调用,用于修改场景图的状态。这是最常用的回调类型。
class OSG_EXPORT NodeCallback : public Object
{
public:
virtual void operator()(Node* node, NodeVisitor* nv) = 0;
// 串联下一个回调
void setNestedCallback(NodeCallback* nc);
NodeCallback* getNestedCallback();
};
更新回调的典型用途包括:
- 动画(物体运动、旋转、缩放)
- 逻辑更新(游戏AI、物理模拟结果应用)
- 数据驱动的场景变化
2. 拣选回调(CullCallback)
拣选回调在拣选遍历阶段被调用,主要用于控制节点的可见性或进行特殊的拣选逻辑。
3. 绘制回调(DrawCallback)
绘制回调在绘制阶段被调用,可以直接插入OpenGL命令。这是最低级别的回调,使用时需要非常小心,因为它会直接影响渲染管线。
4. 事件回调(EventCallback)
事件回调在事件遍历阶段被调用,用于处理用户输入事件。
7.3 回调的串联机制
OSG的回调支持"嵌套"(nested)或"串联"。也就是说,一个节点可以设置多个回调,它们会按顺序依次执行。
// 设置第一个回调
node->addUpdateCallback(new RotateCallback);
// 再添加一个回调(会被串联起来)
node->addUpdateCallback(new FloatCallback);
// 等效于:
// RotateCallback被调用 → 执行 → 调用nested callback → FloatCallback被调用
这种串联机制是通过每个回调对象内部的_nestedCallback指针实现的。当一个回调执行完自己的逻辑后,应该调用traverse(node, nv)来继续执行下一个回调:
void MyCallback::operator()(osg::Node* node, osg::NodeVisitor* nv)
{
// 自定义逻辑...
float angle = computeAngle();
node->asTransform()->asMatrixTransform()->setMatrix(
osg::Matrix::rotate(angle, osg::Z_AXIS));
// 继续执行下一个回调(重要!)
traverse(node, nv);
}
如果你忘记调用traverse(),后面的回调就不会被执行。这是初学者常犯的错误。
7.4 回调的实现示例
让我们来看一个完整的例子:实现一个让节点沿圆形路径运动的回调。
class CircularMotionCallback : public osg::NodeCallback
{
public:
CircularMotionCallback(float radius = 10.0f, float speed = 1.0f)
: _radius(radius)
, _speed(speed)
, _angle(0.0f)
, _previousTime(0.0) {}
virtual void operator()(osg::Node* node, osg::NodeVisitor* nv)
{
// 获取时间信息
if (nv->getFrameStamp())
{
double currentTime = nv->getFrameStamp()->getSimulationTime();
if (_previousTime != 0.0)
{
double deltaTime = currentTime - _previousTime;
_angle += _speed * deltaTime;
}
_previousTime = currentTime;
}
// 更新节点位置
osg::MatrixTransform* mt = node->asTransform()->asMatrixTransform();
if (mt)
{
float x = _radius * cos(_angle);
float y = _radius * sin(_angle);
mt->setMatrix(osg::Matrix::translate(x, y, 0.0f));
}
// 继续遍历(执行下一个回调)
traverse(node, nv);
}
private:
float _radius;
float _speed;
float _angle;
double _previousTime;
};
使用方式:
osg::MatrixTransform* movingNode = new osg::MatrixTransform;
movingNode->addUpdateCallback(new CircularMotionCallback(10.0f, 2.0f));
7.5 回调与访问器的关系
细心的读者可能已经发现:回调和访问器看起来有些相似——它们都可以在场景图遍历过程中执行自定义逻辑。那么,什么时候应该用回调,什么时候应该用访问器呢?
它们的区别主要在于:
- 作用范围不同:访问器作用于整个场景图或子图,回调只作用于单个节点。
- 使用方式不同:访问器是"外部"的,你需要显式调用
node->accept(visitor);回调是"内部"的,一旦设置就会自动被调用。 - 执行时机不同:访问器可以在任何时候被调用;回调则是在引擎的标准遍历过程中被调用。
- 性能特征不同:回调会增加每个节点的开销(虽然很小);访问器的开销则集中在遍历过程中。
一般来说,如果你的逻辑只涉及少数几个特定的节点,使用回调比较方便;如果你的逻辑需要遍历整个场景图或大量节点,使用自定义访问器会更高效、更清晰。
八、数学基础库
8.1 OSG数学库概览
3D图形引擎离不开数学。OSG提供了一套完整、高效的数学库,涵盖了向量、矩阵、四元数、平面、线段、包围体等3D图形中常用的数学对象。
OSG数学库的主要组件包括:
| 类名 | 用途 | 头文件 |
|---|---|---|
Vec2f/Vec2d | 2D向量 | <osg/Vec2> |
Vec3f/Vec3d | 3D向量 | <osg/Vec3> |
Vec4f/Vec4d | 4D向量/颜色 | <osg/Vec4> |
Matrixf/Matrixd | 4x4矩阵 | <osg/Matrix> |
Quat | 四元数 | <osg/Quat> |
Plane | 平面 | <osg/Plane> |
LineSegment | 线段 | <osg/LineSegment> |
BoundingBox | 轴对齐包围盒 | <osg/BoundingBox> |
BoundingSphere | 包围球 | <osg/BoundingSphere> |
Polytope | 多面体(视锥体) | <osg/Polytope> |
Frustum | 视锥体 | <osg/Frustum> |
OSG的数学类都提供了float和double两种精度版本。在大多数情况下,使用float版本就足够了,而且性能更好。但在涉及大地坐标系、大规模场景等对精度要求较高的场合,可能需要使用double版本。
8.2 向量类
OSG的向量类设计得非常简洁高效。以Vec3f为例:
class Vec3f
{
public:
// 构造函数
Vec3f();
Vec3f(float x, float y, float z);
// 元素访问(支持[]和.x()/.y()/.z()两种方式)
float& operator[](int i);
float operator[](int i) const;
float& x();
float& y();
float& z();
// 向量运算
Vec3f operator + (const Vec3f& v) const;
Vec3f operator - (const Vec3f& v) const;
Vec3f operator * (float scalar) const;
Vec3f operator / (float scalar) const;
// 点积、叉积
float operator * (const Vec3f& v) const; // 点积
Vec3f operator ^ (const Vec3f& v) const; // 叉积(注意:^的优先级很低!)
// 长度、归一化
float length() const;
float length2() const; // 长度平方
Vec3f& normalize();
// ...
};
性能注意事项:
OSG的向量类没有虚函数,内存布局就是连续的三个浮点数,这和C语言的数组完全兼容。这意味着你可以安全地将Vec3f数组直接传递给OpenGL的glVertexPointer()等函数,不需要任何格式转换。
另外,OSG的向量运算都是内联的,编译器可以很好地优化它们,性能接近手写的C代码。
8.3 矩阵类
矩阵是3D图形中最重要的数学工具之一。OSG的Matrixf/Matrixd类提供了丰富的矩阵操作功能。
矩阵的存储方式:
OSG采用**列主序(Column-major)**存储,这与OpenGL的惯例一致。也就是说,矩阵在内存中的布局是:
m[0] m[4] m[8] m[12]
m[1] m[5] m[9] m[13]
m[2] m[6] m[10] m[14]
m[3] m[7] m[11] m[15]
其中:
m[12], m[13], m[14]是平移分量m[0]~m[2], m[4]~m[6], m[8]~m[10]是旋转/缩放分量m[15]通常是1.0
常用的矩阵构造方法:
// 单位矩阵
osg::Matrix identity = osg::Matrix::identity();
// 平移矩阵
osg::Matrix trans = osg::Matrix::translate(10.0f, 0.0f, 5.0f);
// 旋转矩阵(绕任意轴)
osg::Matrix rot = osg::Matrix::rotate(osg::PI_2, osg::Z_AXIS);
// 缩放矩阵
osg::Matrix scale = osg::Matrix::scale(2.0f, 2.0f, 2.0f);
// 透视投影矩阵
osg::Matrix persp = osg::Matrix::perspective(60.0f, 1.33f, 0.1f, 1000.0f);
// 正交投影矩阵
osg::Matrix ortho = osg::Matrix::ortho(-10.0f, 10.0f, -10.0f, 10.0f, 0.1f, 1000.0f);
// 观察矩阵(类似gluLookAt)
osg::Matrix lookAt = osg::Matrix::lookAt(
osg::Vec3(0.0f, 0.0f, 10.0f), // 眼睛位置
osg::Vec3(0.0f, 0.0f, 0.0f), // 观察点
osg::Vec3(0.0f, 1.0f, 0.0f) // 上方向
);
矩阵乘法与变换顺序:
矩阵乘法是理解3D变换的关键。在OSG中,矩阵乘法使用*运算符,遵循"右结合"的规则——即右边的变换先执行。
// 先平移,再旋转(绕原点旋转,物体绕着原点转)
osg::Matrix m1 = trans * rot;
// 先旋转,再平移(物体先自转,然后移动到目标位置)
osg::Matrix m2 = rot * trans;
这是一个常见的易错点。记住:向量在左边,矩阵在右边,变换从右往左依次执行。
矩阵类还提供了很多有用的工具方法,如:
invert(matrix):求逆矩阵transpose(matrix):转置矩阵decompose(...):矩阵分解(平移、旋转、缩放、剪切)getTrans()/getRotate()/getScale():获取矩阵的各分量
8.4 四元数与旋转
在3D图形中,旋转有多种表示方式:欧拉角、旋转矩阵、轴角、四元数等。每种方式都有其优缺点。OSG主要使用矩阵和四元数来表示旋转。
四元数(Quaternion)是一种非常优雅的旋转表示方式,它有以下优点:
- 只需要4个浮点数(比矩阵少很多)
- 可以避免万向节锁(Gimbal Lock)
- 球面线性插值(slerp)非常自然
- 可以很容易地转换为矩阵或轴角
class Quat
{
public:
Quat();
Quat(float x, float y, float z, float w);
// 从轴角构造
Quat(float angle, const Vec3f& axis);
// 从欧拉角构造
Quat(float heading, float attitude, float bank);
// 从旋转矩阵构造
Quat(const Matrixf& matrix);
// 四元数乘法(组合旋转)
Quat operator * (const Quat& q) const;
// 共轭(逆旋转)
Quat conj() const;
// 长度、归一化
float length() const;
Quat& normalize();
// 球面线性插值
static Quat slerp(float t, const Quat& from, const Quat& to);
// 线性插值
static Quat lerp(float t, const Quat& from, const Quat& to);
};
在OSG中,PositionAttitudeTransform节点使用四元数来表示姿态(Attitude),这比欧拉角更加灵活和稳定。
8.5 包围体与剔除
包围体(Bounding Volume)是场景图剔除技术的基础。OSG支持两种主要的包围体:包围球(BoundingSphere)和轴对齐包围盒(BoundingBox)。
包围球(BoundingSphere)
包围球由一个中心点和一个半径组成。它的优点是:
- 存储成本低(4个浮点数)
- 变换非常简单(中心点平移,半径不变)
- 相交检测快
class BoundingSphere
{
public:
BoundingSphere();
BoundingSphere(const Vec3f& center, float radius);
// 检测是否为空
bool valid() const;
// 扩展包围球以包含点/另一个包围球
void expandBy(const Vec3f& point);
void expandBy(const BoundingSphere& bs);
// 相交检测
bool contains(const Vec3f& point) const;
bool intersects(const BoundingSphere& bs) const;
bool intersects(const BoundingBox& bb) const;
// 变换包围球
void expandBy(const Matrixf& matrix) const;
};
轴对齐包围盒(BoundingBox)
轴对齐包围盒由最小点和最大点定义。相比于包围球,它通常能更紧密地包裹物体,但变换代价更高(变换后需要重新计算AABB)。
class BoundingBox
{
public:
BoundingBox();
BoundingBox(const Vec3f& min, const Vec3f& max);
// 扩展
void expandBy(const Vec3f& point);
void expandBy(const BoundingBox& bb);
// 相交检测
bool intersects(const BoundingBox& bb) const;
bool intersects(const Plane& plane) const;
// 获取中心点、尺寸等
Vec3f center() const;
Vec3f size() const;
float radius() const;
};
视锥体与剔除
在渲染过程中,OSG使用视锥体(Polytope/Frustum)来进行视锥剔除。视锥体由六个平面(左、右、上、下、近、远)定义,只有在视锥体内的物体才需要被渲染。
OSG的剔除过程大致如下:
- 从相机的投影-视图矩阵计算六个裁剪平面
- 从根节点开始遍历场景图
- 对每个节点,将其包围球与视锥体进行相交检测
- 如果完全在视锥外,跳过该节点及其所有子节点
- 如果部分相交,继续遍历子节点
- 如果完全在视锥内,直接加入渲染队列
这个过程就是著名的"视锥剔除"(Frustum Culling),它是场景图能提供高性能渲染的关键原因之一。
九、多线程基础:OpenThreads
9.1 为什么需要多线程
早期的3D引擎大多是单线程的——更新、剔除、绘制都在同一个线程中顺序执行。随着CPU核心数的增加和场景复杂度的提升,单线程模型逐渐成为了性能瓶颈。
OSG从很早的版本就开始支持多线程渲染,它提供了多种线程模型,可以根据应用场景选择最合适的并发策略。而这一切的基础,就是OpenThreads库。
9.2 OpenThreads概览
OpenThreads是一个轻量级的跨平台多线程库,它抽象了不同操作系统的线程API,提供了统一的接口。OpenThreads支持的平台包括:
- Windows(Win32线程)
- Linux/Unix(POSIX线程)
- macOS/iOS(POSIX线程)
- Android(POSIX线程)
OpenThreads提供的主要类包括:
| 类 | 用途 |
|---|---|
Thread | 线程类 |
Mutex | 互斥锁 |
ScopedLock | 作用域锁(RAII风格) |
Condition | 条件变量 |
Barrier | 屏障 |
Atomic | 原子操作 |
ReadWriteMutex | 读写锁 |
9.3 线程类
OpenThreads::Thread是线程的基类,使用方式与std::thread类似,但它是一个抽象类——你需要继承它并实现run()方法。
class MyThread : public OpenThreads::Thread
{
public:
virtual void run()
{
// 线程执行的代码
for (int i = 0; i < 100; ++i)
{
// 做一些工作...
OpenThreads::Thread::microSleep(1000); // 休眠1毫秒
}
}
};
// 使用方式
MyThread thread;
thread.start(); // 启动线程
thread.join(); // 等待线程结束
Thread类还提供了一些有用的静态方法:
Thread::currentThread():获取当前线程指针Thread::YieldCurrentThread():让出CPUThread::microSleep(us):微秒级休眠Thread::getNumberOfProcessors():获取CPU核心数
9.4 互斥锁与作用域锁
互斥锁(Mutex)是最基本的同步原语,用于保护共享资源不被多个线程同时访问。
OpenThreads::Mutex mutex;
int sharedData = 0;
void threadSafeIncrement()
{
mutex.lock();
++sharedData;
mutex.unlock();
}
直接使用lock()/unlock()容易出错(比如忘记unlock,或者中间抛出异常)。更好的方式是使用ScopedLock(作用域锁),它利用RAII机制自动管理锁的生命周期:
void threadSafeIncrement()
{
OpenThreads::ScopedLock<OpenThreads::Mutex> lock(mutex);
++sharedData;
// 离开作用域时,lock的析构函数会自动调用mutex.unlock()
}
这和C++标准库中的std::lock_guard是一样的思路。
9.5 条件变量
条件变量(Condition)用于线程间的协调——一个线程可以等待某个条件成立,另一个线程可以在条件满足时发出通知。
典型的使用场景是生产者-消费者队列:
OpenThreads::Mutex mutex;
OpenThreads::Condition condition;
std::queue<Data*> dataQueue;
// 生产者线程
void producer()
{
while (running)
{
Data* data = produceData();
OpenThreads::ScopedLock<OpenThreads::Mutex> lock(mutex);
dataQueue.push(data);
condition.signal(); // 通知消费者有新数据
}
}
// 消费者线程
void consumer()
{
while (running)
{
OpenThreads::ScopedLock<OpenThreads::Mutex> lock(mutex);
while (dataQueue.empty())
{
condition.wait(&mutex); // 等待数据
}
Data* data = dataQueue.front();
dataQueue.pop();
// 处理数据...
}
}
9.6 屏障
屏障(Barrier)用于同步多个线程的执行进度——所有线程都到达屏障后,才能继续执行。这在分阶段的并行计算中非常有用。
OpenThreads::Barrier barrier(4); // 4个线程的屏障
void workerThread()
{
// 第一阶段工作
doPhase1Work();
barrier.block(); // 等待所有线程完成第一阶段
// 第二阶段工作(所有线程都进入第二阶段后才开始)
doPhase2Work();
barrier.block(); // 再次同步
}
9.7 OSG的线程模型
基于OpenThreads,OSG实现了多种线程模型,开发者可以根据需要选择:
| 线程模型 | 描述 | 适用场景 |
|---|---|---|
SingleThreaded | 单线程模式,所有操作在一个线程中执行 | 调试、简单场景 |
CullDrawThreadPerContext | 每个图形上下文一个线程,负责剔除和绘制 | 一般场景 |
DrawThreadPerContext | 每个图形上下文一个绘制线程,剔除在主线程 | CPU核心较少时 |
CullThreadPerCameraDrawThreadPerContext | 每个相机一个剔除线程,每个上下文一个绘制线程 | 多相机、多GPU场景 |
这些线程模型的具体工作原理,我们将在后续章节中详细讨论。这里只需要知道:OSG的多线程能力是建立在OpenThreads之上的。
十、构建系统与模块化设计
10.1 CMake构建系统
OSG使用CMake作为其构建系统,这是一个非常明智的选择。CMake是一个跨平台的构建系统生成器,它可以生成Visual Studio解决方案、Xcode项目、Makefile等各种构建文件。
OSG的CMake构建系统设计得非常成熟,具有以下特点:
1. 模块化配置
OSG的每个库和插件都有自己的CMakeLists.txt文件,根目录的CMakeLists.txt负责整体配置。这种模块化的设计使得添加、移除模块都非常方便。
2. 自动依赖检测
OSG的CMake脚本能够自动检测系统中安装的各种第三方库(如OpenGL、FreeType、GDAL、FFmpeg等),并根据检测结果自动启用或禁用相应的功能模块。
3. 丰富的构建选项
OSG提供了大量的构建选项,允许开发者精细控制编译哪些模块、启用哪些功能。常用的选项包括:
BUILD_OSG_EXAMPLES:是否编译示例程序BUILD_OSG_APPLICATIONS:是否编译应用程序DYNAMIC_OPENSCENEGRAPH:编译为动态库还是静态库OSG_GL3_AVAILABLE:是否启用OpenGL 3+支持- 等等
4. 跨平台支持
OSG的CMake脚本处理了大量平台相关的细节,使得在不同平台上的构建过程基本一致。开发者不需要为每个平台手动配置编译选项。
10.2 模块化设计的好处
OSG的模块化设计带来了很多好处:
1. 按需链接,减小体积
应用程序只需要链接自己用到的库。例如,如果你的应用不需要粒子系统,就不需要链接osgParticle库。
2. 独立演化,互不影响
每个模块可以独立开发和维护。对一个模块的修改不会影响其他模块(只要接口不变)。这对于大型项目的协作开发非常重要。
3. 易于扩展
添加新的功能模块只需要创建一个新的目录和CMakeLists.txt,不需要修改核心代码。OSG的插件系统更是将这种扩展性发挥到了极致。
4. 便于测试
每个模块可以独立进行单元测试和集成测试。
10.3 插件系统的设计思想
OSG的插件系统是其最具特色的设计之一。OSG本身只定义了文件读写的接口(ReaderWriter),具体的文件格式支持全部以插件的形式提供。
插件系统的核心是osgDB::Registry(注册表),它管理着所有已加载的插件:
class Registry
{
public:
static Registry* instance(); // 单例
// 读取文件
ReadResult readNode(const std::string& filename,
const Options* options = 0) const;
ReadResult readImage(const std::string& filename,
const Options* options = 0) const;
// 写入文件
WriteResult writeNode(const Node* node,
const std::string& filename,
const Options* options = 0) const;
// 注册读写器
void addReaderWriter(ReaderWriter* rw);
// 加载插件
ReaderWriter* loadReaderWriter(const std::string& name);
};
当你调用osgDB::readNodeFile("model.osgt")时,OSG会:
- 根据文件扩展名(
.osgt)查找对应的插件 - 如果插件还没加载,动态加载对应的动态库(
osgdb_osg.dll或osgdb_osg.so) - 创建
ReaderWriter实例 - 调用插件的
readNode()方法读取文件
这种设计的好处是显而易见的:
- 核心库不需要依赖任何第三方图像/模型库
- 支持新的文件格式只需要编写一个新插件
- 应用程序启动时不会加载所有插件,启动更快
- 可以在运行时动态添加或替换插件
我们将在后续章节中更详细地讨论插件系统的实现机制。
十一、本章总结
在这第一篇文章中,我们从宏观到微观,逐步深入了解了OSG的架构设计和核心概念。让我们回顾一下要点:
-
OSG的定位:一个高性能、可扩展的开源3D场景图形库,专注于渲染与场景管理。
-
分层架构:OSG采用清晰的分层设计——基础层、场景图核心层、功能扩展层、应用框架层、插件层,各层之间依赖关系明确。
-
引用计数内存管理:通过
Referenced基类和ref_ptr智能指针实现侵入式引用计数,配合Observer和DeleteHandler解决循环引用和多线程删除问题。 -
对象系统:
Object基类提供了RTTI、命名、克隆、用户数据等通用功能。 -
场景图核心思想:以树状(DAG)结构组织场景,支持层次变换、状态继承、高效剔除、状态排序等。
-
访问器模式:
NodeVisitor是操作场景图的主要方式,符合开闭原则,使得添加新操作不需要修改节点类。 -
回调机制:通过各种回调(更新回调、拣选回调等)为节点添加动态行为。
-
数学库:提供完整、高效的3D数学支持,包括向量、矩阵、四元数、包围体等。
-
多线程基础:
OpenThreads提供跨平台的线程原语,是OSG多线程渲染的基础。 -
模块化与插件:CMake构建系统和插件机制使得OSG具有极强的可扩展性。
理解了这些核心概念,我们就为深入学习OSG的各个子系统打下了坚实的基础。在下一篇文章中,我们将详细剖析OSG的节点系统——从最基本的Node类到各种功能丰富的特殊节点,探讨它们的设计思路和使用技巧。
本文是「深入理解OpenSceneGraph」系列的第一篇。系列文章将从架构设计、节点系统、渲染管线、高级特性、插件生态等多个维度全面解析OSG这一世界级开源3D引擎。