输入装配阶段
图元拓扑
顶点是以一个叫做顶点缓冲区的Direct3D数据结构的形式绑定到图形管线的。顶点缓冲区只是在连续的内存中存储了一个顶点列表。它并没有说明以何种方式组织顶点,形成几何图元。例如,是应该把顶点缓冲区中的每两个顶点解释为一条直线,还是应该把顶点缓冲区中的每三个顶点解释为一个三角形?我们通过指定图元拓扑来告诉Direct3D以何种方式组成几何图元:
void ID3D11Device::IASetPrimitiveTopology(
D3D11_PRIMITIVE_TOPOLOGY Topology);
typedef enum D3D11_PRIMITIVE_TOPOLOGY
{
D3D11_PRIMITIVE_TOPOLOGY_UNDEFINED = 0,
D3D11_PRIMITIVE_TOPOLOGY_POINTLIST = 1,
D3D11_PRIMITIVE_TOPOLOGY_LINELIST = 2,
D3D11_PRIMITIVE_TOPOLOGY_LINESTRIP = 3,
D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST = 4,
D3D11_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP = 5,
D3D11_PRIMITIVE_TOPOLOGY_LINELIST_ADJ = 10,
D3D11_PRIMITIVE_TOPOLOGY_LINESTRIP_ADJ = 11,
D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST_ADJ = 12,
D3D11_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP_ADJ= 13,
D3D11_PRIMITIVE_TOPOLOGY_1_CONTROL_POINT_PATCHLIST = 33,
D3D11_PRIMITIVE_TOPOLOGY_2_CONTROL_POINT_PATCHLIST = 34,
.
.
.
D3D11_PRIMITIVE_TOPOLOGY_32_CONTROL_POINT_PATCHLIST = 64,
} D3D11_PRIMITIVE_TOPOLOGY;
所有的绘图操作以当前设置的图元拓扑方式为准。在没有改变拓扑方式之前,当前设置的拓扑方式会一直有效。下面的代码说明了一点:
md3dDevice->IASetPrimitiveTopology(
D3D11_PRIMITIVE_TOPOLOGY_LINELIST);
/* ...draw objects using line list... */
md3dDevice->IASetPrimitiveTopology(
D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
/* ...draw objects using triangle list... */
md3dDevice->IASetPrimitiveTopology(
D3D11_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP);
/* ...draw objects using triangle strip... */
索引
三角形是构成3D物体的基本单位。下面的代码示范了使用三角形列表来构建四边形和八边形的顶点数组(即,每三个顶点构成一个三角形)。
Vertex quad[6] ={
v0, v1, v2, // Triangle0
v0, v2, v3, // Triangle1
};
Vertex octagon[24] ={
v0, v1, v2, // Triangle0
v0, v2, v3, // Triangle1
v0, v3, v4, // Triangle2
v0, v4, v5, // Triangle3
v0, v5, v6, // Triangle4
v0, v6, v7, // Triangle5
v0, v7, v8, // Triangle6
v0, v8, v1 // Triangle 7
};
注意:三角形的顶点顺序非常重要,我们将该顺序称为环绕顺序(winding order);
如图5.15所示,构成3D物体的三角形会共享许多相同的顶点。更确切地说,在图5.15a中,四边形的每个三角形都会共享顶点v0和v2。当复制两个顶点时问题并不明显,但是在八边形的例子中问题就比较明显了(图5.15b),八边形的每个三角形都会复制中间的顶点v0,而且边缘上的每个顶点都由相邻的两个三角形共享。通常,复制顶点的数量会随着模型细节和复杂性的提高而骤然上升。
图 5.15 (a)由2个三角形构成的四边形。(b)由8个三角形构成的八边形。
我们不希望对顶点进行复制,主要有两个原因:
1. 增加内存需求量。(为什么要多次存储相同的顶点数据?)
2. 增加图形硬件的处理负担。(为什么要多次处理相同的顶点数据?)
三角形带在一定程度上可以解决复制顶点问题,但是几何体必须按照带状方式组织,实现起来难度较大。相比之下,三角形列表具有更好的灵活性(三角形不必彼此相连),如果能找到一种方法,即移除复制顶点,又保留三角形列表的灵活性,那么会是一件非常有价值的事情。索引(index)可以解决一问题。它的工作原理是:我们创建一个顶点列表和一个索引列表。顶点列表包含所有唯一的顶点,而索引列表包含指向顶点列表的索引值,这些索引定义了顶点以何种方式组成三角形。回顾图5.15中的图形,四边形的顶点列表可以这样创建:
Vertex v[4] = {v0, v1, v2, v3};
而索引列表需要定义如何将顶点列表中的顶点放在一起,构成两个三角形。
UINT indexList[6] = {0, 1, 2, // Triangle0
0, 2, 3}; // Triangle 1
顶点列表中的唯一顶点得到处理之后,显卡可以使用索引列表把顶点放在一起构成三角形。我们将“复制问题”转嫁给了索引列表,但是这种复制是可以让人接受的。因为:
1. 索引是简单的整数,不像顶点结构体那样占用很多内存(顶点结构体包含的分量越多,占用的内存就越多)。
2. 通过适当的顶点缓存排序,图形硬件不必重复处理顶点(在绝大多数的情况下)。
顶点着色器阶段
在完成图元装配后,顶点将被送往顶点着色器(vertex shader)阶段。顶点着色器可以被看成是一个以顶点作为输入输出数据的函数。每个将要绘制的顶点都会通过顶点着色器推送至硬件;实际上,我们可以概念性地认为在硬件上执行了如下代码:
for(UINT i = 0; i < numVertices; ++i)
outputVertex[i] = VertexShader(inputVertex[i]);
顶点着色器函数由我们自己编写,但是它会在GPU上运行,所以执行速度非常快。
许多效果,比如变换(transformation)、光照(lighting)和置换贴图映射(displacement mapping)都是由顶点着色器来实现的。记住,在顶点着色器中,我们不仅可以访问输入的顶点数据,也可以访问在内存中的纹理和其他数据,比如变换矩阵和场景灯光。
我们将会在本书中看到许多不同的顶点着色器示例;当读完本书时,读者会对顶点着色器的功能有一个全面的认识。不过,我们的第一个示例会比较简单,只是用顶点着色器实现顶点变换。在随后的小节中,我们将讲解各种常用的变换算法。
根据模型自身的局部坐标系定义模型,有以下几点好处:
1.简单易用。比如,在局部坐标系中,坐标系的原点通常会与物体的中心对齐,而某个主轴可能正是物体的对称轴。又如,当我们使用局部坐标系时,由于坐标系的原点与立方体的中心对齐,坐标轴垂直于立方体表面,所以可以更容易地描述立方体的顶点(参见图5.17)。
图5.17 当立方体的中心位于坐标系原点且与轴对齐(axis-aligned)时,可以很容易地描述立方体的顶点。当立方体位于坐标系的任意一个位置和方向上时,就很难描述这些坐标了。所以,当我们创建模型时,总是选择一种与物体位置接近且与物体方向对齐的实用坐标系。
2.物体可以在多个场景中重复使用,对物体坐标进行相对于特定场景的硬编码是毫无意义的事情。较好的做法是:在局部坐标系中存储物体坐标,通过坐标转换矩阵将物体从局部坐标系变换到世界坐标系,建立物体与场景之间的联系。
3.最后,有时我们会多次绘制相同的物体,只是物体的位置、方向和大小有所不同(比如,将一棵树重绘多次形成一片森林)。在这种情况下,我们只需要一个相对于局部坐标系的单个副本,而不是多次复制物体数据,为每个实例创建一个副本。当绘制物体时,我们为每个物体指定不同的世界矩阵,改变它们在世界空间中的位置、方向和大小。这种方法叫做instancing。
世界矩阵描述的是一个物体的局部空间相对于世界空间的原点位置和坐标轴方向,这些坐标可以存放在一个行矩阵中。设Qw = (Qx,Qy ,Qz ,1)、uw= (ux ,uy ,uz ,0)、vw= (vx ,vy ,vz ,0)、ww = (wx ,wy ,wz ,0)分别表示局部空间相对于世界空间的原点、x轴、y轴、z轴的齐次坐标,由3.4.3节可知,从局部空间到世界空间的坐标转换矩阵为:
为了生成场景的2D图像,我们必须在场景中放置一架虚拟摄像机。虚拟摄像机指定了观察者可以看到的场景范围,或者说是我们所要生成的2D图像所显示的场景范围。我们把一个局部坐标系(称为观察空间、视觉空间或摄像机空间)附加在摄像机上,如图5.19所示;该坐标系以摄像机的位置为原点,以摄像机的观察方向为z轴正方向,以摄像机的右侧为x轴,以摄像机的上方为y轴。在渲染管线的随后阶段中,使用观察空间来描述顶点比使用世界空间来描述顶点要方便的多。从世界空间到观察空间的坐标转换称为观察变换(view transform),相应的矩阵称为观察矩阵(view matrix)。
图5.19 将相对于世界空间的顶点坐标转换为相对于摄像机空间的顶点坐标。
设Qw = (Qx,Qy ,Qz ,1)、uw= (ux ,uy ,uz ,0)、vw= (vx ,vy ,vz ,0)、ww = (wx ,wy ,wz ,0)分别表示观察空间相对于世界空间的原点、x轴、y轴、z轴的齐次坐标,我们由3.4.3节可知,从观察空间到世界空间的坐标转换矩阵为:
不过,这不是我们想要的结果。我们希望得到的是从世界空间到观察空间的反向变换。回顾3.4.5节可知,反向变换可由逆运算取得。也就是,W-1为世界空间到观察空间的变换矩阵。
世界坐标系和观察坐标系通常具有不同的位置和方向,所以凭直觉就可以知道W = RT的含义(即,世界矩阵可以被分解为一个旋转矩阵和一个平移矩阵)。这种方式可以使逆矩阵的计算过程更简单一些:
我们现在介绍一种更直观的方法来创建构成观察矩阵的向量。设Q为摄像机的位置,T为摄像机瞄准的目标点,j为描述世界空间“向上”方向的单位向量。参考图5.20,摄像机的观察方向为:
图5.20 通过指定摄像机的位置、目标点和世界“向上”向量来创建摄像机坐标系。
向量w描述的是摄像机坐标系的z轴。指向w“右边”的单位向量为:
向量u描述的是摄像机坐标系的x轴。最后,描述摄像机坐标系y轴的向量为:
由于w和u是相互垂直的单位向量,所以w×u必定为单位向量,不需要对它做规范化处理。
这样,给出摄像机的位置、目标点和世界“向上”向量,我们就能够得到摄像机的局部坐标系,该坐标系可以用于创建观察矩阵。
XNA库提供了如下函数,根据刚才描述的过程计算观察矩阵:
XMMATRIX XMMatrixLookAtLH( // Outputs resulting view matrix V
FXMVECTOR EyePosition, // Input camera position Q
FXMVECTOR FocusPosition, // Input target point T
FXMVECTOR UpDirection); // Input world up vector j
``
通常,世界坐标系的y轴就是“向上”方向,所以“向上”向量j通常设为(0, 1,0)。举一个例子,假设摄像机相对于世界空间的位置为(5, 3, −10),目标点为世界原点(0, 0,0)。我们可以使用如下代码创建观察矩阵:
XMVECTOR pos = XMVectorSet(5,3,-10,1.0f);
XMVECTOR target = XMVectorZero();
XMVECTOR up = XMVectorSet(0.0f,1.0f,0.0f,0.0f);
XMMATRIXV = XMMatrixLookAtLH(pos,target,up);
“`
投影与齐次裁剪空间
到目前为止,我们已经知道了如何在场景中描述摄像机的位置和方向,下面我们来讲解如何描述摄像机所能看到的空间范围。该范围通过一个平截头体(frustum)来描述(图5.21),它是一个在近平面处削去尖部的棱锥体。
图5.21 平截头体描述了摄像机可以“看到”的空间范围。
我们的下一个任务是把平截头体内的3D物体投影到2D投影窗口上。投影(projection)必须按照平行线汇集为零点的方式来实现,随着一个物体的3D深度增加,它的投影尺寸会越来越小;图5.22说明了透视投影的实现过程。我们将“从顶点连向观察点的直线”称为顶点的投影线。然后我们可以定义透视投影变换,将3D顶点v变换到它的投影线与2D投影平面相交的点vʹ上;我们将vʹ称为v的投影。对一个3D物体的投影就是对组成该物体的所有顶点的投影。
图5.22 在3D空间中,大小相同、深度不同的两个圆柱体。与观察点距离较近的圆柱体生成的投影较大。在平截头体内的物体可以被映射到投影窗口上;在平截头体外的物体可以映射到投影平面上,但是不会映射到投影窗口上。
定义平截头体
我们可以在观察空间中使用如下4个参数来定义以原点为投影中心、以z轴正方向为观察方向的平截头体:近平面n、远平面f、垂直视域角α和横纵比r。注意,在观察空间中,近平面和远平面都平行于xy平面;所以,我们只需要简单地指定它们沿z轴方向到原点之间的距离即可表示这两个平面。横纵比由r = w/ℎ定义,其中w表示投影窗口的宽度,ℎ表示投影窗口的高度(单位由观察空间决定)。投影窗口本质上是指场景在观察空间中的2D 图像。该图像最终会被映射到后台缓冲区中;所以,我们希望投影窗口的尺寸比例与后台缓冲区的尺寸比例保持相同。在大多数情况下,横纵比就是指后台缓冲区的尺寸比例(它是一个比例值,所以没有单位)。例如,当后台缓冲区的尺寸为800×600时,横纵比r = 800/600 ≈ 1.333。如果投影窗口的横纵比与后台缓冲区的横纵比不同,那么当投影窗口映射到后台缓冲区时,必然会出现比例失衡,导致图像变形(例如,投影窗口中的一个正圆会被拉伸为后台缓冲区中的一个椭圆)。
将水平视域角设为β,它是由垂直视域角α和横纵比r决定的。考虑图5.23,分析一下如何通过α、r来求解β。注意,投影窗口的实际尺寸并不重要,重要的只是横纵比。所以,我们将高度设定为2,则对应的宽度为:
r=w/h =w/2 ⟹ w=2r
图5.23 给出垂直视域角α和横纵比r,求解水平视域角β。
为了获得指定的垂直视域角α,投影窗口必须放在与原点距离为d的位置上:
tan(α/2) = 1/d ⟹ d = cot(α/2)
观察图5.23中的xz平面,可知:
tan(β/2) = r/d = r / cot(α/2) = r•tan(α/2)
所以,只要给出垂直视域角α和横纵比r,我们就能求出水平视域角β。
β = 2tan-1(r•tan(α/2) )
对顶点进行投影
参见图5.24。给出一个点 (x, y, z),求它在投影平面z = d上的投影点 (xʹ, yʹ, d)。通过分析x、y坐标以及使用相似三角形,我们可以求出:
xʹ/d =x/z ⟹ xʹ = xd/z = x cot(α/2) /z = x/ztan(α/2)
和
yʹ/d =y/z ⟹ yʹ = yd/z = y cot(α/2)/ z = y/ztan(α/2)
当且仅当以下条件成立时,点(x, y , z)在平截头体内。
−r ≤ xʹ ≤ r
−1 ≤ yʹ ≤ 1
n ≤ z ≤ f
规范化设备坐标(NDC)
上一节我们讲解了如何在观察空间中计算点的投影坐标。在观察空间中,投影窗口的高度为2,宽度为2r,其中r表示横纵比。这里存在的一个问题是:尺寸依赖于横纵比。这意味着我们必须为硬件指定横纵比,否则硬件将无法执行那些与投影窗口尺寸相关的运算(比如,将投影窗口映射到后台缓冲区)。如果我们能去除对横纵比的依赖性,那么会使相关的运算变得更加简单。为了解决一问题,我们将的投影x坐标从[−r , r] 区间缩放到[−1,1]区间:
−r ≤ xʹ ≤ r
−1 ≤ xʹ ⁄r ≤ 1
在映射之后,x、y坐标称为规范化设备坐标(normalized device coordinates,简称NDC)(z坐标还没有被规范化)。当且仅当以下条件成立时,点(x ,y, z)在平截头体内。
−1 ≤ xʹ⁄r ≤ 1
−1 ≤ yʹ ≤ 1
n ≤ z ≤ f
从观察空间到NDC空间的变换可以看成是一个单位转换。我们有这样一个关系式:在 x轴上的一个NDC单位等于观察空间中的r个单位(即,1 ndc = r vs)。所以给出x观察空间单位,我们可以使用这个关系式来转换单位:
x vs 1ndsrvs1ndsrvs = xrxr ndc
我们可以修改之前的投影公式,直接使用NDC空间中的x、y投影坐标:
x′=xrztanα2x′=xrztanα2
y′=yztanα2y′=yztanα2(方程5.1)
注意:在NDC空间中,投影窗口的高度和宽度都为2。也就是说,现在的尺寸是固定的,硬件不需要知道横纵比,但是我们必须自己来完成投影坐标从观察空间到NDC空间的转换工作(图形硬件假定我们会完成一工作) 。
用矩阵来描述投影方程
为了保持一致,我们将用一个矩阵来描述投影变换。不过,方程5.1是非线性的,无法用矩阵描述。所以我们要使用一种“技巧”将它分为两部分来实现:一个线性部分和一个非线性部分。非线性部分要除以z。我们会在下一节讨论“如何规范化z坐标”时讲解这一问题;现在读者只需要知道,我们会因为个除法操作而失去原始的z坐标。所以,我们必须在变换之前保存输入的z坐标;我们可以利用齐次坐标来解决一问题,将输入的z坐标复制给输出的w坐标。在矩阵乘法中,我们要将元素[2][3]设为1、元素[3][3]设为0(从0开始的索引)。我们的投影矩阵大致如下:
注意矩阵中的常量A和B(它们将在下一节讨论);这些常量用于把输入的z坐标变换到规范化区间。将一个任意点(x , y, z,1)与该矩阵相乘,可以得到
在与投影矩阵(线性部分)相乘之后,我们要将每个坐标除以w = z(非线性部分),得到最终的变换结果:
顺便提一句,你可能会问:“如何处理除数为0的情况”;对于一问题我们不必担心,因为近平面总是大于0的,其他的点都会被裁剪掉(参见5.9节)。有时,与w相除的过程也称为透视除法(perspective divide)或齐次除法(homogeneous divide)。我们可以看到x、y的投影坐标与方程5.1相同。