Welcome to OpenSceneGraph 3.0 Beginner’s Guide’s documentation!

Chapter 1: The Journey into OpenSceneGraph

Chapter 2: Compilation and Installation of OpenSceneGraph

Chapter 3: Creating Your First OSG Program

Constructing your own projects

Using a root node

Understanding memory management

在一个典型的编程场景中,开发者应该创建一个指向根节点的指针,根节点会直接或间接的管理场景图的所有其他子节点。在这种情况下,当他们不再需要进行渲染时,程序会遍历场景图并且小心的删除每一个节点及其内部数据。这个过程非常繁琐且极易出错,因为开发者不会知道有多少其他的对象依然指向正在被删除的节点。然而如果不编写管理代码,所有场景节点所占用的数据段就不会被删除,从而会导致不可预期的内存泄露。

这就是OSG编程中内存管理如此重要的原因。一个基本的内存管理概念通常涉及两个主题:

# 分配:通过分配所请求的内存块为对象提供需要的内存。 # 回收:当其数据不再被需要时回收所分配的内存以供重用。

一些现代的编程语言,例如C#,Java与Visual Basic,使用垃圾回收器来释放任意的程序变量不再引用的内存块。这意味着存储引用内存块的对象数目,并且当数目减少到零时回收内存。

标准的C++方法并没有以这种方式进行工作,但是我们可以通过智能指针(smart pointer)的方法来模拟,智能指针被定义为一个类似于指针的对象,但是在内存管理中更为智能。例如,boost库提供了boost::shared_ptr<>类模板来存储指针以实现动态分配相关的对象。

ref_ptr<> and Referenced classes

幸运的是,OSG也提供了一个原生的智能指针,osg::ref_ptr<>,用于自动垃圾收集与释放。为了使其正确工作,OSG同时提供了osg::Referenced类来管理引用计数的内存块,该类被用作扮演模板参数角色的所有类的基类。

osg::ref_ptr<>类模板重新实现了大量的C++操作符以及成员函数,从而为开发者提供了方便的方法。其主要组成部分如下:

  • get():该公共方法返回被管理的指针,例如,如果我们使用osg::Node作为模板参数则会返回osg::Node* 指针。
  • operator*():这实际上是一个解引用操作符,该操作符会返回指针地址处的左值,例如,osg::Node&引用变量。
  • operator->()与operator=():这两个操作符允许用户程序将osg::ref_ptr<>用作普通指针。前者调用被管理对象的成员函数,而后者使用一个新指针来替换当前被管理的指针。
  • operator==(),operator!=()与operator!():这几个操作符用于比较智能指针,或是检测特定的指针是否正确。被赋值为NULL值或是没有任何赋值的osg::ref_ptr<>对象被认为是不正确的。
  • valid():如果被管理的指针不为NULL,该公共方法会返回真。如果some_ptr被定义为智能指针,则表达式some_ptr.valid()等同于some_ptr!=NULL。
  • release():当由函数中返回被管理的地址时,这个公共方法会非常有用。我们会在稍后进行讨论。

osg::Referenced类是所有场景图中所有元素的纯基类,例如节点,几何体,渲染状态以及所有其他可分配的场景对象。osg::Node类实际上间接派生自osg::Refereced。这也正是我们可以编写如下语句的原因:

osg::ref_ptr<osg::Node> root;

osg::Referenced类包含一个整数用来处理所分配的内存块。在类的构造函数中引用计数被初始化为0,如果osg::Referenced对象被一个osg::ref_ptr<>智能指针引用时,引用计数会增加1。相对的,如果对象被由特定的智能指针移除时,引用计数会减1。当不再为任何智能指针引用时,对象本身将会被自动销毁。

osg::Referenced类提供了三个主要成员方法:

  • 公共方法ref()将引用计数值增加1
  • 公共方法unref()将引用计数值减少1
  • 公共方法referenceCount()返回当前的引用计数值,这对于代码调试非常有用

这些方法同样可以作用于由osg::Referenced派生的类。注意,在用户程序中无需直接调用ref()与unref()方法,这意味着引用计数是由手动进行管理的,且也许会与osg::ref_ptr<>的操作产生冲突。否则,OSG的内部垃圾回收系统将会获得错误的正在使用中的智能指针数目,甚至在以不正确的方式管理内存块会导致程序崩溃。

Collecting garbage: why and how

下面是一些在程序中使用智能指针与垃圾回收系统的原因:

  • 更少的错误:使用智能指针意味着指针的自动初始化与清除。不会产生野指针,因为他们总是引用计数的。
  • 高效管理:当对象不再被引用时他们会被立即回收,从而在有限的资源下为程序提供更多可用的内存。
  • 调试容易:我们可以很容易获取对象的引用计数以及其他信息,然后应用其他的优化与实验。

例如,场景图是由一个根节点与多层子节点构成的。假定所有的子节点都是由osg::ref_ptr<>进行管理的,用户程序只需要保存指向根节点的指针。如下图所示,删除根节点指针的操作将会导致销毁整个节点层次结构的级联效果:

_images/osg_ref_ptr.png

示例中的每一个节点都是由其父节点进行管理的,并会在父节点的删除过程中自动解引用。该节点如果不再由其他的节点进行引用,会被立即销毁,而且其所有的子节点会被释放。整个场景图会在最后一个组合节点或是叶子节点被删除之后被最终清理。

这一过程非常方便与高效,不是吗?请确信OSG智能指针正适合我们,并且使用由osg::Referenced派生的类作为osg::ref_ptr<>的模板参数,并且正确的向智能指针赋值新分配的对象。

智能指针可以被用作局部变量,全局变量或是类变量,并且会在重新赋值给其他对象或是超出智能指针声明的范围时自动减少引用计数。

强烈推荐用户程序总是使用智能指针来管理其场景,但是仍然有一些需要特别注意的问题:

  • osg::Referenced及其派生类只能由堆上生成。他们不能被用作局部变量,因为出于安全考虑类析构函数在内部被声明为proteced。例如:
osg::ref_ptr<osg::Node> node = new osg::Node; // this is legal
osg::Node node; // this is illegal!
  • 普通的C++指针可以临时起作用。但是用户程序应记得将其赋值给osg::ref_ptr<>,或是在最后将其添加到场景图元素(几乎所有的OSG场景类都使用智能指外域来管理子对象),因为他总是最安全的方法。
osg::Node* tmpNode = new osg::Node; // this is OK
…
osg::ref_ptr<osg::Node> node = tmpNode; // Good finish!
  • 不要使用循环引用,因为垃圾回收机制不能处理。循环引用意味着一个对象直接或是间接引用其自身,从而会导致错误的引用计数的计算。

下图中所示的场景图包含两种类型的循环引用,这两种类型都是错误的。节点Child 1.1添加其自身作为子节点,从而会导致遍历子节点时的死循环,因为他也是其自身的子节点。节点Child 2.2是一个间接循环引用,当运行时会导致相同的问题:

_images/osg_reference_cycle.png

下面让我们通过一个非常简单的示例来更好的理解内存管理的基本概念。

Tracing the managed entities

我们所感兴趣的是osg::ref_ptr<>如何绑定与处理osg::Referenced对象,以及何时销毁被管理的对象。我们已经了解的是:当对象不再为任何智能指针所引用,或是其引用者已经超出其声明范围时,所管理的对象会被自动销毁。让我们看一下实际这是如何操作的。

Parsing command-line arguments

主函数的命令行参数为用户程序定义了不同的参数。主函数的声明总是如下面的样子:

int main( int argc, char** argv );

argc与argv参数构成了一个包含程序名与其他必须参数的字符串数组。OSG提供了一个快速与安全的osg::ArgumentParser来读取并使用这些参数。

Tracing with the notifier

OSG通知器机制为由OSG渲染后端或是由用户级别输出可视的调试信息提供了理想方法。对于跟踪与调试程序这确实是一个重要而省时的方法。另外,通知器也被用在OSG核心功能与插件中来显示错误,警告信息或是过程信息。开发者也许仅是简单的在源代码中插入调试输出函数,输出函数,osg::notify(),被设计来接受不同的信息级别并将其发送到控制台划用户定义的控制器。

osg::nofiy()函数可以被用作标准输出流std::cout。他需要一个NotifyServerity参数来指示消息级别,该参数可以为ALWAYS,FATAL,WARN,NOTICE,INFO,DEBUG_INFO与DEBUG_FP,依次由最严重级别到最轻级别。例如:

osg::notify(osg::WARN) << "Some warn message." << std::endl;

默认情况下这会输出一条警告信息。这里osg::WARN被用于向OSG通知系统指示通知级别。

一系列的宏,例如OSG_FATAL,OSG_WARN与OSG_NOTICE与使用不同级别的osg::notify()函数具有相同的作用。

Redirecting the notifier

OSG输出信息总是包含关于运行状态,图形系统扩展以及OSG后端与用户程序中可能问题的相关信息。这对于作为调试基于OSG的程序的引用资源同样非常重要。

在某些情况下,在程序中并没有控制台输出,从而会阻碍我们读取通知信息与查找可能的错误。然而,osg::NotifyHandler派生类可以用于将通知器重定向到其他的输出流,例如文件或是GUI部件。

Summary

本章提供了一个简单的指南来使用CMake工具创建我们自己的简单OSG程序,并且介绍了一些实用程序。OSG使用智能指针用于在运行时分配给场景节点的操作系统资源的高效管理,这对于这些要求安全的程序的性能非常重要。为了有助于理解智能指针的工作原则,我们花费了大量的章节来解释osg::ref_ptr<>的使用以及如何计算引用计数,并且探讨了当管理OSG场景元素时可能会出现的各种情况。

在本章中,我们特别讨论了:

  • 如何编写简单的CMake脚本文件并且使其与我们自己的源码和OSG依赖配合工作
  • OSG智能指针与垃圾回收机制的原则
  • 在场景图对象上使用智能指针的优点与注意事项
  • 用于解析命令行参数以及跟踪与调试我们源代码的其他有用类与函数

Chapter 4: Building Geometry Models

OpenGL图像管线的基本操作是接受顶点数据(点,线,三角形与多边形)以及像素数据(图像数据),将其转换为帧并存储在帧缓冲区中。帧缓冲区作为开发者与计算机显示之间的重要接口,将图像内容的每一帧映射到内存空间用于读写操作。OSG封装了全部的OpenGL顶点转换以及基本的组装操作,从而管理并将顶点数据发送到OpenGL管线,以及用于改善渲染性能的数据传输优化以及额外的多边形技术等。

本章我们将会关注如何通过快速路径绘制与渲染几何模型,涉及如下内容:

  • 如何通过少量的必要参数快速绘制基本对象
  • 如何设置顶点与顶点属性数组来构建几何对象
  • 使用基元索引顶点数据的原因与方法
  • 如何通过使用不同的多边形技术优化渲染
  • 如何访问几何属性与基元
  • 将OpenGL绘制调用集成到我们的基于OSG的程序中

How OpenGL draws objects

OpenGL使用几何基元在3D世界中绘制不同的对象。一个几何基元可以是点集合,线,三角形或是多边形面,决定了OpenGL如何排序并渲染其相关联的顶点数据。渲染基元最简单的方法就是在glBegin()与glEnd()对之间指定一个顶点列表,这被称为立即模式,但是在大多数情况下效率低下。

顶点数据,包括顶点坐标,法线,颜色以及纹理坐标,也可以存储在各种数组中。基元可以通过解引用与索引数组元素来形成。这种方法,名为顶点数组,减少了额外的共享顶点,因而性能要优于立即模式。

显示列表也极大的改进了程序性能,因为所有的顶点与像素数据都被编译并被拷贝到图像内存中。这些准备好的基元可以被重复使用,而无需多次传输数据。这在绘制静态几何时非常有用。

顶点缓冲区对象(VBO)机制允许顶点数组被存储在高性能内存中。这为传输动态数据提供了一种更为高效的解决方案。

默认情况下,OSG使用顶点数组与显示列表来管理与渲染几何。然而,这会依据不同的数据类型与渲染策略而发生变化。

我们需要强调在OpenGL ES与OpenGL 3.x中立即模式与显示列表的去除,这是出于提供更为轻量级的接口的考虑。当然OpenGL 3.x以及后续的版本中将会保留废弃的API以向后兼容。然而,这些API不推荐在新代码中使用。

Geode and Drawable classes

osg::Geode类对应于场景图中的叶子节点。他没有子节点,但是却总是包含渲染所需要的几何信息。其名字Geode是几何节点的缩写。

要被绘制的几何数据存储在由osg::Geode管理的osg::Drawable对象集合中。不可实例化的osg::Drawable类被定义为纯虚类。他有多个子类用于向OpenGL管线渲染模型,图像以及文本。这些可渲染的元素被统称为可绘制元素(drawables)。

osg::Geode类提供提供了多种方法来关联与解关联可绘制元素,以及获取相关的信息:

  1. 公共方法addDrawable()采用一个osg::Drawable指针作为元素并将一个可绘制元素关联到osg::Geode实例。所添加的所有可绘制元素在内部是由osg::ref_ptr<>智能指针进行管理的。
  2. 公共方法removeDrawable()与removeDrawables()将会由当前的osg::Geode对象中删除一个或是多个可绘制对象,同时会减少其引用计数。removeDrawable()方法使用一个osg::Drawable指针作为其唯一参数,而removeDrawables()接受两个参数:基于零索引的起始元素以及要删除的元素数目。
  3. getDrawable()方法返回存储在指定基于零索引处的osg::Drawable对象。
  4. getNumDrawables()方法返回所关联可绘制元素的总数。然后开发者可以通过getDrawable()方法在循环中对每一个可绘制元素进行遍历,或是使用下面的代码来移除所有的可绘制元素:
geode->removeDrawables(0, geode->getNumDrawables());

Rendering basic shapes

OSG提供了一个osg::ShapeDrawable类,该类继承于osg::Drawable基类,使用普通的参数来快速绘制基本几何图形。一个osg::ShapeDrawable实例总是包含一个osg::Shape对象来表示指定几何图形的类型与属性。

setShape()方法通常用于分配并设置图形。例如:

shapeDrawable->setShape(new osg::Box(osg::Vec3(1.0f, 0.0f, 0.0f), 10.0f, 10.0f, 5.0f);

他将会赋值一个中心点位于其本地坐标空间中(1.0, 0.0, 0.0)处,宽与高为10,而深度为5的盒子。这里,类osg::Vec3表示OSG中的一个三元素向量。其他预定义类如osg::Vec2与osg::Vec4在定义顶点,颜色,法线与纹理坐标时会非常有用。

注意,osg::Vec3表示一个浮点类型向量,osg::Vec3d表示一个双精度浮点型向量,同时osg::Vec2与osg::Vec2d,osg::Vec4与osg::Vec4d也是如此。

OSG中定义的最常使用的基本图形有osg::Box,osg::Capsule,osg::Cone,osg::Cylinder与osg::Sphere。其形状可以通过直接向构建函数传递参数进行定义。

Time for action-quickly creating simple objects

使用osg::Shape子类可以很容易创建对象。我们将使用三个典型的图形作为示例:一个具有不同宽度、高度与深度值的盒子,一个具有半径值的圆形以及一个具有半径与高度值的锥体。

  1. 包含所需的头文件:
#include <osg/ShapeDrawable>
#include <osg/Geode>
#include <osgViewer/Viewer>
  1. 依次添加三个osg::ShapeDrawble对象,每一个带有一个基本形状类型。我们将这三个图形放置在不同的位置从而使得他们同时为查看者所见,并且为了将其彼此区分,我们通过使用osg::ShapeDrawble的setColor()方法依次将后两个图形设置为绿色与蓝色:
osg::ref_ptr<osg::ShapeDrawable> shape1 = new osg::ShapeDrawable;
shape1->setShape( new osg::Box(osg::Vec3(-3.0f, 0.0f, 0.0f),
                               2.0f, 2.0f, 1.0f) );
osg::ref_ptr<osg::ShapeDrawable> shape2 = new osg::ShapeDrawable;
shape2->setShape( new osg::Sphere(osg::Vec3(3.0f, 0.0f, 0.0f),
                                  1.0f) );
shape2->setColor( osg::Vec4(0.0f, 0.0f, 1.0f, 1.0f) );
osg::ref_ptr<osg::ShapeDrawable> shape3 = new osg::ShapeDrawable;
shape3->setShape( new osg::Cone(osg::Vec3(0.0f, 0.0f, 0.0f),
                                1.0f, 1.0f) );
shape3->setColor( osg::Vec4(0.0f, 1.0f, 0.0f, 1.0f) );
  1. 创建一个osg::Geode对象,并将所创建的可绘制元素添加到该对象。注意,在这里可绘制元素与几何体节点都是由osg::ref_ptr<>智能指针管理的。osg::Geode对象最终被用作查看器的场景根节点:
osg::ref_ptr<osg::Geode> root = new osg::Geode;
root->addDrawable( shape1.get() );
root->addDrawable( shape2.get() );
root->addDrawable( shape3.get() );
osgViewer::Viewer viewer;
viewer.setSceneData( root.get() );
return viewer.run();
  1. 现在是查看这些图形是否被正确渲染的时候了。我们在这里不必关心顶点位置,法线与颜色的实际绘制,这会为调试与快速的图形查看带来方便:
_images/osg_shape.png

What just happened?

osg::ShapeDrawble类对于快速显示非常有用,但是却并不是一种绘制几体基本元素的高效方法。他应只被用作我们开发3D程序时快速原型与调试的一种方法。要创建具有高效计算与可视化需求的几何体,下面将要介绍的osg::Geometry类总是一种更好的选择。

OSG有一个内部的osg::GLBeginEndAdapter类可以用来执行基本的图形绘制操作。这个类可以允许glBegin()与glEnd()对风格的顶点数组的使用,从而使得基本图形的实现更容易理解与扩展。

要获得并使用一个已初始化的osg::GLBeginEndAdapter对象,我们必须定义一个由osg::Drawble基类派生的类,并重新实现其drawImplementation()方法,并从头开始编程,就如同我们编写典型的OpenGL 1.0绘制调用一样:

我们可以在本章的Implementing your own drawables一节找到关于重新实现osg::Drawable类的更多信息。

Storing array data

正如在前面章节中所提到的,OSG支持顶点数组与VBO来加速渲染过程。要管理在这两种机制中所使用的顶点数据,OSG定义了一个基本的osg::Array类以及一些派生类用于通常用到的数组与索引数组类型。

osg::Array类不能被实例化,但是他声明了与OpenGL调用交换以及缓冲数据修饰符的接口。其子类(osg::Vec2Array,osg::Vec3Array,osg::UIntArray等)继续了STL vector类的特点,因而可以使用所有的std::vector成员,包括push_back(),pop_back(),size()以及STL算法与迭代器。

下面的代码将会向一个已存在的osg::Vec3Array对象命名顶点添加一个三元素的向量:

vertices->push_back( osg::Vec3(1.0f, 0.0f, 0.0f) );

OSG内建数组类应在堆上进行分配并且由智能指针进行管理。然而,对于数组元素如osg::Vec2与osg::Vec3则不必遵循这一规则,因为他们是非常基本的数据类型。

osg::Geometry类扮演了OpenGL顶点数组高层封装者的角色。他会记录不同的数组类型并且管理一个几何基元集合来以顺序方式渲染这些顶点数据。他由osg::Drawable类派生,并且可以被随时添加到osg::Geode对象中。该类接受数组作为基本的数据携带者,并且使用这些数据生成简单或复杂的几何模型。

Vertices and vertex attributes

顶点是几何基元的原子元素。他使用多个数值属性来描述2D或3D空间的点,包括顶点位置,颜色,法线与纹理坐标,雾坐标等。位置值总是必须的,而其他属性则有助于定义点的自然特性。OpenGL接受为每个顶点指定高达16个普通属性,并且可以创建不同的数组来存储这些属性。所有的属性数组都是通过相应的set*Array()方法进行支持的。

OpenGL中内建的顶点属性列表如下表所示:

_images/osg_vertex_attributes.png

在当前的OpenGL图像系统中,一个顶点通常包含八个纹理坐标与三个普通属性。原则上,每一个顶点都应该将其所有属性设置为特定的值,并且构成一个具有相同尺寸的数组集合;否则未定义的属性也许会导致不可预料的问题。OSG提供了绑定方法使得这些工作更为方便。例如,开发者也许会在osg::Geometry对象geom上调用公共方法setColorBinding(),并且使用一个枚举作为参数:

geom->setColorBinding( osg::Geometry::BIND_PER_VERTEX );

这表明颜色与顶点进入一对一的关系。然而,下面的代码:

geom->setColorBinding( osg::Geometry::BIND_OVERALL );

他会将一个颜色值应用到整个几何体上。其他的setNormalBinding(),setSecondaryColorBinding(),setFogCoordBinding()与setVertexAttribBinding()会为其他的属性类型完成相应的工作。

Specifying drawing types

在设置顶点属性数组之后的步骤就是通知osg::Geometry对象如何进行渲染。虚基类osg::PrimitiveSet被用来管理几何基元集合,该集合记录了顶点渲染顺序信息。osg::Geometry提供了一些公共方法在一个或是多个基元集合上进行操作:

  1. addPrimitiveSet()方法使用一个osg::PrimitiveSet指针作为参数并且将基元集合关联到osg::Geometry对象。
  2. removePrimitiveSet()方法需要一个基于零的索引参数以及要移除的基元集合的数目。他将会移除一个或是多个已关联的基元集合。
  3. getPrimitiveSet()返回指定索引处的osg::PrimitiveSet指针。
  4. getNumPrimitiveSets()返回基元集合的总数目。

osg::PrimitiveSet类不能被直接实例化,但是他派生了一些子类用来封装OpenGL的glDrawArrays()与glDrawElements()实体,例如osg::DrawArrays与osg::DrawElementsUInt。

osg::DrawArrays类使用顶点数组中的一系列序列元素来构建几何基元序列。可以通过下面的声明来创建并关联到osg::Geometry对象geom:

geom->addPrimitiveSet( new osg::DrawArrays(mode, first, count) );

第一个参数mode指定了要渲染哪种基元类型。类似于OpenGL glDrawArrays()实体,osg::DrawArrays通常接受十种基元类型:GL_POINTS,GL_LINE_STRIP,GL_LINE_LOOP,GL_LINES,GL_TRIANGLE_STRIP,GL_TRIANGLE_FAN,GL_TRIANGLES,GL_QUAD_STRIP,GL_QUADS与GL_POLYGON。

第二与第三个参数指明基元集合起始于索引first处并且共有coount个元素。开发者应该确保在顶点数组中至少有first+count个元素。OSG并不会检查顶点数目是否满足基元集合需求,而这会导致崩溃。

Time for action - drawing a colored quad

让我们操作一个普通的图形来了解一下完成一个可渲染几何体模型的步骤。我们只使用四个顶点作为四个角,并使用GL_QUADS模式来绘制这些顶点,从而创建一个四边形。GL_QUADS模式会告诉OpenGL组合顶点数组中的前四个坐标作为一个四边形,其次四个坐标作为第二个四边形,依次类推。

  1. 包含必要的头文件:
#include <osg/Geometry>
#include <osg/Geode>
#include <osgViewer/Viewer>
  1. 创建顶点数组,并通过使用与std::vector类似的操作符将四个顶点的坐标加入数组:
osg::ref_ptr<osg::Vec3Array> vertices = new osg::Vec3Array;
vertices->push_back( osg::Vec3(0.0f, 0.0f, 0.0f) );
vertices->push_back( osg::Vec3(1.0f, 0.0f, 0.0f) );
vertices->push_back( osg::Vec3(1.0f, 0.0f, 1.0f) );
vertices->push_back( osg::Vec3(0.0f, 0.0f, 1.0f) );
  1. 我们需要为每个顶点指定法线;否则,OpenGL将会使用默认的法线向量(0,0,1),因而灯光相等计算也许会不正确。四个顶点实际上朝向相同的方向,所以一个法线向量就足够了。同时我们稍后会将setNormalBinding()方法设置为BIND_OVERALL。
osg::ref_ptr<osg::Vec3Array> normals = new osg::Vec3Array;
normals->push_back( osg::Vec3(0.0f,-1.0f, 0.0f) );
  1. 我们会为每个顶点指定唯一的颜色,并使其着色。默认情况下,OpenGL会在每一个顶点同时使用平滑着色与混合颜色:
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, 1.0f, 1.0f) );
  1. 接下来我们创建osg::Geometry对象并设置准备好的顶点,法线与颜色数组。同时我们指示单一的法线应绑定到整个几何体,而颜色则是绑定到每个顶点:
osg::ref_ptr<osg::Geometry> quad = new osg::Geometry;
quad->setVertexArray( vertices.get() );
quad->setNormalArray( normals.get() );
quad->setNormalBinding( osg::Geometry::BIND_OVERALL );
quad->setColorArray( colors.get() );
quad->setColorBinding( osg::Geometry::BIND_PER_VERTEX );
  1. 完成几何体并将其添加到场景图所需的最后一步是指定基元集合。在这里使用一个新分配的并将绘制模式设置为GL_QUADS的osg::DrawArrays实例,从而以顺时针方向将四个顶点渲染为四边形的四个角:
quad->addPrimitiveSet( new osg::DrawArrays(GL_QUADS, 0, 4) );
  1. 将几何体添加到osg::Geode对象并在场景查看器中进行渲染:
osg::ref_ptr<osg::Geode> root = new osg::Geode;
root->addDrawable( quad.get() );
osgViewer::Viewer viewer;
viewer.setSceneData( root.get() );
return viewer.run();
  1. 我们的程序最终会得到一个漂亮的着色的四边形,如下面的截图所示:
_images/osg_quad.png

What just happened?

我们假定已经熟悉下面的OpenGL代码片段:

数组变量vertices被用来定义要渲染的坐标。OpenGL函数glDrawArrays()将会使用数组的四个顺序元素绘制GL_QUADS模式的几何体基元,也就是3D空间中的四边形。

osg::Geometry类主要通过使用setVertexArray()与addPrimitiveSet()方法封装上面所提到的整个处理过程。实际上,这些顶点数据与基元集合并没有在用户程序调用这些方法时立即执行,而是在接下来的场景图绘制遍历中到达该几何体时才会被应用。这使得使用尽量多的osg::Geometry方法成为可能,例如属性,可以被读取与修改而不会强制场景重新渲染。

Pop quiz - results of different primitive types

在前面的示例中,我们定义了基元的mode,start与count参数,从而生成了一个四边形。理解一个几何体是如何被一个或是多个基元集合解释对我们是非常重要的。我们能否列出十种模式符号(GL_TRIANGLES,GL_QUADS,等),及其主要行为?例如,我们是否知道每一个模式是如何处理顶点与索引的,并且在最后会绘制出哪种图形呢?

Indexing primitives

当在数组中顺序读取顶点数据而没有忽略与跳跃时,osg::DrawArrays可以工作得很好。然而,如果存在大量的共享顶点,前面的方法就会有一些低效。例如,为了使得osg::DrawArrays以GL_TRIANGLES模式绘制具有八个顶点的立方体时,顶点数组就会多次重复每一个顶点,从而至少将数据的尺寸增加到36(12个三角面):

_images/osg_index_array.png

osg::DrawElementsUInt类,以及osg::DrawElementsUByte与osg::DrawElementsUShort类被用作索引数组来解决该问题。这些类均派生自osg::PrimitiveSet,使用不同的数据类型封装了OpenGL的glDrawElements()函数。索引数组保存顶点数组元素的索引。这样通过一个关联的索引基元集合,立方体的顶点数组尺寸可以减小到8个。

osg::DrawElements*类被设计为类似于std::vector的使用,所以任意vector相关的方法都可以兼容使用。例如,要向新分配的osg::DrawElementsUInt对象添加索引,我们可以使用如下的代码:

osg::ref_ptr<osg::DrawElementsUInt> de =
    new osg::DrawElementsUInt( GL_TRIANGLES );
de->push_back( 0 ); de->push_back( 1 ); de->push_back( 2 );
de->push_back( 3 ); de->push_back( 0 ); de->push_back( 2 );

这可以指定上图中立方体的前面。

Time for action - drawing and octahedron

八面体是一个具有八个三角面的多面体。这确实是一个显示基元索引为什么如此重要的好盒子。我们首先演示八面体的结构,如下图所示:

_images/osg_index_octahedron.png

八面体有六个顶点,每一个为四个三角形所共享。当使用osg::DrawArrays时,我们需要创建一个具有24个元素的顶点数组来渲染所有的八个面。然而,借助于索引数组以及osg::DrawElementsUInt类,我们可以分配一个只有六个元素的顶点数组,从而改善绘制几何体的效率。

  1. 包含必要的头文件:
#include <osg/Geometry>
#include <osg/Geode>
#include <osgUtil/SmoothingVisitor>
#include <osgViewer/Viewer>
  1. 正如我们前面所讨论的,osg::Vec3Array类继承了std::vector的特征,可以使用一个预先确定的尺寸参数构建,并直接使用operator[]进行操作。
osg::ref_ptr<osg::Vec3Array> vertices = new osg::Vec3Array(6);
(*vertices)[0].set( 0.0f, 0.0f, 1.0f);
(*vertices)[1].set(-0.5f,-0.5f, 0.0f);
(*vertices)[2].set( 0.5f,-0.5f, 0.0f);
(*vertices)[3].set( 0.5f, 0.5f, 0.0f);
(*vertices)[4].set(-0.5f, 0.5f, 0.0f);
(*vertices)[5].set( 0.0f, 0.0f,-1.0f);
  1. osg::DrawElementsUInt除接受绘制模式参数以外也接受一个尺寸参数。然后,我们将指定顶点的索引来描述所有八个三角面。
osg::ref_ptr<osg::DrawElementsUInt> indices =
    new osg::DrawElementsUInt(GL_TRIANGLES, 24);
(*indices)[0] = 0; (*indices)[1] = 1; (*indices)[2] = 2;
(*indices)[3] = 0; (*indices)[4] = 2; (*indices)[5] = 3;
(*indices)[6] = 0; (*indices)[7] = 3; (*indices)[8] = 4;
(*indices)[9] = 0; (*indices)[10]= 4; (*indices)[11]= 1;
(*indices)[12]= 5; (*indices)[13]= 2; (*indices)[14]= 1;
(*indices)[15]= 5; (*indices)[16]= 3; (*indices)[17]= 2;
(*indices)[18]= 5; (*indices)[19]= 4; (*indices)[20]= 3;
(*indices)[21]= 5; (*indices)[22]= 1; (*indices)[23]= 4;
  1. 为了使用默认的白颜色创建几何体,我们仅设置顶点数组与osg::DrawElementsUInt基元集合。法线数组与是必需的,但是手动计算并不容易。我们将会使用一个平滑的法线计算器来自动获取。该计算器将会在下一节进行描……
osg::ref_ptr<osg::Geometry> geom = new osg::Geometry;
geom->setVertexArray( vertices.get() );
geom->addPrimitiveSet( indices.get() );
osgUtil::SmoothingVisitor::smooth( *geom );
  1. 将几何体添加到osg::Geode对象并使其作为场景根节点:
osg::ref_ptr<osg::Geode> root = new osg::Geode;
root->addDrawable( geom.get() );
osgViewer::Viewer viewer;
viewer.setSceneData( root.get() );
return viewer.run();
  1. 生成的八面体如下面的截图所示:
_images/osg_octahedron.png

What just happened?

顶点数组机制减少了OpenGL函数调用的数量。他将顶点数据存储在程序内存中,也就是所谓的客户端。服务器端的OpenGL管线访问不同的顶点数组。

正如由下图中可以看到的,OpenGL由客户端的缓冲区中获取数据,并以一种顺序的方式装配基元数据。

_images/osg_vertex_array.png

这里的顶点缓冲区被用于管理由osg::Geometry类的set*Array()方法指定的数据。osg::DrawArrays在这些数组中直线前进并进行绘制。

然而,为了减少传输的顶点数量,osg::DrawElements*类同时提供了一个索引数组。索引数组允许服务器端的新顶点缓存以用于临时存储。OpenGL将会直接由缓存获取顶点,而不是由位于客户端的顶点缓冲区进行读取。这会极大的改善性能。

Pop quiz - optimizing indexed geometries

我们前面所绘制的八面体仅有六个顶点构成。如果我们不再索引几何体,我们能否算出实际上使用了多少个顶点吗?

在很多情况下,我们将会发现三角形连接串(triangle strips)会在渲染连续的面时提供更好的性能。假定在前面的示例中我们选择了GL_TRIANGLE_STRIP而不是GL_TRIANGLES,那么这次我们要如何构建索引数组呢?

Have a go hero - challenges with cubes and pyramids

现在轮到我们绘制一些其他的多边形了,例如,立方体或锥体。立方体的结构在本节开始处进行了讨论。他包含六个顶点与12个三角面,这是索引顶点一个很的演示。

锥体通常有一个多边形底面,以及多个三角面汇聚在顶点处。以四面锥为例:他有五个顶点与六个三角面(四边形底面包含两个三角面)。每个顶点为三个或是四个三角面共享:

_images/osg_pyramid.png

创建一个新的osg::Geometry对象并且添加顶点与法线数组。同时osgUtil::SmoothlingVisitor将会计算平滑法线。指定一个带有GL_TRIANGLES绘制模式的osg::DrawElementsUInt基元集合。对于高级研究,我们甚至可以添加具有不同绘制模式的多个基元集合,例如,使用GL_QUADS渲染锥底,使用GL_TRIANGLES_FAN渲染三角面。

Using polygonal techniques

OSG支持多种管理几何对象的多边形技术。这些预处理技术,例如多边形缩影与嵌套,经常被用于创建与改进多边形模型以备稍后渲染。他们被设计为具有简单的接口并且易于使用,但是也许会执行复杂的后台计算。他们不应被随时使用,因为也许会涉及到大量的计算。

OSG中的一些多边形技术实现如下所列:

  1. osgUtil::Simplfier:该类可以减少几何体中的多边形数量。公共方法simplify()可以用来简化几何对象。
  2. osgUtil::SmoothingVisitor:该类会为包含基元的几何体计算法线,例如,我们刚才看到的八面体。公共静态方法smooth()可以被用来几何体的光滑法线,而不需要我们自己重新分配并设置法线。
  3. osgUtil::TangentSpaceGenerator:该类会为几何顶点生成包含切线空间基准向量的数组。他会向generate()方法传递几何对象作为参数,并且将结果保存在getTangentArray(),getNormalArray()与getBinormalArray()中。这些结果可以用作GLSL中的变化顶点属性。
  4. osgUtil::Tessellator:该类会使用OpenGL实用程序(glu)嵌套例程来将复杂基元分解为简单基元。他提供了一个retessellatePolygons()方法将输入几何体的基元集合变为嵌套基元集合。
  5. osgUtil::TriStripVisitor:该类将几何体表面基元转换为不连续三角形,从而可以获得更快速的渲染与更高效的内存使用。公共方法stripify()被用来将输入几何体中的基元转换为GL_TRIANGLE_STRIP类型。

所介绍的这些方法都可以通过osg::Geometry&引用参数进行使用,例如:

osgUtil::TriStripVisitor tsv;
tsv. stripify( *geom );

这里geom是一个由智能指针管理的osg::Geometry对象。

osgUtil::Simplifier,osg::SmoothingVisitor与osgUtil::TriStripVisitor类也可以为场景图节点所接受。例如:

osgUtil::TriStripVisitor tsv;
node->accept( tsv );

变量node表示一个osg::Node对象。accept()可以将会遍历节点的孩子直到到达所有的叶子节点,找出并处理osg::Geode节点中所存储的几何体。

Time for action - tessellating a polygon

复杂的基元通常不能直接由OpenGL API进行正确的渲染。这包括凹入的多边形,自相交的多边形,以及带有洞的多边形。只有将其分解为凸多边形,这些非凸多边形也会为OpenGL渲染管线所接受。osgUtil::Tessellator类可以用于处理这种情况下的嵌入情况。

  1. 包含必要的头文件:
#include <osg/Geometry>
#include <osg/Geode>
#include <osgUtil/Tessellator>
#include <osgViewer/Viewer>
  1. 我们将通过使用osg::Geometry类创建一个凹多边形。如果一个简单多边形的内角和大于180度则为凹多边形。这里的示例几何体表示一个在右侧凹入的四边形。他被作为GL_POLYGON基元进行绘制。
osg::ref_ptr<osg::Vec3Array> vertices = new osg::Vec3Array;
vertices->push_back( osg::Vec3(0.0f, 0.0f, 0.0f) );
vertices->push_back( osg::Vec3(2.0f, 0.0f, 0.0f) );
vertices->push_back( osg::Vec3(2.0f, 0.0f, 1.0f) );
vertices->push_back( osg::Vec3(1.0f, 0.0f, 1.0f) );
vertices->push_back( osg::Vec3(1.0f, 0.0f, 2.0f) );
vertices->push_back( osg::Vec3(2.0f, 0.0f, 2.0f) );
vertices->push_back( osg::Vec3(2.0f, 0.0f, 3.0f) );
vertices->push_back( osg::Vec3(0.0f, 0.0f, 3.0f) );
osg::ref_ptr<osg::Vec3Array> normals = new osg::Vec3Array;
normals->push_back( osg::Vec3(0.0f,-1.0f, 0.0f) );
osg::ref_ptr<osg::Geometry> geom = new osg::Geometry;
geom->setVertexArray( vertices.get() );
geom->setNormalArray( normals.get() );
geom->setNormalBinding( osg::Geometry::BIND_OVERALL );
geom->addPrimitiveSet( new osg::DrawArrays(GL_POLYGON, 0, 8) );
  1. 如果我们立即将geom变量添加到osg::Geode对象,并且使用osgViewer::Viewer进行查看,我们会得到一个不正确的结果,如下图所示:
_images/osg_tessellator_wrong.png
  1. 要正确渲染一个凹多边形,我们应该使用osgUtil::Tessellator进行重新载入:
osgUtil::Tessellator tessellator;
tessellator.retessellatePolygons( *geom );
  1. 现在geom变量已经被修改。再次将其添加到几何体节点并启动场景查看器:
osg::ref_ptr<osg::Geode> root = new osg::Geode;
root->addDrawable( geom.get() );
osgViewer::Viewer viewer;
viewer.setSceneData( root.get() );
return viewer.run();
  1. 这次我们会得到正确的结果:
_images/osg_tessellator_right.png

What just happened?

大多数情况下,未经嵌入的凹多边形并不会如我们所期望的那样进行渲染。为了优化性能,OpenGL会将他们看作简单多边形或仅是忽略他们,而这总是会产生不可预期的结果。

osgUtil::Tessellator使用OpenGL嵌入例程来处理存储在osg::Geometry对象中的凹多边形。当执行嵌入时,他会确定最高效的基元类型。对于前面的示例,他将会使用GL_TRIANGLE_STRIP来处理原始多边形,也就是将其分为多个三角形。

_images/osg_tessellator.png

类似于OpenGL嵌入例程,osgUtil::Tessellator类也可以处理带有洞与自相交的多边形。其公共方法setWindingType()接受不同的规则,例如GLU_TESS_WINDING_ODD与GLU_TESS_WINDING_NONZERO,这些规则确定了复杂多边形的内部与外部区域。

Rereading geometry attributes

osg::Geometry通过使用顶点数组管理大量顶点数据,并且使用有序基元集合渲染这些顶点与顶点属性。然而,osg::Geometry并没有任何拓扑元素,例如面,边,及其之间的关系。有时这会妨碍实现复杂的多边形技术以及自由编辑(拖拉特定的面或边来操作模型等)。

当前OSG并不支持数学拓扑功能,也许是因为对于一个渲染API实现该功能看起来有一些怪异。但是OSG已经实现了一个算符(functor)系列来由已存在的可绘制元素中读取几何属性与基元,并且将其用于拓扑编织模型等目的。

算符总是被看作一个类,但是其执行类似于函数。算符可以模仿某些具有相同返回类型与调用参数的已知接口,但是所有传递给算符的属性将会被捕获并且以自定义的方式进行处理。

osg::Drawable类接受四种算符类型:

  1. osg::Drawable::AttributeFunctor将顶点属性读取为数组指针。他具有可以应用于不同数据类型顶点属性的大量虚方法。要使用该算符,我们应继承该类并且重新实现一个或是多个虚方法,并且在其中执行我们需要的操作:
  1. osg::Drawable::ConstAttributeFunctor是osg::Drawable::AttributeFunctor的一个只读版本。区别仅在于他使用常量数组指针作为虚apply()方法的参数。
  2. osg::PrimitiveFunctor模仿OpenGL绘制例程,例如glDrawArrays(),glDrawElements()与立即模式。他会装作可绘制元素已经被渲染,而不是调用算符方法。osg::PrimitiveFunctor有两个重要的模板类:osg::TemplatePrimitiveFunctor<>与osg::TriangleFunctor<>,这两个类可以应用于实际使用。这两个类会接收每个基元顶点绘制结果,并将其发送给用户定义的operator()方法。
  3. osg::PrimitiveIndexFunctor也模拟OpenGL绘制例程。他是osg::TriangleIndexFunctor<>的子类,并接收每个基元的顶点索引并使用。

osg::Drawable派生类,例如osg::ShapeDrawable与osg::Geometry,具有accept()方法接受不同的算符。

Customizing a primitive functor

构思使用具有前述信息的算符的场景非常抽象。我们采用三角集合作为示例。尽管我们使用顶点数组与基元集合来管理osg::Geometry的渲染数据,我们仍然希望收集所有的三角面与面顶点。从而我们可以维护几何体顶点,边与面的信息,并且使用集合器来构建几何数据结构。

Time for action - collecting triangle faces

osg::TriangleFunctor<>算符类是用于收集三角面上信息的理解选择。他会在可能时将osg::Drawable对象的基元集合转换为三角形。模板参数必须实现一个具有三个const osg::Vec3&参数与一个bool参数的operator(),从而在应用算符时为每个三角形进行调用。

  1. 我们将模板参数实现为一个包含operator()的结构。前三个3D向量参数表示三角形顶点,而最后一个参数表明这些顶点是否来自临时顶点数组:
struct FaceCollector
{
    void operator()( const osg::Vec3& v1, const osg::Vec3& v2,
                     const osg::Vec3& v3, bool )
    {
        std::cout << "Face vertices: " << v1 << "; " << v2 << "; "
                  << v3 << std::endl;
    }
};
  1. 我们将使用GL_QUAD_STRIP创建一个类似墙的对象,意味着几何体最初并不是由三角形构成。该对象包含八个顶点与四个四边形面:
osg::ref_ptr<osg::Vec3Array> vertices = new osg::Vec3Array;
    vertices->push_back( osg::Vec3(0.0f, 0.0f, 0.0f) );
    vertices->push_back( osg::Vec3(0.0f, 0.0f, 1.0f) );
    vertices->push_back( osg::Vec3(1.0f, 0.0f, 0.0f) );
    vertices->push_back( osg::Vec3(1.0f, 0.0f, 1.5f) );
    vertices->push_back( osg::Vec3(2.0f, 0.0f, 0.0f) );
    vertices->push_back( osg::Vec3(2.0f, 0.0f, 1.0f) );
    vertices->push_back( osg::Vec3(3.0f, 0.0f, 0.0f) );
    vertices->push_back( osg::Vec3(3.0f, 0.0f, 1.5f) );
    vertices->push_back( osg::Vec3(4.0f, 0.0f, 0.0f) );
    vertices->push_back( osg::Vec3(4.0f, 0.0f, 1.0f) );
    osg::ref_ptr<osg::Vec3Array> normals = new osg::Vec3Array;
    normals->push_back( osg::Vec3(0.0f,-1.0f, 0.0f) );
    osg::ref_ptr<osg::Geometry> geom = new osg::Geometry;
    geom->setVertexArray( vertices.get() );
    geom->setNormalArray( normals.get() );
    geom->setNormalBinding( osg::Geometry::BIND_OVERALL );
    geom->addPrimitiveSet( new osg::DrawArrays(GL_QUAD_STRIP, 0, 10) );
  1. 我们首先通过一个osg::Geode场景根节点与osgViewer::Viewer查看对象。当与前面的几何体相比时并没有特别的东西:
osg::ref_ptr<osg::Geode> root = new osg::Geode;
root->addDrawable( geom.get() );
osgViewer::Viewer viewer;
viewer.setSceneData( root.get() );
viewer.run();

截图如下:

_images/osg_functor_quad.png
  1. 现在添加用户定义的FaceCollector结构作为osg::TriangleFunctor<>的模板参数,并将其应用到osg::Geometry对象:
osg::TriangleFunctor<FaceCollector> functor;
geom->accept( functor );
  1. 在控制台启动程序,我们将会在命令行输出看到所输出的面顶点列表:
_images/osg_functor_quad_output.png

What just happened?

算符简单的在osg::Geometry的accept()实现中模拟OpenGL调用。他通过使用setVertexArray()与drawArrays()方法读取顶点数据与基元集合,这两个方法与OpenGL的glVertexPointer()和glDrawArrays()函数具有相同的输入参数。然而,drawArrays()方法并不会真正在3D世界中绘制对象。他会调用模板类或是结构的成员方法,在其中我们可以执行不同类型的自定义操作,例如收集顶点数据。

_images/osg_functor_vertex.png

osg::TemplatePrimitiveFunctor<T>不仅收集特定可绘制元素的三角面;他还具有获取点,线与面的接口。他要求在模板参数中实现这些操作符:

void operator()( const osg::Vec3&, bool );
void operator()( const osg::Vec3&, const osg::Vec3&, bool );
void operator()( const osg::Vec3&, const osg::Vec3&,
                 const osg::Vec3&, bool );
void operator()( const osg::Vec3&, const osg::Vec3&,
                 const osg::Vec3&, const osg::Vec3&, bool );

Have a go hero - analyzing toplogogy of a geometry

我们是否知道如何分析几何体的拓扑?我们也许需要一个存储这些顶点的顶点共享列表与面列表,或是边列表,每一个具有相邻顶点与相邻面的信息。

当收集可绘制元素的三角面时,算符可以帮助我们获取所有这些信息。唯一的问题是为了构建拓扑多边形我们更喜欢使用哪种数据结构;而这是我们自己的决定。

Implementing your own drawables

osg::Drawable纯虚类有两个非常重要的虚方法:

  • computeBound()常量方法计算几何体周围的边框,这将会用于平面绘制过程以决定是否裁剪几何体。
  • drawImplementation()常量方法使用OSG与OpenGL调用实际绘制几何体。

要自定义用户定义的可绘制类,我们必须重新实现这两个方法并且在合适的位置添加我们自己的绘制代码。

computeBound()常量方法返回一个osg::BoundingBox值作为几何体的边框。创建边框的最简单方法就是设置其最小与最大长度,这两个值都是一个三元素向量。由(0,0,0)到(1,1,1)的边框可以定义为:

osg::BoundingBox bb( osg::Vec3(0, 0, 0), osg::Vec3(1, 1, 1) );

注意,osg::BoundingBox并没有通过智能指针进行管理,同样下一章将会介绍的osg::BoundingSphere也是如此。

drawImplementation()常量方法是不同绘制调用的真正实现。他具有一个osg::RenderInfo&输入参数,该参数存储OSG绘制后端的当前绘制信息。该方法是由osg::Drawable的draw()方法在内部调用的。后者会自动将drawImplementation()中的OpenGL调用保存为显示列表,并且在后续的帧中进行重用。这意味着osg::Drawable实体的drawImplementation()方法只会被调用一次!

为了避免使用显示列表,我们可以在分配新的可绘制类时关闭相关的选项:

drawable->setUseDisplayList( false );

这样后就会每次执行自定义的OpenGL调用。如果在drawImplementation()方法中存在几何变形动作或动画时,这将会非常有用。

Using OpenGL drawing calls

我们可以在drawImplementation()中添加任意的OpenGL函数。在进入该方法之前,渲染环境已经被创建,并且OpenGL使得当前操作已经完成。不要释放OpenGL渲染环境,因为他很快就会为为其他可绘制类使用。

GLUT库具有直接渲染固体茶壶模型的能力。茶壶表面法线与纹理坐标都是自动生成的。而茶壶也是使用OpenGL计算器生成的。

我们也许希望首先下载GLUT库,该库被设计为OpenGL的第三方工程 。源码可以在下面的网站找到:

预编译的二进制,头文件与库文件也可以下载,其中包括我们开始GLUT所需要的所有内容。

  1. 我们需要修改CMake脚本文件来查找GLUT并将其添加为我们基于OSG工程的依赖:
find_package( glut )
add_executable( MyProject teapot.cpp )
config_project( MyProject OPENTHREADS )
config_project( MyProject OSG )
config_project( MyProject OSGDB )
config_project( MyProject OSGUTIL )
config_project( MyProject GLUT )
  1. CMake系统能够使用find_package()宏直接查找GLUT库,但是有时我们遇到空的情况。我们应将GLUT_INCLUDE_DIR设置为gl/glut.h的父目录,而GLUT_LIBRARY设置为GLUT静态链接库,例如,Windows平台上的glut32.lib。然后点击Configure与Generate生成解决方案或是makefile。
_images/osg_teapot_cmake.png
  1. 包含必需的头文件。此时,记住同时添加GLUT头文件:
#include <gl/glut.h>
#include <osg/Drawable>
#include <osg/Geode>
#include <osgViewer/Viewer>
  1. 我们定义一个名为TeapotDrawable的全新类,该类由osg::Drawable类派生。为了确保其通过编译,我们需要使用OSG宏定义META_Object来实现类的某些基本属性。同时创建一个拷贝构造函数来帮助实例化我们的TeapotDrawable类。
class TeapotDrawable : public osg::Drawable
{
public:
    TeapotDrawable( float size=1.0f ) : _size(size) {}
    TeapotDrawable( const TeapotDrawable& copy,
                const osg::CopyOp&
                copyop=osg::CopyOp::SHALLOW_COPY )
    : osg::Drawable(copy, copyop), _size(copy._size) {}
    META_Object( osg, TeapotDrawable );
    virtual osg::BoundingBox computeBound() const;
    virtual void drawImplementation( osg::RenderInfo& ) const;
protected:
    float _size;
};
  1. 要以一种简单的方式实现computeBound()方法,我们可以使用成员变量_size,该变量表示茶壶的相对尺寸,以构建一个足够大的边界方框。由最小点(-_size,-_size,-_size)到最大点(_size,_size,_size)的方框应总是包含茶壶表面:
osg::BoundingBox TeapotDrawable::computeBound() const
{
    osg::Vec3 min(-_size,-_size,-_size), max(_size, _size, _size);
    return osg::BoundingBox(min, max);
}
  1. drawImplementation()的实现也并不复杂。为了表面裁剪的目的,我们使用其前表面多边形顶点渲染GLUT茶壶,从而可以受益于OpenGL的默认后面裁剪机制:
void TeapotDrawable::drawImplementation( osg::RenderInfo&
renderInfo ) const
{
    glFrontFace( GL_CW );
    glutSolidTeapot( _size );
    glFrontFace( GL_CCW );
}
  1. TeapotDrawable对象可以被添加到一个osg::Geode节点,然后通过查看器查看,这是已经多次执行过的操作:
osg::ref_ptr<osg::Geode> root = new osg::Geode;
root->addDrawable( new TeapotDrawable(1.0f) );
osgViewer::Viewer viewer;
viewer.setSceneData( root.get() );
return viewer.run();
  1. 现在构建并启动程序。持续按下鼠标左键来旋转我们的场景到一个合适的位置,并观赏一下最终的茶壶模式:
_images/osg_teapot.png

What just happened?

在这里,拷贝构造函数用来创建一个新的TeapotDrawable作为已有对象的拷贝。在前面的示例中并不需要,但却为osg::Drawable派生类所需要。

另一个宏定义META_Object对于实现自定义的可绘制元素也是必需的。他有两个参数,表明库名字是osg,而类类型与名字为TeapotDrawable。我们总是可以通过使用下面的方法来获取两个字符串值:

const char* lib = obj->libraryName();
const char* name = obj->className();

带有META_Object宏的OSG类将重新实现这两个方法,包括几乎所有的场景相关的类。

用户定义的可绘制元素应总是具有一个拷贝构建函数与META_Object宏,并且应总是重写computeBound()与drawImplementation()方法,否则也许会引起编译错误。

Summary

本章解释了如何使用OSG所定义的顶点与绘制基元创建几何实体。这些几何体被存储在osg::Geode对象中,该对象被看作场景图的叶子节点。为了获得不同的渲染效果,3D世界中的所有场景管理与更新都承担着修改几何体行为与传输顶点数据和几何基元的目的。

在本章中,我们特别讨论了:

  • OpenGL立即模式,显示列表与顶点数组及其OSG实现的基本概念。
  • 如何通过使用osg::ShapeDrawable类渲染简单形状用于快速测试。
  • 如何通过使用osg::Geometry类以更为高效的方式创建与渲染各种形状。
  • 如何在顶点属性数组,索引数组与几何基元集合上进行操作。
  • 如何使用算符获取顶点属性,基元与索引数据,以及通过继承与重写成员方法,实现顶点数据的自定义。
  • 将OpenGL调用集成到自定义的osg::Drawable派生类的可行方法,从而有助于OSG与其他基于OpenGL的库共同工作。

Chapter 5: Managing Scene Graph

场景图是表示图形与状态对象的空间布局的节点的层次图结构图。他封装了最底层的图像基元与状态组合,可以通过底层的图像API创建可视化事物。OpenSceneGraph释放了场景图的威力,并且开发优化机制来管理与渲染3D场景,从而允许开发者以标准方式使用简单但强大的代码实现如对象组装,遍历,传输栈,场景裁剪,细节管理以及其他基本或是高级图像特性等事情。

在本章中,我们将会探讨下列主题:

  • 理解组合节点与叶子节点的概念
  • 如何处理父节点与子节点接口
  • 使用各种节点,包括转换节点,切换节点,细节节点与代理节点
  • 如何由基本的节点类派生我们自己的节点
  • 如何遍历已载入模式的场景图结构

The Group interface

osg::Group类型表示OSG场景图的组合节点。他可以具有任意数量的子节点,包括osg::Geode叶子节点以及其他的osg::Group节点。他是最常用到的各种NodeKits的基类-也就是具有各种功能的节点。

osg::Group类派生自osg::Node,因而间接派生自osg::Referenced。osg::Group类包含一个子节点列表,每一个子节点由智能指针osg::ref_ptr<>进行管理。这可以确保删除场景图中的级联节点集合时不会存在内存泄露。

osg::Group类提供了一个公共方法集合用于定义处理子节点的接口。这些方法非常类似于osg::Geode的可绘制元素的管理方法,但是大多数的输入参数是osg::Node指针。

  1. 公共方法addChild()将一个节点关联到子节点列表的结尾。同时,有一个insertChild()方法用于将节点插入到osg::Group的指定位置处,该方法接受一个整数索引与一个节点指针作为参数。
  2. 公共方法removeChild()与removeChildren()将会由当前的osg::Group对象移除一个或是多个子节点。后者使用两个参数:基于零的起始元素索引,以及要移除的元素数目。
  3. getChild()方法在指定索引处存储的osg::Node指针。
  4. getNumChildren()返回子节点的总数。

由于我们前面处理osg::Geode与可绘制元素的经验,我们可以很容易处理osg::Group子节点接口。

Managing parent nodes

我们已经了解到osg::Group被用作组合节点,而osg::Geode被用作场景图的叶子节点。其方法在上一章中进行了介绍,而在本章中也同样会用到。另外,两个类都应具有用于管理父节点的接口。

正如稍后将会解释的,OSG允许一个节点具有多个父节点。在本节中,我们将会首先概略了解一个父节点管理方法,这些方法直接声明在osg::Node类中:

  1. getParent()方法返回一个osg::Group指针作为父节点。他需要一个表示父节点列表中索引的整数参数。
  2. getNumParents()方法返回父节点的总数。如果节点只有一个父节点,该方法将会返回1,而此时只有getParent(0)是正确可用的。
  3. getParentalNodePaths()方法返回由场景的根节点到当前节点(但是不包含当前节点)的所有可能路径。他返回一个osg::NodePath变量的列表。

osg::NodePath实际上是一个节点指针的std::vector对象,例如,假定我们有如下一个场景图:

_images/osg_parentalnodepath.png

下面的代码片段将会找到由场景根节点到节点child3的唯一路径:

osg::NodePath& nodePath = child3->getParentalNodePaths()[0];
for ( unsigned int i=0; i<nodePath.size(); ++i )
{
    osg::Node* node = nodePath[i];
    // Do something...
}

我们可以在循环中成功获取节点Root,Child1与Child2。

我们并不需要使用内存管理系统来引用节点的父节点。当父节点被删除时,他会自动由子节点的记录中移除。

没有任何父节点的节点只能被看作场景图的根节点。在这种情况下,getNumParents()方法将会返回0,并且不会获取到父节点。

Time for action - adding models to the scene graph

在前面的示例中,我们只是通过osgDB::readNodeFile()函数载入一个模型,例如Cessna。这次我们将会尝试载入并管理多个模型。每一个模型将会被赋值给一个节点指针,然后添加到组合节点。组合节点被定义为根节点,将会被程序用来在最后渲染整个场景图:

  1. 包含必需的头文件:
#include <osg/Group>
#include <osgDB/ReadFile>
#include <osgViewer/Viewer>
  1. 在主函数中,我们首先载入两个不同的模型,然后将其赋值给osg::Node指针。载入的模型同时也是一个通过组合节点与叶子节点构建的子场景图。osg::Node类能够表示任意类型的子场景图,如果需要,他们可以被转换为osg::Group或是osg::Geode,或者通过C++ dynamic_cast<>操作符实现,或者是如asGroup()与asGeode()这样的便利方法,这要比dynamic_cast<>节省时间。
osg::ref_ptr<osg::Node> model1 = osgDB::readNodeFile(
    "cessna.osg" );
osg::ref_ptr<osg::Node> model2 = osgDB::readNodeFile( "cow.osg" );
  1. 通过addChild()方法向osg::Group节点添加两个模型:
osg::ref_ptr<osg::Group> root = new osg::Group;
root->addChild( model1.get() );
root->addChild( model2.get() );
  1. 初始化并启动查看器:
osgViewer::Viewer viewer;
viewer.setSceneData( root.get() );
return viewer.run();
  1. 现在我们将会看到一头牛与Cessna模型粘合在一起。实际上不可能看到这样的场景,但是在虚拟世界中,这两个模型属性于由一个组合节点管理的不相关的子节点,因而可以由场景查看器进行单独渲染。
_images/osg_group_node.png

What just happened?

osg::Group与osg::Geode都由osg::Node基类派生。osg::Group允许添加任意类型的子节点,包括osg::Group自身。然而,osg::Geode类不包含组合节点或是叶子节点。他只接受用于渲染的可绘制元素。

如果我们能够确定一个节点的类型是osg::Group,osg::Geode还是其他的派生类型将会非常方便,特别是由文件读取并由osg::Node类所管理的节点,例如:

osg::ref_ptr<osg::Node> model = osgDB::readNodeFile( "cessna.osg" );

dynamic_cast<>操作符与如asGroup(),asGeode()以及其他的便利方法,会有助于将一个指针或引用类型转换为另一种指针或是引用类型。首先,我们以dynamic_cast<>为例。这可以用来在类的继承层次结构中向下转换,例如:

osg::ref_ptr<osg::Group> model =
    dynamic_cast<osg::Group*>( osgDB::readNodeFile("cessna.osg") );

osgDB::readNodeFile()函数的返回值总是osg::Node*,但是我们也可以尝试使用osg::Group指针进行管理。如果Cessna子图的根节点是一个组合节点,那么转换就会成功,否则转换失败,而变量model将会为NULL。

我们也可以执行向上转换,这实际上是隐式转换:

osg::ref_ptr<osg::Group> group = ...;
osg::Node* node1 = dynamic_cast<osg::Node*>( group.get() );
osg::Node* node2 = group.get();

在大多数编译器上,node1与node2都会通过编译并正常工作。

转换方法也会完成类似的工作。事实上,如果我们所需要的类型存在一个这样的转换方法,则推荐使用转换方法,而不是dynamic_cast<>,特别是在性能要求较高的代码中:

// Assumes the Cessna's root node is a group node.
osg::ref_ptr<osg::Node> model = osgDB::readNodeFile("cessna.osg");
osg::Group* convModel1 = model->asGroup();  // OK!
osg::Geode* convModel2 = model->asGeode();  // Returns NULL.

Pop quiz - fast dynamic casting

在C++程序中,dynamic_cast<>会以运行时检测的安全性执行类型转换,这会要求允许运行时类型信息(RTTI)。有时并不推荐与osg::Node类的转换方法相比较,后者已经由子类,例如osg::Group与osg::Geode进行了重写。我们知道其中的原因吗?何时我们应该使用asGroup()与asGeode(),而何时应该使用dynamic_cast<>呢?

Traversing the scene graph

一个通常的遍历由下列步骤组成:

  1. 首先,由任意节点开始(例如,根节点)。
  2. 递归沿场景图向下(或向上)到子节点,直到叶子节点或是没有子节点的节点。
  3. 反向到达没有完成探索的最近节点,重复上述步骤。这被称为场景图的尝试优先搜索。

在遍历过程中,可以对所有的场景节点应用不同的更新与渲染操作,从而使得遍历成为场景图的关键特性。有不同目的的多种遍历类型:

  1. 事件(event)遍历在遍历节点时首先处理鼠标与键盘输入以及其他的用户事件。
  2. 更新遍历(或应用遍历)允许用户应用修改场景图,例如设置节点与几何属性,应用节点功能,执行回调等。
  3. 裁剪遍历(cull)测试一个节点是否位于一个视口内并可进行渲染。他会裁剪不可见与不可用的节点,并且向内部渲染列表输出优化的场景图。
  4. 绘制遍历(draw)(或渲染遍历)执行底层的OpenGL API调用来真正的渲染场景。注意,他与场景图并没有关系,而仅是作用在由裁剪遍历所生成的渲染列表上。

在通常情况下,这些遍历应依次为每一帧所执行。但是对于具有多处理器与图形卡的系统,OSG可以并行执行从而提高渲染效率。

访问者模式可以用来实现遍历。该模式会在本章稍后进行讨论。

Transformation nodes

osg::Group节点除了向下遍历到子节点外不做任何事情。然而,OSG同时支持osg::Transform类家庭,这是在应用到几何体的遍历相关转换过程中创建的。osg::Transform派生自osg::Group。他不能被直接实例化。相反,他提供了一个用于实现不同转换接口的子类集合。

当向下遍历场景图层次结构时,osg::Transform节点总是将其自己的操作添加到当前的变换矩阵,也就是,OpenGL模型-视图矩阵(model-view matrix)。他等同于如glMultMatrix()这样的连接OpenGL矩阵命令,例如:

上面的示例场景图可以翻译为如下的OpenGL代码:

glPushMatrix();
    glMultMatrix( matrixOfTransform1 );
    renderGeode1();  // Assume this will render Geode1
    glPushMatrix();
        glMultMatrix( matrixOfTransform2 );
        renderGeode2();    // Assume this will render Geode2
    glPopMatrix();
glPopMatrix();

要使用坐标帧(coordinate frmae)的概念来描述上述过程,我们可以说Geode1与Transform2位于Transform1的相对引用帧之下,Geode2位于Transform2的相对引用帧之下。然而,OSG同时也允许设置绝对引用帧,从而导致与OpenGL命令glLoadMatrix()等同的行为:

transformNode->setReferenceFrame( osg::Transform::ABSOLUTE_RF );

而要切换到默认的坐标帧,可以使用如下的代码:

transformNode->setReferenceFrame( osg::Transform::RELATIVE_RF );

Understanding the matrix

osg::Matrix是一个基本的OSG数据类型,而不需要使用智能指针进行管理。他支持4x4矩阵变换接口,例如变换,旋转,缩放与投影操作。他可以显式设置:

osg::Matrix mat( 1.0f, 0.0f, 0.0f, 0.0f,
                 0.0f, 1.0f, 0.0f, 0.0f,
                 0.0f, 0.0f, 1.0f, 0.0f,
                 0.0f, 0.0f, 0.0f, 1.0f ); // Just an identity matrix

其他的方法与操作包括:

  1. 公共方法postMult()与operator*()将当前的矩阵对象与输入矩阵或向量参数执行后乘运算。而方法preMult()执行前乘运算。
  2. makeTranslate(),makeRotate()与makeScale()方法重置当前矩阵并且创建一个4x4变换,旋转或是缩放矩阵。其静态版本,translate(),rotate()与scale()可以使用特定的参数分配一个新的矩阵对象。
  3. 公共方法invert()反转矩阵。其静态版本inverse()需要一个矩阵参数并且返回一个新的反转osg::Matrix对象。

我们将会注意到OSG使用行为主(row-major)矩阵来表示变换。这意味着OSG会将向量看作行,并使用行向量执行前乘矩阵操作。所以将变换矩阵mat应用到坐标vec的方法为:

osg::Matrix mat = …;
osg::Vec3 vec = …;
osg::Vec3 resultVec = vec * mat;

当连接矩阵时,OSG行为主矩阵操作的顺序也很容易理解:

osg::Matrix mat1 = osg::Matrix::scale(sx, sy, sz);
osg::Matrix mat2 = osg::Matrix::translate(x, y, z);
osg::Matrix resultMat = mat1 * mat2;

开发者总是可以由左向右读取变换过程,也就是,resultMat意味着首先使用mat1缩放向量,然而使用mat2进行反转。这种解释听起来更为清晰与合适。

osg::Matrix类表示一个4x4浮点类型矩阵。他可以通过直接使用osg::Matrix重载方法set()进行转换。

The Matrix Transform class

osg::MatrixTransform类派生自osg::Transform。他在内部使用一个osg::Matrix变量来应用4x4双精度浮点类型变换。公共方法setMatrix()与getMatrix()将osg::Matrix参数赋值给osg::MatrixTransform的成员变量。

Time for action - performing translations of child nodes

现在我们要利用变换节点。osg::MatrixTransform节点,将当前的模型视图矩阵与指定的矩阵直接相乘,可以将我们的模型移动到视图空间中的其他位置。

  1. 包含必需的头文件:
#include <osg/MatrixTransform>
#include <osgDB/ReadFile>
#include <osgViewer/Viewer>
  1. 首先载入Cessna模型:
osg::ref_ptr<osg::Node> model = osgDB::readNodeFile(
  "cessna.osg" );
  1. osg::MatrixTransform类由osg::Group类派生,所以他可以使用addChild()方法来添加多个子节点。所有的子节点都会受到osg::MatrixTransform节点的影响,并且会依据当前的矩阵进行变换。在这里,我们将会两次载入模型,以同时单独显示两个实例:
osg::ref_ptr<osg::MatrixTransform> transformation1 = new
osg::MatrixTransform;
transform1->setMatrix( osg::Matrix::translate(
  -25.0f, 0.0f, 0.0f) );
transform1->addChild( model.get() );
osg::ref_ptr<osg::MatrixTransform> transform2 = new
osg::MatrixTransform;
transform2->setMatrix( osg::Matrix::translate(
  25.0f, 0.0f, 0.0f) );
transform2->addChild( model.get() );
  1. 向根节点添加两个变换节点并启动查看器:
osg::ref_ptr<osg::Group> root = new osg::Group;
root->addChild( transformation1.get() );
root->addChild( transformation2.get() );
osgViewer::Viewer viewer;
viewer.setSceneData( root.get() );
return viewer.run();
  1. Cessna模型,最初位于坐标原点,该模型被复制并在不同的位置显示。一个被变换到坐标(-25.0, 0.0, 0.0)处,而另一个被变换到(25.0,0.0,0.0)处:
_images/osg_matrix.png

What just happened?

我们也许会场景图的结构感到迷惑,因为model指针被关联到两个不同的父节点。在一个典型的树结构中,一个节点至多只有一个父节点,因而共享子节点是不可能的。然而,OSG支持对象共享机制,也就是,一个子节点(model指针)可以为不同的祖先节点(transformation1与transformation2)实例化。然后当遍历并渲染场景图时,由根节点到实例化节点会有多条路径,从而导致实例节点被显示多次。

_images/osg_multi_parent.png

这对于减少场景内存非常有用,因为程序只会保存一份共享数据的拷贝,并且在由其多个父节点管理的不同环境中简单的多次调用实现方法(例如,osg::Drawable派生类的drawImplementation())。

共享子节点的每个父节点会保存有其自己的指向子节点的osg::ref_ptr<>指针。在这种情况下,引用计数不会减少到0,而该节点在其所有的父节点解引用之前不会被释放。我们将会发现在管理节点的多个父节点时getParent()与getNumParents()方法将会非常有用。

建议在一个程序中尽可能的共享叶子节点,几何体,纹理以及OpenGL渲染状态。

Pop quiz - matrix multiplications

正如我们已经讨论的,OSG使用行向量与行为主矩阵在右侧原则(right-hand rule)下来执行前相乘(vector*matrix)。然而,OpenGL使用列为主矩阵与列向量来执行后相乘(matrix*vector)。所以,当将OpenGL变换转换为OSG变换时,我们会认为哪一个重要呢?

Have a go hero - making use of the PositionAttitudeTransform class

osg::MatrixTransform类的执行类似于OpenGL的glMultMatrix()与glLoadMatrix()函数,该函数几乎可以实现所有的空间变换类型,但是并不容易使用。然而,osg::PositionAttitudeTransform类的作用类似于OpenGL的glTranslate(),glScale()与glRotate()函数的组合。他提供了公共方法在3D世界中变换子节点,包括setPosition(),setScale()与setAttitue()。前两个需要osg::Vec3输入值,而setAttitude()使用osg::Quat变量作为参数。osg::Quat是一个四元数类,该类被用来表示朝向。其构造函数可以接受一个浮点角度与一个osg::Vec3向量作为参数。欧拉旋转(关于三个固定坐标的旋转)也是可以接受的,但要使用osg::Quat的重载构造函数:

osg::Quat quat(xAngle, osg::X_AXIS,
               yAngle, osg::Y_AXIS,
               zangle, osg::Z_AXIS); // Angles should be radians!

现在让我们重写前面的示例,使用osg::PositionAttitudeTransform类来替换osg::MatrixTransform节点。使用setPosition()来指定变换,使用setRotate()来指定子模型的旋转,体验一下在某些情况下对于是否更为方便。

Switch nodes

osg::Switch节点能够渲染或是略过某些特定条件的子节点。他继承了超类osg::Group的方法,并且可以为每一个子节点关联一个布尔值。他有一些非常有用的公共方法:

  1. 重载的addChild()方法除了osg::Node指针以外还可以有一个布尔参数。当布尔参数被设置为假时,所添加的节点对于查看器不可见。
  2. setValue()方法可以设置指定索引处子节点的可见性值。他有两个参数:基于零的索引与布尔值。getValue()可以获取输入索引处子节点的值。
  3. setNewChildDefaultValue()方法为新子节点设置默认可见性。如果一个子节点只是简单的被添加而没有指定值,则其值由setNewChildDefaultValue()决定,例如:
switchNode->setNewChildDefaultValue( false );
switchNode->addChild( childNode ); // Turned off by default now!

Time for action - switching between the normal and damaged Cessna

我们将要使用osg::Switch节点来构建场景。他甚至可以用来实现状态切换动画与更为复杂的工作,但是目前我们仅是演示如何在场景查看器启动之前预先定义子节点的可见性。

  1. 包含必需的头文件:
#include <osg/Switch>
#include <osgDB/ReadFile>
#include <osgViewer/Viewer>
  1. 我们由文件中读取两个模型并使用开关进行控制。我们可以在OSG示例数据目录中找到一个正常的Cessna与一个损坏的Cessna。他们非常适于模拟飞机的不同状态:
osg::ref_ptr<osg::Node> model1= osgDB::readNodeFile("cessna.osg");
osg::ref_ptr<osg::Node> model2= osgDB::readNodeFile("cessnafire.
osg");
  1. osg::Switch节点能够显示一个或多个子节点并隐藏其他的子节点。其作用不同于osg::Group父类,后者会在渲染场景时显示所有的子节点。如果我们要开发一个战斗游戏,并且要在任何时刻管理某些飞机对象时,这个功能将会非常有用。下面的代码会在将model2添加到根节点时设置为可见,并同时隐藏model1:
osg::ref_ptr<osg::Switch> root = new osg::Switch;
root->addChild( model1.get(), false );
root->addChild( model2.get(), true );
  1. 启动查看器:
osgViewer::Viewer viewer;
viewer.setSceneData( root.get() );
return viewer.run();
  1. 现在我们将会看到一个燃烧的Cessna而不是正常的Cessna:
_images/osg_switch.png

What just happened?

osg::Switch类在由其超类osg::Group管理的子节点列表之处添加一个开关值列表。两个列表具有相同的大小,而列表中的每个元素与另一个列表中的元素具有一对一的关系。所以,开关值列表中的任何变化将会影响到相关的子节点,打开或关闭其可见性。

当OSG后端遍历场景图并应用不同的NodeKit功能时,由addChild()或setValue()所触发的开关值变化将会被保存为属性并在下一个渲染帧中执行。在下面的代码片段中,只有位于索引0与1处的后两个子节点的开关值会实际起作用:

switchNode->setValue( 0, false );
switchNode->setValue( 0, true );
switchNode->setValue( 1, true );
switchNode->setValue( 1, false );

setValue()方法的重复调用会被简单覆盖且不会影响场景图。

Level-of-detail nodes

详细级别技术为指定的对象创建详细或是复杂性级别,并且提供一定的线索来自动选择相应的对象级别,例如,依据距离观看者的距离。他会减少3D世界中对象表示的复杂性,并且在远距离对象的外观上具有不被注意到的质量损失。

osg::LOD节点派生自osg::Group,并且使用子节点来表示可变详细级别上的相同对象,由最高级别到最低级别。每一个级别需要一个最小与最大可视范围来指定在相邻级别之间切换的合理机会。osg::LOD节点的结果是子节点的离散量作为级别,也被称之为离散LOD。

osg::LOD类可以配合子节点指定范围,或是在已有的子节点上使用setRange()方法:

osg::ref_ptr<osg::LOD> lodNode = new osg::LOD;
lodNode->addChild( node2, 500.0f, FLT_MAX );
lodNode->addChild( node1 );
...
lodNode->setRange( 1, 0.0f, 500.0f );

在前面的代码片段中,我们首先添加一个节点,node2,当距离眼睛超过500单位时才会显示该节点。在这这后,我们添加一个高分辨率模型,node1,并且通过使用setRange()方法为近距离观察重置其可视范围。

Time for action - constructing a LOD Cessna

我们将使用一个预定义对象的集合创建一个离散LOD节点来表示相同的模型。这些对象被用作osg::LOD节点的子节点并且在不同的距离上显示。我们将内部多边形减少技术类osgUtil::Simplifier来由源始模型生成各种细节对象。我们也可以由磁盘文件读取低多边形与高多边形模型。

  1. 包含必需的头文件:
#include <osg/LOD>
#include <osgDB/ReadFile>
#include <osgUtil/Simplifier>
#include <osgViewer/Viewer>
  1. 我们要构建三级模型细节。首先,我们需要创建原始模型的三份拷贝。可以由文件三次读取Cessna,但是在这里调用clone()方法来复制所载入的模型以立即使用:
osg::ref_ptr<osg::Node> modelL3 = osgDB::readNodeFile("cessna.
osg");
osg::ref_ptr<osg::Node> modelL2 = dynamic_cast<osg::Node*>(
    modelL3->clone(osg::CopyOp::DEEP_COPY_ALL) );
osg::ref_ptr<osg::Node> modelL1 = dynamic_cast<osg::Node*>(
    modelL3->clone(osg::CopyOp::DEEP_COPY_ALL) );
  1. 我们希望级别三将是原始Cessna,该级别具有最大的多边形数以用于近距离查看。级别二具有较少的可显示的多边形数,而级别一是细节最少的,该级别只在较远的距离上显示。osgUtil::Simplifier类在这里用来减少顶点数与面数。我们使用不同的值为级别一与级别二应用setSampleRation()方法,从而会导致不同的缩放比率:
osgUtil::Simplifier simplifier;
simplifier.setSampleRatio( 0.5 );
modelL2->accept( simplifier );
simplifier.setSampleRatio( 0.1 );
modelL1->accept( simplifier );
  1. 向LOD节点添加级别模型并且以递减顺序设置其可见范围。当我们使用addChild()与setRange()方法配置最小与最大范围值时,不要有重叠的范围,否则就会在相同的位置上显示多个级别模型,从而导致不正确的行为:
osg::ref_ptr<osg::LOD> root = new osg::LOD;
root->addChild( modelL1.get(), 200.0f, FLT_MAX );
root->addChild( modelL2.get(), 50.0f, 200.0f );
root->addChild( modelL3.get(), 0.0f, 50.0f );
  1. 启动查看器。这次程序会需要一些时间来计算并减少模型面数:
osgViewer::Viewer viewer;
viewer.setSceneData( root.get() );
return viewer.run();
  1. 再次出现Cessna模型。尝试持续按下鼠标右键来放大与缩小。当近距离查看时我们会发现模型依然显示很好,如下图中的左侧图片所示。然而,当由远距离查看时,模型会有简化。如下图中的右侧两幅图所示。距离并不会严重影响渲染结果,但如果正确使用将会增强系统效率。
_images/osg_lod.png

What just happened?

我们是否注意到Cessna被拷贝两次来准备不同的多边形级别?modelL3在这里不能被共享,因为简化器会直接在程序内存中操作几何体数据,从而会影响共享相同内存的所有指针。事实上,这被称为浅拷贝(shallow copy)。

在这个示例中,我们引入了clone()方法,该方法可以为所有的场景节点,可绘制元素与对象所用。他能够执行深拷贝(deep copy),也就是,拷贝源对象所用的所有动态分配的内存。所以modelL2与modelL1管理新分配的内存,这两个指针使用与modelL3相同的数据进行填充。

然后osgUtil::Simplifier类开始简化模型,从而减轻图形管理的负载。要应用该简化器,我们必须调用节点的accept()方法。在Visiting scene graph structures一节,我们会了解到该类以及访问者模式的更多信息。

Proxy and paging nodes

代理节点osg::ProxyNode与分页节点osg::PagedLOD是为场景负载均衡而提供的。这两个类都是直接或是间接由osg::Group类派生的。

如果有大量的模型要载入并在场景图中显示时,osg::ProxyNode节点将会减少查看器的启动时间。他能够作为外部文件的接口,帮助程序尽快启动,然后使用一个独立数据线程读取这些等待的模型。他使用setFileName()而不是addChile()来设置模型文件并动态载入作为子节点。

osg::PagedLOD节点同时继承了osg::LOD的方法,但是为了避免图像管线的负载并使得渲染过程尽可能平滑而动态载入或是卸载详细级别。

Time for action - loading a model at runtime

我们将通过使用osg::ProxyNode来演示模型文件的载入。代理将会记录原始模型的文件名,并延迟载入直到查看已经运行并发送相应的请求。

  1. 包含必需的头文件:
#include <osg/ProxyNode>
#include <osgViewer/Viewer>
  1. 我们并没有直接载入模型文件作为子节点,而是为特定索引处的子节点设置文件名。这类似于insertChild()方法,后者会将节点放置在子节点列表的特定索引处,但是列表不会被填充,直到动态载入过程已经完成。
osg::ref_ptr<osg::ProxyNode> root = new osg::ProxyNode;
root->setFileName( 0, "cow.osg" );
  1. 启动查看器:
osgViewer::Viewer viewer;
viewer.setSceneData( root.get() );
return viewer.run();
  1. 模型看起来像通常一样被载入,但是我们会注意到他是突然出现的,而且查看点并没有被调整到最佳位置。这是因为不可见的代理节点的使用就如同在渲染开始时他并没有包含子节点。然后cow模型会在运行时由文件载入,并且会自动添加为代理的子节点并渲染:
_images/osg_proxy.png

What just happened?

osg::ProxyNode与osg::PagedLOD本身非常小巧;他们主要是作为容器。OSG的内部数据载入管理器osgDB::DatabasePager将会在新文件或是详细级别可用时,或是回退到下一个可用的子节点时,会实际完成发送请法度与载入场景图的工作。

数据分页器在多个后台线程中运行,并且驱动静态数据库(由代理与分布节点管理的数据生成文件)与动态数据库数据(在运行时生成成并添加的分布节点)的载入。

数据库分布器自动回收在当前视口中不再出现的分布节点,并且会在渲染后端几乎超负载时将其由场景图中移除,也就是他需要提供大量渲染数据的多线程分页支持时。然而,这并不会影响osg::ProxyNode节点。

Have a go hero - working with the PagedLOD class

类似于代理节点,osg::PagedLOD类也有一个setFileName()方法来设置要载入到特定子节点位置处的文件。然而,作为一个LOD节点,他还需要设置每一个动态载入子节点的最小与最大可视范围。假定我们有一个cessna.osg文件以及一个低多边形版本modelL1,我们可以像下面的样子组织分页节点:

osg::ref_ptr<osg::PagedLOD> pagedLOD = new osg::PagedLOD;
pagedLOD->addChild( modelL1, 200.0f, FLT_MAX );
pagedLOD->setFileName( 1, "cessna.osg" );
pagedLOD->setRange( 1, 0.0f, 200.0f );

注意,modelL1指针不会由内存中卸载,因为他是一个直接子节点,而不是一个文件代理。

我们会看到如果只显一个详细级别的节点,使用osg::LOD与osg::PagedLOD之间并没有区别。一个更好的主意是尝试使用osg::MatrixTransform来构建一个大的Cessna集。例如,我们可以使用一个独立的函数来构建一个可变换的LOD Cessna:

osg::Node* createLODNode( const osg::Vec3& pos )
{
    osg::ref_ptr<osg::PagedLOD> pagedLOD = new osg::PagedLOD;
    …
    osg::ref_ptr<osg::MatrixTransform> mt = new osg::MatrixTransform;
    mt->setMatrix( osg::Matrix::translate(pos) );
    mt->addChild( pagedLOD.get() );
    return mt.release();
}

设置不同的位置参数并向场景根节点添加多个createLODNode()节点。可以看一下分布节点是如何被渲染的。再尝试使用osg::LOD,来比对一下在性能与内存使用上的不同。

Customizing your own NodeKits

在自定义节点与扩展新特性中最重要的步骤就是重写虚方法traverse()。该方法是由OSG渲染后端为每一帧所调用的。traverse()方法有一个输入参数,osg::NodeVisitor&,该参数实际上指明了遍历类型(更新,事件或剪裁)。大多数的OSG NodeKits重写traverse()来实现其自己的功能,以及其他一些属性与方法。

注意,有时重写traverse()方法有一些危险,因为如果开发者不能足够细心,他就会影响遍历过程并有可能导致不正确的渲染结果。如果我们希望通过将每一个节点类型扩展为一个新的自定义类来为多个节点类型添加新功能时,他会显得笨拙难用。在这些情况下,考虑使用节点回调,我们会在第8章中进行讨论。

Time for action - animating the switch node

osg::Switch类可以显示特定的子节点而隐藏他的子节点。他可以用来表示各种对象的动画状态,例如,信号灯。然而,一个典型的osg::Switch节点并不能在不同时刻自动在子节点之间切换。基于这一思想,我们将开发一个新的AnimatingSwitch节点,该类会一次显示一个子节点,并且依据用户定义的内部计数器反转切换状态。

  1. 包含必需的头文件:
#include <osg/Switch>
#include <osgDB/ReadFile>
#include <osgViewer/Viewer>
  1. 声明AnimatingSwitch类。该类将会由osg::Switch类派生并利用setValue()方法。我们同时使用一个OSG宏定义,META_Node,该宏类似于在上一章所介绍的定义节点基本属性的META_Object宏:
class AnimatingSwitch : public osg::Switch
{
public:
    AnimatingSwitch() : osg::Switch(), _count(0) {}
    AnimatingSwitch( const AnimatingSwitch& copy,
             const osg::CopyOp& copyop=osg::CopyOp::SHALLOW_COPY )
    : osg::Switch(copy, copyop), _count(copy._count) {}
    META_Node( osg, AnimatingSwitch );

    virtual void traverse( osg::NodeVisitor& nv );

protected:
    unsigned int _count;
};
  1. 在traverse()实现中,我们将会增加内部计数器并且测试他是否到达60的倍数,并且反转第一个与第二子节点的状态:
void AnimatingSwitch::traverse( osg::NodeVisitor& nv )
{
    if ( !((++_count)%60) )
    {
        setValue( 0, !getValue(0) );
        setValue( 1, !getValue(1) );
    }
    osg::Switch::traverse( nv );
}
  1. 再次载入Cessna模型与燃烧的Cessna模型,并将其添加到自定义的AnimatingSwitch实例:
osg::ref_ptr<osg::Node> model1= osgDB::readNodeFile("cessna.osg");
osg::ref_ptr<osg::Node> model2= osgDB::readNodeFile("cessnafire.
osg");
osg::ref_ptr<AnimatingSwitch> root = new AnimatingSwitch;
root->addChild( model1.get(), true );
root->addChild( model2.get(), false );
  1. 启动查看器:
osgViewer::Viewer viewer;
viewer.setSceneData( root.get() );
return viewer.run();
  1. 因为硬件的刷新速率通常是60Hz,traverse()中的if条件将会每分钟变为真,从而实现动画。那么我们就会在前一分钟内看到Cessna,而在下一分钟内看到燃烧的Cessna,依次循环:
_images/osg_animating_switch.png

What just happened?

因为traverse()方法被广泛重新实现来扩展不同的节点类型,他涉及到为实际使用读取变换矩阵与渲染状态的机制。例如,osg::LOD节点必须计算由子节点的中心到查看者眼睛的距离,从而用作不同级别之间切换的可视范围。

输入参数osg::NodeVisitor&是各种节点操作的关键。他表示访问节点的遍历类型,例如更新,事件与裁剪遍历。前两者与回调相关,我们会在第8章中进行详细讨论。

裁剪遍历,名为osgUtil::CullVisitor,可以使用下面的代码片段由osg::NodeVisitor&参数获取:

osgUtil::CullVisitor* cv = dynamic_cast<osgUtil::CullVisitor*>(&nv);
if ( cv )
{
    // Do something
}

我们应该在程序的开始处包含<osgUtil/CullVisitor>头文件。裁剪访问器通过不同的方法能够获取大量的场景状态,甚至是改变内部渲染列的结构与顺序。osgUtil::CullVisitor的概念与使用超出了本书的范围,但是依然值得由OSG NodeKits的源码进行理解与学习。

Have a go hero - creating a tracker node

我们是否想过实现一个跟踪器节点,该节点会总是跟踪其他节点的位置?跟踪器是一个更好的osg::MatrixTransform派生子类。他可以使用智能指针成员来记录要跟踪的节点并在traverse()重写方法中获取3D世界中的位置。然后跟踪器将会使用setMatrix()方法来将其自身设置到一个相对位置,以实现跟踪操作。

我们可以通过使用osg::computeLocalToWorld()函数计算绝对坐标帧中的顶点:

osg::Vec3 posInWorld = node->getBound().center() *
            osg::computeLocalToWorld(node->getParentalNodePaths()[0]);

这里的getBound()方法将会返回一个osg::BoundingSphere对象。osg::BoundingSphere类表示一个节点的边界圆,用来确定在视图截面裁剪过程中节点是否可见与可裁剪。他有两个主要方法:center()方法简单读取本地坐标中边界圆的中心点;而radius()方法返回半径。

使用Managing parent nodes一节中所提供的getParentalNodePaths()方法,我们可以获得父节点路径并且计算由节点的相对引用帧到世界引用帧的变换矩阵。

The visitor design pattern

访问者模式用来表示在一个图结构的元素上所执行的用户操作,而无需修改这些元素的类。访问者类实现了所有要应用各种元素类型上的相应虚函数,并且通过双分派(double dispatch)机制来实现该目标,也就是,依据接收者元素与访问本身的运行时类型,分派一定的虚函数调用。

基于双分派理论,开发者可以使用特定的操作请求自定义其访问者,并且在运行时将访问者绑定到不同的元素类型而不修改元素接口。这是一种无需定义多个新元素子类来扩展元素功能的好方法。

OSG支持osg::NodeVisitor类来实现访问者模式。也就是,一个osg::NodeVisitor派生类遍历一个场景图,访问每一个节点,并应用用户定义的操作。他是更新,事件与裁剪遍历(例如osgUtil::CullVisitor)以及其他一些场景图工具,包括osgUtil::SmoothingVisitor,osgUtil::Simplifier与osgUtil::TriStripVisitor的实现的基类,所有这些类都会遍历指定的子场景图并且在osg::Geode节点中的几何体上应用多边形修改。

Visiting scene graph structures

要创建一个访问者子类,我们必须重新实现osg::NodeVisitor基类中所声明的一个或是多个apply()虚重载方法。这些方法是为大多数主要的OSG节点类型所设计的。访问者会在遍历过程中为他所访问的每一个节点自动调用相应的apply()方法。用户自定义的访问者类应只为所要求的节点类型重写apply()方法。

在apply()方法的实现中,开发者需要在适当的时候调用osg::NodeVisitor的traverse()方法。他会指示访问者遍历到下一个节点,也许是一个子节点,或者如果当前节点没有子节点要访问,则为兄弟节点。不调用traverse()方法则意味着立即停止遍历,而场景图的其他部分会被忽略而不执行任何操作。

apply()方法具有如下的统一格式:

virtual void apply( osg::Node& );
virtual void apply( osg::Geode& );
virtual void apply( osg::Group& );
virtual void apply( osg::Transform& );

要遍历指定节点的子场景图并调用这些方法,我们首先需要为访问对象选择一个遍历节点。以假定的ExampleVisitor类作为例子,在特定的节点上初始化并启动访问需要两个步骤:

ExampleVisitor visitor;
visitor->setTraversalMode( osg::NodeVisitor::TRAVERSE_ALL_CHILDREN );
node->accept( visitor );

枚举器TRAVERSE_ALL_CHILDREN意味着遍历节点的所有子节点。还有两个其他选项:TRAVERSE_PARENTS,该选项会由当前节点回溯直到根节点,以及TRAVERSE_ACTIVE_CHILDREN,该选项只访问活动子节点,例如,osg::Switch节点的可见子节点。

Time for action - analyzing the Cessna structure

用户程序也许总是会在载入模型文件后在载入的场景图中查找感兴趣的节点。例如,如果根节点是osg::Transform或osg::Switch,我们也许会希望接管载入模型的变换或可见性。我们也许会对收集所有骨骼连接处的变换节点感兴趣,从而用来在稍后执行特征动画。

在这种情况下,载入模型结构的分析非常重要。在这里我们将会实现一个信息输出访问器,该访问器会输出所访问节点的基本信息并将其排列在树结构中。

  1. 包含必需的头文件:
#include <osgDB/ReadFile>
#include <osgViewer/Viewer>
#include <iostream>
  1. 声明InfoVisitor类,并定义必需要虚方法。我们仅处理叶子节点与普通的osg::Node对象。内联函数spaces()用来在节点信息之前输出空格,来表示其在树结构中的级别:
class InfoVisitor : public osg::NodeVisitor
{
public:
    InfoVisitor() : _level(0)
    { setTraversalMode(osg::NodeVisitor::TRAVERSE_ALL_CHILDREN); }

    std::string spaces()
    { return std::string(_level*2, ' '); }

    Virtual void apply( osg::Node& node );
    virtual void apply( osg::Geode& geode );

protected:
    unsigned int _level;
};
  1. 我们将会介绍两个方法,className()与libraryName(),这两个方法都会返回const char*值,例如,作为类名的”Node”以及作为库名的”osg”。META_Object与META_Node宏定义会在内部完成这些工作:
void InfoVisitor::apply( osg::Node& node )
{
    std::cout << spaces() << node.libraryName() << "::"
      << node.className() << std::endl;

    _level++;
    traverse( node );
    _level--;
}
  1. 以osg::Geode&为参数的apply()重载方法的实现与前面的实现略为不同。他会遍历所有关联到osg::Geode节点的可绘制元素并输出其信息。在这里要小心traverse()的调用时,从而保证树中每个节点的级别都是正确的。
void apply( osg::Geode& geode )
{
    std::cout << spaces() << geode.libraryName() << "::"
      << geode.className() << std::endl;

    _level++;
    for ( unsigned int i=0; i<geode.getNumDrawables(); ++i )
    {
        osg::Drawable* drawable = geode.getDrawable(i);
        std::cout << spaces() << drawable->libraryName() << "::"
          << drawable->className() << std::endl;
    }

    traverse( geode );
    _level--;
}
  1. 在主函数中,使用osgDB::readNodeFiles()由命令行参数读取文件:
osg::ArgumentParser arguments( &argc, argv );
osg::ref_ptr<osg::Node> root = osgDB::readNodeFiles( arguments );
if ( !root )
{
    OSG_FATAL << arguments.getApplicationName() <<": No data
      loaded." << std::endl;
    return -1;
}
  1. 现在使用自定义的InfoVisitor来访问载入的模型。为了允许其所有子节点的遍历,我们会注意到在访问器的构造函数中调用了setTraversalMode()方法:
InfoVisitor infoVisitor;
root->accept( infoVisitor );
  1. 是否启动查看器,这取决于我们自己,因为我们的访问器已完成其任务:
osgViewer::Viewer viewer;
viewer.setSceneData( root.get() );
return viewer.run();
  1. 假定我们的可执行文件为MyProject.ext,在命令行输入:
# MyProject.exe cessnafire.osg
  1. 我们会在控制台看到下列信息:
_images/osg_visitor.png

What just happened?

现在我们可以很容易绘制输入的燃烧Cessna模型的结构。他显式包含一个带有几何体对象的osg::Geode节点,该节点包含Cessna的几何数据。几何体节点可以通过其父节点osg::MatrixTransform进行变换。整个模型由osg::Group节点所管理,该模型是由osgDB::readNodeFile()或osgDB::readNodeFiles()函数返回的。

其他以osgParticle为前缀的类现在看起来有些奇怪。他们实际上表示Cessna的烟与火粒子效果,我们会在第8章进行介绍。

现在我们能够基于访问场景图的结果修改模型的基元集合,或是控制粒子系统。要实现该目的,现在我们将指定的节点指针保存在我们自己的访问者类的成员变量中,并在未来的代码中重用。

Summary

本章探讨了如何通过使用OSG实现一个典型的场景图,显示了各种场景图节点类型的使用,特别关注了图树的组装以及如何添加状态对象,例如常用到的;osg::Transform,osg::Switch,osg::LOD以及osg::ProxyNode类。我们特别探讨了:

  • 如何实例化osg::Group与osg::Geode节点来组装一个基本的层次结构图并处理父节点与子节点。
  • 如何使用osg::Transform,基于对矩阵及其实现-osg::Matrix变量-的理解实现空间变换。
  • 如何使用osg::Switch节点来切换场景节点的渲染状态。
  • 如何通过使用osg::LOD类来场景节点确定渲染复杂性的细节。
  • 使用osg::ProxyNode与osg::PagedLOD类来平衡运行时场景载入。
  • 如何自定义节点并强化其特性。
  • 访问者设计模式的基本概念及其在OSG中的实现
  • 使用osg::NodeVisitor派生类遍历节点及其子场景图

Chapter 6: Creating Realistic Rendering Effects

3D场景中的几何模型是顶点,纹理,光以及阴影信息的组合。在图像管线中,渲染是最后的重要一步,使用各种可视化效果,例如亮度,颜色以及用户可以看到的表面细节,由定义的模型中生成图像。OSG几乎封装了所有的OpenGL的渲染接口,包括光线,材质,纹理,Alpha测试,图像混合,雾效果以及OpenGL Shading Language中的顶点,几何以及帧渐变器的实现。

本章将会详细介绍:

  • 理解状态机的概念及其在OSG中的封装
  • 如何为场景对象设置不同的渲染属性与模式
  • 如何在场景图中继承渲染状态
  • 在OSG中实现各种固定功能的渲染效果
  • 如何控制场景光线,这是一个位置状态
  • 如何添加纹理以及设置几何体的纹理坐标
  • 控制绘制透明以及半透明对象的渲染顺序
  • 通过统一变量使用顶点,几何体以及帧渐变器

Encapsulating the OpenGL state machine

通常,OpenGL使用一个状态机来跟踪所有渲染相关的状态。渲染状态是状态属性的集合,如场景光线,材质,纹理以及纹理环境,状态模式,这些可以通过OpenGL函数glEnable()或glDisable()打开或关闭。

当一个渲染状态被设置后,他就会持续作用直到其他的函数修改了渲染状态。OpenGL管线在内部维护一个状态栈,在任意时刻保存或是恢复渲染状态。

状态机为开发者提供了对当前以及保存的渲染状态的精确控制。然而,他却并不适合于在一个场景图结构中直接使用。正因为如此,OSG使用osg::StateSet类来封装OpenGL状态机,并且管理场景图的裁剪与渲染遍历中各种渲染状态的push与pop操作。

一个osg::StateSet实例包含一个不同OpenGL状态的子集,并且可以通过使用setStateSet()方法将其应用到osg::Node或是osg::Drawable对象。例如,我们可以向一个node变量添加一个新分配的状态集:

osg::StateSet* stateset = new osg::StateSet;
node->setStateSet( stateset );

一个更安全的方法是使用getOrCreateStateSet()方法,这可以确保总是返回正确的状态集,并且如果需要会自动关联到节点或是可绘制元素:

osg::StateSet* stateset = node->getOrCreateStateSet();

osg::Node或是osg::Drawable类使用智能指针osg::ref_ptr<>管理osg::StateSet成员变量。这意味着状态集可以为多个场景对象所共享,并且可以在不再需要时被销毁。

Attributes and modes

OSG定义了一个osg::StateAttribute类来记录渲染状态属性。他是一个虚基类,可以继承该类来实现不同的渲染属性,例如光线,材质与雾。

渲染模式的作用类似于可以打开或是关闭的开关。另外,他包含一个用来指示OpenGL模式类型的枚举参数。因为简单,并没有必要为渲染模式定义一个StateMode基类。注意,有时渲染式与一个属性相关联,例如,当模式GL_LIGHTING被打开时,光线变量会被发送到OpenGL管线,相反则会关闭场景光线。

osg::StateSet类将属性与模式分为两类:纹理与非纹理。有多种方法可以将非纹理属性与模式添加到状态集合本身:

  1. 公共方法setAttribute()将一个osg::StateAttribute派生对象添加到状态集。在一个状态集中不能存在相同类型的属性。前一个属性会被新属性所覆盖。
  2. 公共方法setMode()将一个模型枚举关联到状态集,并将其值设置为osg::StateAttribute::ON或osg::StateAttribute::OFF,意味着打开或是关闭该模式。
  3. 公共方法setAttributeAndModes()将一个渲染属性及其相关联的模式关联到状态集,同时设置开关值(默认为ON)。注意,并不是所有的属性都有相对应的模式,但是我们总是可以确定的使用该方法。

要将属性attr及其相关的模式关联到stateset变量,我们可以使用下面的代码:

stateset->setAttributeAndModes( attr, osg::StateAttribute::ON );

纹理属性与模式需要赋值一个额外的单位参数来指定向应用的纹理映射单位,所以osg::StateSet提供了一些额外的公共方法,均以Texture为中缀,包括setTextureAttribute(),setTextureMode()与setTextureAttributeAndMode()。要将纹理属性texattr及其相关的模式关联到stateset变量,并指定纹理单位为0,可以输入下面代码:

stateset->setTextureAttributeAndModes(
    0, texattr, osg::StateAttribute::ON );

Time for action - setting polygon modes of different nodes

我们将要选择载入模型的多边形光栅模式。由osg::StateAttribute基类派生的osg::PolygonMode类将会用来实现该目的。他简单的封装了OpenGL的glPolygonMode()函数并且实现了用于指定面与绘制模式参数的接口,从而会改变所关联节点的最终光栅化效果。

  1. 包含必需的头文件:
#include <osg/PolygonMode>
#include <osg/MatrixTransform>
#include <osgDB/ReadFile>
#include <osgViewer/Viewer>
  1. 我们的工作以上一章的变换示例为基础。我们创建两个osg::MatrixTransform节点并使其共享同一个载入的Cessna模型。两个变换节点被放置在3D世界中的不同位置处,从而会显示两个Cessna模型:
osg::ref_ptr<osg::Node> model = osgDB::readNodeFile(
  "cessna.osg" );
osg::ref_ptr<osg::MatrixTransform> transformation1 = new
osg::MatrixTransform;
transformation1->setMatrix(osg::Matrix::translate(-
25.0f,0.0f,0.0f));
transformation1->addChild( model.get() );
osg::ref_ptr<osg::MatrixTransform> transformation2 = new
osg::MatrixTransform;
transformation2->setMatrix(osg::Matrix::translate(25.0f,0.0f,0.
0f));
transformation2->addChild( model.get() );
  1. 现在我们osg::PolygonMode渲染属性添加到节点transformation1所关联的状态集合。他有一个setMode()方法,该方法接受两个参数:要应用模式的面,以及多边形的光栅化模式:
osg::ref_ptr<osg::PolygonMode> pm = new osg::PolygonMode;
pm->setMode(osg::PolygonMode::FRONT_AND_BACK,
osg::PolygonMode::LINE);
transformation1->getOrCreateStateSet()->setAttribute( pm.get() );
  1. 接下来的步骤是我们所熟悉的了。现在我们可以将节点添加到根节点,并启动查看器来看一下是否发生了变化:
osg::ref_ptr<osg::Group> root = new osg::Group;
root->addChild( transformation1.get() );
root->addChild( transformation2.get() );
osgViewer::Viewer viewer;
viewer.setSceneData( root.get() );
return viewer.run();
  1. 位于(-25.0,0.0,0.0)位置处或初始显示容器左侧的Cessna,使用前面与后面的轮廓多边形进行绘制。相对应的,右侧的模型像平常一样进行填充:
_images/osg_stateset.png

What just happened?

具有OpenGL多边形模式的知识,我们可以很容易想像osg::PolygonMode类的setMode()方法所需要的参数。第一个参数可以是osg::PolygonMode::FRONT,BACK与FRONT_AND_BACK其中的一个,分别对应OpenGL的枚举GL_FRONT,GL_BACK与GL_FRONT_AND_BACK。第二个参数可以是osg::PolygonMode::POINT,LINE与FILL,分别对应GL_POINT,GL_LINE与GL_FILL。当封装OpenGL的渲染状态时,OSG并不需要更多的技巧。

多边形模式并没有相关联的模式,也就是,他不需要调用OpenGL的glEnable()/glDisable()函数,也不需要使用OSG状态集合的setMode()方法。

setAttributeAndMode()方法在这里也可以正确作用,但是在这种情况下开关值(ON/OFF)并不可用。

Inheriting render states

节点的状态集将会影响当前节点及其子节点。例如,节点transformation1的osg::PolygonMode属性将会使得其所有子节点显示为轮廓图。然而,子节点的状态集可以覆盖父节点的状态集,也就是,渲染状态将会由父节点继承,除非子节点改变这一行为。下图显示了一个想像的场景图如何遍历不同的多边形模式状态:

_images/osg_render_state.png

有时我们也许希望不同的行为。例如,在通常的3D编辑器软件中,用户可以由文件载入多个模型,并将其渲染为纹理,金属丝帧或是固体,而不论前一个模型的状态是什么。换句话说,编辑器中的所有子模型应继承统一的而不论他们之前被设置为何种状态。这在OSG中可以通过使用osg::StateAttribute::OVERRIDE标记来实现,例如:

stateset->setAttribute( attr, osg::StateAttribute::OVERRIDE );

要设置渲染模式或是属性与模式,可以使用位或操作符:

stateset->setAttributeAndModes( attr,
    osg::StateAttribute::ON|osg::StateAttribute::OVERRIDE );

回到3D编辑器软件的话题。想像一下我们使用鼠标指针选择一个模型;将会显示一个边框盒子来表示模型已被选中。选中的盒子不会被纹理/丝线边框/固体状态所影响,也就是,属性或状态并没有受到父节点覆盖的影响。OSG使用osg::StateAttribute::PROTECTED标记来支持该特性。

OSG还有第三个标记,osg::StateAttribute::INHERIT,用来表示当前的属性或模式应由父节点的状态集继承。在这种情况下,并不会真正使用所应用的属性或模式。

Time for action - lighting the glider or not

在下面的简短示例中我们将显示OVERRIDE与PROTECTED标记的使用。根节点将会被设置为OVERRIDE,以强制其所有的子节点继承其属性或模式。同时,子节点会尝试通过使用或不使用PROTECTED标记来改变其继承性,从而会导致不同的结果。

  1. 包含必需的头文件:
#include <osg/PolygonMode>
#include <osg/MatrixTransform>
#include <osgDB/ReadFile>
#include <osgViewer/Viewer>
  1. 创建两个osg::MatrixTransform节点并使其共享同一个滑翔机模型。毕竟,我们并不希望总是使用著名的Cessna。滑翔机在尺寸上很小,所以对于setMatrix()方法只需要一个很小的距离:
osg::ref_ptr<osg::Node> model = osgDB::readNodeFile(
  "glider.osg" );
osg::ref_ptr<osg::MatrixTransform> transformation1 = new
osg::MatrixTransform;
transformation1->setMatrix(osg::Matrix::translate(
  -0.5f, 0.0f, 0.0f));
transformation1->addChild( model.get() );
osg::ref_ptr<osg::MatrixTransform> transformation2 = new
  osg::MatrixTransform;
transformation2->setMatrix(osg::Matrix::translate(
  0.5f, 0.0f, 0.0f));
transformation2->addChild( model.get() );
  1. 将两个变换节点添加到根节点:
osg::ref_ptr<osg::Group> root = new osg::Group;
root->addChild( transformation1.get() );
root->addChild( transformation2.get() );
  1. 现在我们要为每一个节点的状态集合设置渲染模式。GL_LIGHTING模式是一个著名的OpenGL枚举,可以用来打开或是禁止场景的全局灯光。注意,OVERRIDE与PROTECTED标记被分别设置到root与transformation2,且其开关值分别被设置为ON与OFF:
transformation1->getOrCreateStateSet()->setMode( GL_LIGHTING,
    osg::StateAttribute::OFF );
transformation2->getOrCreateStateSet()->setMode( GL_LIGHTING,
    osg::StateAttribute::OFF|osg::StateAttribute::PROTECTED);
root->getOrCreateStateSet()->setMode( GL_LIGHTING,
    osg::StateAttribute::ON|osg::StateAttribute::OVERRIDE );
  1. 启动查看器:
osgViewer::Viewer viewer;
viewer.setSceneData( root.get() );
return viewer.run();
  1. 位于屏幕左侧的节点transformation1并没有明显的变化。然而,transformation2则完全不同,尽管他与transformation1共享相同的载入模型:
_images/osg_glider.png

What just happened?

我们可以首先通过下面的命令来看一下正常的滑翔机是什么样子的:

# osgviewer glider.osg

在这个示例中,我们尝试修改transformation1与transformation2的GL_LIGHTING模式来禁止其上的灯光。然而,我们已经为根节点打开了灯光模式,并且使用OVERRIDE标记以其所有的子节点来保持其灯光状态。

正如前面的示例中所示,节点transformation1不顾其自己的设置保持了灯光。然而,transformation2使用PROTECTED标记来阻止其受到根节点的影响。由于该节点关闭了其表面的“灯光”,所以他看起来有一些亮。这是因为几何现在是依据原始颜色数组来直接着色的,而没有对灯光的响应。

将osgDB::readNodeFile()的文件名修改为cessna.osg将会生成两个明亮模型,这是因为Cessna模型在子场景图中打开了GL_LIGHTING模式,以覆盖前面的状态。这次我们知道如何禁止transformation2的灯光了吧?

Playing with fixed-function effects

通过使用osg::StateAttribute派生类,OSG几乎支持所有的OpenGL渲染属性与模式类型。下表是超过40个OSG封装主要OpenGL固定函数状态的组件的一部分:

_images/osg_fix_func_1.png _images/osg_fix_func_2.png _images/osg_fix_func_3.png _images/osg_fix_func_4.png

Type ID列可以由状态集获取特定的属性。他被用作getAttribute()方法的参数,例如:

osg::PolygonMode* pm = dynamic_cast<osg::PolygonMode*>(
    stateset->getAttribute(osg::StateAttribute::POLYGONMODE) );

如果我们之前已经为stateset设置了多边形模式属性,则上面的代码会获取一个正确的指针。否则,getAttribute()将会返回NULL。

上表中的关联模式列显示了当使用setAttributeAndModes()时OSG如何调用OpenGL模式。我们也可以通过使用getMode()方法来检测一个模式是打开还是关闭:

osg::StateAttribute::GLModeValue value =
    stateset->getMode( GL_LIGHTING );

这里枚举GL_LIGHTING被用来打开或是禁止整个场景中的光线。

相关OpenGL函数列表明在一个OSG属性类封装了哪一个OpenGL函数。一个OSG属性类总是包含一系列的方法来指定相关的函数参数-OpenGL开发者可以将其程序迁移到OSG,而无需过多的修改。

Time for action - applying simple fog to models

我们将会以雾效果作为处理各种渲染属性与模式的理想示例。OpenGL接受一个线性与两个指数雾公式,这些参数也为osg::Fog类所支持。

  1. 包含必需的头文件:
#include <osg/Fog>
#include <osgDB/ReadFile>
#include <osgViewer/Viewer>
  1. 我们首先创建雾属性。使用线性模式,我们需要通过使用setStart()与setEnd()方法设置近距离与远距离。为了生成灰尘雾的效果,我们同时设置雾的颜色:
osg::ref_ptr<osg::Fog> fog = new osg::Fog;
fog->setMode( osg::Fog::LINEAR );
fog->setStart( 500.0f );
fog->setEnd( 2500.0f );
fog->setColor( osg::Vec4(1.0f, 1.0f, 0.0f, 1.0f) );
  1. 我们载入一个名为lz.osg的示例地形模型,该模型位于由环境变量OSG_FILE_PATH所指示的数据目录内。我们所要做的唯一工作就是向节点的状态集合设置雾属性与关联模式。
osg::ref_ptr<osg::Node> model = osgDB::readNodeFile( "lz.osg" );
model->getOrCreateStateSet()->setAttributeAndModes( fog.get() );
  1. 启动查看器并操作场景,以便利地形与雾可以较好的展示:
osgViewer::Viewer viewer;
viewer.setSceneData( model.get() );
return viewer.run();
  1. 当我们使用鼠标右键缩放场景时,地形模型将会以一种平滑的过程淡入与淡出。这是非常基本的环境效果,但有时结果依然是非常惊奇的:
_images/osg_fog.png

What just happened?

OpenGL的glFog()函数支持各种模式参数的设置,例如GL_FOG_MODE,GL_FOG_DENSITY,GL_FOG_START与GL_FOG_END。在OSG中这些模式被重定义为setMode(),setDensity(),setStart()与setEnd()方法,每一个还有相对应的get*()方法。

下面是实现雾效果的另外一个小技巧:开发者可以设置几何体每一个顶点的雾坐标,并将其用作计算中的距离值。除了指定雾坐标源之外,这可以通过osg::Geometry类的setFogCoordArray()与setFogCoordBinding()方法来实现:

fog->setFogCoordinateSource( GL_FOG_COORD );

如果方法的参数被设置为GL_FRAGMENT_DEPTH,则在雾颜色的计算中会使用当前片段深度。

Have a go hero - searching for more effects

还有更多将OpenGL函数与参数封装为公共类方法的OSG渲染属性类。要进行详细了解,我们可以阅读在预编译包中的API文档,或是查看头文件中的声明来了解如何使用。

有一些容易理解与使用的渲染属性,osg::ColorMask,osg::LineWidth与osg::ShadeModel。他们具有设置掩码,宽度与模式参数的内部方法,并且当关联到节点或可绘制元素的状态集合时立即产生效果。尝试这些渲染属性来了解一个我们是否能够仅通过API手册与类声明就可以掌握他们。

Lights and light sources

类似OpenGL,OSG支持多达八个固定功能光源用于直接照明3D空间,并且不能够在对象上自动生成与转换阴影。光线通常起源于特定的光源,以直线前进,反射场景对象或是由场景对象分散,并最终为观察者的眼睛所接收。光源性质,表面材质性,以及几何法线均是实现完整光效果所必需的。

osg::Light类提供了精巧的方法用于光源属性上的操作,包括用于处理OpenGL光数目的setLightNum()与getLIghtNum()方法,用于周围环境的setAmbient()与getAmbient()方法,用于处理光组件混合的setDiffuse()与getDiffuse()方法。

OSG同时提供了osg::LightSource类用于向场景图添加光线。他有一个setLight()方法,并且应被用作只有一个光线属性的叶子节点。如果设置了相应的GL_LIGHT1模式,则场景图中的其他所有节点都会受到光源节点的影响:

osg::ref_ptr<osg::Light> light = new osg::Light;
light->setLightNum( 1 );  // Specify light number 1
...
osg::ref_ptr<osg::LightSource> lightSource = new osg::LightSource;
lightSource->setLight( light.get() );  // Add to a light source node
...
// Add the source node to the scene root and enable rendering mode GL_LIGHT1 to fit the light's set!
root->addChild( lightSource.get() );
root->getOrCreateStateSet()->setMode( GL_LIGHT1,
    osg::StateAttribute::ON );

打开指定光线的另一个更为方便的解决方案是setStateSetMods()方法,通过该方法光源会自动将光线数目关联到场景根节点:

root->addChild( lightSource.get() );
lightSource->setStateSetModes( root->getOrCreateStateSet(),
osg::StateAttribute::ON );

有时我们也许会将子节点添加到osg::LightSource节点,但是这并不意味着照亮基于节点层次结构关系的子场景图。他可以被看作表示光源物理开关的几何体。

osg::LightSource节点可以被放置在osg::Transform节点下。然后点光可以依据当前的变换信息进行变换。我们可以通过设置osg::LightSource的引用帧来禁止该特性,例如:

lightSource->setReferenceFrame( osg::LightSource::ABSOLUTE_RF );

其含义类似于osg::Transform类的setReferenceFrame()方法。

Time for action - creating light sources in the scene

默认情况下,OSG自动打开第一道光(GL_LIGHT0),从而为场景提供一个柔软的方向光。然而,这次我们将会自己创建多道光,并且随着变换父节点移动。注意,只有位置可以移动。方向光没有源点,因而不能放置在任何位置。

OpenGL与OSG均使用位置参数的第四部分来确定光是否为点光。也就是说,如果第四部分为0,则该光被看作方向光;否则为点光。

  1. 包含必需的头文件:
#include <osg/MatrixTransform>
#include <osg/LightSource>
#include <osgDB/ReadFile>
#include <osgViewer/Viewer>
  1. 我们创建一个函数来为场景图创建光源。光源应用一个标号(由0到7),一个变换位置,以及一个颜色参数。之所以创建点光是因为位置向量的第四部分为1.0。然后我们将该光赋值给一个新创建的osg::LightSource节点,并将该光源添加到变换的osg::MatrixTransform节点,并返回该变换节点:
osg::Node* createLightSource( unsigned int num,
                              const osg::Vec3& trans,
                              const osg::Vec4& color )
{
    osg::ref_ptr<osg::Light> light = new osg::Light;
    light->setLightNum( num );
    light->setDiffuse( color );
    light->setPosition( osg::Vec4(0.0f, 0.0f, 0.0f, 1.0f) );

    osg::ref_ptr<osg::LightSource> lightSource = new
      osg::LightSource;
    lightSource->setLight( light );
    osg::ref_ptr<osg::MatrixTransform> sourceTrans =
        new osg::MatrixTransform;
    sourceTrans->setMatrix( osg::Matrix::translate(trans) );
    sourceTrans->addChild( lightSource.get() );
    return sourceTrans.release();
}
  1. Cessna模型将会由我们的自定义光进行照射。我们会在创建光源之前将其由文件中载入:
osg::ref_ptr<osg::Node> model = osgDB::readNodeFile(
  "cessna.osg" );
osg::ref_ptr<osg::Group> root = new osg::Group;
root->addChild( model.get() );
  1. 现在创建两个光源节点并将其放置在场景中的不同位置:
osg::Node* light0 = createLightSource(
    0, osg::Vec3(-20.0f,0.0f,0.0f), osg::Vec4(
  1.0f,1.0f,0.0f,1.0f) );
osg::Node* light1 = createLightSource(
    1, osg::Vec3(0.0f,-20.0f,0.0f), osg::Vec4(0.0f,1.0f,1.0f,1.0f)
);
  1. 在这里0号光与1号光。所以我们将会打开根节点的GL_LIGHT0与GL_LIGHT1,意味着场景图中的所有节点将会由两个暖光源中受益:
root->getOrCreateStateSet()->setMode( GL_LIGHT0,
  osg::StateAttribute::ON );
root->getOrCreateStateSet()->setMode( GL_LIGHT1,
  osg::StateAttribute::ON );
root->addChild( light0 );
root->addChild( light1 );
  1. 现在让我们启动查看器:

    osgViewer::Viewer viewer; viewer.setSceneData( root.get() ); return viewer.run();

  2. 我们将会看到Cessna的一侧为黄光照射,而其前面则由青光照射。这正是我们在示例源中所期望的!

_images/osg_light.png

What just happened?

osg::LightSource类是一种特殊类型的节点,该节点会影响允许其关联渲染模式的所有节点,而无论这些节点是否为光源的子节点。有时这会让人感到奇怪,但是可以通过位置状态的概念进行解释。也就是,渲染状态使用当前的模型-视图矩阵来放置其自己。

OpenGL中的典型位置状态包括glLight()函数(点光),glClipPlane()函数与glTexGen()函数(GL_EYE_LINEAR模式)。这些状态应在空间变换中定位;否则其展示会随着每次所应用的不同的模型视图矩阵而发生较大的变化。

OSG使用三个osg::Group派生节点:osg::LightSource,osg::ClipNode与osg::TexGenNode来绑定这些特殊状态。他们均有一个setReferenceFrame()方法来使用绝对引用帧,并且可以被添加到空间中确定位置的变换节点。唯一的区别在于osg::LightSource与osg::TexGenNode会影响允许相关模式的所有节点,但是osg::ClipNode仅会裁剪具有特定裁剪面板的子节点。

Pop quiz - lights without sources

我们也可以将osg::Light看作普通的渲染属性。例如,将一个光对象应用到根节点将会影响其子图。然而,如果我们不使用光源将会有明显的区别。区别是什么呢?当光作为头顶光(headlight)或是天空光(skylight)时呢?

The Image class

在上一章中我们已经了解了如何创建一个方块并填充颜色。然而另一个方法在其上应用纹理映射(通常是位图或栅格图像)。这并不会影响表面的顶点,而只会修改最终的像素数据,大多数情况下,这是用于表示对象细节更为有效和合适的方法。

OSG提供了多个纹理属性与模式用于纹理映射操作,我们将会在下一节进行介绍。在这之前,我们需要探讨osg::Image类,该类存储OpenGL纹理对象载入与使用的图像数据。

由磁盘载入图像的最好方法是使用osgDB::readImageFile()函数。这非常类似于osgDB::readNodeFile()函数,该函数将模型载入为场景节点。假定我们有一个名为picture.bmp的位图文件,下面的代码会将其载入为图像对象用于纹理映射使用:

osg::ref_ptr<osg::Image> image =
    osgDB::readImageFile( "picture.bmp" );

如果图像载入成功,也就是,图像指针是可用的,那么我们就可以使用一些公共方法来读取图像的属性:

  • 公共方法s(),t()与r()返回图像的宽度,高度与深度。
  • 公共方法data()将原始图像数据作为unsigned char*指针返回。为了读取或是修改图像像素数据我们可以直接在指针上进行操作。

data()指针中每一个无符号字符元素的含义是与图像的像素格式与数据类型相关联的,这可以通过getPixelFormat()与getDataType()方法。这两个值与OpenGL glTexImage*()函数的格式与类型参数具有相同的含义。例如,一个像素格式为GL_RGB与数据类型GL_UNSIGNED_BYTE的图像对象将会使用三个独立的无符号字符元素来表示每一个RGB组件,从而形成一个完整的像素,如下图所示:

_images/osg_image.png

我们也可以分配一个新的图像对象并将我们自己的图像对象放置在该对象中:

osg::ref_ptr<osg::Image> image = new osg::Image;
image->allocateImage( s, t, r, GL_RGB, GL_UNSIGNED_BYTE );
unsigned char* ptr = image->data();
...  // Operate on the ptr variable directly!

这里s,t与r表示图像的尺寸,而GL_RGB与GL_UNSIGNED_BYTE被用作像素格式与数据类型的示例设置。内部缓冲区数据将会在调用allocateImage()方法之后分配,并且会在图像不再为任何纹理对象引用时自动销毁。

我们可以深度更多的图像文件,例如.jpg,.png,.tif等。OSG通过文件I/O插件来管理大多数的图像格式,但是其中的一些插件需要依赖第三方库,所以如果我们使用默认设置由源码编译OSG,也许某些插件是不可用的。我们会在第10章中了解关于构建与使用文件读取器/写入器插件的更多内容。

The basis of texture mapping

要在我们的程序中使用基本的纹理映射,我们需要遵循下列步骤:

  1. 设定指定几何体的纹理坐标
  2. 为1D,2D,3D或是立方体映射纹理映射操作创建纹理属性对象
  3. 为纹理属性指定一个或是多个图像
  4. 将相应的纹理属性或模式关联到状态集,该状态将会被应用到相关的节点与可绘制元素

OSG定义了一个osg::Texture类来封装所有的纹理类型。其子类osg::Texture1D,osg::Texture2D,osg::Texture3D与osg::TextureCubMap可以表示不同的OpenGL纹理映射技术。

osg::Texture类最常用的方法是setImage()。该方法简单的将一个已分配的图像设置到纹理对象。例如:

osg::ref_ptr<osg::Image> image =
    osgDB::readImageFile( "picture.bmp" );
osg::ref_ptr<osg::Texture2D> texture = new osg::Texture2D;
texture->setImage( image.get() );

或者,我们也许会直接向构造函数传递图像对象:

osg::ref_ptr<osg::Image> image =
    osgDB::readImageFile( "picture.bmp" );
osg::ref_ptr<osg::Texture2D> texture =
    new osg::Texture2D( image.get() );

在纹理对象内部,图像变量是由智能指针来管理的。我们可以通过使用getImage()方法由纹理对象中读取该图像。

另一个重要事情是为osg::Geometry对象的每一个顶点设置纹理坐标。我们可以通过使用setTexCoordArray()方法将osg::Vec2Array或是osg::Vec3Array应用到几何体,从而使用当前2D或是一个大纹理中相应的数据构建所有帧。

当指定纹理坐标时,我们必须同时为多个纹理实现设置纹理映射单位。要在一个模型上使用单一的纹理,我们可以简单的指定纹理单位为0。例如,下面的代码几何体变量geom的纹理坐标数据设置为0:

osg::ref_ptr<osg::Vec2Array> texcoord = new osg::Vec2Array;
texcoord->push_back( osg::Vec2(...) );
...
geom->setTexCoordArray( 0, texcoord.get() );

在这之后,我们可以将纹理属性添加到状态集,自动依据相关的模式(GL_TEXTURE_2D)进行切换,并将属性应用到几何体本身,或是包含其的节点:

geom->getOrCreateStateSet()->setTextureAttributeAndModes(
    texture.get() );

注意,OpenGL在图像内存(视频卡内存)中管理图像数据,但是osg::Image对象会将载入的数据保存在系统内存中。结果就是相同图像数据的两份拷贝,一个为OpenGL所有,而另一个存储在osg::Image对象中。如果图像没有在多个纹理属性之间共享,那么可以在将其应用到OpenGL管线之后删除图像对象及其所占用的系统内存。osg::Texture类提供了一个setUnRefImageDataAfterApply()方法来执行该操作:

texture->setUnRefImageDataAfterApply( true );

一旦OpenGL纹理对象被创建,内部管理的图像会被释放,而getImage()将会返回一个不正确的指针。这会使得查看器的运行更为高效。

Time for action - loading and applying 2D textures

最常见的纹理映射技术是2D纹理映射。这会接受一个2D图像作为纹理并将其映射到一个或是多个几何体面。在这里osg::Texture2D类被用作特定纹理映射单元的纹理属性。

  1. 包括必需的头文件:
#include <osg/Texture2D>
#include <osg/Geometry>
#include <osgDB/ReadFile>
#include <osgViewer/Viewer>
  1. 我们快速创建一个四边形,并调用setTexCoordArray()方法来将纹理坐标绑定到每个顶点。在这个示例中,纹理坐标数组仅影响纹理单位0,但总是可以在单位之间共享数组:
osg::ref_ptr<osg::Vec3Array> vertices = new osg::Vec3Array;
vertices->push_back( osg::Vec3(-0.5f, 0.0f,-0.5f) );
vertices->push_back( osg::Vec3( 0.5f, 0.0f,-0.5f) );
vertices->push_back( osg::Vec3( 0.5f, 0.0f, 0.5f) );
vertices->push_back( osg::Vec3(-0.5f, 0.0f, 0.5f) );
osg::ref_ptr<osg::Vec3Array> normals = new osg::Vec3Array;
normals->push_back( osg::Vec3(0.0f,-1.0f, 0.0f) );
osg::ref_ptr<osg::Vec2Array> texcoords = new osg::Vec2Array;
texcoords->push_back( osg::Vec2(0.0f, 0.0f) );
texcoords->push_back( osg::Vec2(0.0f, 1.0f) );
texcoords->push_back( osg::Vec2(1.0f, 1.0f) );
texcoords->push_back( osg::Vec2(1.0f, 0.0f) );
osg::ref_ptr<osg::Geometry> quad = new osg::Geometry;
quad->setVertexArray( vertices.get() );
quad->setNormalArray( normals.get() );
quad->setNormalBinding( osg::Geometry::BIND_OVERALL );
quad->setTexCoordArray( 0, texcoords.get() );
quad->addPrimitiveSet( new osg::DrawArrays(GL_QUADS, 0, 4) );
  1. 我们将由磁盘载入文件并将其赋值给2D纹理对象。文件格式.rgb是由SGI开发,通常用于存储2D纹理:
osg::ref_ptr<osg::Texture2D> texture = new osg::Texture2D;
osg::ref_ptr<osg::Image> image =
    osgDB::readImageFile( "Images/lz.rgb" );
texture->setImage( image.get() );
  1. 将四边形添加到osg::Geode节点,然后将纹理属性添加到状态集合。注意,要将属性设置为与纹理坐标数组相同的纹理映射单位:
osg::ref_ptr<osg::Geode> root = new osg::Geode;
root->addDrawable( quad.get() );
root->getOrCreateStateSet()->setTextureAttributeAndModes(
    0, texture.get() );
  1. 启动查看器并查看效果:
osgViewer::Viewer viewer;
viewer.setSceneData( root.get() );
return viewer.run();
  1. 现在我们有一个应用了普通纹理的四边形几何体。尝试使用其他的图像文件来看一下我们是否可以在3D空间构建一个精彩的世界:
_images/osg_texture.png

What just happened?

2D纹理是一个颜色值的二维数组。每个值被称为texel(纹理元素),该元素由一个列值与一个值所构成的唯一地址。相对于纹理的中心(0,0),行被标记为s坐标,而列被标记为t坐标。被称为纹理坐标的地址应其所赋值的唯一顶点映射到对象坐标。这也就是我们为什么应设置几何体的纹理坐标数组并确保其与顶点数组具有相同尺寸的原因。

osg::Geometry类可以拥有以不同纹理映射单位表示的多个纹理坐标数组。要使其全部可用,我们需要通过使用setTextureAttributeAndModes()方法来为每一个单位设置osg::Texture属性。

osg::Texture2D类要求纹理坐标正规化为[0,1],否则他会使用纹理封装来处理多余的部分。他会检测纹理的维度在尺寸上是否全部为2的幂次,例如64x64或256x512,并且在默认情况下会使用OpenGL的gluScaleImage()函数在内部缩放不是2的幂次的图像,这对于读取任意的图像非常方便,但是需要更多的系统时间并会占用较大的图形内存尺寸。还有一个定义我们是否需要强制调整图像大小的setResizeNonPowerOfTwoHint()方法。注意,非2幂次图像是由某些图形显示直接支持的。

osg::TextureRectangle类支持2D纹理,而不需要2的幂次维度。从而避免了重采样,并且需要更少的图形内存来存储图像数据。然而,他并没有用于纹理过滤的mipmaps,且纹理坐标必需是维度相关的。

Have a go hero - making use of filters and rwapping modes

OpenGL已经设计有完美的机制来处理纹理封装与过滤。osg::Texture类也包含有封装的方法。

setWrap()方法需要两个参数:要应用于其上的纹理坐标轴以及要使用的封装模式。然后我们可以这玉色纹理的封装行为,例如:

texture->setWrap( osg::Texture::WRAP_S, osg::Texture::REPEAT );
texture->setWrap( osg::Texture::WRAP_R, osg::Texture::REPEAT );

如果位于坐标轴s与t上的纹理坐标超出了[0,1]范围,这会导致纹理被平铺。

类似的,setFilter()方法用来定义纹理对象的最小与最大过滤器。与OpenGL中的相应函数对比,现在我们是否可以理解setWrap()与setFilter()方法的用法与显示?OpenGL在线文档与红宝书对于理解这些主题将会非常有帮助。

Handling rendering order

在开始解释如何在OSG中处理渲染顺序之前,我们最好理解什么是渲染顺序及其在OpenGL中如何作用。

OpenGL将顶点以及基元数据存储在各种缓冲区中,例如颜色缓冲区,深度缓冲区,模板缓冲区等。除了这些缓冲区之外,他不会记录以其他格式发送给他的顶点与三角形。所以OpenGL总是渲染新的几何体基元,而不会跟踪旧的,这就意味着这些基元以何种顺序被渲染是非常重要的。

借助于深度缓冲区,不透明的对象可以被正确渲染,而在简单情况下,这些对象的渲染顺序并没有关系,因为默认的深度测试会略过小于存储值的新来数据。

然而,当使用OpenGL混合机制时,例如,来实现透明与半透明效果,为了更新颜色缓冲区需要执行特殊的操作。不单是简单的覆盖,新像素与旧像素将会混合,同时考虑alpha值(总是颜色向量的第四个组成部分)或是其他元素。这就会导致渲染顺序将会影响最终结果的问题,如下图所示:

_images/osg_render_order.png

osg::StateSet类的setRenderingHint()方法将会通知OSG在需要时控制节点与可以绘制元素的渲染顺序。他只是简单的指示一个状态集是否为不透明或半透明,并会确保与透明状态关联的对象应在不透明对象之后渲染,而这些透明对象应依据每个对象的中心到人眼位置的距离而存储(也就是由远到近)。

为了指示一个节点或是可绘制元素是不透明的(实际上是默认情况),可以输入下面代码:

node->getOrCreateStateSet()->setRenderingHint(
    osg::StateSet::OPAQUE_BIN );

而对于透明的节点或可绘制元素则使用下面的代码:

node->getOrCreateStateSet()->setRenderingHint(
    osg::StateSet::TRANSPARENT_BIN );

Time for action - achieving the translucent effect

我们将要实现一个将模型作视作玻璃的半透明效果。其他的场景对象可以通过glass对象进行显示。这可以通过OpenGL的混合机制来实现,但是在这个示例中计算场景对象的正确渲染顺序将会非常重要。

  1. 包含必需的头文件:
#include <osg/BlendFunc>
#include <osg/Texture2D>
#include <osg/Geometry>
#include <osgDB/ReadFile>
#include <osgViewer/Viewer>
  1. 我们将会继续通过一个预定义的纹理坐标数组来使用四边形几何体。他应被看作一个半透明对象,并且应在稍后应用渲染属性与模式:
osg::ref_ptr<osg::Vec3Array> vertices = new osg::Vec3Array;
vertices->push_back( osg::Vec3(-0.5f, 0.0f,-0.5f) );
vertices->push_back( osg::Vec3( 0.5f, 0.0f,-0.5f) );
vertices->push_back( osg::Vec3( 0.5f, 0.0f, 0.5f) );
vertices->push_back( osg::Vec3(-0.5f, 0.0f, 0.5f) );
osg::ref_ptr<osg::Vec3Array> normals = new osg::Vec3Array;
normals->push_back( osg::Vec3(0.0f,-1.0f, 0.0f) );
osg::ref_ptr<osg::Vec2Array> texcoords = new osg::Vec2Array;
texcoords->push_back( osg::Vec2(0.0f, 0.0f) );
texcoords->push_back( osg::Vec2(0.0f, 1.0f) );
texcoords->push_back( osg::Vec2(1.0f, 1.0f) );
texcoords->push_back( osg::Vec2(1.0f, 0.0f) );
  1. 要小心设置四边形的颜色数组。要将其与其他的场景对象混合,在这里我们要将alpha部分设置小于1.0的值:
osg::ref_ptr<osg::Vec4Array> colors = new osg::Vec4Array;
colors->push_back( osg::Vec4(1.0f, 1.0f, 1.0f, 0.5f) );
  1. 再次创建四边形几何体:
osg::ref_ptr<osg::Geometry> quad = new osg::Geometry;
quad->setVertexArray( vertices.get() );
quad->setNormalArray( normals.get() );
quad->setNormalBinding( osg::Geometry::BIND_OVERALL );
quad->setColorArray( colors.get() );
quad->setColorBinding( osg::Geometry::BIND_OVERALL );
quad->setTexCoordArray( 0, texcoords.get() );
quad->addPrimitiveSet( new osg::DrawArrays(GL_QUADS, 0, 4) );
osg::ref_ptr<osg::Geode> geode = new osg::Geode;
geode->addDrawable( quad.get() );
  1. 如同我们在前面示例中所做的,将纹理应用到四边形:
osg::ref_ptr<osg::Texture2D> texture = new osg::Texture2D;
osg::ref_ptr<osg::Image> image =
    osgDB::readImageFile( "Images/lz.rgb" );
texture->setImage( image.get() );
  1. 使用osg::BlendFunc类来实现混合效果。其作用与OpenGL的glBlendFunc()完全相同:
osg::ref_ptr<osg::BlendFunc> blendFunc = new osg::BlendFunc;
blendFunc->setFunction( GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA );
  1. 向状态集合添加混合函数属性与纹理属性:
osg::StateSet* stateset = geode->getOrCreateStateSet();
stateset->setTextureAttributeAndModes( 0, texture.get() );
stateset->setAttributeAndModes( blendFunc );
  1. 现在我们来看一下场景是否被正确渲染。试着将几何体模型与一个载入的滑翔机模式添加到场景图中,并看一下会发生什么。
osg::ref_ptr<osg::Group> root = new osg::Group;
root->addChild( geode.get() );
root->addChild( osgDB::readNodeFile("glider.osg") );
osgViewer::Viewer viewer;
viewer.setSceneData( root.get() );
return viewer.run();
  1. 与前面的示例相比,现在的四边形当然是半透明的。然而,在场景视图中有一些不合理的地方。被四边形切割的滑翔机,其中的一个机翼在半透明面之下。这是由于滑翔机与四边形错误的渲染顺序所致。由于OpenGL中的深度测试,后者的渲染并不正确:
_images/osg_render_order_glider.png
  1. 我们是否还记得解决该问题的setRenderingHint()方法?现在让我们在第7步中添加一行来指示四边形是透明的,并使得OSG以正确的顺序存储与渲染:
stateset->setRenderingHint( osg::StateSet::TRANSPARENT_BIN );
  1. 现在一切正常了:
_images/osg_render_order_glider2.png

What just happened?

在绘制遍历中,GL_SRC_ALPHA与GL_ONE_MINUS_SRC_ALPHA枚举会使用下面的等式来确定混合颜色:

R = srcR * srcA + dstR * (1 - srcA)
G = srcG * srcA + dstG * (1 - srcA)
B = srcB * srcA + dstB * (1 - srcA)

这里,[srcR,srcG,srcB]是要渲染的半透明四边形的颜色值,而[dstR,dstG,dstB]是要覆盖的屏幕颜色值,实际上现在是由不透明的滑翔机模型填充的。最终的颜色[R,G,B]是根据不透明颜色向量的alpha部分srcA来计算的,因而与新颜色值和前一个颜色混合来生成半透明效果。

setRenderingHint()方法可以很好的控制渲染顺序,但是大量使用效率并不高。通过每一帧的深度存储所有的透明对象需要更多的系统时间,而且如果有大量的数据需要存储则会导致大量的资源计算。开发者需要随时保持平衡。

Understanding graphics shaders

OpenGL shading language(GLSL)最初是作为OpenGL 1.4的扩展而引入的,以允许顶点与帧级别渲染管线中的可编程性。现在GLSL通常被包含在OpenGL 2.0中,为开发者提供开发图像阴影器(图像软件指令块)的功能来计算更为真实的渲染效果,而不是仅仅使用固定功能状态。

在本书中详细介绍GLSL及其在OpenGL中的实现是不可能的。然而,如果我们有兴趣设计不同的阴影器并将其应用到场景图中时,有一些可以遵循的步骤。

首先,编写我们自己的阴影器,类似于C程序。这些被看作字符串集合被传递给硬件,所以仅是按需创建或是由文本文件读取。

我们所指定的也许不会超过在OpenGL管线中所处理的一个顶点阴影器,一个几何体阴影器以及一个帧阴影器(每一步仅有一个main()函数)。这些将会完全替换固定功能,例如雾,光以及纹理映射,这些需要在我们的阴影器源码中重新实现。

阴影器需要OpenGL API来编译并执行他们。顶点阴影器可以为每一个顶点应用变换;帧阴影器计算来自光栅的单个像素的颜色;而几何体阴影器会由已存在的顶点与基元数据重新生成几何体。

OSG使用osg::Shader类来定义包含源码字符串的阴影器对象。setShaderSource()方法被用来由std::string变量指定源码,而loadShaderSourceFromFile()方法将会由磁盘读取源码文件。除此之外,开发者可以由已存在的字符串vertText直接构建阴影器对象,如下所示:

osg::ref_ptr<osg::Shader> vertShader =
    new osg::Shader( osg::Shader::VERTEX, vertText );

输入参数osg::Shader::VERTEX表示顶点阴影器。我们也可以使用枚举GEOMETRY或是FRAGMENT来代替,以指定几何体阴影器或帧阴影器。例如:

osg::ref_ptr<osg::Shader> fragShader = new osg::Shader(
osg::Shader::FRAGMENT, fragText );

osg::ref_ptr<osg::Shader> geomShader = new osg::Shader(
    osg::Shader::GEOMETRY );
geomShader->loadShaderSourceFromFile( "source.geom" );

在这里我们假定文件source.geom已被载入并包含我们的几何体阴影器。

osgDB::readShaderFile()函数也许更适合由文件读取,因为他会依据文件扩展名(.vert,.frag或是.geom)自动检测阴影器类型。他会返回一个完全形成的正确类型与数据的osg::Shader实例,例如:

osg::Shader* fragShader =  osgDB::readShaderFile("source.frag");

在所有的阴影器被设置并准备好使用以后,我们可以使用osg::Program类与addShader()方法来包含该阴影器并将GLSL渲染属性与模式设置到状态集。在阴影器起效果之后,大多数其他的固定功能状态将会变得不可用,包括光,材质,雾,纹理映射,纹理坐标生成以及纹理环境。

下面的代码片段将所有上面的阴影器添加到一个osg::Program对象并将其关联到一个已存在节点的状态集:

osg::ref_ptr<osg::Program> program = new osg::Program;
program->addShader( vertShader.get() );
program->addShader( fragShader.get() );
program->addShader( geomShader.get() );
node->getOrCreateStateSet()->setAttributeAndModes( program.get() );

Using uniforms

在一个典型的阴影器中有三种类型的输入与输出:uniforms,vertex attributes与varyings。uniforms与vertex attributes在阴影器执行过程中是只读的,但是可以由宿主OpenGL或OSG程序所设置。他们实际上是用于阴影器与用户程序之间交互的全局GLSL变量。

varyings用于由一个阴影器向另一个阴影器传递数据。他们对于外部程序是不可见的。

OSG使用osg::Uniform类来定义GLSL uniform变量。其构造函数有一个名字与一个初始化值参数,该参数必须与阴影器源码中的定义相匹配,例如:

float length = 1.0f;
osg::ref_ptr<osg::Uniform> uniform =
    new osg::Uniform( "length", length );

我们也许会将该uniform对象添加一个状态集,该状态集已经通过使用addUniform()方法与一个osg::Program对象相关联:

stateset->addUniform( uniform.get() );

同时,在阴影器源码中必有一个变量定义,例如:

uniform float length;

否则,uniform变量在OSG程序或阴影器中会不可用。

uniform可以是任意的基本类型,或是组合类型,例如布尔型,浮点型,整型,2D/3D/4D向量,矩阵以及各处纹理取样器。osg::Uniform类通过构造函数与set()方法接受所有的基本类型。同时他还提供了更多的数据类型,例如osg::Matrix2与osg::Matrix3来2x2与3x3矩阵。为了绑定纹理取样器,用在阴影器中表示特定的纹理,osg::Uniform对象唯一的工作就是通过使用unsigned int值指定纹理映射单位,例如:

osg::ref_ptr<osg::Uniform> uniform = new osg::Uniform(
  "texture", 0 );

当然,我们应已经使得osg::Texture对象的单位为0,同时在阴影器源中定义了取样器uniform:

uniform sampler2D texture;

在这里我们假定2D纹理将会用来改变阴影器的执行行为。

Time for action - implementing a cartoon cow

卡通阴影是一种非常简单的在色调之间突然发生变化的非真实效果。要实现一个卡通阴影器,我们只需要将顶点传递给顶点阴影器中内建的gl_Position变量,然后使用帧阴影器中的法线与光线方法进行计算并选择。之后,我们将其应用到一个载入的模型之上,例如,一个漂亮的牛上。

  1. 包含必需的头文件:
#include <osg/Program>
#include <osgDB/ReadFile>
#include <osgViewer/Viewer>
  1. 我们将会使用字符串来编写顶点阴影器源。除了设置gl_Position,他会将一个法线变化变量传递给帧阴影器:
static const char* vertSource = {
    "varying vec3 normal;\n"
    "void main()\n"
    "{\n"
    "    normal = normalize(gl_NormalMatrix * gl_Normal);\n"
    "    gl_Position = ftransform();\n"
    "}\n"
};
  1. 帧阴影器使用四个color uniforms来表示卡通阴影中的色调。他会依据点积(dot product)的几何解释计算法线变化与光位置之间的余弦角。注意,当使用阴影器时固定功能光状态会失去其效果,但是光属性依然可用,并可以由内建的GLSL uniforms中读取:
static const char* fragSource = {
    "uniform vec4 color1;\n"
    "uniform vec4 color2;\n"
    "uniform vec4 color3;\n"
    "uniform vec4 color4;\n"
    "varying vec3 normal;\n"
    "void main()\n"
    "{\n"
    "    float intensity = dot(vec3(gl_LightSource[0].position), normal);\n"
    "    if (intensity > 0.95) gl_FragColor = color1;\n"
    "    else if (intensity > 0.5) gl_FragColor = color2;\n"
    "    else if (intensity > 0.25) gl_FragColor = color3;\n"
    "    else gl_FragColor = color4;\n"
    "}\n"
};
  1. 我们将创建两个阴影器对象并将其添加到程序属性:
osg::ref_ptr<osg::Shader> vertShader =
    new osg::Shader( osg::Shader::VERTEX, vertSource );
osg::ref_ptr<osg::Shader> fragShader =
    new osg::Shader( osg::Shader::FRAGMENT, fragSource );
osg::ref_ptr<osg::Program> program = new osg::Program;
program->addShader( vertShader.get() );
program->addShader( fragShader.get() );
  1. 读取cow模型,并将属性与模式应用到状态集合。在用户程序中要定义四个uniform变量,所以在这里为了将值绑定到uniforms,我们需要四次使用addUniform()方法:
osg::ref_ptr<osg::Node> model = osgDB::readNodeFile( "cow.osg" );
osg::StateSet* stateset = model->getOrCreateStateSet();
stateset->setAttributeAndModes( program.get() );
stateset->addUniform(
    new osg::Uniform("color1", osg::Vec4(
      1.0f, 0.5f, 0.5f, 1.0f)) );
stateset->addUniform(
    new osg::Uniform("color2", osg::Vec4(
      0.5f, 0.2f, 0.2f, 1.0f)) );
stateset->addUniform(
    new osg::Uniform("color3", osg::Vec4(
      0.2f, 0.1f, 0.1f, 1.0f)) );
stateset->addUniform(
    new osg::Uniform("color4", osg::Vec4(
      0.1f, 0.05f, 0.05f, 1.0f)) );
  1. 全部完成!现在启动查看器:
osgViewer::Viewer viewer;
viewer.setSceneData( model.get() );
return viewer.run();
  1. 这次我们将会看到一个完全不同的cow模型。看起来是由一个孩子或是喜剧艺术家绘制的。这种技术广泛应用于计算机游戏与动画电影中:
_images/osg_shader.png

What just happened?

卡通阴影的基本算法是:如果我们有一个复制到光线方向的法线,则使用最明亮的解调(color1)。当面法线与光线之间的夹角增加时,则会使用一系列的暗解调(color2,color3与color4),事实上为所选择的解调提供了强度值。

阴影器源码是由下列网站http://www.lighthouse3d.com 的一个很好的GLSL tutorial修改而来的。

所有四个色调被声明为帧阴影器中的uniform 4D向量,并作为用户程序中的osg::Vec4变量被传递给osg::Uniform对象。

Pop quiz - replacements of built-in uniforms

osg::Geometry类使用stVertexAttribute()与setVertexAttribBinding()方法将顶点属性绑定阴影器,要为这些方法提供每一个顶点。GLSL内建的顶点属性包括gl_Position,gl_Normal与gl_MultiTexCoord*变量。然后,我们也可以指定我们自己的顶点属性,例如正切或顶点权重。

试着在顶点阴影器中声明一个属性,并使用osg::Geometry的顶点属性数组。另一个我们需要执行的重要任务就是借助于osg::Program的addBindAttribLocation()方法绑定外部属性数组与GLSL属性。他有一个name与index参数,前一个指示阴影器源码中的属性名,而后一个要对应于setVertexAttribArray()的输入索引值。

Working with the geometry shader

几何体阴影器包含在OpenGL 3.2核心中,并以低版本被用作扩展,GL_EXT_geometry_shader4,其应在阴影器源码中被声明。

几何体阴影器引入了一些新的连接基元,可以被用作osg::PrimitiveSet派生类的参数。他同时要求设置更多的参数来操纵阴影器操作,包括:

  1. GL_GEOMETRY_VERTICES_OUT_EXT:阴影器将会发射的顶点数目
  2. GL_GEOMETRY_INPUT_TYPE_EXT:发送到阴影器的基元类型
  3. GL_GEOMETRY_OUTPUT_TYPE_EXT:由阴影器发射的基元类型

osg::Program类使用setParameter()方法为这些参数设置值。例如,要表示将由阴影器发射100个顶点到渲染管线中的组合处理器基元,我们可以使用:

program->setParameter( GL_GEOMETRY_VERTICES_OUT_EXT, 100 );

Time for action - generating a Bezier curve

OpenGL在几年前就提供了用于生成Bezier与NURBS曲线与面的函数,但是他们的表现并不像我们所希望的那样好。今天的几何体阴影器可以一种更为使得与高效的方法完成相同的工作。以Cubic Bezier曲线的生成为例。给定两个端点,以及阴影器的两个控制点,则他会使用起始与结束于两个不同的端点,并且朝向控制点的特定段来生成一条光滑的曲线。

  1. 包含必需的头文件。我们需要修改输出线的宽度,所以在这里我们使用osg::LineWith类:
#include <osg/Program>
#include <osg/LineWidth>
#include <osgDB/ReadFile>
#include <osgViewer/Viewer>
  1. 顶点阴影器总是需要的。但这次他仅是将顶点传输给后续的阴影器:
static const char* vertSource = {
    "#version 120\n"
    "#extension GL_EXT_geometry_shader4 : enable\n"
    "void main()\n"
    "{ gl_Position = ftransform(); }\n"
};
  1. 几何体阴影器源是这个示例中的关键。他由内建的gl_Position变量读取端点与控制点,重新组合,并使用EmitVertex()函数发射新顶点。uniform变量segments被用来控制生成曲线的平滑:
static const char* geomSource = {
    "#version 120\n"
    "#extension GL_EXT_geometry_shader4 : enable\n"
    "uniform int segments;\n"
    "void main(void)\n"
    "{\n"
    "    float delta = 1.0 / float(segments);\n"
    "    vec4 v;\n"
    "    for ( int i=0; i<=segments; ++i )\n"
    "    {\n"
    "        float t = delta * float(i);\n"
    "        float t2 = t * t;\n"
    "        float one_minus_t = 1.0 - t;\n"
    "        float one_minus_t2 = one_minus_t * one_minus_t;\n"
    "        v = gl_PositionIn[0] * one_minus_t2 * one_minus_t +\n"
    "            gl_PositionIn[1] * 3.0 * t * one_minus_t2 +\n"
    "            gl_PositionIn[2] * 3.0 * t2 * one_minus_t +\n"
    "            gl_PositionIn[3] * t2 * t;\n"
    "        gl_Position = v;\n"
    "        EmitVertex();\n"
    "    }\n"
    "    EndPrimitive();\n"
    "}\n"
};
  1. 我们将会通过osg::Geometry类创建几何体阴影器的输入基元。他包含一个名为GL_LINES_ADJACENCY_EXT的新基元类型,给出了阴影器的gl_PositionIn变量的维度:
osg::ref_ptr<osg::Vec3Array> vertices = new osg::Vec3Array;
vertices->push_back( osg::Vec3(0.0f, 0.0f, 0.0f) );
vertices->push_back( osg::Vec3(1.0f, 1.0f, 1.0f) );
vertices->push_back( osg::Vec3(2.0f, 1.0f,-1.0f) );
vertices->push_back( osg::Vec3(3.0f, 0.0f, 0.0f) );
osg::ref_ptr<osg::Geometry> controlPoints = new osg::Geometry;
controlPoints->setVertexArray( vertices.get() );
controlPoints->addPrimitiveSet(
    new osg::DrawArrays(GL_LINES_ADJACENCY_EXT, 0, 4) );
osg::ref_ptr<osg::Geode> geode = new osg::Geode;
geode->addDrawable( controlPoints.get() );
  1. 我们要设置阴影器的参数。他有segments+1个要发射的顶点,接收GL_LINES_ADJACENCY_EXT类型,并输出所得到的曲线,如下面的代码所示:
int segments = 10;
osg::ref_ptr<osg::Program> program = new osg::Program;
program->addShader(
    new osg::Shader(osg::Shader::VERTEX, vertSource) );
program->addShader(
    new osg::Shader(osg::Shader::GEOMETRY, geomSource) );
program->setParameter( GL_GEOMETRY_VERTICES_OUT_EXT, segments+1 );
program->setParameter( GL_GEOMETRY_INPUT_TYPE_EXT,
                       GL_LINES_ADJACENCY_EXT );
program->setParameter( GL_GEOMETRY_OUTPUT_TYPE_EXT,
                       GL_LINE_STRIP );
  1. 默认线宽为1.0。设置线宽有助于我们分辨输出的曲线:
osg::ref_ptr<osg::LineWidth> lineWidth = new osg::LineWidth;
lineWidth->setWidth( 2.0f );
  1. 向状态集合设置所有的渲染属性,并且不要忘记向阴影的使用添加uniform:
osg::StateSet* stateset = geode->getOrCreateStateSet();
stateset->setAttributeAndModes( program.get() );
stateset->setAttribute( lineWidth.get() );
stateset->setMode( GL_LIGHTING, osg::StateAttribute::OFF );
stateset->addUniform( new osg::Uniform("segments", segments) );
  1. 一切就绪。现在启动查看器:
osgViewer::Viewer viewer;
viewer.setSceneData( geode.get() );
return viewer.run();
  1. 我们会在看到屏幕中显示一条Bezier曲线。试着修改uniform segments的值。更大的值会使得曲线更为平滑与完整,但是却会占用更多的资源,从而导致更低的渲染效率。
_images/osg_geometry_shader.png

What just happened?

几何体阴影器定义了一个新的基元类型GL_LINE_STRIP_ADJACENCY_EXT,表示相连的点划线。第一个与最后一个顶点提供了相连信息,但是并不作为线段可见。所以,我们使用这两个额外的顶点作为Bezier曲线的端点,而其他的顶点作为控制点。这实际上就是我们由GLSL变量gl_PositionIn[0]到gl_PositionIn[3]所读取的。

Cubic Bezier曲线可以依据下面的等式计算获得:

P(t) = (1-t)^3 *P0 + 3*t*(1-t)^2*P1 + 3*t^2*(1-t)*P2 + t^3*P3

因子t可以设置为范围0到1之间的任何值。

Have a go hero - having fun with shaders

有些人认为阴影器是全能的,而有些人则不这样认为。但是不会有人否认阴影器使得我们的开发更为有趣。已经有一些使用图像阴影器的真实的海洋,大气,光,特征动画 等的成功实现。现在确实有一个使用阴影器替换固定功能管线的任务,从而为我们的程序提供大量的优势。

OSG提供了对阴影语言的完全支持,尽管这些是随着OpenGL4而引入的。他也通过名为osgNV的第三方工程支持NVIDIA Cg。等不及把玩阴影器了?除了我们自己的探索,还有一些很好的使用阴影器的开源工程,我们可以参考:

Summary

正如我们在本章中所了解到的,借助于osg::StateSet类以及osg::StateAttribute子类集合,在管线中的骨架准备好之后,然后,图像渲染可以被应用来添加光,纹理,碰撞映射,或是其他对象的可编程效果。在本章中,我们详细介绍了操作渲染状态与属性的技术,特别是通过使用固定功能管线与OpenGL shading language实现真实渲染效果的两种不同方法。

在本章中,我们特别探讨了:

  • 控制与继承存储在节点与可绘制元素的osg::StateSet对象中的渲染属性与模式。
  • 通过使用不同的OSG渲染状态类,例如osg::PolygonModel,osg::Fog与osg::BlendFunc,实现固定功能渲染效果。当渲染透明与半透明对象时要注意状态集合的渲染顺序。
  • 如何通过使用osg::LightSource节点创建并控制osg::Light。
  • 如何通过使用osg::Image类及相关联的osg::Texture子类,特别是管理与渲染2D纹理的osg::Texture2D类,来实现纹理映射。
  • 图像阴影器的概念及其实现者类:osg::Shader与osg::Program。

Chapter 7: Viewing the World

查看器对场景的观察是3D世界变换为2D图像的结果,这是由渲染引擎实时完成的。假定放置了一个虚拟相机来观察并记录3D及其动态变化,然后其移动,角度,聚焦距离变化以及不同的镜头类型将会改变渲染结果,而这正是我们改变在场景上所见内容的真正方法。

本章主要关注于:

  • 理解OpenGL中所定义的坐标系统的概念
  • 修改视图点与方向,投影与最终视口
  • 如果存在多个相机改变并控制渲染顺序
  • 如何创建单一与复合查看器
  • 如何管理全局显示设置并生成易于使用的立体可视效果
  • 如何将渲染场景用作纹理对象-所谓的渲染到纹理(RTT)

From world to screen

当在3D世界中绘制一个点,一条线或是一个复杂的多边形,我们的最终目标就是在平面上进行显示。也就是,我们将会表示的3D对象将会被转换为2D窗口中的像素集合。在此过程中,有三个主要的矩阵被用来确定不同坐标系统之间的变换。这三个矩阵通常被称为模型(model),视图(view)与投影矩阵。

模型矩阵被用来描述世界中对象的特定位置。他可以将顶点由对象的局部坐标系统转换为世界坐标系统。两个坐标系都是右手的。

接下来要通过使用视图矩阵将整个世界转换为视图空间。假定我们有一个相同放置在世界的中心位置;相机变换矩阵的反转被真正用作视图矩阵。在右手视图坐标系统中,OpenGL定义了相机总是位置原点(0,0,0)并且面向Z轴的负方向。所以,所以我们可以在相机的屏幕上表示世界。

注意,在OpenGL中并没有单独的模型矩阵与视图矩阵。然而,他定义了一个模型-视图矩阵来由对象的局部空间转换为视图空间,这是两个矩阵的组合。所以,要将局部空间中的顶点V转换为视图空间中的Ve,我们可以使用:

Ve = V * modelViewMatrix

接下来的重要工作是确定3D对象如何映射到屏幕上(透视或是直角),并计算对象被渲染的截面。投影矩阵被用来使用六个切面面板:左,右,下,上,近,远,在世界坐标系统中指定截面。OpenGL同时提供了一个额外的gluPerspective()函数来确定具有相机镜头参数的视图域。

所得到的结果坐标系统(被称为法线设备坐标系统,normalized device coordinate system)的范围在每个坐标轴上由-1到+1,并且现在为变为左手系。作为最后一步,我们将所有的结果数据投影到视口(窗口),定义最终图像映射所在的矩形范围,以及窗口坐标的z值。在之后,3D场景被渲染到我们2D屏幕上的一个矩形区域中。最后,屏幕坐标Vs可以通过使用所谓的MVPW矩阵表示3D世界中的局部顶点V,也就是:

Vs = V * modelViewMatrix * projectionMatrix * windowMatrix

Vs依然是一个表示具有深度值的2D像素位置。

通过反转这一映射过程,我们可以由一个2D屏幕点(Xs,Ys)获得一条3D空间的线。这是因为2D点实际上可以被看作两个点:一个位于近切面板(Zs=0)上,而另一个位于远帖面板(Zs=1)上。

这里MVPW的转置矩阵被用来获取“非投影”工作的结果:

V0 = (Xs, Ys, 0) * invMVPW
V1 = (Xs, Ys, 1) * invMVPW

The Camera class

OpenGL开发者经常使用glTranslate()与glRotate()来移动场景,使用glLookAt()来移动相机,尽管他们都可以使用glMultMatrix()函数来代替。事实上,这些函数实际上做的是相同的事情-为由世界空间到视图空间的变换数据计算模型-视图矩阵。类似的,OSG提供了osg::Transform类,当放置在场景图中时,该类可以为当前模型-视图矩阵添加或是设置其自己的矩阵,但是我们通常使用osg::MatrixTransform与osg::PositionAttitudeTransform子类在模型矩阵上进行操作,并使用osg::Camera子类处理视图矩阵。

osg::Camera类是核心OSG库中最重要的类之一。他可以用场景图的组合节点,但是他不仅仅是一个普通的节点。其主要功能可以划分为四类:

首先,osg::Camera类处理视图矩阵,投影矩阵以及视口,这会影响其所有子节点并将其投影到屏幕上。相关的方法包括:

  1. 公共方法setViewMatrix()与setViewMatrixAsLookAt()通过使用osg::Matrix变量或是典型的眼/中心/上(eye/center/up)变量设置视图矩阵。
  2. 公共方法setProjectionMatrix()接受osg::Matrix参数以指定投影矩阵。
  3. 其他一些方便方法,包括setProjectionMatrixAsFrustum(),setProjectionMatrixAsOrtho(),setProjectionMatrixAsOrtho2D()与setProjectionMatrixAsPerspective(),用来使用不同的截面参数设置透视或直角投影矩阵。他们的作用类似于OpenGL投影函数(glOrtho(),gluPerspective()等)。
  4. 公共方法setViewPort()可以使用osg::Viewport对象定义矩形窗口。

下面的代码片段显示了如何设置相机节点的视图与投影矩阵,并将其视口设置为(x,y)-(x+w,y+h):

camera->setViewMatrix( viewMatrix );
camera->setProjectionMatrix( projectionMatrix );
camera->setViewport( new osg::Viewport(x, y, w, h) );

我们可以随时通过相应的get*()方法获取osg::Camera对象的当前视图与投影矩阵以及视口。例如:

osg::Matrix viewMatrix = camera->getViewMatrix();

为了获取视图矩阵的位置与方向,可以使用下面代码:

osg::Vec3 eye, center, up;
camera->getViewMatrixAsLookAt( eye, center, up );

其次,osg::Camera封装了OpenGL函数,例如glClear(),glClearColor()与glClearDepth(),并清空帧缓冲区,以及在当在每一帧中重新将场景绘制到屏幕时预先设置其值。主要方法包括:

  1. setClearMas()方法设置要清空 的缓冲区。默认为GL_COLOR_BUFFER_BIT|GL_DEPTH_BUFFER_BIT。
  2. setClearColor()方法通过使用osg::Vec4变量以RGBA格式设置清理颜色。

类似的,还有setClearDepth(),setClearStencil()与setClearAccum()方法,以及由相机获取设置值的get*()方法。

第三类包含与相机相关联的OpenGL图像环境管理。我们将会在第9章进行探讨。

最后,相机可以将一个纹理对象关联到内部缓冲区组件(颜色缓冲区,深度缓冲区等),并在该纹理内直接渲染子场景图。然后所得到的纹理可以映射到其他场景的表面上。这种技术被称为渲染器到纹理或纹理烘焙,在本章稍后将会介绍。

Rendering order of cameras

在任意场景图中至少有一个主相机节点。他是由osgViewer::Viewer类创建并管理的,并且可以通过getCamera()方法读取。在启动模拟之前,他会自动添加根节点作为其子节点。默认情况下,其他相机,无论是直接还是间接添加到根节点的,都会共享与主相机相关联的图像环境,并且会在相同的渲染窗口上依次绘制其子场景。

osg::Camera类提供了一个setRenderOrder()方法来精确控制相机的渲染顺序。他有一个顺序枚举以及一个可选的顺序数参数。第一个枚举可以是PRE_RENDER或POST_RENDER,指示通常的渲染顺序。第二个是一个整数用来以级联顺序对相同类型的相机排序。默认设置为0。

例如,下面的代码将会强制OSG首先渲染camera1,然后是camera2(具有较大的顺序数),在这两个相机与主相机完成之后是camera3:

camera1->setRenderOrder( osg::Camera::PRE_RENDER );
camera2->setRenderOrder( osg::Camera::PRE_RENDER, 5 );
camera3->setRenderOrder( osg::Camera::POST_RENDER );

如果一个相机首先被渲染(PRE_RENDER),其缓冲区中的渲染结果将会被清空并为下一个相机所覆盖,而查看也许不能看到其子场景。这对于渲染器到纹理的处理情况非常有用,因为我们希望子场景对于场景不可见,并且在开始主场景之前更新所关联的纹理对象。

另外,如果一个相机被稍后渲染(POST_RENDER),他也许会清除缓冲区中的当前颜色与深度值。我们可以通过使用较少的缓冲区掩码调用setClearMask()来避免该问题。一个典型示例就是即时显示(head-up display,HUD)的实现。

Time for action - creating an HUD camera

即时显示可以渲染数据而无需用户偏离其正常的视点。他被广泛用于3D场景中,用于显示重要的2D文本,计算机游戏数据以及战机与战场基础等。这次我们将会设计一个HUD相机,其中包含一个在任何时刻都要放在其他场景对象前面的模型。

  1. 包含必需的头文件:
#include <osg/Camera>
#include <osgDB/ReadFile>
#include <osgViewer/Viewer>
  1. 由磁盘文件中载入两个模型。lz.osg被用作演示地形,而glider.osg将会被放置在HUD相机之下。也就是,他对于观看查看器的用户总是可见的;而无论场景图的其他部分如何变化:
osg::ref_ptr<osg::Node> model = osgDB::readNodeFile("lz.osg");
osg::ref_ptr<osg::Node> hud_model = osgDB::readNodeFile("glider.osg");
  1. HUD相机及其子节点必须在常规的场景完成在屏幕上的绘制之后才被渲染。他会覆盖所有当前的像素数据,而不论其位置与深度。这也正是我们使用GL_DEPTH_BUFFER_BIT来清除深度缓冲区的原因。在这里并没有设置GL_COLOR_BUFFER_BIT,以确保颜色缓冲区被正确保存。
osg::ref_ptr<osg::Camera> camera = new osg::Camera;
camera->setClearMask( GL_DEPTH_BUFFER_BIT );
camera->setRenderOrder( osg::Camera::POST_RENDER );
  1. HUD相机不应被查看器或是其他的父节点所影响,所以他需要被变化到绝对引用帧,并且被设置为一个自定义固定的视图矩阵。滑翔机模式也被添加到相机节点,以作为要显示的内容:
camera->setReferenceFrame( osg::Camera::ABSOLUTE_RF );
camera->setViewMatrixAsLookAt(
    osg::Vec3(0.0f,-5.0f,5.0f), osg::Vec3(),
osg::Vec3(0.0f,1.0f,1.0f)
);
camera->addChild( hud_model.get() );
  1. 我们向根节点添加HUD相机以及一个常规的载入模型:
osg::ref_ptr<osg::Group> root = new osg::Group;
root->addChild( model.get() );
root->addChild( camera.get() );
  1. 现在,像通常一样启动查看器:
osgViewer::Viewer viewer;
viewer.setSceneData( root.get() );
return viewer.run();
  1. 我们将会看到演示地形(常规场景)的渲染以及在用户控制下的操作。然而,滑翔机(后渲染的场景)总是位于所有其他场景对象之上,而其位置与方向不会受到鼠标或键盘输入的影响。
_images/osg_camera.png

What just happened?

我们创建了一个包含滑翔机模型并作为子场景图进行渲染的额外相机。渲染顺序被设置为POST_RENDER,也就是,该相机将会在主相机已完成其场景渲染之后出现。换句话说,他总会在主相机的渲染结果的上面绘制其子场景图(颜色缓冲区与深度缓冲区)。

额外相机的目的就是实现一个覆盖主场景的HUD场景。他清除了深度缓冲区来确保该相机所绘制的所有像素数据可以通过深度测试。然而,颜色缓冲区并没有被清除,保存屏幕上主场景未覆盖的像素数据。这也正是我们进行如下设置的原因:

camera->setClearMask(GL_DEPTH_BUFFER_BIT);  // No color buffer bit

Pop quiz - changing model positions in the HUD camera

我们刚才所创建的HUD相机使用其自己的视图矩阵来配置其视图坐标中的子场景图位置与朝向,但是他并没有预设置的投影矩阵。我们是否知道其实际是什么吗?我们是否了解如何HUD相机的投影矩阵使用滑翔机模型填充整个场景?而我们如何显示一个upside-down的模型?

Using a single viewer

OSG支持单一查看器类osgViewer::Viewer来保存单个场景图的视图。他使用setSceneData()方法来管理场景图的根节点,并使用run()来启动模拟循环,这样场景就会被一次次渲染。从而帧缓冲区被每一个渲染循环的结果所更新,从而被称为一帧。

除了这些,查看器同时还包含一个osg::Camera对象作为主相机,我们已经在前面进行了讨论。相机的视图矩阵是由查看器的内部osgGA::CameraManipulator对象进行控制的。同时,用户输入事件也由查看器通过osgGA::GUIEventHandler处理器接收并处理。查看器甚至可以被设置为全屏幕模式,在一个窗口,或是在球形显示上。我们将会在本章以及接下来的章节中逐步开始解释这些概念。

Digging into the simulation loop

由run()方法所定义的模拟循环总是要执行三种类型的任务:指定主相机的操作吕在,设置相关联的图像环境,并在循环中渲染帧。

操作器可以读取键盘与鼠标事件,并相应的调整主相机的视图矩阵来浏览场景图。他是通过使用setCameraManipulator()方法来设置的,其参数必须是一个osgGA::CameraManipulator()子类。例如:

viewer.setCameraManipulator( new osgGA::TrackballManipulator );

这会向查看器对象添加一个具有自由行为的经典轨迹球操作器。因为在查看器内部相机操作器是被作为智能指针保存的,我们可以在任何时候使用setCameraManipulator()方法赋值一个新的操作器。在osgGA名字空间中定义的一些内建操作器可以在下表中找到:

_images/osg_manipulators.png

这里要小心的是,要声明并使用一个操作器,我们必须添加osgGA库作为我们的工程依赖。这可以通过我们的工程属性或是通过使用CMake脚本来实现。

查看器的图像环境以及可能的线程与资源都是在realize()方法被初始化的。他会在第一帧被渲染之前被自动调用。

在这之后,查看器进入循环。每次他使用frame()方法来渲染一帧,并且检测渲染过程是否应使用done()方法停止并退出。该过程可以通过下面几行代码进行描述:

while ( !viewer.done() )
{
    viewer.frame();
}

这是查看器类所使用的默认渲染模式。如果图形卡的vsync选项被打开,帧速率会与监视器的刷新速率同步以避免浪费系统资源。但是OSG支持另一种按需渲染模式。如下配置查看器变量:

viewer.setRunFrameScheme( osgViewer::Viewer::ON_DEMAND );

现在,frame()方法将只会在场景图修改,更新或是用户输入事件时才会被调用,直到模式被改回默认值CONTINUOUS。

另外,osgViewer::Viewer类同时包含一个使用帧速率数作为参数的setRunMaxFrameRate()方法。这会设置一个最大帧速率来控制查看器运行以强制渲染帧而无需太多的假定。

Time for action - customizing the simulation loop

我们已经非常熟悉了osgViewer::Viewer类的run()方法。他被多次用于启动一个默认模拟循环来将场景图载入到查看器并在每一帧上执行更新,裁剪与绘制遍历。

但是run()方法实际上做了什么呢?是否能够添加某些帧前事件或帧后事件以用于特定目的呢?在这个示例中,我们将会使用C++ while语句来自定义模拟循环,同时显示每一个帧执行之后的帧数。

注意,自定义的模拟循环并不会由即时渲染机制(on-demand rendering scheme)与最大帧速率设置中获益。只有在使用run()方法时他们才可用。

  1. 包含必需的头文件:
#include <osgDB/ReadFile>
#include <osgGA/TrackballManipulator>
#include <osgViewer/Viewer>
#include <iostream>
  1. 载入模型并将其设置为查看器的场景数据:
osg::ref_ptr<osg::Node> model = osgDB::readNodeFile( "lz.osg" );
osgViewer::Viewer viewer;
viewer.setSceneData( model.get() );
  1. 我们需要为查看器设置操作器;否则,我们不能够浏览场景,包括缩放,旋转等控制操作。在这里,向查看器设置一个新的跟踪球操作器。他允许用户点击并拖拽屏幕上某一点,使得对象跟随其旋转。osgGA::TrackballManipulator是run()方法内部所用的默认操作器:
viewer.setCameraManipulator( new osgGA::TrackballManipulator );
  1. 然后我们在while循环中运行查看器。其条件会每次通过done()方法进行测试以检测查看器是否完成。循环体包含frame()方法,该方法会执行一帧来更新,裁剪并渲染场景图,而std::cout语句输出当前的帧号:
while ( !viewer.done() )
{
    viewer.frame();
    std::cout << "Frame number: " <<
        viewer.getFrameStamp()->getFrameNumber() << std::endl;
}
return 0;
  1. 启动查看器并查看控制台输出。我们将会在每一帧执行之后看到一个指示帧号的字符串列表。除此之外,run()方法与自定义的模拟循环之间并没有区别。
_images/osg_viewer.png

What just happened?

在这里我们提出了帧前与帧后事件的概念,并且简单的认为他们会在frame()方法之前与之后确定执行。这种定义实际上是不精确的。

OSG使用多线程来管理不同相机的用户更新,裁剪与绘制,特别在存在多个场景,多处理器与多图形设备的情况下。frame()方法只是启动一个新的更新/裁剪/绘制遍历任务,但是并不会处理线程同步。在这种情况下,frame()这前与之后的代码会被认为是不稳定与不安全的,因为在读取与写入场景图时,他们也许会与其他的处理线程冲突。所以,这里所描述的方法并不推荐用于未来的开发。在下一章中我们将会介绍一些常用的方法来动态修改场景数据。

另一个有趣的问题是viewer.run()方法会在何时返回?当然,开发者可以使用编程的方式通过查看器的setDone()方法设置done标记。OSG系统会检测当前图像上下文环境(例如,渲染窗口)是否已关闭,或者是否按下Esc按键,后者也会改变done标记。setKeyEventSetsDone()方法甚至可以设置哪一个按键将会担当该职责,是默认的Esc(或是设置为0来关闭该特性)。

Have a go hero - viewing in a non-full screen window

osgViewer::Viewr类可以进行快速设置以工作在非全屏模式下。默认全屏显示实际上是一个覆盖整个屏幕的窗口。要生成具有特定左上坐标,宽度与调蓄的窗口,setUpViewInWindow()方法将会非常合适。另一个选项是环境变量OSG_WINDOW,该变量可以像下面这样定义(在UNIX系统下,请使用export命令):

# set OSG_WINDOW=50 50 800 600

这可以有四个或五个参数:前四个是所创建窗口的左上坐标与尺寸,而最后一个定义了在多屏幕环境下的工作屏幕。默认屏幕标号0表示第一个屏幕被用来包含渲染窗口。如果我们多个计算机监视器,可以尝试使用其他的无符号整数。

除此之外,setUpViewOnSingleScreen()方法通过使用整数参数来在其他屏幕上设置全屏窗口。这是通过OSG中所支持的圆形显示来进行演示的。试着使用指定的参数使用setUpViewFor3DSphericalDisplay()方法。我们可以在API文档与osgViewer头文件中找到更多的信息。

Using a composite viewer

尽管osgViewer::Viewer类仅管理一个场景图上的一个视图,还有一个osgViewer::CompositeViewer类,支持多个视图与多个场景。该类具有相同的方法,例如run(),frame()与done(),来管理渲染过程,但是还支持通过使用addView()与removeView()方法添加与删除独立场景视图,以及通过使用getView()方法获取指定索引处的视图对象。这里的视图对象是由osgViewer::View类定义的。

osgViewer::View类是osgViewer::Viewer类的超类。他接受设置根节点作为场景数据,并添加相机操作器与事件处理器来使用用户事件。osgViewer::View与osgViewer::Viewer之间的区别在于前者不能被直接用作单个查看器,也就是,他没有run()或frame()方法。

要向复合查看器添加创建的view对象,可以使用下面的代码:

osgViewer::CompositeViewer multiviewer;
multiviewer.addView( view );

Time for action - rendering more scenes at one time

多查看器在表示复杂场景时非常有用,例如,使用主视图渲染宽广区域,以及一个鹰眼区域,或是显示相同场景的前,侧,上及透视图。在这里我们将会创建三个单独的窗口,包含三个不同的模型,每一个都可以进行单独操作。

  1. 包含必需的头文件:
#include <osgDB/ReadFile>
#include <osgViewer/CompositeViewer>
  1. 我们设计一个函数来创建一个新的osgViewer::View对象并将其应用到已存在的节点。setUpViewInWindow()方法在这里被用来生成非全屏视图:
osgViewer::View* createView( int x, int y, int w, int h,
                             osg::Node* scene )
{
    osg::ref_ptr<osgViewer::View> view = new osgViewer::View;
    view->setSceneData( scene );
    view->setUpViewInWindow( x, y, w, h );
    return view.release();
}
  1. 接下来由磁盘文件读取三个模型。这些模型将会被添加到不同的视图并在不同的窗口中渲染:
osg::ref_ptr<osg::Node> model1 = osgDB::readNodeFile("cessna.osg");
osg::ref_ptr<osg::Node> model2 = osgDB::readNodeFile("cow.osg");
osg::ref_ptr<osg::Node> model3 = osgDB::readNodeFile("glider.osg");
  1. 在特定位置处的320x240窗口内创建三个视图:
osgViewer::View* view1 = createView(50, 50, 320, 240, model1);
osgViewer::View* view2 = createView(370, 50, 320, 240, model2);
osgViewer::View* view3 = createView(185, 310, 320, 240, model3);
  1. 组合查看器的使用非常容易理解:将所有的视图添加到该查看器,并向单个查看一样启动循环。当然,在这个示例中while循环也是有用:
osgViewer::CompositeViewer viewer;
viewer.addView( view1 );
viewer.addView( view2 );
viewer.addView( view3 );
return viewer.run();
  1. 现在我们有多个窗口,同时渲染多个场景。这些窗口中的每一个都可以通过点击右上角的关闭按钮来关闭。而我们也可以通过在键盘上按下Esc按键关闭所有的窗口并退出程序。
_images/osg_composite_view.png

What just happened?

在osgViewer::CompositeViewer与多个相机之间有一些类似之处。也可以创建三个osg::Camera节点,向其中添加不同的子场景,将其关联到不同的图形环境(渲染窗口),从而实现与前面的图像相同的效果。总之,每一个osgViewer::View对象有一个osg::Camera节点,该节点可以用来管理其子场景及其关联窗口。其实际作用类似于容器。

然而,osgViewer::View类也处理操作器与用户事件。所以,在一个组合查看器中,每一个osgViewer::View对象存有其自己的操作器与事件处理器(这会在第9章中进行讨论)。然而,相机集合很难与单独的用户输入进行交互。这也正是我们选择使用组合查看器与少量对象来表示多个场景的原因。

Have a go hero - different views of the same scene

在上面的示例中,我们向视图对象添加了三个不同的载入模型,因而渲染了不同的场景。然而,也可以将相同的根节点添加到所有视图。例如:

view1->setSceneData( root.get() );
view2->setSceneData( root.get() );
view3->setSceneData( root.get() );

毕竟,如果我们希望设计相同场景的前面,侧面,顶面,可以试着向每一个视图的主相机添加一个视图矩阵与一个投影矩阵,并确保操作器已被禁用,因为他会依据用户接口事件重置我们的矩阵配置:

view1->getCamera()->setViewMatrix();
view1->getCamera()->setProjectionMatrix();
view1->setCameraManipulator( NULL );  // Set the manipulator to null!
// Avoid using default manipulator, too!
view1->getCamera()->setAllowEventFocus( false );

这里,setAllowEventFocus()方示指示相机是否可以接受用户输入与事件。这会在第9章中进行讨论。

现在,当设计场景的前视图,侧视图与顶视图时,我们是否知道视图与投影矩阵应是什么?提醒大家,通过getBound()方法获取的根节点边界圆,非常有助于指定视图点与投影范围。

Pop quiz - another way to display the smae scene in different views

在一个或是多个视图中显示相同场景的另一个方法是使用osg::Camera节点。通过setViewport()方法设置到不同的区域,我们可以无重叠的在一个渲染窗口中安排相机视图。我们知道如何设计这样的场景图来实现该目的吗?

Changing global display settings

OSG管理一个为相机,查看器以及其他场景元素所需要全局显示设置集合。他通过osg::DisplaySettings类使用单例模式来声明所有这些元素的窗口的唯一实例。所以我们可以在程序中的任意获取显示设置实例:

osg::DisplaySettings* ds = osg::DisplaySettings::instance();

osg::DisplaySettings实例设置所有新创建的渲染设备所需要的属性,主要是渲染窗口的OpenGL图像环境。其特征包括:

  1. 使用setDoubleBuffer()方法设置双缓冲区或单缓冲。默认为打开。
  2. 使用setDepthBuffer()方法设置是否使用深度缓冲区。默认为打开。
  3. 通过一系列的方法,例如setMinimumNumAlphaBits()等,为OpenGL alpha缓冲区,stencil缓冲区与accumulation缓冲区设置位。默认全为0。
  4. 通过setNumMultiSmaples()方法设置使用多样本缓冲区以及样本数量。默认为0。
  5. 打开stereo渲染并配置stereo模式与eye映射参数。

在下面的章节中,我们将会了解到特定的特性结构,这些特性中的某些可以为不同的图像环境进行单独设置。然而,同时,我们将会首先关注于如何在场景查看器上使用全局显示设置。

Time for action - enabling global multisampling

多重采样是一种反走样技术类型。他无需执行更多的处理就可以改善最终的结果质量。为了实现多重采样光栅化,用户程序应该设置一个采样数。注意,并不是所有的图形卡都支持多重采样扩展,所以该示例在某些系统与平台上会失败。

  1. 包含必需的头文件:
#include <osgDB/ReadFile>
#include <osgViewer/Viewer>
  1. 设置多重采样数。依据特定的图形设备,可用值通常包括2,4与6:
osg::DisplaySettings::instance()->setNumMultiSamples( 4 );
  1. 载入模型并使用标准查看器进行渲染。由osg::DisplaySettings单例所管理的多重采样属性现在已经开始起作用了:
osg::ref_ptr<osg::Node> model = osgDB::readNodeFile("cessna.osg" );
osgViewer::Viewer viewer;
viewer.setSceneData( model.get() );
return viewer.run();
  1. Cessna模型推进器的近距离查看(没有应用setNumMultiSamples()方法)如下面的截图所示。我们可以很清楚的看到推进器边缘的走样错误:
_images/osg_multi_sample.png
  1. 现在多重采样明显降低了渲染模型的变形,并且依据全局显示设置属性提高了最终结果的光滑水平。这会影响当前程序中所创建的所有查看器:
_images/osg_multi_sample2.png

What just happened?

多重采样技术允许程序使用每个像素指定的采样数来创建帧缓冲区,其中包含必需的颜色,尝试以及裁剪信息。这需要更多的显卡内存但是会得到更好的渲染效果。在WGL(OpenGL的窗口界面到Win32的实现)中,这实际上是由两个像素格式属性来确定的:WGL_SAMPLE_BUFFERS_ARB与WGL_SAMPLES_ARB。

OSG有一个内部图形环境管理器osg::GraphicsContext。其子类osgViewer::GraphicsWindowWin32,管理Windows下的渲染窗口的配置与创建,会将这两个属性应用到封装的wglChoosePixelFormatARB()函数,并允许整个场景的多重采样。

osg::DisplaySettings的作用实际上类似于各种显示属性的默认值集合。如果没有某个特定对象的单独设置,则默认值会起作用;否则,osg::DisplaySettings实例不会起作用。

我们会在第9章中讨论用于创建图形环境的单独设置以及osg::GraphicsContext类。

Stereo visualization

我们已经体验到了立体3D电影与图像的魔力。一个很好的盒子就是James Cameron的Avatar,这为我们带来了超出想像的奇异世界。浮雕图像是表现立体可视化最简单与最流行的方法。其他实现包括NVIDIA的方块缓冲,水平或垂直分割,水平或垂直交织等。幸运的是,OSG支持大多数这些常见的立体技术,并且通过很少的命令就可以在查看器中立即实现其中的一个:

osg::DisplaySettings::instance()->setStereoMode( mode );
osg::DisplaySettings::instance()->setStereo( true );

方法setStereoMode()方法由枚举集合中选择一个立体模式,而setStereo()方法允许或禁止该模式。OSG中可用的立体模式有:ANAGLYPHIC,QUAD_BUFFER(NVIDIA的方块缓冲),HORIZONTAL_SPLIT,VERTICAL_SPLIT,HORIZONTAL_INTERLACE,VERTICAL_INTERLACE与CHECKERBOARD(位于DLP投影器上)。我们也可以使用LEFT_EYE或是RIGHT_EYE来指明屏幕被用于左眼还是右眼。

osg::DisplaySettings类还有其他一些额外方法来指示特定的立体参数,例如人眼分割。查看API文档与头文件可以了解更为详细的内容。

Time for action - rendering anaglyph stereo scenes

我们将会应用OSG内部的浮雕立体模式来实现简单快速的立体3D效果。在开始编程并渲染场景之前,我们必须准备一幅3D红/青眼镜观察效果:

_images/osg_3d_glass.png

在大多数情况下,眼镜的左眼是红色的,而右眼是青色的。这是最常用的浮雕效果,具有有限的颜色感知。

  1. 包含必需的头文件:
#include <osgDB/ReadFile>
#include <osgViewer/Viewer>
  1. 我们直接操作全局显示设置。需要完成三个步骤:将立体模式切换为ANAGLYPHIC,使用setEyeSeparation()方法设置合适的眼分离(由左眼到右眼的距离),允许立体可视化:
osg::DisplaySettings::instance()->setStereoMode(osg::DisplaySettings::ANAGLYPHIC );
osg::DisplaySettings::instance()->setEyeSeparation( 0.05f );
osg::DisplaySettings::instance()->setStereo( true );
  1. 然后,我们可以像平时一样构建并渲染我们的场景图。在这里我们将会使用Cessna模型作为一个简单的示例:
osg::ref_ptr<osg::Node> model = osgDB::readNodeFile("cessna.osg" );
osgViewer::Viewer viewer;
viewer.setSceneData( model.get() );
return viewer.run();
  1. 最终的结果与前面的示例完全不同。现在戴上眼镜来看一下是否有尝试感知:
_images/osg_stereo.png

What just happened?

在ANAGLYPHIC模式下,最终的渲染效果总是由两个颜色层构成,并有一个小的偏移来生成深度效果。眼镜的每只眼会看到略微不同的图片,而其组合则生成了一个立体图像,从而会被我们的大脑认为是三维场景。

OSG通过二次渲染机制支持浮雕立体模式。第一次使用红通道颜色掩码渲染左眼图像,第二次使用青通道渲染右眼图像。颜色掩码是由渲染属性osg::ColorMask来定义的。他很容易通过使用下面的代码应用到状态集合与可绘制元素:

osg::ref_ptr<osg::ColorMask> colorMask = new osg::ColorMask;
colorMask->setMask( true, true, true, true );
stateset->setAttribute( colorMask.get() );

立体模式通常会导致场景图被多次渲染,这会导致降低帧速率的副作用。

Rendering to textures

渲染到纹理技术允许开发者其于已渲染场景的子场景外观创建纹理。然后这些纹理可以通过纹理映射应用到接下来的场景图中。他们可以被用来随时创建完美的特殊效果,或是可以被存储用于后续的延时阴影,多次渲染以及其他的高级渲染算法。

要动态实现纹理应用,通常要遵循下面三个步骤:

  1. 为渲染纹理
  2. 将场景渲染到纹理
  3. 使用纹理

我们在应用纹理之前需要创建一个空的纹理对象。OSG可以通过指定其大小来创建一个空的osg::Texture对象。setTextureSize()方法定义了2D纹理的宽度与高度,以及3D纹理的深度参数。

将场景图渲染到一个新创建的纹理的关键是osg::Camera类的attach()方法。这会接受纹理对象作为参数,以及缓冲区组件参数,从而表明了帧缓冲区的哪一部分将会渲染到纹理。例如,要将一个相机的子场景的颜色缓冲区关联到纹理,我们可以使用:

camera->attach( osg::Camera::COLOR_BUFFER, texture.get() );

其他可用的缓冲区组件包括DEPTH_BUFFER,STENCIL_BUFFER与COLOR_BUFFER0到COLOR_BUFFER15(依据图形卡的多渲染目标输出)。

继续设置相机的合适视图与投影矩阵,一个满足纹理尺寸的视口,以及设置纹理作为节点或是可绘制元素的属性。纹理将会使用每一帧中的相机渲染结果进行更新,随着视图矩阵与投影矩阵的变化而动态变化。

注意,查看器的主相机不适合关联纹理。否则,实际的窗口就不会有任何输出,从而使得一片黑。当然,如果我们正在执行脱屏渲染并且不在乎任何可视化效果,我们可以忽略这一点。

Frame buffer, pixel buffer, and FBO

另一个要关注的焦点是如何使得已渲染的缓冲区图像转换为纹理对象。一个直接的方法是使用glReadPixels()方法来由帧缓冲区返回像素数据,并将结果应用到glTexImage*()方法。这很容易理解与使用,但总会将数据拷贝到纹理对象,这是极其慢的。

为了改善效率,glCopyTexSubImage()将会是更好的方法。然而,我们还可以优化该过程。直接将场景渲染到目标而不是缓冲区是一个好主意。为此有两种主要的解决方案:

  1. 像素缓冲区(简写为pbuffer)扩展可以使用像素格式描述符创建一个不可见的渲染缓冲区,这等同于一个窗口。他会在使用之后被销毁,就如同渲染窗口的操作一样。
  2. 帧缓冲区对象(简写为FBO),在节省存储空间方面有时会优于像素缓冲区,可以添加程序创建的帧缓冲区并向其重定渲染输出。他可以输出到一个纹理对象或是一个渲染缓冲区对象,后者只是一个简单的数据存储对象。

OSG支持使用不同的渲染目标实现:直接由帧缓冲区拷贝,像素缓冲区或FBO。他使用osg::Camera类的setRenderTArgetImplementation()方法来从中选择一个解决方案,例如:

camera->setRenderTargetImplementation( osg::Camera::FRAME_BUFFER );

这表明Camera的渲染结果将会使用glCopyTexSubImage()方法渲染到关联的纹理。事实上,这是所有相机节点的默认设置。

其他重要的实现包括PIXEL_BUFFER与FRAME_BUFFER_OJBECT。

Time for action - drawing aircrafts on a loaded terrain

在这一节中,我们将会整合我们在前面所学的内容来创建一个略微复杂的示例,该示例会使用osg::NodeVisitor实用程序标识场景图中的所有纹理对象,将其替换为新创建的共享纹理,并将新纹理绑定到渲染到纹理(render-to-texture)相机。我们希望纹理不仅显示一幅静态图像,从而自定义的模拟循环被用来在调用frame()方法之前使子场景图动起来。

  1. 包含必需的头文件:
#include <osg/Camera>
#include <osg/Texture2D>
#include <osgDB/ReadFile>
#include <osgGA/TrackballManipulator>
#include <osgViewer/Viewer>
  1. 第一个任务是查找被应用到载入模型的所有纹理。我们需要由osg::NodeVisitor基类派生一个FindTextureVisitor类。这会管理稍后被用于渲染到纹理操作的纹理对象。每次我们在场景图中找到一个已有的纹理,我们就使用所管理的纹理进行替换。该操作在replaceTexture()方法中实现:
class FindTextureVisitor : public osg::NodeVisitor
{
public:
    FindTextureVisitor( osg::Texture* tex ) : _texture(tex)
    {
        setTraversalMode(
    osg::NodeVisitor::TRAVERSE_ALL_CHILDREN );
    }
    virtual void apply( osg::Node& node );
    virtual void apply( osg::Geode& geode );
    void replaceTexture( osg::StateSet* ss );
protected:
    osg::ref_ptr<osg::Texture> _texture;
};
  1. 在apply()方法中,在每一个节点与可绘制元素上调用replaceTexture()来检测是否有存储的纹理。不要忘记在每个方法体最后调用traverse()来在场景图中继续:
void FindTextureVisitor::apply( osg::Node& node )
{
    replaceTexture( node.getStateSet() );
    traverse( node );
}
void FindTextureVisitor::apply( osg::Geode& geode )
{
    replaceTexture( geode.getStateSet() );
    for ( unsigned int i=0; i<geode.getNumDrawables(); ++i )
    {
        replaceTexture( geode.getDrawable(i)->getStateSet() );
    }
    traverse( geode );
}
  1. 该用户方法使用getTextureAttribute()由输入状态集合获取单位为0的纹理,并使用所管理的纹理进行替换。因为状态集合是由节点或可绘制元素的getStateSet()方法获取的,而不是由一定会返回已有状态集合或是新创建状态集合的getOrCreateStateSet()方法获取的,这里的输入指针有可能为空:
void replaceTexture( osg::StateSet* ss )
{
    if ( ss )
    {
        osg::Texture* oldTexture = dynamic_cast<osg::Texture*>(
           ss->getTextureAttribute(0,osg::StateAttribute::TEXTURE)
        );
        if ( oldTexture ) ss->setTextureAttribute(
    0,_texture.get() );
    }
}
  1. 载入两个模型作为场景图。lz.osg模型被作为主场景,而滑翔机被看作是将要渲染到纹理的子场景图,并绘制在主场景中的模型的表面上:
osg::ref_ptr<osg::Node> model = osgDB::readNodeFile("lz.osg");
osg::ref_ptr<osg::Node> sub_model = osgDB::readNodeFile("glider.osg");
  1. 创建一个新的纹理对象。这不同于前面所创建的2D纹理并将图像应用于其中的示例。这次我们应指定纹理大小,内部格式以及其他属性:
int tex_width = 1024, tex_height = 1024;
osg::ref_ptr<osg::Texture2D> texture = new osg::Texture2D;
texture->setTextureSize( tex_width, tex_height );
texture->setInternalFormat( GL_RGBA );
texture->setFilter( osg::Texture2D::MIN_FILTER, osg::Texture2D::LINEAR );
texture->setFilter( osg::Texture2D::MAG_FILTER, osg::Texture2D::LINEAR );
  1. 使用FindTextureVisitor来定位lz.osg模型中的所有纹理,并使用新的空白纹理对象进行替换:
FindTextureVisitor ftv( texture.get() );
if ( model.valid() ) model->accept( ftv );
  1. 现在是创建渲染到纹理相机的时候了。我们设置其具有与所指定的纹理大小相同的视口,并在开始渲染子场景之时清除背景颜色与缓冲区:
osg::ref_ptr<osg::Camera> camera = new osg::Camera;
camera->setViewport( 0, 0, tex_width, tex_height );
camera->setClearColor( osg::Vec4(1.0f, 1.0f, 1.0f, 0.0f) );
camera->setClearMask( GL_COLOR_BUFFER_BIT|GL_DEPTH_BUFFER_BIT );
  1. 强制相机在主场景之前渲染,并且使用高效的FBO来实现渲染到纹理技术。这个示例中的关键语句是将颜色缓冲区与纹理对象绑定的语句,这会引起纹理对象的持续更新,一次次重新绘制子场景图:
camera->setRenderOrder( osg::Camera::PRE_RENDER );
camera->setRenderTargetImplementation(osg::Camera::FRAME_BUFFER_OBJECT );
camera->attach( osg::Camera::COLOR_BUFFER, texture.get() );
  1. 设置相机到绝对位置,并将载入的滑翔机模型设置到其子场景图:
camera->setReferenceFrame( osg::Camera::ABSOLUTE_RF );
camera->addChild( sub_model.get() );
  1. 初始化查看器并向其设置一个默认操作器:
osgViewer::Viewer viewer;
viewer.setSceneData( root.get() );
viewer.setCameraManipulator( new osgGA::TrackballManipulator );
  1. 最后一步是设置滑翔机动画。我们还没有学习任何OSG中的动画功能,但是我们已经知道模拟循环可以进行自定义来添加某些帧前与帧后事件。我们将会在每一帧中简单修改渲染到纹理相机的视图矩阵,就如同使得滑翔机滑翔一样。这是通过修改查看视图矩阵的上方面来完成的:
float delta = 0.1f, bias = 0.0f;
osg::Vec3 eye(0.0f,-5.0f, 5.0f);
while ( !viewer.done() )
{
    if ( bias<-1.0f ) delta = 0.1f;
    else if ( bias>1.0f ) delta = -0.1f;
    bias += delta;
    camera->setViewMatrixAsLookAt( eye, osg::Vec3(),

    viewer.frame();
}
return 0;
  1. 现在让我们执行该程序。一个具有黑色背景的巨大滑翔机显示在地形表面上,配合一些小的滑翔机。所有的滑翔机可以快速向左右旋转,这是将父camera节点子场景图渲染为共享纹理的结果:
_images/osg_render_to_texture.png
  1. 如果我们忘记原始场景是什么样的,下面的图会有助于我们回想起来。我们将会看到地形背景与树纹理都已为纹理关联的子场景的颜色缓冲区所替换。这也是非凡的场景被生成作为该示例最终结果的原因:
_images/osg_render_to_texture_origin.png

What just happened?

我们刚刚在主相机之下创建了一个子相机,就如同我们在Creating and HUD camera示例中所做的那样。然而,这次他并没有在场景上生成任何结果。在主相机之前(由于PRE_RENDER设置),渲染到纹理相机在每一帧中被遍历并执行。他将子场景渲染到一个纹理对象,然后被应用到主场景图中所有相关状态集合中。共享对象机制与FBO的使得一切操作具有较高的效率。

注意,在自定义模拟循环中所调用的setViewMatrixAsLookAt()方法并不如我们所希望的那样安全,这是由于OSG后端中的多线程管道。这仅是演示如何实现动态纹理的一个临时实现。在接下来的第8章中,我们将会介绍节点回调,而在第9章中,我们将会解释事件处理器,这两者会以一种线程安全的方式解决该问题。

Have a go hero - saving scene to an image file

无论是否相信,OSG也可以将osg::Image对象关联到相机,并将帧缓冲区数据保存到图像对象的data()指针中。然后,我们可以通过与osgDB::readImageFile()方法相对应的osgDB::writeImageFile()方法将图像数据保存到磁盘文件:

osg::ref_ptr<osg::Image> image = new osg::Image;
image->allocateImage( width, height, 1, GL_RGBA, GL_UNSIGNED_BYTE );
camera->attach( osg::Camera::COLOR_BUFFER, image.get() );
// After running for a while
osgDB::writeImageFile( *image, "saved_image.bmp" );

这里,width与height参数也通过setViewport()方法设置到camera。现在,我们是否可以在退出程序时将场景图像保存到一个位图文件呢?

Summary

本章主要关于借助于osg::Camera类观察与变换3D世界。在本章中,我们同时介绍了如何使用osgViewer::Viewer与osgViewer::CompositeViewer,这两个类封装了相机,操作器以及使其联合工作的立体支持。

在本章中,我们特别探讨了:

  • 如果设置视口,视图以及相机节点的投影矩阵,如何通过使用osg::Camera定义相机的渲染顺序。
  • 通过使用osgViewer::Viewer与osgViewer::CompositeViewer的单个查看器与组合查看器的实现。
  • 通过使用osg::DisplaySettings对全局显示设置以及立体可视化的管理。
  • 通过使用帧缓冲区,像素缓冲区与FBO实现缓冲到纹理技术的不同方法。

Chapter 8: Animating Scene Objects

OSG提供了一系列的工具集支持实时动画的实现,包括变换动画,关键帧动画,骨骼动画以及几乎所有我们可以在本章中发现的其他动画。在本章中我们将会首先解释场景对象动画的基本概念,然后介绍大多数常用场景动画类型的实现细节,从而可以应用到各种场景。

在本章中我们将会讨论:

  • 回调的概念并使用回调
  • 在不同的条件下实现简单的动画
  • 如何创建简单的路径动画
  • 如何构建复杂的关键帧以及动画管道系统
  • 如何使用预先设置的骨骼系统生成特征动画
  • 如何实现渲染状态与纹理动画

Taking references to functions

在上一章中,我们尝试了将子场景图动态渲染到纹理。一个不推荐的方法就是在后帧事件(post-frame events)中更新渲染到纹理(render-to-textures)相机的视图矩阵,其主要问题在于多线程环境。后帧事件也许会独立的裁剪或绘制线程相重叠,从而导致数据访问冲突。

为了避免数据访问冲突的出现,我们可以考虑为更新遍历部署动画功能引用,并由OSG决定执行顺序以及何时依据引用调用这些功能。传递给可执行代码段的引用就被称为回调。

在更新遍历中被触发的回调被称为更新回调。还有分别在事件遍历与裁剪遍历中执行的事件回调与裁剪回调。OSG并没有使用函数的地址作为其引用,而是提供了其自己的执行操作的实现,被称为算符。为了自定义执行代码,我们必须重写回调算符的关键操作符与方法,并将其关联到相应的场景对象,例如,节点或是可绘制元素。

List of callbacks

在OSG场景图与后端有多种回调类型。其中,osg::NodeCallback类是更新,事件与裁剪回调的一个重要实现。他只能被关联到节点。对于可绘制元素,我们有osg::Drawable::UpdateCallback,osg::Drawable::EventCallback与osg::Drawable::CullCallback来实现相同的目的。

osg::NodeCallback类有一个虚operator()方法用于用户重写以自定义其执行代码。为了使其工作,我们必须使用setUpdateCallback()或addUpdateCallback()方法将回调对象关联到场景图中的特定节点。然而,operator()方法会在每帧的更新遍历中被自动调用。

下表提供了OSG中所定义的主要回调的一个简要介绍,其中的每一个都有一个可以为用户子类重写的虚方法,以及一个到属性的关联来表明他被关联到特定类的相应方法。

_images/osg_callback_table1.png _images/osg_callback_table2.png

Time for action - switching nodes in the update traversal

我们是否还记得在第5章中我们设计了一动画开关节点?他由osg::Switch派生,但是通过重写traverse()虚方法,依据一个内部计数器,自动改变其前两个子节点的状态。

现在我们要重做相同的任务,但是这次使用更新回调机制。这需要由osg::NodeCallback基类派生一个自定义类,并且重写operator()来执行回调实现中的操作。

  1. 包含必需的头文件:
#include <osg/Switch>
#include <osgDB/ReadFile>
#include <osgViewer/Viewer>
  1. 声明SwitchingCallback类。他是一个基于osg::NodeCallback的派生类,很快将会用作场景节点的更新,事件与裁剪回调。唯一要实现的虚方法是operator()。该方法会在场景图的更新,事件或裁剪遍历中被自动调用。另外,我们同时初始化成员变量_counter作为内部计数器:
class SwitchingCallback : public osg::NodeCallback
{
public:
    SwitchingCallback() : _count(0) {}
    virtual void operator()( osg::Node* node,
                             osg::NodeVisitor* nv );

protected:
    unsigned int _count;
};
  1. operator()有两个输入参数:与回调相关联的节点,以及在遍历中调用函数的节点访问器。要实现两个子节点状态切换动画,我们需要将节点指针转换为osg::Switch类型。在这里使用static_cast<>,因为我们确定相关联的节点是开关节点。同时,traverse()方法应在特定的位置处执行,以确保更新遍历访问器能继续遍历场景图。
void SwitchingCallback::operator()( osg::Node* node,
                                    osg::NodeVisitor* nv )
{
    osg::Switch* switchNode = static_cast<osg::Switch*>( node );
    if ( !((++_count)%60) && switchNode )
    {
        switchNode->setValue( 0, !switchNode->getValue(0) );
        switchNode->setValue( 1, !switchNode->getValue(1) );
    }
    traverse( node, nv );
}
  1. 接下来的步骤已经在第5章中介绍过了。载入显示两个显示不同Cessna状态的模型,并将其放置在switch节点之下,该节点将会被用在自定义更新回调SwitchingCallback中:
SwitchingCallback:
osg::ref_ptr<osg::Node> model1 = osgDB::readNodeFile(
  "cessna.osg" );
osg::ref_ptr<osg::Node> model2= osgDB::readNodeFile("cessnafire.
osg");
osg::ref_ptr<osg::Switch> root = new osg::Switch;
root->addChild( model1.get(), false );
root->addChild( model2.get(), true );
  1. 不要忘记将更新回调关联到节点。如果我们厌倦了在每一帧中执行该回调,仅需要向setUpdateCallback()方法传递一个NULL参数。如果回调对象的引用计数减少到0,则该对象会被删除:
root->setUpdateCallback( new SwitchingCallback );
  1. 现在启动查看器:
osgViewer::Viewer viewer;
viewer.setSceneData( root.get() );
return viewer.run();
  1. 渲染结果完全类似于第5章中的Animating the switch node示例。Cessna将会交替处于完整与燃烧状态。与重写一个新节点类型相比,使用回调的解决方案对场景图影响更少,而且可以很容易的在运行时移除回调或是替换为其他的回调。

What just happened?

目前为止我们已经处理了神奇的traverse()方法用于两个目的:通过重写traverse()方法自定义节点以用于我们自己的执行代码;调用osg::NodeVisitor类的traverse()方法以在实现节点访问器时继续遍历。尽管这两个操作具有不同的参数,他们实际上表示相同的处理管线。

首先,节点访问器的traverse()方法有一个osg::Node参数,简单调用traverse()虚方法并传递其自身作为参数。

其次,节点的遍历方法必须要实现的结束处调用超类的traverse()方法。然而他将确定是否有要使用当前访问器对象(使用子节点的accept()方法)遍历的子节点。

最后,访问器依次调用apply()虚方法来接收各种节点类型作为其参数,然后实现自定义的访问行为。因为每一个apply()方法必须调用访问器的traverse()方法来结束其自身,循环会回到第一步,直到整个场景图遍历完成。整个过程可以通过下面的图来解释:

_images/osg_callback_diagram.png

回调的operator()方法以第三种形式调用其traverse()方法,使用访问器与节点作为参数。然而,没有必要担心其复杂性,因为他所执行的唯一操作就是调用访问器的traverse()方法并继续遍历。如果我们在回调方法中调用失败,回调会简单停止并立即由当前节点返回。

Pop quiz - adding or setting callbacks

除了setUpdateCallback()之外,addUpdateCallback()方法也可以用来将回调关联到场景节点。他会将新回调对象添加到主回调对象之后,从而使得在一个节点中存在多个回调成为可能。我们喜欢哪一种方式呢?我们能否确定在主回调对象的operator()方法中,嵌套回调将会何时执行呢?

Avoding confilicting modifications

我们以一种非常简单而容易的方法讨论了OSG的多线程实现与线程安全。处理结构的理论超出了本书的范围。但是了显示维护场景对象数据多新性的重要性,我们需要简要讨论一个线程模型。

OSG可以使得绘制遍历,也就是将数据传送给OpenGL管线,在一个单独的线程中运行。他必须与每一帧中的其他绘制遍历相同步,但是绘制遍历的部分可以与来自下一帧的更新遍历相重叠,从而改善渲染效率并减少帧延迟。这意味着osgViewer::Viewer的frame()方法会在绘制工作依然处于活动状态时返回。那么更新回调中的数据变化也许会与未完成的渲染操作相冲突,从而导致不可预期的行为,甚至崩溃。

OSG在setDataVariance()方法中提供了解决方法,该方法属于osg::Object类,这是所有场景对象的基类。这可以设置为三个枚举值之一:UNSPECIFIED(默认),STATIC与DYNAMIC。场景图中的DYNAMIC对象必须在绘制遍历的开始进行处理。也就是,渲染后端应确保所有节点以及被指定为DYNAMIC的场景对象在下一帧的更新与裁剪遍历开始之前已完成绘制。然而,STATIC对象,在更新与绘制过程中会保持不变,从而会被稍后渲染且不会阻塞帧速率。

默认情况下,所有新分配的对象都被指定为UNSPECIFIED,包括节点,可绘制元素,状态集以及属性。这允许OSG预测数据变化。另一方面,我们总是可以重置该值并使其由下一帧开始工作,例如:

node->setDataVariance( osg::Object::DYNAMIC );

Time for action - drawing a geometry dynamically

动态修改几何体的顶点与基元属性是很常见的。我们可以改变每个顶点的位置,法线,颜色与纹理坐标,以及每一帧相关的基元,以实现各种动画类型。在修改过程中,关注数据的变化是很重要的,因为绘制遍历也许会与更新顶点与基元的更新遍历同时运行,从而会导致冲突甚至是崩溃。

在这个示例中,我们将会使用在第4章中所创建的四边形几何体。我们会简单的修改其最后一个顶点,并使其围绕X轴旋转,从而生成一个简单的动画效果。

  1. 包含必需的头文件:
#include <osg/Geometry>
#include <osg/Geode>
#include <osgViewer/Viewer>
  1. 四边形的创建对于我们非常熟悉。指定顶点,法线以及颜色数组,并添加基元集合来表示要安排的所有顶点,并使用GL_QUAD类型进行渲染。最后,返回新分配的几何体对象:
osg::Geometry* createQuad()
{
    osg::ref_ptr<osg::Vec3Array> vertices = new osg::Vec3Array;
    vertices->push_back( osg::Vec3(0.0f, 0.0f, 0.0f) );
    vertices->push_back( osg::Vec3(1.0f, 0.0f, 0.0f) );
    vertices->push_back( osg::Vec3(1.0f, 0.0f, 1.0f) );
    vertices->push_back( osg::Vec3(0.0f, 0.0f, 1.0f) );
    osg::ref_ptr<osg::Vec3Array> normals = new osg::Vec3Array;
    normals->push_back( osg::Vec3(0.0f,-1.0f, 0.0f) );
    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, 1.0f, 1.0f) );
    osg::ref_ptr<osg::Geometry> quad = new osg::Geometry;
    quad->setVertexArray( vertices.get() );
    quad->setNormalArray( normals.get() );
    quad->setNormalBinding( osg::Geometry::BIND_OVERALL );
    quad->setColorArray( colors.get() );
    quad->setColorBinding( osg::Geometry::BIND_PER_VERTEX );
    quad->addPrimitiveSet( new osg::DrawArrays(GL_QUADS, 0, 4) );
    return quad.release();
}
  1. 借助于osg::Drawable::UpdateCallback,我们可以很容易获取每一帧要修改的几何体指针。唯一要覆盖的方法是update(),该方法有一个节点访问器与一个可绘制元素指针作为参数。其超类,osg::Drawable::UpdateCallback,类似于osg::NodeCallback类,不同的是可绘制元素的回调不必遍历到所有子节点(没有子节点的可绘制元素)。
class DynamicQuadCallback : public osg::Drawable::UpdateCallback
{
public:
    virtual void update( osg::NodeVisitor*, osg::Drawable*
drawable );
};
  1. 在update()方法的实现中,我们使用static_cast<>操作符读取所创建四边形几何体的顶点数组。如果DynamicQuadCallback类不仅被应用于osg::Geometry,而是同时应用于其他自定义的可绘制元素,则dynamic_cast<>关键字也许更为安全。然后,我们使用osg::Quat四元数类快速围绕原点(0,0,0)旋转数组中的最后一个顶点。退出方法之前的最后一步工作是重新计算当前几何体的显示列表对象与边界盒子,当任何一个顶点被修改时,这些元素需要进行更新:
void DynamicQuadCallback::update( osg::NodeVisitor*,
                                  osg::Drawable* drawable )
{
    osg::Geometry* quad = static_cast<osg::Geometry*>( drawable );
    if ( !quad ) return;
    osg::Vec3Array* vertices = static_cast<osg::Vec3Array*>(
        quad->getVertexArray() );
    if ( !vertices ) return;
    osg::Quat quat(osg::PI*0.01, osg::X_AXIS);
    vertices->back() = quat * vertices->back();

    quad->dirtyDisplayList();
    quad->dirtyBound();
}
  1. 我们将几何体定义为DYNAMIC,从而OSG后端的绘制遍历会自动指示动态对象来执行稳健的场景图遍历。另外,可绘制元素的修改回调是通过osg::Drawable类的setUpdateCallback()方法指定的:
osg::Geometry* quad = createQuad();
quad->setDataVariance( osg::Object::DYNAMIC );
quad->setUpdateCallback( new DynamicQuadCallback );
  1. 现在将四边形几何体添加到osg::Geode节点,并将根节点关联到查看器:
osg::ref_ptr<osg::Geode> root = new osg::Geode;
root->addDrawable( quad );
osgViewer::Viewer viewer;
viewer.setSceneData( root.get() );
return viewer.run();
  1. 这次四边形动起来了。借助于osg::Quat类,其第四个顶点围绕X轴旋转。这要比仅是在屏幕上显示一个静态的图像动态得多:
_images/osg_quad_animate.png

What just happened?

试着移除setDataVariance()行并看一下会发生什么。奇怪的是示例依然能够正确运行,就如同他没有受到线程模型的影响。这是因为UNSPECIFIED对象能够确定他们是否在回调中被动态修改,并自动将数据变化重置为DYNAMIC。

试着将枚举DYNAMIC修改为STATIC,而我们会发现渲染会闪烁且在控制中中有OpenGL错误消息”invalid operation”。这实际上是由线程冲突引起的。

如果没有调用dirtyDisplayList()方法,OSG将会忽略所有动态可绘制元素的变化并利用显示列表命令来存储前一个顶点与基元数据。同时,如果没有调用dirtyBound()方法,OSG不会知道边界盒子是否适合可绘制元素的尺寸,并且会在执行视图裁剪时出现错误。

Have a go hero - dirtying geometry objects

为了进行正确渲染,我们需要调用dirtyDisplayList()方法来激活可绘制元素数据的更新。但是一个重要的先决条件是可绘制元素应支持显示列表模式,这是可绘制元素的默认行为,并且可以通过setUseDisplayList()方法打开或关闭。

当使用VBO模型时,OSG允许使用更好的机制,这会更为高效。打开setUseVertexBufferOjbects()并禁止setUseDisplayList()可以起作用。我们将会发现在该情况下dirtyDisplayList()方法没有起作用。通过执行dirty()方法可以污染数组数据,例如:

osg::Vec3Array* vertices = ;
// Dynamically modify the vertex array data
vertices->dirty();

看一下我们的修改是否起作用,并在污染相同的几何体时标识两种策略之间的区别。事实上,在这里显示列表不起作用是因为他会在每一帧中重新生成。所以,对于渲染变化的几何体数据,我们更喜欢VBO。

Understanding ease motions

假定有一列火车在15分钟内由A站运行到B站。我们将会在更新回调中通过修改列车的变换矩阵来模拟这一场景。最简单的方法是将位于A站的火车放置在时间点0处,而位于B站的火车位于时间点15(分钟)处,并在变换过程中进行移动。在这里将会着重使用的方法是线性插值。该方法会在两个相邻采样点P0与P1之间绘制一条直线,并且返回直线上的相应点P,从而可以用来表示节点的变换与缩放操作。通常可以使用下面的形式进行表达:

P = (1 - t) * P0 + t * P1

这里t是一个0到1之间的数。

不幸的是,列车的运动通常更为复杂。他由站点A出发,慢慢加速,以平滑的速度运行,减速,最终停靠在站点B。在这种情况下,线性插值总是有些不自然。

所以我们有简单的方法,或是简单的函数。这些是用来在两点这宰插值的数学函数。为了获得更为自然的效果,一个简单的函数通常不会生成非线性的结果。osgAnmination库定义了大量内建的简单函数。其中的每一个至少有两个参数:起如值(通常为0)与过程(通常为1),并生成该范围[起始值,起始值+过程]之内的结果。他们可以被应用起始(InMotion),结束(OutMotion)或同时应用到直动画的起始与结束(InOutMotion)。我们将会在下表中列出这些函数:

_images/osg_ease_motion.png

要创建一个线性插值运动对象,我们可以输入:

// Start value is 0.0, and duration time is 1.0.
osg::ref_ptr<osgAnimation::LinearMotion> motion =
    new osgAnimation::LinearMotion(0.0f, 1.0f);

OSG源码中的examples/osganimationeasemotion文件有助于我们以图形方式理解这些简单运动。要了解详细内容可以尝试编译并运行。

Animating the transformation nodes

路径动画是图形程序中最广为使用的动画。他们可以用来描述运动的汽车,飞机,旋转的球,或是相机运动。路径应总是被首先设置,包括位置,旋转以及不同关键时刻节点的缩放值。当模拟循环运行时,使用为位置与缩放向量使用线性插值以及为旋转四元数据使用球形插值的方法计算每一帧的变换状态。这里内部使用osg::Quat的slerp()方法。

OSG提供了osg::AnimationPath类来封装时间变化变换路径。他有一个insert()方法可以用来在指定的时间点播放一个控制点。控制点由osg::AnimationPath::ControlPoint类所声明,接受一个位置值,一个可选的旋转与缩放值以构建动画路径。例如:

osg::ref_ptr<osg::AnimationPath> path = new osg::AnimationPath;
path->insert(t1, osg::AnimationPath::ControlPoint(pos1,rot1,scale1));
path->insert(t2, …);

这里,t1与t2是以秒计的时间节点,而rot1是一个表示对象旋转的osg::Quat变量。

除此之外,我们可以使用setLoopMode()方法设置动画的循环模式。默认值为LOOP,也就是动画将会在设定好的路径上连续运行。这个参数可以修改为NO_LOOPING(运行一次)或是SWING(创建一个往复路径)以用于其他目的。

然后,我们将osg::AnimationPath对象关联到内建的osg::AnimationPathCallback对象,该类实例上派生自osg::NodeCallback,并帮助开发者以直观的方式控制其动画场景。

Time for action - making use of the animation path

现在我们要使得我们的Cessna绕着一个圆运动。他将在一个圆心位于(0,0,0)的圆内运动。通过关键帧之间的线性插值,路径被用来持续更新模型的位置与朝向。为了实现动画时间线,唯一的工作就是添加控制点,包括位置,可选择的旋转以及缩放关键值。

  1. 包含必需的头文件:
#include <osg/AnimationPath>
#include <osg/MatrixTransform>
#include <osgDB/ReadFile>
#include <osgViewer/Viewer>
  1. 创建动画路径。这实际上是XOY平面上具有指定半径的圆。time参数被用来指定完成一圈所需要的时间。osg::AnimationPath对象被设置为无限循环动画。他包含32个控制点来构成圆路径,这是由局部变量numSamples来定义的:
osg::AnimationPath* createAnimationPath( float radius, float time)
{
    osg::ref_ptr<osg::AnimationPath> path = new
osg::AnimationPath;
    path->setLoopMode( osg::AnimationPath::LOOP );

    unsigned int numSamples = 32;
    float delta_yaw = 2.0f * osg::PI / ((float)numSamples - 1.0f);
    float delta_time = time / (float)numSamples;
    for ( unsigned int i=0; i<numSamples; ++i )
    {
        float yaw = delta_yaw * (float)i;
        osg::Vec3 pos( sinf(yaw)*radius, cosf(yaw)*radius, 0.0f );
        osg::Quat rot( -yaw, osg::Z_AXIS );
        path->insert( delta_time * (float)i,
                      osg::AnimationPath::ControlPoint(pos, rot));
    }
    return path.release();
}
  1. 载入Cessna模型。我们将会注意到这次与之前的文件名之间有着明显的区别。在这里字符串”0,0,90.rot”看起来是多余的。这是一种伪载入器,作为文件名的一部分,但实际上是使模型cessna.osg绕Z轴旋转90度。我们会在第10章中进行详细讨论:
osg::ref_ptr<osg::Node> model =
    osgDB::readNodeFile( "cessna.osg.0,0,90.rot" );
osg::ref_ptr<osg::MatrixTransform> root = new
osg::MatrixTransform;
root->addChild( model.get() );
  1. 将动画路径添加到osg::AnimationPathCallback对象,并将回调关联到节点。注意,动画路径仅影响osg::MatrixTransform与osg::PositionAttitudeTransform节点,在更新遍历中更新其变换矩阵或是位置与旋转属性:
osg::ref_ptr<osg::AnimationPathCallback> apcb = new
osg::AnimationPathCallback;
apcb->setAnimationPath( createAnimationPath(50.0f, 6.0f) );
root->setUpdateCallback( apcb.get() );
  1. 现在简单启动查看器:
osgViewer::Viewer viewer;
viewer.setSceneData( root.get() );
return viewer.run();
  1. 现在Cessna开始做圆周运动。其运动也许会超出屏幕范围,所以我们需要使用相机操作器来切换到一个比初始位置更好的查看位置。使用鼠标按钮来调整视图矩阵从而全面观看所创建的动画路径:
_images/osg_animation_path.png

What just happened?

osg::AnimationPath类使用getMatrix()方法依据指定时间点前与后的两个控制点来计算并返回运动变换矩阵。然而将其应用到主osg::MatrixTransform,osg::PositionAttitudeTransform或osg::Camera节点以使其沿着路径运行。这是由osg::AnimationPathCallback类完成的,该类实际上是用于特定目的的更新回调。

如果osg::AnimationPathCallback对象被关联到其他类型的节点,而不是前面所描述的变换节点,则他会变得无效。同时也不建议将动画路径回调用作事件或裁剪回调,因为这会导致不可预料的结果。

Have a go hero - more controls over the animation path

动画必须能够被停止,重置与快进,从而使得用户的控制更为容易。osg::AnimationPathCallback类提供了reset(),setPause(),setTimeMultiplier()与setTimeOffset()方法来实现这些常见的操作。例如,要重置当前的动画路径,在任意时刻调用apcb:

apcb->setPause( false );
apcb->reset();

为了将时间偏移设置为4.0s,并且以2x倍速度快速前进动画,可以使用:

apcb->setTimeOffset( 4.0f );
apcb->setTimeMultiplier( 2.0f );

现在是我们是否明白应如何创建我们自己的路径动画层了吗?

Changing rendering states

渲染状态也可以进行动画。通过修改一个或是多个渲染属性可以生成大量的效果,包括渐进与渐出,大气的密度与变化,雾,修改光柱的方向等。我们可以很容易在更新回调中实现状态动画。我们可以由重载方法的参数中获取属性对象,或是仅将对象作为用户定义回调的成员变量。记住要使用智能指针来确保成员变量在不再被引用时会被自动销毁。

简单运动类可以用来改善动画质量。我们必须使用起始值与过程参数来分配一个简单运动对象,并使用间隔时间进行更新。例如:

osg::ref_ptr<osgAnimation::LinearMotion> motion =
    new osgAnimation::LinearMotion(0.0, 10.0);
motion->update( dt );
float value = motion->getValue();

这会使用由0.0到10.0范围内的X轴创建一个线性运动对象。getValue()方法在当前的X值上使用特定的公式,并获取相应的Y值。

如果我们希望在我们的工程中使用简单运动以及更多的功能,我们砖雕要将osgAnimation库作为依赖添加进来。

我们已经体验过使用osg::BlendFunc类与渲染顺序来使得场景对象半透明。被称为alpha值的颜色向量的第四个组成部分会为我们提供技巧。但是如果我们有一个连续变化的alpha值时会发生什么呢?当alpha为0时将会完全透明(不可见),而当为1.0时则会完全不透明。因而由0.0到1.0的动画过程将会导致对象逐渐对查看者可见,也就是淡入效果。

更新回调可以用在该任务中。创建一个基于osg::NodeCallback的类并将其设置给将要淡入的类没有任何问题。但是状态属性回调,osg::StateAttributeCallback,在该示例中也可用。

在这里,osg::Material类被用来提供每一个几何顶点的alpha位,而不仅是设置颜色数组。

  1. 包含必需的头文件:
#include <osg/Geode>
#include <osg/Geometry>
#include <osg/BlendFunc>
#include <osg/Material>
#include <osgAnimation/EaseMotion>
#include <osgDB/ReadFile>
#include <osgViewer/Viewer>
  1. 要实例化osg::StateAttributeCallback,我们需要重写operator()方法,并利用其参数:状态属性本身与进行遍历的访问器。这里的另一个任务是使用立体函数在动画曲线的进入出位置声明一个简单运动插值器:
class AlphaFadingCallback : public osg::StateAttributeCallback
{
public:
    AlphaFadingCallback()
    { _motion = new osgAnimation::InOutCubicMotion(0.0f, 1.0f); }
    virtual void operator()(osg::StateAttribute*,
                            osg::NodeVisitor*);

protected:
    osg::ref_ptr<osgAnimation::InOutCubicMotion> _motion;
};
  1. 在operator()中,我们将会获取场景对象的材质属性,该属性可以被用来模拟透明与半透明效果。这需要两步:首先,使用自定义的时间值差量更新简单运动对象;然后,获取0到1之间的运动结果,并将其应用到材质的混合颜色的alpha部分:
void AlphaFadingCallback::operator()( osg::StateAttribute* sa,
                                      osg::NodeVisitor* nv )
{
    osg::Material* material = static_cast<osg::Material*>( sa );
    if ( material )
    {
        _motion->update( 0.005 );

        float alpha = _motion->getValue();
        material->setDiffuse( osg::Material::FRONT_AND_BACK,
                              osg::Vec4(0.0f, 1.0f, 1.0f, alpha));
    }
}
  1. 这就是我们在osg::StateAttribute回调中的全部操作。现在,在示例的主函数中,我们要创建一个四边形并将回调应用于其材质。我们可以拷贝第4章与第6章中的代码来自己创建四边形几何体。OSG支持一个更为方便的名为osg::createTextureQuadGeometry()函数。他需要一个角点,一个宽度向量以及一个高度向量,并返回一个使用预设顶点,法线与纹理坐标数据的新创建的osg::Geometry对象:
osg::ref_ptr<osg::Drawable> quad = osg::createTexturedQuadGeomet
ry(
    osg::Vec3(-0.5f, 0.0f, -0.5f),
    osg::Vec3(1.0f, 0.0f, 0.0f), osg::Vec3(0.0f, 0.0f, 1.0f)
);
osg::ref_ptr<osg::Geode> geode = new osg::Geode;
geode->addDrawable( quad.get() );
  1. 配置材质属性并没有什么特别的。如果有使用OpenGL glMaterial()的经验,我们可以很容易想见osg::Material类是如何使用类似的成员方法设置周边与混合颜色的。此时需要注意的是将AlphaFadingCallback对象关联到材质,并使其在每一帧的所有更新遍历中起作用:
osg::ref_ptr<osg::Material> material = new osg::Material;
material->setAmbient( osg::Material::FRONT_AND_BACK,
                      osg::Vec4(0.0f, 0.0f, 0.0f, 1.0f) );
material->setDiffuse( osg::Material::FRONT_AND_BACK,
                      osg::Vec4(0.0f, 1.0f, 1.0f, 0.5f) );
material->setUpdateCallback( new AlphaFadingCallback );
  1. 将材质属性及相关的模式添加到geode的状态集合。同时,我们需要使能OpenGL混合函数来实现我们的淡入效果,并且确保透明对象以顺序方式进行渲染:
geode->getOrCreateStateSet()->setAttributeAndModes(
  material.get() );
geode->getOrCreateStateSet()->setAttributeAndModes(
    new osg::BlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) );
geode->getOrCreateStateSet()->setRenderingHint(
    osg::StateSet::TRANSPARENT_BIN );
  1. 将四边形添加到根节点。我们同时添加滑翔机模型作为参考模型,其中的一半为四边形所覆盖,从而指示四边形是否淡入淡出:
osg::ref_ptr<osg::Group> root = new osg::Group;
root->addChild( geode.get() );
root->addChild( osgDB::readNodeFile("glider.osg") );
  1. 现在启动查看器:
osgViewer::Viewer viewer;
viewer.setSceneData( root.get() );
return viewer.run();
  1. 出入立体简单运动使得alpha的变以一种平滑的方式出现。我们将会发现他更适合于实现真实的动画而不是简单的线性插值运动。现在,我们是否知道如何使用相同的结构实现淡出效果呢?这两种效果被经常用于表示动态模型与大城市场景构建中:
_images/osg_fade_in_out.png

What just happened?

osgAnimation::InOutCubicMotion类基于时间的立体形式生成值。结果曲线如下图所示:

_images/osg_cubic_line.png

假定当前的时间值为t(位于X轴),运动对象会依据立体形式返回Y值。他会由零加速至半路,然后减速到零。这使得对象看起来更为自然,而不仅使用简单的常速。试着将其以及更多的简单运动应用到材质值,以及osg::MatrixTransform节点的路径动画(在自定义的节点回调中修改其矩阵)。

Pop quiz - choosing the alpha setter and the callback

除了osg::Material类与osg::Geometry的颜色数组之外,还有哪些可以设置顶点的alpha值呢?除了osg::StateAttributeCallback之外,我们知道还有哪些回调可以用来控制淡入效果呢,例如,节点与可绘制元素回调?我们是否能以最简单的方式修改上面的示例来实现淡出效果呢?

Have a go hero - animating in graphics shaders

在图形阴影器中使用状态动画会很酷。在大多数情况下,他要比固定管线更容易控制,并为我们提供各种效果的自由想像,例如海浪,波纹,火焰,阴影以及复杂的实际效果。

osg::Uniform类可以使用setUpdateCallback()方法以及osg::Uniform::Callback派生对象来定义其自己的更新回调。其虚方法operator()有两个输入参数:uniform指针与遍历访问器。使用set()方法来修改uniform值(必须与之前的类型相同)并且查看是否可以在图形阴影器中工作。

Playing movies on textures

如果我们能够在3D世界中观察影片或是存储影片内容将会非常有趣。我们可以将一个大的方块几何体放置为电影屏幕,并将一个动态2D纹理与其表面相关联。纹理包含构成影像的一系列图像。图像序列可以随时添加新的图像是所必需要,该图像可以来自文件或是微型相机。

OSG使用osg::ImageStream类来支持图像流,该类管理数据缓冲区中的子图像。他可以被派生从而由视频文件或是网络读取数据。事实上,OSG已经有一些内建的插件支持AVI,MPG,MOV以及其他文件格式的载入与播放。我们将会在第10章进行详细描述。

在这里,我们将会介绍另一个osg::ImageSequence类,该类存储多个图像对象并依次渲染。他具有下列的公共方法:

  1. addImage()方法向序列添加一个osg::Image对象。同时还有setImage()与getImage()方法操作指定索引处的子图像,以及getNumImages()方法统计子图像的数量。
  2. addImageFile()与setImageFile()方法可以将图像对象压入子图像列表的结尾处。但是无需指定指针,这两个方法都接受一个文件名参数,从而由磁盘读取子图像。
  3. setLength()方法设置以秒计的图像序列总时间。该时间在动画过程中每一个子图像之间平均分配的。
  4. setTimeMultiplier()方法设置时间乘数。默认为1.0,而更大的值指示序列应快进。
  5. play(),pause(),rewind()与seek()方法为开发者提供了对序列的基本控制。seek()方法接受一个时间参数,该参数应小于总时间长度。

Time for action - rendering a flashing spotlight

渲染动态纹理的关键是提供多个图像作为源,并依次进行绘制。这些图像可以由一个视频文件获取,或是由开发者与艺术人员创建。在下面的示例中,我们将会使用变量的半径创建一系列的点光,并将其输出到osg::Image对象,然后使用osg::ImageSequence类将其关联到纹理属性来在特定的模型上生成闪烁效果。

  1. 包含必需的头文件:
#include <osg/ImageSequence>
#include <osg/Texture2D>
#include <osg/Geometry>
#include <osg/Geode>
#include <osgViewer/Viewer>
  1. 点光可以定义为将光束投影在空间上的一系列点。他通常生成围绕中心点的一个晕轮,而且可以被修改来使用不同的颜色与强度范围。这里,函数createSpotLight()使用中心颜色,背景颜色与强度参数简单生成一个osg::Image对象。size参数被用来定义图像本身的最终大小。在这里,data()方法接受列与行索引,并返回一个相对应的起始地址用于赋值:
osg::Image* createSpotLight( const osg::Vec4& centerColor,
                             const osg::Vec4& bgColor,
                             unsigned int size, float power )
{
    osg::ref_ptr<osg::Image> image = new osg::Image;
    image->allocateImage( size, size, 1, GL_RGBA,
                          GL_UNSIGNED_BYTE );
    float mid = (float(size)-1) * 0.5f;
    float div = 2.0f / float(size);
    for( unsigned int r=0; r<size; ++r )
    {
        unsigned char* ptr = image->data(0, r);
        for( unsigned int c=0; c<size; ++c )
        {
            float dx = (float(c) - mid)*div;
            float dy = (float(r) - mid)*div;
            float r = powf(1.0f - sqrtf(dx*dx+dy*dy), power);
            if ( r<0.0f ) r = 0.0f;
            osg::Vec4 color = centerColor*r + bgColor*(1.0f - r);
            *ptr++ = (unsigned char)((color[0]) * 255.0f);
            *ptr++ = (unsigned char)((color[1]) * 255.0f);
            *ptr++ = (unsigned char)((color[2]) * 255.0f);
            *ptr++ = (unsigned char)((color[3]) * 255.0f);
        }
    }
    return image.release();
}
  1. 通过使得的createSpotLight()函数,我们可以使用不同的强度值快速生成多个图像。然后我们将所有这些图像添加到osg::ImageSequence对象用于统一管理:
osg::Vec4 centerColor( 1.0f, 1.0f, 0.0f, 1.0f );
osg::Vec4 bgColor( 0.0f, 0.0f, 0.0f, 1.0f );
osg::ref_ptr<osg::ImageSequence> sequence = new
  osg::ImageSequence;
sequence->addImage( createSpotLight(centerColor, bgColor, 64,
  3.0f) );
sequence->addImage( createSpotLight(centerColor, bgColor, 64,
  3.5f) );
sequence->addImage( createSpotLight(centerColor, bgColor, 64,
  4.0f) );
sequence->addImage( createSpotLight(centerColor, bgColor, 64,
  3.5f) );
  1. 由于osg:ImageSequence是由osg::Image类派生的,他可以直接关联一个纹理作为数据源。这使得在模型表面持续显示图像成为可能:
osg::ref_ptr<osg::Texture2D> texture = new osg::Texture2D;
texture->setImage( imageSequence.get() );
  1. 再次使用osg::createTextureQuadGeometry()函数生成一个四边形。这被用来表示最终的图像序列。如果所有的图像均是由一个视频源中获取,他甚至是可以被看作是在电影中用于显示电影的屏幕。
osg::ref_ptr<osg::Geode> geode = new osg::Geode;
geode->addDrawable( osg::createTexturedQuadGeometry(
    osg::Vec3(), osg::Vec3(1.0,0.0,0.0), osg::Vec3(0.0,0.0,1.0))
  );
geode->getOrCreateStateSet()->setTextureAttributeAndModes(
    0, texture.get(), osg::StateAttribute::ON );
  1. 我们需要配置osg::ImageSequence对象来确定总长度(以秒计),并开始以顺序方式播放序列。这也可以在一个更新回调中完成:
imageSequence->setLength( 0.5 );
imageSequence->play();
  1. 启动查看器:
osgViewer::Viewer viewer;
viewer.setSceneData( geode.get() );
return viewer.run();
  1. 我们可以看到一个点光在四边形中心闪烁。这是因为我们将具有不同半径的点光图像应用到序列,并循环播放(默认情况)。现在我们可以基于这个基本实现想像一些更为真实的效果:
_images/osg_spotlight.png

What just happened?

osg::ImageSequence类在每一帧中由存储的图像更新当前的渲染数据。他使用setImage()方法来配置维度,格式以及像素数据,同时会污染自身-这会使得保存有图像的所有纹理对象更新图形内存并向渲染管线输出新数据。这并不高效,因为如果切换频繁,这会导致较高的CPU-GPU带宽使用的增加。

另一个有趣的地方是addFileName()与setFileName()方法。这两个方法使用磁盘上的图像文件来构成图像序列,并且在默认情况下所有这些文件一次载入。这可以通过setMode()方法进行修改。该方法接受下列参数中的一个:

  1. PRE_LOAD_ALL_IMAGES会导致默认行为
  2. PAGE_AND_RETAIN_IMAGES将会按需由文件载入图像
  3. PAGE_AND_DISCARD_USED_IMAGES会当影片重置时移除所有使用的图像并重新载入

所以,如果强制以分页机制载入图像,在启动循环之间设置模式:

imageSequence->setMode( osg::ImageSequence::PAGE_AND_RETAIN_IMAGES );

Creating complex key-frame animations

现在我们可以进一步探讨osgAnimation库了。除了简单的运动实现,osgAnimation支持更多通用的动画特性,包括固体动画,变形动画,骨骼动画,基本的动画管理器以及时间线高度器。他定义了大量的概念与模板类,这些类看起来非常复杂,但是可以为开发者提供极大的灵活性来构建他们自己的高级动画。

具有了使用动画路径的基础之后,我们可以快速地理解osgAnimation的重要概念,并且由一个实现了与动画路径示例相同效果的示例入手。

动画的基本元素是关键帧。这定义了所有平滑动画的结束点。osg::AnimationPath使用ControlPoint类来创建位置,旋转与缩放值的关键帧。

一个关键帧通常需要两个参数:时间点以及要实现的时间。osgAnimation::TexmplateKeyframe<>类被用来定义osgAnimation库中的一个普通关键帧,而osgAnimation::TemplateKeyframeContainer<>类管理一个相同数据类型的关键帧列表。他派生自std::vector类并且继承了所有的vector方法,例如push_back(),pop_back()与迭代器。所以,要向一个位置关键帧添加到相应的容器对象,我们可以使用:

osgAnimation::TemplateKeyframe<osg::Vec3> kf(0.0, osg::Vec3());
osgAnimation::TemplateKeyframeContainer<osg::Vec3>* container =
    new osgAnimation::TemplateKeyframeContainer<osg::Vec3>;
container->push_back( keyframe );

这里,osg::Vec3是关键帧与容器的模板参数。为了简化代码,我们可以简单的将模板类名替换为osgAnimation::Vec3KeyFrame与osgAnimation::Vec3KeyFrameContainer,也就是:

osgAnimation::Vec3KeyframeContainer* container =
    new osgAnimation::Vec3KeyframeContainer;
container->push_back( osgAnimation::Vec3Keyframe(0.0, osg::Vec3()) );

容器对象实际上由osg::Referenced派生,所以他也可以由智能指针进行管理。然后可以使用一个采样器使用定义了插值方法的算符在关键帧容器中插入元素。

osgAnimation::TemplateSampler<>定义了底层采样器模板。他包含一个内部插值器对象以及一个具有相同模板参数的osgAnimation::TemplateKeyframeContainer<>。采样器也具有别名。例如,osgAnimation::Vec3LinearSampler定义了一个包含osg::Vec3数据与线性插值器的采样器。其公共方法getOrCreateKeyframeContainer()可以随时返回一个正确的3D向量关键帧容器。

下表列出了osgAnimation名字空间内的采样器类型及其相关联的容器与关键帧类:

_images/osg_sampler.png

为了将关键帧添加到一个指定的采样器对象,只需要输入:

// Again, assume it is a 3D vector sampler
sampler->getOrCreateKeyframeContainer()->push_back(
    osgAnimation::Vec3Keyframe(0.0, osg::Vec3()) );  // Frame at 0s
sampler->getOrCreateKeyframeContainer()->push_back(
    osgAnimation::Vec3Keyframe(2.0, osg::Vec3()) );  // Frame at 2s

Channels and animation managers

现在是处理满是关键帧的采样器的时候了。osgAnimation::TemplateChannel<>类接受一个特定的采样器类作为参数,并表示采样器与目标的关联。通道的名字是通过setName()方法设置的,而其所查找的目标是由setTargetName()方法所定义的。

目标对象经常是osgAnimation内建更新回调。他们应使用setUpdateCallback()方法关联到特定节点。osgAnimation::UpdateMatrixTransform就是一个典型。他更新宿主osg::MatrixTransform节点,并使用每一帧的通道结果修改变换矩阵。我们可以在下面的示例中看到其用法。

一个包含3D向量采样器的通道可以替换为osgAnimation::Vec3LinearChannel类,而具有球形四元数据采样器的类被称为osgAnimation::QuatSphericalLinearChannel,等等。

在完成设计所有的关键帧与动画通道以后,构建我们动画场景的最后一步就是为所有通道声明管理器类。在此之前,我们定义osgAnimation::Animation类来包含一系列的动画通道,就如同他们位于相同的层。通道可以使用addChannel()方法被添加到动画对象。

osgAnimation::BasicAnimationManager类是所有动画对象的最终管家。他通过registerAnimation(),unregisterAnimation()与getAnimationList()方法管理osgAnimation::Animation对象,并通过playAnimation(),stopAnimation()与isgPlaying()方法控制一个或多个动画对象的播放状态。同时他也是一个更新回调,但是为了提供对整个场景图动画的完全控制,他应被设置到根节点。

整个过程可以通过下图进行描述:

_images/osg_channel.png

Time for action - managing animation channels

为了实现与我们已经完成的动画路径示例相同的动画效果,我们需要创建两个通道,一个为位置动画目标,而另一个旋转动画目标。

围绕原点生成圆路径的createAnimationPath()函数可以被重用。但是并不能将位置与旋转值组合到一个控制点结构中,这两种类型的关键帧应被添加到属于不同动画通道的单独容器中。

  1. 包含必需的头文件:
#include <osg/MatrixTransform>
#include <osgAnimation/BasicAnimationManager>
#include <osgAnimation/UpdateMatrixTransform>
#include <osgAnimation/StackedTranslateElement>
#include <osgAnimation/StackedQuaternionElement>
#include <osgDB/ReadFile>
#include <osgViewer/Viewer>
  1. createAnimationPath()的算法依然有用。唯一的区别在于所计算的值应被放置在不同类型的关键帧中(VecKeyFrame与QuatKeyFrame),然后添加到输入容器中:
void createAnimationPath( float radius, float time,
    osgAnimation::Vec3KeyframeContainer* container1,
    osgAnimation::QuatKeyframeContainer* container2 )
{
    unsigned int numSamples = 32;
    float delta_yaw = 2.0f * osg::PI/((float)numSamples - 1.0f);
    float delta_time = time / (float)numSamples;
    for ( unsigned int i=0; i<numSamples; ++i )
    {
        float yaw = delta_yaw * (float)i;
        osg::Vec3 pos( sinf(yaw)*radius, cosf(yaw)*radius, 0.0f );
        osg::Quat rot( -yaw, osg::Z_AXIS );
        container1->push_back(
            osgAnimation::Vec3Keyframe(delta_time * (float)i, pos));
        container2->push_back(
            osgAnimation::QuatKeyframe(delta_time * (float)i, rot));
    }
}
  1. 在主函数中,我们首先声明一个“位置动画”通道与一个“旋转动画”通道(QuatSphericalChannel可以实现与osg::Quat的slerp()方法相同的效果)。其名字应是唯一的,而目的名字应与其更新器相同。否则,通道将不会被正确识别:
osg::ref_ptr<osgAnimation::Vec3LinearChannel> ch1 =
    new osgAnimation::Vec3LinearChannel;
ch1->setName( "position" );
ch1->setTargetName( "PathCallback" );
osg::ref_ptr<osgAnimation::QuatSphericalLinearChannel> ch2 =
    new osgAnimation::QuatSphericalLinearChannel;
ch2->setName( "quat" );
ch2->setTargetName( "PathCallback" );
  1. 如前面所描述的,通道的关键帧容器将会在createAnimationPath()函数中接收正确的动画数据:
createAnimationPath( 50.0f, 6.0f,
    ch1->getOrCreateSampler()->getOrCreateKeyframeContainer(),
    ch2->getOrCreateSampler()->getOrCreateKeyframeContainer() );
  1. 现在我们来创建一个osg::Animation对象来包含这两个通道并定义其通用行为。setPlayMode()方法与osg::AnimationPath的setLoopMode()方法等同:
osg::ref_ptr<osgAnimation::Animation> animation = new
osgAnimation::Animation;
animation->setPlayMode( osgAnimation::Animation::LOOP );
animation->addChannel( ch1.get() );
animation->addChannel( ch2.get() );
  1. 动画已完成设置,但还没有被关联到任何场景元素。因为他将要影响变换节点,在这里我们需要创建一个变换更新器目标,来匹配动画的所有通道。其元素与通道通过相同的名字字符串处于一对一的关系:
osg::ref_ptr<osgAnimation::UpdateMatrixTransform> updater =
    new osgAnimation::UpdateMatrixTransform("PathCallback");
updater->getStackedTransforms().push_back(
    new osgAnimation::StackedTranslateElement("position") );
updater->getStackedTransforms().push_back(
    new osgAnimation::StackedQuaternionElement("quat") );
  1. Cessna借助于伪载入器被载入,并被位于osg::MatrixTransform父节点之下。变换动画可以应用于其上的变换父节点将会接受更新器作为更新回调。在这里数据变化确保动画处理总是安全的:
osg::ref_ptr<osg::MatrixTransform> animRoot= new
osg::MatrixTransform;
animRoot->addChild( osgDB::readNodeFile("cessna.osg.0,0,90.rot")
);
animRoot->setDataVariance( osg::Object::DYNAMIC );
animRoot->setUpdateCallback( updater.get() );
  1. 因为我们只有一个要插入的动画对象,一个基本管理器就足够了。下一步是创建一个osgAnimation::BasicAnimationManager对象并向其注册动画:
osg::ref_ptr<osgAnimation::BasicAnimationManager> manager =
    new osgAnimation::BasicAnimationManager;
manager->registerAnimation( animation.get() );
  1. 管理器也是一个更新回调,所以将其关联到场景图的根节点:
osg::ref_ptr<osg::Group> root = new osg::Group;
root->addChild( animRoot.get() );
root->setUpdateCallback( manager.get() );
  1. 现在播放动画。当然,我们也可以将下面的代码放在一个自定义回调中:
manager->playAnimation( animation.get() );
  1. 启动查看器:
osgViewer::Viewer viewer;
viewer.setSceneData( root.get() );
return viewer.run();
  1. 结果与动画路径完全相同。使用如此多的对象实现这样的一个简单的动画似乎有一些复杂。我们这里介绍这个示例仅是演示osgAnimation元素的整体结构,并希望能够激发更多的灵感。

What just happened?

这里osgAnimation::UpdateMatrixTransform对象是两个动画通道的目标,因为其名字PathCallback是在构造函数中设置,也被用于通道的setTargetName()方法。

但是这并不够。更新器应该知道每一个通道将会执行哪一个动作,并将通道链接到正确的动作处理器。例如,osgAnimation::Vec3LinearChannel对象可以被用来表示3D位置,或是实现旋转的欧拉角。要判断他将会被应用于实际任务,我们需要将某些堆叠的元素压入更新,每一个元素与一个预定义的通道相关联。这是通过添加到由getStackedTransforms()方法所返回的列表来实现的,该列表间接派生于std::vector。

可用的堆叠元素包含StackedTranslateElement(变换动作),StackedScaleElement(缩放动作),StackedRotateAxisElement(欧拉旋转动作),StackedQuaternionElement(四元数旋转操作)以及StackedMatrixElement(矩阵赋值操作)。所有这些类定义在osgAnimation名字空间中,并且被链接到相同名字的通道。

Loading and rendering characters

osgAnimation库具有实现特征动画的特定类。osgAnimation::Bone与osgAnimation::Skeleton类被用来构建场景图中的完整骨骼。osgAnimation::UpdateBone类定义了如何由动画通道更新骨骼。

不幸的是,在OSG中构建我们自己的特征并不容易,特别是完全从头开始时。一个较为简单的方法就是由文件载入特征模式并在我们的OSG程序中进行播放。Collada DAE是一个初学者可以用来创建并保存动画特征的格式。我们可以在https://collada.org找到更多关于开放标准与工具的信息。

Autodesk FBX也是一个很好的文件格式,但是他只能为商业软件所支持。

OSG可以通过osgDB::readNodeFile()函数同时读取两种格式,假定我们有第三方库并且编译了相应的OSG插件。要详细了解如何实现可以参考第10章。

Time for action - creating and driving a character system

现在我们要载入并播放已有的OSG人物,bignathan,动画。这是由osgAnimation作者创建的,并包含一系列的滑稽动画。在这里要执行的主要操作是由根节点获取动画管理器,列出所有可用的动画,并在其中播放特定的动画。

  1. 包含必需的头文件:
#include <osgAnimation/BasicAnimationManager>
#include <osgDB/ReadFile>
#include <osgViewer/Viewer>
#include <iostream>
  1. 我们要为程序配置两个参娄。参数–animation指定要在程序中播放的动画,而–listall在控制台列出所有可用的动画:
osg::ArgumentParser arguments( &argc, argv );
bool listAll = false;
std::string animationName;
arguments.read( "--animation", animationName );
if ( arguments.read("--listall") ) listAll = true;
  1. 确保载入bignathan.osg;否则,我们不能继续该示例。他应位于由环境变量OSG_FILE_PATH所定义的示例数据目录中。我们可以通过运行安装器或是通过查找OSG网站来获取:
osg::ref_ptr<osg::Node> model =
  osgDB::readNodeFile("bignathan.osg");
if ( !model ) return 1;
  1. 试着由根模型的更新回调中获取动画管理器:
osgAnimation::BasicAnimationManager* manager =
    dynamic_cast<osgAnimation::BasicAnimationManager*>
    ( model->getUpdateCallback() );
if ( !manager ) return 1;
  1. 现在迭代管理中所记录的所有动画。如果由命令行读取了–listall参数,则每个动画的名字也要输出到屏幕上。播放与–animation参数后的输入参数匹配的动画:
const osgAnimation::AnimationList& animations =
    manager->getAnimationList();
if ( listAll ) std::cout << "**** Animations ****" << std::endl;
for ( unsigned int i=0; i<animations.size(); ++i )
{
    const std::string& name = animations[i]->getName();
    if ( name==animationName )
        manager->playAnimation( animations[i].get() );
    if ( listAll ) std::cout << name << std::endl;
}
if ( listAll )
{
    std::cout << "********************" << std::endl;
    return 0;
}
  1. 启动查看器:
osgViewer::Viewer viewer;
viewer.setSceneData( model.get() );
return viewer.run();
  1. 启动命令行。第一步是列出所有的动画并了解一下哪一个更为有趣。输入下面的命令并查看输出(假定可执行文件名为MyProject.exe):
# MyProject.exe --listall

输出结果如下所示:

_images/osg_animation_manager_output.png
  1. 使用–animation参数输入下面命令:
# MyProject.exe --animation Idle_Head_Scratch_01
  1. 现在我们会看到一个一直在抓头的多边形男孩:
_images/osg_animation_boy.png

What just happened?

也许我们更希望创建能够运动的人物,而不是将其载入OSG中并开始渲染。但是这超出了本书的范围。有大量的3D建模软件可供我们使用:Autodesk 3dsmax,Autodesk Maya,Blender等。可以试着将我们的工作输出为FBX格式,或是我们可以选择通过某些导出工具,例如Cedric Pinson的Blender Exporter,将其转换为OSG原生格式:http://hg.plopbyte.net/osgexport/ 。这是由Mercurial控制的,这是一个非常流行的源码控制管理工具。

除了osgAnimation中的人物解决方案以外,还有更多处理人物动画的第三方工程。其中一个就是所谓的Cal3D工程。这有一个名为osgCal2的OSG封装工程。建议我们可以查看下面的网站来了解一下他们是否更适合我们的程序:

Have a go hero - analyzing the structure of your character

我们是否对bignathan的结构感兴趣?正如前面所介绍的,他应该是由osgAnimation::Bone与osgAnimation::Skeleton类构成的,这两者实际上是节点。所以,节点访问器可以用来分析场景图并了解一下他是如何组织与遍历的。

修改第5章中的访问器示例,并用来查看与操作人物文件中的所有骨骼节点。一个建议就是我们可以读取与每一个osgAnimation::Bone节点相关联的更新回调,而如果可能,将bignathan用作参数构建我们自己的biped。他们通常具有相同的骨架。

Summary

OSG支持可以应用到3D程序中的所有动画类型。最常见的是在时间上变换,这可以通过修改空间状态或是3D对象的渲染来实现,而所谓的关键帧动画被设计用来通过在帧之间进行插值实现平滑运动。骨骼系统是特征动画的关键,其中mesh被用来配置预构建的骨骼。

在本章中我们介绍了OSG动画类的功能,特殊探讨了:

  • 避免冲突修改的原因与方法,特别是当创建动态几何体时。
  • 派生回调基类,包括osg::nodeCallback,osg::StateAttributeCallback等。
  • 通过使用osg::AnimationPath与osg::AnimationPathCallback类在路径动画中插入变换值。
  • 使用简单运动类,例如osgAnimation::LinearMotion与osgAnimation::InOutCubicMotion来实现自然运动效果。
  • 使用osg::ImageSequence类生成动画纹理。
  • 如何通过使用osgAnimation库以及动画通道与控制方法创建复杂通用的关键帧动画。

Chatper 9: Interacting with Outside Elements

OSG提供了一个集中各种不同窗口系统(MFC,Qt,GLUT等)实现方式的图形用户界面(GUI)抽象库。他处理GUI事件,其中最常见的就是与外围设备,例如鼠标与键盘,的用户实时交互。另外,osgViewer库封装了用于构建渲染环境的不同窗口系统的图形环境。这些构成了本章的主题:OSG如何与其他元素交互-例如,输入设备与窗口系统。

在本章中,我们将会介绍:

  • 如何使用自定义事件处理器处理键盘与鼠标事件
  • 如何创建并处理用户定义事件
  • 如何理解场景对象的交互测试
  • 如何配置窗口特点从创建图形环境
  • 如何将渲染的场景集成到窗口系统

Various events

图形用户界面(GUI)是一个接口对象类型,允许计算机用户通过所谓的GUI事件以多种方式与程序进行交互。存在多种可以用来相应用户操作响应的事件类型,例如,移动鼠标,点击鼠标按钮,按下键盘,调整窗口大小,以及事件等待直到最终期限。

在今天的GUI框架中,总是定义部件元素来接收用户动并将其传递给事件处理器对象。后者是由高层开发者编写来实现特定功能。例如,要在点击Browse按钮时弹出一个对话框,或是当按下S键时将当前文本编辑器的内容保存到文件。

不幸的是,大多数框架,包括Windows下的MFC与.NET,Linux下的GTK+,Mac OSX下的Cocoa,以及如Qt和wxWidgets这样的跨平台系统,彼此之间不是兼容的。所以在OSG程序中直接使用非常不方便。相反,OSG为希望处理GUI事件的用户了一个基本接口,名为osgGA::GUIEventHandler。

事件处理器应使用查看器的addEventHandler()方法将其关联到场景查看器,并通过removeEventHandler()方法移除。这是一种在事件遍历过程中会被自动调用的回调类型,我们在第5章中对回调进行了介绍。

当继承osgGA::GUIEventHandler来实现我们自己的事件处理器时,最重要的工作是重写handle()方法。这个方法有两个参数:提供所接收事件的osgGA::GUIEventAdapter参数,以及用于回馈的osgGA::GUIActionAdapter参数。该方法可以写为如下形式:

bool handle( const osgGA::GUIEventAdapter& ea,
             osgGA::GUIActionAdapter& aa )
{
    // concrete operations
}

osgGA::GUIEventAdapter类将会在下一节进行介绍。osgGA::GUIActionAdapter允许处理器请求GUI执行某种动作以响应所接收的事件。大多数情况下,这实际上可以被看作查看器对象。这是因为osgViewer::Viewer类也是由osgGA::GUIActionAdapter派生。dynamic_cast<>操作符可以用在这里以安全的方式执行转换:

osgViewer::Viewer* viewer = dynamic_cast<osgViewer::Viewer*>(&aa);

这里,aa是osgGA::GUIEventHandler的handle()方法的输入参数。

Handling mouse and keyboard inputs

osgGA::GUIEventAdapter类管理所有OSG所支持的事件类型,包括其设置与获取方法。getEventType()方法返回存储在事件适配器中的当前GUI事件。每次当handle()方法被调用时,我们需要首先对其进行检测以及确定事件类型并采用相应的对策。

下表显示了OSG中的主要事件类型,以及用来获取必需事件参数的相关方法:

_images/osg_keyboard_event.png _images/osg_keyboard_event2.png

还有另一个getModKeyMask()方法可以在用来在移动或点击鼠标或在键盘上按下键时用来获取当前的修饰符键。返回值是与相应值的位或结果,包括MODKEY_CTRL,MODKEY_SHIFT,MODKEY_AT,等。所以我们可以使用下面的代码片段来检测Ctrl键是否被按下:

if ( ea.getModKeyMask()&osgGA::GUIEventAdapter::MODKEY_CTRL )
{
   // Related operations
}

注意所有与上面获取方法相对应的设置方法,包括setEventType(),setX(),setY()等,并不适合用在handle()实现中。他们通常为OSG的底层图形窗口系统用来将新事件到事件队列中。

Time for action - driving the Cessna

我们已经了解了如何通过osg::MatrixTransform节点来修改节点的变换矩阵。借助于osg::AnimationPath类与osgAnimation名字空间,我们可甚至可以在这些可变换的对象上创建动画效果。但是这对于交互场景来说并不够。我们进一步的要求是通过用户输入设备控制场景图节点。想像一下在现代的战争游戏中,我们有一个潜水艇,一辆坦克或是熟悉的Cessna。如要我们能够通过键盘,鼠标来模拟驱动这些设备,那才是真正令人激动的。

  1. 包含必需的头文件:
#include <osg/MatrixTransform>
#include <osgDB/ReadFile>
#include <osgGA/GUIEventHandler>
#include <osgViewer/Viewer>
  1. 我们的任务是通过某些按键控制Cessna模型。要处理这些用户事件,我们需要声明一个ModelController类,该类派生自osgGA::GUIEventHandler基类,并重写handle()方法来确保所有的用户事件被作为osgGA::GUIEventAdapter对象传递进来。模型指针也被包含在处理器类中;否则没有办法来区别要控制哪一个模型:
class ModelController : public osgGA::GUIEventHandler
{
public:
    ModelController( osg::MatrixTransform* node )
: _model(node)
{}
    virtual bool handle( const osgGA::GUIEventAdapter& ea,
                         osgGA::GUIActionAdapter& aa );

protected:
    osg::ref_ptr<osg::MatrixTransform> _model;
};
  1. 在handle()方法的实现中,我们要修改成员变量_model的欧拉角,这可以变换表示Cessna或是其他模型的节点。特征按键w,s,a与d通过KEYDOWN事很描述战机的前进与旋转。当然,功能按键与浏览按键,包括KEY_LEFT,KEY_RIGHT等,也可以在这里使用:
bool ModelController::handle( const osgGA::GUIEventAdapter& ea,
                              osgGA::GUIActionAdapter& aa )
{
    if ( !_model ) return false;
    osg::Matrix matrix = _model->getMatrix();

    switch ( ea.getEventType() )
    {
    case osgGA::GUIEventAdapter::KEYDOWN:
        switch ( ea.getKey() )
        {
        case 'a': case 'A':
            matrix *= osg::Matrix::rotate(-0.1f, osg::Z_AXIS);
            break;
        case 'd': case 'D':
            matrix *= osg::Matrix::rotate(0.1f, osg::Z_AXIS);
            break;
        case 'w': case 'W':
            matrix *= osg::Matrix::rotate(-0.1f, osg::X_AXIS);
            break;
        case 's': case 'S':
            matrix *= osg::Matrix::rotate(0.1f, osg::X_AXIS);
            break;
        default:
            break;
        }
        _model->setMatrix( matrix );
        break;
    default:
        break;
    }
    return false;
}
  1. 在主函数中,我们首先载入Cessna模型并将其添加到osg::MatrixTransform父节点。父节点将会被用作被控对象,并传递给ModelController处理器实例:
osg::ref_ptr<osg::Node> model = osgDB::readNodeFile( "cessna.osg"
);
osg::ref_ptr<osg::MatrixTransform> mt = new osg::MatrixTransform;
mt->addChild( model.get() );
osg::ref_ptr<osg::Group> root = new osg::Group;
root->addChild( mt.get() );
  1. 初始化模型控制器并传递变换节点作为参数:
osg::ref_ptr<ModelController> ctrler =
    new ModelController( mt.get() );
  1. 我们并不希望在该示例中使用相机操作器,因为当使用键盘与鼠标时,他也会影响查看器的模型视图矩阵,并且混肴GUI事件的处理结果。所以,除了添加所创建的事件处理器之外,我们通过setAllowEventFocus()方法来阻止主相机接收任何用户事件,并由我们自己来设置合适的视图矩阵(因为现在操作器不能联系到相机):
osgViewer::Viewer viewer;
viewer.addEventHandler( ctrler.get() );
viewer.getCamera()->setViewMatrixAsLookAt(
    osg::Vec3(0.0f,-100.0f,0.0f), osg::Vec3(), osg::Z_AXIS );
viewer.getCamera()->setAllowEventFocus( false );
  1. 现在启动查看器:
viewer.setSceneData( root.get() );
return viewer.run();
  1. 我们将会发现相机操作器(其默认行为类似于轨迹球)失去对主相机的控制,并且现在鼠标按钮不能浏览场景图。然而,现在按下四个按键将会影响Cessna模型。注意,这里的键盘事件仅作用于模型节点,而不会作用于整个场景图。我们可以向根节点添加另一个固定节点并看一下他是否会发生变化:
_images/osg_keyboard_cessna.png

What just happened?

事件处理器可以用于多种目的。在处理器回调中,我们可以移动并旋转可变换节点,记录动画路径,向父节点添加或是由父节点移除节点,计算帧速率与可用内存,以及执行其他我们所希望的操作。他是在事件遍历中触发的,因而他对于动态数据修改总是安全的。

这里一个有趣的问题是如何确定handle()方法的返回值。这里所要求的布尔值被用于指示事件是否已经被处理。如果返回true,OSG认为该用户事件不再为后续的处理器所需要,包括相机操作器。该事件将会被标记为“已处理”,并且在默认情况下会为其他的处理器或事件回调所忽略。在本书的大多数盒子中,我们并不希望该行为。所以在该示例以及后续示例中会毫无疑问的返回false。

Pop quiz - handling events within nodes

类似于更新回调,OSG同时会使用setEventCallback()与addEventCallback()方法允许事件回调被发送到节点与可绘制元素,两个方法都会接受一个osg::NodeCallback指针作为唯一参数。要在重写的operator()方法中获取事件变量,我们仅需要将节点访问器转换为osg::EventVisitor指针:

#include <osgGA/EventVisitor>

void operator()( osg::Node* node, osg::NodeVisitor* nv )
{
    std::list< osg::ref_ptr<osgGA::GUIEventAdapter> > events;
    osgGA::EventVisitor* ev = dynamic_cast<osgGA::EventVisitor*>(nv);
    if (ev) events = ev->getEvents();
    // Handle events with the node
}

我们能够区别使用节点回调与事件处理器之间主要区别吗?使用自定义的事件回调在变换节点上重新生成该示例是否更好?

Have a go hero - manipulating the cameras

无论是否相信,osgGA::CameraManipulator类也有一个名为handle()的虚方法。这实际上是由osgGA::GUIEventHandler派生,但是并不适合通过addEventHandler()方法添加到查看器。他通过调用getInverseMatrix()虚方法浏览主相机,该方法会计算操作器的逆矩阵,也就是视图矩阵,并在更新遍历中通过setViewMatrix()方法设置到主相机。所有的OSG操作器,包括用户自定义的操作器,都应重写该方法以确保他们能够正常工作。

osgGA::CameraManipulator类同时提供了setByMatrix()与getMatrix()虚方法,这两个方法可以重写为指定的矩阵或是获取矩阵。试着重写这些方法来生成我们自己的相机操作器。标准操作器,包括osgGA::TrackballManipulator,以及其他的操作器,可以作为该行为的参考。

Adding customized events

OSG使用内部事件队列在先进先出(FIFO)列表中管理到来的GUI事件。列表头的事件将会被首先处理,然后由列表中删除。也就是,每一个所添加的事件处理器的handle()方法的执行次数与事件队列的大小相同。事件队列类,名为;osgGA::EventQueue,允许新事件在任何时刻使用addEvent()方法被压入。其参数是一个osgGA::GUIEventAdapter指针,该参数使用设置方法,如setEventType()与setButton()来定义其行为。

还有一些其他的osgGA::EventQueue类的方法可以被用来快速设置与添加新GUI事件。其中一个就是userEvent()方法,该方法使用用户数据指针作为参数来适配用户定义事件。这个用户数据可以用来表示任意的自定义事件类型,例如,在下一节中将要描述的计时器事件。

并没有必要创建一个全新的事件队列对象。查看器类已经定义了一个可以进行操作的事件队列:

viewer.getEventQueue()->userEvent( data );

这里,变量data是一个由osg::Referenced派生的对象。在添加这个新事件之后,事件处理器将会接收一个USER事件,而开发者可以由处理器的getUserData()方法进行读取并执行所希望的操作。

Time for action - creating a user timer

当内部计数器到达指定的时间间隔时会触发计时器事件。这常见于各种GUI系统中,并且允许用户设置一个自定义的计时器回调来接收计时消息并实现相关的操作。

现在我们在OSG中能够实现相同的任务。因为在osgGA::GUIEventAdapter类中并没有定义标准的计时器事件,我们需要利用USER事件类型以及额外的数据指针。

  1. 包含必需的头文件:
#include <osg/Switch>
#include <osgDB/ReadFile>
#include <osgGA/GUIEventHandler>
#include <osgViewer/Viewer>
#include <iostream>
  1. 首先定义TimerInfo结构来管理计时器事件的参数(主要是计时器的触发时机)。我们需要将这个osg::Referenced派生指针关联到userEvent()方法,因为他是区分不同自定义事件的唯一元素:
struct TimerInfo : public osg::Referenced
{
    TimerInfo( unsigned int c ) : _count(c) {}
    unsigned int _count;
};
  1. TimerHandler被用于处理计时器对象与计时器事件。我们希望在每次接收到该事件时在Cessna模型的正常与燃烧状态之间进行切换。在第5章与第8章中,这是通过自定义osg::Node类与更新回调来实现的。但是这次我们将会尝试使用以osg::Switch指针作为参数的事件处理器来实现:
class TimerHandler : public osgGA::GUIEventHandler
{
public:
    TimerHandler( osg::Switch* sw ) : _switch(sw), _count(0) {}
    virtual bool handle( const osgGA::GUIEventAdapter& ea,
                         osgGA::GUIActionAdapter& aa );

protected:
    osg::ref_ptr<osg::Switch> _switch;
    unsigned int _count;
};
  1. 在重写的handle()方法有两种类型的事件需要处理。FRAME事件会自动为每一帧所触发,并且可以用来管理与增加内部计数器,当时机成熟时会向事件队列发送userEvent()。在该示例中,我们假定计时器事件在计数到100时触发。另一个是USER事件,该事件除了作为“用户数据”来指示计时器及其计数的TimerInfo对象以外不包含任何其他信息。这里,我们将会输出计数值并在变量_switch的子节点之间切换:
bool TimerHandler::handle( const osgGA::GUIEventAdapter& ea,
                           osgGA::GUIActionAdapter& aa )
{
    switch ( ea.getEventType() )
    {
    case osgGA::GUIEventAdapter::FRAME:
        if (_count % 100 == 0 )
        {
            osgViewer::Viewer* viewer =
                 dynamic_cast<osgViewer::Viewer*>(&aa);
            if ( viewer )
            {
                viewer->getEventQueue()->userEvent(
                    new TimerInfo(_count) );
            }
        }
        _count++;
        break;
    case osgGA::GUIEventAdapter::USER:
        if ( _switch.valid() )
        {
            const TimerInfo* ti =
                dynamic_cast<const TimerInfo*>( ea.getUserData()
);
            std::cout << "Timer event at: " <<ti->_count<<
            std::endl;

            _switch->setValue( 0, !_switch->getValue(0) );
            _switch->setValue( 1, !_switch->getValue(1) );
        }
        break;
    default:
        break;
    }
    return false;
}
  1. 在主函数中,我们简单创建一个切换节点,该节点包含一个正常的Cessna模型与一个燃烧的Cessna模型:
osg::ref_ptr<osg::Node> model1= osgDB::readNodeFile("cessna.osg");
osg::ref_ptr<osg::Node> model2= osgDB::readNodeFile("cessnafire.osg");
osg::ref_ptr<osg::Switch> root = new osg::Switch;
root->addChild( model1.get(), false );
root->addChild( model2.get(), true );
  1. 将计时器事件发送与处理器添加到查看器,并启动:
osgViewer::Viewer viewer;
viewer.setSceneData( root.get() );
viewer.addEventHandler( new TimerHandler(root.get()) );
return viewer.run();
  1. 正如我们多次看到的,Cessna在完整与燃烧之间切换。另外,在控制台屏幕上会有消息输出,通知我们计时器何时被触发:
_images/osg_timer_event.png

What just happened?

在这里我们利用FRAME事件并将用户事件发送到事件队列。这会导致有些复杂的体系结构:事件的发送者与接收者是同一个TimerHandler类。这类似于发信人与接收人是同一个人。

事实上,我们可以避免该问题。发送用户事件的时机是由每一帧决定的。一个新用户事件可以在更新与裁剪遍历中的任何时刻被添加到事件队列。也就是,回调,自定义节点与可绘制元素都可以被用作事件发送器,而不仅仅是事件处理器本身。这使得获取与处理如操纵杆与数据手套这样的复杂事件成为可能。使用必要的信息声明一个名为JoyStickInfo或是DataGloveInfo的结构,设置其属性,在更新回调中使用结构实例发送用户事件,并在处理器中处理用户事件。这是我们利用用户事件机制所需要做的全部工作。

Picking objects

选取功能可以允许用户在已渲染场景的部分上移动鼠标并点击按钮。结果也许是3D世界中打开或是关闭门,或者射击入侵敌人的动作。要执行这些动作类型需要三个主要步骤。

首先,我们使用一个事件处理器来接收鼠标事件。例如,鼠标压入带有光标X与Y位置的事件,当然,这是选择操作最重要的因素。

其次,我们需要确定场景图的哪一部分位于鼠标光标之下。这可以通过使用osgUtil库的交集工具来实现。结果是一个包括选中的可绘制元素,其父节点路径,交集点等的交集集合。

最后,我们利用交集结果来实现我们选中对象或是使其飞翔的目的。

Intersection

OSG有其自己的交集策略,该策略利用节点访问者机制来减少时间消耗。这种策略要比OpenGL的选择特性高效得多。osgUtil::IntersectionVisitor类是实现者。该类继承自osg::NodeVisitor类并且可以将节点的边界值与输入的交集相比对,并且快速略过在遍历过程中不相交的子场景图。

osgUtil::IntersectionVisitor对象使用osgUtil::Intersector派生对象作为其构建函数的参数。他可以配置为使用多种交集器进行相交测试,包括线段,面板与多边形。一个交集器可以作用在四种类型的坐标系统中,其中的每一个都具有不同的输入参数,并且使用不同的转换矩阵将其转换为世界空间。在下表中,我们将会使用线段交集器类osgUtil::LinearSegmentIntersector来作为示例:

_images/osg_intersection.png

假定我们要在一个事件处理器的handle()方法中进行交集测试。在这里WINDOW坐标系统可以用来获取一条由鼠标位置到3D场景的光线。下面的代码片段显示了如何在一个相机节点camera上来实现:

osg::ref_ptr<osgUtil::LineSegmentIntersector> intersector =
    new osgUtil::LineSegmentIntersector(
        osgUtil::Intersector::WINDOW, ea.getX(), ea.getY()
    );
osgUtil::IntersectionVisitor iv( intersector.get() );
camera->accept( iv );

交集器的containsIntersections()方法可以被用来检测是否存在交集结果。osgUtil::LineSegmentIntersector的getIntersections()方法返回一个Intersection集合变量,依据距离查看器的远近进行排序。交集指针可以通过调用一个结果变量的getLocalIntersectPoint()方法或getWorlIntersectPoint()方法获得,例如:

osgUtil::LineSegmentIntersector::Intersection& result =
    *( intersector->getIntersections().begin());
osg::Vec3 point = result.getWorldIntersectPoint();  // in world space

第一行也可以重写为:

osgUtil::LineSegmentIntersector::Intersection& result =
    intersector->getIntersections().front();

类似的,我们可以获取相交的drawable对象,其父节点路径nodePath,甚至是列出与线段相交的三角形的所有顶点与索引的indexList,以备后续使用。

Time for action - clicking and selecting geometries

这次我们的任务是实现3D软件中常见的一个任务-点击来选择空间中的一个对象并且在对象周围显示一个选取框。所选几何体的边界框非常适于表示选取框,而osg::ShapeDrawable类可以快速生成一个用于显示目的的方框。然后osg::PolygonMode属性将会使得渲染管线只绘制盒子的边框,从而有助于将选取框显示为边框。这就是我们生成一个实际选择对象功能所需要的全部知识。

  1. 包含必需的头文件:
#include <osg/MatrixTransform>
#include <osg/ShapeDrawable>
#include <osg/PolygonMode>
#include <osgDB/ReadFile>
#include <osgUtil/LineSegmentIntersector>
#include <osgViewer/Viewer>
  1. PickHandler将会完成我们任务所需要的所有操作,包括鼠标光标与场景图的相交测试,创建并返回选取方框节点(该示例中的_selectionBox变量),以及当按下鼠标按钮时将方框放置在所选择对象的周围:
class PickHandler : public osgGA::GUIEventHandler
{
public:
    osg::Node* getOrCreateSelectionBox();
    virtual bool handle( const osgGA::GUIEventAdapter& ea,
                         osgGA::GUIActionAdapter& aa );

protected:
    osg::ref_ptr<osg::MatrixTransform> _selectionBox;
};
  1. 在下面的方法中,我们将会分配并向主函数返回一个可用的选取方框节点。这里有需要注意的几点:首先,osg::Box对象不会在运行时变化,但出于简化操作的目的,将会使用父变换节点并修改;其次,GL_LIGHTING模式与osg::PolygonMode属性会被用来使得选择方框更为自然;最后,还有一个会让人迷惑的setNodeMask()调用,我们会在稍后进行解释:
osg::Node* PickHandler::getOrCreateSelectionBox()
{
    if ( !_selectionBox )
    {
        osg::ref_ptr<osg::Geode> geode = new osg::Geode;
        geode->addDrawable(
            new osg::ShapeDrawable(new osg::Box(osg::Vec3(), 1.0f)) );
        _selectionBox = new osg::MatrixTransform;
        _selectionBox->setNodeMask( 0x1 );
        _selectionBox->addChild( geode.get() );
        osg::StateSet* ss = _selectionBox->getOrCreateStateSet();
        ss->setMode( GL_LIGHTING, osg::StateAttribute::OFF );
        ss->setAttributeAndModes(new osg::PolygonMode(osg::PolygonMode::FRONT_AND_BACK,osg::PolygonMode::LINE));
    }
    return _selectionBox.get();
}
  1. 我们将会严格限制选择场景对象的时机以确保相机操作可以正常工作。只有当用户持续按下Ctrl按键并释放鼠标左键时他才会被调用。然后,我们通过转换osgGA::GUIActionAdapter对象来获得查看器,并创建相交访问器来查找可以为鼠标光标所选择的节点(这里要注意setTraversalMask()方法,该方法将会与setNodeMask()方法一同介绍)。所得到的可绘制元素对象及其父节点路径可以用来描述选取框的空间位置与编写:
bool PickHandler::handle( const osgGA::GUIEventAdapter& ea,
                          osgGA::GUIActionAdapter& aa )
{
    if ( ea.getEventType()!=osgGA::GUIEventAdapter::RELEASE ||
         ea.getButton()!=osgGA::GUIEventAdapter::LEFT_MOUSE_BUTTON
||
         !(ea.getModKeyMask()&osgGA::GUIEventAdapter::MODKEY_CTRL)
)
        return false;
    osgViewer::Viewer* viewer =
      dynamic_cast<osgViewer::Viewer*>(&aa);
    if ( viewer )
    {
        osg::ref_ptr<osgUtil::LineSegmentIntersector>
            intersector =
            new osgUtil::LineSegmentIntersector(
                osgUtil::Intersector::WINDOW, ea.getX(), ea.getY()
            );
        osgUtil::IntersectionVisitor iv( intersector.get() );
        iv.setTraversalMask( ~0x1 );
        viewer->getCamera()->accept( iv );

        if ( intersector->containsIntersections() )
        {
            osgUtil::LineSegmentIntersector::Intersection&
                result =
                *(intersector->getIntersections().begin());

            osg::BoundingBox bb = result.drawable->getBound();
            osg::Vec3 worldCenter = bb.center() *
                osg::computeLocalToWorld(result.nodePath);
            _selectionBox->setMatrix(
                osg::Matrix::scale(bb.xMax()-bb.xMin(),
                                  bb.yMax()-bb.yMin(),
                                  bb.zMax()-bb.zMin()) *
                osg::Matrix::translate(worldCenter) );
        }
    }
    return false;
}
  1. 其他的工作并不难理解。我们首先通过将两个模型添加到根节点来构建场景图:
osg::ref_ptr<osg::Node> model1 = osgDB::readNodeFile( "cessna.osg"
);
osg::ref_ptr<osg::Node> model2 = osgDB::readNodeFile( "cow.osg" );
osg::ref_ptr<osg::Group> root = new osg::Group;
root->addChild( model1.get() );
root->addChild( model2.get() );
  1. 我们创建选取处理器,同时将getOrCreateSelectionBox()的值添加到根节点。这将会使得选取方框在场景图中可见:
osg::ref_ptr<PickHandler> picker = new PickHandler;
root->addChild( picker->getOrCreateSelectionBox() );
  1. 好了,现在使用PickHandler对象作为自定义的事件处理器启动查看器:
osgViewer::Viewer viewer;
viewer.setSceneData( root.get() );
viewer.addEventHandler( picker.get() );
return viewer.run();
  1. 按下Ctrl键并点击牛。我们将会看到出现一个白色的选取框。试着移动鼠标并点击Cessna而不松开Ctrl键。现在选取框移动到Cessna模型,覆盖其所有顶点。如果Ctrl键没有被持续按下,那么所有其他的操作都不会有影响:
_images/osg_selection_box.png

What just happened?

osg::Node类的setNodeMask()方法出于某些特殊的目的而介绍。他使用特定的场景控制器来执行位逻辑与操作来指示节点对于控制器是否可用。例如,要使得某个节点及其子场景图对相交访问器不可访问,我们可以修改两个操作符,其中一个由setNodeMask()来定义,而另一个则由osg::NodeVisitor类的setTraversalMask()方法来定义,从而使得逻辑与的结果为零。这就是在前面的示例中有这两行代码的原因:

_selectionBox->setNodeMask( 0x1 );
iv.setTraversalMask( ~0x1 );

这可以使得选取框本身不能为访问器所选取,如下图所示:

_images/osg_node_mask.png

Have a go hero - selecting geometries in a rectangular region

osgUtil::LineSegementIntersector可以用来计算线段与场景图之间的相交。他同时接受模型与窗口坐标系统,从而使得屏幕上的鼠标位置可以被转换由近至远的一条线,从而获得所需要的结果。

但是如果我们左键点击并在要选中的场景对象周围拖出一个矩形区域会出现什么情况呢?要形成一个矩形需要记录四个点,而且实际上是模型坐标中的八个点,从而形一个多边形。推荐osgUtil::PolytopeIntersector用于该目的。这会接受坐标帧与四个场景点作为输入参数,并返回一个相交列表作为结果。试着使得该类来选取多个几何体并全部列出。

Windows, graphics contexts, and cameras

在第7章中,我们已经看到osg::Camera类管理与其相关联的OpenGL图像环境,这是通过简单的setGraphicsContext()方法实现的。图像环境实际上封装场景对象被绘制以及渲染状态被应用方式的信息。他可以是一个提供相关窗口API的图像窗口,或是其他缓冲区对象,例如,OpenGL像素缓冲区,存储像素数据而不会将其传递给帧缓冲区。

OSG使用osg::GraphicsContext类来表示抽象图像环境,以及osgViewer::GraphicsWindow类来表示抽象图像窗口。后者还有一个管理GUI事件的getEventQueue()方法。其平台特定的子类会继续向这个事件队列中添加新事件。

由于窗口API(Windows,X11,Mac OS X等)的不可知性,图像环境必须被创建为平台特定图像环境。osg::GraphicsContext的createGraphicsContext()方法会自动为我们做出选择。其唯一的参数,osg::GraphicsContext::Traits指针,将会提供需要哪种图像窗口或缓冲区类型的说明。

The Traits class

osg::GraphicsContext::Traits类可以设置特定图像环境的属性。该类不同于osg::DisplaySettings类,后者管理所有新创建相机的图像环境的特性。Traits类使用公共类成员变量来指示属性,而没有大量的设置与获取属性的方法。这会在createGraphicsContext()被调用时立即生效。Traits的主要组成部分列在下表中:

_images/osg_traits_table1.png _images/osg_traits_table2.png

要创建一个新的Traits指针并设置一个或多个成员变量,我们可以输入下面的代码:

osg::ref_ptr<osg::GraphicsContext::Traits> traits =
    new osg::GraphicsContext::Traits;
traits->x = 50;

Time for action - configuring the traits of a rendering window

我们将创建一个固定尺寸的窗口来包含OSG场景的渲染结果。主要步骤包括:配置渲染窗口的特性(trait),依据特性创建一个图形环境,将图形环境关联到相机,最后将相机设置为查看器的主相机。

  1. 包含必需的头文件:
#include <osg/GraphicsContext>
#include <osgDB/ReadFile>
#include <osgViewer/Viewer>
  1. 创建一个特性结构并设置其属性。这里的采样值被设置来允许当前窗口的多重采样功能,但使其他窗口保持默认值(非多重采样)。这不同于osg::DisplaySettings类的setNumMultiSamples()方法:
osg::ref_ptr<osg::GraphicsContext::Traits> traits =
    new osg::GraphicsContext::Traits;
traits->x = 50;
traits->y = 50;
traits->width = 800;
traits->height = 600;
traits->windowDecoration = true;
traits->doubleBuffer = true;
traits->samples = 4;
  1. 使用createGraphicsContext()函数创建图形环境。在这里注意,不要使用new操作符创建新的图形环境,否则OSG不能为其确定底层窗口平台:
osg::ref_ptr<osg::GraphicsContext> gc =
    osg::GraphicsContext::createGraphicsContext( traits.get() );
  1. 然后图形环境被关联到新创建的相机节点。他将会被用作整个场景的主相机,所以我们需要指定清除掩码与颜色使其功能类似于普通的OSG相机。在这里表示投影矩阵也同样非常重要。但是我们并不需要总是修改投影矩阵,因为他会由渲染后端在合适的时机进行重新计算与更新:
osg::ref_ptr<osg::Camera> camera = new osg::Camera;
camera->setGraphicsContext( gc );
camera->setViewport(
    new osg::Viewport(0, 0, traits->width, traits->height) );
camera->setClearMask( GL_DEPTH_BUFFER_BIT | GL_COLOR_BUFFER_BIT );
camera->setClearColor( osg::Vec4f(0.2f, 0.2f, 0.4f, 1.0f) );
camera->setProjectionMatrixAsPerspective(
    30.0f,(double)traits->width/(double)traits->height,
      1.0,1000.0 );
  1. 载入模型作为场景图:
osg::ref_ptr<osg::Node> root = osgDB::readNodeFile(
  "cessna.osg" );
  1. 将相机设置到查看器并像平时一样启动:
osgViewer::Viewer viewer;
viewer.setCamera( camera.get() );
viewer.setSceneData( root.get() );
return viewer.run();
  1. 现在我们使得Cessna模型显示在渲染窗口中。我们依然可以在窗口中浏览,运行前面的示例,并测试代码。要再次以全屏幕模式渲染,将width与height属性设置为屏幕尺寸,并将windowDecoration设置为false。
_images/osg_render_in_window.png

What just happened?

我们已经在第7章中利用了setUpViewInWindow()方法。他构建一个窗口而不是全屏幕模式来显示渲染结果。无论是否相信,setUpViewInWindow()方法的内容几乎与这里的示例相同。他配置特性,创建特定的图形环境,将其关联到新相机,最后将相机设置为查看器的主相机。其他方法,例如setUpViewFor3DSphericalDisplay(),在渲染开始时执行类似的操作来生成渲染容器。但是之后,他们总是使用特殊的投影矩阵生成多个相机对象来实现丰富的效果。在这些情况下,渲染到纹理技术也非常有用。

Integrating OSG into a window

界面开发者也许工作在各种GUI系统下,并且需要将OSG场景图集成到其UI部件中。依据不同的GUI工作策略,在技术上很难有嵌入OSG查看器的通用方法。然而,确实存在一个我们可以用来使得集成更为简单的技巧:

  • 将窗口句柄关联到osg::GraphicsContext::Traits类的inheritedWindowData。窗口句柄类型可以是一个WIN32的HWND,X11的Window以及Cocoa的WindowRef。之后,OSG将会在继承的窗口上管理OpenGL渲染环境以及绘制调用,从而将整个场景渲染到窗口表面。
  • osgViewer::Viewer类的frame()方法应被连续执行。出于此目的,我们或者可以使用一个单独的线程,或者是使用一个间隔足够短的GUI计时器处理器。
  • 对于支持直接OpenGL绘制调用的部件(Qt的QGLWidget,GLUT,FLTK,等),使用osgViewer::GraphicsWindowEmbedded类来创建图像环境,而无需担心渲染环境与相关的缓冲区属性。OSG查看器的frame()方法必须在部件类的一个连续更新方法中执行。
  • 绝不要在GUI回调或事件处理器中修改场景图(动态修改节点与状态属性,添加或是移除子节点,等)。相反,使用OSG原生的方法可以避免线程冲突。另一个低效的方法是强制查看器使用单线程模式,我们将会在第12章中进行介绍。

Time for action - attaching OSG with a window handle in Win32

Win32程序中的窗口句柄(HWND)使得系统资源知道他所引用的是哪种类型的窗口对象。HWND变量也许会封装对话框,按钮,MDI或SDI窗口等。在这种情况下,将句柄关联到OSG特性,然后关联到图形环境将会使得OSG与Win32 GUI控件集成在一起成为可能,因而可以在各种用户界面对象中显示3D屏幕。

  1. 包含必需的头文件:
#include <windows.h>
#include <process.h>
#include <osgDB/ReadFile>
#include <osgGA/TrackballManipulator>
#include <osgViewer/api/win32/GraphicsWindowWin32>
#include <osgViewer/Viewer>
  1. 在这里声明两个全局变量;我们会在稍后进行解释:
osg::ref_ptr<osgViewer::Viewer> g_viewer;
bool g_finished;
  1. 我们希望使用Win32 API中的CreateWindow()函数来创建一个典型的弹出窗口。他必须使用WNDCLASS结构来定义风格以及自定义的窗口程序(procedure)。在大多数情况下,程序是一个指向处理传递给窗口的窗口信息的静态函数:
static TCHAR szAppName[] = TEXT("gui");
WNDCLASS wndclass;
wndclass.style = CS_HREDRAW | CS_VREDRAW;
wndclass.lpfnWndProc = WndProc;
wndclass.cbClsExtra = 0;
wndclass.cbWndExtra = 0;
wndclass.hInstance = 0;
wndclass.hIcon = LoadIcon(NULL, IDI_APPLICATION);
wndclass.hCursor = LoadCursor(NULL, IDC_ARROW);
wndclass.hbrBackground = (HBRUSH)GetStockObject(WHITE_BRUSH);
wndclass.lpszMenuName = NULL;
wndclass.lpszClassName = szAppName;
if ( !RegisterClass(&wndclass) )
    return 0;
  1. 在(100,100)位置处创建一个800x600的窗口。如果成功则返回窗口句柄,OSG渲染窗口特性将其用于集成处理。我们可以将图形环境的初始化代码放置在这里,或是放在WM_CREATE语句中:
HWND hwnd = CreateWindow( szAppName, // window class name
                          TEXT("OSG and Win32 Window"),
                          // caption
                          WS_OVERLAPPEDWINDOW, // window style
                          100, // initial x position
                          100, // initial y position
                          800, // initial x size
                          600, // initial y size
                          NULL, // parent window handle
                          NULL, // window menu handle
                          0, // program instance handle
                          NULL ); // creation parameters
ShowWindow( hwnd, SW_SHOW );
UpdateWindow( hwnd );
  1. 创建一个消息循环由内部队列获取消息并将其分发给相应的窗口过程:
MSG msg;
while ( GetMessage(&msg, NULL, 0, 0) )
{
    TranslateMessage( &msg );
    DispatchMessage( &msg );
}
return 0;
  1. 现在,在WndProc()处理的实现中,我们将会试着初始化OSG查看器并将其嵌入到所创建的窗口中。这可以在WM_CREATE语句中完成。首先,创建一个WindowData结构来包含HWND句柄。然后将其应用于特性以及所创建的平台特定的图形环境。之后,相机与查看器依次被初始化。在这里setKeyEventSetsDonw()被用来禁止使用Esc键退出OSG程序。最后,我们启动一个新的渲染线程用于查看器中的帧。这也正是我们在开始处声明两个全局变量的原因:
case WM_CREATE:
{
    osg::ref_ptr<osg::Referenced> windata =
        new osgViewer::GraphicsWindowWin32::WindowData( hwnd );
    osg::ref_ptr<osg::GraphicsContext::Traits> traits =
        new osg::GraphicsContext::Traits;
    traits->x = 0;
    traits->y = 0;
    traits->width = 800;
    traits->height = 600;
    traits->windowDecoration = false;
    traits->doubleBuffer = true;
    traits->inheritedWindowData = windata;

    osg::ref_ptr<osg::GraphicsContext> gc =
        osg::GraphicsContext::createGraphicsContext( traits.get()
);

     osg::ref_ptr<osg::Camera> camera = new osg::Camera;
    camera->setGraphicsContext( gc );
    camera->setViewport(
        new osg::Viewport(0, 0, traits->width, traits->height) );
    camera->setClearMask( GL_DEPTH_BUFFER_BIT |
                          GL_COLOR_BUFFER_BIT );
    camera->setClearColor( osg::Vec4f(0.2f, 0.2f, 0.4f, 1.0f) );
    camera->setProjectionMatrixAsPerspective(
      30.0f,(double)traits->width/(double)traits
                          ->height,1.0,1000.0 );

    g_viewer = new osgViewer::Viewer;
    g_viewer->setCamera( camera.get() );
    g_viewer->setSceneData( osgDB::readNodeFile("cessna.osg") );
    g_viewer->setKeyEventSetsDone( 0 );
    g_viewer->setCameraManipulator(
      new osgGA::TrackballManipulator );

    g_finished = false;
    _beginthread( render, 0, NULL );
    return 0;
}
  1. 在WM_DESTROY中,我们需要在释放窗口句柄之前强制退出OSG渲染。setDone()方法通知OSG停止所有的处理并等待程序退出。在这里Sleep()方法非常适用于处理多线程,他会在其完成之前将当前时间片颁给渲染线程:
case WM_DESTROY:
    g_viewer->setDone( true );
    while ( !g_finished ) Sleep(10);
    PostQuitMessage( 0 );
    return 0;
  1. 开启额外渲染线程执行的例程只有做一件事,也就是,他会继续渲染新帧直到查看器通知其停止:
void render( void* )
{
    while ( !g_viewer->done() )
        g_viewer->frame();
    g_finished = true;
}
  1. 现在启动程序。我们将会看到Cessna模型出现在一个新窗口中。osgViewer::Viewer的run()并没有被直接使用,而是使用一个单独的渲染线程将OSG场景图绘制到窗口的图形环境。当然,如果时间间隔对于模拟3D世界足够短,WM_TIMER信息对于帧的连续运行同样可用:
_images/osg_integrate_with_window.png

What just happened?

几乎所有的操作系统类型都支持用于指定基于OpenGL程序的渲染环境的功能。在Windows系统下,WGL(Windows GL)函数被用来将相关的Windows API支持带到OpenGL中,例如wglCreateContext()与wglMakeCurrent()。开发者应首先创建并设置指向类GDI渲染环境的句柄,并且只在当前的环境被允许时才执行OpenGL调用。所有以上这些被封装在内部的osgViewer::GraphicsWindowWin32类中。类似的,同时还有GraphicsWindowX11,GraphicsWindowCarbon与GraphicsWindowCocoa类用于不同的操作系统,从而将OSG程序由维护其程序的可移植性中解放出来,特别在类似Qt这样的跨平台GUI系统中。

在其他的如MFC这样的平台相关的GUI系统中,要遵循的最重要步骤是获取并将窗口句柄(HWND)关联到图形环境的特性上。这总是可以通过GetSafeHwnd()方法由CWND对象中获取。如果GUI系统允许,使用单独的线程渲染帧将会显得更为巧妙。

Have a go hero - embedding into GUI systems

在OSG中有一个特殊的图形环境,名为osgViewer::GraphicsWindowEmbedded。他假定包含图形环境的窗口支持OpenGL而无需其他的操作。在这种情况下,我们可以直接分配一个新的嵌套图形窗口并将其关联到相机,例如:

gw = new osgViewer::GraphicsWindowEmbedded(x,y,width,height);
camera-> setGraphicsContext( gw );

然后,当GUI运行时我们需要以一定的速率绘制帧,并随时将键盘与鼠标事件发送到图形环境的事件队列,例如:

gw->getEventQueue()->keyPress( 'W' );

用于测试嵌套图形环境的GUI就是GLUT库,该库直接支持OpenGL调用。试着使用osgViewer::GraphicsWindowEmbedded类实现OSG与GLUT的集成。examples子目录下的osgviewerGLUT示例也会为我们提供极大的帮助。

现在我们知道,OSG可以与GUI系统,如Qt,MFC,wxWidgets,GTK,SDL,GLUT,FLT,FOX以及Cocoa,实现集成。我们可以在OSG源码的examples目录下找到所有这些实现。

Summary

本章教会了我们用户如何使用OSG的GUI事件适配器与处理器与3D场景进行交互。不同平台下的不同窗口系统的事件被转换为名为osgGA::GUIEventAdapter的兼容接口。

同时我们介绍了将OSG场景图集成到2D窗口系统中的常见解决方案。这里的关键点是使用相应的窗口特征来创建图像环境,包括尺寸,显示设置以及窗口处理参数。

在本章中,我们特别讨论了:

  • 使用osgGA::GUIEventHandler类处理普通的用户事件,该类使用osgGA::GUIEventAdapter来传入事件,以及osgGA::GUIActionAdapter来接收额外的请求(实际上,大多数情况下为查看器对象)。
  • 使用osgGA::EventQueue自定义并发送用户定义的GUI事件。
  • 使用osgUtil::IntersectionVisitor访问器以及如osgUtil::LineSegmentIntersector的操作符进行场景对象测试。
  • 如何使用osg::GraphicsContext::Traits来设置要被渲染窗口的特性。
  • 用于渲染场景的图像环境嵌入到窗口系统中,例如,一个Win32 API窗口句柄。在源码的examples目标可以找到更多的示例。

Chapter 10: Saving and Loading Files

编程中一个更为重要的概念就是在程序结束之后存储信息的能力。这有许多优点,例如较小的可执行大小,容易实现模块化,以及程序记住不同用户信息的能力。

osgDB库提供对读取与写入场景图模型,图像以及其他对象的支持。他同时实现了一个插件框架以及文件I/O实用程序类。他允许各种文件模式,包括将整个场景图元素封装为文本或是二进制文件,可以按需动态载入的OSG原生格式。

在本章中,我们将会讨论:

  • OSG中所实现的文件I/O机制
  • 当前所支持的文件格式的完整列表,包括模型,图像,字体等
  • OSG伪载入的概念与使用
  • 如何自定义OSG插件接口并为用户定义的格式提供支持
  • 如何创建类包装器为OSG原生格式的序列I/O提供支持

Understanding file I/O plugins

在第2章中我们已经了解一些读取与写入数据文件的插件机制。借助于特定的格式管理插件,OSG可以由处部文件载入各种模型,图像,字体甚至视频数据。这里的插件独立于自定义基于OSG程序的支持文件格式的功能组件。他被看作是实现必需读取或写入接口(或同时两者)的共享库文件。用户需要不同的插件来载入并构建大而复杂的场景图而不需要更多的编程工作。

所有的文件I/O插件遵循相同的命名约定;否则他们不会被识别并且不能用于读取文件。以原生的.osg文件格式作为示例:在Windows系统下,插件库文件是osgdb_osg.dll。在Linux下名字则为osgdb_osg.so。两者都具有osgdb_前缀,而其后的名字通常表示文件扩展名。

然而,一个插件有可能支持多个扩展名。例如,JPEG图像格式使用.jpeg与.jpg作为最常见的文件扩展名。在两者之间并没有本质的区别,所以统一的osgdb_jpeg插件应该足够处理任意扩展名的输入与输出。幸运的是,osgDB库可以通过一个内部的扩展到处理器映射来支持这种插件类型,我们将会在后面进行介绍。

在文件I/O插件准备好并且放置在可以被引用的位置之后,我们可以使用osgDB名字空间的函数来读取OSG场景节点或是图像:

osg::Node* node = osgDB::readNodeFile( "cow.osg" );
osg::Image* image = osgDB::readImageFile( "Images/lz.rgb" );

正如我们刚才所讨论的,OSG将会自动查找名为osgdb_osg与osgdb_rgb的插件库文件并由磁盘读取这两个文件。所需的数据文件应位于特定的相对或是绝对路径,或是由环境变量OSG_FILE_PATH所定义的OSG查找路径中。

Discovery of specified extension

查找与定位用于特定文件类型处理的基本原则可以使用以下两步来描述:

首先,OSG在osgDB::Registry类管理一个常用的插件列表。这个类被设计为单例模式,并且只能使用instance()方法进行实例化与获取。osgDB注册的保护插件列表有助于基于责任链设计模式快速查找并调用所需格式的读或写实体。这意味着每一个插件对象,在OSG中称为读取器-写入器,将会尝试处理输入文件的扩展,如果扩展不为该插件所识别,则将其传递给列表中的下一个插件。

如果预存储的读取器-写入器均不能处理该文件扩展,OSG将会使用该扩展作为一个关键词来查找并由外部共享模块载入插件,也就是osgdb_<ext>库文件。这里,<ext>表示扩展字符串,但是扩展到处理器映射同时被用于确定扩展与特殊插件库名的关系。例如,我们可以使用addFileExtensionAlias()方法定义扩展字符串与插件名字的关系:

// Add alias extensions for specified plugin
osgDB::Registry::instance()->addFileExtensionAlias( "jpeg", "jpeg" );
osgDB::Registry::instance()->addFileExtensionAlias( "jpg", "jpeg" );
// Now OSG can read .jpg files with the osgdb_jpeg plugin
osg::Image* image = osgDB::readImageFile( "picture.jpg" );

在任何其他的读取与写入操作之前调用这两行会自动将*.jpeg与*.jpg文件链接到osgdb_jpeg库文件,后者是在这种文件类型需要使用时自动载入的。

注意,我们并不需要为JPEG支持添加这样的别名,因为当注册对象被实例化时他已经被嵌入其中了。支持多文件格式的OSG预定义插件列在下节的表格中。

Supported file formats

在这里我们列表OSG 3.0所支持的插件列表。其中的一些需要第三方依赖,我们在Notes列表中进行标识。Interface属性表明插件是否支持读(R)或写(W)接口。Extensions列的省略号表明插件支持其他的文件格式。我们可以在源码的src/osgPlugins目标中找到更为详细的信息。

_images/osg_support_file_formats1.png _images/osg_support_file_formats2.png _images/osg_support_file_formats3.png _images/osg_support_file_formats4.png _images/osg_support_file_formats5.png

为特定插件所列出的关于配置第三方依赖的细节可以在本章中的Configuring third-party dependencies一节中找到。另外,还有另一个名为Zlib的重要工程,被用作osgDB库与osgdb_ive插件的可选部分以允许OSG原生文件格式的压缩,并且也为某些第三方工程所需要。

The pseudo-loader

在前面的表中,某些扩展被标记为伪载入器。这意味着他们并不是真正的文件扩展名,而只是向真正的文件名添加一个后缀以表明该文件应由特定的插件读取。例如:

osgviewer worldmap.shp.ogr

磁盘上的真正文件为worldmap.shp,该文件将以ESRI的shapefile格式存储整个世界地图。后缀.ogr强制osgdb_ogr来读取.shp文件并构建场景图;否则将按默认设置自动查找并使用osgdb_shp。

另一个很好的示例就是osgdb_ffmpg插件。FFmpeg库支持超100种不同的编码。要读取任意一个,我们只是简单的文件名之后个后缀.ffmpeg,并将工作留给FFmpeg本身。

另外,我们已经看到一些其他如下面格式的伪载入器:

node = osgDB::readNodeFile( "cessna.osg.0,0,90.rot" );

真正的文件名与后缀之间的字符串0,0,90是参数。某些伪载入器需要特定的参数来使其正常工作。

Time for action - reading files from the Internet

要理解伪载入器的使用,我们将会尝试由网络载入一个模型。osgviewer实用程序对于执行该示例就足够了,但是我们也可以在基于OSG的程序中使用osgDB::readNodeFile()函数来实现相同的结果。

  1. 模型已存在于下列URL处:http://www.openscenegraph.org/data/earth_bayarea/earth.ive
  2. 在尝试由互联网或是内联网读取文件之前,我们最好检查一下OSG插件目录以确定是否存在osgdb_curl插件。如果我们使用第2章所描述的安装器进行安装,则应该存在该插件。但是对于由源代码编译OSG的开发者而言,在CMake配置中也许会被忽略。对于后一种情况,请参考下一节并首先获取该插件。
  3. 使用下面的参数启动osgviewer程序:
# osgviewer http://www.openscenegraph.org/data/earth_bayarea/earth.ive.curl
  1. .curl后缀通知OSG使用osgdb_curl插件来载入特定的文件。多余的部分会自动被读取器-写入器接口移除。
  2. 现在我们将会在屏幕上看到一个地球模型。使用我们的相机操作器旋转并缩放视图矩阵,并试着在地图上找到我们家的位置:
_images/osg_earth.png

What just happened?

尽管整个地球模型对于浏览来说比较粗略,我们依然会发现某些部分在我们缩放时会变得更为详细。实际上该模型是由一个;osg::PagedLOD节点树构成的,其中的每一个节点存储在远程站点上的一个单独文件中,并管理不同层级的地形几何体。该技术被称之为四边形树,我们会在本书的最后一章进行详细描述。

当由特定的URL分析并载入文件时,osgdb_curl插件会有极大的帮助。他依赖于一个名为libcurl的第三方库,该库提供了一个易于使用的客户端URL传播接口。在这里伪载入器机制可以快速确定所需要的文件名是否应被直接发送到osgdb_curl;否则OSG会首先检测文件名是否包含一个远程地址,并做出最终的决定。

Pop quiz - getting rid of pseudo-loaders

有些人也许会重命名伪载入器,例如,可以读取.avi,.mpg以及其他多种媒体格式的osgdb_ffmpeg库,或是类似osgdb_avi的其他插件名。然后,.ffmpeg后缀会变得不可用,并且只有.avi文件可以通过使用osgDB::readNodeFile()函数直接读取。现在,我们明白伪载入器失效的原因,以及如何使得新的osgdb_avi插件对于.mpg以及其他原始支持的格式依然可用吗?

Configuring third-party dependencies

我们是否使用过本地编译器与CMake系统由源码构建过OSG?那么当与安装器所提供的组件相比,我们就会发现在自制的OSG中有大量的未编译的组件。例如:

# osgviewer --image picture.jpg

图像picture.jpg也许不会显示,尽管他位于正确的查找路径中。如果我们遇到这种情况,查看一下插件目录,我们就会发现没有osgdb_jpg或是osgdb_jpeg库。这是因为我们并没有为一个重要的第三方库libjpeg配置选项,该库是为JPEG读写器所需要的。

OSG本身并不会载入大多数的文件格式,而是将数据载入委托给第三方依赖。特别是当处理各种类型的模型,图像与文件时,有大量优秀的开源工程也可以为不同的插件用作第三方依赖。有效的方法可以在OSG引擎的与实现阶段为全世界的开发者所共享,并支持连续的,稳定的,团队风格的设计。

Time for action - adding libcurl support for OSG

在本书中,我们将为由源码编译与链接OSG二进制构建osgdb_curl支持。如果没有必需的第三方库libcurl,osgdb_curl插件将会被整个解决方案所忽略,因而不会被生成。在第2章中,我们并没有介绍将libcurl添加到CMake配置的选项。但是借助于构建目录中保存的CMake缓存文件与中间文件,我们可以快速重启配置并重新构建我们的OSG库与开发文件。Visual Studio解决方案将会被自动更新来包含新的osgdb_curl工程。

  1. 由下列网址下载预编译包:http://curl.haxx.se/download.html 。Visual Studio用户应在Win32-MSCV部分选择下载链接并将ZIP文件解压到单独目录。
  2. 目录包含所要用到的最重要的开发文件:include子目录下的头文件,静态链接文件libcurl.lib,以及动态库libcurl.dll。其位置对于CMake系统是固定的:
_images/osg_libcurl.png
  1. 现在是我们重启CMaek GUI环境的时候了。我们无需由源目录载入CMakeLists.txt文件,而是可以将CMaekCache.txt由源目录之外的构建目录中拖拽到主窗口(我们还没有移除整个构建目录,对吗?)来快速应用以前的设置。切换到Grouped View并展开组CURL。
  2. 将CRUL_INCLUDE_DIR设置为解压目录的include目录。他将会被用作所生成的Visual Studio工程的额外依赖目录。CURL_LIBRARY与CURL_LIBRARY_DEBUG都可以设置为libcurl.lib文件,这会被自动添加到相同工程的依赖库列表。我们预编译的libcurl有一个名字libcurl.dll的动态库,所以选项CURL_IS_STATIC应被关闭:
_images/osg_libcurl_cmake.png
  1. 这就是全部的配置。点击Configure然后Generate,打开更新的OpenSceneGraph.sln,并查看是否发生了变化。我们很快就会发现一个新的插件curl工程出现在插件工程中。
  2. 重复编译并链接OSG库与插件的步骤。然而构建ALL_BUILD工程,之后构建INSTALL工程。osgdb_curl库将会在整个过程中被创建。
  3. 现在可以由网络查看模型与图像。让我们回到前一个示例并使用我们所生成的osgdb_curl插件来浏览地球模型。

What just happened?

在配置CURL组时查看一下所用的CMake选项;我们就会发现一些指示不同第三方依赖的选项组合,例如JPEG,GIFLIB,TIFF与ZLIB。某些组合只有在必要的组合被设置时才会显示,例如,PNG组合。大多数需要<PROJ>_INCLUDE_DIR选项来设置包含目录,以及<PROJ>_LIBRARY与<PROJ>_LIBRARY_DEBUG选项来定位静态链接库(布与调试)。这里的<PROJ>会依据CMake中的组合名而变化。

在Windows平台下,这些选项被应用到Visual Studio工程属性以能够正确编译与链接。在UNIX系统下,这些可以影响Makefile。

为了使用cmake命令行并配置这些第三方依赖,我们可以像下面这样通过-D前缀来添加每个选项:

# cmake -DCMAKE_BUILD_TYPE=Release
–DCURL_INCLUDE_DIR=/usr/local/include
-DCURL_LIBRARY=/usr/local/lib/libcurl.so …

我们也许会担心为了构建不同的OSG插件类型,我们需要获取如此多的第三方工程。确实很多,但是由源码进行编译并学习如何在开源世界中生存将会是非常有趣的工作。但是对于急切要尝试大多数常用OSG文件I/O插件(通常包括osgdb_jpeg,osgdb_gif,osgdb_tiff与osgdb_png,对于这些插件,zlib库是作为先决条件而需要的)的开发者来说,下面的网站也许会提供某些有用的预编译包与开发文件:http://gnuwin32.sourceforge.net/packages.html

如果我们熟悉SVN与SourceForge(http://sourceforge.net)网站,下面的链接与会非常有帮助:http://osgtoy.svn.sourceforge.net/viewvc/osgtoy/3rdParty

另外,OSG同时提供了CMake选项ACTUAL_3RDPARTY_DIR来避免手动设置如此多的包含目录与库选项。开发者可以首先创建一个名为3rdparty的目录,以及名为include,lib与bin的子目录。然后我们需要将所有第三方依赖的头文件放置在include目录中,所有静态链接(.lib)文件放在lib目录,所有动态库(.dll)放在bin子目录下。之后,打开Ungrouped entries,将ACTUAL_3RDPARTY_DIR设置为新创建的3rdparth目录,点击Configure并查看OSG是否能够自动查找某些常用依赖的包含路径与库(包括FreeType,gdal,glut,libcurl,libjpeg,libpng,libtiff,libungif与zlib)。

Have a go hero - adding FreeType support for OSG

FreeType被osgText库用来允许用于2D与3D文本的字体的载入与渲染。强烈推荐为osgdb_freetype插件进行构建。否则,osgText功能不能正确处理多语言与TrueType字体。

OSG需要2.35版本以上的FreeType。源码可以由下面的链接下载:http://savannah.nongnu.org/download/freetype

预编译包可以在下面网站找到:http://gnuwin32.sourceforge.net/packages/freetype.htm

CMake GUI窗口中的FreeType组合项与其他的组合项略有不同。他需要两个额外选项:FREETYPE_INCLUDE_DIR_freetype2与FREETYPE_INCLUDE_DIR_ft2build。第一个选项指向freetype子目录的父路径,而第二个选项指向ft2build.h的位置。所有这些选项都应进行正确配置以确保osgdb_freetype可以无错误的生成。我们将会下一节创建场景文本时介绍其使用。

Writing your own plugins

扩展虚读写器接口,OSG允许开发者添加额外的自定义文件格式作为插件。虚接口是由osgDB::ReaderWriter类所定义的。他有一些重要的虚方法可以使用或是重新实现以实现读取与写入功能。

_images/osg_own_plugins1.png _images/osg_own_plugins2.png

readNode()方法的实现可以使用下面的代码进行描述:

osgDB::ReaderWriter::ReadResult readNode(
                            const std::string& file,
                            const osgDB::Options* options) const
{
    // Check if the file extension is recognizable and file exists
    bool recognizableExtension = ;
    bool fileExists = ;
    if (!recognizableExtension) return ReadResult::FILE_NOT_HANDLED;
    if (!fileExists) return ReadResult::FILE_NOT_FOUND;

    // Construct the scene graph according to format specification
    osg::Node* root = ;

    // In case there are fatal errors during the process,
    // return an invalid message; otherwise return the root node
    bool errorInParsing = ;
    if (!errorInParsing) return ReadResult::ERROR_IN_READING_FILE;
    return root;
}

osgDB::ReaderWriter::ReadResult对象由readNode()方法返回且并不是所期望的节点指针看起来有些奇怪。这个读取结束对象可以用作节点,图像,状态枚举(例如FILE_NOT_FOUND)),以及其他一些特殊对象,甚至是错误字符串的容器。他有多个隐式构建函数来实现这种目的。这也正是我们为什么在上面示例代码的结束处直接返回根节点的原因。

在这里另一个有用的类就是osgDB::Options。这可以使用setOptionString()与getOptionString()方法设置或是获取通用选项,从而被传递给不同的插件来控制其操作。将字符串作为参数传递给构建函数也是可以的。

开发者也许会依据不同的选项字符串来设计他们的插件特性与行为。注意,选项对象是在readNodeFile()函数中被设置的,所以插件接口也许总是会接收一个NULL指针,意味着没有输入选项。这实际上是readNodeFile()的默认设置:

osg::Node* node1 = osgDB::readNodeFile("cow.osg");  // Option is NULL!
osg::Node* node2 = osgDB::readNodeFile("cow.osg", new osgDB::Options(string));

Handling the data stream

osgDB::ReaderWriter基类有一个流数据处理方法集,这些方法也可以为用户定义的插件所重写。区别仅在于输入文件参数被std::istream&或std::ostream&变量所代替。使用文件流总是优于直接操作物理文件。要在读取文件时执行流操作,我们可以将读写器接口设计为如下样子:

osgDB::ReaderWriter::ReadResult readNode(
                            const std::string& file,
                            const osgDB::Options* options) const
{

    osgDB::ifstream stream( file.c_str(), std::ios::binary );
    if ( !stream ) return ReadResult::ERROR_IN_READING_FILE;
    return readNode(stream, options);
}
osgDB::ReaderWriter::ReadResult readNode(
                            std::istream& stream,
                            const osgDB::Options* options) const
{
    // Construct the scene graph according to format specification
    osg::Node* root = ;
    return root;
}

然后我们使用osgDB::readNodeFile()像平常一样载入并解析文件,但是他实际上是在读写器实现中创建并处理流数据。这里的问题在于如何在某些已存在的流上,例如数据缓冲区中的字符串流或是套接口上传输的流,直接执行操作。正如我们已经看到的,OSG并没有定义一个直接的用户接口,例如著名的osgDB::readNodeFile()与osgDB::readImageFile()用于分析流。

一个解决方法就是使用osgDB::Registry的getReaderWriterForExtension()方法接收特定的读取器并用其来分析缓冲区中的当前流。所获取的读写器必须已经实现了流操作接口,而开发者本身必须确保流数据格式如解析器的规范定义相对应。这意味着3D读写器必须仅用来读取3D格式流;否则一个没有很好编写的插件在尝试解析不可预测的数据时也许会导致系统崩溃。

使用osgdb_osg插件读取流数据的示例代码如下所示:

osgDB::ReaderWriter* rw =
    osgDB::Registry::instance()->getReaderWriterForExtension("osg");
if (rw)
{
    osgDB::ReaderWriter::ReadResult rr = reader->readNode(stream);
    if ( rr.success() )
        node = rr.takeNode();
}

node变量可以用作稍后载入的场景图。success()与takeNode()方法由读取结果读取状态信息与存储的osg::Node指针。

Time for action - designing and parsing a new file format

在该示例中我们将会设计一个新的文件格式并为其创建I/O插件。其格式规格应足够简单,从而我们无需花太多的时间解释其使用及在场景图中的解析。

新格式仅关注快速创建三角形链-也就是有N+2个共享顶点的一系列连接的三角形,其中N是要绘制的三角形数目。文件以文本格式存储,其扩展名.tri,意思为三角形文件格式。顶点的总数总是在每个.tri文件的第一行。接下来的行提供顶点数据域。每个顶点存储为一行中的三个浮点值。新格式的示例内容如下所示:

8
0 0 0
1 0 0
0 0 1
1 0 1
0 0 2
1 0 2
0 0 3
1 0 3

将这些值保存到example.tri文件中,我们稍后将会使用。现在是开始实现我们的读写器接口的时候了。

  1. 包含必需的头文件:
#include <osg/Geometry>
#include <osg/Geode>
#include <osgDB/FileNameUtils>
#include <osgDB/FileUtils>
#include <osgDB/Registry>
#include <osgUtil/SmoothingVisitor>
  1. 我们要实现新格式的读方法。所以在这里要重写两个readNode()方法,一个用于由文件读取数据,而另一个用于由流读取数据:
class ReaderWriterTRI : public osgDB::ReaderWriter
{
public:
    ReaderWriterTRI();

    virtual ReadResult readNode(
       const std::string&, const osgDB::ReaderWriter::Options*)
const;
    virtual ReadResult readNode(
       std::istream&, const osgDB::ReaderWriter::Options*) const;
};
  1. 在构造函数中,我们需要声明扩展名.tri是由该插件所支持的。所支持的扩展名可以使用相同的supportExtension()方法在这里添加:
ReaderWriterTRI::ReaderWriterTRI()
{ supportsExtension( "tri", "Triangle strip points" ); }
  1. 现在我们要实现由磁盘读取文件的readNode()方法。他将会检测输入扩展名与文件名是否正确,并尝试将文件内容重定向到std::fstream对象用于后续操作:
ReaderWriterTRI::ReadResult ReaderWriterTRI::readNode(
       const std::string&, const osgDB::ReaderWriter::Options*)
const
{
    std::string ext = osgDB::getLowerCaseFileExtension( file );
    if ( !acceptsExtension(ext) ) return
        ReadResult::FILE_NOT_HANDLED;
    std::string fileName = osgDB::findDataFile( file, options );
    if ( fileName.empty() ) return ReadResult::FILE_NOT_FOUND;

    std::ifstream stream( fileName.c_str(), std::ios::in );
    if( !stream ) return ReadResult::ERROR_IN_READING_FILE;
    return readNode( stream, options );
}
  1. 这是新文件格式的核心实现。我们所需要做的就是由数据流中读取总数与所有顶点,并将其存入osg::Vec3Array变量中。然后创建一个新的osg::Geometry对象来包含顶点数组与相关的基元对象。最后,我们生成几何体的法线并返回一个新的osg::Geode作为读取结果:
ReaderWriterTRI::ReadResult ReaderWriterTRI::readNode(
    std::istream&, const osgDB::ReaderWriter::Options*) const
{
    unsigned int index = 0, numPoints = 0;
    stream >> numPoints;

    osg::ref_ptr<osg::Vec3Array> va = new osg::Vec3Array;
    while ( index<numPoints && !stream.eof() &&
            !(stream.rdstate()&std::istream::failbit) )
    {
        osg::Vec3 point;
        stream >> point.x() >> point.y() >> point.z();
        va->push_back( point );
        index++;
    }
    osg::ref_ptr<osg::Geometry> geom = new osg::Geometry;
    geom->setVertexArray( va.get() );
    geom->addPrimitiveSet(
        new osg::DrawArrays(GL_TRIANGLE_STRIP, 0, numPoints) );
    osgUtil::SmoothingVisitor smoother;
    smoother.smooth( *geom );
    osg::ref_ptr<osg::Geode> geode = new osg::Geode;
    geode->addDrawable( geom.get() );
    return geode.release();
}
  1. 使用下列的宏注册读写器类。必须在源文件的结尾处为所有插件执行该操作。第一个参数指示插件库名字(没有osgdb_前缀),而第二个参数提供类名:
REGISTER_OSGPLUGIN( tri, ReaderWriterTRI )
  1. 注意,此时的输出目标名应为osgdb_tri,而且必须为共享库文件而不是可执行文件。从而用于生成我们工程的CMake脚本可以使用宏add_library()来替换add_executable(),例如:
add_library( osgdb_tri SHARED readerwriter.cpp )
  1. 启动控制并使用example.tri作为输入文件名运行osgviewer:
# osgviewer example.tri
  1. 结果可以的清楚的表明顶点是否被正确读取并将几何体构成三角形链:
_images/osg_new_file_format.png

What just happened?

在这里某些实用函数被用来在readNode()方法检测输入文件名的正确性。osgDB::getLowerCaseFileExtension()获取文件扩展名,该扩展名将会由osgDB::ReaderWriter基类的acceptsExtension()方法检测。然后osgDB::findDataFile()函数在可能的路径(当前路径与系统路径)内查找文件。他将会返回第一个可用文件的完整路径,如是没有找到则为空字符串。

另一个需要注意的重要点是REGISTER_OSGPLUGIN宏。这实际是在构建函数中定义了一个新用户定义读写器注册到osgDB::Registry实例的全局变量。当动态库被首次载入时,全局变量会被自动分配,读写器会被找到以处理输入文件或流。

Have a go hero - finishing the writing interface of the plugin

我们通过实现了两个虚readNode()方法演示了.tri格式的读取操作。现在轮到我们重新实现writeNode()方法并完成读写器接口。当然,一个插件只会使用读取功能或是写入功能,如果我们有机会,为什么不做得完美呢?

Serializing OSG native scenes

由osgdb_ive与osgdb_osg插件所实现的OSG原生格式被用来封装OSG原生类并将其转换为可以保存到数据流中的表现形式。这使得将场景图保存到磁盘文件并且不丢失信息的再次读取成为可能。

例如,Cessna模型被存储到一个名为cessna.osg的文件中。他实际上是由一个osg::Group根节点,一个osg::Geode子节点以及一个具有特定矩阵以及其渲染属性的osg::Geometry对象构成的。在一个文本文件中,他也许是由如下的行来定义的:

osg::Group {
    Name "cessna.osg"
    DataVariance STATIC
    UpdateCallback FALSE

    Children 1 {
        osg::Geode {

            Drawables 1 {
                osg::Geometry {

                }
            }
        }
    }
}

每一个场景对象(节点,可绘制元素,等)是一个类名定义的,并且由花括号开始与结束。对象的属性,包括其父类的属性,被写入一个位序列用于存储在文件与缓冲区中。例如,Name与DataVariance域是在osg::Object基类中这玉色的,UpdateCallback定义在osg::Node中,而Children是osg::Group的唯一原生属性。他们均为Cessna的根节点保存来记录一个完整的模型所需要的所有信息。

这些属性可以被再次读取依次相同的位序列来创建一个原始Cessna场景图的语义相同的克隆。场景图的序列化(写入为一系列数据)与反序列化(重建数据序列)的过程被称为I/O序列化。每一个可以保存到序列或是由序列读取的属性被称为可序列化对象,或是简写为序列化器。

Creating serializers

OSG原生格式,包括.osg,.osgb,.ost以及.osgx可以进行扩展以保存到文件与数据流,或是由文件与数据流中读取。除了废弃的.ive格式外,他们均需要被称为包装器的特殊助手类,包装器包装了提供API的类的实用方法与属性的基本值。当新的方法与类被引入到OSG核心库中时,他们都应用相对应的包装器,以确保所有的新特性可以在原生格式文件中被立即支持。在这种情况下,序列化理论非常有用,使得简单而常见的输入/输出接口可用。

.osg格式已经在OSG社区中广泛使用多年。几乎所有本书中引用的模型都是这种格式的。他只支持ASCII格式,并且使用一种略微复杂的接口来实现包装器。

但是还有另一种正在开发中的“第二代”格式,该格式可以进行良好的序列化,容易扩展,甚至可以跨平台。ASCII格式(.osgt),二进制格式(.osgb)与XML格式(.osgx)文件都是由一个核心类包装器所支持的,其中的每一个都使用一系列的序列化器来绑定读取与写入成员。在下面的示例中,我们将会讨论如何在我们自己的程序中或是动态库为用户自定义类编写包装器。要被包装的类必须派生自osg::Object,并且必须有一个名字空间用于osgDB中的包装器管理。

所有OSG预定义的包装器存储在源码的src/osgWrappers目录中。对于用户自定义设计与编程,这是一个很好的参考。

Time for action - creating serializers for user-defined classes

要为类创建序列化器并使其可以由OSG原生格式进行访问,需要一些先决条件:首先,类必须由osg::Object派生,直接或间接;其次,类必须在名字空间中声明,并使用META_Ojbect来定义正确的名字空间与类名;最后也是最重要的是,对于每个成员属性类必须至少有一个读取(getter)与设置(setter)方法,从而使其可序列化,也就是,他可以随时存储到OSG原生场景文件并反序列化到一个克隆的场景对象。

  1. 包含必须的头文件:
#include <osg/Node>
#include <osgDB/ObjectWrapper>
#include <osgDB/Registry>
#include <osgDB/ReadFile>
#include <osgDB/WriteFile>
#include <iostream>
  1. 我们定义要被序列化的testNS::ExampleNode类。他非常容易理解,除了记录无符号整数_exampleID之外不做任何事情。我们很容易会发现设置(setter)与获取(getter)以严格的命名约定进行定义(set或get前缀后是相同的字符串,输同的输入与返回值类型,以及getter方法的constant关键字):
namespace testNS {
    class ExampleNode : public osg::Node
    {
    public:
        ExampleNode() : osg::Node(), _exampleID(0) {}

        ExampleNode(const ExampleNode& copy,
                    const osg::CopyOp& copyop=osg::CopyOp::SHALLOW_COPY)
        : osg::Node(copy, copyop), _exampleID(copy._exampleID) {}
        META_Node(testNS, ExampleNode)
        void setExampleID( unsigned int id ) { _exampleID = id; }
        unsigned int getExampleID() const { return _exampleID; }
    protected:
        unsigned int _exampleID;
    };
}
  1. REGISTER_OBJECT_WRAPPER宏被用来定义一个封装器类。他有四个参数:唯一的封装器名字,属性,类名以及字符串形式的继承关系。要添加的唯一的序列化器对象是_exampleID属性。其共享名(为setter与getter共享)名为ExampleID,且默认值为0:
REGISTER_OBJECT_WRAPPER( ExampleNode_Wrapper,
                         new testNS::ExampleNode,
                         testNS::ExampleNode,
                         "osg::Object osg::Node
                         testNS::ExampleNode" )
{
    ADD_UINT_SERIALIZER( ExampleID, 0 );
}
  1. 现在我们进入主要部分。我们希望这个简短的程序能够同时演示写入与读取操作。当指定-w参数时,一个新分配的节点被保存到.osgt文件(OSG原生ASCII格式);否则保存的文件会被载入且在屏幕上输出_exampleID:
osg::ArgumentParser arguments( &argc, argv );
unsigned int writingValue = 0;
arguments.read( "-w", writingValue );
  1. 如果有一个可以通过setExampleID()方法设置的正确值,将ExampleNode节点写入examplenode.osgt文件:
if ( writingValue!=0 )
{
    osg::ref_ptr<testNS::ExampleNode> node = new testNS::ExampleNode;
    node->setExampleID( writingValue );
    osgDB::writeNodeFile( *node, "examplenode.osgt" );
}
  1. 由相同的文件读回节点,并使用getExampleID()方法输出所写入的值:
else
{
    testNS::ExampleNode* node = dynamic_cast<testNS::ExampleNode*>(
        osgDB::readNodeFile("examplenode.osgt") );
    if ( node!=NULL )
    {
        std::cout << "Example ID: " << node->getExampleID()
                  << std::endl;
    }
}
  1. 我们首先设置一个_exampleID值并将场景图写入.osgt文件,假定可执行文件名为MyProject.exe:
# MyProject.exe -w 20
  1. 在当前路径下将会创建一个名为examplenode.osgt的文件。现在让我们将其读回内存并输出所存储的_exampleID:
# MyProject.exe
  1. 他仅是简单的输出刚刚输入的值。他是在载入磁盘上的文件并且重新构建前面的场景图的克隆时获取的:
_images/osg_serializer.png

What just happened?

使用文本编辑器打开examplenode.osgt文件。他也许会包含下列的文本行:

testNS::ExampleNode {
  UniqueID 1
  ExampleID 20
}

名字空间与类名在属性块的前面,包括保存我们输入值的ExampleID。OSG会获取名字空间与类名,并查找已在系统内存中注册的相应的封装器对象。如果找到,封装器将会由属性创建ExampleNode实例,并且会遍历继承关系字符串指定的所有超类来读取所有的属性(具有默认值的属性不会被保存或是由ASCII文件中读取)。

REGISTER_OBJECT_WRAPPER宏将会指定的类定义原型与继承关系。类似于REGISTER_OSGPLUGIN,他实际上是一个将封装器注册到OSG注册对象的全局变量。当包含这些封装器的动态库被载入时,或是全局变量在程序的可执行段被分配,封装器将会立即准备好读取原生的.osgt,.osgb与.osgx格式。

Pop quiz - understanding the inheritance relations

正如我们已经实现的,ExampleNode类是由osg::Node派生的。依据继承关系,他必须在其超类与其自身中记录所有发生变化的属性。但是如果我们由继承关系字符串移除osg::Node字符串时会发生什么情况呢?封装器将会失败还是失去其有效性?或是在大多数情况下仅是丢失某些信息并正常工作?我们是否有什么好主意或测试代码来验证我们的答案呢?

Have a go hero - adding more serializers

很明显,ADD_UINT_SERIALIZER()宏被用来调用类方法来设置或读取一个无符号整数属性。事实上,还有更多的预定义序列化器,包括ADD_BOOL_ERIALIZER(),ADD_FLOAT_SERIALIZER(),ADD_VEC3_SERIALIZER()等。要定义枚举属性,BEGIN_ENUM_SERIALIZER(),ADD_ENUM_VALUE()与END_ENUM_SERIALIZER()宏应被用来构成完全的序列化器。还有一个用来设计用户自定义序列化器的ADD_USER_SERIALIZER()。src/osgWrappers/serializers目录下的源码对于我们学习序列化器将会非常有用,而下面的链接也可以被用作一个快速参考文档:http://www.openscenegraph.org/projects/osg/wiki/Support/KnowledgeBase/SerializationSupport

现在让我们试着向ExampleNode类添加更多的属性,以及相对应的setter与getter方法。我们是否能够为其他的属性实现不同的序列化器并使得类总是可序列化到OSG原生格式?

Summary

在本章中,我们主要讨论了文件I/O机制,包括插件的使用以及OSG中的责任链设计模式。osgDB::Registry是存储所有读写器以及在运行时被链接用于读取原生与非原生包装器的单体类。在本章结束时,我们能够理解OSG插件是如何工作的,以及如何使用osgDB::ReaderWriter基类的用户自定义子类来实现新的插件读取与写入接口。

在本章中,我们学到了:

  • 如何载入特定扩展的文件,以及在所提供的表中查找指定的插件。
  • 如何理解伪载入器,以及如何使用osgdb_curl插件由网络载入文件。
  • 重新配置CMake构建系统来为OSG插件设置第三方依赖选项,从而使得相关的文件格式可以读取或写入。
  • 构建OSG原生编译工具链与第三方依赖的基本方法。
  • 如何由osgDB::ReaderWriter基类实现自定义的读写器接口。
  • 如何为OSG原生格式设计可序列的类。

Chapter 11: Developing Visual Components

在前面的10章中,我们已经介绍了OSG的历史与安装,以及几何体,场景图节点,渲染状态,相机,动画,交互以及文件I/O机制的概念。然而完整的3D渲染API还有许多方面,包括文本显示,粒子,阴影,特殊效果,体积渲染以及大量被统称为NodeKits的模型。在本书或是任意其他的书中,解释所有这些内容都是不可能的,但是向我们民展示如何利用这些典型的可视化效果并在本章结束时通过提供一个实际的NodeKits列表来使得球旋转则是值得的。

在本章中,我们将会了解:

  • 如何将几何体创建为场景中的宣传板
  • 如何在场景中显示2D与3D文本
  • 如何设计一个粒子系统并使其动起来
  • 如何在场景对象上实现阴影
  • 特殊效果的理论与实现

Creating billboards in a scene

在3D世界中,宣传板是总指向特定方向的2D图像。程序可以使用宣传板技术来创建许多特效类型,例如爆炸,闪光,天空,云与树。事实上,当由远处观察时,任何本身被缓冲为纹理的对象都可以被看作宣传板。所以,宣传板的实现成为最流行的技术之一,广泛用于计算机游戏与实时可视化模拟程序。

osg::BillBoard类被用来表示3D场景中的宣化板列表。他由osg::Geode类派生,并且可以使得其所有子节点(osg::Drawable对象)朝向查看器的视口。他有一个重要方法,setMode(),可以用来确定旋转行为,其必须设置下列枚举中的一个作为参数:

_images/osg_billboard.png

osg::BillBoard节点中的所有可绘制元素应具有一个轴点位置,这是通过重载的addDrawable()方法指定的,例如:

billboard->addDrawable( child, osg::Vec3(1.0f, 0.0f, 0.0f) );

所有的可绘制元素同时需要一个统一的初始方向,该方向用来计算旋转值。初始方向是通过setNormal()方法设置的。并且每一个新添加的可绘制元素必须确保其前面朝向与其法线值位于相同的方向;否则,宣传板的结果也许会不正确。

Time for action - creating banners facing you

在OSG中实现电子公告板的前提条件是首先创建一个或是多个四边形几何体。然后这些四边形由osg::BillBoard类进行管理。这会强制所有的子可绘制元素自动围绕指定的轴放置,或是面向查看器。这可以通过指定一个统一的法线值并依据法线与当前旋转轴或是查看向量每一个电子公告板来实现。

我们将会创建两行OSG标记,排列为V字形,来演示OSG中电子公告板的使用。无论查看者在哪里以及如何操作场景相机,标记的前面总是会朝向查看者。该特性可以用来表示我们程序中的纹理树与粒子。

  1. 包括必需的头文件:
#include <osg/Billboard>
#include <osg/Texture2D>
#include <osgDB/ReadFile>
#include <osgViewer/Viewer>
  1. 直接由osg::createTexturedQuadGeometry()函数创建四边形几何体。每一个生成的四边形具有相同的尺寸与源点,并使用相同的图片文件。注意,osg256.png文件可以在我们的OSG安装路径下的data目录中找到,但是他需要osgdb_png插件来读取图片文件。如果我们在配置与编译该插件遇到问题时请参考第10章。
osg::Geometry* createQuad()
{
    osg::ref_ptr<osg::Texture2D> texture = new osg::Texture2D;
    osg::ref_ptr<osg::Image> image =
        osgDB::readImageFile( "Images/osg256.png" );
    texture->setImage( image.get() );

    osg::ref_ptr<osg::Geometry> quad=
        osg::createTexturedQuadGeometry(
                                    osg::Vec3(-0.5f, 0.0f,-0.5f),
                                    osg::Vec3(1.0f,0.0f,0.0f),
                                    osg::Vec3(0.0f,0.0f,1.0f) );
    osg::StateSet* ss = quad->getOrCreateStateSet()
    ss->setTextureAttributeAndModes( 0, texture.get() );
    return quad.release();
}
  1. 在主要部分,我们首先创建一个电子公告板节点并将模型设置到POINT_ROT_EYE。也就是,可绘制元素将会旋转来面向查看者并使得其Z轴在渲染窗口保持向上。osg::Billboard类的默认法线设置是负Y轴,所以将其旋转到查看向量将会在XOZ平面的最佳位置处显示四边形:
osg::ref_ptr<osg::Billboard> geode = new osg::Billboard;
geode->setMode( osg::Billboard::POINT_ROT_EYE );
  1. 现在让我们创建标记四边形并将其排列为V字形:
osg::Geometry* quad = createQuad();
for ( unsigned int i=0; i<10; ++i )
{
    float id = (float)i;
    geode->addDrawable( quad, osg::Vec3(-2.5f+0.2f*id, id, 0.0f));
    geode->addDrawable( quad, osg::Vec3( 2.5f-0.2f*id, id, 0.0f));
}
  1. 所有四边形纹理的背景将会被自动清除,因为alpha测试在osgdb_png插件内部是自动执行的。这意味着我们需要设置所有可绘制元素的正确渲染顺序来确保整个过程被正确处理:
osg::StateSet* ss = geode->getOrCreateStateSet();
ss->setRenderingHint( osg::StateSet::TRANSPARENT_BIN );
  1. 现在是启动查看器的时候了,因为创建并渲染电子公告板再无需其他的步骤了:
osgViewer::Viewer viewer;
viewer.setSceneData( geode.get() );
return viewer.run();
  1. 试着在场景图中浏览:
_images/osg_banner.png
  1. 我们将会发现电子公告板的所有子节点都会进行旋转来面向查看者,但是图片的Y方面不会发生变化(始终指向窗口的Y坐标)。将模式POINT_ROT_EYE替换为POINT_ROT_WORLD并看一下是否有什么区别。
_images/osg_banner2.png

What just happened?

该示例显示了OSG场景图中电子公告板的使用。但是依然能够进一步改进。这里所有的标记几何体都是使用createQuad()函数创建的,这意味着同样的四边形与同样的纹理至少被重新分配了20次。对象共享机制无疑是适用于这里的一种优化。不幸的是,他并不聪明到将相同的可绘制元素添加到osg::Billboard的不同位置处,从而会使得节点的处理不正确。我们所要做的就是创建共享相同纹理对象的多个四边形几何体。这将会高度重用视频纹理内存占用与渲染负载。

另一个可能的问题是有些人也许会要求所载入的节点被渲染为电子公告板,而仅是可绘制元素。一个节点可以由不同类型的子节点组成,因而要比一个基本的图形或是几何体丰富得多。OSG同时提供了osg::AutoTransform类,该类自动旋转对象的子节点与屏幕坐标相一致。

Have a go hero - planting massive trees on the ground

电子公告板广泛用于大量的树与植物。一个或是多个具有透明背景的树图片被应用到不同尺寸的四边形,然后被添加到电子公告板节点。这些树会自动面向查看者,或是更为真实,围绕某个轴旋转就如枝叶总是向前。现在让我们创建一些简单的公告板树。我们只需要准备一幅足够好的图片(例如,OpenSceneGraph预编译包中data目录下的Images/tree0.rgba),遵从前面示例所给出的步骤来创建我们自己的树与植被。

Creating texts

在所有类型的虚拟现实程序中,文本是其中最重要的组件之一。他被用在各处-用于在屏幕上显示状态,标识3D对象,日志,以及调试。文本至少具有字体来指定字样与质量,以及其他参数,包括尺寸,对齐,布局(由左至右或由右至左)以及分辨率来确定其显示行为。OpenGL并不直接支持3D空间中字体的载入与显示文本,但是OSG为渲染高质量文本与配置不同文本属性提供了全面支持,从而使得开发相关的程序更为简单。

osgText库实际上实现了所有字体与文本功能。他需要osgdb_freetype插件以正常工作。这个插件可以借助于FreeType,一个著名的第三方依赖,来载入与解析字体。然后,他会返回一个osgText::Font实例,该实例由纹理字体轮廓的全集构成。整个过程可以通过osgText::readFontFile()函数进行描述。

osgText::TextBase类是所有OSG文本类型的纯基类。他由osg::Drawable派生,但是默认情况下并不支持显示列表。其子类,osgText::Text被用来管理世界坐标中的平字符。重要的方法包括setFont(),setPosition(),setCharacterSize()与setText(),这些方法的每一个都很容易理解与使用,如下面的示例所示。

Time for action - writing descriptions for the Cessna

这次我们将在3D空间中显示Cessna并在渲染场景的前面提供描述性文本。在这里可以使用HUD相机,该相机会在主相机之后渲染,并且为直接将文本更新到帧缓冲区而仅清除深度缓冲区。然后HUD相机将会以一种总是可见的方式渲染其子节点。

  1. 包含必须的头文件:
#include <osg/Camera>
#include <osgDB/ReadFile>
#include <osgText/Font>
#include <osgText/Text>
#include <osgViewer/Viewer>
  1. osgText::readFontFile()函数被用来读取合适的字体文件,例如,非变形的TrueType字体。OSG数据路径(通过OSG_FILE_PATH指定)与windows系统路径将被搜索以确定指定的文件是否存在:
osg::ref_ptr<osgText::Font> g_font =
    osgText::readFontFile("fonts/arial.ttf");
  1. 创建标准的HUD相机并且为了在2维空间绘制3D文本的目的设置2D拼接投影矩阵。相机不应接收任何用户事件,也不应受到父节点变换的影响。这是通过setAllowEventFocus()与setReferenceFrame()方法来保证的:
osg::Camera* createHUDCamera( double left, double right,
                              double bottom, double top )
{
    osg::ref_ptr<osg::Camera> camera = new osg::Camera;
    camera->setReferenceFrame( osg::Transform::ABSOLUTE_RF );
    camera->setClearMask( GL_DEPTH_BUFFER_BIT );
    camera->setRenderOrder( osg::Camera::POST_RENDER );
    camera->setAllowEventFocus( false );
    camera->setProjectionMatrix(
        osg::Matrix::ortho2D(left, right, bottom, top) );
    return camera.release();
}
  1. 文本也是由一个单独的全局函数创建的。他定义了一个描述每一个字符轮廓,以及世界空间中的大小与位置参数和文本内容的字体对象。在HUD文本实现中,文本应总是与XOY面对齐:
osgText::Text* createText( const osg::Vec3& pos,
                           const std::string& content,
                           float size )
{
    osg::ref_ptr<osgText::Text> text = new osgText::Text;
    text->setFont( g_font.get() );
    text->setCharacterSize( size );
    text->setAxisAlignment( osgText::TextBase::XY_PLANE );
    text->setPosition( pos );
    text->setText( content );
    return text.release();
}
  1. 在主体部分,我们创建一个新osg::Geode节点并向该节点添加多个文本对象。这些文本介绍了Cessna的主要特性。当然,我们也可以使用额外的osgText::Text可绘制元素添加我们自己的关于这种类型单翼机的介绍:
osg::ref_ptr<osg::Geode> textGeode = new osg::Geode;
textGeode->addDrawable( createText(
    osg::Vec3(150.0f, 500.0f, 0.0f),
    "The Cessna monoplane",
    20.0f)
);
textGeode->addDrawable( createText(
    osg::Vec3(150.0f, 450.0f, 0.0f),
    "Six-seat, low-wing and twin-engined",
    15.0f)
);
  1. 这个节点包含所有应添加到HUD的文本。为了确保文本不会为OpenGL法线与光(毕竟他们也是纹理几何体)影响,我们必须关闭相机节点的光:
osg::Camera* camera = createHUDCamera(0, 1024, 0, 768);
camera->addChild( textGeode.get() );
camera->getOrCreateStateSet()->setMode(
    GL_LIGHTING, osg::StateAttribute::OFF );
  1. 最后一步是将Cessna模型与相机添加到场景图,并像通常一样启动查看器:
osg::ref_ptr<osg::Group> root = new osg::Group;
root->addChild( osgDB::readNodeFile("cessna.osg") );
root->addChild( camera );
osgViewer::Viewer viewer;
viewer.setSceneData( root.get() );
return viewer.run();
  1. 在渲染窗口中,我们会看到在Cessna模型上有两行文本。无论我们如何变换,旋转与缩放视图矩阵,HUD文本绝不会被覆盖。所以,用户总是可以直接读取最生要的信息,而无需脱离其通常的视图:
_images/osg_text.png

What just happened?

为了使用CMake或是其他的本地编译器构建该示例,我们需要将osgText库添加为依赖,并且包含osgParticle,osgShadow与osgFX库。

在这里我们指定了arial.ttf字体。这是大多数Windows与UNIX系统的默认字体,同时也可以在OSG数据路径内找到。正如我们所看到的,这种字体类型为开发者提供了高精度的字符显示,而无论字体大小设置。这是因为TrueType字体的轮廓线是由数学线段与Bezier曲线构成的,这意味着他们并不是向量字体。位图(光栅)字体不具有这种特性,因而当调整大小时会变昨非常丑陋。在这里禁止setFont()来强制osgText使用默认的12x12的位图字体。我们可以看出这种字体之间的区别吗?

Pop quiz - text positions and the projection matrix

我们使用下面的代码来定义我们的文本对象:

text->setAxisAlignment( osgText::TextBase::XY_PLANE );
text->setPosition( pos );

这里有两个需要我们思考的问题:

  1. 首先,为什么平面字体必须放置在XOY平面上?如果我们不这样做会出现什么情况?我们是否应该使用HUD相机?
  2. 其次,这些文本位置的参考帧是什么?也就是,当设置文本对象的位置时,我们如何在渲染窗口中对其定位?他是否与拼接投影矩阵有关?我们能否将示例中的两行移动右上角?

Have a go hero - using wide characters to support more languages

osgText::Text的setText()方法直接接受std::string变量。同时,他也可以接受宽字符作为输入参数。例如:

wchar_t* wstr = ;
text->setText( wstr );

这使其支持多语言,例如,中文与日文字符,成为可能。现在试着通过直接定义宽字符或是由多字节字符转换来获取一个宽字符序列,并将其应用到osgText::Text对象,来验证一下我们所感兴趣的语言是否能够被渲染。注意字体也要进行相应的变化来支持相应的语言。

Creating 3D texts

无论是否相信,OSG同时提供了对场景图中3D文本的支持。每个字符都有一个深度参数,并且最终使用OpenGL的顶点数组机制进行渲染。实现者类,osgText::Text3D,也是由osgText::Textbase派生的,所以具有与osgText::Text几乎相同的方法。他需要一个osgText::Font3D实例作为字体参数,该参数可以通过osgText::readFont3DFile()函数获取。

Time for action - creating texts in the world space

在该示例中我们将会创建一个简单的3D文本对象。类似于2D文本类osgText::Text,osgText::Text3D类也继承了一个方法列表来设置基本的文本参数,包括位置,大小,对齐方式,字体对象以及内容。3D文本最通常用作游戏与程序的特殊效果。

  1. 包含必须的头文件:
#include <osg/MatrixTransform>
#include <osgDB/ReadFile>
#include <osgText/Font3D>
#include <osgText/Text3D>
#include <osgViewer/Viewer>
  1. 使用osgText::readFont3DFile()函数读取相应的字体文件,该函数类似于osgText::readFontFile()。使用osgdb_freetype插件,TrueType字体可以被分析为细节良好的3D字符轮廓:
osg::ref_ptr<osgText::Font3D> g_font3D =
    osgText::readFont3DFile("fonts/arial.ttf");
  1. 所以我们将会模拟上个示例中的createText()函数。唯一的区别在于我们需要为文本字符设置一个额外的深度参数使其站立在3D世界中。这里的setAxisAlignment()方法表明文本对象位于XOZ平面上,并使其前面朝向Y轴负方向:
osgText::Text3D* createText3D( const osg::Vec3& pos,
                               const std::string& content,
                               float size, float depth )
{
    osg::ref_ptr<osgText::Text3D> text = new osgText::Text3D;
    text->setFont( g_font3D.get() );
    text->setCharacterSize( size );
    text->setCharacterDepth( depth );
    text->setAxisAlignment( osgText::TextBase::XZ_PLANE );
    text->setPosition( pos );
    text->setText( content );
    return text.release();
}
  1. 使用简短的文本创建一个3D文本对象。注意,由于3D文本实际上是由顶点与几何体基元构成的,对其的过度使用会导致较高的资源消耗:
osg::ref_ptr<osg::Geode> textGeode = new osg::Geode;
textGeode->addDrawable(
    createText3D(osg::Vec3(), "The Cessna", 20.0f, 10.0f) );
  1. 这次我们添加一个osg::MatrixTransform作为textGeode的父节点。当渲染所有的文本可渲染元素时,他会向模型视图矩阵应用额外的变换矩阵,从而会改变他们在世界坐标系中的显示位置与高度:
osg::ref_ptr<osg::MatrixTransform> textNode= new
osg::MatrixTransform;
textNode->setMatrix( osg::Matrix::translate(0.0f, 0.0f, 10.0f) );
textNode->addChild( textGeode.get() );
  1. 再次将我们的Cessna添加到场景图并启动查看器:
osg::ref_ptr<osg::Group> root = new osg::Group;
root->addChild( osgDB::readNodeFile("cessna.osg") );
root->addChild( textNode.get() );
osgViewer::Viewer viewer;
viewer.setSceneData( root.get() );
return viewer.run();
  1. 我们将会看到模型上面大大的字母,但事实上,3D文本对象的初始位置应为(0,0,0),这也是Cessna的原点。这里osg::MatrixTransform节点通过将textNode变换到新位置(0,0,10)来避免模型与文本彼此重叠:
_images/osg_3dtext.png

What just happened?

2D与3D文本都可以通过其父节点进行变换。当我们必须构建组合或是使得模型位于文本标签之后时这会非常有用。类似于OSG的变换节点,osgText::TextBase的setPosition()方法只会设置文本对象的父节点的相对参考帧下的位置。这同样适用于setRotation()方法,该方法会确定文本的旋转,而setAxisAlignment()方法会将文本对齐到指定的平面。

唯一的例外是SCREEN对齐模式:

text->setAxisAlignment( osgText::TextBase::SCREEN );

这会模信场景对象的公告板技术,并且使得文本(osg::Text或osg::Text3D)总是面向查看者。在3D地理信息系统(3DGIS中),将路标放置在地球上或是城市上作为公告板是非常常见的技术,因而可以通过SCREEN模式实现。在这种情况下,旋转与父节点变换不再可用,因而不会被使用,因为他们会引起误解与潜在的问题。

Creating particle animations

粒子系统被用在各种3D程序中用于特殊效果,例如烟,灰尘,爆炸,液体,火与雨。比起其他简单场景对象的构建,构建并管理一个完全的粒子系统更为困难。事实上,OSG在osgParticle库中提供了大量的类来支持复杂粒子系统的自定义,其中的大多数类也可以在需要用户定义的算法时使用继承进行扩展与重写。

粒子类,osgParticle::Particle,表示原子粒子单元。他通常被用作模拟循环开始前的设计模板,并且在运行时由粒子系统拷贝并重新生成来渲染大量的粒子。

粒子系统类,osgParticle::Particle,管理所有粒子的创建,更新,渲染与销毁。他由osg::Drawable派生,所以他可以接受不同的渲染属性与模式,就如同普通的可绘制元素一样。他应被添加到osg::Geode节点,如同上一个类一样。

发射器抽象类(osgParticle::Emitter)定义了每一帧新生成粒子的数量与基本操作。其派生类,osgParticle::ModularEmitter,其作用类似于一个普通的发射器,提供了对所创建粒子的控制机制。他总是存储三种类型的子控制器:

  • 位置器(osgParticle::Placer)设置每个粒子的初始位置
  • 射击器(osgParticle::Shooter)设置粒子的初始速度
  • 计数器(osgParticle::Counter)确定要创建多少粒子

程序的抽象类(osgParticle::Program)操作其生命周期中每一个粒子的位置,速度以及其他属性。其派生类,osgParticle::ModularProgram,是由一个在已有粒子上进行操作的osgParticle::Operator子类的列表构成的。

发射器与程序类都是间接派生自osg::Node,这意味着他们可以被看作是场景图中的节点。在更新与裁剪遍历中,他们会被自动遍历,而子控制器与操作符将会被执行。然后,粒子系统将会使用其结果来重新计算并绘制其所管理的粒子。重新计算的过程可以通过osgParticle::ParticleSystemUpdater来实现,后者实际上也是一个节点。更新器应放置在场景图中的发射器与程序之后,从而确保更新以正确的顺序被执行。例如:

root->addChild( emitter );
root->addChild( program );
root->addChild( updater );  // Added last

下图显示了上面的osgParticle类的层次结构:

_images/osg_particle_system.png

Time for action - building a fountain in the scene

我将会演示如何实现一个粒子喷泉。喷泉的模拟可以描述如下:首先,水以一定的初速度由某一点射出;然后由于重力的原因速度减少,直到到达最高点;之后,水掉落到地面上或是池子里。要实现该效果,osgParticle::ParticleSystem节点以及发射器与程序处理器应被创建并添加到场景图。

  1. 包含必须的头文件:
#include <osg/MatrixTransform>
#include <osg/Point>
#include <osg/PointSprite>
#include <osg/Texture2D>
#include <osg/BlendFunc>
#include <osgDB/ReadFile>
#include <osgGA/StateSetManipulator>
#include <osgParticle/ParticleSystem>
#include <osgParticle/ParticleSystemUpdater>
#include <osgParticle/ModularEmitter>
#include <osgParticle/ModularProgram>
#include <osgParticle/AccelOperator>
#include <osgViewer/ViewerEventHandlers>
#include <osgViewer/Viewer>
  1. 创建粒子系统的整个过程可以在一个单独的用户函数中实现:
osgParticle::ParticleSystem* createParticleSystem(
    osg::Group* parent )
{

}
  1. 现在我们位于函数内部。每一个粒子系统都有一个确定所有新生成粒子的行为的模板粒子。这里,我们将系统中所有粒子的形状设置为POINT。借助于OpenGL的点精灵(point sprite)扩展,这些点可以被渲染为纹理公告板,这在大多数情况下就足够了:
osg::ref_ptr<osgParticle::ParticleSystem> ps =
    new osgParticle::ParticleSystem;
ps->getDefaultParticleTemplate().setShape(
    osgParticle::Particle::POINT );
  1. 设置粒子系统的渲染属性与模式。这会自动影响到所有已渲染的粒子。这里,我们将一个纹理图片关联到粒子,并且定义一个混合函数以使得图片背景变得透明:
osg::ref_ptr<osg::BlendFunc> blendFunc = new osg::BlendFunc;
blendFunc->setFunction( GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA );
osg::ref_ptr<osg::Texture2D> texture = new osg::Texture2D;
texture->setImage( osgDB::readImageFile("Images/smoke.rgb") );
  1. 另两个重要属性为osg::Point与osg::PointSprite。前者将会设置点的大小(光栅化点的直径),而后者将会打开点精灵,从而会高效的将四点四边形替换为单一顶点,而无需指定纹理坐标以及旋转前面朝向查看者。另外,我们最好关闭粒子的光线,并且我们设置一个合适的渲染顺序以使其在整个场景图中被正确绘制:
osg::StateSet* ss = ps->getOrCreateStateSet();
ss->setAttributeAndModes( blendFunc.get() );
ss->setTextureAttributeAndModes( 0, texture.get() );
ss->setAttribute( new osg::Point(20.0f) );
ss->setTextureAttributeAndModes( 0, new osg::PointSprite );
ss->setMode( GL_LIGHTING, osg::StateAttribute::OFF);
ss->setRenderingHint( osg::StateSet::TRANSPARENT_BIN );
  1. osgParticle::RandomRateCounter类生成每一帧粒子的随机个数。他是由osgParticle::Counter类派生并且有一个setRateRange()方法可以用来指定元素数的最小值与最大值:
osg::ref_ptr<osgParticle::RandomRateCounter> rrc =
    new osgParticle::RandomRateCounter;
rrc->setRateRange( 500, 800 );
  1. 向标准发射器添加随机速率计数器。同时我们需要将粒子系统关联到该发射器作为操作目标。默认情况下,模块发射器已经包含有(0,0,0)位置点形状位置器,以及为每个粒子选择方向与初始随机速度的光线发射器,所以我们无需指定新值:
osg::ref_ptr<osgParticle::ModularEmitter> emitter =
    new osgParticle::ModularEmitter;
emitter->setParticleSystem( ps.get() );
emitter->setCounter( rrc.get() );
  1. osgParticle::AccelOperator类将固定加速随时应用到所有粒子。为了模拟重力,我们可以使用setAcceleration()来指定重力加速向量,或是直接调用setToGravity()方法:
osg::ref_ptr<osgParticle::AccelOperator> accel =
    new osgParticle::AccelOperator;
accel->setToGravity();
  1. 将唯一的操作符添加到标准程序代码并同时关联粒子系统:
osg::ref_ptr<osgParticle::ModularProgram> program =
    new osgParticle::ModularProgram;
program->setParticleSystem( ps.get() );
program->addOperator( accel.get() );
  1. 粒子系统实际上是一个可绘制元素对象,应该添加到场景图的叶子节点。然后,我们将所有粒子相关的节点添加到parent节点。这是一个关于世界坐标与本地坐标的有趣问题,我们会在稍后进行探讨:
osg::ref_ptr<osg::Geode> geode = new osg::Geode;
geode->addDrawable( ps.get() );
parent->addChild( emitter.get() );
parent->addChild( program.get() );
parent->addChild( geode.get() );
return ps.get();
  1. 现在让我们回到主体部分。首先,我们为定位粒子系统创建一个新的变换节点:
osg::ref_ptr<osg::MatrixTransform> mt = new osg::MatrixTransform;
mt->setMatrix( osg::Matrix::translate(1.0f, 0.0f, 0.0f) );
  1. 创建所有的粒子系统组件并将其添加到输入变换节点。同时使用addParticleSystem()方法将粒子系统注册到粒子系统更新器。
osgParticle::ParticleSystem* ps = createParticleSystem( mt.get()
);
osg::ref_ptr<osgParticle::ParticleSystemUpdater> updater =
    new osgParticle::ParticleSystemUpdater;
updater->addParticleSystem( ps );
  1. 将上述的所有节点添加到场景的根节点,包括作为参考的小轴模型(我们可以在数据目录内找到)。然后,启动查看器:
osg::ref_ptr<osg::Group> root = new osg::Group;
root->addChild( updater.get() );
root->addChild( mt.get() );
root->addChild( osgDB::readNodeFile("axes.osg") );
osgViewer::Viewer viewer;
viewer.setSceneData( root.get() );
return viewer.run();
  1. 我们的粒子喷泉最终完成了。放大,我们将会发现所有的粒子开始于X轴正方向的一点,X=1。现在仅通过一些简单的固定函数属性,粒子可以被渲染为良好的纹理点,并且由于溶合操作,每个粒子元素的显示非常类似于水滴:
_images/osg_particle.png

What just happened?

在上面的图片中,我们可以看出整个粒子系统被移动到世界空间中的(1,0,0)处。这是因为我们将发射,程序与粒子系统的父节点添加到变换节点。但事实上,如果我们将这三个元素中的一个放在变换节点之下,而另两个放在根节点之上,则会得到不同的结果。仅将osg::Geode节点添加到osg::Transform将会使得整个粒子系统随其移动;但是仅添加发射器将变改变新生成粒子的变换行为,但会使得已有粒子保留在世界坐标中。类似的,仅添加程序节点会使得父变换节点仅影响操作符。

一个很好的示例是设计喷气式飞机。当在天空中盘旋时,机翼的位置与方面会随时发生变化。使用osg::MatrixTransform作为粒子发射器的父节点将会非常有助于表示这样的基元粒子的场景。粒子系统与更新器不应被放置在相同的变换节点之下;否则空气中旧的粒子也会移动与旋转,这是真实世界中一定是不合理的。

Have a go hero - designing a rotary sprinkler

我们是否见过旋转的洒水车?他是由至少一个可以自动旋转360度并将水喷射在洒水车直径周围的圆头组成。使用简单的圆柱模型与粒子系统来创建这样的一个机器,我们需要设计一个具有将粒子发射到指定水平方向的发射器的模块发射器,以及一个具有重力加速操作符的模块程序。

作为提示,默认的光线发射器(osgParticle::RadialShooter)使用两个指定范围内的角度,theta与phi,以确定粒子的随机方向,例如:

osg::ref_ptr<osgParticle::RadialShooter> shooter =
    new osgParticle::RadialShooter;
// Theta is the angle between the velocity vector and Z axis
shooter->setThetaRange( osg::PI_2 - 0.1f, osg::PI_2 + 0.1f );
// Phi is the angle between X axis and the velocity vector projected
// onto the XOY plane
shooter->setPhiRange( -0.1f, 0.1f );
// Set the initial speed range
shooter->setInitialSpeedRange( 5.0f, 8.0f );

要旋转发射粒子的初始方向,我们可以使用一个修改theta与phi范围的更新回调,或是考虑添加一个变换节点作为发射器的父节点。

Creating shadows on the ground

阴影也是3D程序的一个重要概念。当创建如数字城市这样的大规模的3D场景时,模型设计者首先在如3dsmax,Maya以及Blender这样的建模软件设计并计算建筑物上的光线,模型以及地面,然后将这些阴影应用到纹理上。然后实时程序读取这些带有纹理的模型文件,并将阴影静态的渲染到渲染窗口中。

实时阴影也是可能的,但是不能无限制的使用。osgShadow库在需要阴影的场景图上提供了一系列的阴影技术。核心类,名为osgShadow::ShadowScene,应被用作这些阴影子子图的根节点。他接受一个osgShadow::ShadowTechnique实例作为用来实现阴影的技术。派生技术类将会扩展场景图来支持更多的算法与解决方案,从而会丰富阴影功能。

Time for action - receiving and casting shadows

我们的目标是通过在模型上转换阴影展示场景的构建。他总是包含一个特定的阴影场景根,一个内建的或自定义的阴影技术,以及一个具有可区分的接收或转换掩码的子节点。如果不添加阴影场景作为父节点,一个普通的场景不会有阴影,相对的,一个具有阴影的场景图可以通过移除应用到节点的osgShadow::ShadowedScene根节点或是阴影技术对象(通过简单的设置为null)来去除阴影计算与效果。在该示例中,我们只是在阴影场景根下创建并管理场景图,并且使用预定义的阴影映射技术来正确渲染真实对象与阴影。

  1. 包含必须的头文件:
#include <osg/AnimationPath>
#include <osg/MatrixTransform>
#include <osgDB/ReadFile>
#include <osgShadow/ShadowedScene>
#include <osgShadow/ShadowMap>
#include <osgViewer/Viewer>
  1. 用于创建动画路径的代码是由第8章中拷贝的。他使用少量的采样控制点来生成圆,然后可以应用到osg::AnimationPathCallback来实现随时间变化的变换路径:
osg::AnimationPath* createAnimationPath( float radius, float time)
{
    osg::ref_ptr<osg::AnimationPath> path =
        new osg::AnimationPath;
    path->setLoopMode( osg::AnimationPath::LOOP );

    unsigned int numSamples = 32;
    float delta_yaw = 2.0f * osg::PI/((float)numSamples - 1.0f);
    float delta_time = time / (float)numSamples;
    for ( unsigned int i=0; i<numSamples; ++i )
    {
        float yaw = delta_yaw * (float)i;
        osg::Vec3 pos( sinf(yaw)*radius, cosf(yaw)*radius, 0.0f );
        osg::Quat rot( -yaw, osg::Z_AXIS );
        path->insert( delta_time * (float)i,
                      osg::AnimationPath::ControlPoint(pos, rot));
    }
    return path.release();
}
  1. 设置阴影接收器与转换器的掩码。这两个掩码的与操作的结果必须为0:
unsigned int rcvShadowMask = 0x1;
unsigned int castShadowMask = 0x2;
  1. 创建地面模型。这仅会由其他场景对象接收阴影,所以在其节点掩码与接收器掩码上执行与操作将会返回一个非零值,而节点掩码与转换器掩码的位与操作应总是返回0。所以我们可以依据这些原则来确定节点掩码:
osg::ref_ptr<osg::MatrixTransform> groundNode =
    new osg::MatrixTransform;
groundNode->addChild( osgDB::readNodeFile("lz.osg") );
groundNode->setMatrix( osg::Matrix::translate(0.0f, 0.0f,-200.0f));
groundNode->setNodeMask( rcvShadowMask );
  1. 设置Cessna模型,该模型也接受一个执行路径动画的更新回调。在我们的示例中,他仅是在地面与其他的场景对象上转换阴影:
osg::ref_ptr<osg::MatrixTransform> cessnaNode =
    new osg::MatrixTransform;
cessnaNode->addChild( osgDB::readNodeFile("cessna.osg.0,0,90.rot"));
cessnaNode->setNodeMask( castShadowMask );
osg::ref_ptr<osg::AnimationPathCallback> apcb =
    new osg::AnimationPathCallback;
apcb->setAnimationPath( createAnimationPath(50.0f, 6.0f) );
cessnaNode->setUpdateCallback( apcb.get() );
  1. 使用合适的变换矩阵将卡车模型添加到地面。他由头上的Cessna圆接收阴影,并将阴影变换到地面上。这意味着当我们在接收器与变换器掩码之间执行位与操作时,我们需要设置合适的节点掩码来接收非零值:
osg::ref_ptr<osg::MatrixTransform> truckNode =
    new osg::MatrixTransform;
truckNode->addChild( osgDB::readNodeFile("dumptruck.osg") );
truckNode->setMatrix( osg::Matrix::translate(0.0f, 0.0f,-100.0f));
truckNode->setNodeMask( rcvShadowMask|castShadowMask );
  1. 为产生阴影设置光源。我们使用setPosition()方法来指定平行光的方向来生成减弱的阴影:
osg::ref_ptr<osg::LightSource> source = new osg::LightSource;
    source->getLight()->setPosition( osg::Vec4(4.0, 4.0, 10.0,
0.0) );
    source->getLight()->setAmbient( osg::Vec4(0.2, 0.2, 0.2, 1.0)
);
    source->getLight()->setDiffuse( osg::Vec4(0.8, 0.8, 0.8, 1.0)
);
  1. 在这里我们必须设置阴影技术。已经有多种由组织与个人实现的基于OpenGL的阴影技术,包括使用投影纹理映射的阴影映射,通过stencil buffer实现的阴影量,以及其他实现。我们选择著名高效的阴影映射(osgShadow::ShadowMap)技术,并设置其必需的参数,包括光源,阴影纹理的大小与单位等:
osg::ref_ptr<osgShadow::ShadowMap> sm = new osgShadow::ShadowMap;
sm->setLight( source.get() );
sm->setTextureSize( osg::Vec2s(1024, 1024) );
sm->setTextureUnit( 1 );
  1. 设置阴影场景的根节点,并向其应用技术实例以及阴影掩码:
osg::ref_ptr<osgShadow::ShadowedScene> root =
    new osgShadow::ShadowedScene;
root->setShadowTechnique( sm.get() );
root->setReceivesShadowTraversalMask( rcvShadowMask );
root->setCastsShadowTraversalMask( castShadowMask );
  1. 将所有的模型与光源添加到根节点并启动查看器:
root->addChild( groundNode.get() );
root->addChild( cessnaNode.get() );
root->addChild( truckNode.get() );
root->addChild( source.get() );
osgViewer::Viewer viewer;
viewer.setSceneData( root.get() );
return viewer.run();
  1. 应用简单的光源以及最常用且稳定的阴影映射技术,我们现在可以在阴影场景中渲染地面,Cessna以及卡车。我们可以通过setTextureSize()方法来改变纹理分辨率,或是切换到其他阴影技术来看一下是否有变化或是改进:
_images/osg_shadow.png

What just happened?

setNodeMask()方法在第9章中进行了介绍。当时他被用来指示相交访问器略过指定地场景图。但此时,我们利用该方法来区分阴影接收器与变化器。在这里,他在阴影场景节点的掩码上执行位逻辑与操作,而不是之前节点访问器的遍历掩码。

setNodeMask()甚至可以用来由将要被渲染的场景裁剪节点,也就是,由渲染管线中移除特定的子场景图。在OSG后端的裁剪遍历中,每个节点的掩码值将会使用节点的裁剪掩码进行计算,而该裁剪掩码则是通过osg::Camera类的setCullMask()方法设置的。所以,如是节点掩码值为0,节点及其子图将不会被绘制,因为在裁剪过程中与操作总是返回0。

注意,当前的OSG阴影映射实现仅处理节点的变换阴影掩码。他会调整阴影映射使得所有对象集合的边界适应变换阴影,但是我们需要处理无需接收阴影的对象,例如,不要将其添加到阴影场景节点。实践中,几乎所有的对象都会被设置来接收阴影,而只有地面被设置为不转换阴影。

Have a go hero - testing other shadow techniques

除了阴影映射还有其他的阴影技术,包括使用纹理与固定函数的最简单实现,stencill buffer(目前并未完成)的volume算法,软边阴影,平行分割阴影,光空间透视阴影等。

我们可以在http://www.openscenegraph.org/projects/osg/wiki/Support/ProgrammingGuide/osgShadow找到简要介绍。

如何创建高级图像效果(阴影只是其中一部分)的知识还有许多。如果我们有兴趣了解更多的相关知识,我们可以阅读一些高级的书,例如Akenin-Moller,Haines,Hoffman的《实时渲染》(Real-time rendering)以及Foley,Van Dam et al的《计算机图形学:原理与实践》(Computer Graphics: Principles and Practice)。

现在,在这些阴影技术中选择性能最好的技术。如果我们的程序开发技术需求不能由现有的阴影技术满足而且自己冒险会有切实的效益,那另一个选择是设计我们自己的阴影技术。

Implementing special effects

osgFX库提供了一个特殊效果框架。他有一些类似于osgShadow NodeKits,后者使用一个阴影场景作为所有阴影子图的父节点。osgFX::Effect类,派生自osg::Group,在其子节点上实现了特殊效果,但是不会影响到其兄弟节点与父节点。

osgFX::Effect是一个不会实现真正效果的纯虚类。其派生包括散射光,亮光,卡通等,也可以随时为不同目的进行扩展。

Time for action - drawing the outline of models

勾勒对象的轮廓是用于在游戏,多媒体与工业应用表示特殊效果的实践技术。OpenGL的一个实现是在stencil buffer中写入固定值,然后使用细边线渲染对象。在两遍渲染过程之后,对像周围的轮廓就会显示出来。幸运的是,该效果已经由osgFX库中的osgFX::Effect的派生类osgFX::Outline类实现了。

  1. 包含必须的头文件:
#include <osg/Group>
#include <osgDB/ReadFile>
#include <osgFX/Outline>
#include <osgViewer/Viewer>
  1. 载入Cessna模型:
osg::ref_ptr<osg::Node> model = osgDB::readNodeFile( "cessna.osg" );
  1. 创建勾勒效果。设置宽度与颜色参数,并将模型节点添加为子节点:
osg::ref_ptr<osgFX::Outline> outline = new osgFX::Outline;
outline->setWidth( 8 );
outline->setColor( osg::Vec4(1.0f, 0.0f, 0.0f, 1.0f) );
outline->addChild( model.get() );
  1. 正如前面所讨论的,为了精确渲染效果,轮廓需要stencil buffer。所以我们需要在osg::DisplaySettings实例中为渲染窗口设置正确的stencil位。默认情况下,stencil被设置为0,意味着stencil buffer不可用。
osg::DisplaySettings::instance()->setMinimumNumStencilBits( 1 );
  1. 在启动查看器之前,不要忘记重置清除掩码,以清除每一帧中的stencil位。在这里具有轮廓效果的节点被用作根节点。他可以被添加到更为复杂的场景图中进行渲染。
osgViewer::Viewer viewer;
viewer.getCamera()->setClearMask(
    GL_COLOR_BUFFER_BIT|GL_DEPTH_BUFFER_BIT|GL_STENCIL_BUFFER_BIT);
viewer.setSceneData( outline.get() );
return viewer.run();
  1. 这就是所需要的全部操作。当与本章中的其他示例相比时,这只是一个简单的示例。然而,通过使用传递的节点与相关联的状态集合并不容易实现类似的效果。这里osgFX库使用多次渲染概念来实现这种类型的效果:
_images/osg_outline.png

What just happened?

OSG的效果类实际上是状态属性与模式的集合。他们允许为一个主节点管理多个状态集合。当遍历场景图时,节点的遍历次数与预定义的状态集合的次数相同。因而,模型会在渲染管线中被绘制多闪(所谓的多遍),每一次应用不同的属性与模式,然后与前一次相组合。

对于轮廓的实现,在内部定义了两遍:首先,如果可以通过,模型使用stencil buffer设置1的状态下绘制;其次,模型以wireframe模式被再次绘制。如果最后一次没有设置stencil buffer,像素仅会被绘制帧缓冲区,因而结果就是带有颜色的轮廓。为了更好的理解他是如何工作的,鼓励大家了解一下OSG源码的src/osgFX目录下的osgFX::Outline类的实现。

Playing with more NodeKits

在OSG源码中或是第三方贡献中有大量的NodeKits。每一个都提供了可用于场景图的特定功能。其中的大多数同时了OSG原生格式(.osg,.osgb,等)来支持读取或是写入的扩展节点与对象类型。

下表是列出了可以丰富基于OSG的程序的可视组件的已有NodeKits。自由进行尝试,或是加入其中的一个社区来共享你的想法代码。注意,并不是所有的这些NodeKits都是可以直接使用的,但是他们总是被认为是值得使用的,并且将会吸引更多开发者的注意:

_images/osg_nodekits1.png _images/osg_nodekits2.png

Summary

在本章中,我们讨论了渲染API最重要的可视化组件。这些组件实际上通过继承基本场景类(例如,osg::Group),重新实现其功能,并向场景图添加派生的对象来扩展了核心OSG元素。由于场景图的灵活性,只要模拟循环启动并遍历场景节点,我们就可以享受各种自定义NodeKits的新特性。设计我们自己的NodeKits并不困难,尽管我们并不具备OSG全方面的知识。

在本章中,我们特别讨论了:

  • 如何通过使用osg::Billboard来创建总是面向查看器的特殊效果
  • 如何使用osgText::Text与osgText::Text3D创建并设置文本,以及如何使用osgText::Font与osgText::Font3D来指定相应的字体
  • 粒子系统的主要组件,包括osgParticle::Particle与osgParticle::ParticleSystem类,以及粒子系统更新器,发射器,程序,计数器,射击器,位置器以及操作的概念
  • osgShadow::ShadowScene类与可用的阴影技术类,以及使用构建场景的使用
  • 使用osgFX库的特殊效果的实现
  • 当前OSG发布以及第三方工程中更多的NodeKits

Chapter 12: Improving Rendering Efficiency

在本书的最后一章,我们将介绍用于构建快速,实时渲染系统所需要的技术,从而以一种高效的方式帮助用户载入,组织与渲染大规模数据集。理解一个较大API调用集合的所有类,方法与全局变量相对较为容易,但是将我们所学的内容应用实际应用,正确且高效的方式,则是另一回事。这里改善渲染效率的方法也许有助于解决我们时不时会遇到的某些引擎问题。

在本章中,我们将会了解:

  • 在OSG中实现多线程操作与渲染的基本原则
  • 场景裁剪与闭塞裁剪的概念
  • 通过修改与共享几何体与纹理改善渲染性的不同方法
  • 在处理大数据集时的动态分页机制及其应用

Open Threads basics

OpenThreads是一个用于OSG类与应用的一个轻量级,跨平台的API。他支持一个多线程程序所需要的基本元素,也就是,线程对象(OpenThreads::Thread),用于锁住不同线程共享数据的信号量(OpenThreads::Mutex),边界(OpenThreads::Barrier),以及条件(OpenThreads::Condition)。后两者经常用于线程同步。

为某一目的创建新线程,我们需要派生OpenThreads::Thread基类并重新实现其虚方法。还有一些方便处理线程与线程属性的全局函数,例如:

  • GetNumberOfProcessors()函数获取可用的处理器数量
  • SetProcessorAffinityOfCurrentThread()函数设置处理与当前线程的关系(也就是,哪一个处理器用来执行当前线程)。他应在线程当前运行时调用。
  • OpenThreads::Thread的CurrentThread()静态函数返回指向当前运行线程实例的指针。
  • OpenThreads::Thread的YieldCurrentThread()静态函数获取当前线程并且让其他线程接管处理器的控制。
  • OpenThreads::Thread的microSleep()静态方法使得当前线程睡眠指定的毫秒数。他也可以用在单线程程序中。

Time for action - using a separate data receiver thread

在该示例中,我们将使用OpenThreads库设计一个新线程,并用来由标准输入读取字符。同时,主进程,也就是,OSG查看器与渲染后端将会接收输入字符并使用osgText库在屏幕上进行显示。只有当数据线程与主进程同时完成时,整个程序才会正常退出。

  1. 包含必须的头文件:
#include <osg/Geode>
#include <osgDB/ReadFile>
#include <osgText/Text>
#include <osgViewer/Viewer>
#include <iostream>
  1. 声明一个新的DataReceiverThread类作为OpenThread::Thread类的派生类。在该类中需要实现两个新方法以确保线程可以正常工作:cancel()方法定义了线程的关闭处理,而run()方法定义了由线程的开始到结束所发生的动作。我们同时定义了一个mutex变量用于线程间同步,并有为了方便使用单例模式:
class DataReceiverThread : public OpenThreads::Thread
{
public:
    static DataReceiverThread* instance()
    {
        static DataReceiverThread s_thread;
        return &s_thread;
    }
    virtual int cancel();
    virtual void run();

    void addToContent( int ch );
    bool getContent( std::string& str );
protected:
    OpenThreads::Mutex _mutex;
    std::string _content;
    bool _done;
    bool _dirty;
};
  1. 关闭处理非常简单:设置变量_done(在run()实现中重复检测直到为true)并等待线程完成:
int DataReceiverThread::cancel()
{
    _done = true;
    while( isRunning() ) YieldCurrentThread();
    return 0;
}
  1. run()方法是线程类的核心。他通常包含一个循环,在其中执行实际的处理动作。在我们的数据接收线程中,我们使用std::cin.get()来由键盘输入读取字符并且确定是否可以添加到成员字符串_content中。当_done被设置为true时,run()方法结束生命周期,而整个线程也同样如此:
void DataReceiverThread::run()
{
    _done = false;
    _dirty = true;
    do
    {
        YieldCurrentThread();

        int ch = 0;
        std::cin.get(ch);
        switch (ch)
        {
        case 0: break;  // We don't want '\0' to be added
        case 9: _done = true; break;  // ASCII code of Tab = 9
        default: addToContent(ch); break;
        }
    } while( !_done );
}
  1. 在这里要小心std::cin.get()函数:他首先由用户输入读取一个或多个字符,直到回车键被按下并接收到’n’为止。然后,他由缓冲区中一次选取一个字符,并连续将其添加到成员字符串中。当缓冲区中的所有字符都遍历完成后,他会清空缓冲区并再次等待用户输入。
  2. 自定义的addToContent()方法向_content添加新字符。该方法当然是在数据接收者线程中被调用的,所以当我们改变_content变量时,我们必须锁定mutex对象,来避免其他线程与主进程污染该变量:
void DataReceiverThread::addToContent( int ch )
{
    OpenThreads::ScopedLock<OpenThreads::Mutex> lock(_mutex);
    _content += ch;
    _dirty = true;
}
  1. 自定义的getContent()方法被用来获取_content变量并将其添加到输入字符串参数。该方法是前面addToContent()方法的逆操作,只能在后续的OSG回调实现中调用。互斥(mutex)对象的作用域锁定操作将会使得整个处理是线程安全的,就如是addToContent()中的处理一样:
bool getContent( std::string& str )
{
    OpenThreads::ScopedLock<OpenThreads::Mutex> lock(_mutex);
    if ( _dirty )
    {
        str += _content;
        _dirty = false;
        return true;
    }
    return false;
}
  1. 线程实现完成了。现在让我们回到渲染。这里我们所希望的是一个可以依据由主进程中接收到的字符串而动态改变其内容的文本对象。文本对象的更新回调对于实现该功能是必须的。在自定义的更新回调的虚update()方法中(用于可绘制元素,所以这里不需要osg::NodeCallback),我们简单的接收osgText::Text对象以及接收器线程实例,然后重置所显示的文本:
class UpdateTextCallback : public osg::Drawable::UpdateCallback
{
public:
    virtual void update( osg::NodeVisitor* nv,
                         osg::Drawable* drawable )
    {
        osgText::Text* text =
            static_cast<osgText::Text*>(drawable);
        if ( text )
        {
            std::string str("# ");
            if ( DataReceiverThread::instance()->getContent(str) )
                text->setText( str );
        }
    }
};
  1. 在主体部分,我们首先创建osgText::Text可绘制元素,并且应我们文本更新回调的实例。这里setAxisAlignment()将文本定义为场景中的公告板,而setDataVariance()确保文本对象在更新与绘制过程中是动态更新的。同时还有一个setInitialBound()方法,该方法接受osg::BoundingBox变量作为参数。他会强制可绘制元素最小边界框的定义,并依此计算初始视图矩阵:
osg::ref_ptr<osgText::Text> text = new osgText::Text;
text->setFont( "fonts/arial.ttf" );
text->setAxisAlignment( osgText::TextBase::SCREEN );
text->setDataVariance( osg::Object::DYNAMIC );
text->setInitialBound(
    osg::BoundingBox(osg::Vec3(), osg::Vec3(400.0f, 20.0f, 20.0f))
);
text->setUpdateCallback( new UpdateTextCallback );
  1. 将文本对象添加到osg::Geode节点并关闭灯光。在启动查看器之前,我们同时需要确保场景被渲染到固定尺寸的窗口中。这是因为我们同时需要使用控制台窗口用于键盘输入:
osg::ref_ptr<osg::Geode> geode = new osg::Geode;
geode->addDrawable( text.get() );
geode->getOrCreateStateSet()->setMode(
    GL_LIGHTING, osg::StateAttribute::OFF );
osgViewer::Viewer viewer;
viewer.setSceneData( geode.get() );
viewer.setUpViewInWindow( 50, 50, 640, 480 );
  1. 在查看器运行之前启动数据接收器,并在之后退出:
DataReceiverThread::instance()->startThread();
viewer.run();
DataReceiverThread::instance()->cancel();
return 0;
  1. 如果我们使用子系统控制台编译我们的工程,则会出现两个窗口。将焦点放在控制台窗口中并输入一些字符。当我们完成时输入回车,并在回车后按下Tab以退出接收者线程:
_images/osg_threads_console.png
  1. 我们将会注意到相同的字符出现在OSG渲染窗口中。这可以被看作一个非常基本的文本编辑器,文本源位于一个单独的接收线程中,而绘制界面则是在OSG场景图中实现的:
_images/osg_threads_window.png

What just happened?

介绍线程与多线程编程超出了本书的范围。然而,使用单独的线程来由磁盘或是局域网载入大文件已经非常常见。其他一些程序使用线程来持续接收来自网络服务或客户端计算机的数据,或是用户定义输入设备,包括GPS与雷达信号,这会极大的改善速度与效率。其余的数据处理线程甚至可以指定要其上进行处理的处理器,从而可以利用今天双核心与四核CPU。

OpenThreads库为OSG开发者甚至是普通的C++线程程序员提供了一个最小但完整的面向对象线程接口。他被osgViewer库用来实现多线程场景更新、裁剪与绘制,这是OSG中高效渲染的秘密。在这里要注意,多线程渲染并不简单指在单独的线程中执行OpenGL调用,因为相关的渲染环境(在Win32下为HGLRC)是线程相关的。一个OpenGL环境只能位于一个线程中(使用wglMakeCurrent()函数)。所以,仅封装一个OpenGL环境的OSG渲染窗口在多线程中不会被激活和接受异步OpenGL调用。他要求对线程模型的精确控制来使得一切运行良好。

Pop quiz - carefully blocking threads

存在一个同时用在DataReceiverThread类示例中的addToContent()与getContent()方法中的互斥对象。他可以阻止不同的线程同时访问相同的数据。我们是否可以指出两个方法同时处理冲突的_content变量的最可能时刻吗?如果这里我们没有使用互斥会发生什么情况呢?

Understanding multithreaded readering

实时渲染的传统方法总是涉及到三个单独步骤:用户更新(UPDATE),场景裁剪(CULL)以及执行OpenGL调用(DRAW)。

用户更新包括各种类型的动态数据修改与操作,例如修改场景图层次结构,载入文件,骨骼顶点动画,以及更新相机位置与属性。然后他将场景图发送到裁剪阶段,在该阶段,出于改善最终渲染性的目的,场景会被重新构建。在查看截面不可见或是出于某种原因隐藏的对象将会被移除,而其余部分会由渲染状态进行排序并推送到绘制列表。该列表会在最后的绘制阶段进行遍历,而所有的OpenGL命令将会被执行来进行图像管线的处理。

一个单处理器系统需要串行处理所有三个阶段,从而导致一帧对于用户需求过长的情况。

在一个多处理器与多显示设备的系统中,我们可以有多个并行的裁剪与绘制任务来加快帧速率。特别是当管理多个渲染窗口时,为每个窗口生成一个处理裁剪与绘制阶段的新线程模型是必需的,并且同时执行。当然,这样会比仅使用一个线程要高效得多。

Time for action - switching between different threading models

OSG提供了非常方便的接口用于选择线程模型。不同的线程模型可以用于不同的环境,并有不同的效率。在此示例中,我们将会显示当运行一个具有大量四边形几何体的场景,在osgViewer::CompisteViewer的三个渲染窗口同时运行时,三个常用线程模型之间的区别。

  1. 包含必须的头文件:
#include <osg/Group>
#include <osgDB/ReadFile>
#include <osgViewer/ViewerEventHandlers>
#include <osgViewer/CompositeViewer>
  1. 四边形可以使用osg::createTexturedQuadGeometry()函数生成。其位置简单的通过一个随机数生成器确定。这样的一个四边形不会占用过多的系统资源。但是大量没有使用对象共享机制的四边形将会很快耗尽系统与视频卡内存(由于每一个几何体显示列表的构建),这对于测试系统负载容量非常有帮助:
#define RAND(min, max) \
        ((min) + (float)rand()/(RAND_MAX+1) * ((max)-(min)))
osg::Geode* createMassiveQuads( unsigned int number )
{
    osg::ref_ptr<osg::Geode> geode = new osg::Geode;
    for ( unsigned int i=0; i<number; ++i )
    {
        osg::Vec3 randomCenter;
        randomCenter.x() = RAND(-100.0f, 100.0f);
        randomCenter.y() = RAND(1.0f, 100.0f);
        randomCenter.z() = RAND(-100.0f, 100.0f);

        osg::ref_ptr<osg::Drawable> quad =
            osg::createTexturedQuadGeometry(
                randomCenter,
                osg::Vec3(1.0f, 0.0f, 0.0f),
                osg::Vec3(0.0f, 0.0f, 1.0f)
            );
        geode->addDrawable( quad.get() );
    }
    return geode.release();
}
  1. 组合查看器对于每个渲染窗口需要一个单独的osgViewer::View实例。窗口的位置与大小是由setUpViewInWindow()方法确定的:
osgViewer::View* createView( int x, int y, int w, int h,
                             osg::Node* scene )
{
    osg::ref_ptr<osgViewer::View> view = new osgViewer::View;
    view->setSceneData( scene );
    view->setUpViewInWindow( x, y, w, h );
    return view.release();
}
  1. 在主体部分,我们首先使用一个参数解析器来选择线程模型。默认情况下,OSG会依据处理器与程序的渲染窗口的数量自动选择最佳的线程策略,也就是AutomaticSelection。但是我们仍然由内建值中指定一种处理多线程渲染的方法,包括SingleThreaded,ThreadPerContext与ThreadPerCamera:
osg::ArgumentParser arguments( &argc, argv );
osgViewer::ViewerBase::ThreadingModel th =
    osgViewer::ViewerBase::AutomaticSelection;
if ( arguments.read("--single") ) th =
    osgViewer::ViewerBase::SingleThreaded;
else if ( arguments.read("--useContext") ) th =
    osgViewer::ViewerBase::ThreadPerContext;
else if ( arguments.read("--useCamera") ) th =
    osgViewer::ViewerBase::ThreadPerCamera;
  1. 创建三个渲染视图并将大量的四边形几何体应用到其中的每一个。在该示例中总计分配了20000个四边形用于演示不同的线程模型:
osgViewer::View* view1 = createView( 50, 50, 640, 480,
    createMassiveQuads(10000) );
osgViewer::View* view2 = createView( 50, 550, 320, 240,
    createMassiveQuads(5000) );
osgViewer::View* view3 = createView( 370, 550, 320, 240,
    createMassiveQuads(5000) );
view1->addEventHandler( new osgViewer::StatsHandler );
  1. 创建组合查看器并设置用户指定的线程模型。注意,这里的setThreadingModel()方法不仅能用于osgViewer::CompositeViewer,而且对于大多数普通的osgViewer::Viewer实例也是可用的:
osgViewer::CompositeViewer viewer;
viewer.setThreadingModel( th );
viewer.addView( view1 );
viewer.addView( view2 );
viewer.addView( view3 );
return viewer.run();
  1. 编译程序(假定其名字为MyProject.exe)并在控制台模式下输入下面命令:
# MyProject.exe --single
  1. 最终的结果如下图所示。注意对于单线程模型帧速率仅为20,其中,更新、裁剪与绘制阶段是在相同的线程中依次完成的:
_images/osg_multithread_single.png
  1. 将参数由–single改变–useContext并再次启动测试程序。这次我们将会发现帧速率增加了。这是因为OSG除了用户更新阶段,对于裁剪与绘制阶段使用单独的线程,从而大大的改进了渲染性能:
_images/osg_multithread_context.png
  1. 将命令行参数修改为–useCamera并再次启动测试程序。这际上是对于现令大多数多处理器计算机的默认策略。他甚至好于第二种线程模型,因为他对于相机与渲染窗口使用不同的线程,并在单独的CPU上运行线程以获得最大的效率:
_images/osg_multithread_camera.png

What just happened?

SingleThreaded线程模型可以表示为下图。每个渲染窗口中的CULL与DRAW阶段也许会有不同的聚合时间,在这里一帧被定义为由每一个视图的CULL开始直到最后一个视图的DRAW为止的总时间。在这里忽略了用户更新操作,因为在所有的线程模型中他们总是占用相同的聚合时间:

_images/osg_threads_frame.png

更新、裁剪与绘制操作总是在一个线程内执行。如果有多个子视图,也就是,多个裁剪与绘制任务要完成,那么他们就会被依次执行。这是OSG中渲染场景最高效的模型,但是对于测试新功能依然有用。同时他也简化了与GUI的集成,例如MFC与Qt。因为我们并不关心线程冲突,所以我们可以仅将osgViewer::Viewer或osgViewer::CompositeViewer的run()方法放在GUI计时器事件回调中,而不需要使用额外的线程,正如我们在第9章所做的那样。

ThreadPerContext模型可以直接由下图进行表示:

_images/osg_threads_context.png

每个组合查看器的子视图有其自己的线程,在其中执行裁剪与绘制任务。由于线程的并行特性,每一个帧的执行时间将会短于最长CULL与DRAW对的总时间。在所有的DRAW任务完成之后,下一帧的用户更新将会立即启动。

这在渲染性能方面要好于单线程模型。他甚至可以利用多处理器,因为每个线程可以占用单独的处理器,以最大化硬件资源的使用。

然而,更好的解决方案是ThreadPerCamera模型。这会将每个视图的CULL阶段与DRAW阶段分开,同时在线程中实现。这意味着对于每个渲染窗口我们至少有一个CULL线程与一个DRAW线程,因而可以完全利用多处理器系统。因为裁剪操作一定与osg::Camera节点相关(他为视图截图裁剪管理视图与投影矩阵),我们将该线程模型称为“每个相机一个线程”模型,如下图所示:

_images/osg_threads_camera.png

在线程模型中,DRAW阶段被看作两个并行处理,在CPU端分民命令,而在GPU端执行渲染缓冲区交换并执行。交换缓冲区操作的时间消耗可以是统一的,并且在所有的DRAW分发操作完成之后执行。但是在这之前,线程模型会预告启动下一帧的用户UPDATE阶段。这种处理再一次极大的改善了渲染性能,但是如果用户更新改变正在被分发的场景数据,也许会导致未预期的结果。这也正是我们为也许会被修改的场景对象设置动态标记的原因:

node->setDataVariance( osg::Object::DYNAMIC );

默认情况,如果检测到多处理器系统,OSG将会支持ThreadPerCamera线程模型。

Dynamic scene culling

裁剪技术可以进行简单的描述:不绘制我们看不见的东西。我们可以使用两种主要方法实现该目的:通过减少不需要进行细化的多边形面,以及忽略当前视口中不可见的对象。

前者通常是由LOD(level-of-detail)算法实现的,在OSG中是通过osg::LOD类实现的。后者实际上是场景裁剪的定义,目的是查找场景图中根本不需要渲染的对象。在OSG中有多种裁剪技术类型:

  • 背面裁剪(Back face culling):这是由osg::CullFace类实现的,该类封装了OpenGL的glCullFace()函数。他会由渲染管线的相机中移除所有多边形面,从而减少内存占用。这种技术很有用,特别是对于复杂的对象,但是对于透明的物体或是有洞的物体也许会有错误。
  • 小特性裁剪(Smallfeature culling):该技术会基于可见性测试允许过小而看不见的对象的移除,结果导致如果绘制对象则会影响对象的像素数。如果该数目小于用户定义的最小像素阈值,则该对象会由渲染列表中移除。
  • 视图截面裁剪(View-frustum culling):其思想只是简单的不渲染超出由渲染窗口的视图与投影矩阵所定义的视图截面的部分。这是现代渲染程序中最高效的方法。
  • 闭合裁剪(Occlusion culling):该技术会确定由于隐藏在其他对象之后,哪些对象会完全不可见。我们很快会在下一节进行讨论。

注意,小特性裁剪方法也许会导致实际的实际的几何体点不可渲染。要禁止该特性,我们可以使用相机节点的setCullingMode()方法:

camera->setCullingMode(
   camera->getCullingMode() & ~osg::Camera::SMALL_FEATURE_CULLING );

Occluders and occludees

当渲染复杂的场景时,由查看者的视角来看,两个或多个对象彼此重叠是很常见的现象。这会导致重复绘制,这意味着当最终的图片仅显示最后一个对象时,相同位置的像素会被多次写入帧缓冲区。这会导致效率损失,因为多次绘制并没有必要(所谓的覆盖绘制)。

闭合裁剪技术简单的通过不绘制为距离相机更近的对象所隐藏的几何体来提高渲染性能。覆盖其他可绘制元素的对象被称之为遮光板,而场景的其余部分可以被看作非遮光区域(未没有必要使用这样不熟悉的单词)。

Improving your application

Paging huge scene data

Making use of the quad-tree

Summary

Indices and tables