在这里我可以大胆的断言,目前在地球上,所有的图形引擎都在使用场景图(Scene Graph)的概念来组织管理它里面可渲染的物体(我甚至怀疑外星人也在用这个)。 场景图在图形引擎中的地位是毋庸置疑的,它不仅提供了对用户在空间中查找和搜索物体提供了高速的优化,并且针对程序库(对我们而言就是Ogre)的渲染需要,提供了相应的搜索、排序以及剔除功能。在某些时候也被用来进行碰撞检测。在一些具体的设计里面,场景图甚至可以被所有子系统使用,比如声音和物理系统都可能依赖场景图来实现相应的功能。
在管理场景中的对象以及几何体的管理功能方面,Ogre中的场景图与其他的引擎实现的功能没有什么本质的区别。不过Ogre并没有使用“一个”场景图,通过引擎所使用的插件机制,Ogre通常会使用一些外部“插入”的场景图算法来使用。Ogre中提供的SceneManager类只不过是场景图的接口,你可以通过它来调用Ogre本身提供的几个不同的场景图算法,也可以在网上找到一些Ogre场景算法的其他实现,其中包括商用的内存分页场景管理器(Paging Scene Manager——http://tuan.kuranes.free.fr/Ogre.html),以及商用收费/非商用免费的场景管理器,比如oFusion(http://ofusion.inocentric.com)。
如果你在之前已经对一些完整的游戏引擎有一些了解,比如Torque、虚幻(Unreal)或者CryENGINE这些商业游戏引擎,亦或者使用过某些图形渲染库,比如Irrlicht3D、TrueVision3D或者Blitz3D。可能在你潜意识里,就会已经认为场景图里面一定应该有一个复杂的继承体系来实现所需相应的功能,所有物体都应该继承于一个“场景节点”基类。不过如果真的这样做了的话(确实大部分其他的引擎,这样做了),你就会发当你要在场景图中扩展处理其他类型的数据时(比如声音或者物理内容),这种设计的笨拙和固化就会暴露出来。
就像在这本书前面第三章中所讲的,Ogre从核心设计之初就已经将场景结构(场景图)和场景内容完全分离,把场景图的结构和它所使用的数据节点作为平等的继承体系来使用。这种灵活且强大的设计理念,使得多种场景图的管理方式(比如BSP、八叉树或者KD-Tree)可以灵活的替换,而不会影响具体的场景内容的实现。换句话说,Ogre在场景图的领域,打破了传统,有了崭新的突破。
Ogre在场景图中还有另外一项即将实现的设计(计划在1.6X之后的版本实现):可以在同一时间内在程序中同时使用多个场景管理器。我在这里大胆的预言,这种特性将在以后的几年内将会成为热门的话题。不过不论在你的程序中是否准备同时使用多个场景管理器,你都会遇到“入口(Portalling)”问题。可以简单的理解“入口”就是两个场景区间的分界线(比如当你从一个关卡的室内场景中看到了室外场景中的一部分,那么这个窗子就成为了所谓的“入口”)。不论在什么时候,管理“入口”都是一件复杂且麻烦得让人头痛的问题。不过对于Ogre而言,对“入口” 管理的责任已经交给了每个单独的场景管理器自身,引擎的使用者已经幸运的摆脱了这个问题。只有当你希望设计自己独有的场景管理器的时候才真正的涉及到“入口”管理,不过这是图形学中更高级的话题,这本书的目的不是讨论这个。如果你真的需要,可以网上看一看相关的文章。
场景管理器
在通常的情况下,Ogre的场景管理器会负责处理以下事情:
·在场景中创建和放置活动物体、灯光以及摄像机,并维护他们的在场景图中的周游和变换。
·载入和布置世界地图(World geometry,与活动实体不同,世界地图是巨大且可以延伸的,通常情况下是不可移动的,比如一个完整的BSP地图)。
·对场景查询(Scene Queries)的支持,比如回答“在世界的某个原型空间内,都包含了那些物体”。
·剔除不可见物体并且将可见物体放入渲染队列。
·根据当前和渲染物体的透视图,对无方向的光源(Nondirectional Light)进行组织和排序(按由近到远)。
·设置并且渲染场景中的阴影。
·渲染场景中的其他物体,如背景和天空盒
·发送组织好的内容到渲染系统执行渲染
场景管理器类型
在Ogre的论坛中经常有人问这样一个问题“我们已经有了场景管理器可以使用了,为什么还要有‘场景类型’概念呢?”这里可以简单的回答一下这个问题:因为Ogre中所使用的场景管理器事实上是以插件的形式提供的,这就代表着在同一个程序中可能存在着多个场景管理器。如果你是这样一个游戏,既有室内的密集空间,又有室外的稀疏场景。这时候你可能需要为每一种世界地图使用不同的场景管理器来进行具体的管理(不同的管理算法适用于不同的情况,后面将会具体介绍)。
每个场景管理器插件都会向Ogre系统注册一个用来产生管理器的工厂实例,并且同时注册一个代表场景管理器的创建类型的字符串ID到系统中。作为历史遗留的原因,为了兼容早期版本的使用方法,插件也同时向系统注册了一个代表它自己的掩码ID(mask identifying)。
注意:如果你是从当前版本才开始接触Ogre的使用者,你可以跳过这个段落继续阅读。但如果你有使用早期版本Ogre的经验,你就要注意这里的问题:从Ogre1.2(Dagon)版本之后(也就是本书所介绍的版本)。多场景管理器能支持场景管理器把自己的类型注册为字符串ID(比如“OctreeSceneManager”或“TerrainSceneManager”)。这样你就可以使用迭代的方法在管理器中找到自己所需要的具体实现,亦或者可以简单的使用最后一个被注册进来的管理器类型。不同于之前Ogre版本中使用枚举类型作为索引(如TR_INTERIOR或者ST_EXTERIOR_CLOSE),使用这种方式注册和索引可以让用户摆脱只能使用固定数量场景管理器类型的限制。不过当前Ogre仍然可以对以前的使用方法兼容,所以就算你仍然坚持使用以前的方法,也不会有什么问题。
你可以自己创建一个场景管理器实例,并为它提供一个名字(也可以让Ogre帮你分配)。不过通常情况下,你需要在代码中保存一个指向场景管理器的指针,因为在大多数情况下只有一个场景管理器名字可能不怎么够用。
Ogre自身提供两个场景管理器类型:OctreeSceneManager和TerrainSceneManager。OctreeSceneManager是一个通用的场景管理器。TerrainSceneManager是一个为高度场场景优化的场景管理器,我们将在之后的章节中具体的了解它们相应的细节。
场景对象的创建
对于程序的开发者而言,场景管理器的直接用途一般是用来创建场景中所使用的对象,比如:灯光、摄影机、实体、粒子系统以及公告栏这些活动物体,以及天空盒、静态几何体和世界地图(World geometry)这些非活动物体。对于场景中存在的物体,不论是否能被渲染,都会交给场景管理器进行具体的管理工作。这样做的好处就是场景管理器可以根据自己的需要对物体进行相应的优化处理(例如,可以在你场景管理器的具体实现里面对摄像机进行有针对性的优化工作)。注意,这里的“管理”指的是对场景中物体整个生命周期的管理:提供了创建、获得、销毁以及销毁本类型全部实例的方法。任何从场景管理器得到的对象必须由同一个场景管理器销毁:换句话说,不可以直接“删除”任何有场景管理器创建返回给你的指针所指对象。当你需要释放和清空整个场景的时候,必须让相应的场景管理器自己来做(也可以让Ogre的Root对象在关闭的时候自动得帮你调用相应的方法)。
场景节点
场景管理器用场景节点来定义场景图的结构。这些场景节点以层次的结构组织在场景管理器中:一个场景节点可以有一个父节点和任意数量的子节点。你可以对场景管理器中的节点进行绑定或者摘除操作;这里提供一个简单的办法来关闭场景中的某个部分:只要把不希望渲染部分的根节点从场景图中摘除下来,这个部分就不会被渲染了。场景节点必须通过创建它们的场景管理器来销毁。
小心:虽然这么做不会导致Ogre系统崩溃,不过当你删除了还有内容或者数据挂接在上面的场景节点的时候,还是需要人为来保证这个被挂接的对象不会导致内存泄漏。尽管你可以根据你自己的需要随意管理所需的节点的生命期,不过同时也许要你对这些“脆弱”的场景节点行使谨慎而严密的相关处理。
在场景管理器创建的时候,会给整个场景提供一个总的父节点:场景的根节点(Root Scene Node)——场景中唯一没有父节点的场景节点。根节点的概念对整个场景图至关重要,所以用户无权销毁它。对于场景图来说,根节点的显式作用可以用来挂接所有场景中存在的其他节点;隐式作用是场景图用它来挂接静态物体和整个世界地图。尽管你可以移动根节点,但尽量不要这么做,因为大多数时候都需要保持根节点的稳定性。
场景中新建立的节点必须作为子节点挂接在已经存在的节点上面。这就意味着,第一个放入场景的节点,必须挂到根节点上面,然后再不断挂接扩充,直至形成一个你所需要的场景树状结构图。图5-1中展示了经过几次挂接后场景图的层次结构。
图5-1:在场景图中挂接6个场景节点过程中的不同状态,从空场景(a)一直到过程结束(f)
在你创建了一个场景节点之后,场景管理器不会往上面挂接任何内容。当你需要的时候,应该自己创建内容并向需要操作的节点挂接。不过并没有要求场景中存在的节点一定要有绑定内容在上面,你可以挂接一个空节点,并可以根据需要时随时挂接内容到它上面。单独的节点上面并没有限制帮定内容的数量。你可以把所有内容对象都帮定到一个节点上面,不过这也就意味着无法单独的移动它们(如同连体婴一般)。
就像我们之前所说,场景内容独立于场景节点。你可以把场景内容从场景节点中分离出来,这样的操作并不会引起场景内容的销毁或者删除。但还是不能把一个场景内容同时挂接到两个以及两个以上的场景节点上面,也不能让一个场景节点同时挂接到两个父节点上面,这些操作都会引起Ogre的内部异常。
在这里你必须了解:在场景图中有三个重要的空间操作(移动、旋转、缩放),它们的操作目标都是场景节点,而不是场景内容。换句话说,用户操作场景节点,场景内容跟随着所挂接节点一同移动、旋转和缩放。
场景查询
场景管理起另外一个很重要的功用就是用来进行空间场景查询(从场景中得到查询的反馈信息),其中包括:光线查询(Ray Queries)、球体查询(Sphere Queries)、边界盒查询(Bounding-Box Queries)、边界平面查询(Bounding-Plane Queries)以及相交查询(Intersection Queries)。其中光线查询返回与给定光线(空间中两点组成的虚拟线)相交的物体信息;球体查询返回给定球体(通过球中点和半径确定的空间区域)中所有包含的物体信息;边界盒查询返回给定轴向包围盒(通过两个三维向量作为对角点产生的与空间轴平行的长方体空间)中所包含的物体信息;边界平面查询返回与给定的无限延伸的平面相交物体的信息;相交查询返回与指定物体相交的所有物体信息。
这些查询的结果都是随着场景变化而变动的,所以当你要解决类似地形跟随关卡的时候,就需要在每一帧对地图执行相关的查询操作。
提示:地形跟随的概念指的是在某些场景中需要某个对象与世界地图(通常是室外场景)保持一个固定距离(通常是零)。要实现这种动作需要知道所在地形的垂直高度,简单的作法是利用一条射线(光线检测)射向地面,得到与世界片段的交点。然后通过与世界的相对高度来设置对象的高度。这是一个很普遍的技术。(举一个简单的例子:在《三角洲特种部队》中,士兵作为场景中的物体,在移动时脚一定要和起伏的地面接触,也就是要让人物和地图保持一定的距离。)
这里所提到的所有查询过程都已经进行了很好的优化(比如采用轴向包围盒的原因,就是因为它比一般的包围盒可以使用更少的计算量)。所有的查询过程都是可进行屏蔽操作的(Maskable),这意味着你可以通过这个功能过滤到你不需要的对象。据个例子来说,你需要得到一个给定球体空间内所有的物体,但是不需要里面的地形信息。你就可以在执行前通过设置屏蔽信息来过滤掉地形片段的返回。
空间关系和3D变换
对很多刚开始接触3D应用程序开发的人来说,很难搞清楚场景节点和场景内容的关系,甚至对场景节点之间的关系也很模糊。为了帮助大家更好的理解这些概念,在这里让我们来看一些3D实际操作的例子。
空间变换(Spatial Transforms)
如果能回忆起以前学过的3D数学知识(比如线性代数),你就会想起来,任何3D空间的位置、方向以及缩放都能被表现为一个4*4矩阵的形式。换句话说,空间的任何关系都可以被定义为“一组给定的坐标集合”。
因此,空间变换运算总是会参考所给定的坐标系统。而最常使用的坐标系统空间(同时也是Ogre程序所能提供的)即是世界空间(World)、父节点空间(Parent)以及本地空间(Local)。
那什么是世界空间呢?世界就是物体(模型)所存在的地方。当我们把一个模型放进世界里面去,那么它就有了一个世界坐标,这个世界坐标是用来标记世界中不同的模型所处的位置的。在世界空间里,世界的中心就是原点(0, 0, 0)。而在Ogre中,这个点也当于场景根节点的位置,所以世界空间在Ogre中也可以理解为“相对于场景根节点”。世界空间也意味着是使用全局的X,Y,Z坐标。
父节点空间变换是相对于一个节点的父节点,也就是把父节点的坐标作为变换空间的矩阵原点。本地空间变换是相对于对象绑定的节点本身。大多数情况下,物体的移动是在父空间里面进行,旋转和缩放是在本体空间里面做,这也是Ogre中所默认的操作方式。
然而,你可能不是很擅长处理三维空间中的矩阵变换,这时候需要用更简单清晰的方法来操纵场景图之中的物体。幸运的是,Ogre里面提供了简单的移动、旋转和缩放的方法,Ogre亲自来帮你处理那些丑陋而复杂的矩阵变换实现。不过你仍然希望了解当前物体位置、旋转以及缩放的矩阵的话,Ogre也能很好的提供给你,甚至可以选择从三种空间中参考任意一个坐标系来提供相应的矩阵。
物体空间(Object Space)
如果你创建了一个物体(不论是通过3D模型工具产生还是手动构造),物体的顶点都会被放置在世界空间中。我们在这里举个例子:我们在这里是用3DS Max工具来进行模型的制作,在编辑场景的原点(0,0,0)建立一个边长为1的立方体,这样他的顶点都在类似(0.5,0.5,0.5)的范围内。参考图5-2,当Ogre载入了这个物体之后,所有顶点的位置仍然何在3DS Max编辑空间内相同。
注意:在某些时候,可能“物体空间”的意义会等同于“本地空间”;不过在本书的这个章节里,物体空间的概念专指“相对于物体本身的坐标系”。
图5-2:单位立方体相对于物体空间的顶点位置
当物体被挂接到场景节点上的时候,这个场景节点上的世界空间转换矩阵就会应用到这个物体上面的所有顶点上,从而把物体转换到世界空间的绝对位置,这也是确定物体在3D空间位置、缩放以及方向的原理。事实上这些转换并不是直接施加在物体顶点上面的,所以这些操作也不会影响物体上面所顶点到所挂接场景节点位置的距离。
图5-3: 不管场景节点如何移动,立方体相对于场景节点的位置不会改变
在上图的(a)中,场景节点(立方体中心的实心黑圈)在世界坐标的原点(0,0,0)处。而在(b)部分,我们把原点移动到点(10,10,10),同时展示了立方体在世界空间新的顶点位置。
如果你刚刚接触3D图形,并且同时也从Softimage|XSI,Maya,3DSMax或者Blender这些3D工具创建过场景内容,可能会对上面的叙述感到有些混乱,可也能会让你误认为3D工具中所提及的坐标和Ogre中所讲的不一样。但事实并不是这样:一般来说导出的模型的坐标位置会遵守“世界标准单位”(除非3D建模工具和导出插件设定了自己的“度量衡”),这种单位和3D世界中的相同。换句话说,一个在3D工具中设置在点(1,1,1)的顶点,在导出到Ogre系统中,这个顶点相对于它挂接的场景节点的位置仍然是(1,1,1)。举个具体一点的例子,如果你是用Blender工具中制造的怪物头模型,把它放置在工具中点(0,2,0)位置上,那么当他挂接到Ogre场景之后,在它的物体空间中(也就是相对于它所挂接的场景节点)的位置仍然是(0,2,0)。
本地空间
所谓变换(Translation),一般来说指的是某种物体和“别的东西”之间的关系。(在3D节点的场景中,“别的东西”包括父节点或世界原点等等)。但实际上,你也可以通过变换来改变物体和它自身的关系(也就是我们所说的本地空间变换)。例如,你可以让一个物体沿着自身的Z轴正方向旋转一定的角度。
对于物体的旋转和缩放来说,“本地空间”的概念就是绕着自己的轴旋转;相对于自己的中心点进行缩放。图5-4和图5-5中分别展示了本地空间的旋转和缩放。
图5-4: 在本地空间中,一个节点沿着自己的轴旋转。图中的箭头表示旋转的方向
图5-5:在本地空间,一个节点在自己的中心位置进行缩放。图(b)的实心球缩小为原来的体积的1/2,半径变为0.5
虽然在转换的过程中,我们要依赖场景节点进行旋转或者缩放,不过实际上,并没有对场景节点做任何操作,因为节点既不能被看到也不能被渲染出来,所以真正被操作的是节点所挂接物体上面的每个顶点,参考图5-5所示。对场景节点的影响只是让其中的变换矩阵转化成新的状态。比如,偏移(yaw)、倾斜(pitch)以及滚动(roll)的操作都可以改变场景节点自身的旋转矩阵的状态(或者也可以通过四元数或者方向来改变)。
父节点空间(Parent Space)
父节点空间变换就是相对于父节点的变换。图5-6 演示了在父节点空间中进行的变换。
图 5-6:(a)为变换前的父节点与子节点,(b)为变换后子节点变为父节点空间的(-1,-1,-1)
如上图5-6(a)所示,在世界空间内存在父节点(较大实心球)在点(5,5,0)位置上,其子节点(较小实心球)处在其父节点空间(2,-1,0)位置上。在父节点没有旋转的情况下,父空间轴向平行于世界坐标系轴向,这时子节点在世界坐标系的位置,可以简单通过本身在父空间的位置和父节点在世界坐标系位置向累加得到。在图5-6(a)中为世界坐标系点(7,4,0)上。在图5-6(b)中,我们把子节移动到父节点空间坐标系(-1,-1,-1)位置,相应的它所在世界空间的位置也改变到点(6,3,-1)上。
警告:在这里需要搞清楚坐标变换和直接设置位置的区别。变换可以在任何空间体系中使用(不论是世界空间、本体空间还是父节点空间),然而设置一个节点的位置总是在其父空间坐标系里面。另外,坐标变换是累加的:总是在上一次变换的基础上进行变换。而设置位置总是绝对的。
图 5-7展示了一个节点的子节点围绕它旋转效果。在图5-7(b)中父节点已经绕Z轴旋转了45度,首先需要要注意的是,在旋转之后,对其子节点的操作都会受到影响。子节点在父节点空间的移动、旋转以及缩放的参照坐标轴都已经改变,如果这时候把子节点(在父空间)移动向量 (1,0,0),产生的结果是沿着新的父节点空间 “专用” 的X轴移动1个单位,而不是沿着世界坐标系的“公用”的X轴移动。
图 5-7:旋转一个场景节点同时它的子节点也跟着旋转。(b)中,父节点绕着自身Z轴倾斜(父节点在本地空间旋转)
另外一个需要注意的是新的变换对子节点的影响。因为子节点会随着父节点的移动、旋转以及缩放而变换到新的位置。并且同样因为对一个节点的变换会同时作用到它的子节点,并且依次向下传递。所以当一个节点旋转的时候,其所有子节点依次旋转同样的角度,并且这些子节点的子节点也会变换。进而导致这个分支的所有节点都会发生改变。
世界空间(World Space)
虽然世界空间是最简单的空间系统,但从节点层次中提取具体节点位置的时候,世界空间仍然起着极其重要的作用。为了防止对节点进行过多的计算,Ogre在进行每次变换操作的时候将都会将世界空间坐标放入向关节点的缓存。(这极大限度的提高了整个体系的渲染速度。)
世界空间永远平行于全局坐标轴,并且总是使用这些轴向来表示世界空间坐标系。
世界空间对渲染和观察投影(View Projection)操作有非常重要的作用,除非像在世界空间内布置物体这种特殊的操作除外,用户很少直接使用世界空间来进行工作。大部分的场景内节点的操作(比如以动物体),通常是在其他空间内完成。
场景中的活动物体(Movable Scene Object)
活动物体(Movable Object)由场景管理器创建,并且绑定到场景节点上,最后再由场景管理器销毁。因为这些内容独立于场景图,所以可以根据需要随时从场景节点上分离或者挂接,也可以在一个节点上挂接多个活动物体。不过需要注意的是同一时间内一个活动物体只能被挂接到一个场景节点上。
其中可以被渲染的活动动物,比如只包含几何和纹理的模型,在它们里面并不包含或处理具体的场景图信息。而另外不可渲染的活动物体(比如进入场景中的灯光或者摄影机等),虽然并不包含任何几何体或者纹理数据,但是它们仍然有能力简单的绘制自己(例如你可以在需要的时候让一个摄影机在场景中可见)。
以资源为基础的物体(Resource-Based Objects)
在Ogre中有相当多的场景内容是根据磁盘的信息来创建的,在这里把它们称为以资源为基础(也可称为磁盘基础)的活动物体,其中最常见的莫过于模型本身以及相应的骨骼数据。虽然我们可以在场景管理器中直接通过磁盘路径来创建这些模型实体,不过事实上场景管理器并没有负责实际载入工作,它只是帮你调用相应的资源管理器来读入数据,因为这些资源仍然被Ogre的相应的资源管理器(将在第7章了解更多的细节)进行管理。
在一般的时候,你可以直接通过场景管理器来构建模型实体,场景管理器会帮助你把这个过程分解成为详细的步骤,比如通过资源管理器分别载入模型、骨骼以及材质等,并把它们组合起来交给你处理。不过如果有需要,你也可以通过手动的方式来分别载入比如骨骼这种模型相关的信息。(因为骨骼模型其实可以和不同的模型数据搭配,所以有时候可能需要手工载入骨骼来处理相关问题。)
在成功创建了模型实体之后,它并没有被挂接到任何场景节点之上。为了让它进入渲染队列,你还需要进行相应的挂接工作。另外还需要注意的是,当挂接之后,模型被创建到所挂接节点的本地空间,而不是世界空间。
提示:虽然可能这不是什么至关紧要的问题,但是我还是想要在这里顺便提及一下。当你的美工(也许就是你本人),制作3D模型的时候,通常会使用3D模型工具的场景来作为管理模型的基础,而这些场景数据在导出模型时都会被原封不动的导出到Ogre的模型文件中。但是因为很多3D模型工具所使用的坐标系统是Z轴向上,这就与Ogre空间中Y轴向上的坐标系统产生一些小茅盾。为了解决这个问题,大多数Ogre的导出插件都提供了“旋转90度”的设置。
以四边形为基础的物体(Quad-Based Objects)
在活动物体中有很多的以四边形(Quad)为基础的实体,其中包括了粒子系统(Particle System)、公告栏(Billboards)、跟踪轨迹(Ribbon Trail)、表层(Overlay)和天空盒(Skybox,以及相关的天空面Skyplane和穹顶Skydemo)。它们由四边形面片构造,并且没有相关的模型资源。而他们所拥有的资源是包括定义纹理本身和生命周期(粒子系统以及跟踪轨迹会用到)的脚本,通常而言这些四边形为基础的物体都会把带有纹理的一面一直朝向着摄影机方向。在这一章节中我们着重介绍天空盒、天空面以及穹顶;而关于粒子系统和跟踪轨迹的具体细节我们将在之后的第十章进行具体的讨论;表层相关的信息要到第十二章才会有所涉及。
虽然天空面、天空盒和穹顶之间的区别是很让人感兴趣的,不过在这之前我们首先要了解它们的共同之处。其中最大的共同点是它们总是和摄像机保持着固定距离;其次,它们要么是在所有场景物体渲染之前渲染(这也是默认的情况),要么是在所有场景物体渲染之后渲染;虽然他们高高在上,但仍然和所有场景中其他物体是用相同的材质进行渲染,因此天空的纹理和其他的活动纹理并没有本质区别(下一章会着重讲解这些材质相关细节);最后它们都可以通过场景管理器开放或者关闭,也可以重新设置它和摄像机的距离。
天空面(Skyplane)
简单而言,天空面就是一个用来模拟现实世界中天空的一个平面。和Ogre中所有其他的平面一样,通过法线和距离来定在场景中的放置位置。通常而言,它只是一个平面;不过如果你需要,也可以把它当成“弯曲的”平面。场景管理器可以把天空面分解成多个片断,并能控制它的弧度,甚至你也可以亲自通过控制每个顶点来改变天空面的样子。当天空面弯曲的时候,可能很类似穹顶所产生的效果,不过还是需要运用一些云雾的效果来掩饰其中的不足。
穹顶(Skydome)
和使用了一个平面的“天空面”不同,穹顶技术使用了五个平面来构造天空,它是一个拥有一个顶和四个侧面构成的的半盒。换句话说它是一个没有底的盒子,意味着你必须要用世界地图(World geometry)来挡住盒子的底部,也就是类似地平线的效果。不过半盒下部分的纹理因为曲率过大而导致失真,所以地平线的高度应该调整到足够挡住这些问题的地方。系统通过调整纹理的坐标来产生弧度效果,这种弧度是可以被手动调整的。当这个值设置的较低的时候会让天空产生“平坦”的效果(这种状态适合在室外场景中看到“宽广的天空”的效果);较高的设置会产生“陡峭”的弧度(适合在室内场景的关卡中所透过窗口所看到的天空,虽然很小但是可以在任何时候看到全貌)。
天空半盒的每一个面都和摄像机总是保持固定的距离,这个距离可以交给具体应用来配置。不过这里需要注意的是:当你把这个距离设置得太近,可能会看到因天空渲染得太近,导致把场景地图中的一些区域给覆盖了。
与天空面类似,你可以任意的给天空半盒制定所需的纹理,比如你可以通过活动纹理来产生浮云一样的效果。如果想更好的控制这些就需要了解纹理坐标变换的一些高级特性(在本书的下一章节中会详细介绍这些)。
天空盒(Skybox)
首先要注意的是,天空盒并不是简单的在穹顶技术上增加了一个底部平面。而是不同前面所介绍的技术,天空盒并没有“弯曲”纹理坐标的能力,而是使用了标准的UV坐标映射的方法来处理相应纹理,进而只有天空盒才能使用立方体纹理(Cubic Texture)技术来实现天空的细节。与普通的纹理不同,立方体纹理可以“平铺”到整个空间中。
使用天空盒的目的也与穹顶和天空面不同,经常被用于展示摄像机六个方向所有能看到的天空面,而不只是头顶的一部分。另外,如果可能的话,天空盒还能潜在的利用到硬件对立方体纹理的加速功能。
渲染对象(Rendering Objects)
因为英文翻译到中文的关系,有些语义会变得模糊,所以首先要说明一下,这里阐述的渲染对象(Rendering Objects)和之前看到的可渲染物体(Renderable Object)概念不同。渲染对象指的是用来帮助场景图进行渲染功能的对象,而不是被渲染到屏幕的物体。在场景中两个最主要的渲染对象是摄象机和灯光。其中摄像机用来帮助你“拍摄”场景中的物体,而活动的灯光使你的场景显得更立体和真实。
摄象机(Camera)
对于场景中的摄像机来说,最主要的工作就是定义产生一个视截体用来处理渲染工作。更细致一点地说,它是一个包含“眼睛”所能看到所有内容的“盒子”(我们可以简单认为眼睛存在于视截体四条边所汇聚的点上面)。请参看图5-8。
图 5-8: 视平截头体的图形描述
在上面的图5-8中,你可以认为那个奇怪的眼睛就是“你”在场景中所拥有的。其中的近截面定义使用了两个参数:其与眼睛的距离和所拥有的视野(也就是你眼睛所能看到的范围);远截面不仅通过两个参数来定义了它的尺寸,同时还拥有和眼镜(摄像机)距离的参数。通过这两个“截面”进而产生了六个面组成的视截体。
Ogre利用这六面来剔除场景中不可见的物体,换句话说,图形硬件使用它裁减掉“盒子外”一定粒度下的几何图形(多边形的级别)。
同时摄像机也是场景中的活动物体,所以它也可以有移动、旋转以及改变位置的操作(视截体也一同被操作)。另外,作为特殊属性,摄像机可以在没有挂接到场景节点的情况下可以直接放置在场景中,也可以不依赖节点进行自身的旋转移动等操作。这其实是一种比较常用的做法,当摄像机被创建之后就已经开始正常的工作了,场景把它配置在世界空间坐标的(0,0,0)位置上,你可以把它移动到任何你希望的位置上去。
提示:这里并没有说摄像机不能挂接到场景节点上进行工作。不过需要注意的是,在把摄像机放置在节点上操作的时候,需要关闭其“锁定偏移轴(use fixed yaw axis)”设置,否则摄像机将会维持固定的“向上”状态,导致无法进行滚动(Roll)操作。当你对挂载摄像机的节点进行场景操作的时候,如果觉得有些不对劲,比如有一个或者更多的轴向被锁定,那么八九不离十就是上面所说的问题了。
灯光(Light)
很多时候,灯光都会被绑定到某些场景节点上,用来产生特殊的效果,在这里我们可考虑下面的一些情况:
•汽车模型的前灯
•手掌中散发出来的火焰
•黑暗走廊中飘荡的火球
上面每个例子里描述的灯光都需要绑定到一个场景中的对象,换句话说,它们都需要挂接在同一个场景节点上。
可以认为任何能够照亮场景的东西都是灯光。灯光既可以是活动的也可以是固定的。在场景中灯光拥有其类型、位置以及强度的属性。不过,灯光只能被用于场景中物体的局部辐射光照算法(Local Object Illumination)使用。
信息:所谓全局辐射度模型(Global Illumination),就是在计算光照的时候考虑所有光源,其中不仅包括光源本身,也包括从其他物体上反射过来的光线。使用全局辐射度模型的算法包括光线跟踪(Raytracing)和光能传递(Radiosity)。由于这种算法都有很高的计算强度,在本书写作的时候,硬件还鲜有能力进行真正的实时渲染,所以目前版本的Ogre也没有支持任何真正的全局辐射度算法。不过相对而言,还有一些准全局辐射度算法模型的存在(更精确的说,是半影模型“Soft Shadow Model”),其中包括环境光吸收算法(Ambient Occlusion,简称AO)以及计算次表面散射算法(Percomputed Radiance Transfer,简称PTR)已经被“通过”为3D硬件的解决方案。Ogre提供了对其中AO渲染算法的支持。
在局部光照的模型中,对物体的光照计算只考虑光源和物体本身。详细一点地讲,就是通过物体表面法线以及材质属性来计算光照最终在摄像机中的表现,最终计算出的颜色值提供给显示器输出。虽然理论上Ogre本身可以支持任意数量的光源,不过在下一章节中会看到,在实际执行的过程中,还是会有一些具体的限制(比如硬件)。其中有一个限制是: 3D硬件在渲染的过程中,每个渲染通路(Pass)只能使用一定数目的灯光(通常情况下是八个),所以如果你希望使用更多的灯光,那么就要使用更多的渲染通路。最终导致渲染效率的下降。另外一个限制是:真的有必要计算所有灯光么?在实际的场景中,可能有一些灯光微弱且距离很远,这时候可能只会对物体产生微弱的影响。Ogre提供了对灯光由近及远的排序,所以在大多数情况下你只要处理一个渲染通路所提供的灯光数量就足够了。
Ogre中包含了三种灯光模型(与普通3D硬件相同):点光源(Point),聚光灯光源(Spot),有向光源(Directional)。虽然它们都是可移动的,不过移动对它们的意义却各不相同;其中对有向光源的位置改变并没有实际意义,事实上有向光模型并没有位置属性,你只能通过四元数来操纵它的方向,进而改变光线的方向。点光源和聚光灯光源都对会对距离的增加进行衰减,你可以应用不同类型的衰减系数(包括常量,线性和二次)来具体影响它们。这两种光源还有其影响范围属性,超过这个范围的物体不会得到光照。所有的光源都有自身的颜色属性,被用于最终的颜色计算。
点光源(Point Lights)
点光源是在3D场景中比较常用的一种光源。它可以从空间中的一个点向周围辐射光线,点光非常适合模拟一个辐射的灯光,例如,一个壁灯或者台灯,也可以是场景中任何可以移动的需要向周围辐射灯光的对象。
聚光灯光源(Spot Lights)
聚光灯光源与点光非常相似,但是带有了方向特性。一个聚光灯光源光有一个锥形作用范围,你可以指定一个附加的衰减率作外锥所特有的叠加衰减系数,看图5-9。
图5-9:聚光灯光源的光锥特效
聚光灯光源就像它的名字一样,非常适合模拟聚光灯,也可以模拟出摩托车前灯的照射效果。
有向光(Directional Lights)
有向光完全不同于前面提到的两种光。通过“有向”这个词,可以想象到这种光源有方向特性。因为在该概念中,它是从场景外非常遥远的地方发射过来的光线,所以它并没有位置属性。我们可以拿真实世界中的太阳光来说明:太阳从很远的地方以一定角度照射地球上的所有物体。跟太阳一样,因为太遥远了,进而没有明显的距离衰减。因此它也不需要衰减参数,任何与衰减有关的参数都被有向光忽略。当然,有向光最适合模拟场景中的太阳光。
世界地图(World Geometry)
你可以认为世界地图中包含除了活动物体(Movable Object)之外所有的东西,比如高度场地图,网格地图,以及在地图上面的建筑和自然景观(树木以及植被),室内的门和窗户。而比如怪物和场景中活动的门不属于地图本身,它们是活动物体。可以这么理解,世界地图是角色们表演的“舞台”,是演员们存在和表演的场所。
通常来说,可以通过离线的制作工具来生成世界地图,比如一些3D建模工具Softimage|XSI、3D Studio Max、Maya以及Blender,或者高度场地图制作工具Terragen(http://www.planetside.co.uk/terragen/)。最终产生的地图通常是由网格模型为基础构成的,其中包括诸如一些简单的室内场景、建筑结构(比如桥梁)、植物(比如树木或者灌木丛)等等。虽然地形和整个室外场景也是可以用网格模型来构成,不过为了提高性能,它们被“分割”成一些较小的部分,当它们被系统载入的时候才会“缝合”在一起以供显示。
注意:当创建以网格模型为基础的场景或者关卡的时候,你有必要把它们拆分成许多小的部分。否则的话,就算整个网格模型只有一小部分需要显示,Ogre也会渲染整个模型的全部,这样对整个渲染效率的影响就不言自明了。一般来说,大部分的场景管理器(比如分页场景管理器“Paging Scene Manager”)都会提供自己的场景地图分割工具,虽然高度场地图本身并不支持在构建的时候进行分块,但是这些工作会交给系统在载入的时候自动进行。
Ogre的场景管理器提供了两种不同(却类似)的场景读取方式:直接从磁盘文件载入数据以及从任意的数据流载入数据。其中使用数据流的方式允许你在程序中把网格模型包装构造成通用或者私有的地图格式,并直接发送到场景管理器中。但是大多数情况还是使用从磁盘中读取地图的方法,在这种方法里世界地图文件通常是能直接打开和载入的磁盘上的独立文件。
通用的空间分割策略
场景管理器会根据设计和优化的需要,把场景中的几何体分解成一些集合来进行操纵和管理。这些几何体集合的划分方式被称空间分割(Spatial Partitioning),其分割的依据包括每个自空间有多少个几何体才能达到稳定、内含多少个可活动的物体、期待的密度和如何布置它们。对于Ogre来说,对场景地图的分割会建立在物体粒度上而不是多边形本身。这是因为Ogre 3D引擎是一个依靠硬件加速的工具,而当前的3D图形硬件中能更好的处理几何体批次,所以针对物体的分割方式可以得到比针对多边形的分割方式更好的效率。(分割多变形的技术更适合那些软件3D光栅化的工作,不过那是在没有图形硬件对3D变换和光照提供加速的时候)。
四叉树(Quadtree)和八叉树(Octree)的空间分割策略
四叉树是一种历史悠久的2D分割策略,不过在某些3D场景中也可以很好的使用,比如那些垂直高度变化很小的室外场景或者地形中。而八叉树是一种比较适合小型室外场景等分割方式,并且在允许摄像机随意位置的室内场景中工作的一样良好(比如摄像机可以在天棚上面或者地板之下)。
在图5-10中,分别提供了一个八叉树的垂直分割和四叉树分割过程展示,其中包含了三层的空间分割。
图 5-10 :八叉树(a)和四叉树(b)空间分割方试图
图 5-10(a)中你能够看到把空间逐层分割到八个(所以叫做八叉树)子空间的每一步。同样的,你也可以在图5-10(b)中看到四叉树算法把空间逐层分割成四个相同子空间的过程。这两种方式都是递归的分割独立的空间。
八叉树和四叉树更擅长处理在把多变形打包成几何体进行分割的工作,而这并不是二叉空间分割树(BSP)所擅长的。如果希望更多的了解四叉树和八叉树的信息,可以去网上找一些相关的资料来参阅。本书就不在这里多费口舌了。
二叉空间分割树(Binary-Space Partitioning)
BSP(binary-space partitioning)是一个非常有效的场景组织方式,但是对于现在的应用程序而言,更倾向于使用BSP做空间快速碰撞检测而不是几何体分割。虽然Ogre提供了一个简单得BSP支持(事实上也就是《雷神之锤III竞技场》所使用的地图格式),不过因为《雷神之锤III》因为历史的原因,更倾向于把几何数据划分成很多小的数据块而不是打包成大的数据批次,这使得现代的图形硬件并不能很好的进行处理。也便因此,已经没有开发人员继续维护这种BSP格式的简单实现。虽然现在仍然提供这种BSP地图场景管理器插件,但是它的作用也仅限于载入那些使用《雷神之锤III》关卡编辑器制作的地图所用。
静态几何体(Static Geometry)
看起来似乎静态几何体是活动物体(Moveable Object)的反义词,但事实上也不全是:通常来说静态几何体会由很多不再活动的活动物体来构成。
在这里需要要再次提到这个问题,现代的GPU更适合渲染少量巨大物体,而不是很多小几何片断。
注意:因为这个原因,当在现在图形硬件上来测试3D应用程序性能时,光衡量渲染三角面数量的能力通常是没有意义的事情:比如当一百万个三角面捆绑成一个簇里能达到每秒300帧的渲染速度,在结构组织改变的时候,把这些三角形分布在一千个簇中(平均每簇一千个三角面),就可能会降低到30帧每秒这样的结果。所以在现代3D应用程序中可渲染三角面的数量已经不是衡量应用程序的唯一标准。
所以通常而言,越多的三角面集中在一个簇中渲染,对提升你的应用程序效率越有利(当然,也不能盲目,很多时候还要考虑诸如图形硬件带宽吞吐量等诸多因素的影响。)
可能在这里你会觉得把复杂的场景作为静态物体来处理可能是一个不错的注意,但事实也并非如此,在下面列出静态物体的几个缺点:
·一般而言,巨大的静态物体需要在使用前被构建,通常这是一个缓慢的过程,所以不能在每一帧都执行。
· 静态几何体将按着放入物体的材质来进行分类,并且把材质相同的集合体放置到同一个渲染设置当中(换句话说,也就是放到同一个簇中)。但这并不表示只要把一组几何体打包成一个静态物体就能神奇的把它们捆绑到一个渲染设置中:不同材质的物体仍然会被拆分成不同的簇中被渲染。所以这时候你需要在两种需求中进行折衷:是在一次调用中渲染最多的三角形还是最少的调用的次数(也就意味着决定在静态物体中放置多少不同的材质)。
·在静态几何体中“静态”的含义是:一旦物体被放入静态几何体中,你就不能在单独移动它了。任何对静态几何体的世界变换都会应用到所有里面包含的物体上。这就好像放入咖啡中的方糖,你可以扔进去,但是再也捞不到它了。
·通常来说静态几何体会比同样大小的活动物体占用更多的内存;这是因为对于活动物体来说,可以多个实体共享一个网格模型。而静态几何体会为每一个实体创建一个网格模型数据的拷贝。不过在这本书写作的时候,GPU的几何处理也正在发生变革,可能在这本书出版之后,硬件会支持更加优化静态几何体的处理方式。
·就算在你的视野里(视截体)中看到了整个静态几何体的一小部分,甚至包括在你身后的整个数据都会传到图形硬件中渲染。假如你把场景中所有的植被都放到一个静态物体中,即使你只看到一颗小草,那么整个森林都会被渲染。
此外,在场景对静态几何体材质和模型的LoD(细节等级)处理中,会遵照整个静态场景最远的物体距离来设定整组物体的等级。这导致当距离改变的时候,整个静态物体的LoD都会有相同的改变。不过除了上面说的问题之外也有好的一面,场景管理器会通过空间分割算法把静态几何体分组,进而把那些没有在显示空间的静态几何体直接屏蔽到渲染队列之外。对于静态几何体的处理就和所有软件工程中所出现的问题一样,你必须为提高程序的性能而进行一系列设计上的折衷。
通过例子了解场景管理器
前面的文字主要的讨论了为什么要使用场景管理器,在之后的文字中我们将要通过一系列的例子来学习具体使用过程以及相应的编码。
以下的例子都取自Ogre本身的演示程序,你可以在网上免费下载到完整版本。而之后我会尽量把关键的片断列出来,并详细讲解里面的重点内容。
基础工作
在真正的通过场景管理器操纵整个场景之前,我们需要完成一些基础工作。首先要建立一个场景管理器的实例,然后至少还要构建一个摄像机,并执行“放置一个实体到场景中”的操作。
建立一个场景管理器和摄像机
在Ogre的演示程序中,创建场景管理器和摄像机的工作是教给作为演示程序框架的基类(在文件ExampleApplication.h中的ExampleApplication类)来实现,具体的过程可以在方法chooseSceneManager()和createCamera()中找到(代码5-1)。
代码5-1:Ogre演示程序中创建场景管理器和摄像机的相应方法
virtual void chooseSceneManager(void)
{
// Create the SceneManager, in this case a generic one
mSceneMgr = mRoot->createSceneManager(ST_GENERIC, "ExampleSMInstance");
}
virtual void createCamera(void)
{
// Create the camera
mCamera = mSceneMgr->createCamera("PlayerCam");
// Position it at 500 in Z direction
mCamera->setPosition(Vector3(0,0,500));
// Look back along -Z
mCamera->lookAt(Vector3(0,0,-300));
mCamera->setNearClipDistance(5);
mCamera->setFarClipDistance(1000);
}
代码5-1中所展示的两个方法都是对象的私有成员函数。其中创建场景管理器的代码是一种遵循旧版本Ogre的方法,通过枚举变量ST_GENERIC以及实例名"ExampleSMInstance"来创建场景管理器实例。因为你可以同时拥有多个管理器,并且可能它们是同一个类型,所以要通过不同的实例名称(字符串变量)来区分它们。如果你想尝试用新的方法来构建场景管理器实例,可以考虑下面代码。
mSceneMgr = mRoot->createSceneManager(“TerrainSceneManager”);
在上面的这一行代码中,我们没有使用直接使用场景管理器的名字作为参数,来代替ST_*形式的枚举类型。而且我也没有像前面一样提供场景管理器实例的名称参数,这样Ogre会默认的帮助场景管理器分配一个唯一的名称,不过代价就是我们必须自己来维护场景管理器的指针。
在Ogre中,摄像机实例也需要一个唯一的名字来创建,如果试图创建两个相同名字的摄像机会导致产生程序异常。在上面所示代码中,摄像机被设定为朝向Z周,在Ogre的坐标系里面代表着朝向屏幕内部的意思。然后摄像机被向“屏幕外”移动500个单位。这样就能让摄像机直接拍摄世界的中心点。当向世界添加实体的时候,可以直接被显示在屏幕上面。
在代码中,视截体的近截面被设置为5个世界单位;在本书前面提到过,在默认的情况下,Ogre把近截面的距离设置为100单位,远截面为1000 000单位。但是事实上这并不是一个好主意,为了确保深度缓存精度的细致,把远近界面调整为近似1000:1的比率会得到较好的效果。所以在代码5-1中的最后,我擅自增加了一行演示程序中没有的代码,把远截面的距离调整为1000(而不是默认的1000 000)。
创建实体和灯光
在Ogre的演示程序中,都会存在一个createScene方法用来构建场景,当需要载入场景的时候会由框架来调用。通常在这里会包括创建实体、灯光、公告板以及粒子系统等。
代码 5-2:在Environment Mapping Demo(EnvMapping.h)中的场景创建
void createScene(void)
{
// Set ambient light
mSceneMgr->setAmbientLight(ColourValue(0.5, 0.5, 0.5));
// Create a point light
Light* l = mSceneMgr->createLight(“MainLight”);
// Accept default settings: point light, white diffuse, just set position
// NB I could attack the light to a SceneNode if I wanted it to move
// automatically with other objects, but I don’t
l->set Position(20,80,50);
Entity * ent = mSceneMgr->createEntity(“head”, “ogrehead.mesh”);
// Set material loaded from Example.material
Ent->setMaterialName(“Examples/EnvMappedRustySteel”);
// Add entity to the root scene node
mSceneMgr->gerRootSceneNode()->createChildSceneNode()->attachObject(ent);
}
上面的代码5-2非常清晰,并没有什么特别值得注意的地方。其中mSceneMgr是在代码5-1中初始化的场景管理器实例的指针。代码5-2首先设置了环境光颜色并创建了名字为MainLight的灯光实例。然后通过场景管理器构建了一个食人魔(Ogre)头颅的模型的实体,接着把它的材质设置成为有环境映射特效的材质脚本“Examples/EnvMappedRustySteel”,最后再把整个实体(场景内容)挂接到场景中的节点上,这里创建了一个匿名的节点,并且也没有维护节点的指针,这是因为在这个演示程序中食人魔头颅从来不需要移动。
场景节点的移动和旋转
在之前的章节中很好的讨论过“为什么”和“如何”定义空间的关系,接下来我们将要了解怎样去使用和改变它们。进而讨论一些旋转、移动以及缩放节点的容易忽略的细节问题。
场景节点在默认的情况下变换空间为父节点空间(TS_PARENT),所以在使用父节点空间变换的时候不需要额外的设置:
mSceneNode->translate(100.0, 10.0, 0.0);
但是如果需要在世界空间进行节点变换,就的通过下面的方法来调用:
mSceneNode->translate(100.0, 10.0, 0.0, TS_WORLD);
对于本体空间的变换也同样如此。下面表示将一个节点沿着它朝向的方向“向前”移动100个单位:
mSceneNode->translate(0.0,0.0,100.0,TS_LOCAL);
对于旋转场景节点而言,默认的变换空间为本地空间(TS_LOCAL);如果你需要不同的旋转空间,必须明确的告知Ogre:
// 对象绕自己的Y轴旋转一弧度,大约57角度
mSceneNode->yaw(Ogre::Radian(1.0));
// 对象绕父节点的X轴旋转一弧度,大约57角度
mSceneNode->pitch(Ogre::Radian(1.0), TS_PARENT);
// 对象绕世界的Z轴旋转一弧度,大约57角度
mSceneNode->roll(Ogre::Radian(1.0),TS_WORLD);
缩放不需要关系空间;它在节点本身执行,同时影响节点的所有子节点。
// 在X轴缩放两倍,其他轴不缩放
mSceneNode->scale(2.0, 1.0, 1.0);
提示:如果你在缩放场景节点的时候,发现实体模型忽然产生了奇怪的变化。可能是因为法线也一同被缩放所致,因为光线计算会参照标准法线的数据,而缩放后的法线将会对计算产生相应的影响。解决的办法是通过在缩放后调用setNormaliseNormals()方法,不过这也会消耗一些执行效率。
场景管理器特性
接下来让我们通过更多的实践来了解场景管理器更广泛的应用。
载入世界地图
至今为止的演示都是载入对象到一个空的世界。大多数应用程序,需要有一个“布景”来作为放置对象的场所:换句话说就是载入“世界地图”。Ogre的场景管理器提供一个可以方便的用来载入世界地图到场景中的方法。
基本室外场景
在这里首先让我们看一下Terrain演示程序的代码(参看代码5-3)
代码5-3:Terrain演示程序中载入世界几何体到场景
void createScene(void)
{
// Set ambient light
mSceneMgr->setAmbientLight(ColourValue(0.5, 0.5, 0.5));
// Create a light
Light* l = mSceneMgr->createLight("MainLight");
// Accept default settings: point light, white diffuse, just set position
l->setPosition(20,80,50);
ColourValue fadeColour(0.93, 0.86, 0.76);
mSceneMgr->setFog( FOG_LINEAR, fadeColour, .001, 500, 1000);
mWindow->getViewport(0)->setBackgroundColour(fadeColour);
std::string terrain_cfg(“terrain.cfg”);
mSceneMgr -> setWorldGeometry(terrain_cfg);
// Infinite far plane?
if (mRoot->getRenderSystem()->getCapabilities()->hasCapability(RSC_INFINITE_FAR_PLANE))
{
mCamera->setFarClipDistance(0);
}
// Define the required skyplane
Plane plane;
// 5000 world units from the camera
plane.d = 5000;
// Above the camera, facing down
plane.normal = -Vector3::UNIT_Y;
// Set a nice viewpoint
mCamera->setPosition(707,2500,528);
mCamera->lookAt(0, 0, 0);
}
如果把上面的代码和Ogre演示程序中的实际代码比较,就会发现代码5-3中删改了原来程序中一些晦涩的部分(例如,实际代码中对摄像机的方向设置是通过四元数来完成的,在这里我改成了lookAt方法,在我看来这样可以帮助我们更清楚的了解代码的意图)。
其实代码5-3的前半部分和5-2非常类似,只是没有构建食人魔的头颅的网格模型。而在创建了灯光之后,增加了设置场景中雾效果的代码,其中包括对雾颜色的设置以及相应的方程参数。在这里值得注意的是,后面对视口的设置背景色选择了和雾相同的颜色,这是为了让背景不会与场景显得太过突兀。
天空面,天空盒,穹顶和雾
可能你注意到了,在上面代码中虽然建立了天空平面(高度5000),但并没有真正通过场景管理器来设置天空面。这是因为对雾的妥协。
图5-11:地形演示,在高度场图的边缘截屏
在图5-11中,我们把摄像机移动到整个地图的边缘,这里既有地表也有雾以及和雾一样颜色的背景。在这里我们可以观察到,远处的地表已经被雾气掩盖变得模糊。
但似乎并不能辨认出视口背景色的影响,好的,我们在图5-12中把背景色摄制成黑色,这样就能很容易辨认出来哪里是背景了。不过雾仍然对整个场景有影响,远处的地表也仍然模糊。
图5-12:地形演示,视口颜色为黑色
现在我们可以恢复代码中没有设置的天空面了,在代码5-3中加入下面的一行:
mSceneMgr->setSkyPlane(true, plane, ”Examples/CloudySky”);
图5-13: 地形演示,带有天空面
重新回到地图边缘(图5-13),现在我们能看到天空面的存在了,确实是一个“面”,不过却看不到天空的颜色,而是只有简单的雾色。这样结果的原因是天空被设置成5000个世界单位的高度,在这么远的距离下,已经完全被雾笼罩,完全辨认不出本来的纹理。现代图形硬件对雾化的处理是在整个场景被渲染完成之后才进行,所以GPU无法从场景图中挑拣出天空面,导致天空面也会被雾化所影响。为了解决这个问题,我们把天空面拉近到500单位(雾所不能影响的地方,参看图5-14)。
图5-14:地形演示,调整距离后的天空面
这次我把摄像机放置在贴近地表的地方向上仰视天空。效果不错,不是吗?看上去象清晨刚刚升起雾的样子。这是因为天空已经移到500个世界单位的地方,在雾无法影响的范围内。
以上就是为什么演示程序中取出了天空面的创建的原因,因为太高太远了,就算绘制了也会被雾所遮盖住,索性就用视口背景颜色来代表所有看不到的部分,包括天空面。最起码这样能节省不少效率。
另外在这里提及一点,就是为什么一定要“仰视”天空面。毕竟天空面只是一个平面,当你“平视”的时候就会看到天空和背景的交界线,你可以用地形去遮盖,也或者可以通过背景颜色来掩饰这条线的存在,不过更好的办法是改用天空盒或者穹顶(至少是天空曲面)来构造天空,这样可以减少或者解除可视角度的限制。
Terrain.cfg
你可能注意到了代码5-3中包含的terrain.cfg文件。它只是一个包含许多“名称=值”的配置文件,用来帮助Ogre生成的高度场地形。你可以如同我们在代码中是从磁盘中载入它并使用。在下面代码5-4列出了这个文件的内容(也可以在Ogre源代码和发布的SDK中找到)。
代码5-4:Ogre演示中提供的terrain.cfg文件的内容
# The main world texture (if you wish the terrain manager to create a material for you)
WorldTexture=terrain_texture.jpg
# The detail texture (if you wish the terrain manager to create a material for you)
DetailTexture=terrain_detail.jpg
#number of times the detail texture will tile in a terrain tile
DetailTile=3
# Heightmap source
PageSource=Heightmap
# Heightmap-source specific settings
Heightmap.image=terrain.png
# If you use RAW, fill in the below too
# RAW-specific setting - size (horizontal/vertical)
#Heightmap.raw.size=513
# RAW-specific setting - bytes per pixel (1 = 8bit, 2=16bit)
#Heightmap.raw.bpp=2
# How large is a page of tiles (in vertices)? Must be (2^n)+1
PageSize=513
# How large is each tile? Must be (2^n)+1 and be smaller than PageSize
TileSize=65
# The maximum error allowed when determining which LOD to use
MaxPixelError=3
# The size of a terrain page, in world units
PageWorldX=1500
PageWorldZ=1500
# Maximum height of the terrain
MaxHeight=100
# Upper LOD limit
MaxMipMapLevel=5
#VertexNormals=yes
#VertexColors=yes
#UseTriStrips=yes
# Use vertex program to morph LODs, if available
VertexProgramMorph=yes
# The proportional distance range at which the LOD morph starts to take effect
# This is as a proportion of the distance between the current LODs effective range,
# and the effective range of the next lower LOD
LODMorphStart=0.2
# This following section is for if you want to provide your own terrain shading routine
# Note that since you define your textures within the material this makes the
# WorldTexture and DetailTexture settings redundant
# The name of the vertex program parameter you wish to bind the morph LOD factor to
# this is 0 when there is no adjustment (highest) to 1 when the morph takes it completely
# to the same position as the next lower LOD
# USE THIS IF YOU USE HIGH-LEVEL VERTEX PROGRAMS WITH LOD MORPHING
#MorphLODFactorParamName=morphFactor
# The index of the vertex program parameter you wish to bind the morph LOD factor to
# this is 0 when there is no adjustment (highest) to 1 when the morph takes it completely
# to the same position as the next lower LOD
# USE THIS IF YOU USE ASSEMBLER VERTEX PROGRAMS WITH LOD MORPHING
#MorphLODFactorParamIndex=4
# The name of the material you will define to shade the terrain
#CustomMaterialName=TestTerrainMaterial
看了上面的代码可能会让人感到头痛,因为有太多的参数项需要设置(还有一些被注释掉的)。不过只要细心的观察,其实只是分别作了两项工作而已。首先是根据高度场图片以及相应参数来生成地形的网格模型,然后是根据自定义材质和GPU程序生成相应的地形材质纹理。
虽然上面的代码中有详细的注释,不过还是有一些参数需要我们特别的解释一下:
·Ogre中的TerrainSceneManager(地面场景管理器)会把整个高度场地图“切割”成相应的区域用来分割显示,其中每个区域被称为“Page(页)”,在配置文件中通过定义Tile(瓦片)的数量来决定每一个“页”的大小,所谓Tile代表着一个正方形的顶点组。
·Heightmap(高度场图)和WorldTextur(整个场景的纹理),并不需要与实际的地形大小一样。就是说,如果你想要建立一个2000*2000的世界,你不需要用2000*2000的纹理图。
·上面一条能正确执行的原因是你可以通过PageWorldX和PageWorldZ这两个参数告知Ogre如何重新设置它们的大小。
·MaxHeight参数告诉Ogre如何对应高度场图的高度元素到世界坐标。
·DetailTexture可以帮助你指定一层纹理循环的附着在地面上面,你可以用它来绘制草地或者沙场,如果你希望 “层叠”其他贴图在它的上面的话(比如用alpha混合,等等),就需要使用自定义材质了。
不通过配置文件直接创建地形
在某些时候并不需要从实际文件中载入地形数据。比如,我参与过这样一个项目,地形信息保存为一个特殊类型的二进制数据,在游戏运行的时候被载入并产生一个相应的表单数据。这个表单和terrain.cfg配置文件的功能很相似:我们通过标准模板库中的map类型来保存这些索引和值。然后通过迭代的方式把这些数据压入MemoryDataStream对象的实例中,最后把这个实例作为参数传递给场景管理器的setWorldGeometry()方法(参看代码5-5)。在代码中SceneData是std::map类型的重定义(typedef)。Ogre::DataStreamPtr是一个引用记数的智能指针,使用它会让程序更加安全。
代码 5-5:不用terrain.cfg文件载入Ogre地形数据
Ogre::DataStreamPtr Process_Loader::getSceneDataStream(SceneData& data){
// 为Ogre创建类似一个配置文件格式的数据
Std::string mem;
SceneData::iterator it;
for (it=data.bengin(); it!=data.end(); it++){
mem += it->first;
mem += “=”;
mem += it->second;
mem+= “\n”;
}
void *pMem = (void *)new unsigned char[mem.length()+1];
memset(pMem, 0, mem.length()+1);
memcpy(pMem,mem.c_str(),mem.length()+1);
// 把这些放入一个新创建的MemoryDataStream
Ogre::DataStreamPtr pStr(new Ogre::MemoryDataStream(pMem, mem.length()+1));
return pStr;
}
// 在创建世界时载入
Ogre::DataStreamPtr pStr = getSceneDataStream(terrainDef);
m_sceneMgr->setWorldGeometry(pStr);
场景查询的执行
通常把场景查询用于得到“在指定空间中都有哪些对象?”这样问题的答案。你可以使用下面的几种方案来得到这个问题的答案:
·轴对称包围盒(Axis-aligned bounding box):通过两个三维向量作为对角点产生的与空间轴平行的长方体空间。
·球体(Sphere):通过中心和半径定义的球形空间。
·平面包围体(Plan-bounded volume):任何三个或者更多平面包围的体积空间。
·光线(Ray):从一点指向一个指定方向的射线,可以找出与这条线相交的物体。
·任意相交(Arbitrary intersections):场景中任何相交对象。
执行所有的查询都可以事先过滤掉你不需要的对象类型,或者能够指定要返回的对象类型。例如,我们可以创建一个轴对称包围盒的场景查询让它只返回可移动灯光(看代码5-6)。
代码 5-6:简单的轴对称包围盒查询例子
const unsigned int LIGHT_QUERY_MASK = 0x00000001
Light* light1 = mSceneMgr->createLight(“Light1”);
Light* light2 = mSceneMgr->createLight(“Light2”);
light1->setPosition(12, 12, 12);
light2->setPosition(5, 5, 5);
light1->setQueryFlags(LIGHT_QUERY_MASK);
light2->setQueryFlags(LIGHT_QUERY_MASK);
AxisAlignedBoxSceneQuery* lightQuery = mSceneMgr->createAABBQuery(
AxisAlignedBox(0,0,0,10,10,10), LIGHT_QUERYMASK);
// 查询空间内所有的灯光。
SceneQueryResult& results = lightQuery->excute();
// 迭代查询结果。
// 迭代器中返回的是活动对象类型指针
SceneQueryResultMovableList::iterator it = results.movalbles.begin();
for(; it != results.moveables.end(); it++)
{
// 检查是否返回的都是灯光
assert(((*it)->getQueryFlags() & LIGHT_QUERY_MASK) != 0);
//在这里加入对灯光的操作。
}
// 结束后销毁场景查询实例
mSceneMgr->destroyQuery(lightQuery);
代码5-6中展示了最基础的场景查询过程。在实际的运用中,应该单独创建一个场景查询结构以便在每次查询中反复使用。这是因为创建场景查询对象的时间会大于场景查询的执行时间。换句话说,查询很快,但创建很慢。
在这段程序中以及所有其他场景查询操作,首先要解决的问题都是对返回结果的掩码处理,否则很难断定返回的具体类型。这是因为Ogre的查询返回结果只提供了活动物体和地图片断两种不同的类型指针,以至于当你得到返回结果的时候并不能分辨其具体类型(比如区分灯光和模型实体),这时候就要通过自定义的掩码来确定类型。同时也可以通过在查询中设置相同的掩码来过滤掉不需要的返回类型。
在代码5-6中,我们首先声明了一个灯光的类型掩码。这只对我们具体的应用有意义,Ogre只是保存这个信息以提供查询(在执行查询的时候使用它)。在后面代码中的断言是用来确定返回的对象与过滤标记之间的匹配,它的作用就象看上去那么简单。
光线投射查询(Raycasting)
大多数的空间查询方法是根据所提供的空间来查询空间内的物体,另外的查询方法是通过给定物体(或空间)查询相交的其他物体。而光线查询混合了这两种查询方法:返回在给定空间内部和光线相交的对象(所谓光线,其实是数学中所定义的射线,一端是顶点,另外一端是没有尽头的“无穷远”)。
地形跟随
下面提供了一段作为地形跟踪简单算法的代码。它是一个我们之前所学习到的简单方法的汇总,执行的结果让摄像机一直“贴”在地面上移动。
代码5-7:地形跟随
void Entity::clampToTerrain(){
static Ogre::Ray updateRay;
updateRay.setOrigin(m_controlledNode->getposition() + Ogre::Vector3(0, 15, 0));
updateRay.setDirection(Ogre::Vector3::NeGATIVE_UNIT_Y);
m_raySceneQuery->setRay(updateRay);
Ogre::RaySceneQueryResult& qryResult = m_raySceneQuery->execute();
if (qryResult.size() == 0)
{
//当我们在地形的下面的时候,需要升到地表上面
updateRay.setQrigin(m_controlledNode->getPosition());
updateRay.setDirection(Ogre::Vector3::UNIT_Y);
m_raySceneQuery->setRay(updateRay);
qryResult = m_rayScenQuery->execute();
}
Ogre::RaySceneQueryResult::iterator i = qryResult.begin();
if( i != qryResult.end() && i->worldFragment)
{
Ogre::SceneQuery::WorldFragment* wf = i->worldFragment;
m_controlledNode->setPosition(m_controlledNode->getPosition().x,
i->worldFragment->singleIntersection.y,
m_controlledNode->getPosition().z);
}
}
void Entity::init()
{
// 创建光线查询
m_raySceneQuery = sm->createRayQuery(
Ogre::Ray(m_controlledNode->getPosition(),
Ogre::Vector3::NEGATIVE_UNIT_Y);
// 把这个节点移动到贴紧地表的位置
clampToTerrain();
}
在大多数情况下,地形跟随的算法是通过光线检查物体下方的世界地表片段的高度(在高度场地图中垂直下面地表的焦点只有一个)。然后把物体设置成与地表相同的高度,这样就能让物体产生“紧贴着”地面的效果。过当物体移动的时候,我们同时要把检查用的光线更新到新的位置,在代码中是通过在每一帧调用clampToTerrain()方法来实现更新。
事实上这并不是地形跟踪的“唯一实现方法”。不过Ogre中的地形演示程序(Terrain)中所采用的方法和我们所看到的类似。clampToTerrain()方法中之行了如下工作:我们首先检查了NEGATIVE_UNIT_Y方向(Y周负方向,节点下面),如果下面没有地表,那么证明我们已经被埋入土里了,所以我们再一次向上看,检查UNIT_Y方向(Y周正方向,头顶)。这样就能保证我们一定能找到“地表”的位置,然后再更新节点的位置到“贴紧”地面的地方。从而实现对地图的“漫游”工作。
结语
在这一章节中,我们不仅了解了场景管理器的作用,更具体的看到了相应的操作方法。虽然你已经能随心所欲的操作场景中的物体位置关系和运动变换。但是想要渲染出华丽的3D世界这还是远远不够的,在下一章节中,我们将进一步接触材质在Ogre中具体的概念和应用,你会真正的接触到Ogre中比较有趣而漂亮的部分。