3.4 渲染器核心(一)
渲染系统背后的设计驱动力是对一个通用和强大的几何图元绘制函数的需求。这个函数是Renderer::Draw(Geometry*)。它必须支持下面:
#允许任何拓扑形式的图元,例如点,折线,三角形网格。
#允许图元重写任何当前起作用的全局渲染状态。
#在几何管线里设置变换,不管是用透视还是正交投影。
#当光照出现在场景中时,支持动态光照。这些光照由用户编写在着色程序里面,为程序添加任意特定的使用。
#允许几何图元拥有一个或多个特效。每个效果实现一个单通或多通绘制操作。
达到这些目标就是渲染系统的核心。
在Wild Magic的版本3或之前的版本里,几何图元需要拥有单一的效果附加在它们上面,每个效果实现一个单通的绘制操作。增加一个多通效果到引擎里需要你创建一个Effect对象,并且为那个效果添加一个新的纯虚函数到Renderer。每个渲染器继承类得去实现这个虚函数。与引擎一起运送的实例,包括凹凸贴图,球面环境贴图,投射阴影和平面反射。这种方法的问题是,为引擎添加多通特效相当不灵活;就是说,当想要增加新效果时,引擎是不容易扩展的。此外,渲染系统可能经常改变。理想的是拥有一个核心渲染系统,在不用修改渲染器的前提下,这个系统支持多通操作和每个图元多个效果。
Wild Magic的版本4实现如此一个渲染系统。版本4之前,一个Effect继承类保存了Renderer函数指针,这个函数的工作是使用那个效果来绘制几何图元。现在角色反转了。如果一个Effect继承类有多通绘制的特殊需求,那么它要实现一个Draw函数,而这个函数由Renderer调用。这个绘制函数由核心渲染系统调用,并且它会调用Renderer函数,如果需要的话。这章用一种从上到下的方法来详细讲述渲染系统,这会激发一个问题,为什么你会需要沿路遇到的每个子系统。
3.4.1 绘制场景
组织需要绘制的物体的顶层数据结构是一个场景图。为了这节的目的,你只要认为场景图是一棵节点树,这棵树的叶子节点表示几何图元,内部节点表示基于邻近空间的图元组。关于场景图的更多细节可以在第四章找到。在程序运行期间,场景图传递给剔除系统来产生潜在可见物体集。每个集合都传递给渲染器绘制。绘制的顶层函数是
void Renderer::DrawScene( VisibleSet& );
在这个最简单的层次,潜在可见集是一个几何图元的集合,这些图元的顺序是由剔除器深度优先遍历场景决定的。DrawScene遍历这个集合,为每个物体调用Renderer::Draw(Geometry*)。
尖端的效果,然而,需要一个比只是遍历几何图元更复杂的系统。例如,投射平面阴影有阴影投射器(例如,一个双足角色)和阴影接收平面的概念。多通的实现必须修改场景的相关部分。阴影是从光线发射器的角度产生的,但阴影投射器必须从摄像机的角度绘制。一个附加在由场景叶子节点表示的几何图元上面的效果,被称作局部效果。诸如投射阴影这样的效果附加在一组图元集合上,这些图元集合由场景里的子树表示。子树的根节点就是效果所附着的物体。这样,子树就在效果的范围之内。这类型效果被称作全局效果。
剔除器实质上将场景图变平为一列需要绘制的物体。当一个或多个全局效果存在的时候,列表必须包含额外的信息去帮助渲染器控制怎样绘制列表里的物体。图3.7显示一个场景图有多个全局效果。接下来的讨论解释全局效果是怎样保存在列表里面。
Index
|
0
|
1
|
2
|
3
|
4
|
5
|
6
|
7
|
8
|
9
|
10
|
11
|
12
|
13
|
14
|
Object
|
X0
|
A
|
Y0
|
B
|
C
|
D
|
Y1
|
E
|
F
|
Z0
|
G
|
H
|
Z1
|
X1
|
I
|
接下来以数组的形式列出由剔除器计算的潜在可见集。下标为0的全局效果指示效果范围的起点。下标为1的全局效果指示效果范围的终点。例如,B,C和D物体在Y0和Y1之间,因此它们受全局效果Y影响。它们同样也在X0和X1之间,因此也受全局效果X影响。集合元素Xi,Yi和Zi是哨兵,不是可绘制物体。这些哨兵是渲染器用来控制可绘制物体的渲染。
DrawScene遍历潜在可见集,传递物体子集到全局效果的Draw函数。接下来的步骤用伪代码列出。
globalEffectStack = {};
startIndexStack = {};
globalEffectStack = {X}; // found X0,push
startIndexStack = {1}; // push first index scope X
finalIndex = 1; // last index scope X
globalEffectStack = {Y,X}; // found Y0,push
startIndexStack = {3,1}; // push first index scope Y
finalIndex = 3; // last index scope Y
finalIndex = 4; // last index scope Y
finalIndex = 5; // last index scope Y
globalEffectStack = {X}; // found Y1,pop
startIndex = 3; // pop startIndexStack
startIndexStack = {1}; // …
Y.Draw(startIndex,finalIndex); // draw objects 3 to 5
finalIndex = 7; // last index scope X
finalIndex = 8; // last index scope X
globalEffectStack = {Z,X}; // found Z0,push
startIndexStack = {10,1}; // push first index scope Z
finalIndex = 11; // last index scope Z
globalEffectStack = {X}; // found Z1,pop
startIndex = 10; // pop startIndexStack
startIndexStack = {1}; // …
Z.Draw(startIndex,finalIndex); // draw objects 10 and 11
globalEffectStack = {}; // found X1,pop
startIndex = 1; // pop startIndexStack
startIndexStack = {}; // …
X.Draw(startIndex,finalIndex); // draw objects 1 to 11
finalIndex = 14; // no global scope
Draw(finalIndex); // draw object 14(local)
3.4.2 绘制几何图元
Renderer::Draw(Geometry*)函数接下来列出。我会逐块解释。
void Renderer::Draw(Geometry* pkGeometry)
{
m_pkGeometry = pkGeometry;
SetGlobalState( m_pkGeometry->State );
SetWorldTransformation();
EnableIBuffer();
bool bPrimaryEffect = true;
if( m_pkGeometry->LEffect )
{
ApplyEffect( m_pkGeometry->LEffect, bPrimaryEffect );
}
const int iEffectQuantity = m_pkGeometry->GetEffectQuantity();
for( int iEffect=0; iEffect<iEffectQuantity; iEffect++ )
{
ShaderEffect* pkEffect =
DynamicCast<ShaderEffect*>(m_pkGeometry->GetEffect(iEffect));
ApplyEffect( pkEffect, bPrimaryEffect );
}
DisableIBuffer();
RestoreWorldTransformation();
RestoreGlobalState( m_pkGeometry->States );
m_pkGeometry = 0;
}
一个成员指针,m_pkGeometry,用作临时引用正在绘制的几何图元。这仅仅是为了方便。设置矩阵渲染器常量的函数需要访问图元相关的变换。顶点缓存的启用同样需要访问图元来取得它的顶点缓存。
渲染器维护着一组起作用的全局状态(alpha混合,深度缓存,材质等等)。几何图元可能需要重写部分状态。调用SetGlobalStates来进行重写。调用RestoreGlobalStates来恢复绘制指令开始调用之前起作用的全局状态。
几何图元保存它自己的模型空间到世界空间的变换。SetWorldTransformation传递这些信息给图形API,这样,它们可以设置它们的内部矩阵。RestoreWorldTransformation恢复内部矩阵到绘制指令调用之前的值。
几何图元的拓扑结构(例如索引数组)不会在绘制指令调用期间改变,并且独立于绘制通道数目。几何图元的索引缓存通过调用图形API EnableIBuffer来加载(如果有需要)和启用。绘制之后,索引缓存通过调用DisableIBuffer来禁用。
如果几何图元受到场景里的光照影响,独立于附加在图元上的着色效果,光照会首先应用在图元上。当需要动态光照时,数据成员LEffect为非空。通过调用ApplyEffect,效果被来应用到图元上。当多个效果必须应用到一个图元上时,第一个效果总是被应用,这样颜色缓存值会被效果颜色替代。然而,第一个之后的其它效果必须混合到颜色缓存。使效果知道这些的唯一方法是传递给它一个布尔标志,bPrimaryEffect,这个标志让它知道它是否是第一个效果(主要效果)。
直接附加在几何图元上的特效一次只能应用一个。这些特效必定是ShaderEffect对象,它们直接使用着色程序。全局效果继承自基类Effect。通过调用ApplyEffect,每个效果被应用到图元上。布尔标志bPrimaryEffect让每个效果知道它自己是否是第一个绘制的效果。
3.4.3 应用一个效果
Renderer::ApplyEffect封装了这节前面部分描述的大多数资源管理。源代码在下面。我会逐一解释。
void Renderer::ApplyEffect( ShaderEffect* pkEffect, bool& rbPrimaryEffect )
{
const int iPassQuantity = pkEffect->GetPassQuantity();
for( int iPass=0; iPass<iPassQuantity; iPass++ )
{
pkEffect->LoadProgram( iPass, m_iMaxColors, m_iMaxTCoords,
m_iMaxVShaderImages, m_iMaxPShaderImages );
pkEffect->SetGlobalState( iPass, this, rbPrimaryEffect );
VertexProgram* pkVProgram = pkEffect->GetVProgram( iPass );
EnableVProgram( pkVProgram );
PixelProgram* pkPProgram = pkEffect->GetPProgram( iPass );
EnablePProgram( pkPProgram );
const int iPTQuantity = pkEffect->GetPTextureQuantity( iPass );
int iTexture;
for( iTexture=0; iTexture<iPTQuantity; iTexture++ )
{
EnableTexture( pkEffect->GetPTexture( iPass, iTexture ) );
}
const Attributes& rkIAttr = pkVProgram->GetInputAttributes();
ResourceIdentifier* pkId = EnableVBuffer( iPass, rkIAttr );
DrawElements();
DisableVBuffer( iPass, pkId );
for( iTexture=0; iTexture<iPTQuantity; iTexture++ )
{
DisableTexture( pkEffect->GetPTexture( iPass, iTexture ) );
}
DisablePProgram( pkPProgram );
DisableVProgram( pkVProgram );
pkEffect->RestoreGlobalState( iPass, this, rbPrimaryEffect );
}
rbPrimaryEffect = false;
}
这个函数设计为支持多通操作。通道数目读入到iPassQuantity,如果为1就是单通效果。让我们看看每通发生了什么。
循环里的第一个语句使效果为特定通道加载着色程序。着色程序必须首先被加载,因为(1)这通的顶点缓存的启用基于着色程序的输入,(2)第二个语句的全局状态的设置需要访问着色程序相关的采样器。资源限制传递给LoadProgram函数,因为着色程序是编写为渲染器类型和平台独立的。准备加载的着色程序可能需要比当前渲染器能提供的更多资源。在这种情况下,为了处理这个问题需要在效果方面执行一些措施。我现在的抉择是仅仅使用默认着色程序。一个商业引擎应该提供一些回调机制给着色器,使着色器能产生相似的效果,可能低质量,但在渲染器有限资源内应该可以获得。
着色程序加载之后,效果有一个设置全局渲染状态的机会。最一般的操作是设置alpha混合函数,特别当效果不是主要效果的时候。注意布尔标志bPrimaryEffect是传递给效果的SetGlobalState函数,这样它就可以决定是否需要启用alpha混合。全局状态必须在启用着色程序之前设置,因为着色程序需要为即将启用的采样器设置采样器状态。
接下来,顶点和像素程序被启用(和第一时间加载到显存,如果有需要)。任何效果相关的纹理被启用。这些可以每通指定。相似的,这个特定通道的几何图元相关的顶点缓存也被启用。
一旦所有资源都加载和启用,我们准备通知图形API绘制几何图元。这发生在调用DrawElements的时候。DrawElements的OpenGL和Direct3D实现非常短,但它们隐藏了许多细节。这些细节在3.1章以软件渲染的方式讨论了。
绘制之后,所有的资源被禁用,全局状态恢复到它之前的状态,并且效果处理完成。