[3D图形学]视锥剔除入门(翻译)

最近在学3D图形学...看到篇不错的视锥剔除入门教程,于是搬来翻译了...原文:http://www.flipcode.com/archives/Frustum_Culling.shtml

时至今日,许多刚刚下海的3D引擎程序员仍不了解视锥剔除(Frustum Culling)的重要性和益处,这让我和我的小伙伴们感到很震♂惊.我在Flipcode的论坛中发现尽管网络上有海量的相关资料,仍有许多人提出对视锥剔除实现的问题.因此我决定撰写这篇文档,简单描绘出我现在所使用的四叉树剔除引擎(Quad-tree Culled Engine)的工作方式.诚然,市面上有许多种成熟且高效的视锥剔除算法,但我认为这个算法足以用来学习视锥剔除的理论基础.在正式开始前我还想说明一件事,以前我一直把Frustum(平截头体)打成Frustrum(截头锥),为此我没少被论坛上的人喷.在这里我承认Frustum是正确的拼写.对那些以前被我冒犯的人我表示抱歉...你们这群吹毛求疵的傻[哔-]...

大多数人已经知道什么是视锥剔除了(译者:如果你是手滑误点进来的...视锥剔除是一个图形渲染前的步骤,用于剔除掉不需要绘制的部分).视锥(准确说是平截头体Frustum)的形状酷似一个塔尖被削平了的金字塔,更准确地说,是一个四棱锥的顶点偏下位置被一个裁面(Clipping Plane,见图1)裁断.事实上,视锥本身就是由6个面所组成.这6个面被称为近裁面,远裁面,上裁面,下裁面,左裁面,右裁面.视锥剪裁仅仅是一个用来判断物体是否需要被绘制的过程.尽管从本质上讲视锥剔除应该是三维层面的,但事实上大多数时候它仅仅需要以纯代数的方法便能解决.这也是为什么我如此推崇视锥剔除的原因,它非常的快(如果算法好的话),而且是在渲染管线(Rendering Pipeline)之前进行的,不像背面剔除(Backface Culling)那样需要在渲染管线之后一个顶点一个顶点地计算.对于被剪裁掉的物体绘图引擎都不会将其送入显卡(译者:那是...被剔除掉的压根都不用渲染),因此视锥剔除对渲染速度有巨大的改善,毕竟什么都不渲染是最快的渲染.

然而,视锥剔除的高效还得益于它背后的算法,分层剔除法(Hierarchy Culling Method)是一种很好的算法.该算法将一个三维世界分割为一个树型结构,一旦我们剔除掉一个节点,那么那个节点往下的子节点就也被一并剔除了.这样我们就不必一个个剔除三维世界中每一个物体.我们只需简单地分层处理便能成倍地剔除物体,省下了大量的剔除计算时间.若不使用分层法,视锥剔除就必须以最少O(n)的时间复杂度一个个判断物体是否需要绘制,换句话说,100个物体需要100次计算,1000000个物体就需要1000000次!尽管进行视锥剔除总比不进行好,但糟糕的算法却会使我们的努力变得不那么明显.若要制作出一个优化良好的游戏,能使用一个O(lgN)甚至O(c)的算法就不要使用一个O(n)的算法,我是绝不接受后者的,因此我选择了分层剔除法.想象一下,这里有100个物体,但只有1个位于我们的视野内,如果我们使用二分法来剔除的话,原本需要100次计算的线性算法,现在只需要7步(100->50->25->13->7->4->2->1,作者原文写的6步...).O(n)与O(lgN)的区别,很容易就能看出来.

我的分层剔除法使用四叉树分层.一个二叉树或八叉树或随便什么结构其实都可以胜任这个工作.事实上,代码很容易移植.我选择四叉树是因为它非常直观.本质上四叉树是一个二维平面上有4个节点的树(见图2).因此,每一个子节点都刚好是父节点的平面中的一个象限.因此我们认为它是"分层的",每一个子节点都在父节点之内,如果我们认定一个父节点是不可见的,我们便也可以断定子节点同样是不可见的.因此我们可以以飞快的速度处理海量的物体数据,特别是对于拥有数十万顶点数据的地形渲染引擎.而且它具有扩展性,地形上的装饰物可以被加进最小的四叉树节点当中.

基本方法1

要规划一个视锥剔除系统的第一步是确保你已经有合适的剔除算法,这意味着我们需要先知道如何根据视角/投影矩阵(View/Projection Matrices)通过6个平面构建出一个平截头体,同时还要让它既能检测球体也能检测立方体.说的更简单一点,我们得知道判断一个球体或立方体与平截头体是包含、相交还是外离的方法.这可以让我们待会将要制作分层剔除法更加完善.

首先让我们来定义我们的平截头体.平截头体的6个面可以根据我们使用的渲染API(Direct3D/OpenGL)的镜头系统的视角/投影矩阵来定义.在不同的渲染API中定义起来有些差别.我尝试为Direct3D和OpenGL各写一份,但我最后发现这样只能让读者越来越困惑.幸运的是我的一位朋友已经写了一份很好的教程.回到1996年,我参观Raven公司时遇见了吉尔,那时我几乎已经是铁定被签约下来,到Raven和吉尔一起工作,但NAFTA(北美自由贸易协定)却阻止我这样没有大学文凭的人跨国工作.NAFTA我去年买了个登山包!超耐磨!不管怎么说,这是题外话.最重要的是那份教程清晰地描述了如何从矩阵中提取出6个裁面来.我建议你仔细读读并理解它:

http://www2.ravensoft.com/users/ggribb/plane%20extraction.pdf
(译注:这个网址已经访问不了了,可以试试这个http://www.cs.otago.ac.nz/postgrads/alexis/planeExtraction.pdf)

现在我假定你已经编写好了一个通过6个裁面定义的平截头体类,为了提高效率,请将其设计为仅在视角/投影矩阵发生改变时(换句话说,镜头发生移动时)才进行重构,而不是每一帧都重构一次.接下来我们要设计判断一个球体与平截头体是内含、相交还是外离的算法(译注:内含和相交有什么区别?等看到章节"优化1"时你就明白了).这其实非常简单:遍历6个裁面,判断球体是在该裁面的正面,背面,还是相交,实现起来确实不难,我们需要计算从球体中心到该裁面的距离.如果距离的绝对值小于球体半径,那么就是相交.如果距离大于0那就是在裁面正面(有可能在平截头体内).如果小于0那么就是在裁面背面.计算方法是:

设C(Center)为球中心坐标
设N(Normal)为平面法线向量
设D(Distance)为坐标系原点到裁面的距离

则计算公式为:距离=(C向量点乘N)+D,即C·N+D
(译注:由于C和N同维数,所以C与N可以直接点乘.我刚开始看到这个公式时很晕乎,后来百度出一篇高中教案,其中写道"利用法向量求点到平面的距离...把点A到平面的距离看成点A与平面内任意一点B所构成的向量在法向量方向上的投影的长度...",这便是C·N的出处,由于坐标系原点不一定在我们的裁面上,所以最后还要+D来修正一下.顺便一提那教案结尾还加了一句"此方法无需技巧,人人都会",我去年买了个表.)

正如我之前说的那样,再往下我们所需要做的就仅仅只是判断距离与球体半径的大小关系.这里是我编写的C++代码:

///判断某球体是否在平截头体内
int Frustrum::ContainsSphere(const Sphere& refSphere) const
{
	//球体中心到某裁面的距离
	float fDistance;
	//遍历所有裁面并计算
	for(int i = 0; i < 6; ++i)
	{
		//计算距离
		fDistance = m_plane[i].Normal().dotProduct(refSphere.Center())+m_plane[i].Distance();
		//如果距离小于负的球体半径,那么就是外离
		if(fDistance < -refSphere.Radius())
			return(OUT);
		//如果距离的绝对值小于球体半径,那么就是相交
		if((float)fabs(fDistance) < refSphere.Radius())
			return(INTERSECT);
	}
	//否则,就是内含
	return(IN);
}

接下来就是(在四叉树中)判断一个轴对齐包围盒(Axis-aligned Bounding Box,简称AABB,不是BBA哦...说白了就是一个边平行于坐标轴的立方体)与平截头体的关系.对于这步操作有数种方法.首先是包围盒(Bounding Box,指一个多用于碰撞检测的有边界的立方体,AABB包围盒是最简单的一种)的定义,我的包围盒是由2个坐标组成,分别代表"最小(靠近坐标轴负方向)"顶点和"最大"顶点,为了压缩体积,我干脆将它的数据结构设计为连续的6个数,接下来便是将包围盒的8个顶点依次与平截头体作对比,尽管这种做法不是最快的一种,但它却是最容易实现,最容易被学会的一种.我们只需要测试每一个顶点(立方体的角)与平截头体的关系即可.如果所有的点都在平截头体内,那这个立方体就是被包含;如果有1~7个点在里面,就是相切;如果所有的点都在某一个裁面的背后,那就是外离;否则,就还是相切,为什么?很好,因为有可能立方体没有一个点在平截头体内,但依然和平截头体相切(译注:比如平截头体的一个"尖"恰好插入立方体内).事实上,我们仍没有考虑一种极端情况:有一个超大的包围盒将平截头体整个包裹起来了,按我们现在的算法它是相交,事实上它真的是相交吗?这种情况我们会在章节"优化1"中讨论.

检测一个点是否在平截头体内也依旧简单到不需要妈妈担心.我们只需要将它和6个裁面作对比,判断它是在每一个裁面的正面就行(作者:别忘了,我们的裁面都是面向平截头体内部的. 译者:我去年买了个表,你之前有说吗?).判断点和裁面的关系其实和判断球体和裁面的关系一样...依然是万金油公式:C·N+D.如果结果大于0,就是在正面.如果小于0,就是在背面.如果是0就说明是在面内.除非你设计的算法有特殊的要求,不然我建议你仅需简单地将其等价视为在正面.(另外,如果你真的需要判断点是否是在面上的话,请记住绝对不要直接用等于号判断,别忘了浮点精度啊...).尽管听起来它和球体判断是一样的,但我仍不介意再重写一遍,下面是代码:

///判断AABB盒是否在平截头体内
int Frustrum::ContainsAaBox(const AaBox& refBox) const
{
	Vector3f vCorner[8];
	int iTotalIn = 0;
	//获得所有顶点
	refBox.GetVertices(vCorner);
	//测试6个面的8个顶点
	//如果所有点都在一个的背后,那就是外离
	//如果所有点都在每一个面的正面,就是内含
	for(int p = 0; p < 6; ++p) {
		int iInCount = 8;
		int iPtIn = 1;
		for(int i = 0; i < 8; ++i) {
			//测试这个点
			if(m_plane[p].SideOfPlane(vCorner[i]) == BEHIND)
			{
				iPtIn = 0;
				--iInCount;
			}
		}
		//所有点都在p面背后吗?
		if(iInCount == 0)
			return(OUT); //外离
		iTotalIn += iPtIn;
	}
	//如果iTotalIn是6,那么就都是在正面
	if(iTotalIn == 6)
		return(IN); //内含
	return(INTERSECT); //相交
}

好了,这就是视锥剔除的主要部分,你还好吧?

优化1

第一个对上面的函数的优化的方式非常简单.如果你仔细研究了那两个函数,你会发现球体检测比立方体检测要快很多.这意味着我们最好尽量使用球体检测而不是立方体检测,或者用球体检测进行预预判,判断通过后再使用立方体检测进行详细判断,有时我们使用立方体作为物体的渲染范围是因为它更匹配物体的形态,但我仍推荐先使用球体进行预判断,然后再用立方体判断.事实上,我现在使用的游戏引擎为每一个渲染目标同时分配了球体和立方体两个渲染范围.它们都在同一个四叉树节点上,在判断时前者优先于后者,这样我就可以用球体判断来快速剔除掉大量的节点/物体,然后再使用立方体判断仔细筛选.这一种简单的优化仅需要牺牲一点点内存空间便能提升性能.你所需要的仅仅只是为每一个物体分配两个渲染范围判定.

下一个优化是选择一种最优的分层结构遍历方式.以我们使用的四叉树为例,传统方法是检测一个父节点是否为可见的.如果是,那么就去检测每一个子节点.如果不是,那么就停止处理.说白了这就是对每一个节点进行递归处理.一旦我们遍历完成并决定出了需要绘制的节点,我们便将那些节点的内容送往显卡进行渲染.但想象一下,对于一个尚未完结的节点(一个有子节点的节点),如果这个节点是全部可见的,那么我们还需要检查它的子节点吗?既然它已经是全部可见的了,那么它的子节点也一定是可见的,再对它们进行判断就只是浪费你的CPU了!这就好比你不会去处理那些绝对不可见的节点一样.明白为何我们之前要判断"内含,相交,外离"而不是简单的"包含,不包含"了吧?对于完全不可见的节点,我们只需简单地剔除掉即可,对于全部可见的节点,我们也只需简单地放行即可,真正需要进一步递归处理的是部分可见而部分又不可见的节点,也就是那些"相交"的节点.这一步优化又能提升不少性能.

第三项优化则是如果镜头位于某个节点的渲染范围内部的话,就直接视为检测通过并开始检测它的子节点.这种情况可以被视为相交,我们只需要开始检测子节点就行了.我的实现方法是如果判断镜头位于一个立方检测盒内部的话,就直接按照相交来处理.如果采用了这个优化,那么你的四叉树递归函数应该类似于这样:

///对节点的递归处理
void QuadTree::RecurseProcess(Camera* pPovCamera, QuadNode* pNode, bool bTestChildren)
{
	//在裁剪前是否需要检测?
	if(bTestChildren) {
	//首先检测是否是在检测盒内
	if(pNode->m_bbox.ContainsPoint(pPovCamera->Position()) == NOT_INSIDE)
	{
	//先进行球体检测
		switch(pPovCamera->Frustrum().ContainsSphere(pNode->m_sphere)) {
			case OUT:
				return;
			case IN:
				bTestChildren = false;
				break;
			case INTERSECT:
			//检测立方体是否在视角内
				switch(pPovCamera->Frustrum().ContainsAaBox(pNode->m_bbox)) {
				case IN:
					bTestChildren = false;
					break;
				case OUT:
					return;
				}
				break;
			}
		}
	}
//[在这里填入判断代码]
}

基本方法2

如果你已经实现了上述所有的内容,并且注意观察你的程序的性能的话,你应该会注意到相交判断代码依然有些冗杂,它们占了CPU运算的大头.当然具体程度取决于你的程序中的物体数,以及你的四叉树的深度,和各种各样的因素.就算不进行性能分析,光凭眼睛看也会发现相交判断代码十分的复杂.就算是相对最快的球体检测也依旧需要检测球心和数个面的关系,最好情况下也至少得有1个,最糟情况下需要和全部6个面进行检测.不过这还有改善空间.先让我简单阐述一下接下来我们要做的.

第一个是球-球相交检测.这个检测又快又简单.只需要计算两个球体的球心距离然后和两者的半径和作对比,若距离小于半径和则是相交(在这里我们将内含也视为相交,因为已经没有必要分的那么清楚了),否则就是不想交.下面是我的算法,你应该会注意到我是使用距离的平方进行判断,因为开根操作很慢!

///测试两个球体是否相交
bool Sphere::Intersects(const Sphere& refSphere) const
{
//获得一个从目标球心指向本球心的向量
	Vector3f vSepAxis = this->Center() - refSphere.Center();
//计算半径和
	float fRadiiSum = this->Radius() + refSphere.Radius();
//简单地说,如果向量的模长小于半径的话,就是相交
//但直接求向量模长的话,需要一次开根操作
//而乘法(平方)操作远比开跟操作要快
//所以我使用摸的平方与半径和的平方作对比
	if(vSepAxis.getSqLength() < (fRadiiSum * fRadiiSum))
		return(true);
//否则就是不相交
	return(false);
}

下一个是判断球体和圆锥是否相交的算法.这个要比球-球相交判定复杂得多.我可以给你完整解释一遍如何实现,但幸运的是,已经有人替我这么做了.Dave Eberly(译注:《3D Game Engine Architecture》一书的作者)已经在他的网站Magic-Software.com上撰写了一篇完善的文档,用以解释球-圆锥相交判定的算法.

译者:事实上,Magic-Software.com似乎已经易主了...原文中的链接也自然不在了,你可以在百度文库中找到一个副本:
http://wenku.baidu.com/view/6388b482e53a580216fcfe9c

我认为这个网站是一个超棒的资料库,里面有大量的代码和文档(译者:可是TMD已经不在了啊...).他的文档即详细解释了原理,又给出了完整的代码.事实上,如果你没有雄厚的数学背景的话,你也可以简单地把结尾的代码复制然后直接使用.虽然我不推荐这样做,但有些人就是不能徒手将自然底数算到小数点后第⑨位,对于像译者那样的数死早,我只能说呵♂呵.那么现在我们已经有了两个高贵上档次的新算法了...可它们能用来干啥?很好,这就是下一章的内容...

优化2

Yep,你猜到了.那两个算法是用来进一步优化我们的视锥剔除算法的.正如我在上一章的开头所说,平截头体检测其实是很慢的,能避免的话就不要进行与它相关的操作.你应该也注意到球-球检测和球-锥检测要比平截头体检测要快一些.所以我们可以把它们作为预检测算法,利用它们的高速,提前剔除掉那些根本不可能相交的物体,仅让可能相交的物体进行平截头体检测.接下来我们要做的是为平截头体构建一个球体和圆锥.之后在检测时,我们让物体的渲染判定球和平截头体的球进行检测,通过后再与圆锥进行检测,依然通过后则再与平截头体进行检测.这种算法看上去很麻烦,其实对计算机而言计算起来远比直接检测平截头体要快得多.

创建一个包裹着平截头体的球体并不难(译者:尼玛我怎么就不会...),但创建一个合适的圆锥就不容易了.创建球体的方法是让球的球心位于平截头体的中央,让它的半径能够刚好到平截头体的远角(远裁面的任意一个角).为什么是远角?好吧,平截头体其实不就是一个向外扩张的被砍掉头的棱柱吗?无论你的视野再怎么远,也远不过平截头体的远角,换句话说,如果半径能够到它,那么球体就能完整地包裹下整个平截头体.你也许会问为何不是近角(近裁面的角),这给就是纯几何问题了,请看图3.定位球体中心位置的过程毫不费力.将近裁面与远裁面之间中间的那个点定为中点就足够了.而计算球体半径有一点小难.通常平截头体是由FOV(视野范围,一个角度,通常为75)决定.利用FOV,我们能通过几何方法计算出从球心到远角的距离.我们将原点定为镜头位置,X轴Y轴定为与近/远裁面平行,Z轴定为从原点指向远裁面中点的方向.将P点定为球心,Q定为一个远点,则P可以被表示为(0,0,nearClip + ((farClip + nearClip) / 2)),其中nearClip和farClip分别为原点到近裁面和远裁面的距离.下面是我的代码:

//计算平截头体球的半径
//首先计算远裁面与近裁面的距离
float fViewLen = m_fFarPlane - m_fNearPlane;
//计算远裁面的高,这里有个问题,FOV通常是从视角原点开始算的,这里应该用m_fFarPlane而不是fViewLen才对,作者的笔误?
float fHeight = fViewLen * tan(m_fFovRadians * 0.5f);
//在横纵比为1时,宽和高一样,否则还要再计算宽...
float fWidth = fHeight;
//确定P点
Vector3f P(0.0f, 0.0f, m_fNearPlane + fViewLen * 0.5f);
//确定Q点
Vector3f Q(fWidth, fHeight, fViewLen);
//获得一个半径向量
Vector3f vDiff(P - Q);
//于是球体半径就是模长了
m_frusSphere.Radius() = vDiff.getLength();
//然后我们要确定这个球在全局坐标系中的位置
Vector3f vLookVector;
m_mxView.LookVector(&vLookVector);
//计算球体中心在全局坐标中的位置
m_frusSphere.Center() = m_vCameraPosition + (vLookVector * (fViewLen * 0.5f) + m_fNearPlane);

构建包围平截头体的圆锥则相对简单一些.如果你读了那篇介绍球-锥相交判定的文章的话,你应该知道一个圆锥可以通过一个顶点(圆锥的原点),一个轴射线(指向圆锥的方向)以及一个锥角(母线和轴射线的夹角)组成.圆锥的顶点就是镜头的原点.轴射线就是镜头面向的方向.唯一的难度在于锥角的计算.如果我们只是简单地用FOV作为锥角的话,那么我们创建出的圆锥无法包裹住平截头体的四个远角.所以还是要通过计算来算出一个合适的锥角.利用一些几何学技巧,我们可以算出新的FOV.既然我们已经有平截头体的FOV,我们就能利用勾股定理,使用旧FOV(以及屏幕的尺寸)计算出FOV三角形的临边,然后再用临边与屏幕中点到边角的线段组成一个新的三角形,进而计算出新的FOV.听起来有些略繁琐,可以看下面的代码:

// vLookVector是镜头的方向向量
// Position()为获取镜头的位置
// fWidth为屏幕宽度的一半(单位:像素).
// fHeight为屏幕高度的一半(单位:像素).
// m_fFovRadians是平截头体的FOV

// 计算FOV三角形的临边
float fDepth  = fHeight / tan(m_fFovRadians * 0.5f);
// 计算从屏幕中点到边角的距离
float fCorner = sqrt(fWidth * fWidth + fHeight * fHeight);
// 计算新的FOV
float fFov = atan(fCorner / fDepth);
// 初始化圆锥
m_frusCone.Axis() = vLookVector;
m_frusCone.Vertex() = Position();
m_frusCone.SetConeAngle(fFov);

构建完球体和圆锥后,我们就可以用它们来快速剔除物体了.(你应该注意到我没有剔除圆锥之内、远裁面之外的节点.这是因为大多数的这些节点已经在球-球检测阶段被剔除了.圆锥检测主要是为了剔除平截头体侧面的节点.)这是改写后的代码:

///对节点的递归处理
void QuadTree::RecurseProcess(Camera* pPovCamera, QuadNode* pNode, bool bTestChildren) {
	//在裁剪前是否需要检测?
	if(bTestChildren)
	{
		//首先检测是否是在检测盒内
		if(pNode->m_bbox.ContainsPoint(pPovCamera->Position()) == NOT_INSIDE)
		{
			//检测我们是否和平截头体的球体相交
			if(!pPovCamera->FrustrumSphere().Intersects(pNode->m_sphere))
				return;
			//检测我们是否和平截头体的圆锥相交
			if(!TestConeSphereIntersect(pPovCamera->FrustumCone(), pNode->m_sphere))
				return;
			//先进行球体检测
			switch(pPovCamera->Frustrum().ContainsSphere(pNode->m_sphere))
			{
			case OUT:
				return;
			case IN:
				bTestChildren = false;
				break;
			case INTERSECT:
				//检测立方体是否在视角内
				switch(pPovCamera->Frustrum().ContainsAaBox(pNode->m_bbox))
				{
				case IN:
					bTestChildren = false;
					break;
				case OUT:
					return;
				}
				break;
			}
		}
	}
	//[在这里填入额外的判断和渲染代码]
}

结论

我自认为这是一篇挺不错的视锥剔除入门文章,我希望它能对新手图像引擎程序员有帮助.如果你仔细读下来的话,你也会发现本文除了译者糟糕的翻译以外,没有什么过于复杂的概念和大笔大笔的数学公式(事实上,数学公式都在引用的两篇文章里...).诚然,我介绍的算法并非最佳的视锥剔除算法,更何况对于这种算法而言,仍有不少进一步优化的方法,但对于入门而言,它已经足够了.如果你发现文中有含糊不清的地方的话,如果确保不是译者犯二的话就尽管找我抗议吧!

我很感谢我的好基友Gil Gribb(译注:Raven公司员工,参与过包括《战争机器》、《命运战士》等多款游戏的制作)和Klaus Hartmann(译者:《哈德曼的妖怪少女》...歪楼了(づ ̄3 ̄)づ),感谢他们在裁面提取方面的优秀的文章.我也很感谢Dave Eberly在圆锥算法方面的帮助,以及他那个包罗万象的网站(尽管已关闭)!另外,感谢Charles Bloom在网上撰写的一篇文章,它启发了我使用球体和圆锥来进行快速剔除.人类的最大美德莫过于知识的分享,以至于我除了发自心底地感谢那些愿意将自己的知识分享给他人的人以外,别无他言.

另外记住...练习才是进步的最佳捷径 - 百试百灵,无效退款.我(口头)保证!

译者:既然作者写完了那么就轮到我来蛋逼了!(~o ̄▽ ̄)~o 很久没做翻译了...上一次翻技术文章还是去年暑假...老实说在计算机图形学我还是个猹啊,专业名词方面我尽量做到规整,不过还是翻得有些213...好吧不多说了,撤了...ԅ(¯﹃¯ԅ)