深入理解OpenSceneGraph(二):场景图节点系统深度解析

1 阅读44分钟

深入理解OpenSceneGraph(二):场景图节点系统深度解析

本文是「深入理解OpenSceneGraph」系列文章的第二篇,将深入剖析OSG场景图的节点系统——从最基础的Node基类到各种功能丰富的特殊节点,探讨它们的设计思想、实现机制与最佳实践。

一、节点系统概述

1.1 节点的角色与分类

如果说场景图是OSG的骨架,那么节点(Node)就是构成这个骨架的基本单元。每个节点代表场景中的一个元素,它们通过父子关系组织成层次结构,共同描述整个三维世界。

OSG的节点系统设计遵循"单一职责原则"——每种节点只负责一件事情。这种设计使得节点类型虽然众多,但每个节点的职责都很清晰,组合起来却能表达复杂的场景。

根据功能的不同,OSG的节点大致可以分为以下几类:

组节点(Group Nodes) :这类节点的主要作用是组织子节点,它们本身不包含几何数据,也不直接参与渲染。

节点类型功能
Group最基本的组节点,简单地将子节点组合在一起
Switch开关节点,可以控制哪些子节点处于激活状态
LOD细节层次节点,根据距离选择不同精度的子节点
PagedLOD分页LOD节点,支持从外部文件动态加载子节点
Sequence序列节点,按时间顺序依次显示子节点
Impostor冒充者节点,远距离时用公告板纹理代替真实几何
OccluderNode遮挡节点,用于遮挡剔除优化
MultiSwitch多重开关节点,支持多组开关状态

变换节点(Transform Nodes) :这类节点会影响其子节点的空间位置和方向。

节点类型功能
Transform变换节点基类
MatrixTransform矩阵变换节点,使用4x4矩阵描述变换
PositionAttitudeTransform位姿变换节点,使用位置+四元数描述变换
AutoTransform自动变换节点,自动朝向相机(公告板效果)
Billboard公告板节点,始终面向相机
Projection投影变换节点,应用投影矩阵
Camera相机节点,定义视点和投影

叶子节点(Leaf Nodes) :这类节点是场景图的末端,包含实际的可渲染内容。

节点类型功能
Geode几何节点,包含一个或多个可绘制对象(Drawable)
ShapeDrawable简单形状(球、立方体、圆锥等)的可绘制对象
Geometry几何数据(顶点、法线、纹理坐标、图元等)
Text文本可绘制对象

其他特殊节点:

节点类型功能
LightSource光源节点
ClipNode裁剪平面节点
TexGenNode纹理生成节点
ClearNode清除节点,用于清除颜色/深度缓冲
CoordinateSystemNode坐标系节点,用于地理信息系统
ProxyNode代理节点,延迟加载外部模型

1.2 节点继承层次

OSG的节点类之间有清晰的继承关系。最顶层是Object,然后是Node,各种具体的节点类型都从Node派生而来。

主要的继承层次如下:

Object
  └── Node
        ├── Group
        │     ├── Transform
        │     │     ├── MatrixTransform
        │     │     ├── PositionAttitudeTransform
        │     │     ├── AutoTransform
        │     │     ├── Projection
        │     │     └── Camera
        │     ├── LOD
        │     │     └── PagedLOD
        │     ├── Switch
        │     │     └── MultiSwitch
        │     ├── Sequence
        │     ├── OccluderNode
        │     ├── CoordinateSystemNode
        │     └── ...
        ├── Geode
        ├── Billboard
        ├── LightSource
        ├── ClipNode
        ├── ClearNode
        ├── ProxyNode
        └── ...

理解这个继承层次很重要,因为在使用as<T>()进行类型转换时,你需要知道目标类型是否是实际类型的基类。


二、Node基类深度剖析

2.1 Node的核心职责

osg::Node是所有场景图节点的抽象基类,它定义了节点的通用行为和属性。虽然Node本身不能被实例化,但它包含了几乎所有节点都需要的功能:

  1. 父子关系管理:维护父节点列表,支持多父节点(共享子图)
  2. 包围体管理:维护节点的包围球,用于剔除
  3. 状态集管理:持有StateSet,管理渲染状态
  4. 回调机制:支持更新回调、拣选回调等
  5. 节点掩码:控制节点在不同遍历中的可见性
  6. 描述信息:节点名称、描述等
  7. 用户数据:支持附加自定义数据

2.2 父子关系与共享子图

OSG场景图最独特的特性之一就是支持共享子图——一个节点可以同时有多个父节点。这意味着同一份几何数据可以在场景的多个位置被"实例化",而不需要复制数据。

// 创建一个共享的子图
osg::ref_ptr<osg::Node> treeModel = osgDB::readNodeFile("tree.osgt");
​
// 在场景的多个位置放置同一棵树
osg::ref_ptr<osg::MatrixTransform> tree1 = new osg::MatrixTransform;
tree1->setMatrix(osg::Matrix::translate(0, 0, 0));
tree1->addChild(treeModel.get());  // 第一次引用
​
osg::ref_ptr<osg::MatrixTransform> tree2 = new osg::MatrixTransform;
tree2->setMatrix(osg::Matrix::translate(10, 0, 0));
tree2->addChild(treeModel.get());  // 第二次引用(共享同一份数据)
​
osg::ref_ptr<osg::MatrixTransform> tree3 = new osg::MatrixTransform;
tree3->setMatrix(osg::Matrix::translate(20, 0, 0));
tree3->addChild(treeModel.get());  // 第三次引用

在这个例子中,treeModel节点有三个父节点(tree1、tree2、tree3),但内存中只有一份几何数据。这对于大规模场景(如森林、城市)的内存优化至关重要。

实现机制:

Node内部维护了一个父节点列表_parents:

class Node : public Object
{
protected:
    ParentList _parents; // 父节点列表
    // ...
};

当调用Group::addChild(node)时,会同时调用node->addParent(this)来建立双向链接:

bool Group::addChild(Node* child)
{
    if (child == 0) return false;
    
    // 检查是否已经是子节点
    if (containsNode(child)) return false;
    
    _children.push_back(child);  // Group保存子节点引用
    child->addParent(this);      // 子节点记录父节点
    
    // 更新各种计数(需要更新遍历的子节点数等)
    // ...
    
    return true;
}

这种双向链接的设计有几个好处:

  • 可以从任意节点向上遍历到根节点
  • 可以计算节点的世界变换(需要从根节点累积变换)
  • 可以检查节点是否在场景图中

多父节点的注意事项:

共享子图虽然强大,但也有一些需要注意的地方:

  1. 修改共享节点会影响所有实例:如果你修改了共享节点的状态(如材质),所有引用它的地方都会受到影响。如果需要独立修改,应该先克隆节点。
  2. 更新回调会执行多次:如果一个节点有N个父节点,并且在更新遍历中被访问N次,那么它的更新回调也会被调用N次。这可能导致逻辑错误或性能问题。
  3. 包围体计算的复杂性:由于节点可能出现在场景图的多个位置,它的"世界空间包围体"不是唯一的。OSG的节点只保存局部空间的包围球,世界空间的包围体需要在遍历时实时计算。

2.3 包围体与剔除

每个节点都维护着一个局部空间的包围球(_boundingSphere),这是场景图剔除功能的基础。

包围球的计算:

节点的包围球是"惰性计算"的——只有在需要的时候才会计算,并且会缓存结果。当节点的状态发生变化时(如添加了子节点、修改了几何数据),包围球会被标记为"需要重新计算"。

class Node : public Object
{
public:
    // 获取节点的包围球(局部空间)
    const BoundingSphere& getBound() const;
    
    // 强制重新计算包围球
    void dirtyBound();
    
protected:
    // 计算包围球的虚函数,子类需要重写
    virtual BoundingSphere computeBound() const;
    
    mutable BoundingSphere _boundingSphere;
    mutable bool _boundingSphereComputed;
};

对于不同类型的节点,computeBound()的实现方式不同:

  • Geode:合并所有可绘制对象的包围体
  • Group:合并所有子节点的包围体
  • Transform:将子节点的包围体进行变换后再合并
  • LOD:合并所有子节点的包围体(因为不确定哪一级会被渲染)

包围体的用途:

包围体在OSG中有很多用途:

  1. 视锥剔除:在拣选遍历中,将节点的包围体与视锥体比较,决定是否需要渲染该节点
  2. 遮挡剔除:与遮挡体比较,判断节点是否被遮挡
  3. LOD选择:根据包围体到相机的距离,选择合适的细节层次
  4. 碰撞检测:快速的粗略碰撞检测
  5. 场景管理:如空间划分、八叉树构建等

2.4 StateSet与状态继承

每个节点都可以关联一个StateSet(状态集),用于控制该节点及其子节点的渲染状态。我们将在后续章节中详细讨论StateSet,这里只介绍节点与状态集的关系。

class Node : public Object
{
public:
    // 设置/获取状态集
    void setStateSet(StateSet* stateset);
    StateSet* getStateSet();
    const StateSet* getStateSet() const;
    
    // 获取或创建状态集(确保返回非空)
    StateSet* getOrCreateStateSet();
};

状态集具有继承性:子节点会继承父节点的状态,如果子节点也设置了自己的状态集,那么子节点的状态会与父节点的状态"合并",子节点的设置会覆盖父节点中相同的状态属性。

这种状态继承机制的好处是:

  • 减少状态冗余:相同的状态只需要在高层节点设置一次
  • 便于统一管理:可以很容易地修改一组节点的状态
  • 支持状态覆盖:子节点可以有自己的特殊状态

需要注意的是,状态的合并不是简单的替换,而是按照属性逐一进行的。具体的合并规则我们将在状态管理章节详细讨论。

2.5 节点掩码(NodeMask)

我们在第一篇中已经提到过NodeMask,这里再做一些补充。

NodeMask是一个32位的位掩码,它提供了一种非常高效的方式来控制节点在不同类型遍历中的"可见性"。

// 设置节点掩码
node->setNodeMask(0xffffffff); // 所有位都设为1(默认值)
​
// 获取节点掩码
unsigned int mask = node->getNodeMask();

在场景图遍历时,访问器有自己的traversalMask,只有当(traversalMask & nodeMask) != 0时,节点才会被访问。

实际应用场景:

// 定义不同功能的位
const unsigned int VISIBLE_MASK = 0x0001;
const unsigned int SHADOW_CASTER_MASK = 0x0002;
const unsigned int SHADOW_RECEIVER_MASK = 0x0004;
const unsigned int PICKABLE_MASK = 0x0008;
const unsigned int REFLECTION_MASK = 0x0010;
​
// 普通物体:可见、投射阴影、接收阴影、可拾取、在反射中可见
normalObject->setNodeMask(VISIBLE_MASK | SHADOW_CASTER_MASK | 
                          SHADOW_RECEIVER_MASK | PICKABLE_MASK | 
                          REFLECTION_MASK);
​
// 玻璃:可见、接收阴影、不可拾取、不在反射中
glass->setNodeMask(VISIBLE_MASK | SHADOW_RECEIVER_MASK);
​
// 天空:可见、不投射阴影、不接收阴影、不可拾取
sky->setNodeMask(VISIBLE_MASK);
​
// 使用示例:拾取检测时只检测可拾取的物体
picker->setTraversalMask(PICKABLE_MASK);

NodeMask是一个非常强大但容易被忽视的功能。善用NodeMask可以让你的场景管理更加清晰和高效。


三、Group组节点详解

3.1 Group节点的基本功能

osg::Group是最基本的组节点,它的功能就是简单地管理一组子节点。几乎所有的分支节点都继承自Group。

class OSG_EXPORT Group : public Node
{
public:
    Group();
    Group(const Group& group, const CopyOp& copyop = CopyOp::SHALLOW_COPY);
    
    // 添加子节点
    virtual bool addChild(Node* child);
    virtual bool addChild(Node* child, unsigned int index);
    
    // 移除子节点
    virtual bool removeChild(Node* child);
    virtual bool removeChildren(unsigned int pos, unsigned int numChildrenToRemove);
    
    // 替换子节点
    virtual bool replaceChild(Node* origNode, Node* newNode);
    
    // 获取子节点
    virtual Node* getChild(unsigned int i);
    virtual const Node* getChild(unsigned int i) const;
    
    // 获取子节点数量
    virtual unsigned int getNumChildren() const;
    
    // 查找子节点索引
    virtual unsigned int getChildIndex(const Node* node) const;
    
    // 检查是否包含某个节点
    virtual bool containsNode(const Node* node) const;
    
    // 接受访问器
    virtual void accept(NodeVisitor& nv);
    
protected:
    virtual ~Group();
    
    NodeList _children; // 子节点列表
};

Group的实现非常直接,它内部维护一个NodeList(实际上是std::vector<ref_ptr<Node>>)来存储子节点。

3.2 Group的accept与遍历

Group::accept()是场景图向下遍历的关键实现:

void Group::accept(NodeVisitor& nv)
{
    if (nv.validNodeMask(*this))
    {
        nv.pushOntoNodePath(this);
        nv.apply(*this);
        
        if (nv.getTraversalMode() == NodeVisitor::TRAVERSE_ALL_CHILDREN)
        {
            // 遍历所有子节点
            for (NodeList::iterator itr = _children.begin();
                 itr != _children.end();
                 ++itr)
            {
                (*itr)->accept(nv);
            }
        }
        
        nv.popFromNodePath();
    }
}

这个函数的逻辑很清晰:

  1. 检查节点掩码,决定是否处理该节点
  2. 将自己压入节点路径
  3. 调用访问器的apply()方法
  4. 如果是向下遍历模式,递归遍历所有子节点
  5. 从节点路径中弹出自己

需要注意的是,子节点的遍历顺序是按照它们被添加的顺序进行的。这个顺序在某些情况下很重要,比如透明物体的渲染顺序、Sequence节点的播放顺序等。

3.3 子节点计数优化

在Group的代码中,你会注意到一些看起来有些奇怪的变量,比如_numChildrenRequiringUpdateTraversal、_numChildrenWithCullingDisabled等。这些变量是OSG的性能优化手段之一。

让我们以更新遍历为例来说明这个优化。在更新遍历中,如果一个组节点的所有子节点都不需要更新(没有更新回调等),那么这个组节点本身也不需要继续向下遍历更新。

问题是,如果每次都要检查所有子节点是否需要更新,那开销也不小。OSG的解决方案是:跟踪计数。

Group维护了一个计数器_numChildrenRequiringUpdateTraversal,记录有多少个子节点需要更新遍历。当添加或移除子节点时,这个计数器会被相应地增减。

void Group::addChild(Node* child)
{
    _children.push_back(child);
    child->addParent(this);
    
    // 如果子节点需要更新遍历,增加计数
    if (child->requiresUpdateTraversal())
    {
        incrementNumChildrenRequiringUpdateTraversal(1);
    }
    // ... 其他类似的计数
}

void Group::removeChild(Node* child)
{
    // 找到并移除子节点...
    
    // 如果子节点需要更新遍历,减少计数
    if (child->requiresUpdateTraversal())
    {
        incrementNumChildrenRequiringUpdateTraversal(-1);
    }
    // ...
}

有了这个计数器,节点可以在O(1)时间内判断自己是否需要继续向下遍历:

bool Node::requiresUpdateTraversal() const
{
    return _updateCallback.valid() ||
           _numChildrenRequiringUpdateTraversal > 0 ||
           (_stateset.valid() && _stateset->requiresUpdateTraversal());
}

类似的优化还有:

  • _numChildrenRequiringEventTraversal:需要事件遍历的子节点数
  • _numChildrenWithCullingDisabled:禁用了剔除的子节点数
  • _numChildrenWithOccluderNodes:包含遮挡节点的子节点数

这些计数器看似不起眼,但它们是OSG能够高效处理大规模场景图的重要原因之一。通过在节点添加/移除时维护这些计数,避免了在每次遍历时都去检查所有子节点,将很多O(n)的操作变成了O(1)。


四、变换节点详解

4.1 Transform基类

变换节点是场景图中最常用的节点类型之一,它们用于描述物体在空间中的位置、方向和缩放。osg::Transform是所有变换节点的基类,它继承自Group。

class OSG_EXPORT Transform : public Group
{
public:
    Transform();
    Transform(const Transform&, const CopyOp& copyop = CopyOp::SHALLOW_COPY);
    
    // 计算变换矩阵(最核心的方法)
    virtual bool computeLocalToWorldMatrix(Matrix& matrix, 
                                           NodeVisitor* nv) const;
    virtual bool computeWorldToLocalMatrix(Matrix& matrix, 
                                           NodeVisitor* nv) const;
    
    // 变换的参考系
    enum ReferenceFrame {
        RELATIVE_RF,  // 相对参考系(相对于父节点)
        ABSOLUTE_RF,  // 绝对参考系(相对于世界坐标)
        ABSOLUTE_RF_INHERIT_VIEWPOINT  // 绝对参考系但继承视点
    };
    
    void setReferenceFrame(ReferenceFrame rf);
    ReferenceFrame getReferenceFrame() const;
};

Transform最核心的功能是两个虚函数:computeLocalToWorldMatrix()和computeWorldToLocalMatrix()。它们负责计算从局部坐标到世界坐标(或反向)的变换矩阵。不同类型的变换节点有不同的实现方式。

参考系(Reference Frame)

变换节点的参考系是一个重要但容易被忽略的概念。默认情况下,变换是相对的(RELATIVE_RF)——子节点的变换叠加在父节点的变换之上。但在某些情况下,你可能希望某个节点的变换是绝对的,不受父节点变换的影响。

比如,你想在场景中添加一个始终显示在屏幕某个位置的HUD元素,无论相机怎么移动。这时候可以将HUD的相机节点设置为绝对参考系。

设置为ABSOLUTE_RF后,该节点的变换会"重置"坐标系,不再继承父节点的变换。这在实现HUD、屏幕空间UI等功能时非常有用。

4.2 MatrixTransform

osg::MatrixTransform是最常用的变换节点,它直接使用一个4x4矩阵来描述变换。

class OSG_EXPORT MatrixTransform : public Transform
{
public:
    MatrixTransform();
    MatrixTransform(const Matrix& mat);
    MatrixTransform(const MatrixTransform&, 
                    const CopyOp& copyop = CopyOp::SHALLOW_COPY);
    
    // 设置/获取变换矩阵
    void setMatrix(const Matrix& mat);
    const Matrix& getMatrix() const;
    Matrix& getMatrix();
    
    // 实现Transform的虚函数
    virtual bool computeLocalToWorldMatrix(Matrix& matrix,
                                           NodeVisitor* nv) const;
    virtual bool computeWorldToLocalMatrix(Matrix& matrix,
                                           NodeVisitor* nv) const;
    
protected:
    Matrix _matrix;
};

MatrixTransform的实现非常直接:它保存一个矩阵,需要计算世界变换时,就把这个矩阵乘到结果上。

使用示例:

// 创建一个平移变换
osg::ref_ptr<osg::MatrixTransform> trans = new osg::MatrixTransform;
trans->setMatrix(osg::Matrix::translate(10.0f, 0.0f, 5.0f));
trans->addChild(model.get());

// 创建一个旋转+平移的变换
osg::Matrix m;
m.makeRotate(osg::PI_4, osg::Z_AXIS);  // 绕Z轴旋转45度
m.postMultTranslate(osg::Vec3(5, 0, 0));  // 然后平移

osg::ref_ptr<osg::MatrixTransform> rotTrans = new osg::MatrixTransform(m);

注意:矩阵的顺序

在OSG中,矩阵的乘法顺序是"右结合"的——右边的变换先执行。所以:

  • m = trans * rot 表示先旋转,再平移(物体先自转,然后移动到目标位置)
  • m = rot * trans 表示先平移,再旋转(物体绕原点旋转)

这是一个非常容易出错的地方,建议使用preMult/postMult等命名更清晰的方法:

osg::Matrix m = osg::Matrix::identity();
m.postMultTranslate(osg::Vec3(5, 0, 0));  // 后乘平移
m.preMultRotate(osg::Quat(...));          // 先乘旋转

4.3 PositionAttitudeTransform

osg::PositionAttitudeTransform(简称PAT)是另一种常用的变换节点。它不直接使用矩阵,而是将变换分解为位置(Position)、姿态(Attitude,即旋转)、缩放(Scale)和枢轴点(PivotPoint)几个分量。

class OSG_EXPORT PositionAttitudeTransform : public Transform
{
public:
    PositionAttitudeTransform();
    
    // 位置
    void setPosition(const Vec3d& pos);
    const Vec3d& getPosition() const;
    
    // 姿态(旋转,用四元数表示)
    void setAttitude(const Quat& quat);
    const Quat& getAttitude() const;
    
    // 缩放
    void setScale(const Vec3d& scale);
    const Vec3d& getScale() const;
    
    // 枢轴点(旋转/缩放的中心点)
    void setPivotPoint(const Vec3d& pivot);
    const Vec3d& getPivotPoint() const;
};

PAT的变换组合顺序是:

  1. 平移 -枢轴点(将枢轴点移到原点)
  2. 缩放
  3. 旋转
  4. 平移 +枢轴点 + 位置(移回枢轴点位置,再加上位置偏移)

等效于矩阵:T(position) * T(pivot) * R * S * T(-pivot)

PAT vs MatrixTransform:

什么时候用PAT,什么时候用MatrixTransform呢?

  • 如果你需要分别控制位置、旋转、缩放,用PAT更方便,代码更易读
  • 如果你有复杂的变换组合,或者需要从其他地方获取矩阵,用MatrixTransform更直接
  • PAT使用双精度(double),对于大地坐标等需要高精度的场景更合适
  • MatrixTransform使用单精度(float),性能略好(差距很小)

在实际项目中,PAT通常用于表示物体的位姿(如角色的位置和朝向、飞行器的姿态等),因为它的接口更符合人的直觉。

4.4 AutoTransform自动变换

osg::AutoTransform是一个非常有用的变换节点,它可以根据相机的位置和方向自动调整自己的变换。这在实现"公告板"(Billboard)效果时特别方便。

class OSG_EXPORT AutoTransform : public Transform
{
public:
    AutoTransform();
    
    enum AutoRotateMode {
        NO_ROTATION,       // 不自动旋转
        ROTATE_TO_SCREEN,  // 旋转到正对屏幕(公告板效果)
        ROTATE_TO_CAMERA   // 朝向相机位置
    };
    
    // 设置自动旋转模式
    void setAutoRotateMode(AutoRotateMode mode);
    AutoRotateMode getAutoRotateMode() const;
    
    // 设置自动缩放到屏幕尺寸
    void setAutoScaleToScreen(bool autoScale);
    bool getAutoScaleToScreen() const;
    
    // 设置屏幕尺寸(用于自动缩放)
    void setMinimumScale(float minScale);
    void setMaximumScale(float maxScale);
    
    // 位置、旋转、缩放(同PAT)
    void setPosition(const Vec3d& pos);
    void setRotation(const Quat& quat);
    void setScale(const Vec3d& scale);
};

AutoRotateMode的区别:

  • ROTATE_TO_SCREEN:节点始终平行于屏幕平面。无论相机如何旋转,节点都正对着观察者。这是最常见的公告板效果,适用于树木、标牌、粒子等。
  • ROTATE_TO_CAMERA:节点的Z轴始终指向相机位置。这种模式下,节点会"看向"相机,但如果相机从不同角度观察,节点可能会有透视变形。
  • NO_ROTATION:不自动旋转,和普通变换节点一样。

自动缩放功能:

AutoTransform还支持自动缩放到屏幕尺寸的功能。启用后,节点会自动调整缩放比例,使得无论距离相机多远,它在屏幕上的大小都保持不变。这对于HUD元素、标签文字等非常有用。

osg::ref_ptr<osg::AutoTransform> at = new osg::AutoTransform;
at->setPosition(osg::Vec3(0, 0, 100));
at->setAutoRotateMode(osg::AutoTransform::ROTATE_TO_SCREEN);
at->setAutoScaleToScreen(true);
at->setMinimumScale(1.0f);
at->addChild(label.get());

4.5 Billboard公告板

osg::Billboard是另一种实现公告板效果的节点。与AutoTransform不同,Billboard直接继承自Geode(而不是Transform),它在拣选阶段直接修改模型视图矩阵来实现公告板效果。

class OSG_EXPORT Billboard : public Geode
{
public:
    enum Mode {
        POINT_ROT_EYE,     // 绕视点旋转(最常用)
        POINT_ROT_WORLD,   // 绕世界轴旋转
        AXIAL_ROT          // 绕指定轴旋转
    };
    
    Billboard();
    
    void setMode(Mode mode);
    Mode getMode() const;
    
    // 设置旋转轴(用于AXIAL_ROT模式)
    void setAxis(const Vec3& axis);
    const Vec3& getAxis() const;
    
    // 设置法线方向
    void setNormal(const Vec3& normal);
    const Vec3& getNormal() const;
};

Billboard vs AutoTransform:

这两种方式都能实现公告板效果,但有一些区别:

  1. 实现方式不同:AutoTransform是一个变换节点,通过修改变换矩阵实现;Billboard是一个Geode,在拣选阶段直接操作渲染状态。
  2. 灵活性不同:AutoTransform可以包含任意子节点(包括组节点);Billboard是Geode,只能包含Drawable。
  3. 性能不同:Billboard在拣选阶段处理,开销较小;AutoTransform需要在更新阶段计算矩阵。
  4. 功能不同:AutoTransform支持自动缩放;Billboard支持轴向旋转模式。

一般来说,如果只是简单的公告板效果(比如一个四边形贴图),用Billboard更高效;如果需要更复杂的功能(自动缩放、包含子图等),用AutoTransform更灵活。


五、LOD细节层次节点

5.1 LOD技术简介

LOD(Level of Detail,细节层次)是3D图形中一项经典的优化技术。其基本思想是:当物体距离相机较远时,人眼无法分辨细节,此时可以使用较简单的模型来渲染,从而节省渲染时间。

一个典型的LOD设置包含三个层次:

  • 高细节模型(近距离):完整的几何细节,可能包含几千甚至上万个三角形
  • 中细节模型(中距离):简化后的模型,几百到几千个三角形
  • 低细节模型(远距离):非常简单的模型,甚至只是一个公告板贴图

通过在不同距离使用不同精度的模型,可以在不明显影响视觉质量的前提下,大幅提升渲染性能。

5.2 OSG的LOD节点

osg::LOD节点实现了细节层次功能。它有多个子节点,每个子节点对应一个细节层次,并且有一个距离范围。在渲染时,OSG会根据节点到相机的距离,选择合适的子节点进行渲染。

class OSG_EXPORT LOD : public Group
{
public:
    LOD();
    
    enum RangeMode {
        DISTANCE_FROM_EYE_POINT,  // 基于距离(默认)
        PIXEL_SIZE_ON_SCREEN      // 基于屏幕像素大小
    };
    
    // 设置范围模式
    void setRangeMode(RangeMode mode);
    RangeMode getRangeMode() const;
    
    // 添加子节点及其距离范围
    bool addChild(Node* child, float min, float max);
    
    // 设置某个子节点的范围
    void setRange(unsigned int childNo, float min, float max);
    
    // 获取范围
    float getMinRange(unsigned int childNo) const;
    float getMaxRange(unsigned int childNo) const;
    
    // 设置中心点(用于计算距离)
    void setCenter(const Vec3& center);
    const Vec3& getCenter() const;
    void setCenterMode(CenterMode mode);
    
    enum CenterMode {
        USE_BOUNDING_SPHERE_CENTER,  // 使用包围球中心(默认)
        USER_DEFINED_CENTER,         // 用户自定义中心点
        UNION_OF_BOUNDING_SPHERE_AND_USER_DEFINED
    };
};

使用示例:

osg::ref_ptr<osg::LOD> lod = new osg::LOD;

// 添加三个细节层次
lod->addChild(highDetailModel.get(), 0.0f, 100.0f);     // 0-100米:高细节
lod->addChild(mediumDetailModel.get(), 100.0f, 500.0f);  // 100-500米:中细节
lod->addChild(lowDetailModel.get(), 500.0f, 2000.0f);    // 500-2000米:低细节
// 超过2000米:不渲染

范围模式:

LOD有两种距离计算方式:

  1. DISTANCE_FROM_EYE_POINT(默认):直接使用节点到相机的欧氏距离。这种方式计算简单,但没有考虑物体的大小。一个很大的物体和一个很小的物体,在相同距离下的屏幕尺寸可能差异很大。
  2. PIXEL_SIZE_ON_SCREEN:根据物体在屏幕上的像素大小来切换。这种方式更加合理,因为它考虑了物体的实际大小和视场角。但计算也更复杂一些。

中心点:

LOD计算距离时需要一个参考点。默认情况下,使用节点的包围球中心。但有时候你可能想自定义这个中心点,比如物体的包围球中心和视觉中心不一致时。

5.3 LOD切换的平滑过渡

LOD技术最大的问题是"切换跳变"——当物体从一个距离范围移动到另一个范围时,模型会突然从高细节变成低细节(或反之),这种突变在视觉上很明显,影响沉浸感。

OSG本身没有提供内置的LOD平滑过渡功能,但可以通过一些方式来缓解这个问题:

  1. 使用更多的LOD层次:层次越多,每次切换的变化越小,跳变越不明显。
  2. 使用Fade效果:可以通过自定义节点或着色器,在切换距离附近实现两个LOD层级之间的淡入淡出。
  3. 基于屏幕像素的切换:使用PIXEL_SIZE_ON_SCREEN模式可以让切换更加一致,避免因物体大小不同而产生的不合理切换。
  4. 距离抖动:在切换距离附近加入一些随机抖动,可以让切换不那么明显。

5.4 LOD的性能考量

LOD是一个非常有效的性能优化手段,但使用不当也可能带来问题:

内存开销:每个LOD层级都需要一份模型数据,如果层级很多,内存占用会相应增加。需要在性能和内存之间找到平衡。

加载时间:更多的模型意味着更长的加载时间。可以配合PagedLOD实现按需加载。

切换开销:LOD的判断本身也有开销,但相对于节省的渲染时间来说,通常是微不足道的。

LOD距离的设置:LOD距离的设置是一门艺术。设置得太近,效果不好;设置得太远,性能提升不明显。一般需要根据实际项目反复调试。

一个经验法则是:当物体在屏幕上的像素大小小于某个阈值(如64像素)时,就可以降低细节层次。具体阈值取决于应用的性能要求和视觉质量要求。


六、PagedLOD分页LOD节点

6.1 为什么需要PagedLOD

对于大规模场景(如整个城市、大面积地形),不可能也不需要把所有模型都加载到内存中。大部分时候,用户只能看到场景的一小部分,远处的物体要么很小看不清,要么根本不在视野内。

osg::PagedLOD就是为了解决这个问题而设计的。它继承自LOD,但增加了"分页加载"的能力——低细节的模型可以存储在外部文件中,只有当需要的时候才加载进内存,不需要的时候可以卸载。

6.2 PagedLOD的工作原理

PagedLOD扩展了LOD的功能,它的每个子节点可以有三种状态:

  1. 已加载:模型数据已经在内存中,可以直接渲染
  2. 加载中:模型文件正在被后台线程加载
  3. 未加载:模型数据不在内存中

PagedLOD与DatabasePager(数据库分页器)配合工作。在拣选遍历过程中,如果发现某个PagedLOD的子节点应该被显示但还没加载,就会向DatabasePager提交一个加载请求。DatabasePager会在后台线程中加载模型,加载完成后在合适的时机合并到场景图中。

同时,对于已经加载但长时间不用的节点,DatabasePager也会将其卸载,以释放内存。

6.3 PagedLOD的使用

class OSG_EXPORT PagedLOD : public LOD
{
public:
    PagedLOD();
    
    // 设置文件名(用于分页加载)
    void setFileName(unsigned int childNo, const std::string& filename);
    const std::string& getFileName(unsigned int childNo) const;
    
    // 设置优先级偏移(影响加载顺序)
    void setPriorityOffset(float offset);
    float getPriorityOffset() const;
    
    void setPriorityScale(float scale);
    float getPriorityScale() const;
    
    // 设置加载后的回调
    void setLoadCallback(LoadCallback* cb);
    LoadCallback* getLoadCallback();
    
    // 设置数据库分页器(可选,默认使用viewer中的)
    void setDatabasePager(DatabasePager* pager);
    DatabasePager* getDatabasePager();
};

使用示例:

osg::ref_ptr<osg::PagedLOD> plod = new osg::PagedLOD;

// 第一层:高精度模型,直接在内存中
plod->addChild(highDetailModel.get(), 0.0f, 100.0f);

// 第二层:中精度模型,从外部文件加载
plod->setFileName(1, "models/building_med.osgt");
plod->setRange(1, 100.0f, 500.0f);

// 第三层:低精度模型,从外部文件加载
plod->setFileName(2, "models/building_low.osgt");
plod->setRange(2, 500.0f, 2000.0f);

在这个例子中,只有高精度模型是一开始就在内存中的。当相机距离在100-500米之间时,OSG会自动从models/building_med.osgt加载中精度模型。

6.4 DatabasePager数据库分页器

PagedLOD的背后是osgDB::DatabasePager,它负责管理所有的分页加载请求。DatabasePager是OSG中最复杂的组件之一,我们在这里只介绍它的基本原理。

DatabasePager的核心组件:

  1. 加载请求队列:存放需要加载的文件请求
  2. 加载线程:后台线程,负责从磁盘加载模型
  3. 已加载队列:加载完成但还没合并到场景图的数据
  4. 已加载节点列表:当前在内存中的PagedLOD节点,用于过期管理

加载优先级:

DatabasePager不是按照请求的先后顺序来加载的,而是按照优先级。优先级通常基于距离——离相机越近,优先级越高,越先加载。这样可以保证用户最先看到的内容最先加载。

优先级的计算公式大致是:priority = -distance * priorityScale + priorityOffset

你可以通过PagedLOD::setPriorityOffset()和setPriorityScale()来调整某个节点的加载优先级。比如,重要的建筑可以设置更高的优先级,让它优先加载。

过期策略:

当加载的PagedLOD节点越来越多时,内存占用会越来越大。DatabasePager有一套过期机制,会自动卸载长时间不用的节点。

主要的过期参数包括:

  • 目标最大PagedLOD数量(OSG_MAX_PAGEDLOD环境变量)
  • 未使用时间阈值
  • 内存使用阈值

当超过阈值时,DatabasePager会从最近最少使用(LRU)的节点开始卸载,直到回到阈值以下。

6.5 PagedLOD的最佳实践

使用PagedLOD时,有一些最佳实践需要注意:

  1. 合理设置LOD距离:近处的LOD层级切换要密集一些,远处可以稀疏一些。因为近处的细节变化更明显。
  2. 使用合适的文件格式:对于分页加载,建议使用二进制格式(如.ive),它的加载速度比文本格式(.osg/.osgt)快很多。
  3. 注意坐标系一致性:所有分页的模型应该使用相同的坐标系,否则加载后位置会出错。
  4. 预加载策略:可以根据相机的移动方向,预加载前方可能会看到的区域。OSG的DatabasePager有一些参数可以调整预加载行为。
  5. 避免"冒泡"现象:如果LOD距离设置得太近,用户快速移动时可能会看到模型从无到有"冒出来"。可以适当增大加载距离,让模型在进入视野前就加载好。
  6. 监控内存使用:分页加载虽然可以节省内存,但如果设置不当,也可能导致内存不足。需要根据目标平台的内存容量合理设置最大PagedLOD数量。

七、Switch开关节点与Sequence序列节点

7.1 Switch开关节点

osg::Switch节点可以控制哪些子节点处于"激活"状态,哪些处于"关闭"状态。激活的子节点会被正常渲染和遍历,关闭的子节点则会被跳过。

class OSG_EXPORT Switch : public Group
{
public:
    Switch();
    
    // 设置某个子节点的开关状态
    void setValue(unsigned int childNo, bool val);
    bool getValue(unsigned int childNo) const;
    
    // 设置所有子节点的开关状态
    void setAllChildrenOn();
    void setAllChildrenOff();
    
    // 设置单个子节点开启(其他关闭)
    void setSingleChildOn(unsigned int childNo);
    
    // 添加子节点并指定初始状态
    bool addChild(Node* child);
    bool addChild(Node* child, bool switchOn);
};

使用场景:

Switch节点的应用场景非常广泛:

  1. 物体显示/隐藏:最直接的用法,控制物体的可见性。比如可以让用户通过UI切换某些图层的显示。
osg::ref_ptr<osg::Switch> buildingLayer = new osg::Switch;
buildingLayer->addChild(buildings.get(), true);  // 默认显示

// 隐藏建筑层
buildingLayer->setAllChildrenOff();
  1. 多版本模型切换:同一个物体有多个版本,可以用Switch来切换。比如一辆车有完好版和损坏版。
osg::ref_ptr<osg::Switch> car = new osg::Switch;
car->addChild(carIntact.get(), true);   // 完好的车
car->addChild(carDamaged.get(), false); // 损坏的车

// 车被撞坏了
car->setSingleChildOn(1);  // 切换到损坏版本
  1. 不同细节的UI:根据不同的条件显示不同的UI元素。
  2. 动画状态切换:对于简单的帧动画,可以用Switch来切换不同的帧。

Switch vs NodeMask:

你可能会问:NodeMask也可以控制节点的可见性,和Switch有什么区别?

主要区别在于:

  • NodeMask是按位掩码过滤的,适合分类控制(如"所有可拾取的物体")
  • Switch是按子节点索引控制的,适合一组互斥的选项
  • NodeMask影响所有类型的遍历;Switch只影响拣选和绘制遍历(默认情况下)
  • Switch可以有更复杂的开关逻辑(虽然OSG的Switch实现比较简单)

在实际项目中,可以根据具体需求选择使用哪种方式。很多时候两者结合使用效果最好。

7.2 Sequence序列节点

osg::Sequence是Switch的"升级版",它不仅可以开关子节点,还可以按照时间顺序自动切换,实现简单的帧动画效果。

class OSG_EXPORT Sequence : public Switch
{
public:
    Sequence();
    
    // 设置每帧的持续时间(秒)
    void setTime(unsigned int frame, float time);
    float getTime(unsigned int frame) const;
    
    // 播放控制
    void play();
    void stop();
    bool isPlaying() const;
    
    // 设置循环模式
    void setLoopMode(LoopMode mode);
    LoopMode getLoopMode() const;
    
    enum LoopMode {
        LOOP,        // 循环播放
        SWING,       // 往复播放(正播+倒播)
        ONCE         // 播放一次
    };
    
    // 设置播放速度
    void setSpeed(float speed);
    float getSpeed() const;
    
    // 跳到指定帧
    void setCurrentFrame(unsigned int frame);
    unsigned int getCurrentFrame() const;
};

使用示例:

osg::ref_ptr<osg::Sequence> explosion = new osg::Sequence;

// 添加爆炸动画的各帧
for (int i = 0; i < 10; ++i)
{
    osg::ref_ptr<osg::Node> frame = loadFrame(i);
    explosion->addChild(frame.get());
    explosion->setTime(i, 0.05f);  // 每帧0.05秒(20fps)
}

explosion->setLoopMode(osg::Sequence::ONCE);  // 播放一次
explosion->play();

Sequence节点在内部使用更新回调来跟踪时间,并根据时间自动切换当前帧。它支持三种循环模式:

  • LOOP:循环播放,从第一帧到最后一帧,然后回到第一帧
  • SWING:往复播放,从第一帧到最后一帧,再倒着回到第一帧
  • ONCE:播放一次,停在最后一帧

Sequence适合实现比较简单的帧动画,比如爆炸、火焰、闪烁的灯光等。对于复杂的角色动画,应该使用osgAnimation模块。


八、Geode几何节点与Drawable

8.1 Geode节点

osg::Geode(Geometry Node的缩写)是场景图中的叶子节点,它包含一个或多个可绘制对象(Drawable)。Geode本身不包含几何数据,它只是一个容器,真正的几何数据存储在Drawable中。

class OSG_EXPORT Geode : public Node
{
public:
    Geode();
    
    // 添加/移除Drawable
    bool addDrawable(Drawable* drawable);
    bool removeDrawable(Drawable* drawable);
    
    // 获取Drawable
    Drawable* getDrawable(unsigned int i);
    const Drawable* getDrawable(unsigned int i) const;
    
    // 获取Drawable数量
    unsigned int getNumDrawables() const;
    
    // 设置/获取包围体
    void setBoundingBox(const BoundingBox& bb);
    virtual BoundingBox getBoundingBox() const;
};

为什么要有Geode这一层?为什么不直接把Drawable作为节点?

这是因为Drawable和Node是两个不同的概念:

  • Node是场景图的元素,有父子关系、变换、状态等
  • Drawable是可渲染的对象,有顶点数据、绘制命令等

将两者分开有几个好处:

  1. 职责分离:场景图管理和几何数据管理是两个不同的关注点
  2. 共享Drawable:同一个Drawable可以被多个Geode引用(虽然不常见)
  3. 一个节点可以包含多个Drawable:Geode可以包含多个相关的可绘制对象,它们作为一个整体参与场景图管理

8.2 Drawable基类

osg::Drawable是所有可绘制对象的抽象基类。它定义了可绘制对象的通用接口。

class OSG_EXPORT Drawable : public Object
{
public:
    Drawable();
    
    // 绘制(由渲染后端调用)
    virtual void draw(State& state) const = 0;
    
    // 包围体
    virtual BoundingBox computeBoundingBox() const = 0;
    
    // 支持的显示列表和VBO
    void setSupportsDisplayList(bool support);
    bool getSupportsDisplayList() const;
    
    void setUseDisplayList(bool use);
    bool getUseDisplayList() const;
    
    void setUseVertexBufferObjects(bool use);
    bool getUseVertexBufferObjects() const;
    
    // 绘制回调
    void setDrawCallback(DrawCallback* callback);
    DrawCallback* getDrawCallback();
    
    // 裁剪回调
    void setCullCallback(CullCallback* callback);
    CullCallback* getCullCallback();
    
    // ...
};

Drawable最重要的方法是draw(),它负责执行实际的OpenGL绘制命令。不同的Drawable子类有不同的实现。

OSG中常用的Drawable子类包括:

  • Geometry:最常用的,基于顶点数组的几何数据
  • ShapeDrawable:简单的基本形状(球、立方体、圆锥等)
  • Text:文本
  • ImageStream:视频/动态纹理
  • LightPointDrawable:光点(用于模拟远处的灯光)

8.3 Geometry几何数据

osg::Geometry是最常用、最重要的Drawable子类。它使用顶点数组(Vertex Array)的方式来描述几何数据,支持各种图元类型(点、线、三角形等)。

class OSG_EXPORT Geometry : public Drawable
{
public:
    Geometry();
    
    // 设置顶点数组
    void setVertexArray(Array* array);
    Array* getVertexArray();
    
    // 设置法线数组
    void setNormalArray(Array* array);
    Array* getNormalArray();
    
    // 设置颜色数组
    void setColorArray(Array* array);
    Array* getColorArray();
    
    // 设置纹理坐标数组
    void setTexCoordArray(unsigned int unit, Array* array);
    Array* getTexCoordArray(unsigned int unit);
    
    // 设置数据绑定方式
    void setNormalBinding(AttributeBinding binding);
    void setColorBinding(AttributeBinding binding);
    
    enum AttributeBinding {
        BIND_OFF,      // 不使用
        BIND_OVERALL,  // 整体一个值
        BIND_PER_PRIMITIVE_SET, // 每个图元集一个值
        BIND_PER_PRIMITIVE,     // 每个图元一个值
        BIND_PER_VERTEX         // 每个顶点一个值
    };
    
    // 添加图元集
    void addPrimitiveSet(PrimitiveSet* primitiveset);
    
    // ...
};

顶点数组的结构:

Geometry的核心是各种属性数组(顶点、法线、颜色、纹理坐标等)和图元集(PrimitiveSet)。属性数组描述了每个顶点的数据,图元集描述了如何将这些顶点组织成几何图元。

这种设计的好处是:数据和拓扑分离,可以灵活组合。比如,同样的顶点数据,可以用三角形来渲染,也可以用线框来渲染,只需要替换图元集即可。

创建一个简单的几何体:

让我们来看一个例子,创建一个彩色的四边形:

osg::ref_ptr<osg::Geode> createQuad()
{
    osg::ref_ptr<osg::Geode> geode = new osg::Geode;
    osg::ref_ptr<osg::Geometry> geom = new osg::Geometry;
    
    // 顶点数组(4个顶点)
    osg::ref_ptr<osg::Vec3Array> vertices = new osg::Vec3Array;
    vertices->push_back(osg::Vec3(-1.0f, -1.0f, 0.0f));  // 左下
    vertices->push_back(osg::Vec3( 1.0f, -1.0f, 0.0f));  // 右下
    vertices->push_back(osg::Vec3( 1.0f,  1.0f, 0.0f));  // 右上
    vertices->push_back(osg::Vec3(-1.0f,  1.0f, 0.0f));  // 左上
    geom->setVertexArray(vertices.get());
    
    // 颜色数组(每个顶点一个颜色)
    osg::ref_ptr<osg::Vec4Array> colors = new osg::Vec4Array;
    colors->push_back(osg::Vec4(1.0f, 0.0f, 0.0f, 1.0f));  // 红
    colors->push_back(osg::Vec4(0.0f, 1.0f, 0.0f, 1.0f));  // 绿
    colors->push_back(osg::Vec4(0.0f, 0.0f, 1.0f, 1.0f));  // 蓝
    colors->push_back(osg::Vec4(1.0f, 1.0f, 0.0f, 1.0f));  // 黄
    geom->setColorArray(colors.get());
    geom->setColorBinding(osg::Geometry::BIND_PER_VERTEX);
    
    // 法线数组(整体一个法线,朝向z轴正方向)
    osg::ref_ptr<osg::Vec3Array> normals = new osg::Vec3Array;
    normals->push_back(osg::Vec3(0.0f, 0.0f, 1.0f));
    geom->setNormalArray(normals.get());
    geom->setNormalBinding(osg::Geometry::BIND_OVERALL);
    
    // 图元集:四边形(用两个三角形表示)
    geom->addPrimitiveSet(
        new osg::DrawArrays(osg::PrimitiveSet::QUADS, 0, 4));
    
    geode->addDrawable(geom.get());
    return geode;
}

8.4 PrimitiveSet图元集

osg::PrimitiveSet描述了如何将顶点组织成几何图元。它有几个重要的子类:

类名用途
DrawArrays直接使用顶点数组的顺序绘制
DrawElementsUByte使用索引绘制(8位索引)
DrawElementsUShort使用索引绘制(16位索引)
DrawElementsUInt使用索引绘制(32位索引)
DrawArrayLengths多组DrawArrays(用于多个独立的图元)

图元类型:

OSG支持OpenGL的所有基本图元类型:

enum Mode {
    POINTS,         // 点
    LINES,          // 线段(每两个顶点一条线)
    LINE_STRIP,     // 连续线段
    LINE_LOOP,      // 闭合线段
    TRIANGLES,      // 三角形(每三个顶点一个三角形)
    TRIANGLE_STRIP, // 三角形条带
    TRIANGLE_FAN,   // 三角形扇
    QUADS,          // 四边形
    QUAD_STRIP,     // 四边形条带
    POLYGON         // 多边形
};

索引绘制 vs 数组绘制:

DrawArrays直接使用顶点数组的顺序,简单直接,但会有很多重复的顶点数据。比如一个立方体有8个顶点,但如果用DrawArrays来绘制6个面(每个面4个顶点),需要24个顶点,很多顶点是重复的。

DrawElements使用索引数组,可以复用顶点数据。同样的立方体,只需要8个顶点+36个索引(6个面×2个三角形×3个顶点)。索引绘制可以显著减少顶点数据的内存占用,并且有利于顶点缓存的命中率,从而提升性能。

在实际项目中,大多数模型都使用索引绘制。

8.5 ShapeDrawable简单形状

osg::ShapeDrawable提供了一种快速创建简单几何形状的方式。它支持以下几种基本形状:

形状类描述
Sphere球体
Box长方体/立方体
Cone圆锥
Cylinder圆柱
Capsule胶囊体(圆柱+两个半球)
HeightField高度场(地形)
InfinitePlane无限平面

使用示例:

osg::ref_ptr<osg::Geode> createShapes()
{
    osg::ref_ptr<osg::Geode> geode = new osg::Geode;
    
    // 创建一个球体
    osg::ref_ptr<osg::Sphere> sphere = new osg::Sphere(osg::Vec3(0,0,0), 1.0f);
    osg::ref_ptr<osg::ShapeDrawable> sphereDrawable = 
        new osg::ShapeDrawable(sphere.get());
    geode->addDrawable(sphereDrawable.get());
    
    // 创建一个立方体
    osg::ref_ptr<osg::Box> box = new osg::Box(osg::Vec3(3,0,0), 1.0f);
    osg::ref_ptr<osg::ShapeDrawable> boxDrawable = 
        new osg::ShapeDrawable(box.get());
    geode->addDrawable(boxDrawable.get());
    
    // 创建一个圆锥
    osg::ref_ptr<osg::Cone> cone = new osg::Cone(osg::Vec3(6,0,0), 1.0f, 2.0f);
    geode->addDrawable(new osg::ShapeDrawable(cone.get()));
    
    return geode;
}

ShapeDrawable内部会根据形状参数自动生成顶点数据和索引数据。这对于快速原型开发、调试可视化等场景非常方便。但在性能要求高的场合,建议使用预先生成好的模型,因为ShapeDrawable的细分是固定的,不够灵活。


九、其他重要节点

9.1 Camera相机节点

osg::Camera是OSG中最重要的节点之一,它继承自Transform和CullSettings,定义了一个视点(观察点)和投影方式。

class OSG_EXPORT Camera : public Transform, public CullSettings
{
public:
    Camera();
    
    // 视图矩阵
    void setViewMatrix(const Matrix& matrix);
    const Matrix& getViewMatrix() const;
    
    // 投影矩阵
    void setProjectionMatrix(const Matrix& matrix);
    const Matrix& getProjectionMatrix() const;
    
    // 便捷的投影设置
    void setProjectionMatrixAsPerspective(double fovy, double aspectRatio,
                                          double zNear, double zFar);
    void setProjectionMatrixAsOrtho(double left, double right,
                                    double bottom, double top,
                                    double zNear, double zFar);
    void setViewMatrixAsLookAt(const Vec3d& eye, const Vec3d& center,
                               const Vec3d& up);
    
    // 渲染目标
    void setRenderTargetImplementation(RenderTargetImplementation impl);
    enum RenderTargetImplementation {
        FRAME_BUFFER,          // 直接渲染到帧缓冲(主相机)
        PIXEL_BUFFER,          // 渲染到PBuffer
        FRAME_BUFFER_OBJECT,   // 渲染到FBO
        SEPARATE_WINDOW,       // 渲染到独立窗口
    };
    
    // 附加渲染目标(纹理等)
    void attach(BufferComponent buffer, Texture* texture, int level = 0,
                int face = 0);
    void attach(BufferComponent buffer, Image* image);
    
    // 清除掩码
    void setClearMask(GLbitfield mask);
    GLbitfield getClearMask() const;
    
    // 清除颜色
    void setClearColor(const Vec4& color);
    const Vec4& getClearColor() const;
    
    // 渲染顺序
    void setRenderOrder(RenderOrder order, int orderNum = 0);
    enum RenderOrder {
        PRE_RENDER,    // 预渲染(在主相机之前)
        NESTED_RENDER, // 嵌套渲染
        POST_RENDER    // 后渲染(在主相机之后)
    };
};

Camera节点的功能非常丰富,它不仅可以表示正常的观察相机,还可以实现:

  • 离屏渲染(Render to Texture):将场景渲染到纹理中,用于镜面、阴影、后处理等效果
  • 多相机渲染:一个场景可以被多个相机从不同角度渲染
  • HUD/UI渲染:使用正交投影的相机来渲染2D界面元素
  • 多窗口/多屏渲染:每个窗口/屏幕对应一个Camera

我们将在渲染管线的章节中更详细地讨论Camera的工作原理。

9.2 LightSource光源节点

osg::LightSource是光源节点,它包含一个Light对象,用于在场景中添加光源。

class OSG_EXPORT LightSource : public Group
{
public:
    LightSource();
    
    void setLight(Light* light);
    Light* getLight();
    
    // 设置光源编号(OpenGL支持最多8个固定管线光源)
    void setGLModeValue(int value);
    int getGLModeValue() const;
    
    // 光源是否为有向的(影响变换方式)
    void setLocalStateSetModes(unsigned int modes);
};

OSG支持以下几种光源类型(在osg::Light中定义):

  • AMBIENT:环境光
  • DIRECTIONAL:方向光(如太阳光)
  • POINT:点光源(如灯泡)
  • SPOT:聚光灯

光源的位置和方向由LightSource节点在场景图中的位置决定。也就是说,如果LightSource节点在一个变换节点之下,那么光源会跟随变换节点一起移动。这是一个非常直观和强大的设计——你可以把光源"挂"在任何物体上。

9.3 ClipNode裁剪节点

osg::ClipNode用于添加裁剪平面。裁剪平面可以"切掉"场景的一部分,只显示平面某一侧的内容。这在医学可视化、工程分析等领域非常有用。

class OSG_EXPORT ClipNode : public Group
{
public:
    ClipNode();
    
    // 添加/移除裁剪平面
    bool addClipPlane(ClipPlane* clipplane);
    bool removeClipPlane(ClipPlane* clipplane);
    
    // 获取裁剪平面
    ClipPlane* getClipPlane(unsigned int i);
    
    unsigned int getNumClipPlanes() const;
};

ClipNode的使用方式很简单:创建一个或多个ClipPlane,添加到ClipNode中,然后把需要被裁剪的场景作为ClipNode的子节点。

裁剪平面的位置和方向也是由场景图的变换决定的,因此可以很方便地实现"移动的切割面"等效果。

9.4 OccluderNode遮挡节点

osg::OccluderNode是一种用于遮挡剔除优化的节点。它表示一个遮挡体(通常是一个简单的多边形,如墙面),在它后面的物体会被认为是被遮挡的,从而跳过渲染。

class OSG_EXPORT OccluderNode : public Group
{
public:
    OccluderNode();
    
    void setOccluder(ConvexPlanarOccluder* occluder);
    ConvexPlanarOccluder* getOccluder();
};

遮挡剔除是一种非常有效的优化技术,尤其是在建筑内部、城市街道等有很多遮挡物的场景中。OSG的遮挡剔除采用了"遮挡体"的方式——用户需要手动指定哪些物体作为遮挡体。

虽然手动指定遮挡体增加了一些工作量,但这种方式的好处是:

  1. 不需要硬件遮挡查询(Occlusion Query),避免了CPU-GPU同步的开销
  2. 可以在软件层面进行剔除,更早地排除不可见物体
  3. 遮挡体可以比实际物体简单,降低计算量

在实际项目中,通常会用简单的几何体作为遮挡体,而复杂的视觉模型作为普通节点。


十、节点的创建与使用最佳实践

10.1 场景图构建的原则

构建一个结构良好的场景图对于性能和可维护性都非常重要。以下是一些经验原则:

1. 合理的层次深度

场景图的层次既不能太浅,也不能太深。太浅的话,剔除效率不高;太深的话,遍历的开销会增大。一般来说,3-8层的深度是比较合理的。

2. 空间局部性

在空间上靠近的物体,在场景图中也应该尽量靠近(属于同一个父节点)。这样可以提高包围体的紧密度,从而提高剔除效率。

3. 状态归类

使用相同渲染状态的物体尽量放在一起,这样可以减少渲染时的状态切换,提高性能。

4. 共享实例

对于重复的物体,尽量使用共享子图的方式,而不是复制多份。这可以节省大量内存。

5. 适度使用LOD

对于距离变化大、细节丰富的物体,一定要使用LOD。不要等到性能不够了才想起加LOD。

10.2 常见的场景图反模式

以下是一些常见的"反模式",在构建场景图时应该尽量避免:

反模式1:单根节点下挂几百个子节点

Root
├── obj1
├── obj2
├── ...
└── obj500

这种扁平的结构意味着每次剔除都要检查所有500个节点,效率很低。应该按照空间位置或其他逻辑将它们分组。

反模式2:过度深的嵌套

Root
└── Group1
    └── Group2
        └── Group3
            └── Group4
                └── actualNode

有些开发者喜欢为每个小功能都加一层Group,导致场景图过深。过深的层次会增加遍历开销,也不利于调试。

反模式3:在Geode上挂Transform

Geode是叶子节点,不能有子节点。变换应该使用Transform节点来包裹Geode。

反模式4:一个Geometry只有几个三角形

每个Drawable都有一定的开销(状态切换、绘制调用等)。如果一个Geometry只有几个三角形,那开销的占比就太高了。应该尽量合并小的几何体。

当然,也不是说合并得越大越好。太大的Geometry会导致剔除效率下降(因为包围体太大,很多时候整个都在视锥内,无法部分剔除)。需要在粒度和数量之间找到平衡。

10.3 节点创建的性能优化

创建场景图时,有一些性能优化技巧:

1. 预分配空间

如果你知道一个Group会有多少个子节点,可以预先分配空间,避免频繁的内存重分配。

// 不好的做法:反复push_back导致多次扩容
osg::ref_ptr<osg::Group> group = new osg::Group;
for (int i = 0; i < 1000; ++i) {
    group->addChild(createNode(i));
}

// 好的做法:(OSG没有直接的reserve,但可以用其他方式优化)
// 可以先创建节点数组,再一次性添加

2. 批量设置状态

对于有相同状态的节点,尽量共享同一个StateSet,而不是每个节点创建一个。这不仅节省内存,也有利于状态排序。

// 共享StateSet
osg::ref_ptr<osg::StateSet> sharedState = createSharedState();

for (int i = 0; i < 100; ++i) {
    osg::Node* node = createNode(i);
    node->setStateSet(sharedState.get());  // 共享
}

3. 延迟计算

不要在创建场景图的时候立即计算所有东西。OSG的很多计算(如包围体)都是惰性的,只有在需要的时候才计算。尽量利用这种惰性计算的特性。

10.4 调试与检查场景图

在复杂的项目中,场景图可能变得非常庞大,调试起来很困难。OSG提供了一些工具来帮助你检查场景图:

1. PrintVisitor打印场景图

osgUtil::PrintVisitor pv(std::cout);
root->accept(pv);

PrintVisitor可以打印出场景图的结构,包括每个节点的类型、名称、子节点数量等信息。这对于理解场景图的结构非常有帮助。

2. Statistics统计信息

osgUtil::Statistics stats;
root->accept(stats);
std::cout << "Total vertices: " << stats._vertexCount << std::endl;
std::cout << "Total triangles: " << stats._triangleCount << std::endl;
std::cout << "Total nodes: " << stats._nodeCount << std::endl;

Statistics访问器可以统计场景图中的各种数据,如顶点数、三角形数、节点数、纹理数等。这对于性能分析很有用。

3. osgviewer检查

使用osgviewer工具可以快速查看模型文件。在osgviewer中按t键可以显示统计信息,按s键可以显示状态信息,这些对于调试都很有帮助。


十一、本章总结

在这篇文章中,我们深入探讨了OSG场景图的节点系统。让我们回顾一下主要内容:

  1. 节点分类:OSG的节点分为组节点、变换节点、叶子节点等几大类,每种节点有特定的职责。
  2. Node基类:提供了父子关系管理、包围体、状态集、节点掩码等通用功能。支持多父节点(共享子图)是OSG的重要特性。
  3. Group组节点:最基本的组节点,通过子节点计数优化提高了遍历效率。
  4. 变换节点:包括MatrixTransform、PositionAttitudeTransform、AutoTransform、Billboard等,用于描述物体的空间变换。
  5. LOD与PagedLOD:细节层次技术和分页加载技术,是大规模场景渲染的关键。
  6. Switch与Sequence:用于控制子节点的激活状态,可实现显示切换和简单的帧动画。
  7. Geode与Drawable:场景图的叶子节点,包含实际的可渲染数据。Geometry是最常用的Drawable,使用顶点数组+图元集的方式描述几何数据。
  8. 其他重要节点:如Camera、LightSource、ClipNode、OccluderNode等。
  9. 最佳实践:合理的场景图结构、避免反模式、性能优化技巧、调试工具等。

节点系统是OSG的骨架,理解了各种节点的特性和用法,才能构建出高效、可维护的3D应用。在下一篇文章中,我们将深入OSG的渲染管线,探讨状态管理、拣选遍历、渲染排序等核心机制。


本文是「深入理解OpenSceneGraph」系列的第二篇。下一篇我们将聚焦于OSG的渲染管线与状态管理系统,揭示其高效渲染的内部机制。