2007年3月25日
#
隐藏面剔除算法的出现是基于以下的现实,在游戏场景中你不必每帧都将所有的物体渲染到屏幕上,它的作用主要有两个:一个是减少GPU的屏幕填充率,另一个是减少CPU和GPU的传输带宽。根据当前的硬件现状GPU的填充率已经不存在任何问题,而现在唯一的限制位于AGP上,它那可怜的一点带宽已经成为图形渲染的瓶颈,因此当前HSR算法大部分都放在CPU端来做以尽量减少每帧需要传输的数据量,这里你不需要对CPU的负担感到担忧,因为现在的CPU完全可以适应这样的要求。当你将场景采用适当的场景分割算法如BSP、OC tree算法组织起来后,就需要在每帧渲染时使用适当的HSR算法对其中无用的信息进行剔除,现在的HSR算法基本上可以分为四种:backface culling、frustum culling、portal culling、occlusion culling。它们的作用分别如下:backface culling用于剔除mesh背面的triangle,frustum culling用于剔除屏幕之外的物体,portal culling用于剔除不能通过portal看到的物体,而occlusion culling用于剔除场景中被自己前面的物体所遮挡的物体。其中前两种算是最常用的算法了,对于backface culling来说它已经在GPU的硬件上实现,不过为了减少传输带宽,在适当的地方你也可以将其使用到CPU部分,主要是指地形这样的大规模MESH上,如何使用这需要看你是如何组织地形数据的,当你使用triangle strip渲染地形时,尽量按块来对地形进行culling,而当你使用triangle list来渲染地形时,你就可以尝试使用逐triangle来对其进行culling,关于实际使用应该使用哪种方法,需要你自己试验一下。frustum culling由于太普遍这里不再解释,而portal culling如果详细介绍的话那就是另外一篇文章了,这里只是简单的介绍一下,通常的portal culling实际分为两种,一个是用于bsp的PVS算法,这种方法的优点是通过预计算获得可见性信息,速度非常快,而且场景中的portal可以通过程序获得,不需要手工进行指定,但是缺点也非常明显,基本上它只能用于室内场景,而且由于PVS是一个可能的可见集合因此不能对不可见场景作到完全的剔除。另一个可以被称为实时的portal culling,这种方法的优点很明显,如果场景安排合理的话它可以作到完全的剔除,而且它可以用于任意的场景中,indoor、city、terrain它都可以使用,缺点也非常明显,portal必须手工进行指定,而且由于portal culling每帧都需要计算因此每帧出现在屏幕上的portal必须进行限制,还有最重要一点通常由于场景的限制尤其是在outdoor场景中它也不能完全作到完全剔除,下面我举一个简单的例子来看看它为什么不能作到完全的剔除:

(感谢Siney提供插图)
上面可以看作是一个房子,B为房间的门,也就是一个portal,A是场景中的一个物体如果我们做portal culling计算时,如果一个npc位于D点那么可见的,但是当位于C点时,由于前面存在物体A,应当是不可见的,但是portal culling并没有将其剔除因此npc还是会被传送到GPU进行渲染。
这个时候就需要可以使用occlusion culling技术来弥补这个缺憾了,因此在一个portal引擎中必然需要OC技术来配合使用,这里再举一个outdoor的例子,在室外存在一个building,我们可以使用building的门和窗户作为portal来对building内部进行culling,将building作为一个occluder对整个场景进行OC操作。根据相关文献提供的资料,当场景使用OC技术后会比不使用OC的场景提高30%到70%不等的FPS,因此如果有可能的话应尽量将OC技术加入到你的引擎中,而且OC技术并不是只能使用到portal引擎中,它可以被使用到任意技术构建的引擎中。
现在的OC算法非常多,比较优秀的如HZB、HOM算法虽然比较强悍,但是放在CPU进行计算的话,效率还是非常低的,达不到实时的要求,而当前的GPU还没有对其实现硬件的支持,因此不是本文介绍的重点,这里介绍的OC算法称为区间扫描线Z缓冲器算法,这是一种可以进行完全剔除并且效率非常高的一种OC算法。在进行介绍前我们必须明确几个概念,在OC中遮挡物被称为occluder,被遮挡物称为occludee,OC算法就是检查occludee是否被occluder所遮挡。场景中那些比较大的物体可以作为occluder,如房屋,城堡等,occluder上位于同一plane上的polygen称为surface,surface上由edge首尾相连构成一个闭合的空间。
进行OC计算之前我们首先需要获得场景中的所有occluder,这是非常重要的一个步骤,这一步通常在场景的预处理时进行,根据场景来产生一个occluder列表并保存到文件中。
下面我们讨论一下如何获得occluder,场景中的occluder通常需要手工进行指定,这是因为开发一个自动获得occluder的算法非常复杂,同时由于OC运算效率问题同一帧中出现的occluder数量必须进行限制。下面我们分情况来考虑一下:对于一个标准的BSP引擎来说,整个场景都由brush构成,因此获得occluder相对简单一些,在对场景建模时手工指定哪些brush作为occluder,然后在进行BSP分割时对这部分brush单独进行处理,按照和场景获得可见面一样方法获得这些brush的可见面来作为occluder。而对于其他技术构建的引擎获得occluder也相对简单,例如现在MMO比较流行的基于terrain的引擎,场景中可以作为occluder的物体其实都是一个个单独的model,因此求occluder实际上就变成获得model的convex hull,这类算法也比较多如qhull算法,但是如果使用程序获得hull的话对model的外形存在一定的限制,一个替代的方法是在美工对物体进行建模时同时为其指定一个hull并保存到文件中,这样的话就不会限制model的形状了,因此对于第二种情况概括起来说就是在编辑场景时指定适当的物体作为occluder,然后将物体的convex hull保存到occluder list中。
当我们获得场景中的occluder就可以进行OC运算了,先来看看区间扫描线Z缓冲器算法是依照什么样的原理来实现OC的。
|
(感谢Siney提供插图)
在上图中物体N为occluder,A、B、C为occludee,我们假定它们投影到屏幕上都为一个矩形,我们这里称N的上下两条edge a、b为扫描线,从上图中可以看出如果物体A位于N的后面那么A一定被N所遮挡,这可以通过比较N和A所在surface的z值来进行比较,这也就是本算法中Z缓冲器的来历,现在我们假设物体A、B、C都位于N的后面,可以看出A被完全遮挡,B是部分遮挡,C没有被遮挡,如何判断呢,先看A的情况,N和A存在四条扫描线按照y从小到大顺序分别是b,d,c,a,在扫描线b和d构成的区间中由于不存在A的edge因此跳过,检查扫描线d和c构成的区间,我们称位于两条扫描线之间的edge为活化边,它们保存在活化边列表active edge list中,每一条活化边保存了两个surface指针,分别是左surface和右surface,例如对于N的左edge而言,它的左surface不存在因此指向屏幕的background,也就是z值为最大的surface,而右surface为N所在的surface,其他类似。现在我们检查扫描线d和c之间的活化边列表,首先获得A的x值最小的火花边为A的左edge,接着获得N的活化边列表中x大于A的左edge的活化边,为N的右edge,比较两条edge的左surface,A的左edge的左surface的z大于N的右edge的左surface的z,因此A的左edge被N所遮挡,接着同样的方法检查A的右edge还是被遮挡,这样我们就可以判断A完全被N所遮挡。使用同样的方法检查B,在扫描线f和b构成的区间中,由于N不存在活化边列表可以判断B没有被N完全遮挡。再检查C,在扫描线h和g之间N的活化边列表中所有的edge的x都小于C的左edge,可以判断C没有被N遮挡。对于其它复杂的情况例如存在多个occluder的情况你自己可以验证,这种算法在任何情况下都可以对物体是否被完全遮挡进行精确的判断。
通过上面的介绍相信你已经完全理解区间扫描线Z缓冲器算法的基本原理,下面看一下具体如何来实现这个算法,步骤如下:
1、获得场景中所有的occluder,剔除occluder上所有背对camera的surface。
2、使用near clip plane对所有的surface进行clip操作,剔除位于near clip plane之外的surface,如果surface和near clip plane相交计算交点,这样也就剔除了那些位于nearclip plane之外的edge。
3、将所有的顶点变换到投影空间,这里需要注意投影空间的性质,投影变换实际上就是将frustum变换为一个BOX,对于openGL来说就是一个立方体,最小拐点为(-1,-1,-1),最大拐点为(1,1,1),对于DX来说是一个长方体,最小拐点为(-1,-1,0),最大拐点为(1,1,1)。在投影空间所有物体投影到屏幕上形状不会发生改变,这也是为什么要变换到投影空间的原因。计算每一个surface在屏幕上的投影面积,剔除那些面积过小的surface,剔除位于frustum之外的surface,但是并不对surface求交,这是为了防止因为计算精度的原因出现错误。将surface所在的plane也变换到投影空间,这样做是为了方便计算指定顶点的z值。
4、将变换到投影空间中的所有顶点保存到edge list中,注意edge保存方式,edge起点的y值应当小于终点的y值,并且我们称起点处的y值为edge的y值。在edge list中按照edge的y对所有的edge按照从小到大的顺序进行排序,如果y相同比较x值,如果x也相同比较edge的斜率。因为edge list完全按照y进行排序,因此我们也可以称其为Y桶。需要注意的是如果edge和X轴平行的话,并不需要将其加入到edge list中。
5、查找扫描线并计算扫描线之间的活化边列表。从上面的原理介绍中我们也能大致了解如何获得扫描线,扫描线必然位于edge的端点上,而且如果edge存在相交的话,那么交点也需要做为扫描线,那么我们需要对Y桶进行遍历来查找位于扫描线Y=-1和Y=1之间所有的扫描线,当获得所有的扫描线后接着需要计算扫描线区间的活化边列表,注意活化边列表中的edge需要使用edge与扫描线之间的交点作为端点,并且活化边列表是按照x进行排序,因此也可以称为X桶。
6、对场景需要检查是否被遮挡的物体传入它的AABB作为occludee,首先检查occludee在屏幕上的投影面积是否足够大,如果太小不需要对其进行OC,接着将AABB转换为一个BOX,计算BOX的surface和edge,将其变换到投影空间并按照上面的方法查找扫描线并获得活化边列表。从occluder和occludee中y最小的扫描线出发,逐区间对活化边列表进行比较检查occludee是否被完全遮挡。
在这种算法中,几乎每帧都需要对活化边列表进行更新,因此计算量最大的部分也在这里,不过一旦活化边列表建立以后对occludee进行遮挡计算非常方便快捷,适合对大量的动态物体进行OC运算,尤其是那些渲染代价非常大的角色模型。
HL2的地形系统是一个另类的地形系统,它没有使用当前流行的heightmap技术,而是完全使用displacement map技术来进行处理,使用displacement map技术的初衷是为了在框架中和BSP技术保持一致,这是因为在BSP中最小的渲染图元是surface,surface的限制是所有的顶点必须位于同一个平面上,实际上从理论上来说在HL2这样的引擎中也可以使用heightmap来构造地形,那就是将heightmap数据也分块保存,但是当前流行的地形技术使用的都是一个整张heightmap,而且大量的LOD算法都是基于这个基础上的。使用displacement map的另外一个好处是可以构造更加复杂的地形,这是因为它存在法线的缘故。同时使用displacement map还有一个好处是在将来,因为当前MS已经对displacement map技术进行了支持,现在的问题是GPU在硬件上还不支持,如果一旦硬件支持的话,就有可能将整个地形由GPU来处理,那么对地形渲染的速度和精度会有非常大的提高。
1、what’s Displacement Map
displacemtn map技术是最近几年才出现的一个技术,它出现的初衷是为了解决内存和GPU之间的带宽问题,它开始主要用于高poly角色模型的渲染,由于模型的高数据量会使内存和GPU之间的传输成为瓶颈,因此将其转换为低poly模型和一张displacement传送到GPU中,在GPU中转换为高poly模型进行渲染,可以大大减轻传送的数据量。
但是当前这项技术已经获得非常大的发展,它使用的范围非常大,从室外地形、水面、海洋到室内场景的渲染,它为提高场景的真实度提供了一个有力的工具。一个displacement map通常包含两部分:一张normal map用来保存法线,另一张height map用来保存顶点的偏移。当需要从低poly模型转换为高poly模型时很简单,首先从模型的一个face上获得一个插值点P1,然后从displacement map中获得相应位置的normal和distance,那么新的顶点P2为:
P2 = P1 + normal*distance
关于displacement map的参考文章请看shaderX2中的Displacement Mapping。
2、HL2 Displacement Map Rule
在HL2中displacement map信息被保存在BSP文件中,它有以下几个数据块:
LUMP_DISP_VERTS 保存displacement真实顶点信息
LUMP_DISP_TRIS 保存displacement三角形标识符信息(walkable or buildable)
LUMP_DISPINF 保存displacement 的连接信息
它们保持的信息结构如下:
LUMP_DISP_VERTS:
class CDispVert
{
public:
Vector m_vVector;// Vector field defining displacement volume.
float m_flDist; // Displacement distances.
float m_flAlpha;// "per vertex" alpha values.
};
LUMP_DISP_TRIS:
class CDispTri
{
public:
unsigned short m_uiTags; // Displacement triangle tags.
};
LUMP_DISPINF:
class ddispinfo_t
{
public:
int NumVerts() const{ return NUM_DISP_POWER_VERTS(power); }
intNumTris() const{ return NUM_DISP_POWER_TRIS(power); }
public:
Vector startPosition;// start position used for //orientation -- (added BSPVERSION 6)
int m_iDispVertStart;// Index into LUMP_DISP_VERTS.
int m_iDispTriStart;// Index into LUMP_DISP_TRIS.
int power; // power - indicates size of map //(2^power + 1)
int minTess; // minimum tesselation allowed
float smoothingAngle; // lighting smoothing angle
int contents; // surface contents
unsigned short m_iMapFace; // Which map face this
//displacement comes from.
int m_iLightmapAlphaStart;// Index into ddisplightmapalpha.
int m_iLightmapSamplePositionStart;// Index into.
// LUMP_DISP_LIGHTMAP_SAMPLE_POSITIONS
CDispNeighbor m_EdgeNeighbors[4];// Indexed by NEIGHBOREDGE_ defines.
CDispCornerNeighbors m_CornerNeighbors[4]; // Indexed by CORNER_ defines.
enum { ALLOWEDVERTS_SIZE = PAD_NUMBER( MAX_DISPVERTS, 32 ) / 32 };
unsigned long m_AllowedVerts[ALLOWEDVERTS_SIZE]; //
// This is built based on the layout and
//sizes of our neighbors
// and tells us which
//vertices are allowed to be active.
};
在HL2中规定一个displacement最大由17*17个顶点组成,每一个displacement都将左下角看作是自身坐标系的原点,并按照顺时针方向进行记数。如下:

在ddispinfo_t类中我们可以看到为了记录displacement的邻接关系,它使用了两个数组,m_EdgeNeighbors记录四条边的邻接关系,而m_CornerNeighbors记录了四个角的邻接关系。必须注意的是在HL2中邻接的displacement并不一定都是一样大的,也就是说邻接的displacement不一定是一个,也可以是两个,如果邻接的为两个的话那么邻接的displacement的大小必须小一倍。如下:

在displacement和surface中都有一个变量来指定它们相互之间的对应关系,你可以这样认为,如果一个surface是一个displacement surface的话,那么在surface所保存的顶点信息就是displacement所在的基准面信息,实际上就是记录基准面的四角坐标,当产生实际顶点时过程如下:通过这四角的坐标和displacement的power信息可以插值获得每一个真实顶点的基准点坐标,然后通过CdispVert所保存的信息获得真实的顶点坐标。
3、如何处理地形系统
在HL2中为了高效的渲染地形定义了大量的类,其中最重要的为CdispInfo,它几乎包含了地形的大部分处理,还有一个类CpowerInfo,这是一个予计算的模版类,它预先计算了每一种大小displacement的信息,这样做是为了加速地形的渲染,而类CcoreDispInfo只用来载入数据时使用,它的功能就是将BSP文件中的信息转换为程序可以处理数据。其实在HL2中比较难懂的是它的LOD算法,下面是它具体的算法:
首先是它的更新策略,在正常情况下(LOD的控制台变量为默认值)对displacement的更新有两种情况,它规定了一个更新半径(由控制台变量r_DispFullRadius指定)凡是位于此半径内的displacement的LOD等级都为最大。另外在每次更新时每一个displacement内部都记录了更新时camera的位置,如果当前camera的位置减去上一次更新时camera位置的大小大于一个规定值(由控制台变量r_DispRadius指定),对其进行更新,必须注意的是此时更新的是位于更新半径外的displacement。
下面是它的LOD算法,还是要提醒此时是对更新半径外的displacement进行处理。通常现在LOD算法都是基于距离进行计算的,但是HL2中没有采用这种方法,而是使用一种称为屏幕误差的方法进行。具体方法如下:
首先假设将要更新的displacement位于屏幕的中心,然后求出此时的camera变换矩阵,遍历displacement中的所有顶点(不包含当前displacement的中心点和四个角上的点),对于每一个顶点,用它的坐标和它所在error edge的算术中点坐标相减,然后用这个值和当前displacement的AABB中心点相加,结果通过camera矩阵进行变换然后将X,Y部分分别除以Z值得到的就是这个顶点在屏幕上的视觉误差了。这是一个非常不容易理解的部分,尤其是除以Z值的部分,其实这样做是为了将其投影到Z为1.0的平面上,省略了进行投影变换的过程,提高了效率。当这个误差大于预定义值就将这个顶点渲染出来。
通过上面的介绍可以看出在HL2中并没有象其它LOD算法一样对每一个displacement硬性的规定一个LOD级别,而是通过每一个顶点的屏幕误差来确定它是否可以被渲染,这样做在渲染非常大的地形时效率不会太好,但是在处理HL2这样的小场景时效率确实非常不错。同时也要注意这种地形方法的优点,它非常适合处理落差非常大的室外场景,如地牢围攻这样比较夸张的地形,不过此时就需要对它的LOD算法进行一下改进,另外在HL2中地形一般都比较小,如果地图设计良好的话基本上很少会渲染hidden surface,因此上不需要做地形tile的HSR,而将这种地形系统扩展到无限地形系统时,必然需要使用一定OC算法来做HSR的工作,这可能是将其扩展到无限地形系统的一个非常具有挑战性的工作了。
你是否正在考虑构建一个游戏引擎呢?你对如何构建一个游戏引擎是否已经有了一个明确的
计划呢?你是否已经对如何组织游戏引擎各个模块之间的关系有了一个通盘的考虑?如果没有,
那么本文将对你建立一个良好的游戏架构提出一些有益的方案,如果你已经对上面的问题有了一
个明确的答案,那么本文不是你需要阅读的内容。本文的目的是给那些没有任何建立完整游戏引
擎经验的人提供一些入门性的知识,使他们初步了解一下如何来构建一个游戏引擎,构建游戏引
擎应该注意哪些方面的问题,并提供了一些成熟的设计模版并指出这些设计模版使用的范围,我
希望这些内容对那些中级编程人员也有一个良好的参考作用。本文的内容来源于一些流行的编程
书籍,具体书目请见本文最后的部分,由于本文是介绍性质的文章,因此如果你对哪方面的内容
非常感兴趣请参考相应的书籍,本文或许有很多错误的地方,如果你有什么看法的话可以通
过Email和我进行讨论,我的地址为dreams_wu@sina.com。
这里必须再次提醒你,本文介绍的是一些通用的游戏编程技巧,虽然是通用但是可能并不是
非常全面,可能存在这样或那样的缺陷,因此如果你希望它发挥最大的效用必须恰当的使用它,
而不是不分场合的滥用。切记切记,一个初学者最容易犯的错误就是任意使用一些设计模版而不
顾它的使用范围。
在开始构建一个游戏引擎时你需要先考虑哪些方面的问题呢?这是你必须认真考虑的问题,
我的答案是首先必须考虑代码的可读性,尤其是在多人进行开发时更必须高度重视,如果你写的
代码其他人需要花费非常大的精力进行阅读,那么根本谈不上提高工作效率,下面是提高代码可
读性的一些良好建议:
1、建立一份简单明了的命名规则。一份良好的命名规则可以大幅提高代码的可读性,规则必须
简单明了,通常只需要两三分钟的阅读应该可以让其他人掌握,例如在代码中直接使用匈牙利
命名法这种大家熟知的规则,使用字母I作为接口类的首字母,使用C开头作为实现类的首字母,
使用g_开头的变量名作为全局变量,s_开头作为静态变量名,m_开头作为内部变量名,使用_开
头作为类内部使用的函数名等等,通过名字就可以使你大概了解对象的使用范围和基本功能。
2、不要讨厌写注释。一个编程者易犯的错误就是不写注释,认为它会增加自己的工作量,但是
他没有考虑到相应的工作量已经转移到代码阅读者的身上,可能看代码的人会花费比写注释时间
两倍或者三倍的时间来阅读代码,这是一种非常不负责任的行为,通过一段简短的注释可以使阅
读者迅速的了解代码的功能,从而把时间更多的用到功能的扩展上。下面是一些良好的建议:尽
量对每一个变量标明它的功能。对每一个函数声明的地方标明它的功能,对于复杂的函数还应当
写清参数和返回值的作用,注意是在声明函数的头文件中。在关键的代码处写清它的作用,尤其
是在进行复杂的运算时更应如此。在每一个类声明的地方简要的介绍它的功能。
3、减少类的继承层次。通常对于游戏编程来说每一个类的继承层次最好不要超过4层,因为过多
的继承不仅会减少代码的可读性,同时使类表指针变长,代码体积增大,减低类的执行效率。还
要注意要减少多重继承,因为不小心它会形成编程者非常讨厌的“钻石”形状。同时还要注意如
果能使用类的组合的话那么就尽量减少使用类的继承,当然这是设计技巧的问题。
4、减少每行代码的长度。尽量不要在一行代码中完成一个复杂的运算,这样做会增加阅读难度,
同时不符合现代CPU的执行,由于CPU现在都使用了超长流水线的设计,它非常适合执行那些每行
代码非常短而行数非常多的代码,例如对一个复杂的数学运算,写成一行不如每一步骤写一行。
以上建议是我的一些粗略看法,如果你还有什么好的看法可以给我指出来,同时上面的建议
并不是绝对的,例如类的继承并不是绝对不能超过4层,如果你需要的话可以使用更多的继承,前
提是这样带来的好处大于代码执行效率的损失。
接着看看要考虑什么,在Game Programming Gems3的《一个基于对象组合的游戏架构》一文
指出了几个值得考虑的问题,首先是平台相关性与独立性和游戏相关性与独立性的问题,也就是
说应当作到引擎的架构与平台和游戏都无关。为什么要做到与平台无关性呢?这是因为你必须在
开始架构引擎考虑它的可移植性,如果在开始你没有注意到这个问题,那么一旦在游戏完成后需
要移植到其他的游戏平台上,你会发现麻烦大了,你需要修改的地方实在是太多了,所有与平台
相关的API调用都需要修改,所有使用了平台特定功能的模块也需要修改,这是一个非常耗费精力
的事情,可能需要花费和开发一个游戏一样的时间,而如果你在开始的时候就考虑到这个问题,
那么非常简单,只需要写一个相应平台的模块替换掉原来的模块即可,这样精力就可以放在如何
充分的利用特定平台的能力来提高游戏的表现力上,而不是代码修改上。下面简单的谈一下如何
使引擎作到与平台无关。
1、注意操作系统的差异。现在主流的操作系统主要是Windows和Linux两种,当然还有Unix和Mac
,在编程时你必须注意这一点,当你需要包含Windows的头文件时,你必须将它包含在宏_WIN32
中,下面是一个简单的例子:
#ifdef _WIN32
#include "windows.h"
#endif
而你使用Windows平台特定的API时也应当如此,这样在其他平台上编译时可以保证Windows平台
相应的代码不会被编译进去。对于其他平台也应当如此。
2、注意编译器的差异。现在通用的编译器主要有VC,BC和gcc几种,在进行Windows平台编程时,
你通常会使用VC或BC,而对Linux平台编程时通常使用gcc,使用VC编译器你不可能编译出用于Linux
平台的代码,因此在编程时也需要注意,你可以使用上面的方法通过特定的宏来将不同的编译器
分离开。举一个简单的例子:
#ifdef _WIN32
#ifdef _MSC_VER
typedef signed __int64 int64;
#endif
#elif defined _LINUX
typedef long long int64;
#endif
在不同的编译器中对64位变量的命名是不同的,因为它并不是C++标准的一部分,而是编译器的扩
展部分。另外一个例子是编译器使用的内联汇编代码,在VC中你可以使用_asm来指明,而对于
Linux平台的编译器你需要使用它的专用关键字了。
3、注意CPU的差异。对于不同平台来说它通常会使用不同的CPU,不过幸好Windows和Linux都支持
X86的CPU,这也是PC游戏的主流CPU平台,而XBOX使用的也是X86的CPU,除非你需要移植到PS2平台
,否则这将大大减轻你的编程负担,在X86平台上提供了一个cpuid的指令可以非常方便的检查CPU
的特性,如是否支持MMX,SSE,SSE2,3DNow!技术等,通过它你可以使用特定的CPU特性来加速你
的代码执行速度。
4、注意图形API的差异。现在图形API主要存在两种主流的平台DirectX和OpenGL,DirectX只能用于
Windows平台,而OpenGL几乎被所有的平台所支持。因此你需要为不同的图形API进行封装,将它做
成不同的模块,在需要的时候进行切换。完成这个工作最好的方法是使用后面介绍的类厂模式。
5、注意显卡的差异。现在显卡有两大主流ATI和NV,虽然显卡可以被主流的操作系统所支持,但是
必须注意在不同的游戏平台上还是使用不同的GPU,而在GPU之间也相应有自己的功能扩展,因此在
使用特定的扩展功能时必须检查一下是否被显卡所支持。
6、注意shader语言的差异。可编程图形语言的出现是最重要的一项发明,现在几乎每一个游戏都
在使用这项技术,而正由于它的重要性现在出现了多个标准,HLSL只能用于DX中,而OpenGL由于
标准的开放性更加混乱,每一个显卡厂商都根据自己的产品推出相应的扩展指令来实现shader,
而NV更推出了GC可以同时适用于DirectX和OpenGL,这是一个非常好的想法,不过由于这不是一个
开放的标准因此没有得到其他厂商的支持,在ATI显卡上运行GC代码你会发现比在NV显卡慢了几个
数量级,由于上面的情况你需要根据不同的平台相应进行封装,方法和第4条一样。下面的建议值得
你去考虑,当你使用DirectX平台时应当使用HLSL,而对于OpenGL可以封装为两个模块,根据显卡
的不同进行切换,也可以使用GC特别为NV的显卡封装一个模块来对它进行优化。
这里需要补充一点,如果可以的话尽量和OGRE一样为不同的操作系统进行封装,这样方便在
不同的系统之间进行切换。
接着看看如何实现游戏无关性,通常游戏引擎如果要实现游戏的无关性是非常困难的,这也就
是说要求你的引擎适合所有的游戏类型,这太难了,考虑一下一个RPG游戏引擎如果用来做一个RTS
游戏那简直是不可能,类似的你不可能拿Q3引擎来做RTS游戏,但是如果引擎设计的非常良好的话
还是可以实现部分的游戏无关性。也就是说你可以将引擎的一部分模块设计成通用的模块,这样在
开发其他类型的游戏时可以重用这部分的代码,这部分代码包括底层显示,声音,网络,输入等
部分,在设计它们时你必须保证它们具有良好的通用性。
在这些问题之后你应当考虑程序的国际化问题。这也是非常重要的方面,因为你的游戏可能在
其它国家发行,这主要是注意语言方面的问题,尤其是字符串的处理,在C++的标准库中提供了一个
String容器,它提供了对国际化的良好支持,因此在引擎中你需要从头到尾的使用它。
接下来我们看看本文最重要的内容,如何组织一个引擎的架构。这是引擎最重要的部分,为什么
重要呢?如果我们把引擎看作一间房子的话,那么架构可以看作是房子的框架,当你完成这个框架
后就可以向框架内添砖加瓦盖房子了。下面让我们来看看如何构建这个框架,通常一个大型的软件
工程是按照模块化的方式来构建的,编程之前要进行必要的需求分析,将软件工程根据不同的功能
划分为几个较大的功能模块,对比较复杂的模块你可能还需要将它分为几个子模块,并需要给出各
个模块之间的逻辑关系。当你编写一个引擎时也需要进行相应的功能分析,让我们看看如何来划分
引擎的功能模块,如果按照上面的游戏无关性和相关性进行分析的话我们可以发现它可以分为游戏
相关层和无关层两层,游戏相关层由于包含了游戏的逻辑性代码也被称为逻辑层。逻辑层应该位于
引擎的最顶层,如果你在开发一个局域网或在线游戏的话,按照网络程序的C/S开发模式,这一层
应该分为两个模块,服务器和客户端模块,它包含了和特定游戏相关的所有功能,如AI,游戏角色,
游戏事件管理,网络管理等等。在它下面就是游戏无关层了,包括了引擎核心模块,GUI模块,文件
系统管理模块等等,其中引擎的核心模块是最重要的部分,逻辑层主要通过它来和底层的模块打交
道,它应该包含场景管理,特效管理,控制台管理,图形处理等等内容。在向下就是一些底层模块
了,如图形渲染模块,输入设备模块,声音模块,网络模块,物理模块,角色模型模块等等,所有
的这些底层模块必须通过核心模块来和逻辑层进行交互,因此核心模块是整个引擎的枢纽,所有的
模块都通过它来进行交互。
下面看看应该如何来进行模块的设计,这里有一些通用的规则是你应当遵守的:
1、减少模块之间的关系复杂度。我们知道通常每一个模块内部都存在大量的对象需要在各个模块
之间进行相互的调用,如果我们假设每一个模块内部对象的数量为N的话,那么每两个模块之间的
关系复杂度为N*N,这样的复杂度是不可接受的,为什么呢?首先是它非常不利于管理,由于各个
模块都存在大量的全局对象,并存在相互依存的关系,并且各自建立的时间各不相同,这就存在
初始化顺序的矛盾,考虑这种情况,一个模块中存在一个对象需要另外一个模块中的对象才能进行
初始化,当这个对象进行初始化时而另外的对象在之前并没有初始化就会引发程序的崩溃。其次,
不利于多人进行同时的开发,由于各个模块存在相互依存的关系,当复杂度非常高时就会出现模块
与模块的高度依存,也就是说一个模块没有完成下一个模块就无法完成,因此就需要一个模块一个
模块按照它的依存关系进行编程,而无法同步进行。因此在设计模块时的第一件事情
是减少模块之间的复杂度,为此你在设计模块时必须为模块设计一个交互接口,并约定所有模块之
间的交互必须通过这个接口来进行,这样模块之间的关系复杂度就降低为1*1了,非常方便管理,同
时这非常利于多人之间进行开发,假如每个人负责一个模块的开发的话,那么你只需要先完成这个
接口类,其他人就可以利用这个接口进行其他模块的开发,而不必等到你完成所有的类再进行,这
样所有的模块都是同步进行,可以节省大量宝贵的开发时间。
2、对类的抽象接口而不是类的实现编程。这是《Design Patten》一书作者对所有软件编程者的建议
,它也对游戏编程有很大的指导意义。对模块中所有被其它模块使用的类都要建立一个抽象接口,
其它模块要使用这个抽象接口进行编程,这样其它模块就可以在不需要知道类是如何实现的情况下
进行编程。这样做的好处是在接口不改变的情况下任意对类的实现进行改变而不必通知其它人,这
对多人开发非常有用。
3、根据调用对象的不同对类进行分层。实际上本条还是对第2条的补充,分层还是为了更好隐藏底
层的实现。通常一个类不仅被其它模块使用还要被自身模块所调用,而且它们需要的功能也不同,
因此我们可以让一个类对外部显现一个接口而对内部也显现一个接口,这样做的好处和上面一样,
因为一个复杂的模块也是多人在进行编程的。
4、通过让一个类对外显现多个接口来减少类的数量。减少关系复杂度的一个方法是减少类的数量,
因此我们可以把完成不同功能的类合并成一个类,并让它对外表现为多个接口,也就是一个类的
实现可以继承多个接口。
上面的建议只是起到参考作用,具体实现时你应该根据情况灵活使用,而不是任意乱用。
下面的内容涉及到具体的编程技巧,
对于引擎中的全局对象你可以使用Singleton,如果你不了解它是什么可以阅读《Design Patten
》,里面有对它的详细介绍,具体的使用可以通过OGRE引擎获得。
调用模块内的对象可以通过类厂来实现。COM可以看作是一种典型的类厂,DX就是使用它来进行
设计的,而著名的开源引擎Crystle Space也是通过建立一个类似的COM物体来实现的,但是我并不
对它很认可,首先构建一个类似COM的类厂非常复杂,开销有点大,其次COM的一个优点是可以对程
序实现向下兼容,这也是DX使用它的重要原因,而一个游戏引擎并不需要。OGRE中也实现了一个类厂
结构,这是一个比较通用的类厂,但是使用起来还是需要写一段代码。我比较欣赏VALVE的做法,它
通过使用一个宏就解决了这个问题,非常高效,使用起来也非常方便。这个做法很简单,它把每个
模块中需要对外暴露的接口都连接到一个内部维护的链表上,每一个接口都和一个接口名相连,这
样外部模块可以通过传入一个接口名给CreateInterface函数就可以获得这个接口的指针了,非常简
单。下面看看它的具体实现。它内部保存的链表结构如下:
class InterfaceReg
{
public:
InterfaceReg( InstantiateInterfaceFn fn , const char *pName );
public:
InstantiateInterfaceFn m_CreateFn;
const char *m_pName;
InterfaceReg *m_pNext;
static InterfaceReg *s_pInterfaceRegs;
};
并定义了两个函数指针和一个函数
#define CREATEINTERFACE_PROCNAME "CreateInterface"
typedef void *(CreateInterfaceFn)( const char *pName , int *pReturnCode );
typedef void *(InstantiateInterfaceFn)( void );
DLL_EXPORT void *CreateInterface( const char *pName , int *pReturnCode );
下面看看它如何通过宏来建立链表
#define EXPOSE_INTERFACE( className , interfaceName , versionName ) \
static void *__Create##className##_Interface() { return (interfaceName*) new className; } \
static InterfaceReg __g_Create##interfaceName##_Reg( __Create_##className##_Interface , versionName );
如果你有一个类CPlayer它想对外暴露接口IPlayer,那么很简单,可以这么做
#define PLAYER_VERSION_NAME "IPlayer001"
EXPOSE_INTERFACE( CPlayer , IPlayer , PALYER_VERSION_NAME );
如果在其他模块内你需要获得这个接口,可以这么做
CreateInterfaceFn factory = reinterpret_cast<CreateInterfaceFn> (GetProcAddress( hDLL , CREATEINTERFACE_PROCNAME ));
IPlayer player = factory( PLAYER_VERSION_NAME , 0 );
其中hDLL为模块的句柄。这里函数指针factory实际指向模块内部的CreateInterface函数,这个
函数通过比较传入的接口名从链表找到指定类指针。
解决了类厂问题,下面让我们看看如何建立模块对外的接口,在Game Programming Gems3的
《一个基于对象组合的游戏架构》一文提出了一种架构,Half Life2引擎中对这种架构进行了有效
的扩展,你可以让所有的对外暴露的接口都使用这个架构,前提是模块只有一个接口对外暴露。
class IAppSystem
{
public:
// Here's where the app systems get to learn about each other
virtual bool Connect( CreateInterfaceFn factory ) = 0;
virtual void Disconnect() = 0;
// Here's where systems can access other interfaces implemented by this object
// Returns NULL if it doesn't implement the requested interface
virtual void *QueryInterface( const char *pInterfaceName ) = 0;
// Init, shutdown
virtual InitReturnVal_t Init() = 0;
virtual void Shutdown() = 0;
};
通过Connect方法你可以将两个模块建立一个连接关系,通过QueryInterface方法你可以检索到其他
需要暴露接口,这种方法很好的为所有的模块建立一个标准的对外接口,极大的减轻了编程的复杂
性,遗憾的是在HL2引擎中只有部分模块使用了这个方法,可能是这个接口引入时间太晚的缘故。
(待续)
对于现代的游戏引擎来说,为了提高性能和有效的管理内存,需要使用各种各样
的内存分配模型,内存池作为一种有效的分配模型被大量的使用,它通过一次分配
足够的内存来减少对new delelte使用以提高引擎的性能,并且由于每一个内存块
都有相同的大小因此非常易于管理,并可以防止内存的泄露。它通常被用于需要
分配大量相同对象的场合,如粒子系统这样的地方。
对于在运行时可以明确知道分配数量的物体,可以通过一个静态数组来实现它,
但对于不知道分配数量的地方,设计就变的有些复杂,通常需要使用一个链表来
进行实现,如STL的list容器,不过使用它有一个很大的缺点,链表对表中的对象
进行查询操作时速度不是很理想,会极大影响它的性能。因此需要寻找一个比较
好的方法对其进行改进。
通常设计内存池有两个问题必须考虑,一个是内存分配的策略,由于你不是明确
知道待分配物体的数量,因此每次分配多大数量的内存是一个值得注意的问题。
另一个是如何对内存池进行管理,使用什么样的数据结构才能在常数时间内来获得
指定的内存。对于第一个问题解决的方案很多,你可以每次都分配一个指定数量
的内存块,也可以在每次分配时都分配比上一次多一倍的内存,哪种方案更好,需要
你自己在实际使用中体会。第二个问题是本文的核心,一般的做法是将已经分配的
内存块分成两个部分,已使用和未使用两个链表,但是这样做的性能并不理想,下面
看看如何对其进行改进:
我们先建立一个结构用于保存每次分配的一整块内存:
sturct MemChunk
{
MemChunk* m_pPre;
MemChunk* m_pNext;
unsigned int m_nSize;
char m_Data[1];
}
在这个结构中m_pPre,m_pNext用于建立一个双向链表将每一次分配的内存连接起来,
m_nSize表示当前内存块的大小,m_Data是所分配的内存指针,必须注意这是一个BYTE
指针。我们现在假设当前的内存池用于对CObject物体分配内存,每次分配都一次分配
64个CObject物体的内存,因此m_nSize的大小为64*sizeof(CObject)。下面看看如何
保存未使用的内存块,我们需要一个指针来指向当前未使用的内存块。
void* s_pCurrent;
然后令它指向当前还未使用的内存块。
s_pCurrent = pMemChunk->mData;
下面是本文最关键的部分,为了提高性能我们令每一个未使用的内存块的头部都保存
一个指针,让它指向下一个未使用的内存块,这样就为未使用的内存块形成了一个单
向链表。当你需要一个物体的内存时可以这么做:
void* returnPtr = s_pCurrent;
s_pCurrent = *((void **)s_pCurrent);
return returnPtr;
这样returnPtr就是你要获得的内存指针,而s_pCurrent通过一个简单的指针转换巧妙
的又指向了下一个内存块,如果上一句看不懂,请你重新复习一下C++教材中关于指针
的解释。
当你需要释放一个物体的内存时,方法和此类似。
*((void**)pMem = s_pCurrent;
s_pCurrent = pMem;
这样就可以将内存块重新连接到未使用的内存块链表中。通常对指针进行转换的时间
非常短,比一般的链表的插入、删除操作速度快的多,因此这个技巧是非常值得借鉴
的做法。
在我翻译的《BSP技术详解》一文中对radiosity技术介绍的非常简单,而且很多地方都没有解释清楚,下面我将
结合自己研究的结果对如何在BSP场景中进行radiosity做一下介绍,这里只介绍关键的步骤,至于详细的实现细
节请自己参考相关的代码。
通常如果需要在一个构建好的BSP场景中进行radiosity计算大概需要下面的几个步骤:
1、将场景中每一个face分解为patch;
2、对每一个patch构建一个sample数组;
3、获得每一个patch的基本光照值;
4、对patch进行supersample操作;
5、通过pvs数据获得patch之间的可见性关系,为每一个patch建立一个可见patch列表;
6、在所有的patch之间模拟能量传递的过程,进行radiosity运算。
下面我对每一个步骤分别进行介绍。
第一步,在进行radiosity运算时能量传递是在patch之间进行的,因此需要将每一个face分解为patch,为了建立
patch需要确定patch最大size,它由引擎定义的lightmap最大size来决定,在ID系列引擎这个值定义为64,因此
patch在任意轴上的size不能超过64,知道这个值就可以建立patch了,为了方便管理,首先为每一个face建立一个
初始的patch,然后检查每个face在轴向上的size,如果大于64就沿最长轴对patch分割,获得两个子patch,接着
对子patch重复上面的过程直到face上所有的子patch在任意轴上的size都不超过64为止。当获得patch后需要计算
每一个patch的normal,注意face上的所有patch不能都使用face的normal,应该使用一种phong normal的算法来
获得,为了使用这种算法,应该从face上最顶层的root patch开始进行计算,对于root使用face的normal,接下来
计算两个子patch,计算子patch的normal时就需要使用phong normal了,方法很简单,通过比较子patch中心点
坐标和parent patch中心点的坐标获得一个权值,然后用这个权值在patent patch的normal和子patch上的所有
顶点的normal进行插值获得patch的phong normal。
第二步,在lightmap中每一个pixel被称为luxel,计算lightmap实际上也就是获得每个luxel的颜色值,因此首先
需要为每一个patch建立一张lightmap,然后为lightmap中的每一个luxel建立sample结构。这个过程并不太复杂
,在ID的引擎中每一个顶点并不存在纹理坐标这样的东西,它为每一个face都建立纹理坐标系和lightmap坐标系,
如果需要获得顶点对应的纹理坐标和lightmap坐标,只需要将顶点坐标通过矩阵变换到纹理空间就可以获得了,
现在为了获得patch的sample结构,首先应该获得每个patch的lightmap坐标系,注意这样作的原因是因为每个
patch的normal是phong normal,而不等于face的normal。获得patch的lightmap坐标系后获得patch上的每个顶点
的lightmap坐标,好了获得lightmap坐标之后就可以知道patch上到底存在多少个luxel了,然后沿着lightmap的
坐标轴对patch进行分割,建立和luxel同等数量的sample结构。
第三步,当上面的结构都建立起来后需要获得每个patch获得直接光照的lightmap,首先需要获得场景中所有的
灯光,然后对每一盏灯光做以下处理:
1、获得灯光D所在的cluster A索引;
2、通过pvs获得A所有可见的cluster;
3、遍历所有可见cluster中的每一个face上的每一个sample,检查其是否可以被灯光照亮,如果可以使用标准
的光照公式获得luxel的颜色值。
第四步、为了使patch上的lightmap看起来更加自然,剔除lightmap上的光斑和硬边需要对其进行supersample
操作,首先对每一个sample的颜色和相临8个sample的颜色进行比较,如果大于一个阀值,说明它需要进行
supersample操作,否则不需要。对于需要进行supersample操作的sample,首先将其颜色清空,然后根据其所
受到的光照类型分别处理,对于受到的环境光源按照正常方式进行计算,因为环境光源不需要进行supersample
处理,对于其他非环境光源需要进行supersample处理,通常在进行光源计算时我们使用的是sample的中心点
坐标,但是在做supersample时不能这样做,我们需要使用组成sample的顶点坐标进行计算,在前面进行分割
时我们将patch分割为一个一个sample,sample其实就是一个非常小的矩形patch,它由四个顶点构成,现在
为了做supersample需要计算出四个顶点的normal,然后计算每个顶点受到光照的颜色值,使用它们的算术平均
值作为sample的颜色值。
第五步、好了现在需要进行radiosity前的最后一步,因为radiosity的过程是在场景中所有patch之间进行不断
的能量传递的过程,直到在所有patch上的能量获得平衡为止,由于patch的能量只能从其可见的patch中获得,
因此需要获得每一个patch的所有可见patch列表,通过使用pvs数据可以快速的获得可见patch列表,并通过列表
获得其形状因子。
第六步、现在可以对场景进行radiosity运算了,首先将场景中的所有灯光剔除,接着将每一个patch所获得的
初始光照值做为每个patch需要发射的能量,换句话说就是将每一个patch都看作是场景中的灯光,而patch的
初始光照值作为灯光的光照值,好了,对于每一个patch来说都需要进行以下的步骤:
1、遍历可见patch列表,使用标准的光照公式计算从可见patch上获得的光照值并保存起来。
2、第一步完成后,接着使用从可见patch上获得的光照值作为每个patch的需要发射的能量,重新进行第一步。
3、当有patch需要发射的能量小于一个预定义的阀值将这个patch推出radiosity运算。
从上面的介绍可以看出整个radiosity运算最耗费时间的是在最后一步,因此为了提高运算速度可以使用多线程
技术(注意超线程技术对它并没有帮助)和分布式运算技术来进行优化,同时也可以使用逐步求精的算法来对
运算进行加速。
在.bsp文件中用于可见性检测的信息被保存在一串串的字节中,而字节中每一位表示一个cluster,这是因为在BSP树中进行可见性检测的PVS信息数量非常大,而使用这个方法可以做到快速进行存取并使保存的信息量变的很小。下面是这个结构的一个实例,你可以通过两个方法来计算需要读入的字节数:用numOfClusters* bytesPerCluster;或用这个结构的长度减去两个整型字节。结构中的pBitsets是动态进行分配的并保存了所计算的字节数。这个结构或许是.bsp文件格式中最不容易理解的部分了,下面我将结合一些代码进行详细的介绍。
struct tBSPVisData
{
int numOfClusters; // clusters的数量
int bytesPerCluster; // Bytes (8 bits) in the cluster’s bitset
byte *pBitsets; // Array of bytes holding the cluster vis.
};
为了演示cluster是什么和怎么来使用它,我们来举一个简单的例子,当我们进行渲染时,首先我们需要知道自己处于哪一个叶节点中,在BSP树的叶节点中保存了一系列关于面、brush和它所位于的cluster的信息,一旦它通过用摄象机的位置与分割面进行比较被检测出来,我们需要遍历所有的叶节点来检查哪些cluster对于我们所处的cluster是可见的,检测完成后,用cluster的包围盒与摄象机的可视平截体进行检测,对在摄象机可视范围内的cluster进行渲染。
假如现在有cluster A、B、C,每一个cluster作为位集合中的一位保存在位集合中,一个位集合是一个巨大的二进制数列表,每一个cluster都保存了一个位列表,每一位上的1或0表示在列表上所代表的cluster是可见(1)还是不可见(0)。由于大部分情况下一个地图上有超过32个的cluster,因此不能使用一个32位的整型数来进行表示,这也是为什么在cluster使用多个bytes数来进行表示。接着让我们看一下它是如何工作的。
对于cluster A、B、C,下面显示了在一个位集合中如何对它们进行表示:
l ABC
l 000
上面每一个0表示了一个cluster的位置,现在让我们假设
1. clusterA可以看见B但不能看见C;
2. clusterB可以看见B和C;
3. clusterC可以看见B但不能看见A。
下面是每一个cluster所保存的位集合:
l A 110
l B 111
l C 011
这是什么意思呢,对于cluster A来说,第一位为1这表示它自己是可见的,而第二位为1表示cluster B对于它来说也是可见的,但是第三位为0则表示cluster C由于墙壁或其它物体的阻挡导致它对于cluster A是不可见的。采用这个方法可以获得非常好的速度。
去检测一个cluster相对于其它cluster是否可见,可以通过进行移位和简单的位运算来获得,基本的法则如下:
int visible = pBitsets[currentCluster*bytesPerCluster + ( testCluster/8 )] & (1 << (testCluster & 7))
如果返回的结果不为0,则表示testCluster对于currentCluster来说是可见的。公式中我们除以8是因为我们使用的是8位的字节来保存PVS数据的。在上式中的第一部分是通过索引cluster数组来获得正确的位集合,接着使用一个二进制与操作来获得正确的结果。下面是进行具体检测的代码:
inline int IsClusterVisible(tBSPVisData *pPVS, int current, int test)
{
if(!pPVS->pBitsets || current < 0) return 1;
byte visSet = pPVS->pBitsets[(current*pPVS->bytesPerCluster) + (test/8)];
int result = visSet & (1 << (test & 7));
return ( result );
}
当前对于流行的第一人称游戏来说,将室内场景渲染和室外场景渲染进行融合是主流的趋势,本文试图从
场景架构入手对当前流行的两个游戏引擎Far Cry和Half Life2进行一些非常浅显的介绍、分析、比较,
由于本人学识浅薄,本文中可能存在一些错误之处,请大家予以谅解,并请高手给予指正。
在过去由于硬件发展的限制因此第一人称游戏只能在室内场景进行,随着硬件技术的发展它逐渐由室内向
室外场景进行过渡,发展到现在已经有大量的游戏可以同时对室内和室外场景进行渲染,当前这类游戏的
代表就是Far Cry和Half Life2,它们完全使用了两种不同的方法来实现室内外场景的融合。
Far Cry:
Far Cry将场景分为室内和室外两部分,对于室内场景它使用了流行的BSP方法来对场景进行组织,BSP方法
在网上有大量的资源可以进行参考,这里我不在进行详细的阐述,在Half Life2部分我再进行一些简单的
介绍,这里我将重点介绍一下它室外场景部分所使用的技术。通常对于室外场景部分最重要的是地形部分
的渲染,当前对地形的渲染存在着大量的算法,其中比较主流的有ROAM、GeoMipmap、Chunk LOD等等,
Far Cry并没有使用上述的算法,为什么呢?对于ROAM来说它确实是一种非常良好的地形渲染解决方案,
但是它存在一个非常大的弊端就是它的灵敏度非常高,因为它是一种连续的基于三角形分割的LOD算法,
因此只要Camera的位置发生很小的位移,整个场景的LOD都需要发生变化,这非常不适应于当前的硬件架构
。而对于GeoMipmap这种利用当前显卡高渲染速度通过增加填充率来加速地形渲染的方法其实也不符合
当前游戏引擎的发展方向,正是由于这些方法的限制Far Cry使用了一种比较折中的地形渲染方法,这种
方法非常类似与Game Programming Gems2中Greg Snook提出的Interlocking Tiles方法,但是还存在着
一些相差别的地方,在Far Cry的关卡编辑器SandBox中我们可以看到通常它使用的是1025*1025大小的
HeightMap,每一个Tile的大小为(32+1)*(32+1),存在4级的LOD,因此一个Tile最简化时的大小为(4+1)
*(4+1),对于不同LOD级别Tile的连接使用了和Interlocking Tiles一样的方法,使用固定的连接模版进行
连接,这里两者差异最大的地方是对Tile内部三角形的生成方式,对于Interlocking Tiles方法它使用
的类似于以前CLOD的方法,分割后的形状象是一个“米”字的形状,而Far Cry的分割方式是对角分割线
的方向都是一致的,这样做的好处是非常容易生成Trigle Strip。好了现在我们看看使用这种方法有那些
优点。其一,由于每一个Tile只存在4级LOD因此避免了ROAM那种灵敏度过高的弊端。其二,由于地形是由
一个个Tile构成,因此非常适合使用Quad Tree这样的结构来进行管理。其三,由于每一级LOD的Tile顶点
顺序都是固定的,因此可以对地形顶点数据进行预先的优化,如提前就可以确定生成Trigle Strip的索引
缓冲区。其四、可以使用预先计算好的LOD查找表的方法来获得每一个Tile的LOD级别,而不需要通过实时
计算来获得。这种方法还有许多其他的优点需要实际使用时认真的体会我就不再一一列举了,Far Cry在
对地形进行渲染时还使用一种OC方法来对地形中被遮挡住的Tile进行剔除,相关的论文请参考A.James
Stewart发表于Eurographics Rendering Workshop的论文Hierarchical Visibility in Terrains,june
1997,我不再进行介绍。当Far Cry需要同时对室内外场景进行渲染时,它使用portal来对场景进行渲染,
如果从整体上来说的话,Far Cry的做法是对室内外场景的渲染分别进行处理,它们完全是两个部分,当
这两个部分需要发生联系时,通过portal来进行处理,这样Far Cry的场景架构就显得非常简单明了,至
于portal的工作过程我后面再进行介绍。下面来看看Half Life2的做法。
Half Life2:
在介绍Half Life2架构之前,我需要先解释一下surface的概念,因为Half Life2的场景都是由surface
所组成,它是场景中最基本的图元。surface是由共处于一个平面上多个顶点所组成的物体,例如一面
墙我们可以称它为surface,因为组成墙的所有顶点位于同一个平面内。通常在Half Life2中把surface
分为四类:水面、水面之上、水面之下、横跨水面。了解了surface的基本概念,下面看一下如何进行
场景处理。在Half Life2中场景被分为三部分:cluster,area,displacement。其中cluster对应于室内
场景的处理,area对应于类似于城市这样建筑物众多的室外场景,displacement对应于地形这样的室外
场景。必须注意的是在Half Life2中对所有的场景都使用BSP来进行场景管理,而不是象Far Cry那样
分别进行处理,它是如何实现的呢,下面我们根据不同的场景分别进行介绍。
对于cluster这样的室内场景,使用最基本的BSP方法来对场景进行管理,也就是基于多边形对齐的BSP
算法,什么是cluster呢?实际上一个房间就可以看作是一个cluster,一个cluster是有严格的定义的,
它必须保证组成cluster的多边形形成一个“凸”物体,也就是组成cluster的任意两个顶点之间的直线
不能位于cluster的外面,一个室内场景完全由一个一个的cluster所组成,cluster之间通过portal进行
连接,房间中的门、窗户都可以看作是一个protal,它是由处于同一个平面上的多个顶点组成的闭合的
多边形,通过使用portal可以对每一个cluster做光线跟踪处理,获得在每一个cluster上可见的cluster
列表,这就是PVS数据了,室内场景在实际运行时可以通过PVS数据快速的进行可见性检查,剔除不可见
的cluster来加速场景的渲染。室内场景中的portal通常是由程序自动生成的,相关的算法非常多,一篇
非常好的参考文章是位于GameDev.net上的由国人胡灵和李振霄所写的Automatic Portal Generation,
非常佩服,这是我在gamedev.net上看到的第一篇由中国人所写的技术文章。当获得了场景的cluster
信息后就可以将它和BSP的节点信息相连了,由于portal必然处于节点的分割面上,因此这一步非常简单。
下面看看如何处理城市这样的室外场景,这类场景由于拥有大量的建筑物并将场景分割成一块一块的区域
,因此也可以使用上面处理室内场景的方法来对它进行处理。这方面的论文也有很多,比较好的一篇是
由Alon Lerner等人写的Breaking the Walls: Scene Partitioning and Portal Creation。我这里只是
简单的介绍一下,这类场景可以看作是由一个个的area组成,area的概念非常类似cluster,一条街道,
一栋楼房都可以看作是一个area,这里必须提醒一下组成area的surface的法线必须指向其内部,cluster
也是这样,因此街道这样的area是由楼房的外表面所组成,而楼房这样的area是由其内表面所组成。每
一个area也是通过portal进行连接,对于这样的场景不能再使用预计算PVS的方法进行可见性检查,而
需要通过使用portal对场景进行实时的剪切剔除运算,必须注意这里对portal的记录方式,在室内场景中
的portal通常需要记录它所连接的两个cluster,而这里的area portal只记录一个与其相连的area,也就
是这里的portal都是单向的,这样做的原因是为了方便进行area的可见性检查,只要知道portal可见那么
和其相连的area一定可见。area和cluster存在一个非常大的差异地方是area不再必须是一个“凸”物体,
它可以是任意的由surface组成的一个空间,因此如果对这样的场景进行BSP分割后,一个area可能只包含
一个叶节点,也可能是包含多个叶节点的完整BSP子树。对于area进行处理时还有一种特殊的情况,就是area
和cluster发生连接时,通常这个时候cluster被包含在area内部,例如对于场景中的一栋建筑物可以看作是
一个area,通向外部的门、窗可以看作是此area的portal,这个建筑物的内部可以看作是一个完整的室内场
景,形成一个BSP子树,这样通过portal可以巧妙的将两种场景融合到一起。下面具体看看如何通过portal
来对area进行可见性检查,需要注意一点area没有AABB,而只有portal和叶节点才拥有AABB,方法如下:
1、由摄象机所位于的area出发递归遍历场景中所有的area portal,检查哪些portal是可见的(使用简单的
frustum culling),通过这些portal我们可以获得一个当前可见area列表。
2、对于所有可见的portal将其顶点投影到屏幕上,获得它在屏幕上的一个包围框。
3、对portal在屏幕上的包围框使用视口的包围框进行剪切,获得portal包围框在当前视口完全可见的部分。
4、通过使用这个包围框可以获得一个frustum,这样每一个可见的portal都可以对其相连的area所包含叶节
点的AABB进行frustum culling运算,从而最终获得一个当前可见的叶节点列表(当然对当前所在的area所
包含的叶节点也需要进行frustum culling)。
最后来看看HL2是如何来处理地形这样的大动态场景的。HL2处理地形使用的是一种非常特殊的displacement
系统,现在heightmap是记录地形信息最流行的做法,但是HL2并没有使用这种方法,他使用了一种称为
displacement map的物体来记录地形信息,为什么这样做呢?根据我的看法主要有以下的原因:首先使用
heightmap记录地形信息其实有一个非常大的弊端,它不能处理非常复杂的地形,用它生成的地形都是连续
的,这是因为在每一个位置它只能记录一个高度值,当同一个位置发生高度重叠时它无能为力。其次我前面
介绍过HL2场景完全是由surface所组成的,使用heightmap你无法将地形信息和surface相连,而通过使用
displacement map可以简单的将地形信息和surface连接起来。下面我们具体来看看它是如何工作的,通常
一个displacement map对应一块地形,它最大可以表示(16+1)*(16+1)这样的地形块,它非常类似heightmap,
不过heightmap只记录每一个位置上的高度值,而displacement map不仅记录高度值而且记录每一点的法线,
由于法线的存在它就可以记录非常复杂的地形,不过displacement map存在一个限制,它的四角的顶点必须
位于同一个平面上,正是这个限制的存在从而使displacement map和surface巧妙的联系在一起,因为surface
上的所有顶点必须位于同一平面上。每一个displacement map不仅记录高度信息还记录了邻边和邻角的
displacement map信息,通过这些信息可以对一片地形快速的建立一个Quad tree,通过它来对地形进行
有效的管理。每一个displacement map都拥有四级LOD,从(2+1)*(2+1)到(16+1)*(16+1)。下面来看看HL2
是如何将地形和BSP相联系的,对于一片地形我们可以把它看作是一个area,这样与其它场景可以通过portal
相连接起来,而每一个displacement map你可以把它看作一个BSP的叶节点,通过前面建立的Quad tree,
你可以快速的将它转换为BSP tree(其实这一步不是必须的,你只需要知道displacement map和叶节点的
连接关系即可),当进行地形的可见性检查时使用Quad tree进行,最终还是可以获得一个可见的叶节点
列表。
通过上面的介绍可以看出Far Cry和HL2的场景架构差异是非常大的,Far Cry的各个场景之间是完全独立的,
而HL2则巧妙的将其融合到BSP格式中,形成了架构的统一性。正是因为如此使HL2对付任意的场景都应付自如
,而Far Cry却只能限制游戏的场景形式,因此HL2的游戏架构明显优于Far Cry,成为了3D引擎发展的主流
趋势,不过Far Cry在一些场景细节上的处理确实非常优秀,如海岸边的潮起潮落,这是HL2需要明显改进的
地方,不过HL2在光源处理上确实是技术超群,全lightmap光照架构,将lightmap应用到极至的bumped lightmap
技术,只能是令人叹服,这里不再一一列举,我只能说HL2是当前商业引擎中的NO.1。
由于行文仓促,难免存在许多错误,尤其是HL2的地形部分由于我还没有完全研究透彻,因此避免不了会出现
错误,请研究HL2的高手给予指正,我这里表示感谢,如果你有什么关于HL2的技术问题希望和我进行交流,
我非常欢迎你发送邮件到我的邮箱dreams_wu@sina.com
终于将HL2的vbsp,vvis看完了,随便也翻了翻q3map代码,发现两者的代码太相似了,可以看出在BSP分割,portal的查找方面在Q2中都应该已经定型了,因此两者使用的算法都是一样的。下面我主要谈一下计算pvs的算法,因为在我翻译的那篇文章《bsp技术详解》中计算pvs是通过在每个portal上确定一些采样点然后进行ray cast来计算pvs的,这种方法的速度没有保证,而且处理起来也非常麻烦。而ID通过对每一个portal的polygen进行clip的方法来获取pvs,确实速度上要比上一个方法好上
很多。下面我来对算法进行详细的介绍,如果你对bsp不是很熟的话需要明确一下几个概念,一个是cluster,对于室内场景来说它完全是由cluster组成的,简单的你可以将它认为是场景中的一个房间,另一个是portal,场景中的cluster是由portal连接为一个整体,你可以把它看作是房间的门或窗户。在进行pvs计算的时候已经将整个场景划分为bsp tree,查找完portal并将cluster和leaf node连接起来了。
1、首先对所有的portal先进行一下预处理,让一个portal只和一个cluster发生联系,这样作的目的是为了获得一个单向的portal,也就是说portal只在一边可见,这样做是为了方便进行处理。我们知道一个portal通常连接了两个cluster,一般情况下使用的是portal所在plane正面的portal,但为了达到上述目的,我们需要将portal分为正反两个,由于在bsp中每一个plane都保存正反两个,因此位于反面的portal只需要对ploygen颠倒一下
顶点顺序即可。这里规定一个portal只和位于其plane的法线方向上的luster发生联系,预处理后保存的portal数量是原来的2倍。
2、接着我们需要对portal进行一下分类处理。通过简单的常识我们知道,由于现在每一个portal都是单向可见,因此只有位于portal可见方向(称为front方向)的portal才可能是可见的,我们需要对每一个portal都获得一个front portal列表并保存起来。
3、下面我们需要进一步的对每一个portal的front portal列表中不可见的portal进行剔除。我们知道场景完全是由一个个portal连接起来的cluste组成的,对于一个portal来说位于同一个cluster的portal一定可见,而其他portal要想可见最基本的要求是它可以通过其他portal连接到这个cluster上,因此通过portal的连接关系我们可以从front portal列表中剔除那些和当前portal没有连接关系的portal,并保存到floodportal列表中。
4、好了经过上面的处理我们已经剔除了大部分不可见的portal,可见的portal一定包含在flood portal列表中,因此需要使用更精确的方法进行检查。为了方便描述,我假定当前计算pvs的portal为A,任选和A所在的cluster ca相连的一个portal称为B,注意B一定是可见,因此B所在的cluster cb一定可见,但是和cb相连的其它portal并不一定可见,为了检查是否可见,我们假定选取其中的一个portal称为C。好了现在的问题简化为已知A和B求C是否可见,算法如下:
在A上选取一条边和B上的一个顶点构成一个clip plane,为了保证这是一个合法的clip plane我们需要做一下检查,为了简单化我们首先需要保证clip plane的法线方向必须指向portal A的外部,也就是说A上所有的顶点都位于clip plane的背面。
其次我们要保证portal B上所有的顶点都位于clip plane的正面,这样做可以保证当你选择A上最左边的一条边时必须要和B上最右边的一个顶点构成clip plane,当你选择A上最右边的一条边时必须要和B上最左边的一个顶点构成clip plane,将所有的clip plane合并起来实际上就获得一个A到B的最大可见frustum,只有位于frustum内部的portal才是可见的。当建立起这个frustum后我们就可以使用它对C的polygen进行clip操作了,当C clip后如果没有polygen在frustum内部那么它是不可见的,否则portal C可见并将可见的polygen保存下来。当C可见后我们需要接着对和C相连的portal进行检查,方法还是一样不过上面的portal B变成了C而且必须要注意,建立frustum使用C的polygen应该是clip后的polygen数据。通过上面的方法对A的flood portal列表进行递归运算最终将获得一个真正的可见portal集合保存到vis portal列表中。还需要指出一点的是建立clip plane的过程实际上需要两次,第一次是从A到B,第二次是从B到A,这样做的原因是并不是所有的极值点都位于B上的,也可能位于A上因此需要进行两次。
Q:怎么根据AB生成Frustum?
A:如果portal A和B都是一个四边形的话,frustum的四个面是这样组成的,A的左边和B的右边构成一个plane,A的右边和B的左边构成一个plane,上下方向也是,这样就形成一个frustum。但是当portal由多个顶点构成时,组成frustum的面也不会只有四个,这样形成的会是一个多面的frustum。
第三节 室内场景中光照运算
关于Radiosity的算法最早是由Goral、Cindy M、Torrance、Kenneth E、Greenberg、Donald P、Battaile和Bennett在论文《Modelling the interaction of light between diffuse surfaces》提出的。他们使用Radiosity来模拟能量在漫反射表面之间进行传送,漫反射表面对照到表面上的光线在所有的方向上都进行相同的反射,和它相反的是镜面反射表面,它只在反射方向上传播反射光。由于漫反射表面的这个特性,这就意味着对于所有的观察角度而言看起来表面都是相同的,这样对于场景中的每一个表面只需要进行一次光照运算,而且可以在场景的预渲染时进行,因此这项技术被大量的3D游戏所采用。
下面我再简短的讲解一下Radiosity是如何工作的,而将主要的精力放在如何使用BSP树来加速Radiosity的计算,对于Radiosity的详细介绍请参考前面的章节。Radiosity技术是设计用来使场景中光照看起来更加真实和光滑,如果我们使用一个一直向前传播而不考虑反射的光照模型,那么当场景中的灯光照亮场景中的物体时,并不会计算远处经过反射过来的光线,这样场景中的阴影看起来非常尖锐而物体表面也看起来非常不真实。为了使用radiosity技术我们需要把场景分割成一块一块很小的部分,每一部分我们称它为patch,每一个patch都有一个初始化的能量级别,如果它不是一个灯光这样的发光体的话通常为0,有许多方法来分配场景中的能量,这里我们将要使用的方法称为交互式radiosity。这个方法的过程是我们从场景中未发送能量的级别最高的patch开始发送能量,能量经过传递后将不再发送能量的patch的等级设为0,重复这个过程直到场景中的每一个patch的能量等级都小于一个预定值为止。
当能量从一个patch(j)开始发送到另一个patch(i)时我们使用下面的公式:
Bi = Bi + Bj * Fij * Ai / Aj
这里Bi = patch(i)的能量级别 Bj = patch(j)的能量级别
Ai= patch(i)的作用区域 Aj = patch(j)的作用区域
Fij = patch(i)与patch(j)之间的系数
在公式中系数Fij是由以下公式来确定的:
Fij = (cos qi * cos qj) / d2 * Hij
这里Fij = patch(i)与patch(j)之间的系数
qi = patch(i)与patch(j)法线之间的夹角
qj = patch(i)与patch(j)法线之间的夹角
d = patch(i)与patch(j)之间的距离
Hij = patch(i)与patch(j)之间的可见性系数。如果在两个patch之间只有一条光线可以跟踪,这个值为1,如果没有光线可以跟踪为0。一般情况下由于每一个patch都不是一个点而是一个区域,因此光线有很多条。
从上面的公式中我们可以看到在场景中进行radiosity计算是非常耗费时间的。这个函数的复杂度为O(n3),这里的n为场景中patch的数量。由于对于场景中每一个patch你需要发送最少一条光线到其它patch上,因此需要对场景中的几乎所有的多边形都进行光线跟踪计算。在上面的公式中系数H的计算非常耗费时间,下面我们将看一下如何在BSP树中对它的计算进行优化。
BSP树中的radiosity计算
在进行场景中的光照计算之前需要把场景中的面分割为patch,一个方法是在开始的时候设定每一个patch为预定的大小,当计算每一个patch的能量时,如果在patch上的能量足够大,对这个patch进行分割。不过这个方法是非常耗费时间的,因此必须寻找一个更好的方法来通过BSP树对计算进行优化。
在radiosity的一般算法中场景中的每一个光源都被看作为一个或多个patch,这里我们可以改进一下,将每一个光源放在它所位于的叶节点中,接下来每一个光源都发送自己的能量到场景中所有的patch上,当这个过程完成后radiosity计算也就结束了。为了使最后的结果看起来更好可以使用一种称为“渐进精选”(progressive refinement)的技术来对这个方法进行很小的修改。在每一次过程中,叶节点中具有高能量的patch将发送能量到其它低能量的patch上,这样做的结果是高亮度的patch将发送能量到处于阴影中patch上。这是因为在实际生活中并没有真正黑暗的地方,它多多少少要获得一些其它物体反射过来的光亮。
由于计算非常耗费时间需要做一下优化,使用渲染BSP树时获得的PVS信息可以在选择哪些patch将接受能量时剔除一些无用的计算。因为在计算PVS时使用了相同的方法来进行光线跟踪。
通过场景来分配能量的算法如下:
l 函数RADIOSITY
l 参数:
l Tree – 进行radiosity计算的BSP树。
l 返回值:
l None
l 功能:
l 在场景中的patch之间发送能量。
RADIOSITY (Tree)
1 for(each leaf L in Tree)
2 for(each light S in L)
3 for(each leaf V that is in L’s PVS)
4 Send S’s energy to the patches in V
l 下面语句5是为了让地图编辑者在任何时候都可以检查场景渲染的效果,如果他感到看起来已经足够好了可以中断能量的传播。
5 while(not looks good enough)
6 for(each leaf L in Tree)
7 for(each leaf V that is in L’s PVS)
8 Send energy from the patch with the most unsent energy in L
to all patches in V.
复杂度分析
这个函数的运算费用实在是太高昂了,可以称为时间杀手,在最坏的情况下每一条光线将不得不检测场景中的每一个多边形,此时复杂度为O(n3),这里n为树中patch的数量。一般情况下由于进行了优化可以减少大量的计算,但是减少多少并不能计算出来,因为这依赖于树结构的复杂度。
上面的函数给出了一个充分利用BSP树的优点来加速场景光照运算的方法,尤其是可以显著的减少光线跟踪的计算量,而且地图设计者可以来决定当场景渲染时如果渲染的效果可以接受中断渲染循环。这对地图的预渲染实在是太方便了,运行的时间可以根据渲染的效果来决定。
第四节 BSP树的预渲染
现在我们需要完成一个完整BSP引擎的预处理过程,下面的算法显示如何将场景渲染到BSP树中。
l 函数RENDER-SCENE
l 参数:
l Scene – 被渲染的场景
l 返回值:
l 一个BSP树。
l 功能
l 预渲染来获得一个包含场景信息的BSP树。
RENDER-SCENE (Scene)
l 使用描述场景中图元的物体来渲染BSP树。
1 GeometryPolygons = {}
2 for (每一个包含场景图元的物体object O)
3 GeometryPolygons = GeometryPolygons U O.PolygonSet
4 GENERATE-BSP-TREE (Tree.RootNode, GeometryPolygons)
l 分配叶节点上的取样点。
5 DISTRIBUTE-SAMPLE-POINTS (Tree.RootNode, {})
6 TRACE-VISIBILITY (Tree)
7 for 每一个场景中的静态物体object O
8 for 物体O中每一个多边形P
9 PUSH-POLYGON (Node, P)
l 函数CREATE-PATCHES是一个未定义的函数,由于我们的解决方案效率并不是太好,因此没有对它进行详细的介绍。
10 CREATE-PATCHES (Tree)
11 RADIOSITY (Tree)
复杂度分析
函数的复杂度如下:
函数 最坏情况 一般情况 描述
GENERATE-BSP-TREE O(n2 lg n) O(n2) n为场景中多边形的数量
DISTRIBUTE-SAMPLE-POINTS Q (np + xy) Q (np + xy) n为树中多边形的数量,p为树中典型点的数量,x和y为分割面的宽度和高度。
TRACE-VISIBILITY O(n2) O(n lg n), n为树中多边形的数量。
RADIOSITY O(n3) O(n2 lg n) n为树中patch的数量
在一般情况这一列中显示了算法通常所需运行的时间,对算法时间影响最大的是函数RADIOSITY,它使整个算法的复杂度趋向于O(n3)。
第二节 隐藏面剔除
对不可见物体进行剔除是游戏行业为了满足提高画面渲染速度的要求而产生的一项技术,就是在硬件加速技术飞跃发展的今天,虽然现在已经可以完成许多在过去被认为是不可能实现的工作,但是对于隐藏面进行剔除仍是加速图形渲染的一项重要技术。通常当一个游戏运行的时候,它最少需要以每秒30帧的速度运行。在几年前这意味着如果每一帧你渲染的带纹理的多边形数量超过5000个就被认为是不可接受的,而现在几乎所有的商业显卡每一秒都可以渲染几千万个多边形。可是现在仍然需要使用隐藏面剔除这项技术,这是为什么呢?显而易见,对不可见物体渲染以后将会被可见物体遮挡住,这样做无谓的浪费了显卡的带宽,但是同时它也增加了场景的细节,使游戏画面看起来更加吸引人。现在的问题是多大程度上来剔除隐藏的多边形,象view frustum culling和portal渲染这样的技术来剔除一个不可见多边形是非常耗费时间的,用来去做这些计算的CPU时间可以用来完成其它诸如AI或碰撞检测这样的工作,因此开发一个隐藏面剔除算法必须注意到这一点。对于现在的游戏来说几乎没有一个是将每一个隐藏的多边形都进行剔除,而是剔除一个多边形的集合如一个节点或一个物体等等。对于一个单独的多边形它并不进行剔除,因此一个正确的隐藏面剔除方案是允许一定的重复渲染来适当的减少计算量。
当建立一个FPS游戏时进行隐藏面剔除最通常的方法是使用portal渲染。这项技术可以非常充分的利用BSP的优点,但是请注意portal技术并不仅仅只能用于BSP中。Portal技术还可以用来产生一些特效如镜子和监视器等等。
Portal渲染
在这里我将介绍一下portal技术的原理,通常对于一个室内场景来说它可以被描述为由一个个“洞口”相互连接的“房间”组成,这里“洞口”被称为portal而“房间”被称为sector,通常sector被定义为一个“凸”的“闭合”的多边形集合,定义中的“凸”阅读过前面的内容你应当已经能很好的理解了,而“闭合”是什么意思呢?它意味着在sector内部任意连接两个顶点做一条线段,这条线段不会和其它的多边形相交。换句话说如果你想在sector内部任意画一条线段通到sector的外部必定与组成sector的多边形相交。这也意味着连接每一个sector的“洞口”必须被一个组成portal的多边形所填充,而对于放置portal多边形来说你既可以手工放置也可以由程序自动产生,在我讲解这项技术之前我必须提醒一下,由于硬件加速Z缓冲的出现对sector必须为“凸”的限制已经消除,因此有许多游戏引擎已经不再遵守这个标准,但是在这里我还是要对过去的方法进行一下介绍。
一个portal引擎的基本方法是当你通过一个指定观察位置的可视平截体(view frustum)进行渲染时,如果一个portal出现在可视范围内,那么portal将对可视平截体进行剪切,这样与其相连的sector将会通过一个观察位置相同但已经改变过的可视平截体进行渲染。这是一个非常简单而且非常适合进行递归调用的方法,由于可视平截体被portal进行了精确的限制,因此被隐藏的物体可以很简单进行剔除。下面的例图显示了一个portal引擎中的可视平截体是如何被剪切的。

图6.10
在图6.10中观察者的位置位于V,而初始的可视平截体为F1,当它通过一个portal多边形P1后被P1剪切产生新的可视平截体F2,接着当它通过portal多边形P2、P3后F2被剪切为F3、F4,而F3通过P4后被剪切为F5而F4被剪切为F6。观察这个过程我们可以发现portal技术非常适合进行递归调用。
接着需要考虑的是如何对物体进行拣选剔除,通常对于所有的3D引擎来说都需要通过一系列步骤来加速这个处理过程,回忆一下前面讲解的内容,这个过程首先要计算出物体的“最大包围球”或是“最大包围盒”,它是包含了物体所有顶点的最小包围体,接着用包围体来和“可视平截体”每一个剪切面进行碰撞检测,如果包围体位于每一个剪切面的“反面”那么物体将不会进行渲染,下图显示了这个过程:

图6.11
图中物体1位于右剪切面的“正面”但位于左剪切面的“反面”因此它将不会被渲染,而物体2不仅位于左剪切面的“正面”而且有一部分位于右剪切面的“正面”因此将会被渲染出来。
Portal技术最初的想法是通过剪切多边形来保证只有物体可见的部分被渲染出来,也就是无效渲染的多边形数量为0。但是现在这种想法被认为不是很理想,因为它无谓的浪费了处理时间。但是由于一个多边形在递归循环过程中将被遍历多次因此我们需要知道在渲染场景时它是否已经被渲染过,一个较好的方法是使用一个帧数来标识这个多边形,这样可以很容易的来描述这个多边形在上一帧是否被渲染过。再看一下图6.10中最右边的墙,它同时通过F5和F6来进行渲染,通过对它进行标识我们可以知道它是否被渲染过,否则就会产生Z缓冲错误。
为了便于在portal引擎中进行渲染我们需要对“可视平截体”进行一下定义,一个“可视平截体”是一个保存了多个剪切面的结构,每一个剪切面的法线都将指向“可视平截体”的内部,因此在它内部将产生一个闭合的锥体。下面的算法显示了如何计算一个多边形是否包含在“可视平截体”的内部。在这个算法中我们使用了一个函数CLASSIFY-POINT,它使用一个剪切面和一个点作为输入参数。
l 函数INSIDE-FRUSTUM
l 参数:
l Frustum – 用于检测多边形是否位于其中的“可视平截体”。
l Polygon – 用于检测的多边形。
l 返回值:
l 多边形是否位于“可视平截体”的内部。
l 功能:
l 使用“可视平截体”的每一个剪切面来对多边形的每一个顶点进行检测,如果所有的顶点都位于所有剪切面的“正面”,那么多边形处于“可视平截体”的内部。
INSIDE-FRUSTUM (Frustum, Polygon)
1 for each point Pt in Polygon
2 Inside = true
3 for each plane Pl in Frustum
4 if (CLASSIFY-POINT (Pl, Pt) <> INFRONT)
5 Inside f false
6 if (Inside)
7 return true
8 return false
在一个portal引擎中它的主渲染函数可以简单的用下面的方法来实现。
函数RENDER-PORTAL-ENGINE
参数:
Sector – 观察者所处的sector。
ViewFrustum – 当前的“可视平截体”。
返回值:
None
功能:
渲染portal引擎中的多边形,场景被描述为由多个portal连接的sectors所组成。
RENDER-PORTAL-ENGINE (Sector, ViewFrustum)
1 for each polygon P1 in Sector
2 if (P1是一个portal and INSIDE-FRUSTUM (ViewFrustum, P1))
3 NewFrustum = CLIP-FRUSTUM (ViewFrustum, P1)
4 NewSector = 获得由当前sector通过portal P1相连接的sector
5 RENDER-PORTAL-ENGINE (NewSector, NewFrustum)
6 else if (P1任然没有被渲染)
7 draw P1
8 return
如何放置portal
正如我前面提到的在一个portal引擎中最大的问题就是如何放置portal,如果手工来放置它的话非常花费时间,同时要求地图的设计者有熟练的技巧。因此一个良好的自动放置portal的算法非常有必要,在这里我将要介绍一下关于这方面的几个解决方案,这些方案都使用了BSP。
我介绍的第一个解决方案是由瑞典DICE公司的Andreas Brinck提出的,它的原理非常简单,观察一下一个完整的BSP层次树,可以发现这样一个现象,对于每一个portal来说它必定与BSP层次树中由分割多边形定义的分割面位置相同,因此在相同的位置上我们可以在分割面之外建立一个portal多边形,portal多边形被初始化为一个矩形,这个矩形的大小将超过portal所处的BSP节点的“最大包围盒”的大小,接着将portal多边形放入包含它的节点所位于的子树中,当节点不是叶节点时,那么portal将继续被传送到节点的子树中,这样子节点中的分割面将对它进行分割,而当包含它的节点为叶节点时,它也会被节点中的多边形进行剪切,因为portal初始化的大小超过了节点的范围。当portal被分割后,被分割的两个部分会继续传送到最顶层的节点重复这个过程,而当portal不需要进行分割时,根据它所处于分割面的位置来放置到相应的子节点中,如果位于分割面的“正面”它将被放置在右子树中,而当它位于分割面的“反面”将被放置于左子树中。如果portal正好位于分割面之上它将同时放在左右子树中。
为了方便方便将所有的portal放置到BSP中我们需要定义如何对一个多边形进行分割,为了方便使用我们假设完成这个功能的函数为INTERSECTION-POINT,它将返回一个面与一个线段的交点。
l 函数CLIP-POLYGON
l 参数:
l Clipper – 去分割其它多边形的多边形或面。
l Polygon – 被分割的多边形。
l 返回值:
l 被分割后的两个部分。
l 功能:
l 由指定的分割面来分割多边形。如果多边形没有被分割将会返回一个空的多边形。
CLIP-POLYGON (Clipper, Polygon)
1 RightPart = {}
2 LeftPart = {}
3 for each point edge E in Polygon
4 Side1 = CLASSIFY-POINT (Clipper, E.Point1)
5 Side2 = CLASSIFY-POINT (Clipper, E.Point2)
6 if (Side1 <> Side2 and
Side1 <> COINCIDING and
Side2 <> COINCIDING)
7 Ip = INTERSECTION-POINT (Clipper, E)
8 if (Side1 = INFRONT)
9 RightPart = RightPart U E.Point1
10 RightPart = RightPart U Ip
11 LeftPart = LeftPart U Ip
12 LeftPart = LeftPart U E.Point2
13 if (Side1 = BEHIND)
14 LeftPart = LeftPart U E.Point1
15 LeftPart = LeftPart U Ip
16 RightPart = RightPart U Ip
17 RightPart = RightPart U E.Point2
18 else
19 if (Side1 = INFRONT or Side2 = INFRONT or
Side1 = COINCIDING and Side2 = COINCIDING)
20 RightPart = RightPart U E.Point1
21 RightPart = RightPart U E.Point2
22 if (Side1 = BEHIND or Side2 = BEHIND)
23 LeftPart = LeftPart U E.Point1
24 LeftPart = LeftPart U E.Point2
25 return (RightPart, LeftPart)
现在我们可以定义如何在一个BSP层次树中对portal进行分配了,在算法中portal被初始化为一个大于BSP根节点“最大包围盒”的多边形。
l 函数PLACE-PORTALS
l 参数:
l PortalPolygon – 放置到BSP中的多边形。
l Node – 我们当前遍历的节点。
l 返回值:
l None
l 功能:
l 放置一个portal多边形到BSP层次树中,如果需要的话对它进行剪切。这个函数将会产生一个节点将由portal连接而每一个节点将包含一个portal多边形列表的BSP层次树。
PLACE-PORTALS (PortalPolygon, Node)
1 if (IS-LEAF (Node))
l 将portal和节点中的每一个多边形进行检测。当portal所包含的多边形和由一个多边形定义的面相交时它将被这个面进行分割,分割后的两个部分将被重新传送到最顶层的节点中继续进行检测。
2 for (each polygon P2 in Node)
3 IsClipped = false
4 if (CALCULATE-SIDE (P2, PortalPolygon) = SPANNING)
5 IsClipped = true
6 (RightPart, LeftPart) = CLIP-POLYGON (P2, PortalPolygon)
7 PLACE-PORTALS (RightPart, RootNode)
8 PLACE-PORTALS (LeftPart, RootNode)
9 if (not IsClipped)
10 从当前节点中将portal的多边形剔除,因为它的位置和节点中一个多边形的位置相同。
l 参考下面的描述。
11 添加当前节点到portal多边形所连接的节点集合中。
12 else
13 if (当前节点的分割多边形没有放置在树中)
14 建立一个多边形P,它的大小将超过包含当前节点所有多边形的最大包围盒的范围,并且和分割多边形的位置相同。
15 PLACE-PORTALS (P, Node.LeftChild)
16 PLACE-PORTALS (P, Node.RightChild)
17 Side = CALCULATE-SIDE (Node.Divider, PortalPolygon)
18 if (Side = POSITIVE)
19 (RightPart, LeftPart) = CLIP-POLYGON(P2, PortalPolygon)
20 PLACE-PORTALS (RightPart, RootNode)
21 PLACE-PORTALS (LeftPart, RootNode)
22 if (Side = POSITIVE or COINCIDING)
23 PLACE-PORTALS (PortalPolygon, Node.RightChild)
24 if (Side = NEGATIVE or COINCIDING)
25 PLACE-PORTALS (PortalPolygon, Node.LeftChild)
下面我将对算法中的第10行进行一下解释,当剔除和当前节点中一个多边形位置相同的portal多边形部分将会产生什么样的结果。参考一下图6.12。

图6.12
在图6.12中一个portal已经到达了一个叶节点,图中灰色的区域是是portal多边形在遍历BSP树过程中被剔除的区域称它为1。而图中标注为2、3、4的亮灰色部分是和portal多边形位置相重叠的多边形,因此这一部分的portal多边形也将会被剔除,而剩下的部分5将被用来做为一个portal。
在上面的算法第一次看的话会觉的非常复杂,但实际上它非常简单和容易理解,最终的结果就是每一个portal将会在两个节点上结束而对于任一个portal来说它必定可以和一个portal之间相互可见。在下面我将用一个实际的例子来解释一下这个算法。

图6.13
对于图6.13中的结构,需要进行下面的几步:
第一步,portal多边形s1进入节点n1中。

在节点n1中portal多边形s1将会被分割,因为它的一部分和节点中间的一个多边形位置相同,因此portal多边形s1将会被分割为两部分分别为p1、p2,重叠的部分被剔除。
第二步,p1、p2进入节点s2中。
在节点s2中由于p1位于分割面s2的“正面”,因此将和分割面s2一起被送入到节点n2中,而p2由于位于分割面s2的“反面”,因此将和分割面s2一起被送入到节点s3中。由于p1、p2没有和分割面相交因此这一步没有出现分割操作。
第三步,p1、s2进入节点n2中。

在节点n2中n2被认可作为一个portal,因此在节点n1和n2中它不会再发生任何变化。而多边形p3的一部分将会被剔除,因为它的一部分和节点中间的一个多边形位置相同,在上一步中多边形s2也被送入到节点s3中,而在这里它又被称为p3。
第四步,p3和s3进入节点n3中。

在节点n3中多边形p3被认可为一个portal,而多边形s3的一部分因为和上面的原因一样被剔除,同时在这里它又被称为p4。
第五步,p2和p4进入节点s4中。
没有任何多边形要进行分割,因此多边形p2和p4将与s4一起被送入节点n4中,而s4将被单独送入节点n5中。
第六步,p2、p4和s4一起进入节点n4中。

无论是p2还是p4都不需要分割,但是由于s4和中间的一个多边形完全重叠因此将会被剔除。
第七步,结束,没有任何多边形进入节点n5中。
这个节点将没有portal,因为对于任何节点来说它都是不可见的,而从这个节点位置上将不会看见任何一个节点。
结果:
portal p1同时位于节点n1和n2中;
portal p2同时位于节点n1和n4中;
portal p1同时位于节点n2和n3中;
portal p1同时位于节点n3和n4中。
上面我所讲解的内容是建立一个简单的portal引擎所必备的功能,它能给我们提供一个较高的运行帧速。
PVS
一个portal引擎虽然能够提供许多非常好的特性,但是它的结构太复杂。当你使用portal技术来构建一个游戏引擎时你会发现它存在许多问题,最大的一个问题是在渲染场景的每一帧都需要进行可视性检测,这会产生大量的多边形剪切操作,在场景非常复杂的情况下,运算的费用会非常的高,因此需要寻找一种技术来对场景中可视性检测进行预计算而不是在运行期间进行计算。PVS(Potentially Visible Set)可视性集合,就是为了解决这个问题而出现的一项技术,可以通过对BSP中每一个叶节点设置一个PVS,这个PVS保存了从第一个叶节点开始看到的叶节点集合,它不仅可以用来帮助加速场景渲染,还可以用来加速场景中光照运算和进行网络优化。
PVS是在场景进行预渲染时计算出来的,每一个BSP的叶节点都保存一个可视节点的集合,当对场景进行渲染时,摄象机所在的叶节点将被渲染,同时保持在PVS中的叶节点也将会被渲染出来,这里需要一些算法来避免场景重复渲染,由于今天硬件加速卡的发展,它所提供的硬件Z缓冲的大小已经可以方便的解决这个问题。
计算PVS
如果要求PVS必须在BSP的叶节点之间进行标准的光线跟踪计算,来查找一下在一个叶节点中任一个点是否在其它叶节点中可见。为了加速光线跟踪的计算,在每一个叶节点中必须指出一些典型的位置点来避免无谓的计算,现在的问题就是如何放置这些典型的点。
对于一个portal引擎的portal来说,典型点可以沿着树的分割面放置,这是因为只有在两个portal是相互开放的情况下才可以进行可视化检测。如果位于一个叶节点中间的一个点被从另一个叶节点发射出来的光线认为是可见的话,那么这条光线必然通过连接两个节点的portal,参考下图:

图6.14
在图6.14中我们可以看见如果在一个节点中的一个点可以从其它节点看见,那么视线必然通过两个节点相互连通的区域。这是非常明显的,如果视线被物体阻断的话那么两点之间必定不可见。因此将典型点放置在两个节点之间所开放的区域是非常合适的,下面描述的算法将在一个BSP树上放置典型点。对于这个函数需要一组帮助函数来把点放置在一个节点上,它们是
l DISTRIBUTE-POINTS (Node) 这个函数将按照一定的间隔沿着给定节点的分割面来放置点,点的位置会处于节点包围盒的内部。它将返回一个点的集合,复杂度为O(xy),这里x为节点包围盒内分割面的宽度,而y为高度。
l CLEANUP-POINTS (Node, PointSet) 从点的集合中剔除不合格的点,这些点可能和节点中的一个多边形位置相同,也可能是位于节点包围盒的外部。函数的复杂度为O(np),这里n为节点中多边形的数量而p为集合中点的数量。
l 函数 DISTRIBUTE-SAMPLE-POINTS
l 参数:
l Node – 当前我们遍历的节点。
l PointSet – 放置到给定节点子树中的点的集合。
l 返回值:
l None
l 功能:
l 沿着给定节点的分割面放置点。它将按照分割面的位置来对输入的点进行检测,如果这些点和节点中的一个多边形位置相同,或者位于节点包围盒的外部那么将会被剔除。新产生的点将会被添加到左、右两个集合中,当一个点的集合遍历到一个叶节点时那么它就是这个叶节点的典型点。
DISTRIBUTE-SAMPLE-POINTS (Node, PointSet)
1 CLEANUP-POINTS (Node, PointSet)
2 if (IS-LEAF (Node))
3 设置点的集合为当前节点的典型点。
4 else
5 RightPart = NewPoints
6 NewPoints = DISTRIBUTE-POINTS (Node)
7 RightPart = NewPoints
8 LeftPart = NewPoints
9 for each point P in PointSet
10 Side = CLASSIFY-POINT (Node.Divider, P)
11 if (Side = COINCIDING)
12 RightPart = RightPart U P
13 LeftPart = LeftPart U P
14 if (Side = INFRONT)
15 RightPart = RightPart U P
16 if (Side = BEHIND)
17 LeftPart = LeftPart U P
18 DISTRIBUTE-SAMPLE-POINTS (Node.LeftChild, LeftPart)
19 DISTRIBUTE-SAMPLE-POINTS (Node.RightChild, RightPart)
算法分析
每一次调用这个函数的复杂度为O(np + xy)(参考函数CLEANUP-POINTS和
DISTRIBUTE-POINTS),为了计算完整的复杂度我们可以用下面的公式来表示(我们假设典型点的集合同时分布在两个集合之中):
T(n) = 2T(n/2) + O(np + xy)
这个函数第一次调用时将会从BSP树的根节点开始并会传入一个空的集合。换句话说它是按照下面的方法进行的,它是从被BSP树根节点所定义的分割面上的点开始的,由于一个面的大小没有限制因此也会有无限个典型点,因此就需要对典型点进行限制,这个现在就是根节点的包围盒。
在根节点上获得合格的典型点后需要把所有的点发送到根节点的两个子树中,当一个典型点的集合进入一个节点后会被分割为两个部分,一个部分包含了所有位于节点分割面的“正面”的点,而另一部分包含了所有位于节点分割面的“反面”的点。而位于分割面上的点将会同时放在两个集合中。接着“正面”集合将会被传入到右子树中而“反面”集合将会被传入到左子树中。重复这个过程直到进行到叶节点时结束,在这些操作后每一个叶节点将包含一个典型点的集合,这些点都位于节点所连通的地方。
如果我们现在在这个阶段对每一个节点进行光线跟踪那么是非常耗费时间的,但是如果我们知道叶节点是与哪一个节点相连的,那么这个过程将会变的简单,因为这样可以减少一些不必要的光线跟踪运算。查找相互连接的叶节点非常简单,可以通过检测每一个叶节点的典型点来查找,如果同时有两个节点共享一个点那么这两个节点必然是相互连通的,因为在遍历BSP树放置典型点的过程中,点不是没有放置到节点上就是同时放置在两个节点上。当我们知道哪些节点是相互连通的,就可以定义进行光线跟踪的算法了,不过首先我们需要定义一些帮助函数。
为了方便进行光线跟踪我们需要一些基本的光线跟踪函数,BSP树是非常适合进行光线跟踪的结构,因为大部分不可见的区域通过BSP树可以很简单的剔除掉,不用进行遍历,因此花费的时间也非常少。我们需要的函数如下:
POLYGON-IS-HIT (Polygon, Ray) 返回光线是否和多边形相交。
RAY-INTERSECTS-SOMETHING-IN-TREE (Node, Ray) 返回光线是否和节点中的子树相交。
INTERSECTS-SPHERE (Sphere, Ray) 返回光线是否和球体相交。
CREATE-RAY (Point1, Point2) 通过两个点来建立一条光线。
上面的函数RAY-INTERSECTS-SOMETHING-IN-TREE是一个非常有趣的函数,因为它显示了一些BSP树的优点,同时也显示了如何使用BSP树来加速光线跟踪的处理。它是一个递归函数,首先在树的根节点上使用,伪算法如下:
l 函数RAY-INTERSECTS-SOMETHING-IN-TREE
l 参数:
l Node – 用来进行跟踪的节点。
l Ray – 用来进行求交的光线。
l 返回值:
l 光线是否和节点中的物体相交。
l 功能:
l 检测光线是否与给定节点极其子树中的物体相交。
RAY-INTERSECTS-SOMETHING-IN-TREE (Node, Ray)
1 for each polygon P in Node
2 POLYGON-IS-HIT (P, Ray)
3 startSide = CLASSIFY-POINT (Ray.StartPoint, Node.Divider)
4 endSide = CLASSIFY-POINT (Node.EndPoint, Node.Divider)
l 如果光线和节点的分割面相交或和分割面的位置重叠,对节点的两个子节点进行检测。
5 if ((startSide = COINCIDING and endSide = COINCIDING) or
startSide <> endSide and startSide <> COINCIDING and
endSide <> COINCIDING)
6 if (RAY-INTERSECTS-SOMETHING-IN-TREE (Node.LeftChild, Ray))
7 return true
8 if (RAY-INTERSECTS-SOMETHING-IN-TREE (Node.RightChild, Ray))
9 return true
l 如果光线只位于分割面的“正面”对节点的右子树进行检测。在if语句中使用or操作符是因为光线的一个端点可能与分割面的位置重叠。
10 if (startSide = INFRONT or endSide = INFRONT)
11 if(RAY-INTERSECTS-SOMETHING-IN-TREE (Node.RightChild, Ray))
12 return true
l 如果光线只位于分割面的“反面”对节点的左子树进行检测。在if语句中使用or操作符是因为光线的一个端点可能与分割面的位置重叠。
13 if (startSide = BEHIND or endSide = BEHIND)
14 if (RAY-INTERSECTS-SOMETHING-IN-TREE (Node.LeftChild, Ray))
15 return true
l 光线没有和任何物体相交,返回到上一层。
16 return false
算法分析
最坏的情况是光线遍历了BSP树中每一个节点,这样光线将与节点中每一个单独的多边形进行检测,这时函数的复杂度将为O(n),这里n为BSP树中所有多边形的数量。一般情况下光线并不会检测树中的每一个节点,这样将会大大减少进行检测的多边形数量。最佳的情况是光线被限制在一个节点中,这时函数的复杂度接近于O(lg n).这取决于BSP树的结构大小。
l 函数CHECK-VISIBILITY
l 参数:
l Node1 – 开始处的节点。
l Node2 – 结束处的节点。
l 返回值:
l Node2是否可以被node1看见。
l 功能:
l 在两个叶节点的典型点之间进行跟踪,检测两个节点之间是否可见。
CHECK-VISIBILITY (Node1, Node2)
1 Visible = false
2 for each 典型点P1 in Node1
3 for each 典型点P2 in Node2
4 Ray = CREATE-RAY (P1, P2)
5 if(not RAY-INTERSECTS-SOMETHING-IN-TREE(Node1.Tree.RootNode,
Ray)
6 Visible = true
7 return Visible
算法分析
函数CHECK-VISIBILITY非常耗费时间,当我们在两个叶节点之间进行光线跟踪来检测两者之间是否可见时,我们不得不从Node1的每一个典型点开始对Node2的每一个典型点进行跟踪,最坏的情况是每一次跟踪都将对树中所有的多边形进行检测,这时函数的复杂度O(s1 s2 p),这里s1为Node1中典型点的数量,s2为Node2中典型点的数量,p为树中多边形的数量。通常它的性能是比较好的,接近于O(s1 s2 lg p)。
l 函数TRACE-VISIBILITY
l 参数:
l Tree – 去进行光线跟踪的BSP-tree。
l 返回值:
l None
l 功能:
l 对于树中每一个叶节点,它将进行光线跟踪运算来检测和它相连的节点的可见性。每一个被发现是可见的节点将会被添加到当前节点的PVS中。当一个叶节点发现是可见的,我们还要对和它相连的节点进行光线跟踪运算来检测可见性。
TRACE-VISIBILITY (Tree)
1 for (each 树中的叶节点L)
2 for (each 和叶节点L相连的叶节点C)
3 添加节点C到节点L的PVS中
4 for (each 树中的叶节点L1)
5 while (在叶节点L的PVS中存在一个叶节点L2,它所连接的节点没有进行可见性检测)
5 for (each 连接到节点L2中的叶节点C)
6 if (节点C没有位于节点L1的PVS中 and
CHECK-VISIBILITY (L1, C))
7 添加节点C到节点L1的PVS中
7 添加节点L1到节点C的PVS中
算法分析
如果我们没有使用根据叶节点的相连情况来进行处理的优化技术,那么必须对树中的每一个叶节点进行检测,这时函数的复杂度为O(n2),这里n为树中叶节点的数量。要对上面的算法估计一个对处理过程提高了多少性能的具体数值非常困难,因为它依赖于所处理的结构的大小。对于每一个叶节点与其它节点都是可见的结构,这个算法不会做任何优化。对于每一个叶节点只有一到二个可见的节点的结构,这个算法将会产生非常大优化,它的复杂度接近与O(n)。
现在在一个设计良好的地图上所产生的结构在每一帧上将会避免检测大量的多边形。一个设计良好的地图在建立时将会考虑物体的可见性,这意味着应尽可能的在地图中放入一些能障碍视线的物体,如墙壁等。如果在地图中包含一个有大量细节的大房间,那么在上面的算法中(或在一个portal引擎中)进行的隐藏面剔除工作对它不会产生任何效果。这时我们需要使用其它类似于LOD这样的技术来进行多边形剔除。
静态物体
考虑一下这样的场景,一个球体位于一个立方体的中心,如果用BSP对它进行渲染那么将会产生大量的节点,并会产生大量的分割多边形,这是因为在球体上的每一个多边形都会送入到叶节点中。如果球体有200个多边形当渲染时就会产生200个叶节点的BSP树。这样做非常影响运行的速度,因此必须寻找一个方法来避免这种现象的发生。
为了解决这个问题地图的设计者可以选择组成地图的几何体,在上面的例子中也就是立方体,接着将剩余的物体做为静态物体,它们将不会用来对BSP树进行渲染或进可见性检测,但是它们会参与到地图的光照运算当中去。当进行可见性运算时每一个静态物体会被添加到BSP树中,每一个静态物体的多边形将会被添加到BSP树中,这个过程如下:
l 函数PUSH-POLYGON
l 参数:
l Node – 多边形当前所在的节点。
l Polygon – 将被添加的多边形。
l 返回值:
l None
l 功能:
l 将多边形添加到树中。如果多边形的一些点与节点的分割面相交将会被分割。分割后的部分将会被继续向下传送。当一个多边形进入一个叶节点后它将被添加到叶节点的多边形集合中。
PUSH-POLYGON (Node, Polygon)
1 if (IS-LEAF (Node))
2 Node.PolygonSet = Node.PolygonSet U Polygon
3 else
4 value = CALCULATE-SIDE (Node.Divider, Polygon)
5 if (value = INFRONT or value = SPANNING)
6 PUSH-POLYGON (Node.RightChild, Polygon)
7 else if (value = BEHIND)
8 PUSH-POLYGON (Node.LeftChild, Polygon)
9 else if (value = SPANNING)
10 Split_Polygon28 (P1, Divider, Front, Back)
11 PUSH-POLYGON (Node.RightChild, Front)
12 PUSH-POLYGON (Node.LeftChild, Front)
PUSH-POLYGON是一个递归函数,它将一个多边形添加到BSP树中,这个函数对每一个静态物体的每一个多边形都会调用一次,
在这个过程处理之后叶节点可能不再成为一个“凸”集合,这在进行碰撞检测时可能会产生一些问题,后面的内容将会对这个问题进行阐述。
翻译自一篇老外的文章<Binary Space Partioning Trees and Polygon
Removal in Real Time 3D Rendering>,最后一点没有翻译完,呵呵,不太好意思!!!!!
BSP技术作为室内引擎渲染的主流技术虽然已经存在多年,但是生命力仍然非常顽强,最新的DOOM3,HL2仍然将它作为渲染的主流技术,但是在网上对它介绍文章虽然多却非常浅显,大多是使用Q3的BSP文件进行渲染,而BSP文件如何产生则介绍非常少,盖因为这一部分是场景编辑器的工作,而完成一个这样的BSP编辑器是非常困难的,需要掌握的知识非常多.下面我将对BSP编辑器这一部分需要用到的BSP知识进行一下介绍,这只是一些很初步的知识,如希望了解更多的内容,Q2开源代码中有一个BSP编辑器的代码是你研究的重点,还有就是HL2泄露代码中的编辑器代码,(一个痛苦的研究过程,可能要花费你几个月甚至一年的时间,不过这是值得的,如果你想完成一个主流的射击游戏引擎的话,没有BSP编辑器是不可想象的).
第一节 BSP Trees
BSP Trees英文全称为Binary Space Partioning trees,二维空间分割树,简称为二叉树。它于1969年被Shumacker在文章《Study for Applying Computer-Generated Images to Visual Simulation》首次提出,并被ID公司第一次使用到FPS游戏Doom中,Doom的推出获得了空前的成功,不仅奠定了ID公司在FPS游戏开发的宗师地位,也使BSP技术成为室内渲染的工业标准,从BSP产生到现在已经有30多年了,其间虽然产生了大量的室内渲染的算法,但却无人能撼动它的地位,对于以摩尔定律发展的计算机业来说这不能不是一个奇迹。
为什么使用BSP Trees
一个BSP Trees如同它的名字一样是一个层次树的结构,这个树的叶节点保存了分割室内空间所得到的图元集合。现在随着硬件加速Z缓冲的出现,我们只需要用很小的代价就可以对空间中的图元进行排序,但是在90年代初由于硬件的限制,使用BSP的主要原因是因为它可以对空间中的图元进行排序来保证渲染图元的顺序是按照由后至前进行的,换句话说,Z值最小的物体总是最后被渲染。当然还有其他的算法可以完成这个功能,例如著名的画家算法,但是它与BSP比较起来速度太慢了,这是因为BSP通常对图元排序是预先计算好的而不是在运行时进行计算。从某种意义上说BSP技术实际上是画家算法的扩展,正如同BSP技术的原始设计一样,画家算法也是使用由后至前的顺序对场景中的物体进行渲染。但是画家算法有以下的缺点:
l 如果一个物体从另一个物体中穿过时它不能被正确的渲染;
l 在每一帧对被渲染的物体进行排序是非常困难的,同时运算的代价非常大;
l 它无法管理循环覆盖的情况,如图所示
图1.1
BSP原理
建立BSP Trees的最初想法是获得一个图元的集合,这个集合是场景的一部分,然后分割这个图元集合为更小的子集合,这里必须注意子集合必须为“凸多边形”。这意味着子集合中任一个多边形都位于相同集合中其它多边形的“前面”。是不是有点难以理解呢,举一个例子,如果多边形A的每一个顶点都位于由多边形B所组成的一个面的正面,那么可以说多边形A位于多边形B的“前面”,参考左图。我们可以想象一下,一个盒子是由6个面组成的,如果所有的面都朝向盒子的内部,那么我们可以说盒子是一个“凸多边形”,如果不是都朝向盒子的内部,那么盒子就不是“凸多边形”。

图1.2
下面让我们看一下如何确定一个图元集合是否是一个“凸多边形”,伪算法如下:
l 函数CLASSIFY-POINT
l 参数:
l Polygon – 确定一个3D空间中点相对位置的参考多边形。
l Point – 待确定的3D空间中的点。
l 返回值:
l 点位于多边形的哪一边。
l 功能:
l 确定一个点位于被多边形定义的面的哪一边。
CLASSIFY-POINT (Polygon, Point)
1 Sidevalue = Polygon.Normal * Point
2 if (Sidevalue == Polygon.Distance)
3 then return COINCIDING
4 else if (Sidevalue < Polygon.Distance)
5 then return BEHIND
6 else return INFRONT
l 函数 POLYGON-INFRONT
l 参数:
l Polygon1 – 用来确定其它多边形是否在其“前面”的多边形。
l Polygon2 – 检测是否在第一个多边形“前面”的多边形。
l 返回值:
l 第二个多边形是否在第一个多边形的“前面”。
l 功能:
l 检测第二个多边形的每一个顶点是否在第一个多边形的“前面”。
POLYGON-INFRONT (Polygon1, Polygon2)
1 for each point p in Polygon2
2 if (CLASSIFY-POINT (Polygon1, p) <> INFRONT)
3 then return false
4 return true
l 函数 IS-CONVEX-SET
l 参数:
l PolygonSet – 用来检测是否为“凸多边形”的图元集合。
l 返回值:
l 集合是否为“凸多边形”。
l 功能:
l 相对于集合中的其它多边形检查每一个多边形,看是否位于其它多边形的“前面”,如果有任意两个多边形不满足这个规则,那么这个集合不为“凸多边形”。
IS-CONVEX-SET (PolygonSet)
1 for i = 0 to PolygonSet.Length ()
2 for j = 0 to PolygonSet.Length ()
3 if(i != j && not POLYGON-INFRONT(PolygonSet[i], PolygonSet[j]))
4 then return false
5 return true
在函数POLYGON-INFRONT中并没有进行对称的比较,这意味着如果多边形A位于多边形B的“前面”你并不能想当然的认为多边形B一定位于多边形B的“前面”。下面的例子简单的显示了这一点。

图1.3
在图1.3中我们可以看到多边形1位于多边形2的“前面”,这是因为顶点p3、p4位于多边形2的“前面”,而多边形2却没有位于多边形1的“前面”,因为顶点p2位于多边形1的“后面”。
对于一个BSP层次树来说可以用下面结构来定义:
class BSPTree
{
BSPTreeNode RootNode // 树的根节点
}
class BSPTreeNode
{
BSPTree Tree // 接点所属的层次树
BSPTreePolygon Divider // 位于两个子树之间的多边形
BSPTreeNode *RightChild // 节点的右子树
BSPTreeNode *LeftChild // 节点的左子树
BSPTreePolygon PolygonSet[] // 节点中的多边形集合
}
class BSPTreePolygon
{
3DVector Point1 // 多边形的顶点1
3DVector Point3 // 多边形的顶点2
3DVector Point3 // 多边形的顶点3
}
现在你可以看见每一个多边形由3个顶点来定义,这是因为硬件加速卡使用三角形来对多边形进行渲染。将多边形集合分割为更小的子集合有很多方法,例如你可以任意选择空间中的一个面然后用它来对空间中的多边形进行分割,把位于分割面正面的多边形保存到右子树中而位于反面的多边形保存到左子树中。使用这个方法的缺点非常明显,那就是如果想选择一个将空间中的多边形分割为两个相等的子集合的面非常困难,这是因为在场景中有无数个可选择的面。如何在集合中选择一个最佳的分割面呢?下面我将对这个问题给出一个比较适当的解决方案。
我们现在已经有了一个函数POLYGON-INFRONT,它的功能是确定一个多边形是否位于其它多边形的正面。现在我们要做的是修改这个函数,使它也能够确定一个多边形是否横跨过其它多边形定义的分割面。算法如下:
l 函数 CALCULATE-SIDE
l 参数 :
l Polygon1 – 确定其它多边形相对位置的多边形。
l Polygon2 – 确定相对位置的多边形。
l 返回值:
l 多边形2位于多边形1的哪一边
l 功能:
l 通过第一个多边形对第二个多边形上的每一个顶点进行检测。如果所有的顶点位于第二个多边形的正面,那么多边形2被认为位于多边形1的“前面”。如果第二个多边形的所有顶点都位于第一个多边形的反面,那么多边形2被认为位于多边形1的“后面”。如果第二个多边形的所有顶点位于第一个多边形之上,那么多边形2被认为位于多边形1的内部。最后一种可能是所有的顶点即位于正面有位于反面,那么多边形2被认为横跨过多边形1。
CALCULATE-SIDE (Polygon1, Polygon2)
1 NumPositive = 0, NumNegative = 0
2 for each point p in Polygon2
3 if (CLASSIFY-POINT (Polygon1, p) = INFRONT)
4 then NumPositive = NumPositive + 1
5 if (CLASSIFY-POINT (Polygon1, p) = BEHIND)
6 then NumNegative = NumNegative + 1
7 if (NumPositive > 0 && NumNegative = 0)
8 then return INFRONT
9 else if(NumPositive = 0 && NumNegative > 0)
10 then return BEHIND
11 else if(NumPositive = 0 && NumNegative = 0)
12 then return COINCIDING
13 else return SPANNING
上面的算法也给我们解答了一个问题,当一个多边形横跨过分割面时如何进行处理,上面的算法中将多边形分割为两个多边形,这样就解决了画家算法中的两个问题:循环覆盖和多边形相交。下面的图形显示了多边形如何进行分割的。

图1.4
如图1.4所示,多边形1为分割面,而多边形2横跨过多边形1,如图右边所示,多边形被分割为2、3两部分,多边形2位于分割面的“前面”而多边形3位于分割面的“后面”。
当建立一个BSP树时,首先需要确定的问题是如何保证二叉树的平衡,这意味着对于每一个叶节点的分割深度而言不能有太大的差异,同时每一个节点的左、右子树需要限制分割的次数。这是因为每一次的分割都会产生新的多边形,如果在建立BSP树时产生太多的多边形的话,在图形加速卡对场景渲染时会加重渲染器的负担,从而降低帧速。同时一个不平衡的二叉树在进行遍历时会耗费许多无谓的时间。因此我们需要确定一个合理的分割次数以便于获得一个较为平衡的二叉树,同时可以减少新多边形的产生。下面的代码显示了如何通过循环多边形集合来获得最佳的分割多边形。
l 函数 CHOOSE-DIVIDING-POLYGON
l 参数:
l PolygonSet – 用于查找最佳分割面的多边形集合。
l 返回值:
l 最佳的分割多边形。
l 功能:
l 对指定的多边形集合进行搜索,返回将其分割为最佳子集合的多边形。如果指定的集合是一个“凸多边形”则返回。
CHOOSE-DIVIDING-POLYGON (PolygonSet)
1 if (IS-CONVEX-SET (PolygonSet))
2 then return NOPOLYGON
3 MinRelation = MINIMUMRELATION
4 BestPolygon = NOPOLYGON
5 LeastSplits = INFINITY
6 BestRelation = 0
l 循环查找集合的最佳分割面。
7 while(BestPolygon = NOPOLYGON)
8 for each 多边形P1 in PolygonSet
9 if (多边形P1在二叉树建立过程中没有作为分割面)
l 计算被当前多边形定义的分割面的正面、反面和横跨过分割面的多边形的数量。
10 NumPositive = 0, NumNegative = 0, NumSpanning = 0
11 for each 多边形P2 in PolygonSet except P1
12 value = CALCULATE-SIDE(P1, P2)
13 if(value = INFRONT)
14 NumPositive = NumPositive + 1
15 else if(value = BEHIND)
16 NumNegative = NumNegative + 1
17 else if(value = SPANNING)
18 NumSpanning = NumSpanning + 1
l 计算被当前多边形分割的两个子集合的多边形数量的比值。
19 if (NumPositive < NumNegative)
20 Relation = NumPositive / NumNegative
21 else
22 Relation = NumNegative / NumPositive
l 比较由当前多边形获得的结果。如果当前多边形分割了较少的多边形同时分割后的子集合比值可以接受的话,那么保存当前的多边形为新的候选分割面。
l 如果当前多边形和最佳分割面一样分割了相同数量的多边形而分割后的子集合比值更大的话,将当前多边形作为新的候选分割面。
23 if (Relation > MinRelation &&
(NumSpanning < LeastSplits ||
(NumSpanning = LeastSplits &&
Relation > BestRelation))
24 BestPolygon = P1
25 LeastSplits = NumSpanning
26 BestRelation = Relation
l 通过除以一个预先定义的常量来减少可接受的最小比值。
27 MinRelation = MinRelation / MINRELATIONSCALE
28 return BestPolygon
算法分析
对于上面的函数来说,根据场景数据大小的不同它可能花费很长一段时间。常量MINRELATIONSCALE用来确定在每次循环时所分割的子集合多边形数量的比值每次减少多少,为什么要使用这个常量呢,考虑一下,对于给定的MinRelation如果我们找不到最佳的分割面,通过除以这个常量将比值减少来重新进行循环查找,这样可以防止死循环的出现,因此当这个比值足够小时我们必定可以获得可接受的最佳结果。最坏的事情是我们有一个包含N个多边形的非“凸”集合,分割多边形将集合分割为一个包含N-1个多边形的部分和一个包含1个多边形的部分。这个结果只有在最小比值小于1/(n-1)才是可以接受的(参考算法的19-23行)。这意味着MinRelation /MINRELATIONSCALEi < 1/(n-1),这里i是循环重复的次数。让我们假设MinRelation的初始化值为1,由于比值永远为0-1之间的值因此这是最可能的值(参考算法的19-22行)。我们有
1 / MINRELATIONSCALEi < 1/(n-1)
1 < MINRELATIONSCALEi/(n-1)
(n-1) < MINRELATIONSCALEi
logMINRELATIONSCALE (n-1) < i
这里的i没有上边界,但是因为i非常接近于logMINRELATIONSCALE (n-1),我们可以简单的假设两者是相等的。另外我们也假设MINRELATIONSCALE永远大于或等于2,因此我们可以有
logMINRELATIONSCALE (n-1) = i
MINRELATIONSCALE >= 2
i = logMINRELATIONSCALE (n-1) < lg(n-1) = O(lg n)
在循环的内部,对多边形的集合需要重复进行两次循环,因此对我们来说最坏的情况下这个算法的复杂度为O(n2lg n),而通常情况下这个算法的复杂度接近于O(n2)。
在函数CHOOSE-DIVIDING-POLYGON的循环中看起来如果不发生什么事情的话好象永远不会停止,但是这不会发生,这是因为如果多边形集合为非“凸”集合的话总能找到一个多边形来把集合分割为两个子集合。CHOOSE-DIVIDING-POLYGON函数总是选择分割集合的多边形数量最少的多边形,为了防止选择并不分割集合的多边形,分割后的子集合的多边形数量之比必须大于预先定义的值。为了更好的理解我上面所讲解的内容,下面我将举一个例子来说明如何选择一个多边形对一个很少数量多边形的集合进行分割。

图1.5
在上面的例子中无论你选择多边形1、6、7还是多边形8进行渲染时都不会分割任何其它的多边形,换句话说也就是所有的其它多边形都位于这些多边形的“正面”。
关于分割时选择产生多边形最少的分割面另外一个不太好的原因是大多数时候它所产生的层次树通常是不平衡的,而一个平衡的层次树在运行的时候通常比不平衡的层次树性能更好。
当获得最佳的分割面后伴随着必然产生一些被分割的多边形,如何对被分割的多边形进行处理呢,这里有两个方法:
1. 建立一个带叶节点的二叉树,这意味着每一个多边形将被放在叶节点中,因此每一个被分割的多边形也将被分开放在二叉树的一边。
2. 另外一个方法是将被分割的多边形保存到公共节点中,对每一个子树重复这个过程直到每一个叶节点都包含了一个“凸”多边形集合为止。
产生带叶节点的BSP树的算法如下:
l 函数GENERATE-BSP-TREE
l 参数:
l Node – 欲建立的类型为BSPTreeNode的子树。
l PolygonSet – 建立BSP-tree的多边形集合。
l 返回值:
l 保存到输入的父节点中的BSP-tree。
l 功能:
l 对一个多边形集合产生一个BSP-tree。
GENERATE-BSP-TREE (Node, PolygonSet)
1 if (IS-CONVEX-SET (PolygonSet))
2 Tree = BSPTreeNode (PolygonSet)
3 Divider = CHOOSE-DIVIDING-POLYGON (PolygonSet)
4 PositiveSet = {}
5 NegativeSet = {}
6 for each polygon P1 in PolygonSet
7 value = CALCULATE-SIDE (Divider, P1)
8 if(value = INFRONT)
9 PositiveSet = PositiveSet U P1
10 else if (value = BEHIND)
11 NegativeSet = NegativeSet U P1
12 else if (value = SPANNING)
13 Split_Polygon10 (P1, Divider, Front, Back)
14 PositiveSet = PositiveSet U Front
15 NegativeSet = NegativeSet U Back
16 GENERATE-BSP-TREE (Tree.RightChild, PositiveSet)
17 GENERATE-BSP-TREE (Tree.LeftChild, NegativeSet)
算法分析
函数CHOOSE-DIVIDING-POLYGON的时间复杂度为O(n2 lg n),除非出现递归调用否则它将控制其它的函数,如果我们假设对多边形集合的分割是比较公平的话,那么我们可以通过公式来对函数GENERATE-BSP-TREE的复杂度进行表达:
T(n) = 2T(n/2) + O(n2 lg n)
通过公式我们可以知道这个函数的复杂度为Q (n2 lg n)。这里n为输入的多边形集合的多边形数量。
下面我要用一个例子来演示如何产生一个BSP-tree。下面的结构是一个多边形的原始集合,为了表示方便对每一个多边形都进行了编号,这个多边形集合将被分割为一个BSP-tree。

图1.6
为了能够运行这个算法我们必须对常量MINIMUMRELATION和MINRELATIONSCALE进行赋值,在实际运行中我们发现当MINIMUMRELATION=0.8而MINRELATIONSCALE=2时可以获得比较好的结果。但是你也可以对这些数值进行试验来比较一下,通常当常数MINIMUMRELATION比较大时获得的层次树会比较平衡但同时分割产生的多边形也会大量增加。在上图显示的多边形集合并不是一个“凸”的,因此首先我们需要选择一个合适的分割面。在快速的对上面的结构进行一下浏览后我们可以知道多边形(1、2、6、22、28)不能被用来作为分割面,这是因为它们定义了包含所有多边形集合的外形。但是其它的多边形都可以作为候选的分割面。分割产生的多边形最少同时分割为两个子集合的多边形数目之比为最佳的多边形是16与17,它们位于同一条直线上同时并不会分割任何的多边形。而分割后的子集合的多边形数目也是一样的,都是“正面”为13而“反面”为15。让我们选择多边形16作为分割面,那么分割后的的结构如下:
图1.7
现在从图1.7我们可以看到无论是左子树还是右子树都没有包含“凸”多边形集合,因此需要对两个子树继续进行分割。
在左子树中多边形1、2、6、7作为多边形集合的“凸边”不能用做分割面,而多边形4、10在同一条直线上同时没有分割任何多边形,而分割后的多边形子集合:“正面”为8而“反面”为7非常的平衡,我们选择多边形4为分割面。
在右子树中多边形16、17、22、23、28不能作为分割面,而多边形18、19、26、27虽然没有分割任何多边形但是分割后的多边形子集合:“正面”为11而“反面”为3,3/11这个比值小于最小比值0.5因此我们需要寻找其它更适合的多边形。多边形20、21、24、25都只分割了一个多边形,但是多边形21看起来分割后的结果更合理,在分割过多边形22后多边形子集合的结果为:“正面”为8而“反面”为6。
下图显示了操作后的结果:

图1.8
在图中每一个子集合还不是一个“凸”集合,因此需要继续进行分割,按照上面的法则对图1.8所示的结构进行分割后,结果如下:
图1.9
上图显示了最后的结果,这可能不是最优的结果但是我们对它进行处理所花费的时间并不太长。
渲染BSP
现在我们已经建立好一个BSP树了,可以很容易对树中的多边形进行正确的渲染,而不会产生任何错误。下面的算法描述了如何对它进行渲染,这里我们假设函数IS-LEAF的功能为给定一个BSP节点如果为叶节点返回真否则返回假。
函数DRAW-BSP-TREE
参数:
l Node – 被渲染的节点。
l Position – 摄象机的位置。
l 返回值:
l None
l 功能:
l 渲染包含在节点及其子树中的多边形。
DRAW-BSP-TREE (Node, Position)
1 if (IS-LEAF(Node))
2 DRAW-POLYGONS (Node.PolygonSet)
3 return
l 计算摄象机包含在哪一个子树中。
4 Side = CLASSIFY-POINT (Node.Divider, Position)
l 如果摄象机包含在右子树中先渲染右子树再渲染左子树。
5 if (Side = INFRONT || Side = COINCIDING)
6 DRAW-BSP-TREE (Node.RightChild, Position)
7 DRAW-BSP-TREE (Node.LeftChild, Position)
l 否则先渲染左子树。
8 else if(value = BEHIND)
9 DRAW-BSP-TREE (Node.LeftChild, Position)
10 DRAW-BSP-TREE (Node.RightChild, Position)
用这个方法进行渲染并没有减少渲染到屏幕上的多边形数量,由于一个场景可能包含成百上千个多边形因此这个方法并不是很好的解决方案。通常情况下有大量的节点和多边形并没有处于摄象机的视野范围之内,它们并不需要被渲染到屏幕上,如何查找这些不可见的节点和多边形防止它们渲染到屏幕上呢,隐藏面剔除就是为了解决这个问题而提出一项技术,在下一节中我们将对这项技术进行详细的阐述。
gameres的blog完全不可用了,今天开始将以前的东东都转移过来.