9.1、尽可能减少渲染
GPU能够在33毫秒内完成惊人的计算量,以实现30Hz的渲染刷新率。尽管如此,GPU在给定的时间内也只能渲染这么多的点、线段和三角形。当几何图元的数量太多,超过了GPU的处理能力时,再怎么优化也无法获得想要的刷新率。本节会讲解一个叫做剔除(culling) 的技术,使用这个技术可以在不减少输出质量的情况下,尽量减少需要提交给GPU的几何图元的数量。
剔除涵盖了能够避免处理对于颜色渲染缓存中的片元没有影响的几何对象的各种技术。通常剔除能够非常明显地减少GPU的工作量。可以不实现其他的优化,但是这个优化最好是实现了。
OpenGL ES包含了一些内置的剔除能力。例如第7章就利用了“ 背面剔除”技术。每个三角形都有前面和后面,这个前面和后面是根据OpenGLES接收三角形顶点的顺序来确定的。背面剔除就是让OpenGLES丟弃那些背离观察者的三角形。在使用实心模型对象的应用中,这个优化几乎是无损的,因为用户通常永远看不到背面的三角形。
最有效的优化方式之一是避免渲染在当前视平截体外的对象。很多场景经常会包含用户永远都看不到的几何对象。只有在当前视平截体内的对象会影响在帧缓存的像素颜色渲染缓存中的片元颜色。第2章介绍了渲染缓存。第5章和第8章讲解了视平截体。
9.1.1 基于视平 截体的剔除
之前的例子OpenGLES_Ch8_4 使用了一个基于视平截体剔除的简单形式:使用一个简单的距离计算来判断一个点或者一个公告牌的位置在观察者的前面还是后面。这个例子会避免渲染处在观察者后面的公告牌几何图形。这个方法不仅适用于公告牌,还适用于更多的几何图形对象。假设视点处在一个复杂场景的中心,那么可能一半的几何图形对象会处在这个视点的后面,并且位于视平截体的外面。这个简单的计算机距离计算 可能会从渲染列表中剔除这个场景内一半的几何图形。
稍微复杂些的计算甚至可以剔除更多。图9-1显示了物体相对于视平截体的位置。一个对象可能完全处在平截体内,也可能部分处在平截体内,或者完全处在平截体外部。用来确定对象完全处在平截体外部的代码,几乎总是比向GPU提交物体的几何图形,并避免处理没有机会影响渲染场景外观的对象的代码执行得更快。
在图9-1中,宇宙飞船完全处在视平截体的外部。一个太远而看不到,另一个在视点的后面,第三个在视域外部的一侧。与此相反,每个圆柱体都是完全或者部分在视平截体内部的。
例子OpenGLES_Ch9_1 包含分散在整个场景中的81个宇宙飞船模型,并使用视平截体剔除技术有效地减少了GPU需要处理的几何图形的数量。在这个例子中的用户界面开关可以通过开启和关闭剔除来展示其影响。在第一代iPad上,开启剔除后,场景的刷新率是每秒30帧。关闭剔除后,刷新率会降到大约每秒7帧或者更少。在这个例子中剔除是非常有效的,因为任何时刻在视域中只有5到6个宇宙飞船。剔除可以把每帧中GPU需要处理的模型的数量从81个减少到5到6个。

在下面的AGLKFrustum数据类型中粗体突出的元素定义了类似图9-2中显示的视平截体。其他的元素源于视平截体的定义,用于帮助确定几何对象是否与视平截体相交。
/////////////////////////////////////////////////////////////////
// This data type is used to store the parameters that define a
// viewing frustum
typedef struct
{ // Frustum definition
GLKVector3 eyePosition;
GLKVector3 xUnitVector;
GLKVector3 yUnitVector;
GLKVector3 zUnitVector;
GLfloat aspectRatio;
GLfloat nearDistance;
GLfloat farDistance;
// Derived frustum properties
GLfloat nearWidth;
GLfloat nearHeight;
GLfloat tangentOfHalfFieldOfView;
GLfloat sphereFactorX;
GLfloat sphereFactorY;
}
AGLKFrustum;
三个法向量、两个距离、一个纵横比和一个视野共同确定了视平截体,参见图9-2。不巧的是,这个平截体数据结构存储的信息与编码在投影矩阵和model-view矩阵中的信息是相同的。平截体数据结构只是另一种表示形式,用于测试几何图形是否与视平截体相交。可以从已初始化的平截体中提取出等价的投影和model-view矩阵。

下面的AGLKFrustumMakeFrustumWithParameters(GLfloat fieldOfViewRad, GLfloat aspectRatio, GLfloat nearDistance, GLfloat farDistance)函数会接收与GLKMatrix4Make- Perspective()函数相同的参数并返回一个已初始化的AGLKFrustum数据结构。相关的GLKMatrix4 AGLKFrustumMakePerspective(const UtilityFrustum * frustumPtr)函数可以 从一个平截体中提取出一个投影矩阵。当用来初始化平截体的参数相同时,这个函数返回的矩阵与GLKMatrix4MakePerspective()函数返回的矩阵是相同的。
AGLKFrustum AGLKFrustumMakeFrustumWithParameters
(
GLfloat fieldOfViewRad,
GLfloat aspectRatio,
GLfloat nearDistance,
GLfloat farDistance)
{
AGLKFrustum frustum;
AGLKFrustumSetPerspective(
&frustum,
fieldOfViewRad,
aspectRatio,
nearDistance,
farDistance);
return frustum;
}
/////////////////////////////////////////////////////////////////
//
extern void AGLKFrustumSetPerspective
(
AGLKFrustum *frustumPtr,
GLfloat fieldOfViewRad,
GLfloat aspectRatio,
GLfloat nearDistance,
GLfloat farDistance
)
{
NSCAssert(NULL != frustumPtr,
@"Invalid frustumPtr parameter");
NSCAssert(0.0f < fieldOfViewRad && M_PI > fieldOfViewRad,
@"Invalid fieldOfViewRad");
NSCAssert(0.0f < aspectRatio, @"Invalid aspectRatio");
NSCAssert(0.0f < nearDistance, @"Invalid nearDistance");
NSCAssert(nearDistance < farDistance, @"Invalid farDistance");
const GLfloat halfFieldOfViewRad = 0.5f * fieldOfViewRad;
// store the information
frustumPtr->aspectRatio = aspectRatio;
frustumPtr->nearDistance = nearDistance;
frustumPtr->farDistance = farDistance;
// compute width and height of the near section
frustumPtr->tangentOfHalfFieldOfView =
tanf(halfFieldOfViewRad);
frustumPtr->nearHeight = nearDistance *
frustumPtr->tangentOfHalfFieldOfView;
frustumPtr->nearWidth = frustumPtr->nearHeight * aspectRatio;
// Calculate sphere factors (used when testing sphere
// intersection with frustum)
frustumPtr->sphereFactorY =
1.0f/cosf(frustumPtr->tangentOfHalfFieldOfView);
const GLfloat angleX =
atanf(frustumPtr->tangentOfHalfFieldOfView * aspectRatio);
frustumPtr->sphereFactorX = 1.0f/cosf(angleX);
}
除了可以存储透视信息,视平截体还可以存储用于定义渲染场景的视点的值。下面的AGLKFrustumSetPositionAndDirection (AGLKFrustum frustumPtr, GLKVector3 eyePosition, GLKVector3 lookAtPosition, GLKVector3 upVector)函数可以使用与GLKMatrix4-MakeLookAt0函数类似的参数来更新平截体的视点。可以使用相关的GLKMatrix4AGLKFrustumMakeMode-lview(const AGLKFrustum frustumPtr) 函数来获得与平截体的视点对应的model-view矩阵,当用设置平截体位置和方向的参数调用GLKMatrix4MakeLookAt()函数时,求得的model-view矩阵与这个函数返回的矩阵是相同的。
void AGLKFrustumSetPositionAndDirection
(
AGLKFrustum *frustumPtr,
GLKVector3 eyePosition,
GLKVector3 lookAtPosition,
GLKVector3 upVector)
{
NSCAssert(NULL != frustumPtr,
@"Invalid frustumPtr parameter");
frustumPtr->eyePosition = eyePosition;
// compute the Z axis of the frustum. The Z axis points in
// the direction from eye position to look at position
const GLKVector3 lookAtVector =
GLKVector3Subtract(eyePosition, lookAtPosition);
NSCAssert(0.0f < AGLKVector3LengthSquared(lookAtVector),
@"Invalid eyeLookPosition parameter");
frustumPtr->zUnitVector = GLKVector3Normalize(lookAtVector);
// The frustum's X axis is the cross product of the
// normalized “up” vector and the frustum's Z axis
frustumPtr->xUnitVector = GLKVector3CrossProduct(
GLKVector3Normalize(upVector),
frustumPtr->zUnitVector);
// The frustum's Y axis is the cross product of the
// frustum's Z axis and the frustum's X axis.
frustumPtr->yUnitVector = GLKVector3CrossProduct(
frustumPtr->zUnitVector,
frustumPtr->xUnitVector);
}
为了测试一个点是否落在一个平截体内部,第一步是计算从眼睛的位置到当前测试的点之间的矢量,然后提取出这个矢量的平截体轴对齐分量。回顾一下1.4 节,每个 3D矢量都可以表示为三个不同的轴对齐分量矢量的结合。图9-3显示了与每个平截体轴对齐的分量矢量。分量矢量的总和相当于从眼睛的位置到当前测试的点之间的矢量。

分量矢量的长度是使用“ 眼睛到点”的矢量与平截体的每个标准化轴矢量之间的标量积计算出来的。第一个也是最快的测试是确定这个点是否在平截体远面的前面或者在 平截体近面的后面。如果第一个测试的结果为否,那么就没有必要再做任何工作了。下面摘录自例子OpenGLES_Ch9_1的AGLKFrustumComparePoint (const AGLKFrustum *frustumPtr,GLKVector3point)函数的代码会计算从眼睛位置到测试点之间的矢量。然后提取了这个矢量与平截体Z轴对齐的分量。如果这个Z轴分量要比远面大,或者比近面小,那么这个点就处在平截体的外部。
// compute vector from eye position to point
const GLKVector3 eyeToPoint = GLKVector3Subtract(
frustumPtr->eyePosition, point);
// compute and test Z coordinate within frustum
const GLfloat pointZComponent = GLKVector3DotProduct(
eyeToPoint, frustumPtr->zUnitVector);
if(pointZComponent > frustumPtr->farDistance ||
pointZComponent < frustumPtr->nearDistance)
{ // The point is not within frustum
result = AGLKFrustumOut;
}
下一步,这个眼睛到点的矢量的Y分量必须要短于在被测点的z位置处的平截体的高度。如图9-4所示,这个高度是通过用视野角度的一半的正切值乘以被测点的z位 置计算出来的。

最后,这个眼睛到点的矢量的X分量必须要短于在被测点的z位置处的平截体的宽度。平截体的这个宽度等于平截体的高度乘以纵横比。下面的摘录代码会测试X和Y分量是否在平截体内部。
// compute and test Y coordinate within frustum
const GLfloat pointYComponent =
GLKVector3DotProduct(eyeToPoint,
frustumPtr->yUnitVector);
const GLfloat frustumHeightAtZ = pointZComponent *
frustumPtr->tangentOfHalfFieldOfView;
if(pointYComponent > frustumHeightAtZ ||
pointYComponent < -frustumHeightAtZ)
{ // The point is not within frustum
result = AGLKFrustumOut;
}
else
{ // compute and test the X coordinate within frustum
const GLfloat pointXComponent =
GLKVector3DotProduct(eyeToPoint,
frustumPtr->xUnitVector);
const GLfloat frustumWidthAtZ = frustumHeightAtZ *
frustumPtr->aspectRatio;
if(pointXComponent > frustumWidthAtZ ||
pointXComponent < -frustumWidthAtZ)
{ // The point is not within frustum
result = AGLKFrustumOut;
}
}
测试一个球体是否与视平截体相交会更复杂一点。 好像测试从球体的中心到平截体内的一个点之间的距离小于球体的半径几乎就足够了。不幸的是,这个测试漏掉了一个特殊情况,参见图9-5。

例子OpenGLES_Ch9_1 中的AGLKFrustum数据类型包含了命名为sphereFactorX,和sphereFactorY的专门用于处理球体的特殊情况的元素。这些因数的值稍大于1.0,当 测试球体与平截体相交时,会用这些因数乘以球体的半径。图9-6显示了球体因数与半径之间的几何关系。;

球体因子是基于球体的视野角度计算出来的,参见下面的代码:
// Calculate sphere factors (used when testing sphere
// intersection with frustum)
frustumPtr->sphereFactorY =
1.0f/cosf(frustumPtr->tangentOfHalfFieldOfView);
const GLfloat angleX =
atanf(frustumPtr->tangentOfHalfFieldOfView * aspectRatio);
frustumPtr->sphereFactorX = 1.0f/cosf(angleX);
所有的几何关系都是为了有效地测试球体是否与视平截体相交。仔细检查例子OpenGLES_Ch9_1中的AGLKFrustumCompareSphere (const AGLKFrustum * frustumPtr,GLKVector3 center, float radius) 函数的实现以感受一下实际的应用。要确定一个模型是 否与平截体相交,只要测试包裹这个模型的球体是否与这个平截体相交就足够了。例子中的“-drawModels”方法试图用第7章和第8章例子中的相似代码来绘制散落在整个场景中的81个模型。如下代码中粗体显示的是一个快速测试,这个测试会剔除在当前渲染帧中不可见的模型。
- (void)drawModels
{
const float modelRadius = 7.33f; // Used to cull models
self.baseEffect.texture2d0.name =
self.modelManager.textureInfo.name;
self.baseEffect.texture2d0.target =
self.modelManager.textureInfo.target;
[self.modelManager prepareToDraw];
// Draw an arbitary large number of models
for(NSInteger i = -4; i < 5; i++)
{
for(NSInteger j = -4; j < 5; j++)
{
const GLKVector3 modelPosition = {
-100.0f + 150.0f * i,
0.0f,
-100.0f + 150.0f * j
};
if(!self.shouldCull ||
AGLKFrustumOut != AGLKFrustumCompareSphere(
&frustum_, modelPosition, modelRadius))
{
// Savethe current matrix
GLKMatrix4 savedMatrix =
self.baseEffect.transform.modelviewMatrix;
// Translate to the model position
self.baseEffect.transform.modelviewMatrix =
GLKMatrix4Translate(savedMatrix,
modelPosition.x,
modelPosition.y,
modelPosition.z);
[self.baseEffect prepareToDraw];
// Draw the model
[self.model draw];
// Restore the saved matrix
self.baseEffect.transform.modelviewMatrix =
savedMatrix;
}
}
}
}
为了保持例子OpenGLES_Ch9_1的简单性,每次渲染场景时都会测试每个模型与平截体是否相交。通过把模型位置存储在一个叫做场景图的数据结构中可以避免很多的测试。
场景图辅助剔除
场景图(scene graph)是一个使用树形数据结构的几何对象层次组织。在这个树形结构中有一个根元素。在这个数据结构中,这个根元素在概念上是其包含的其他元素的“父”。每个根元素的“子”也可能是其他元素的“父”。在Cocoa Touch应用中的视图层次结构就是2D UIView实例的场景图,参见苹果的帮助文档: http://developer.apple.com/library/ios/documentation/General/Conceptual/Devpedia-CocoaApp/View%20Hierarchy.html。同样的概念也适用于3D对象的层次结构。由于“父”元素在空间上包含它的所有子元素,因此只测试父元素是否与平截体相交就足够了。如果父元素在平截体外部,那么根据定义所有它的子元素也在平截体外部,因此没有必要单独地测试每个子元素。
维基百科上有关于场景图的一个详细解释,地址为: http://en.wikipedia.org/wiki/Scene_graph。 高效图形应用通常使用一个叫做八叉树(octree) 的相关数据结构,详见 http://en.wikipedia.org/wiki/Octree。本书中没一个例子有足够的对象以受益于完全基于八叉树的场景图。但是,在第10章中的例子OpenGLES_Ch10_1在绘制一个由丘陵 和山谷组成的模拟地形时,会使用一个简单形式的用于剔除的空间优化。地形本身是一个类似第6章中的网格的大网格。
例子OpenGLES_Ch10_1的地形网格包含的顶点超过了一次glDrawElements()函数调用能够访问的最大量。因此,需要把这个地形网格分割成“ 瓦片(tiles)”, 每个瓦片 引用这些顶点的一个子集。瓦片足够小,因而可以使用glDrawElements()函数来绘制,多个瓦片共同组成一个简单的场景图。模型和其他的几何对象成为位于瓦片内的子元 素。如果一个瓦片没有与平截体相交,那么瓦片内的对象也是不可见的。
9.1.2 简化
如果你无法避免绘制一些东西,那就让这个绘制保持简单。使用类似公告牌的特效可以减少传给GPU的几何对象的复杂性。减少网格中的顶点数量。在保证渲染质量的情况下使用尽可能小的纹理,考虑使用第3章介绍的压缩纹理。使用第3章介绍的多重纹理来避免多通道渲染。如果可能的话,多重纹理也是应该避免的。使用在第4章介绍的烘焙灯光来避免GPU的灯光计算。灯光需要GPU执行一些计算代价很高的操作。下一章的例子OpenGLES_Ch10_1会为地形网格使用灯光预计算以减轻GPU的负担。
当你花时间做优化时,剔除和简化总是能产生最好的回报。当渲染对象时,再怎么提高效率也不如渲染更少的对象节省的时间多。再怎么优化灯光效果也不如完全不做灯 光计算节省的时间多。