原文:Introduction to 3D Game Programming with DirectX 12 学习笔记之 --- 第五章:渲染流水线
学习目标
- 了解几个用以表达真实场景的标志和2D图像的深度空间;
- 学习在Direct3D中如何表示3D物体;
- 学习如何模拟虚拟摄像机;
- 理解渲染流水线:如何用几何描述的3D场景渲染出2D图像;
1 3D幻觉
如何在2D平面(显示器)上产生3D场景的幻觉:
1、根据与摄像机的距离缩放;
2、遮挡关系;
3、光照;
4、阴影
2 模型的表示
一个模型是由三角形网格近似模拟,常用的建模工具有:
3D Studio Max (http://usa.autodesk.com/3ds-max/)
LightWave 3D (https://www.lightwave3d.com/)
Maya (http://usa.autodesk.com/maya/)
Softimage|XSI (www.softimage.com)
Blender (www.blender.org/) 对业余爱好者比较好,因为它开源并且免费
3 颜色计算基础
计算机显示器通过发射红绿蓝混合的光线,在人眼中产生颜色:
其每种光线的强度在0~1之间。
3.1 颜色的运算
部分向量的运算也可以应用到颜色的运算,比如向量的加法:
(0.0, 0.5, 0) + (0, 0.0, 0.25) = (0.0, 0.5, 0.25)
向量的减法:
(1, 1, 1) – (1, 1, 0) = (0, 0, 1)
标量相乘:
0.5(1, 1, 1) = (0.5, 0.5, 0.5)
但是向量的点积和叉积都不能用于颜色的运算,颜色相乘有自己的计算公式:分量相乘(modulation or componentwise multiplication):
这个运算主要用以光照计算中。
颜色值在进行运算后可能会超出0~1的范围,需要把他们固定到0到1之间(大于1的等于1,小于0的等于0)。
3.2 128位的颜色
它在基本颜色上增加了一个透明度组件(alpha component),可以用一个4D向量来表示,所以就可以使用XMVECTOR类型来表示,这样就可以使用DirectX数学库函数利用SIMD操作的好处进行加减和标量相乘;对于分量相乘运算,DirectX数学库中提供下面的方法:
XMVECTOR XM_CALLCONV XMColorModulate( // Returns c1 ⊗ c2
FXMVECTOR C1,
FXMVECTOR C2);
3.3 32位颜色
在32位颜色中,每个字节表示一个颜色组件,所以每个颜色有255种不同的值。在DirectX数学库中((#include
<DirectXPackedVector.h>)提供了下面的结构来保存32位颜色:
namespace DirectX
{
namespace PackedVector
{
// ARGB Color; 8-8-8-8 bit unsigned normalized integer components packed
// into a 32 bit integer. The normalized color is packed into 32 bits
// using 8 bit unsigned, normalized integers for the alpha, red, green,
// and blue components.
// The alpha component is stored in the most significant bits and the
// blue component in the least significant bits (A8R8G8B8):
// [32] aaaaaaaa rrrrrrrr gggggggg bbbbbbbb [0]
struct XMCOLOR
{
union
{
struct
{
uint8_t b; // Blue: 0/255 to 255/255
uint8_t g; // Green: 0/255 to 255/255
uint8_t r; // Red: 0/255 to 255/255
uint8_t a; // Alpha: 0/255 to 255/255
};
uint32_t c;
};
XMCOLOR() {}
XMCOLOR(uint32_t Color) : c(Color) {}
XMCOLOR(float _r, float _g, float _b, float _a);
explicit XMCOLOR(_In_reads_(4) const float *pArray);
operator uint32_t () const { return c; }
XMCOLOR& operator= (const XMCOLOR& Color) { c = Color.c; return *this; }
XMCOLOR& operator= (const uint32_t Color) { c = Color; return *this; }
};
} // end PackedVector namespace
} // end DirectX namespace
DirectX数学库提供了XMCOLOR转化为XMVECTOR的函数:
XMVECTOR XM_CALLCONV PackedVector::XMLoadColor(
const XMCOLOR* pSource);
同时也提供了XMVECTOR转化为XMCOLOR的函数:
void XM_CALLCONV PackedVector::XMStoreColor(
XMCOLOR* pDestination,
FXMVECTOR V);
通常情况下,128位颜色用来进行高精度的计算,这样算法上的错误积累就少很多;最终颜色一般保存为32位(在back buffer中也通常是32位),当前的物理显示设备无法体现128位颜色的好处。
4 概述渲染流水线
箭头的指向代表可以读取/写入资源:
5 输入装配阶段
输入装配阶段(IA)从内存中读取数据(顶点和索引)来装配称几何基元(三角形、线段等)。
5.1 顶点
顶点不仅包含基本的位置信息,也可以包含其他数据比如法向量,UV值等。
5.2 基元的拓扑机构
顶点是通过顶点缓冲(vertex buffer)来绑定到渲染流水线的,顶点缓冲只是在一段连续的内存中保存一系列点点,它并不知道拓扑结构,所以就需要通过Direct3D指定拓扑结构:
void ID3D12GraphicsCommandList::IASetPrimitiveTopology( D3D_PRIMITIVE_TOPOLOGY Topology);
typedef enum D3D_PRIMITIVE_TOPOLOGY
{
D3D_PRIMITIVE_TOPOLOGY_UNDEFINED = 0,
D3D_PRIMITIVE_TOPOLOGY_POINTLIST = 1,
D3D_PRIMITIVE_TOPOLOGY_LINELIST = 2,
D3D_PRIMITIVE_TOPOLOGY_LINESTRIP = 3,
D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST = 4,
D3D_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP = 5,
D3D_PRIMITIVE_TOPOLOGY_LINELIST_ADJ = 10,
D3D_PRIMITIVE_TOPOLOGY_LINESTRIP_ADJ = 11,
D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST_ADJ = 12,
D3D_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP_ADJ = 13,
D3D_PRIMITIVE_TOPOLOGY_1_CONTROL_POINT_PATCHLIST = 33,
D3D_PRIMITIVE_TOPOLOGY_2_CONTROL_POINT_PATCHLIST = 34,
.
.
.
D3D_PRIMITIVE_TOPOLOGY_32_CONTROL_POINT_PATCHLIST = 64,
} D3D_PRIMITIVE_TOPOLOGY;
后续的绘制调用(drawing calls)都将使用当前的拓扑结构,直到拓扑结构被改变:
mCommandList->IASetPrimitiveTopology( D3D_PRIMITIVE_TOPOLOGY_LINELIST);
/* …draw objects using line list… */
mCommandList->IASetPrimitiveTopology( D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
/* …draw objects using triangle list… */
mCommandList->IASetPrimitiveTopology( D3D_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP);
/* …draw objects using triangle strip… */
5.2.1 Point List
由D3D_PRIMITIVE_TOPOLOGY_POINTLIST指定
5.2.2 Line Strip
由D3D_PRIMITIVE_TOPOLOGY_LINESTRIP指定
5.2.3 Line List
由D3D_PRIMITIVE_TOPOLOGY_LINELIST指定
5.2.4
由D3D_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP指定
5.2.5 Triangle List
由D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST指定
5.2.6 带有邻接的基元
比如带有邻接的三角形列表,它还包含相邻的三个三角形(adjacent triangles):
这个主要用以几何着色器(geometry shader),由D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST_ADJ指定。
5.2.7 Control Point Patch List
由D3D_PRIMITIVE_TOPOLOGY_N_CONTROL_POINT_PATCHLIST指定,主要用以曲面细分阶段(tessellation stage)。
5.3 索引
下面代码展示了一个顶点数组用来组成四边形和八边形:
Vertex quad[6] = {
v0, v1, v2, // Triangle 0
v0, v2, v3, // Triangle 1
};
Vertex octagon[24] = {
v0, v1, v2, // Triangle 0
v0, v2, v3, // Triangle 1
v0, v3, v4, // Triangle 2
v0, v4, v5, // Triangle 3
v0, v5, v6, // Triangle 4
v0, v6, v7, // Triangle 5
v0, v7, v8, // Triangle 6
v0, v8, v1 // Triangle 7
};
三角形组成的顺序非常重要,它被称之为winding order。
6 顶点着色器阶段
GPU会对每个顶点使用定义的顶点着色器执行一遍,概念上可以理解为下面代码:
for(UINT i = 0; i < numVertices; ++i)
outputVertex[i] = VertexShader( inputVertex[i] );
许多效果可以在顶点着色器中实现,比如:变换,光照,贴图置换等。它不仅可以访问顶点数据,还可以访问其他保存在GPU内存中的数据,比如:纹理,灯光数据等。
6.1 局部坐标系和全局坐标系
从局部坐标系转换到全局坐标系的矩阵操作称之为世界矩阵(world matrix),每个物体都有自己的世界矩阵。
根据之前第三章4.3节,变换矩阵可以写为:
U,V,W分别为世界坐标系的三个坐标轴方向的单位向量在局部坐标系的值,Q为原点坐标;但是在实际应用中向得到这些值比较麻烦和困难,所以可以采用另外一种做法:W = SRT:即在局部坐标系下,把物体缩放S,旋转R,位移T,它们的乘积得到W。例如:
还有另外一种思路,即假设局部坐标系和世界坐标系一致,那么世界矩阵就是一个单位矩阵,然后再对物体进行缩放,平移和旋转操作;从数学上讲,它们是一致的。
6.2 视图坐标系
为了生成2D图形,我们必须设置一个虚拟摄像机,如果为相机关联一个局部坐标系,那么相机看向负Z轴,X轴指向相机右方,Y轴指向相机上方;从世界坐标系变换到视图坐标系称之为视图变换,对于的矩阵称之为视图矩阵。
同样根据第三章4.3节,从视图坐标系到世界坐标系变换矩阵可以表示为:
然后去它的逆矩阵,得到视图矩阵:
还有另一种更直观的方法构建视图矩阵,如下图:
令Q是相机的位置,T是相机看向的点,j = (0, 1, 0),那么:
w=T−Q∣∣T−Q∣∣w = \frac{T - Q}{|| T - Q ||}w=∣∣T−Q∣∣T−Q
那么:
u=j×w∣∣j×w∣∣u = \frac{j \times w}{|| j \times w ||}u=∣∣j×w∣∣j×w
那么:
v=w×uv = w \times uv=w×u
至此u,v,w和Q都得到了,所以只要给出相机位置,瞄准方向和j即可得到视图矩阵。在DirectX数学库中提供了相面的函数根据上面的方法来计算视图矩阵:
XMMATRIX XM_CALLCONV XMMatrixLookAtLH( // Outputs view matrix V
FXMVECTOR EyePosition, // Input camera position Q
FXMVECTOR FocusPosition, // Input target point T
FXMVECTOR UpDirection); // Input world up direction 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);
XMMATRIX V = XMMatrixLookAtLH(pos, target, up);
6.3 透视投影和其次裁切空间
下图表示了相机可以看到的空间,是一个截头锥体:
下一步我们要将在截头锥体中的3D几何物体投射到2D投影平面上;如下图所示,这个变换我们定义为透视投影变换:
6.3.1 定义截头锥体
在视图坐标系下,设投影的中心在原点并看向负Z轴,那么截头锥体就可以通过下面4个变量来定义:
- *面n;
- 远平面f;
- 竖直方向的视图角度α;
- 宽高比r;
因为*面和远平面都是平行于xy平面的,所以可以简单的使用在Z轴方向上距离原点的距离来定义它们;宽高比根据投影窗口的宽高来计算r = w/h;投影窗口本质上时一个2D图像,它会被映射到后台缓冲,所以宽高比最好和后台缓冲保持一致。
在水平方向上的视图角度定义为β,它可以通过α和r来计算;投影窗口的大小本质上不重要,所以为了方便,我们将高度设置为2,那么就可以计算出宽度:
为了通过α指定竖直区域,投影窗口必须指定一个d代表它与原点的距离:
进而可以求出β:
所以:
6.3.2 投影顶点
给出点(x,y,z)(x, y, z)(x,y,z),我们希望求出它的投影点(x1,y1,z1)(x^1, y^1, z^1)(x1,y1,z1),我们可以根据下图使用相似三角形:
可以看出,一个点在截头锥体内部,当且仅当满足下面条件:
6.3.3 标准化的设备坐标(Normalized Device Coordinates (NDC))
现在需要把投影窗口的大小映射到设备大小,即从[−r, r] 到 [−1, 1]:
那么当且仅当下面条件成立,我们可以说已经标准化到了NDC:
变换到NDC也可以看成是一个单位的变化:
然后我们就可以修改投影公式:
6.3.4 利用矩阵表示投影公式
为了一致性,我们将使用矩阵来表示投影变换,上面得出的公式不是线性的,所以它没有一个矩阵表示;所以我们把它分成两部分,一部分是线性的,另一部分是非线性的,非线性部分是除以Z;所以我们保存变换前输入的Z值,根据其次坐标系的优点,我们可以让输出的W为保存的Z值,那么我们的变换矩阵看起来就像下面这样:
其中设置的A和B常数用以Z坐标系变换到标准范围;利用该矩阵乘以变量点(x, y, z, 1):
然后除以W值以后,完成变换:
在这里Z不会为0,因为*面Z一定大于0,Z轴等于0的点会被裁切掉;除以W的操作也被称为透视分割(perspective divide or homogeneous divide)。
6.3.5 标准化深度值
Direct3D要求深度数据在[0, 1]中,所以需要构建一个方程g(z)将深度数据从[n, f]映射到[0, 1];根据之前得到的方程:
带入*面时为0:g(n) = A + B/n = 0
带入远平面是为1:g(f) = A + B/f = 1
求出B = −An,所以可以求解方程:
那么
根据得出的公式可以画出图:
可以看出它是严格递增的,并且是非线性的;它大部分使用的值都集中在接近*面的地方,所以:由于主要的值都映射到了一小部分,这就可以深度缓存精度问题;(计算机不能正确区别差别很小的数字之间的大小)通常的建议是尽可能减少远平面和*面的距离。
现在我们可以解出A和B,并且得到透视投影矩阵:
6.3.6 XMMatrixPerspectiveFovLH
一个透视投影矩阵可以通过DirectX数学库的方法来计算:
// Returns the projection matrix
XMMATRIX XM_CALLCONV XMMatrixPerspectiveFovLH(
float FovAngleY, // vertical field of view angle in radians
float Aspect, // aspect ratio = width / height
float NearZ, // distance to near plane
float FarZ); // distance to far plane
下面的代码片段展示了如何使用该函数:
XMMATRIX P = XMMatrixPerspectiveFovLH(0.25f*XM_PI, AspectRatio(), 1.0f, 1000.0f);
宽高比匹配我们的窗口分辨率:
float D3DApp::AspectRatio()const
{
return static_cast<float>(mClientWidth) / mClientHeight;
}
7 曲面细分阶段(THE TESSELLATION STAGES)
曲面细分阶段可以细分三角形,产生新的三角形,并且调整它的位置,用以生成更多细节和更好效果:
曲面细分阶段是DX11的新功能,它可以在GPU上细分几何体;在DX11之前,如果要做曲面细分,只能在CPU上完成,一方面传资源到GPU比较慢,而且细分工作对于CPU也是一个很大的负担;所以在DX11前,该技术并不流行。DX11后提供了API可以让该工作完全由显卡执行,这让该技术变得非常有吸引力,本书将在第14章详细讲解。
8 几何着色阶段(THE GEOMETRY SHADER STAGE)
几何着色阶段是可选的,并且本书12章前不会使用,所以这里就概括一下;
几何着色器输入完整的基元,它可以扩展/增加多个基元或者根据某些条件删除基元,而顶点着色器不能;比如它可以把点扩展成线或者矩形面;
几何着色器可以流出(stream-out)顶点数据到内存中的缓存,用以后续的渲染,这个是高级应用,后续的章节中会描述。
9 裁切
如果几何体完全在截头锥体以外需要被丢弃;如果和边界相交,那么就需要裁切:
截头锥体是由6个平面组成,所以我们对几何体通过每个平面进行裁切;因为它是由硬件完成的,所以我们无法得到细节,但是推荐一篇很流行的文章Sutherland-Hodgeman裁切算法:它主要讲了找到平面与几何体的交点的和生成裁切后新几何体的方法;
10 光栅化阶段
光栅化阶段的工作是通过投影的3D三角形计算出像素的颜色;
10.1 视口变换
裁切后,硬件就可以做透视分割从其次裁切空间变换到NDC,顶点变换到NDC以后,2D的x和y坐标在后置缓存中生成2D图像(称为视图),其x和y坐标对于的单位是像素;通常视图变换不会修改Z值,但是为了应用到深度缓存,它会被修改到D3D12_VIEWPORT结构的MinDepth和MaxDepth之间(0~1)。
10.2 背面消除(Backface Culling)
三角形有2个面,为了区别它们我们有如下约定:如果三角形顶点顺序是V0,V1,V2,那么它的法向量N为:
法向量指向的方向是前方向,相反为背面;
另一种约定:从视点出发,如果三角形顶点顺序是顺时针,则为正向,相反为反向:
10.3 顶点属性差值
三角形内每个像素上属性会根据顶点属性值在屏幕空间进行线性差值,该过程被称为perspective correct interpolation:
对它的数学求导感兴趣的话可以参考[Eberly01],下图给出基本思路:
11 像素着色器阶段
像素着色器是指向在GPU上的程序,它可以逐像素计算颜色,光照,反射和阴影等效果。
12 输出合并阶段(THE OUTPUT MERGER STAGE)
在这个阶段,某些像素片段可能被拒绝(深度或者模板缓冲测试),没有被拒绝的像素片段将被写入后置缓冲,混合操作也在这里指向;
13 总结
- 我们可以通过透视,光照,阴影和遮挡等效果在2D平面上模拟3D场景;
- 我们使用三角形网格来模拟物体,每个三角形用3个顶点表示;
- 颜色可以用红绿蓝三种强度混合来描述,通常还会增加一个透明度;大部分向量的算法可以运用到颜色,除了点集和叉积,颜色有自己的乘法公式;
- 给出一个3D场景描述的几何体和一个具有瞄准方向的虚拟摄像机,通过渲染流水线可以一个可以展示在硬件显示器上的2D图像;
- 渲染流水线可以被划分为:IA(输入装配阶段);VS(顶点着色阶段);曲面细分阶段;GS(几何着色阶段);裁切阶段;RS(光栅化阶段);PS(像素着色阶段)和OM(输出合并阶段)。