dx11 龙宝书 第五 六章 流水线
5.1 三维视觉
本章的主要讲解渲染管线(rendering pipeline)。渲染管线是指:在给定一个3D场景的几何描述及一架已确定位置和方向的虚拟摄像机(virtual camera)时,根据虚拟摄像机的视角生成2D图像的一系列步骤(如图5.1所示)。本章的内容大部分是理论性的——下一章才会把理论用于实践。在我们开始学习渲染管线之前,读者应该先了解两个基本概念:一是构成3D视觉的基本要素(也就是,研究如何通过2D屏幕来呈现3D场景);二是讲解如何在Direct3D中以数学方式表示和使用颜色。
图5.1:左图是放置在3D场景中的一些物体以及一架已确定位置和方向的摄像机;中图表示的是同一个场景,但是是从上方向下看的视角。“棱锥体”表示观察者所能看到的视域空间;视域空间之外的物体(或者物体的一部分)不会被看到。右图是根据摄像机的“视角”生成的2D图像。
学习目标
- 了解如何在2D图像中表现物体的体积感和纵深感。
- 了解如何在Direct3D中描述3D物体。
- 学习如何模拟虚拟摄像机。
- 理解渲染管线——以几何方式描述3D场景并生成2D图像的过程。
在踏上3D计算机绘图的旅程之前,我们必须先弄明白一个问题:如何在2D屏幕上表现3D场景的纵深感和体积感?幸运的是,这个问题已经得到了很好的解决,因为几个世纪以来艺术家们一直是在2D画布上绘制3D场景。本节我们将讲述几种表现图像立体感的关键技术,虽然它实际上是画在平面上的。
假设有一条笔直的铁路,它一直向远处延伸。两条铁轨之间彼此平行,互不相交。当你站在铁路上向远处望时,你会发现随着距离的增加,两条铁轨之间的间隔会越来越近,直至在一个无限远的地方相交为一点。这是通过观察总结出来的我们人类视觉系统的一个特性:平行线会汇集为一个零点(vanishing point),如图5.2所示。
图5.2:平行线汇集为一个零点。艺术家们有时将它称为线性透视(linear perspective)。
从观察中总结出来的人类视觉的另一个特性是物体的大小会随着深度的增加而减小;也就是,近处的物体比远处的物体大。例如,在远处山坡上的一栋房子看上去很小,而近处的一棵树看上去很大。图5.3展示了一个简单的场景,其中放置了几排柱子。这些柱子的大小实际上是一样的,但是从观察者的角度上看,随着深度的增加,柱子会变得越来越小。而且我们还可以看到,这些柱子在地平线上会相交为一个零点。
图5.3:这里,所有的柱子大小相同,但是由于景深现象(depth phenomenon),观察者会发现柱子越来越小。
物体重叠(object overlap)是我们能感受到的另一种现象。物体重叠是指一个不透明的物体会挡住它后面的其他物体的一部分(或全部)。这一点非常重要,因为它告诉我们物体在场景中的远近关系。我们已经(在第4章中)讨论了如何使用Direct3D的深度缓冲区来判定哪些像素会被遮挡,不应该被绘制。出于完整性的考虑,我们在图5.4中再次展示了这一情形。
图5.4:彼此遮挡的一组物体。
考虑图5.5。左边是一个没有光线照射的球体,右边是一个有光线照射的球体。可以看到,左边的球体看上去相当单调——完全不像球体,只能算是一个2D圆!因此,在表现3D物体的立体感和体积感时光照(lighting)和阴影(shading)具有非常重要的作用。
图5.5:(a)没有光线照射的球体看上去就像一个2D圆。(b)而有光线照射的球体看上去很立体。
最后,图5.6展示了一艘飞船和它的阴影。阴影具有两个关键作用:一是告诉我们场景中的光源位置,二是告诉我们飞船距离地面的高度。
图5.6:一艘飞船和它的阴影。阴影间接地说明了光源在场景中的位置以及飞船距离地面的高度。
刚才讨论的现象都很简单,都是我们在日常生活中能够观察到的现象。但是,更进一步地了解这些现象有助于我们学习和使用3D计算机绘图。
5.2 模型的表现形式
3D物体可以通过三角形网格近似地模拟表示,三角形是构成物体模型的基本单位。图5.7说明,我们可以通过三角形网格来模拟真实世界中的任何3D物体。一般来说,网格的三角形密度越大,模拟出来的效果就越好。当然,我们使用的三角形越多,所要求的硬件性能也就越高,所以必须根据应用程序目标用户的硬件性能来决定模型的精度。除三角形外,有时还需要绘制点和直线。例如,通过绘制一系列1像素宽的短线段可以模拟出一条平滑曲线。
图 5.7:(左)由低密度三角形网格模拟的轿车。(右)由高密度三角形网格模拟的头骨。
图5.7中的大规则三角形网格说明了一件事情:要以手工方式编写一个3D模型的三角形列表是一件极其困难的事情。除了最简单的模型外,所有的模型都是用专门的3D建模软件生成的。这些建模软件提供了可视化的交互环境以及非常丰富的建模工具,用户可以使用些软件来创作复杂而逼真的网格模型,整个建模过程非常简单,很容易就能学会。现在在游戏开发领域中较为流行的建模软件有:3ds Max(http://usa.autodesk.com/3ds-max/)、LightWave 3D(http://www.newtek.com/lightwave/)、Maya(http://www.autodesk.com/maya)和Softimage XSI(www.softimage.com)和Blender(http://www.blender.org/)。不过,在本书的第一部分中,我们仍会通过手工方式或数学公式生成一些非常简单的3D模型(例如,使用参量公式可以很容易地生成圆柱体和球体的三角形列表)。在本书的第三部分中,我们会讲解如何载入和显示由3D建模软件导出的3D模型。
5.3 基本计算机颜色
计算机显示器通过每个像素发射红、绿、蓝光的混合光线。当混合光线进入人的眼睛时会触碰到视网膜的某些区域,对锥感细胞产生刺激,神经触突会通过视觉神经传送到大脑。大脑会解释些信号并生成颜色。随着混合光线的变化,细胞会受到不同的刺激,从而在我们的思想意识中产生不同的颜色。图5.8说明了红、绿、蓝三色的混合方式以及不同强度的红色。通过为每个颜色分量指定不同的强度并对其进行混合,可以描述我们所要显示的真实图像中的所有颜色。
图5.8 (上)对纯红、纯绿、纯蓝三种颜色进行混合,得到新的颜色。(下)通过控制红光的强度得到不同明暗程度的红色。
读者可以使用绘图软件(比如Adobe Photoshop)或Win32颜色对话框(图5.9)进一步了解如何使用RGB(red、green、blue)值来描述颜色。尝试使用不同的RGB组合,看一看它们产生的颜色。
显示器所能发射的红、绿、蓝光的强度有最大限制。我们使用从0到1的规范化区间来描述光线强度。0表示没有强度,1表示最高强度。中间值表示中等强度。例如,值(0.25,0.67,1.0)表示混合光由强度为25%的红光、强度为67%的绿和强度为100%的蓝光组成。如本例所示,我们可以通过3D向量(r,g,b)来表示颜色,其中0≤r,g,b≤1,三个颜色分量分别描述红、绿、蓝光的强度。
5.3.1 颜色运算
某些向量运算也适用于颜色向量。例如,我们可以把颜色向量加在一起得到一个新的颜色:
(0.0, 0.5, 0.0) + (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)
也就是,将白色乘以0.5,得到一个中等强度的灰色。而运算2(0.25, 0.0, 0.0) = (0.5, 0.0, 0.0),可使红色分量的强度增大一倍。
颜色向量的点积和叉积没有意义。不过,颜色向量有一种特殊的乘法运算,叫做分量乘法(componentwise multiplication)。其定义如下:
(cr,cg,cb)⨂(kr,kg,kb) = (crkr,cgkg,cbkb)
这一运算主要用于光照方程。例如,一个颜色为(r,g,b)的入射光,照射在一个平面上。该平面反射50%的红光、75%的绿和25%的蓝光,其余线均被平面吸收。则折回的反射光颜色为:
(r,g,b)⨂(0.5,0.75,0.25) = (0.5r,0.75g,0.25b)
我们可以看到,由于平面吸收了一些线,所以当光线照射在平面上时会丢失一些颜色。
当进行颜色运算时,某些颜色分量可能会超出[0,1]区间;例如,方程(1,0.1,0.6) + (0.0,0.3,0.5) = (1,0.4,1.1)。由于1.0表示颜色分量的最大强度,任何分量都不能大于该值。所以,我们要把1.1截取为1.0。同样,显示器不能发射负光,所以任何负的颜色分量(负值是由减法运算取得的结果)都必须截取为0.0。
5.3.2 128位颜色
通常,在颜色中会包含一个附加的颜色分量,叫做alpha分量。alpha分量用于表示颜色的不透明度,我们会在第9章“混合”中使用alpha分量。(由于我们目前还用不到混合,所以现在暂且将alpha分量设置为1。)
包含alpha分量意味着我们要使用4D向量(r,g,b,a)来表示颜色,其中0≤r,g,b,a≤1。要表示一个128位颜色,可以为每个分量指定一个浮点值。因为从数学上来说,颜色就是一个4D向量,所以我们可以在代码中使用XMVECTOR类型表示一个颜色,而且还可以利用XNA数学矢量函数所用的SIMD操作带来的优势进行颜色运算(例如颜色相加、相减、标量乘法)。对于分量乘法,XNA数学库提供了以下方法:
XMVECTOR XMColorModulate(// Returns (cr, cg, cb, ca) ⊗ (kr,kg,kb,ka)
FXMVECTOR C1, // (cr, cg, cb, ca)
FXMVECTOR C2 // (kr, kg, kb, ka) );
5.3.3 32位颜色
当使用32位表示一个颜色时,每个字节会对应于一个颜色分量。由于每个颜色分量占用一个8位字节,所以每个颜色分量可以表示256种不同的明暗强度——0表示没有强度,255表示最高强度,中间值表示中等强度。从表面上看,为每个颜色分量分配一个字节似乎很小,但是通过计算组合值(256×256×256 = 16,777,216)可以发现,这种方式可以表示上千万种不同的颜色。XNA数学库提供了以下结构用于存储32位颜色:
// 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]typedef struct _XMCOLOR{union{struct{UINT b : 8; // Blue: 0/255 to 255/255UINT g : 8; // Green: 0/255 to 255/255UINT r : 8; // Red: 0/255 to 255/255UINT a : 8; // Alpha: 0/255 to 255/255};UINT c;};#ifdef __cplusplus_XMCOLOR() {};_XMCOLOR(UINT Color) : c(Color) {};_XMCOLOR(FLOAT _r, FLOAT _g, FLOAT _b, FLOAT _a);_XMCOLOR(CONST FLOAT *pArray);operator UINT () { return c; }_XMCOLOR& operator= (CONST _XMCOLOR& Color);_XMCOLOR& operator= (CONST UINT Color);#endif // __cplusplus} XMCOLOR;
通过将整数区间[0,255]映射到实数区间[0,1],可以将一个32位颜色转换为一个128位颜色。这一映射工作是通过将每个分量除以255来实现。也就是,当n为0到255之间的一个整数时,对应于规范化区间[0,1]的分量值为0≤≤1。例如,32位颜色(80,140,200,255)变为:
(80,140,200,255) → (,,,) ≈ (0.31,0.55,0.78,1.0)
另一方面,通过将每个颜色分量乘以255并进行四舍五入,可以将一个128位颜色转换为一个32位颜色。例如:
(0.3,0.6,0.9,1.0) → (0.3*255,0.6*255,0.9*255,1.0*255) = (77,153,230,255)
当把一个32位颜色转换为一个128位颜色或者进行反向转换时,通常要执行额外的位运算,因为8位颜色分量通常会被封装在一个32位整数中(例如,无符号整数),即在XMCOLOR中。XNA数学库使用以下函数处理一个XMCOLOR并以XMVECTOR的形式返回:
XMVECTOR XMLoadColor(CONST XMCOLOR* pSource);
图5.10:一个32位颜色,它为每个颜色分量分配一个字节。
图5.10说明了如何将4个8位颜色分量封装为一个无符号整数。注意,这只是用于封装颜色分量的方式之一。除使用ARGB外,还可以使用ABGR或RGBA。不过,XMCOLOR类使用ARGB格式。XNA数学库提供了一个函数可以将一XMVECTOR颜色转化为一个XMCOLOR:
VOID XMStoreColor(XMCOLOR* pDestination,FXMVECTOR V);
通常,许多颜色运算(例如,在像素着色器中)使用的都是128位颜色值;通过这一方式,我们可以有足够多的二进制位来保证计算的精确度,减少算术错误的累积。不过,最终的像素颜色通常是存储在后台缓冲区的32位颜色值中;目前的物理显示设备还不能充分利用更高的分辨率颜色。
5.4 渲染管线概述
渲染管线(rendering pipeline)是指:在给定一个3D场景的几何描述及一架已确定位置和方向的虚拟摄像机时,根据虚拟摄像机的视角生成2D图像的一系列步骤(译者注:渲染管线由许多步骤组成,每个步骤称为一个阶段)。图5.11所示为构成渲染管线的各个阶段,以及与各个阶段相关的内存资源。从内存指向阶段的箭头表示该阶段可以从内存读取数据;例如,像素着色器阶段(pixel shader stage)可以从内存中的纹理资源中读取数据。从阶段指向内存的箭头表示该阶段可以向内存写入数据;例如,输出合并器阶段(output merger stage)可以将数据写入后台缓冲区和深度/模板缓冲区。我们还可以看到输出合并器阶段的箭头是双向的(可以读取和写入GPU资源)。大多数阶段并不会写入GPU资源,它们只是将输出传递到下一阶段;例如,顶点着色器阶段(Vertex Shader Stage)读取输入装配阶段的数据,然后进行处理,接着将结果输出到几何着色器阶段(Geometry Shader Stage)。在随后的几节中,我们将分别讲解渲染管线的各个阶段。
图5.11:渲染管线的各个阶段
5.5 输入装配阶段
输入装配(Input Assembler,简称IA)阶段从内存读取几何数据(顶点和索引)并将这些数据组合为几何图元(例如,三角形、直线)。(索引将在随后的小节中讲解。简单地说,索引规定了顶点的组织形式,解释了该以何种方式组成图元。)
5.5.1 顶点
从数学上讲,三角形的顶点位于两条边相交的位置上;而直线的顶点是端点。对于一个单个点来说,点本身就是顶点。图5.12说明了顶点的几何图形。
图5.12 由三个顶点V1、V2、V3组成的三角形;由两个顶点p0、p1表示的直线;由顶点表示的点。
图5.12说明顶点只是几何图元中的一个特殊的点。不过,在Direct3D中,顶点具有更多含义。本质上,Direct3D中的顶点由空间位置和各种附加属性组成;例如,在第7章中,我们会在顶点中添加法线向量实现光照,在第8章中,在顶点中添加纹理坐标实现纹理。Direct3D可以让我们灵活地建立属于我们自己的顶点格式(例如,允许我们定义顶点的分量)。在本书中,我们会基于要绘制的效果定义一些不同的顶点格式。
5.5.2 图元拓扑
顶点是以一个叫做顶点缓冲区的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... */
下面的各小节会详细描述各种不同的图元拓扑方式。在本书中,我们主要使用三角形列表。
5.5.2.1 点列表
点列表(point list)由D3D11_PRIMITIVE_TOPOLOGY_POINTLIST标志值表示。当使用点列表时,每个顶点都会被绘制为一个独立的点,如图5.13a所示。
图5.13 (a)点列表。(b)线带。(c)线列表。(d)三角形带。
5.5.2.2 线带
线带(line strip)由D3D11_PRIMITIVE_TOPOLOGY_LINESTRIP标志值表示。当使用线带时,前后相邻的两个顶点会形成一条直线(参见图5.13b);这样,n+1个顶点可以形成n条直线。
5.5.2.3 线列表
线列表(line list)由D3D11_PRIMITIVE_TOPOLOGY_LINELIST标志值表示。当使用线列表时,每两个顶点会形成一条独立的直线(参见图5.13c);这样,2n个顶点可以形成n条直线。线列表和线带之间的区别是线列表中的直线可以断开,而线带中的直线会自动连在一起;因为内部的每个顶点由两条直线共享,所以线带使用的顶点数量更少。
5.5.2.4 三角形带
三角形带(triangle strip)由D3D11_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP标志值表示。当使用三角形带时,顶点会按照图5.13d所示的带状方式形成连续的三角形。我们可以看到顶点由相邻的三角形共享,n个顶点可以形成n-2个三角形。
注意:偶数三角形和奇数三角形的顶点环绕顺序不同,由此会产生背面消隐问题(参见5.10.2节)。为了解决一问题,GPU会在内部交换偶数三角形的前两个顶点的顺序,以使它们的环绕顺序与奇数三角形相同。
5.5.2.5 三角形列表
三角形列表(triangle list)由D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST标志值表示。当使用三角形列表时,每三个顶点会形成一个独立的三角形(参见图5.14a);这样,3n个顶点可以形成n个三角形。三角形列表与三角形带之间的区别是:三角形列表中的三角形可以断开,而三角形带中的三角形会自动连在一起。
图5.14 (a)三角形列表。(b)邻接三角形列表——可以看到每个三角形需要6个顶点来描述它和它的邻接三角形。所以,6n个顶点可以形成n个带有邻接信息的三角形。
5.5.2.6 带有邻接信息的图元
在包含邻接信息的三角形列表中,每个三角形都有与之相邻的3个邻接三角形;图5.14b说明了这些三角形的定义方式。它们主要用于几何着色器,因为某些几何着色算法需要访问邻接三角形。为了实现这些算法,邻接三角形必须与原三角形一起通过顶点/索引缓冲区提交给管线。通过指定D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST_ADJ拓扑标志值可以使管线知道如何从顶点缓冲区中构建三角形以及它的邻接三角形。注意,邻接图元顶点只能作为几何着色器的输入数据——它们不会被绘制出来。如果没有几何着色器,那么邻接图元也不会被绘制出来。
线列表、线带和三角形带也可以包含邻接图元;详情请参见SDK文档。
5.5.2.7 控制点面片列表
D3D11_PRIMITIVE_TOPOLOGY_N_CONTROL_POINT_PATCHLIST拓扑标志表示将顶点数据作为N控制点的面片列表,这些点用于(可选)图形管线的曲面细分阶段(tessellation stage),我们要到第13章才会讨论到它。
5.5.3 索引
如前所述,三角形是构成3D物体的基本单位。下面的代码示范了使用三角形列表来构建四边形和八边形的顶点数组(即,每三个顶点构成一个三角形)。
Vertex quad[6] ={v0, v1, v2, // Triangle0v0, v2, v3, // Triangle1};Vertex octagon[24] ={v0, v1, v2, // Triangle0v0, v2, v3, // Triangle1v0, v3, v4, // Triangle2v0, v4, v5, // Triangle3v0, v5, v6, // Triangle4v0, v6, v7, // Triangle5v0, v7, v8, // Triangle6v0, v8, v1 // Triangle 7};
注意:三角形的顶点顺序非常重要,我们将该顺序称为环绕顺序(winding order);详情请参见5.10.2节。
如图5.15所示,构成3D物体的三角形会共享许多相同的顶点。更确切地说,在图5.15a中,四边形的每个三角形都会共享顶点v0和v2。当复制两个顶点时问题并不明显,但是在八边形的例子中问题就比较明显了(图5.15b),八边形的每个三角形都会复制中间的顶点v0,而且边缘上的每个顶点都由相邻的两个三角形共享。通常,复制顶点的数量会随着模型细节和复杂性的提高而骤然上升。
图 5.15 (a)由2个三角形构成的四边形。(b)由8个三角形构成的八边形。
我们不希望对顶点进行复制,主要有两个原因:
- 增加内存需求量。(为什么要多次存储相同的顶点数据?)
- 增加图形硬件的处理负担。(为什么要多次处理相同的顶点数据?)
三角形带在一定程度上可以解决复制顶点问题,但是几何体必须按照带状方式组织,实现起来难度较大。相比之下,三角形列表具有更好的灵活性(三角形不必彼此相连),如果能找到一种方法,即移除复制顶点,又保留三角形列表的灵活性,那么会是一件非常有价值的事情。索引(index)可以解决一问题。它的工作原理是:我们创建一个顶点列表和一个索引列表。顶点列表包含所有唯一的顶点,而索引列表包含指向顶点列表的索引值,这些索引定义了顶点以何种方式组成三角形。回顾图5.15中的图形,四边形的顶点列表可以这样创建:
Vertex v[4] = {v0, v1, v2, v3};
而索引列表需要定义如何将顶点列表中的顶点放在一起,构成两个三角形。
UINT indexList[6] = {0, 1, 2, // Triangle0
0, 2, 3}; // Triangle 1
在索引列表中,每3个元素表示一个三角形。所以上面的索引列表的含义为:“使用顶点v[0]、v[1]、v[2]构成三角形0,使用顶点v[0]、v[2]、v[3]构成三角形1”。
与之类似,八边形的顶点列表可以这样创建:
Vertex v[9] = {v0, v1, v2, v3, v4, v5, v6, v7, v8};
索引列表为:
UINT indexList[24] = {
0, 1, 2, // Triangle 0
0, 2, 3, // Triangle 1
0, 3, 4, // Triangle 2
0, 4, 5, // Triangle 3
0, 5, 6, // Triangle 4
0, 6, 7, // Triangle 5
0, 7, 8, // Triangle 6
0, 8, 1 // Triangle7
};
当顶点列表中的唯一顶点得到处理之后,显卡可以使用索引列表把顶点放在一起构成三角形。我们将“复制问题”转嫁给了索引列表,但是这种复制是可以让人接受的。因为:
- 索引是简单的整数,不像顶点结构体那样占用很多内存(顶点结构体包含的分量越多,占用的内存就越多)。
- 通过适当的顶点缓存排序,图形硬件不必重复处理顶点(在绝大多数的情况下)。
5.6 顶点着色器阶段
在完成图元装配后,顶点将被送往顶点着色器(vertex shader)阶段。顶点着色器可以被看成是一个以顶点作为输入输出数据的函数。每个将要绘制的顶点都会通过顶点着色器推送至硬件;实际上,我们可以概念性地认为在硬件上执行了如下代码:
for(UINT i = 0; i < numVertices; ++i)
outputVertex[i] = VertexShader(inputVertex[i]);
顶点着色器函数由我们自己编写,但是它会在GPU上运行,所以执行速度非常快。
许多效果,比如变换(transformation)、光照(lighting)和置换贴图映射(displacement mapping)都是由顶点着色器来实现的。记住,在顶点着色器中,我们不仅可以访问输入的顶点数据,也可以访问在内存中的纹理和其他数据,比如变换矩阵和场景灯光。
我们将会在本书中看到许多不同的顶点着色器示例;当读完本书时,读者会对顶点着色器的功能有一个全面的认识。不过,我们的第一个示例会比较简单,只是用顶点着色器实现顶点变换。在随后的小节中,我们将讲解各种常用的变换算法。
5.6.1 局部空间和世界空间
现在让我们来假设一个情景:你正在参与一部影片的拍摄工作,剧组要为拍摄某些特殊效果而搭建一个微缩场景。你的具体任务是搭建一座小桥。现在,你不能在场景中搭建小桥,你必须另选地点,在远离场景的地方建立工作台,制作小桥,以避免弄乱场景中的其他微缩物品。当小桥建成后,你要按照正确的位置和角度把小桥放到场景中。
3D美术师在创建3D场景时也采用同样的工作方式。他们不在全局场景坐标系(world
space,世界空间)中建立物体,而是在局部坐标系(local space,局部空间)中建立物体;局部坐标系是最常用的实用坐标系,它的原点接近于物体中心,坐标轴的方向与物体的方向对齐。在完成3D模型的制作之后,美术师会将模型放到全局场景中;通过计算局部坐标系相对于世界坐标系的原点和轴向,实现相应的坐标转换变换(参见图5.16,并回顾3.4节的内容)。将坐标从局部坐标系转换到世界坐标系的过程称为世界变换(world transform),相应的变换矩阵称为世界矩阵(world matrix)。当所有的物体都从局部空间变换到世界空间后,这些物体就会位于同一个坐标系(世界空间)中。如果你希望直接在世界空间中定义物体,那么可以使用单位世界矩阵(identity world matrix)。
图 5.16 (a)物体的每个顶点都是相对于它们自己的局部坐标系来定义的。我们根据物体在场景中的位置和方向来定义每个局部坐标系相对于世界坐标系的位置和方向。然后我们执行坐标转换变换,将所有坐标转换到世界坐标系中。(b)在世界变换后,所有物体的顶点都会位于同一个世界坐标系中。
根据模型自身的局部坐标系定义模型,有以下几点好处:
1.简单易用。比如,在局部坐标系中,坐标系的原点通常会与物体的中心对齐,而某个主轴可能正是物体的对称轴。又如,当我们使用局部坐标系时,由于坐标系的原点与立方体的中心对齐,坐标轴垂直于立方体表面,所以可以更容易地描述立方体的顶点(参见图5.17)。
图5.17 当立方体的中心位于坐标系原点且与轴对齐(axis-aligned)时,可以很容易地描述立方体的顶点。当立方体位于坐标系的任意一个位置和方向上时,就很难描述这些坐标了。所以,当我们创建模型时,总是选择一种与物体位置接近且与物体方向对齐的实用坐标系。
2.物体可以在多个场景中重复使用,对物体坐标进行相对于特定场景的硬编码是毫无意义的事情。较好的做法是:在局部坐标系中存储物体坐标,通过坐标转换矩阵将物体从局部坐标系变换到世界坐标系,建立物体与场景之间的联系。
3.最后,有时我们会多次绘制相同的物体,只是物体的位置、方向和大小有所不同(比如,将一棵树重绘多次形成一片森林)。在这种情况下,我们只需要一个相对于局部坐标系的单个副本,而不是多次复制物体数据,为每个实例创建一个副本。当绘制物体时,我们为每个物体指定不同的世界矩阵,改变它们在世界空间中的位置、方向和大小。这种方法叫做instancing。
如3.4.3节所述,世界矩阵描述的是一个物体的局部空间相对于世界空间的原点位置和坐标轴方向,这些坐标可以存放在一个行矩阵中。设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节可知,从局部空间到世界空间的坐标转换矩阵为:
示例
假设一个正方形的顶点局部坐标在(−0.5,0,−0.5)和(0.5,0,0.5)之间,将它的边长变为2,顺时针旋转45°,并放置在世界空间的(10,0,10)坐标上,那如何求它在世界空间中的坐标呢?我们需要构建S,R,T矩阵,世界矩阵W如下所示:
根据3.5节的讲解,W中的行表示相对于世界空间的局部坐标系;即uw=(,0, -,0),vw=(0,1,0 ,0),ww=(,0, ,0),Qw=(10,0, 10,1)。当我们使用W将局部坐标系转换到世界坐标系时,正方形就会处在期望的位置上(见图5.18)。
图5.18 世界矩阵的行向量表示相对于世界空间的局部坐标系。
这个例子的要点是无需计算Qw,uw,vw,ww直接获得世界矩阵,而是通过组合一系列简单的变换矩阵获得世界矩阵,这通常比直接求解Qw,uw,vw,ww简单。我们只需确定:物体在世界空间中的尺寸多大,在世界空间中的朝向如何,我们要将该物体放置在世界空间中的何处。
还有一种考虑世界变换的方式是:只考虑局部坐标并把它作为世界坐标对待(这相当于使用一个单位矩阵作为世界变换矩阵)。这样,如果物体建模时就位于局部坐标的原点,那么它也在世界空间的坐标原点。通常,世界空间的坐标原点并不是我们想放置物体的位置,所以,对每个物体,我们只要施加一组变换用于缩放、选择、平移,将物体放置在世界空间中的确定位置。从数学上来说,这与将矩阵从局部空间转换到世界空间的变换效果是相同的。
5.6.2 观察空间
为了生成场景的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的含义(即,世界矩阵可以被分解为一个旋转矩阵和一个平移矩阵)。这种方式可以使逆矩阵的计算过程更简单一些:
V = W-1 = (RT)-1 = T-1R-1 = T-1RT
所以,观察矩阵为:
我们现在介绍一种更直观的方法来创建构成观察矩阵的向量。设Q为摄像机的位置,T为摄像机瞄准的目标点,j为描述世界空间“向上”方向的单位向量。参考图5.20,摄像机的观察方向为:
图5.20 通过指定摄像机的位置、目标点和世界“向上”向量来创建摄像机坐标系。
向量w描述的是摄像机坐标系的z轴。指向w“右边”的单位向量为:
向量u描述的是摄像机坐标系的x轴。最后,描述摄像机坐标系y轴的向量为:
v=w×u
由于w和u是相互垂直的单位向量,所以w×u必定为单位向量,不需要对它做规范化处理。
这样,给出摄像机的位置、目标点和世界“向上”向量,我们就能够得到摄像机的局部坐标系,该坐标系可以用于创建观察矩阵。
XNA库提供了如下函数,根据刚才描述的过程计算观察矩阵:
XMMATRIX XMMatrixLookAtLH( // Outputs resulting view matrix VFXMVECTOR EyePosition, // Input camera position QFXMVECTOR FocusPosition, // Input target point TFXMVECTOR 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);
5.6.3 投影与齐次裁剪空间
到目前为止,我们已经知道了如何在场景中描述摄像机的位置和方向,下面我们来讲解如何描述摄像机所能看到的空间范围。该范围通过一个平截头体(frustum)来描述(图5.21),
它是一个在近平面处削去尖部的棱锥体。
图5.21 平截头体描述了摄像机可以“看到”的空间范围。
我们的下一个任务是把平截头体内的3D物体投影到2D投影窗口上。投影(projection)必须按照平行线汇集为零点的方式来实现,随着一个物体的3D深度增加,它的投影尺寸会越来越小;图5.22说明了透视投影的实现过程。我们将“从顶点连向观察点的直线”称为顶点的投影线。然后我们可以定义透视投影变换,将3D顶点v变换到它的投影线与2D投影平面相交的点vʹ上;我们将vʹ称为v的投影。对一个3D物体的投影就是对组成该物体的所有顶点的投影。
图5.22 在3D空间中,大小相同、深度不同的两个圆柱体。与观察点距离较近的圆柱体生成的投影较大。在平截头体内的物体可以被映射到投影窗口上;在平截头体外的物体可以映射到投影平面上,但是不会映射到投影窗口上。
5.6.3.1 定义平截头体
我们可以在观察空间中使用如下4个参数来定义以原点为投影中心、以z轴正方向为观察方向的平截头体:近平面n、远平面f、垂直视域角α和横纵比r。注意,在观察空间中,近平面和远平面都平行于xy平面;所以,我们只需要简单地指定它们沿z轴方向到原点之间的距离即可表示这两个平面。横纵比由r = w/ℎ定义,其中w表示投影窗口的宽度,ℎ表示投影窗口的高度(单位由观察空间决定)。投影窗口本质上是指场景在观察空间中的2D图像。该图像最终会被映射到后台缓冲区中;所以,我们希望投影窗口的尺寸比例与后台缓冲区的尺寸比例保持相同。在大多数情况下,横纵比就是指后台缓冲区的尺寸比例(它是一个比例值,所以没有单位)。例如,当后台缓冲区的尺寸为800×600时,横纵比r = 800/600 ≈ 1.333。如果投影窗口的横纵比与后台缓冲区的横纵比不同,那么当投影窗口映射到后台缓冲区时,必然会出现比例失衡,导致图像变形(例如,投影窗口中的一个正圆会被拉伸为后台缓冲区中的一个椭圆)。
将水平视域角设为β,它是由垂直视域角α和横纵比r决定的。考虑图5.23,分析一下如何通过α、r来求解β。注意,投影窗口的实际尺寸并不重要,重要的只是横纵比。所以,我们将高度设定为2,则对应的宽度为:
图5.23 给出垂直视域角α和横纵比r,求解水平视域角β。
为了获得指定的垂直视域角α,投影窗口必须放在与原点距离为d的位置上:
5.6.3.2 对顶点进行投影
参见图5.24。给出一个点 (x, y, z),求它在投影平面z = d上的投影点 (xʹ, yʹ, d)。通过分析x、y坐标以及使用相似三角形,我们可以求出:
图 5.24 相似三角形。
当且仅当以下条件成立时,点(x, y , z)在平截头体内。
−r ≤ xʹ ≤ r
−1 ≤ yʹ ≤ 1
n ≤ z ≤ f
5.6.3.3 规范化设备坐标(NDC)
上一节我们讲解了如何在观察空间中计算点的投影坐标。在观察空间中,投影窗口的高度为2,宽度为2r,其中r表示横纵比。这里存在的一个问题是:尺寸依赖于横纵比。这意味着我们必须为硬件指定横纵比,否则硬件将无法执行那些与投影窗口尺寸相关的运算(比如,将投影窗口映射到后台缓冲区)。如果我们能去除对横纵比的依赖性,那么会使相关的运算变得更加简单。为了解决一问题,我们将的投影x坐标从[−r , r] 区间缩放到[−1,1]区间:
5.6.3.4 用矩阵来描述投影方程
为了保持一致,我们将用一个矩阵来描述投影变换。不过,方程5.1是非线性的,无法用矩阵描述。所以我们要使用一种“技巧”将它分为两部分来实现:一个线性部分和一个非线性部分。非线性部分要除以z。我们会在下一节讨论“如何规范化z坐标”时讲解这一问题;现在读者只需要知道,我们会因为个除法操作而失去原始的z坐标。所以,我们必须在变换之前保存输入的z坐标;我们可以利用齐次坐标来解决一问题,将输入的z坐标复制给输出的w坐标。在矩阵乘法中,我们要将元素[2][3]设为1、元素[3][3]设为0(从0开始的索引)。我们的投影矩阵大致如下:
在与投影矩阵(线性部分)相乘之后,我们要将每个坐标除以w = z(非线性部分),得到最终的变换结果:
顺便提一句,你可能会问:“如何处理除数为0的情况”;对于一问题我们不必担心,因为近平面总是大于0的,其他的点都会被裁剪掉(参见5.9节)。有时,与w相除的过程也称为透视除法(perspective divide)或齐次除法(homogeneous divide)。我们可以看到x、y的投影坐标与方程5.1相同。
5.6.3.5 规范化深度值
你可能认为在投影之后可以丢弃原始的3D z坐标,因为所有的投影点已经摆放在2D投影窗口上,形成了我们最终看到的2D图像,不会再使用3D z坐标了。其实不然,我们仍然需要为深度缓存算法提供3D深度信息。就如同Direct3D希望我们把x、y投影坐标映射到一个规范化区间一样,Direct3D也希望我们将深度坐标映射到一个规范化区间[0,1]中。所以,我们需要创建一个保序函数(order preserving function)g(x)把[n,f]区间映射到[0,1]区间。由于该函数是保序的,所以当z1,z2∈[n,f]且z1<z2时,必有g(z1)<g(z2)。这样,即使深度值已经被变换过了,相对的深度关系还是会被完好无损地保留下来,我们依然可以在规范化区间中得到正确的深度测试结果,这就是我们要为深度缓存算法做的全部工作。
通过缩放和平移可以实现从[n ,f]到[0,1]的映射。但是,这种方式无法与我们当前的投影方程整合。我们可以从方程5.3中看到经过变换的z坐标为:
从g(z)的曲线图(图5.25)中可以看出,它会限制增长的幅度(保序)而且是非线性的。从图中我们还可以看到,区间中的大部分取值落在近平面附近。因此,大多数深度值被映射到了一个很窄的取值范围内。这会导致深度缓冲区出现精度问题(由于所能表示的数值范围有限,计算机将无法识别变换后的深度值之间的微小差异)。通常的建议是让近平面和远平面尽可能接近,把深度的精度性问题减小到最低程度。
图5.25 相对于不同近平面的g(z)曲线图。
现在我们已经解出了A和B,我们可以确定出完整的透视投影矩阵:
在与投影矩阵相乘之后,进行透视除法之前,几何体所处的空间称为齐次裁剪空间(homogeneous clip space)或投影空间(projection space)。在透视除法之后,几何体所处的空间称为规范化设备空间(normalized device coordinates,简称NDC)。
5.6.3.6 XMMatrixPerspectiveFovLH
透视投影矩阵可由如下XNA函数生成:
XMMATRIX XMMatrixPerspective FovLH(// returns projection matrixFLOAT FovAngleY, // vertical field of view angle in radiansFLOAT AspectRatio, // aspect ratio = width / heightFLOAT NearZ, // distance to near planeFLOAT FarZ); // distance to far plane
下面的代码片段示范了XMMatrixPerspectiveFovLH函数的使用方法。这里,我们将垂直视域角设为45°,近平面z设为1,远平面z设为1000(这些长度是在观察空间中的)。
XMMATRIX P = XMMatrixPerspectiveFovLH(0.25f*MathX::Pi,AspectRatio(),1.0f,1000.0f);
横纵比要匹配窗口的横纵比:
float D3Dapp::AspectRatio() const{return static_cast<float>(mClientWidth)/mClientHeight;}
5.7 曲面细分阶段
曲面细分(Tessellation)是指通过添加三角形的方式对一个网格的三角形进行细分,这些新添加的三角形可以偏移到一个新的位置,让网格的细节更加丰富。(见图5.26)。
图5.26 左图是原始网格,右图是经过曲面细分处理后的网格
下面是曲面细分的一些优点:
1.我们可以通过曲面细分实现细节层次(level -of-detai l,LOD),使靠近相机的三角形通过细分产生更多细节,而那些远离相机的三角形则保持不变。通过这种方式,我们只需在需要细节的地方使用更多的三角形就可以了。
2.我们可以在内存中保存一个低细节(低细节意味着三角形数量少)的网格,但可以实时地添加额外的三角形,这样可以节省内存。
3.我们可以在一个低细节的网格上处理动画和物理效果,而只在渲染时才使用细分过的高细节网格。
曲面细分阶段是Direct3D 11中新添加的,这样我们就可以在GPU上对几何体进行细分了。而在Direct3D 11之前,如果你想要实现曲面细分,则必须在CPU上完成,经过细分的几何体还要发送到GPU用于渲染。然而,将新的几何体从CPU内存发送到显存是很慢的,而且还会增加CPU的负担。因此,在Direct3D 11出现之前,曲面细分的方法在实时图形中并不流行。Direct3D 11提供了一个可以完全在硬件上实现的曲面细分API。这样曲面细分就成为了一个非常有吸引力的技术了。曲面细分阶段是可选的(即在需要的时候才使用它)。我们要在第13章才会详细介绍曲面细分。
5.8 几何着色器阶段
几何着色器阶段(geometry shader stage)是可选的,我们在第11章之前不会用到它,所以这里只做一个简短的概述。几何着色器以完整的图元作为输入数据。例如,当我们绘制三角形列表时,输入到几何着色器的数据是构成三角形的三个点。(注意,这三个点是从顶点着色器传递过来的。)几何着色器的主要优势是它可以创建或销毁几何体。例如,输入图元可以被扩展为一个或多个其他图元,或者几何着色器可以根据某些条件拒绝输出某些图元。这一点与顶点着色器有明显的不同:顶点着色器无法创建顶点,只要输入一个顶点,那么就必须输出一个顶点。几何着色器通常用于将一个点扩展为一个四边形,或者将一条线扩展为一个四边形。
我们可以在图5.11中看到一个“流输出(stream output)”箭头。也就是,几何着色器可以将顶点数据流输出到内存中的一个顶点缓冲区内,这些顶点可以在管线的随后阶段中渲染出来。这是一项高级技术,我们会在后面的章节中对它进行讨论。
注意:顶点位置在离开几何着色器之前,必须被变换到齐次裁剪空间。
5.9 裁剪阶段
我们必须完全丢弃在平截头体之外的几何体,裁剪与平截头体边界相交的几何体,只留下平截头体内的部分;图 5.27以2D形式说明了一概念。
图5.27 (a)裁剪之前。(b)裁剪之后。
我们可以将平截头体视为由6个平面界定的空间范围:顶、底、左、右、近、远平面。要裁剪与平截头体方向相反的多边形,其实就是逐个裁剪与每个平截头体平面方向相反的多边形,当裁剪一个与平面方向相反的多边形时(参见图5.28),我们将保留平面正半空间中的部分,而丢弃平面负半空间中的部分。对一个与平面方向相反的凸多边形进行裁剪,得到的结果仍然会是一个凸多边形。由于硬件会自动完成所有的裁剪工作,所以我们不在这里讲解具体的实现细节;有兴趣的读者可以参阅[Sutherland74],了解一下目前流行的Sutherland-Hodgeman裁剪算法。它基本思路是:求出平面与多边形边之间的交点,然后对顶点进行排序,形成新的裁剪后的多边形。
图 5.28 (a)裁剪一个与平面方向相反的三角形。(b)裁剪后的三角形。注意,裁剪后的三角形已经不再是一个三角形了,它是一个四边形。所以,硬件必须将个四边形重新划分为三角形,对于凸多边形来说这是一个非常简单的处理过程。
图5.29 齐次裁剪空间中xw平面上的截头体边界
那么在透视除法之前,平截头体内的4D点(x , y , z , w)在齐次裁剪空间中的边界为:
−w ≤ x ≤ w
−w ≤ y ≤ w
0 ≤ z ≤ w
也就是,顶点被限定在以下4D平面构成的空间范围内:
左:w = −x
右:w = x
底:w = −y
顶:w = y
近:z = 0
远:z = w
只要我们知道齐次剪裁空间中的平截头体平面方程,我们就能使用任何一种裁剪算法(比如Sutherland-Hodgeman)。注意,由线段/平面相交测试的数学推论可知,这个测试在ℝ4也能使用,所以我们可以在齐次裁剪空间中进行4D点和4D平面的相交测试。
5.10 光栅化阶段
光栅化(rasterization)阶段的主要任务是为投影后的3D三角形计算像素颜色。
5.10.1 视口变换
在裁剪之后,硬件会自动执行透视除法,将顶点从齐次裁剪空间变换到规范化设备空间(NDC)。一旦顶点进入NDC空间,构成2D图像的2D x、y坐标就会被变换到后台缓冲区中的一个称为视口的矩形区域内(回顾4.2.8节)。在该变换之后,x、y坐标将以像素为单位。通常,视口变换不修改z坐标,因为z坐标还要由深度缓存使用,但是我们可以通过D3D11_VIEWPORT结构体的MinDepth和MaxDepth值修改z坐标的取值范围。MinDepth和MaxDepth的值必须在0和1之间。
5.10.2 背面消隐
一个三角形有两个面。我们使用如下约定来区分这两个面。假设三角形的顶点按照v0、v1、v2的顺序排列,我们这样来计算三角形的法线n:
图5.30 左边的三角形正对我们的观察点,而右边的三角形背对我们的观察点。
当观察者看到三角形的正面时,我们说三角形是朝前的;当观察者看到三角形的背面时, 我们说三角形是朝后的。如图5.30所示,左边的三角形是朝前的,而右边的三角形是朝后的。而且,按照我们的观察角度,左边的三角形会按顺时针方向环绕,而右边的三角形会按逆时针方向环绕。这不是巧合:因为按照我们选择的约定(即,我们计算三角形法线的方式),按顺时针方向环绕的三角形(相对于观察者)是朝前的,而按逆时针方向环绕的三角形(相对于观察者)是朝后的。
现在,3D空间中的大部分物体都是封闭实心物体。当我们按照这一方式将每个三角形的法线指向物体外侧时,摄像机就不会看到实心物体朝后的三角形,因为朝前的三角形挡住了朝后的三角形;图5.31和图5.32分别以2D和3D形式说明了一概念。由于朝前的三角形挡住了朝后的三角形,所以绘制它们是毫无意义的。背面消隐(backface culling)是指让管线放弃对朝后的三角形的处理。这可以将所要处理的三角形的数量降低到原数量的一半。
图5.31 (a)一个带有朝前和朝后三角形的实心物体。(b)在剔除了朝后的三角形之后的场景。注意,背面消隐不会影响最终的图像,因为朝后的三角形会被朝前的三角形阻挡。
图5.32 (左图)当以透明方式绘制立方体时,我们可以看到所有的6个面。(右图)当以实心方式绘制立方体时,我们无法看到朝后的3个面,因为朝前的3个面挡住了它们——所以朝后的三角形可以被直接丢弃,不再接受后续处理,没人能看到些朝后的三角形。
默认情况下,Direct3D将(相对于观察者)顺时针方向环绕的三角形视为朝前的三角形,将(相对于观察者)逆时针方向环绕的三角形视为朝后的三角形。不过,这一约定可以通过修改Direct3D渲染状态颠倒过来。
5.10.3 顶点属性插值
如前所述,我们通过指定三角形的3个顶点来定义一个三角形。除位置外,顶点还可以包含其他属性,比如颜色、法线向量和纹理坐标。在视口变换之后,这些属性必须为三角形表面上的每个像素进行插值。顶点深度值也必须进行插值,以使每个像素都有一个可用于深度缓存算法的深度值。对屏幕空间中的顶点属性进行插值,其实就是对3D空间中的三角形表面进行线性插值(如图5.33所示);这一工作需要借助所谓的透视矫正插值(perspective
correct interpolation)来实现。本质上,三角形表面内部的像素颜色都是通过顶点插值得到的。
图5.33:通过对三角形顶点之间的属性值进行线性插值,可以得到三角形表面上的任一属性值p(s,t)。
我们不必关心透视精确插值的数学细节,因为硬件会自动完成这一工作;不过,有兴趣的读者可以在[Eberly01]中查阅相关的数学推导过程。图5.34介绍了一点基本思路:
图5.34 一条3D线被投影到投影窗口上(在屏幕空间中投影是一条2D线)。我们看到,在3D线上取等距离的点,在2D屏幕空间上的投影点却不是等距离的。所以,我们在3D空间中执行线性插值,在屏幕空间需要执行非线性插值。
5.11 像素着色器阶段
像素着色器(Pixel shader)是由我们编写的在GPU上执行的程序。像素着色器会处理每个像素片段(pixel fragment),它的输入是插值后的顶点属性,由此计算出一个颜色。像素着色器可以非常简单地输出一个颜色,也可以很复杂,例如实现逐像素光照、反射和阴影等效果。
5.12 输出合并阶段
当像素片段由像素着色器生成之后,它们会被传送到渲染管线的输出合并(output
merger,简称OM)阶段。在该阶段中,某些像素片段会被丢弃(例如,未能通过深度测试或模板测试)。未丢弃的像素片段会被写入后台缓冲区。混合(blending)工作是在该阶段中完成的,一个像素可以与后台缓冲区中的当前像素进行混合,并以混合后的值作为该像素的最终颜色。某些特殊效果,比如透明度,就是通过混合来实现的;我们会在第9章专门讲解混合。
6.1 顶点和顶点布局
5.5.1节已经讲过,在Direct3D中,顶点由空间位置和各种附加属性组成,Direct3D可以让我们灵活地建立属于我们自己的顶点格式;换句话说,它允许我们定义顶点的分量。要创建一个自定义的顶点格式,我们必须先创建一个包含顶点数据的结构体。例如,下面是两种不同类型的顶点格式;一个由位置和颜色组成,另一个由位置、法线和纹理坐标组成。
struct Vertex1{XMFLOAT3 Pos;XMFLOAT4 Color;};struct Vertex2{XMFLOAT3 Pos;XMFLOAT3 Normal;XMFLOAT2 Tex0;XMFLOAT2 Tex1;};
在定义了顶点结构体之后,我们必须设法描述该顶点结构体的分量结构,使Direct3D知道该如何使用每个分量。这一描述信息是以输入布局(ID3D11InputLayout)的形式提供给Direct3D的。输入布局是一个D3D11_INPUT_ELEMENT_DESC数组。D3D11_INPUT_ELEMENT_DESC数组中的每个元素描述了顶点结构体的一个分量。比如,当顶点结构体包含两个分量时,对应的D3D11_INPUT_ELEMENT_DESC数组会包含两个元素。我们将D3D11_INPUT_ELEMENT_DESC称为输入布局描述(input layout description)。D3D11_INPUT_ELEMENT_DESC结构体定义如下:
typedef struct D3D11_INPUT_ELEMENT_DESC {LPCSTR SemanticName;UINT SemanticIndex;DXGI_FORMAT Format;UINT InputSlot;UINT AlignedByteOffsetD3D11_INPUT_CLASSIFICATION InputSlotClass;UINT InstanceDataStepRate;} D3D11_INPUT_ELEMENT_DESC;
1.SemanticName:一个与元素相关的字符串。它可以是任何有效的语义名。语义(semantic)用于将顶点结构体中的元素映射为顶点着色器参数(参见图6.1)。
图6.1 顶点结构体中的每个元素分别由D3D11_INPUT_ELEMENT_DESC数组中的对应元素描述。语义名和语义索引提供了一种将顶点元素映射为顶点着色器参数的方法。
2.SemanticIndex:附加在语义上的索引值。图6.1说明了使用该索引的原因;举例来说,当顶点结构体包含多组纹理坐标时,我们不是添加一个新的语义名,而是在语义名的后面加上一个索引值。在着色器代码中没有指定索引的语义默认索引为0,例如,在图6.1中的POSITION相当于POSITION0。
3.Format:一个用于指定元素格式的DXGI_FORMAT枚举类型成员;下面是一些常用的格式:
- DXGI_FORMAT_R32_FLOAT // 1D 32-bit float scalar
- DXGI_FORMAT_R32G32_FLOAT // 2D 32-bit float vector
- DXGI_FORMAT_R32G32B32_FLOAT // 3D 32-bit float vector
- DXGI_FORMAT_R32G32B32A32_FLOAT // 4D 32-bit float vector
- DXGI_FORMAT_R8_UINT // 1D 8-bit unsigned integer scalar
- DXGI_FORMAT_R16G16_SINT // 2D 16-bit signed integer vector
- DXGI_FORMAT_R32G32B32_UINT // 3D 32-bit unsigned integer vector
- DXGI_FORMAT_R8G8B8A8_SINT // 4D 8-bit signed integer vector
- DXGI_FORMAT_R8G8B8A8_UINT // 4D 8-bit unsigned integer vector
4.InputSlot:指定当前元素来自于哪个输入槽(input slot)。Direct3D支持16个输入槽(索引依次为 0到15),通过这些输入槽我们可以向着色器传入顶点数据。例如,当一个顶点由位置元素和颜色元素组成时,我们既可以使用一个输入槽传送两种元素,也可以将两种元素分开,使用第一个输入槽传送顶点元素,使用第二个输入槽传送颜色元素。Direct3D可以将来自于不同输入槽的元素重新组合为顶点。在本书中,我们只使用一个输入槽,但是在本章结尾的练习2中我们会引导读者做一个使用两个输入槽的练习。
5.AlignedByteOffset:对于单个输入槽来说,该参数表示从顶点结构体的起始位置到顶点元素的起始位置之间的字节偏移量。例如在下面的顶点结构体中,元素Pos的字节偏移量为0,因为它的起始位置与顶点结构体的起始位置相同;元素Normal的字节偏移量为12,因为必须跳过由Pos占用的字节才能到达Normal的起始位置;元素Tex0的字节偏移量为24,因为必须跳过由Pos和Normal占用的字节才能到达Tex0的起始位置;元素Tex1的字节偏移量为32,因为必须跳过由Pos,Normal和Tex0占用的字节才能到达Tex1的起始位置。
struct Vertex2{XMFLOAT3 Pos; // 0-byte offsetXMFLOAT3 Normal; // 12-byte offsetXMFLOAT2 Tex0; // 24-byte offsetXMFLOAT2 Tex1; // 32-byte offset};
6.InputSlotClass:目前指定为D3D11_INPUT_PER_VERTEX_DATA;其他选项用于高级实例技术。
7.InstanceDataStepRate:目前指定为0;其他值只用于高级实例技术。
对于前面的两个示例顶点结构体Vertex1和Vertex2来说,对应的输入布局描述为:
D3D11_INPUT_ELEMENT_DESC desc1[]={{"POSITION", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 0,D3D11_INPUT_PER_VERTEX_DATA, 0},{"COLOR", 0, DXGI_FORMAT_R32G32B32A32_FLOAT, 0, 12,D3D11_INPUT_PER_VERTEX_DATA, 0}};D3D11_INPUT_ELEMENT_DESC desc2[]={{"POSITION", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 0,D3D11_INPUT_PER_VERTEX_DATA, 0},{"NORMAL", 0, DXGI_FORMAT_R32G32B32_FLOAT,0, 12,D3D11_INPUT_PER_VERTEX_DATA, 0},{"TEXCOORD", 0, DXGI_FORMAT_R32G32_FLOAT, 0, 24,D3D11_INPUT_PER_VERTEX_DATA, 0}{"TEXCOORD", 1, DXGI_FORMAT_R32G32_FLOAT, 0, 32,D3D11_INPUT_PER_VERTEX_DATA, 0}};
指定了输入布局描述之后,我们就可以使用ID3D11Device::CreateInputLayout方法获取一个表示输入布局的ID3D11InputLayout接口的指针:
HRESULT ID3D11Device::CreateInputLayout(
const D3D11_INPUT_ELEMENT_DESC *pInputElementDescs,
UINT NumElements,
const void *pShaderBytecodeWithInputSignature,
SIZE_T BytecodeLength,
ID3D11InputLayout **ppInputLayout);
1.pInputElementDescs:一个用于描述顶点结构体的D3D11_INPUT_ELEMENT_DESC数组。
2.NumElements:D3D11_INPUT_ELEMENT_DESC数组的元素数量。
3.pShaderBytecodeWithInputSignature:指向顶点着色器参数的字节码的指针。
4.BytecodeLength:顶点着色器参数的字节码长度,单位为字节。
5.ppInputLayout:返回创建后的ID3D11InputLayout指针。
我们需要进一步解释一下第3个参数的含义。本质上,顶点着色器以一组顶点元素作为它的输入参数——也就是所谓的输入签名(input signature)。自定义顶点结构体中的元素必须被映射为与它们对应的顶点着色器参数,图6.1解释了这一问题。通过在创建输入布局时传入顶点着色器签名,Direct3D在创建时就可以验证输入布局是否与输入签名匹配,并建立从顶点结构体到着色器参数之间的映射关系。一个ID3D11InputLayout对象可以在多个参数完全相同的着色器中重复使用。
假设有下列输入参数和顶点结构:
VertexOut VS(float3 Pos:POSITION, float4 Color:COLOR,float3 Normal: NORMAL){ }struct Vertex{XMFLOAT3 Pos ;XMFLOAT4 Color;};
这样会产生错误,VC++的调试输出窗口会显示以下信息:
D3D11:ERROR:ID3D11Device::CreateInputLayout:The provided inputsignature expects to read an element with SemanticName/Index:'NORMAL'/0, but the declaration doesn't provide a matching name.
假如顶点结构和输入参数与输入元素匹配,但类型不同:
VertexOut VS(int3 Pos:POSITION, float4 Color:COLOR) { }struct Vertex{XMFLOAT3 Pos;XMFLOAT4 Color;} ;
这样做是可行的,因为Direct3D允许输入寄存器中的字节被重新解释。但是,VC ++调试输出窗口会显示以下信息:
D3D11:WARNING:ID3D11Device::CreateInputLayout:The provided inputsignature expects to read an element with SemanticName/Index:'POSITION'/0 and component(s) of the type 'int32'. However,thematching entry in the InputLayout declaration, element[0],specifies mismatched format:'R32G32B32_FLOAT'.This is not an error,since behavior is well defined :The element format determines whatdata conversion algorithm gets applied before it shows up in ashader register.Independently,the shader input signature defineshow the shader will interpret the data that has been placed in itsinput registers,with no change in the bits stored.It is valid forthe application to reinterpret data as a different type once it isin the vertex shader,so this warning is issued just in case reinte rpretation was not intended by the author.
下面的代码说明了该如何调用ID3D11Device::CreateInputLayout方法。注意,这些代码涉及了一些我们还未讨论的内容(比如ID3D11Effect)。本质上,一个effect可以封装一个或多个pass,而每个pass都会与一个顶点着色器相连。所以,我们可以从effect中得到有关pass的描述信息(D3D11_PASS_DESC),然后再从中得到顶点着色器的输入签名。
ID3D11Effect* mFX;ID3D11EffectTechnique* mTech;ID3D11InputLayout* mVertexLayout;/* ...create the effect... */mTech = mFX->GetTechniqueByName("Tech");D3D11_PASS_DESC PassDesc;mTech->GetPassByIndex(0)->GetDesc(&PassDesc);HR(md3dDevice->CreateInputLayout(vertexDesc, 4,PassDesc.pIAInputSignature, PassDesc.IAInputSignatureSize,&mVertexLayout));
创建了输入布局对象之后,它不会自动绑定到设备上。我们必须调用下面的语句来实现绑定:
ID3D11InputLayout* mVertexLayout;/* ...create the input layout... */md3dImmediateContext->IASetInputLayout(mVertexLayout);
如果你打算用一个输入布局来绘制一些物体,然后再使用另一个的布局来绘制另一些物体,那你必须按照下面的形式来组织代码:
md3dImmediateContext->IASetInputLayout(mVertexLayout1);/* ...draw objects using input layout 1... */md3dImmediateContext->IASetInputLayout(mVertexLayout2);/* ...draw objects using input layout 2... */
换句话说,当一个ID3D11InputLayout对象被绑定到设备上时,如果不去改变它,那么它会始终驻留在那里。
6.2 顶点缓冲
为了让GPU访问顶点数组,我们必须把它放置在一个称为缓冲(buffer)的特殊资源容器中,该容器由ID3D11Buffer接口表示。
用于存储顶点的缓冲区称为顶点缓冲(vertex buffer)。Direct3D缓冲不仅可以存储数据,而且还说明了如何访问数据以及数据被绑定到图形管线的那个阶段。要创建一个顶点缓冲,我们必须执行以下步骤:
1.填写一个D3D11_BUFFER_DESC结构体,描述我们所要创建的缓冲区。
2.填写一个D3D11_SUBRESOURCE_DATA结构体,为缓冲区指定初始化数据。
3.调用ID3D11Device::CreateBuffer方法来创建缓冲区。
D3D11_BUFFER_DESC结构体的定义如下:
typedef struct D3D11_BUFFER_DESC{UINT ByteWidth;D3D11_USAGE Usage;UINT BindFlags;UINT CPUAccessFlags;UINT MiscFlags;UINT StructureByteStride;} D3D11_BUFFER_DESC;
1.ByteWidth:我们将要创建的顶点缓冲区的大小,单位为字节。
2.Usage:一个用于指定缓冲区用途的D3D11_USAGE枚举类型成员。有4个可选值:
(a)D3D10_USAGE_DEFAULT:表示GPU会对资源执行读写操作。在使用映射API(例如ID3D11DeviceContext::Map)时,CPU在使用映射API时不能读写这种资源,但它能使用ID3D11DeviceContext::UpdateSubresource。ID3D11DeviceContext::Map方法会在6.14节中介绍。
(b)D3D11_USAGE_IMMUTABLE:表示在创建资源后,资源中的内容不会改变。这样可以获得一些内部优化,因为GPU会以只读方式访问这种资源。除了在创建资源时CPU会写入初始化数据外,其他任何时候CPU都不会对这种资源执行任何读写操作,我们也无法映射或更新一个immutable资源。
(c)D3D11_USAGE_DYNAMIC:表示应用程序(CPU)会频繁更新资源中的数据内容(例如,每帧更新一次)。GPU可以从这种资源中读取数据,使用映射API(ID3D11DeviceContext::Map)时,CPU可以向这种资源中写入数据。因为新的数据要从CPU内存(即系统RAM)传送到GPU内存(即显存),所以从CPU动态地更新GPU资源会有性能损失;若非必须,请勿使用D3D11_USAGE_DYNAMIC。
(d)D3D11_USAGE_STAGING:表示应用程序(CPU)会读取该资源的一个副本(即,该资源支持从显存到系统内存的数据复制操作)。显存到系统内存的复制是一个缓慢的操作,应尽量避免。使用ID3D11DeviceContext::CopyResource和ID3D11DeviceContext::CopySubresourceRegion方法可以复制资源,在12.3.5节会介绍一个复制资源的例子。
3.BindFlags:对于顶点缓冲区,该参数应设为D3D11_BIND_VERTEX_BUFFER。
4.CPUAccessFlags:指定CPU对资源的访问权限。设置为0则表示CPU无需读写缓冲。如果CPU需要向资源写入数据,则应指定D3D11_CPU_ACCESS_WRITE。具有写访问权限的资源的Usage参数应设为D3D11_USAGE_DYNAMIC或D3D11_USAGE_STAGING。如果CPU需要从资源读取数据,则应指定D3D11_CPU_ACCESS_READ。具有读访问权限的资源的Usage参数应设为D3D11_USAGE_STAGING。当指定这些标志值时,应按需而定。通常,CPU从Direct3D资源读取数据的速度较慢。CPU向资源写入数据的速度虽然较快,但是把内存副本传回显存的过程仍很耗时。所以,最好的做法是(如果可能的话)不指定任何标志值,让资源驻留在显存中,只用GPU来读写数据。
5.MiscFlags:我们不需要为顶点缓冲区指定任何杂项(miscellaneous)标志值,所以该参数设为0。有关D3D11_RESOURCE_MISC_FLAG枚举类型的详情请参阅SDK文档。
6.StructureByteStride:存储在结构化缓冲中的一个元素的大小,以字节为单位。这个属性只用于结构化缓冲,其他缓冲可以设置为0。所谓结构化缓冲,是指存储其中的元素大小都相等的缓冲。
D3D11_SUBRESOURCE_DATA结构体的定义如下:
typedef struct D3D11_SUBRESOURCE_DATA {const void *pSysMem;UINT SysMemPitch;UINT SysMemSlicePitch;} D3D11_SUBRESOURCE_DATA;
1.pSysMem:包含初始化数据的系统内存数组的指针。当缓冲区可以存储n个顶点时,对应的初始化数组也应至少包含n个顶点,从而使整个缓冲区得到初始化。
2.SysMemPitch:顶点缓冲区不使用该参数。
3.SysMemSlicePitch:顶点缓冲区不使用该参数。
下面的代码创建了一个只读的顶点缓冲区,并以中心在原点上的立方体的8顶点来初始化该缓冲区。之所以说该缓冲区是只读的,是因为当立方体创建后相关的几何数据从不改变——始终保持为一个立方体。另外,我们为每个顶点指定了不同的颜色;这些颜色将用于立方体着色,我们会在本章随后的小节中对此进行讲解。
// 定义在d3dUtil.h中的Colors命名空间//// #define XMGLOBALCONST extern CONST __declspec(selectany)// 1. extern so there is only one copy of the variable, and not a separate// private copy in each .obj.// 2. __declspec(selectany) so that the compiler does not complain about// multiple definitions in a .cpp file (it can pick anyone and discard// the rest because they are constant--all the same).namespace Colors{XMGLOBALCONST XMVECTORF32 White = {1.0f, 1.0f, 1.0f, 1.0f};XMGLOBALCONST XMVECTORF32 Black = {0.0f, 0.0f, 0.0f, 1.0f};XMGLOBALCONST XMVECTORF32 Red = {1.0f, 0.0f, 0.0f, 1.0f};XMGLOBALCONST XMVECTORF32 Green = {0.0f, 1.0f, 0.0f, 1.0f};XMGLOBALCONST XMVECTORF32 Blue = {0.0f, 0.0f, 1.0f, 1.0f};XMGLOBALCONST XMVECTORF32 Yellow = {1.0f, 1.0f, 0.0f, 1.0f};XMGLOBALCONST XMVECTORF32 Cyan = {0.0f, 1.0f, 1.0f, 1.0f};XMGLOBALCONST XMVECTORF32 Magenta = {1.0f, 0.0f, 1.0f, 1.0f};XMGLOBALCONST XMVECTORF32 Silver = {0.75f, 0.75f, 0.75f, 1.0f};XMGLOBALCONST XMVECTORF32 LightSteelBlue = {0.69f, 0.77f, 0.87f, 1.0f};}// 创建顶点缓冲Vertex vertices[] ={{ XMFLOAT3(-1.0f, -1.0f, -1.0f), (const float*)&Colors::White },{ XMFLOAT3(-1.0f, +1.0f, -1.0f), (const float*)&Colors::Black },{ XMFLOAT3(+1.0f, +1.0f, -1.0f), (const float*)&Colors::Red },{ XMFLOAT3(+1.0f, -1.0f, -1.0f), (const float*)&Colors::Green },{ XMFLOAT3(-1.0f, -1.0f, +1.0f), (const float*)&Colors::Blue },{ XMFLOAT3(-1.0f, +1.0f, +1.0f), (const float*)&Colors::Yellow },{ XMFLOAT3(+1.0f, +1.0f, +1.0f), (const float*)&Colors::Cyan },{ XMFLOAT3(+1.0f, -1.0f, +1.0f), (const float*)&Colors::Magenta }};D3D11_BUFFER_DESC vbd;vbd.Usage = D3D11_USAGE_IMMUTABLE;vbd.ByteWidth = sizeof(Vertex) * 8;vbd.BindFlags = D3D11_BIND_VERTEX_BUFFER;vbd.CPUAccessFlags = 0;vbd.MiscFlags = 0;vbd.StructureByteStride = 0;D3D11_SUBRESOURCE_DATA vinitData;vinitData.pSysMem = vertices;ID3D11Buffer * mVB;HR(md3dDevice->CreateBuffer(&vbd, &vinitData, &mVB));Vertex类型和颜色由以下结构定义:struct Vertex{XMFLOAT3 Pos;XMFLOAT4 Color;};
在创建顶点缓冲区后, 我们必须把它绑定到设备的输入槽上,只有这样才能将顶点送入管线。这一工作使用如下方法完成:
void ID3D11DeviceContext::IASetVertexBuffers(UINT StartSlot,UINT NumBuffers,ID3D10Buffer *const *ppVertexBuffers,const UINT *pStrides,const UINT *pOffsets);
1.StartSlot:顶点缓冲区所要绑定的起始输入槽。一共有16个输入槽,索引依次为0到15。
2.NumBuffers:顶点缓冲区所要绑定的输入槽的数量,如果起始输入槽为索引k,我们绑定了n个缓冲,那么缓冲将绑定在索引为Ik,Ik+1……Ik+n-1的输入槽上。
3.ppVertexBuffers:指向顶点缓冲区数组的第一个元素的指针。
4.pStrides:指向步长数组的第一个元素的指针(该数组的每个元素对应一个顶点缓冲区,也就是,第i个步长对应于第i个顶点缓冲区)。这个步长是指顶点缓冲区中的元素的字节长度。
5.pOffsets:指向偏移数组的第一个元素的指针(该数组的每个元素对应一个顶点缓冲区,也就是,第i个偏移量对应于第i个顶点缓冲区)。这个偏移量是指从顶点缓冲区的起始位置开始,到输入装配阶段将要开始读取数据的位置之间的字节长度。当希望跳过顶点缓冲区前面的一部分数据时,可以使用该参数。
因为IASetVertexBuffers方法支持将一个顶点缓冲数组设置到不同的输入槽中,因此这个方法看起来有点复杂。但是,大多数情况下我们只使用一个输入槽。本章最后的练习部分你会遇到使用两个输入插槽的情况。
顶点缓冲区会一直绑定在输入槽上时。如果不改变输入槽的绑定对象,那么当前的顶点缓冲区会一直驻留在那里。所以,当使用多个顶点缓冲区时,你可以按照下面的形式组织代码:
ID3D11Buffer* mVB1; // stores vertices of type Vertex1ID3D11Buffer* mVB2; // stores vertices of type Vertex2/*...Create the vertex buffers...*/UINT stride = sizeof(Vertex1);UINT offset = 0;md3dImmediateContext->IASetVertexBuffers(0, 1, &mVB1, &stride, &offset);/* ...draw objects using vertex buffer 1... */stride = sizeof(Vertex2);offset = 0;md3dImmediateContext->IASetVertexBuffers(0, 1, &mVB2, &stride, &offset);/* ...draw objects using vertex buffer 2... */
把顶点缓冲区指定给输入槽并不能实现顶点的绘制;它只是绘制前的准备工作(准备把顶点传送到管线)。顶点的实际绘制工作由ID3D11DeviceContext::Draw方法完成:
void ID3D11DeviceContext::Draw(UINT VertexCount, UINT StartVertexLocation);
这两个参数定义了在顶点缓冲区中所要绘制的顶点的范围,如图6.2所示。
图6.2 StartVertexLocation 指定了在顶点缓冲区中所要绘制的第一个顶点的索引(从0开始)。VertexCount指定了所要绘制的顶点的数量。
6.3 索引和索引缓冲
由于索引要由GPU访问,所以它们必须放在一个特定的资源容器中,该容器称为索引缓冲(index buffer)。创建索引缓冲的过程与创建顶点缓冲的过程非常相似,只不过索引缓冲存储的是索引而非顶点。所以,这里不再赘述之前讨论过的内容,我们直接给出一个创建索引缓冲区的示例:
UINT indices [24] = {0, 1, 2, // Triangle 00, 2, 3, // Triangle 10, 3, 4, // Triangle 20, 4, 5, // Triangle 30, 5, 6, // Triangle 40, 6, 7, // Triangle 50, 7, 8, // Triangle 60, 8, 1 // Triangle 7} ;
// 要创建的索引的描述D3D11_BUFFER_DESC ibd;ibd.Usage = D3D11_USAGE_IMMUTABLE;ibd.ByteWidth = sizeof(UINT) * 24;ibd.BindFlags = D3D11_BIND_INDEX_BUFFER;ibd.CPUAccessFlags = 0;ibd.MiscFlags = 0;ibd.StructureByteStride = 0;// 设定用于初始化索引缓冲的数据D3D11_SUBRESOURCE_DATA iinitData;iinitData.pSysMem = indices;// 创建索引缓冲ID3D11Buffer* mIB;HR(md3dDevice->CreateBuffer(&ibd, &iinitData, &mIB));
与顶点缓冲区相同,所有的Direct3D资源在使用之前都必须先绑定到管线上。我们使用ID3D11DeviceContext::IASetIndexBuffer方法将一个索引缓冲区绑定到输入装配阶段。下面是一个例子:
md3dImmediateContext->IASetIndexBuffer(mIB, DXGI_FORMAT_R32_UINT, 0);
第2个参数表示索引格式。在本例中,我们使用的是32位无符号整数(DWORD);所以,该参数设为DXGI_FORMAT_R32_UINT。如果你希望节约一些内存,不需要这大的取值范围,那么可以改用16位无符号整数。还要注意的是,在IASetIndexBuffer方法中指定的格式必须与D3D11_BUFFER_DESC::ByteWidth数据成员指定的字节长度一致,否则会出现问题。索引缓冲区只支持DXGI_FORMAT_R16_UINT和DXGI_FORMAT_R32_UINT两种格式。第3个参数是一个偏移值,它表示从索引缓冲区的起始位置开始、到输入装配时实际读取数据的位置之间的字节长度。如果希望跳过索引缓冲区前面的一部分数据,那么可以使用该参数。
最后,当使用索引时,我们必须用DrawIndexed方法代替Draw方法:
void ID3D11DeviceContext::DrawIndexed(UINT IndexCount,UINT StartIndexLocation,INT BaseVertexLocation);
1.IndexCount:在当前绘图操作中使用的索引的数量。在一次绘图操作中不一定使用索引缓冲区中的全部索引;也就是说,我们可以绘制索引的一个连续子集。
2.StartIndexLocation:指定从索引缓冲区的哪个位置开始读取索引数据。
3.BaseVertexLocation:在绘图调用中与索引相加的一个整数。
我们通过分析如下情景来解释些参数。假设有三个物体:一个球体、一个立方体和一个圆柱体。每个物体都有它自己的顶点缓冲和索引缓冲。在每个独立的索引缓冲中的索引都与各自独立的顶点缓冲对应。现在,我们把这三个物体的顶点和索引数据合并到一个全局的顶点缓冲和一个全局的索引缓冲中,如图6.3所示(假如有许多小顶点缓冲和索引缓冲,并且它们可以很容易的合并,那么合并它们可以带来性能提升)。在合并之后,索引就不再正确了(因为这些索引是与各自独立的顶点缓冲区对应的,它们与全局顶点缓冲区没有对应关系);所以,这些索引必须被重新计算,使它们与全局顶点缓冲区建立正确的对应关系。
图6.3 将多个顶点缓冲合并为一个大的顶点缓冲,将多个索引缓冲合并为一个大的索引缓冲。
假设原先的立方体索引为0、1、...、numBoxVertices-1,通过个索引可以遍历立方体的所有顶点。在合并之后,索引应该变为firstBoxVertexPos、firstBoxVertexPos+1、...、firstBoxVertexPos+ numBoxVertices -1。所以,要更新索引,我们必须将firstBoxVertexPos与立方体的每个索引相加。而且,我们必须将firstCylVertexPos与圆柱体的每个索引相加。注意,球体的索引不需要修改(因为球体的顶点位置为0)。通常,只要将一个物体在全局顶点缓冲区中的第一个顶点位置与原索引相加就可以得到该物体的新索引值。所以,只要给出物体在全局顶点缓冲中的第一个顶点的位置(例如,BaseVertexLocation),Direct3D就可以在绘图调用中为我们重新计算索引。我们可以使用以下3条语句依次绘制球体、立方体和圆柱体:
md3dImmediateContext->DrawIndexed(numSphereIndices, 0, 0);md3dImmediateContex->DrawIndexed(numBoxIndices, firstBoxIndex, firstBoxVertexPos);md3dImmediateContex->DrawIndexed(numCylIndices, firstCylIndex, firstCylVertexPos);
后面的“Shape”示例程序就用到了这个技术。
6.4 顶点着色器示例
下面是一个顶点着色器的示例,它的代码非常简单:
cbuffer cbPerObject{float4x4 gWVP;};void VS(float3 iPosL : POSITION,float4 iColor : COLOR,out float4 oPosH : SV_POSITION,out float4 oColor : COLOR){// 转换到齐次裁剪空间oPosH = mul(float4(iPosL, 1.0f), gWVP);// 把顶点颜色直接传到像素着色器oColor = iColor;}
着色器使用一种称为高级着色语言(High-Level Shading Language,简称HLSL)的脚本语言来编写,它的语法与C++相似,很容易就能学会。附录B提供了一些有关HLSL的简要概述。在本书中,我们将采用一种基于示例的方式讲解HLSL及着色器编程。也就是,根据贯穿本书的每个演示程序所涉及的技术讲解相关的HLSL概念。着色器通常保存在一种称为effect文件(.fx)的纯文本文件中。我们会在本章随后的小节中讨论effect文件,而现在我们主要讨论顶点着色器。
这里,顶点着色器是一个称为VS的函数。注意,你可以为顶点着色器指定任何有效的函数名。该顶点着色器包含4个参数;前两个是输入参数,后两个是输出参数(由out关键字表示)。HLSL没有类似于C++的引用和指针,所以当一个函数要返回多个值时,我们必须使用结构体或输出参数。
前两个输入参数对应于我们在顶点结构体中定义的数据成员。参数语义“:POSITION”和“:COLOR”用于将顶点结构体的数据成员映射为顶点着色器的输入参数,如图6.4所示。
图6.4 D3D11_INPUT_ELEMENT_DESC数组为每个顶点元素指定了一个相关的语义,而顶点着色器的每个参数也都带有一个附加语义。语义用于建立顶点元素和顶点着色器参数之间的对应关系。
输出参数也带有附加语义(“:SV_POSITION”和“:COLOR”)。这些语义用于将顶点着色器的输出数据映射为下一阶段(几何着色器或像素着色器)的输入数据。注意,SV_POSITION是一个特殊的语义(SV表示系统值,即system value的缩写)。它用于告诉顶点着色器该元素存储的是顶点位置。顶点位置的处理方式与其他顶点属性不同,因为它涉及到一些其他属性所没有的特殊运算,比如裁剪。若不是系统值,那么输出参数的语义名称可以是任何有效的语义名称。
该顶点着色器的代码非常简单。第一行通过与一个4×4矩阵gWorldViewProj相乘,将顶点位置从局部空间变换到齐次裁剪空间,矩阵gWorldViewProj是世界矩阵、观察矩阵和投影矩阵的组合矩阵:
// 转换到齐次裁剪空间
oPosH = mul(float4(iPosL, 1.0f), gWorldViewProj);
构造函数语法“float4(iPosL, 1.0f)”用于创建4D向量,它相当于“float4(iPosL.x, iPosL.y, iPosL.z, 1.0f)”;我们知道,顶点位置是一个点而不是一个向量,所以第4个分量应设为1(即w=1)。float2和float3分别表示2D和3D向量。矩阵变量gWorldViewProj定义在一个常量缓冲区中,我们会在下一节讨论对它进行讨论。内置函数mul用于实现向量-矩阵乘法,它为不同维数的矩阵乘法定义了多个重载版本;例如,该函数可以实现4×4矩阵乘法、3×3矩阵乘法、或者1×3向量与3×3矩阵的向量-矩阵乘法。最后一行是将输入的颜色赋值给输出参数,把颜色传递给管线的下一阶段:
oColor = iColor;
我们可以使用结构体来重写上面的顶点着色器,实现相同的功能:
cbuffer cbPerObject{float4x4 gWVP;};struct VS_IN{float3 posL : POSITION;float4 color : COLOR;};struct VS_OUT{float4 posH : SV_POSITION;float4 color : COLOR;};VS_OUT VS(VS_IN input){VS_OUT output;output.posH = mul(float4(input.posL, 1.0f), gWVP);output.color = input.color;return output;}
注意:当没有几何着色器时,顶点着色器至少要实现投影变换,因为当顶点离开顶点着色器时(在没有几何着色器的情况下),硬件假定顶点位于投影空间。当包含一个几何着色器时,投影工作可以转嫁到几何着色器中完成。
注意:顶点着色器(或几何着色器)不执行透视除法;它只完成投影矩阵部分。透视除法会随后由硬件完成。
6.5 常量缓冲
在上一节的顶点着色器示例中包含如下代码:
cbuffer cbPerObject{float4x4 gWorldViewProj;};
这段代码定义了一个称为cbPerObject的cbuffer对象(constant buffer,常量缓冲)。常量缓冲只是一个用于存储各种变量的数据块,这些变量可以由着色器来访问。在本例中,常量缓冲区只存储了一个称为gWorldViewProj的4×4矩阵,它是世界矩阵、观察矩阵和投影矩阵的组合矩阵,用于将顶点从局部空间变换到齐次裁剪空间。在HLSL中,4×4矩阵由内置的float4x4类型表示;与之类似,要定义一个3×4矩阵和一个2×2矩阵,可以分别使用float3x4和float2x2类型。顶点着色器不能修改常量缓冲中的数据,但是通过effect框架(6.9节),C++应用程序代码可以在运行时修改常量缓冲中的内容。它为C++应用程序代码和effect代码提供了一种有效的通信方式。例如,因为每个物体的世界矩阵各不相同,所以每个物体的“WVP”组合矩阵也各不相同;所以,当使用上述顶点着色器绘制多个物体时,我们必须在绘制每个物体前修改gWorldViewProj变量。
通常的建议是根据变量修改的频繁程度创建不同的常量缓冲。比如,你可以创建下面的常量缓冲:
cbuffer cbPerObject{float4x4 gWVP;};cbuffer cbPerFrame{float3 gLightDirection;float3 gLightPosition;float4 gLightColor;};cbuffer cbRarely{float4 gFogColor;float gFogStart;float gFogEnd;};
在本例中,我们使用了3个常量缓冲区。第1个常量缓冲区存储“WVP”组合矩阵。该变量随物体而定,所以它必须在物体级别上更新。也就是,当我们每帧渲染100个物体时,每帧都要对这个变量更新100次。第2个常量缓冲存储了场景中的灯光变量。这里,我们假设要生成灯光动画,所以些变量必须在每帧中更新一次。最后一个常量缓冲存储了用于控制雾效的变量。这里,我们假设场景的雾效变化频率很低(例如,在游戏的一个特定时段中变化一次)。
对常量缓冲进行分组是为了提高运行效率。当一个常量缓冲区被更新时,它里面的所有变量都会同时更新;所以,根据它们的更新频率进行分组,可以减少不必要的更新操作,提高运行效率。
6.6 像素着色器示例
5.10.3节说过,由顶点着色器(或几何着色器)输出的顶点属性都已经过了插值处理。这些插值随后会作为像素着色器(pixel shader)的输入数据传入像素着色器。假设这里没有几何着色器,图6.5说明了目前顶点数据的流动过程。
图6.5 D3D11_INPUT_ELEMENT_DESC数组为每个顶点元素指定了一个关联语义,而顶点着色器的每个参数都有一个附加语义。这些语义描述了顶点元素和顶点着色器参数之间的对应关系。同样,顶点着色器的每个输出参数和像素着色器的每个输入参数也都有一个附加语义。这些语义用于将顶点着色器的输出参数映射为像素着色器的输入参数。
与顶点着色器相似,像素着色器也是一个函数,只不过它处理的数据是像素片段(pixel fragment)。像素着色器的任务是为每个像素片段计算一个颜色值。请注意,像素和像素片段的含义不同,像素片段可能不会被存入后台缓冲区;例如,像素着色器可以对像素片段进行裁剪(在HLSL中的一个clip函数,它可以终止对一个像素片段的处理工作),或者当一个像素片段没有通过深度测试或模板测试时,它会被丢弃。所以,后台缓冲区中的一个像素可能会对应多个候选像素片段;这就是我们要区分“像素片段”和“像素”这两个术语的原因,虽然有时这两个术语会交替使用,但是读者应该明白它们在特定环境下的特定含义。
下面是一个简单的像素着色器,它与6.4节给出的顶点着色器对应。出于完整性的考虑,
我们将该顶点着色器再次列了出来。
cbuffer cbPerObject{float4x4 gWorldViewProj;};void VS(float3 iPosL : POSITION, float4 iColor : COLOR,out float4 oPosH : SV_POSITION,out float4 oColor : COLOR){// 转换到齐次剪裁空间oPosH = mul(float4(iPosL, 1.0f), gWVP);// 将顶点颜色直接传递到像素着色器oColor = iColor;}float4 PS(float4 posH: SV_POSITION,float4 color : COLOR) : SV_TARGET{return color;}
在本例中,像素着色器只是简单地返回插值颜色。注意,像素着色器的输入参数和顶点着色器的输出参数是对应的;这是一项规定。该像素着色器返回了一个4D颜色值。函数参数列表中的SV_TARGET语义表示返回值与渲染目标视图的格式一致。
我们也可以使用输入/输出结构体的形式重写上面的顶点着色器和像素着色器代码。我们将语义附加到输入/输出结构的成员上,使用return语句用于输出代替输出参数。
cbuffer cbPerObject{float4x4 gWorldViewProj;};struct VertexIn{float3 PosL : POSITION;float4 Color : COLOR;};struct VertexOut{float4 PosH : SV_POSITION;float4 Color : COLOR;};VertexOut VS(VertexIn vin){VertexOut vout;// 转换到齐次剪裁空间vout.PosH = mul(float4(vin.PosL, 1.0f), gWorldViewProj);// 将顶点颜色直接传递到像素着色器vout.Color = vin.Color;return vout;}float4 PS(VertexOut pin) : SV_Target{return pin.Color;}
6.7 渲染状态
从本质上讲,Direct3D是一个状态机(state machine)。在我们改变它的状态之前,驻留在状态机内的当前状态是不会改变的。例如,我们在6.1节、6.2节和6.3节中看到,当顶点缓冲和索引缓冲绑定到管线的输入装配阶段时,如果我们不绑定其他缓冲,那么它们就会一直驻留在那里;同样,在没有改变图元拓扑之前,当前的图元拓扑设置会一直有效。另外,Direct3D将配置信息封装在状态组中,我们可以使用如下3种状态组配置Direct3D:
1.ID3D11RasterizerState:该接口表示用于配置管线光栅化阶段的状态组。
2.ID3D11BlendState:该接口表示用于配置混合操作的状态组。我们将在有关混合的章节讨论这些状态;默认情况下,混合处于禁用状态,所以我们可以先不考虑这方面的问题。
3.ID3D11DepthStencilState:该接口表示用于配置深度测试和模板测试的状态组。我们将在有关模板缓冲的章节讨论这些状态;默认情况下,模板是禁用的,所以我们可以先不考虑这方面的问题。而默认的深度测试是我们在4.1.5节描述的标准深度测试。
目前,我们唯一需要关心的状态块接口是ID3D11RasterizerState。我们可以通过填充一个D3D11_RASTERIZER_DESC结构体并调用如下方法来创建ID3D11RasterizerState对象:
HRESULT ID3D11Device::CreateRasterizerState(const D3D11_RASTERIZER_DESC *pRasterizerDesc,ID3D11RasterizerState **ppRasterizerState);
第1个参数是一个指向D3D11_RASTERIZER_DESC结构体的指针,该结构体用于描述所要创建的光栅化状态块;第二个参数用于返回创建后的ID3D11RasterizerState对象。
D3D11_RASTERIZER_DESC结构体的定义如下:
typedef struct D3D11_RASTERIZER_DESC{D3D11_FILL_MODE FillMode; // Default:D3D11_FILL_SOLIDD3D11_CULL_MODE CullMode; // Default:D3D11_CULL_BACKBOOL FrontCounterClockwise; // Default:falseINT DepthBias; // Default:0FLOAT DepthBiasClamp; // Default:0.0fFLOAT SlopeScaledDepthBias; // Default:0.0fBOOL DepthClipEnable; // Default:trueBOOL ScissorEnable; // Default:falseBOOL MultisampleEnable; // Default:falseBOOL AntialiasedLineEnable; // Default:false} D3D11_RASTERIZER_DESC;
这里面的大部分成员是高级选项或者不常用的选项;因此,我们在这里只讲解前3个成员的含义,其他成员的详情请参见SDK文档。
1.FillMode:当指定为D3D11_FILL_WIREFRAME时,表示以线框模式渲染几何体;当指定为D3D11_FILL_SOLID时,表示以实心模式渲染几何体,这是默认值。
2.CullMode:当指定为D3D11_CULL_NONE时,表示禁用背面消隐功能;当指定为D3D11_CULL_FRONT时,表示消隐朝前的三角形;当指定为D3D11_CULL_BACK时,表示消隐朝后的三角形,这是默认值。
3.FrontCounterClockwise:当设为false时,表示按顺时针方向环绕的三角形(相对于观察者)是朝前的,而按逆时针方向环绕的三角形(相对于观察者)是朝后的,这是默认值。当设为true时,表示按逆时针方向环绕的三角形(相对于观察者)是朝前的,而按顺时针方向环绕的三角形(相对于观察者)是朝后的。
在创建ID3D11RasterizerState对象之后,我们可以使用个新的状态块来更新设备:
void ID3D11DeviceContext::RSSetState(ID3D11RasterizerState *pRasterizerState);下面的代码示范了如何通过创建一个光栅化状态块来禁用背面消隐:D3D11_RASTERIZER_DESC rsDesc;ZeroMemory(&rsDesc, sizeof(D3D11_RASTERIZER_DESC));rsDesc.FillMode = D3D11_FILL_SOLID;rsDesc.CullMode = D3D11_CULL_NONE;rsDesc.FrontCounterClockwise = false;rsDesc.DepthClipEnable = true;HR(md3dDevice->CreateRasterizerState(&rsDesc,&mNoCullRS));
注意:因为没有设置的属性的默认值是0或false,所以使用ZeroMemory可以正常初始化这些属性。但是,若有些属性默认值不是0或是true,那么你就必须显式地设置这些值。
注意,对于一个应用程序来说,你可能会用到多个不同的ID3D11RasterizerState对象。所以,你应该在初始化时把它们都创建出来,然后在应用程序的更新/绘图代码中切换些状态。例如,场景中有两个物体,你希望先以线框模式绘制第一个物体,然后再以实心模式绘制第二个物体。那么,你就应该创建两个ID3D11RasterizerState对象,当绘制物体时,切换这两种不同的状态:
// Create render state objects at initialization time.ID3D11RasterizerState* mWireframeRS;ID3D11RasterizerState* mSolidRS;...// Switch between the render state objects inthe draw function.md3dDeviceContext->RSSetState(mSolidRS);DrawObject();md3dDeviceContext->RSSetState(mWireframeRS);DrawObject();
注意,Direct3D不会从一种状态自动恢复到先前状态。所以,当绘制物体时,你应该根据需要手工指定状态对象。错误地假设设备的当前状态必然会导致错误的渲染结果。
每个状态块都有一个默认状态。我们可以通过在调用RSSetState方法时指定空值来恢复默认状态:
md3dDeviceContext->RSSetState( 0 );
注意:应用程序无需在运行时创建额外的渲染状态组。所以,应该在初始化时就定义并创建所有需要用到的状态组。而且,因为无需在运行时修改状态组,你可以在渲染代码中对这些状态组提供全局只读访问。例如,你可以将所有状态组对象放置在一个静态类中,通过这个方法,你就无需创建重复的状态组,渲染代码的不同部分都能共享这个渲染状态组对象。
6.8 Effects
effect框架是一组用于管理着色器程序和渲染状态的工具代码。例如,你可能会使用不同的effect绘制水、云、金属物体和动画角色。每个effect至少要由一个顶点着色器、一个像素着色器和渲染状态组成。
在Direct3D 11中,effects框架已从D3DX库中移除,你必须包含一个单独的头文件(d3dx11Effect.h),链接一个单独的库文件(D3DX11Effects.lib用于release生成,而D3DX11EffectsD.lib用于debug生成)。
而且,在Direct3D 11中提供了effect库的完整源代码(DirectX SDK\Samples\C++\Effects11)。因此,你可以根据需要修改effect框架。本书中,我们只是使用、并不会修改effect框架。要使用这个库,首先需要生成Effects11项目的Release和Debug模式,用于获得D3DX11Effects.lib和D3DX11EffectsD.lib文件,除非effect框架进行了更新(例如,新版本的DirectX SDK可能会更新这些文件,这时就需要重新生成.lib文件),这个步骤只需进行一次。d3dx11Effect.h头文件可在DirectX SDK\Samples\C++\Effects11\Inc文件夹中找到。在示例代码中,我们将d3dx11Effect.h,D3DX11EffectsD.lib和D3DX11Effects.lib文件都放在Common文件夹中,这样所有的项目文件都能共享这些文件。
6.8.1 Effect文件
我们已经讨论了顶点着色器、像素着色器,并对几何着色器、曲面细分着色器进行了简要概述。我们还讨论了常量缓冲,它可以用于存储由着色器访问的“全局”变量。这些代码通常保存在一个effect文件(.fx)中,它是一个纯文本文件中(就像是C++代码保存在.h和.cpp文件中一样)。除了着色器和常量缓冲之外,每个effect文件至少还要包含一个technique,而每个technique至少要包含一个pass。
1.technique11:一个technique由一个或多个pass组成,用于创建一个渲染技术。每个pass实现一种不同的几何体渲染方式,按照某些方式将多个pass的渲染结果混合在一起就可以得到我们最终想要的渲染结果。例如,在地形渲染中我们将使用多通道纹理映射技术(multi-pass texturing technique)。注意,多通道技术通常会占用大量的系统资源,因为每个pass都要对几何体进行一次渲染;不过,要实现某些渲染效果,我们必须使用多通道技术。
2.pass:一个pass由一个顶点着色器、一个可选的几何着色器、一个像素着色器和一些渲染状态组成。这些部分定义了pass的几何体渲染方式。像素着色器也是可选的(很罕见)。例如,若我们只想绘制深度缓冲,不想绘制后台缓冲,在这种情况下我们就不需要像素着色器计算像素的颜色。
注意:techniques也可以组合在一起成为effect组。如果你没有显式地定义一个effect组,那么编译器会创建一个匿名effect组,把所有technique包含在effect文件中。本书中,我们不显式地定义effect组。
下面是本章演示程序使用的effect文件:
cbuffer cbPerObject{float4x4 gWorldViewProj;};struct VertexIn{float3 PosL : POSITION;float4 Color : COLOR;};struct VertexOut{float4 PosH : SV_POSITION;float4 Color : COLOR;};VertexOut VS(VertexIn vin){VertexOut vout;// 转换到齐次剪裁空间vout.PosH = mul(float4(vin.PosL, 1.0f), gWorldViewProj);// 将顶点颜色直接传递到像素着色器vout.Color = vin.Color;return vout;}float4 PS(VertexOut pin) : SV_Target{return pin.Color;}technique11 ColorTech{pass P0{SetVertexShader( CompileShader( vs_5_0, VS() ) );SetPixelShader( CompileShader( ps_5_0, PS() ) );}}
注意:点和向量可以在许多不同的空间中描述(例如,局部空间、世界空间、观察空间、齐次裁剪空间)。当阅读代码时,有时很难看出点和向量的坐标系是相对于哪个坐标系的。所以,我们经常使用下面的后缀来表示空间:L(局部空间)、W(世界空间)、V(观察空间)、H(齐次裁剪空间)。下面是一些例子:
float3 iPosL; // local spacefloat3 gEyePosW; // world spacefloat3 normalV; // view spacefloat4 posH; // homogeneous clip space
前面提到,pass可以包含渲染状态。也就是,状态块可以直接在effect文件中创建和指定。当effect需要特定的渲染状态时,这种方式非常实用;但是,当某些effect需要在运行过程中改变渲染状态时,我们更倾向于在应用程序层执行状态设定,因为这样进行状态切换更方便一些。下面的代码示范了如何在一个effect文件中创建和指定光栅化状态块。
RasterizerState Wireframe{FillMode = Wireframe;CullMode = Back;FrontCounterClockwise = false;// 我们没有设置的属性使用默认值};technique11 ColorTech{pass P0{SetVertexShader( CompileShader( vs_5_0, VS() ) );SetPixelShader( CompileShader( ps_5_0, PS() ) );SetRasterizerState(Wireframe);}}
可以看到,在光栅化状态对象中定义的常量与C++中的枚举成员基本相同,只是省去了前缀而已(例如,D3D11_FILL_和D3D11_CULL_)。
注意:由于effect通常保存在扩展名为.fx的文件中,所以在修改effect代码之后,不必重新编译C++源代码。
6.8.2 编译着色器
创建一个effect的第一步是编译定义在.fx文件中的着色器程序,可以由下面的D3DX方法完成:
HRESULT D3DX11CompileFromFile (LPCTSTR pSrcFile ,CONST D3D10_SHADE R_MACRO *pDefines,LPD3D10INCLUDE pInclude ,LPCSTR pFunctionName ,LPCSTR pProfile,UINT Flags 1,UINT Flags 2,ID3DX11ThreadPump *pPump ,ID3D10Blob **ppShader,ID3D10Blob **ppErrorMsgs,HRESULT *pHResult);
1.pSrcFile:.fx文件名,该文件包含了我们所要编译的效果源代码。
2.pDefines:高级选项,我们不使用;请参阅SDK文档。
3.pInclude:高级选项,我们不使用;请参阅SDK文档。
4.pFunctionName:着色器入口函数的名字。只用于单独编译着色器程序的情况。当使用effect框架时设置为null,这是因为在effect文件中已经定义了入口点。
5.pProfile:用于指定着色器版本的字符串。对于Direct3D 11来说,我们使用的着色器版本为5.0(“fx_5_0”)。
6.Flags1:用于指定着色器代码编译方式的标志值。SDK文档列出了很多标志值,但本书只使用其中的2个:
- D3D10_SHADER_DEBUG:以调试模式编译着色器。
- D3D10_SHADER_SKIP_OPTIMIZATION:告诉编译器不做优化处理(用于进行调试)。
7.Flags2:高级选项,我们不使用;请参阅SDK文档。
8.pPump:指向线程泵的指针,多线程编程时使用,是高级选项,我们不使用;请参阅SDK文档。本书中这个值都设为null。
9.ppShader:返回一个指向ID3D10Blob数据对象的指针,这个数据对象保存了经过编译的代码。
10.ppErrorMsgs:返回一个指向ID3D10Blob数据对象的指针,这个数据对象存储了一个包含错误信息的字符串。
11.pHResult:在使用异步编译时,用于获得返回的错误代码。仅当使用pPump时才使用该参数;我们在本书中将该参数设为空值。
注意:1.除了可以编译在.fx文件内的着色器代码,这个方法也可以编译单独的着色器代码。有些程序不使用effect框架,它们会单独的定义和编译自己的着色器代码。
2.方法中的指向“D3D10”的引用并不是打印错误。因为D3D11编译器是建立在D3D10的编译器之上的,所以Direct3D 11开发组就没有修改某些标识的名称。
3.ID3D10Blob只是一个通用内存块,它有两个方法:
(a)LPVOID GetBufferPointer:返回指向数据的一个void*,所以在使用时应该对它执行相应的类型转换(具有请参见下面的示例)。
(b)SIZE_T GetBufferSize:返回缓冲的大小,以字节为单位。
编译完成后,我们就可以使用下面的方法创建一个effect(用ID3DXEffect11接口表示):
HRESULT D3DX11CreateEffectFromMemory(void *pData,SIZE_T DataLength,UINT FXFlags ,ID3D11Device *pDevice,ID3DX11Effect **ppEffect);
1.pData:指向编译好的effect数据的指针。
2.DataLength:effect数据的长度,以字节为单位。
3.FXFlags:Effect标识必须与定义在D3DX11CompileFromFile方法中的Flags2匹配。
4.pDevice:指向Direct3D 11设备的指针。
5.ppEffect:指向创建好的effect的指针。
下面的代码演示了如何编译并创建一个effect:
DWORD shaderFlags = 0;#ifdefined (DEBUG)||defined(_DEBUG)shaderFlags |= D3D10_SHADER_DEBUG;shaderFlags |= D3D10_SHADER_SKIP_OPTIMIZATION ;#endifID3D10Blob * compiledShader = 0;ID3D10Blob * compilationMsgs = 0;HRESULT hr = D3DX11CompileFromFile(L"color.fx", 0,0, 0, "fx_5_0", shaderFlags,0, 0, &compiledShader, &compilationMsgs, 0);// compilationMsgs包含错误或警告的信息if(compilationMsgs ! = 0){MessageBoxA(0, (char*)compilationMsgs->GetBufferPointer(), 0, 0);ReleaseCOM(compilationMsgs);}// 就算没有compilationMsgs,也需要确保没有其他错误if(FAILED(hr)){DXTrace(__FILE__,(DWORD)__LINE__,hr,L"D3DX11Compile FromFile",true);}ID3DX11Effect* mFX;HR(D3DX11CreateEffectFromMemory(compiledShader->Ge tBufferPointer(),compiledShader->Ge tBufferSize(),0, md3dDevice, &mFX));// 编译完成释放资源ReleaseCOM(compiledShader);
注意:创建Direct3D资源代价昂贵,尽量在初始化阶段完成,即创建输入布局、缓冲、渲染状态对象和effect应该总在初始化阶段完成。
6.8.3 在C++应用程序中与Effect进行交互
C++应用程序代码通常要与effect进行交互;尤其是C++应用程序经常要更新常量缓冲中的变量。例如,在一个effect文件中,我们有如下常量缓冲定义:
cbuffer cbPerObject{float4x4 gWVP;float4 gColor;float gSize;int gIndex;bool gOptionOn;};
通过ID3D11Effect接口,我们可以获得指向常量缓冲变量的指针:
ID3D11EffectMatrixVariable* fxWVPVar;ID3D11EffectVectorVariable* fxColorVar;ID3D11EffectScalarVariable* fxSizeVar;ID3D11EffectScalarVariable* fxIndexVar;ID3D11EffectScalarVariable* fxOptionOnVar;fxWVPVar = mFX->GetVariableByName("gWVP")->AsMatrix();fxColorVar = mFX->GetVariableByName("gColor")->AsVector();fxSizeVar = mFX->GetVariableByName("gSize")->AsScalar();fxIndexVar = mFX->GetVariableByName("gIndex")->AsScalar();fxOptionOnVar = mFX->GetVariableByName("gOptionOn")->AsScalar();
ID3D11Effect::GetVariableByName方法返回一个ID3D11EffectVariable指针。它是一种通用effect变量类型;要获得指向特定类型变量的指针(例如,矩阵、向量、标量),你必须使用相应的As-方法(例如,AsMatrix、AsVector、AsScalar)。
一旦我们获得变量指针,我们就可以通过C++接口来更新它们了。下面是一些例子:
fxWVPVar->SetMatrix( (float*)&M ); // assume M is of type XMMATRIXfxColorVar->SetFloatVector( (float*)&v ); // assume v is of type XMVECTORfxSizeVar->>SetFloat( 5.0f );fxIndexVar->SetInt( 77 );fxOptionOnVar->SetBool( true );
注意,这些语句修改的只是effect对象在系统内存中的一个副本,它并没有传送到GPU内存中。所以在执行绘图操作时,我们必须使用Apply方法更新GPU内存(参见6.8.4节)。这样做的原因是为了提高效率,避免频繁地更新GPU内存。如果每修改一个变量就要更新一次GPU内存,那么效率会很低。
注意:effect变量不一定要被类型化。例如,可以有如下代码:
ID3D11EffectVariable* mfxEyePosVar;mfxEyePosVar = mFX->GetVariableByName("gEyePosW");...mfxEyePosVar->SetRawValue(&mEyePos, 0, sizeof(XMFLOAT3));
这种方式可以用来设置任意大小的变量(例如,普通结构体)。注意,ID3D11EffectVectorVariable接口使用4D向量。如果你希望使用3D向量的话,那应该像上面那样使用ID3D11EffectVariable接口。
除了常量缓冲变量之外,我们还需要获得指向technique对象的指针。实现方法如下:
ID3D11EffectTechnique* mTech;mTech = mFX->GetTechniqueByName("ColorTech");
该方法只包含一个用于指定technique名称的字符串参数。
6.8.4 使用effect绘图
要使用technique来绘制几何体,我们只需要确保对常量缓冲中的变量进行实时更新。然后,使用循环语句来遍历technique 中的每个pass,使用pass来绘制几何体:
// 设置常量缓冲XMMATRIX world = XMLoadFloat4x4(&mWorld);XMMATRIX view = XMLoadFloat4x4(&mView);XMMATRIX proj = XMLoadFloat4x4(&mProj);XMMATRIX worldViewProj = world*view*proj;mfxWorldViewProj->SetMatrix(reinterpret_cast<float*>(&worldViewProj));D3DX11_TECHNIQUE_DESC techDesc;mTech->GetDesc(&techDesc);for(UINT p = 0;p < techDesc.Passes;++p ){mTech->GetPassByIndex(p)->Apply(0,md3dImmediateContext);// 绘制几何体md3dImmediateContext->DrawIndexed(36, 0, 0);}
当使用pass来绘制几何体时,Direct3D会启用在pass中指定的着色器和渲染状态。ID3D11EffectTechnique::GetPassByIndex方法返回一个指定索引的pass对象的ID3D11EffectPass接口指针。Apply方法更新存储在GPU内存中的常量缓冲、将着色器程序绑定到管线、并启用在pass中指定的各种渲染状态。在当前版本的Direct3D 11中,ID3D11EffectPass::Apply方法的第一个参数还未使用,应设置为0;第二个参数指向pass使用的设备上下文的指针。
如果你需要在绘图调用之间改变常量缓冲中的变量值,那你必须在绘制几何体之前调用Apply方法:
for(UINT i = 0; i < techDesc.Passes; ++i){ID3D11EffectPass* pass = mTech->GetPassByIndex(i);//设置地面几何体的WVP组合矩阵worldViewProj = mLandWorld*mView*mProj;mfxWorldViewProj->SetMatrix(reinterpret_cast<float*>(& worldViewProj);pass->Apply(0, md3dImmediateContext);mLand.draw();// 设置水波几何体的WVP组合矩阵worldViewProj = mWavesWorld*mView*mProj;mfxWorldViewProj->SetMatrix(reinterpret_cast<float*>(& worldViewProj);pass->Apply(0 ,md3dImmediateContext);mWaves.draw();}
6.8.5 在生成期间编译effect
我们已经介绍了如何在运行时通过D3DX11CompileFromFile方法编译一个effect。但这样做会带来一个小小的不便:如果你的effect文件有一个编译错误,直到程序运行时你才会发现这个错误。我们还可以使用DirectX SDK自带的fxc工具(位于DirectX SDK\Utilities\bin\x86)离线编译你的effect。而且,你还可以修改你的VC++项目,将调用fxc编译effect的过程作为生成过程的一部分。步骤如下:
1.确保路径DirectX SDK\Utilities\bin\x86位于你的项目的VC++目录的“可执行文件目录(Executable Directories)”之下。
2.在项目中添加effect文件。
3.在解决方案资源管理器中右击每个effect文件选择属性,添加自定义生成工具(见图6.6):
图6.6 在项目中添加自定义生成工具
调试模式:
fxc /Fc /Od /Zi /T fx_5_0 /Fo " % (RelativeDir)\% (Filename).fxo " " % (FuIIPath) "
发布模式:
fxc /T fx_5_0 /Fo " o/o (RelativeDir)\% (Filename).fxo” " % (FuIIPath) "
你可以在SDK文档中找到fxc完整的编译参数说明。在调试模式中我们使用了以下三个参数,“/Fc /Od /Zi”分别对应输出汇编指令,禁用优化,开启调试信息。
现在当生成项目时,就会在每个effect上调用fxc并生成它的编译版本,以后缀为.fxo的文件的形式保存。而且,如果有来自于fxc的编译警告或错误,会在调试输出窗口显示相关信息。例如,如果在color.fx文件中打错了一个变量的名称:
// 应该是gWorldViewProj而不是worldViewProj!
vout.PosH = mul(float4(vin.Pos, l.Of), worldViewProj);
在调试输出窗口就会显示从这个错误引发的一系列错误的信息(第一条错误信息是修正的关键):
error X3004: undeclared identifier 'worldViewProj'
error X3013: 'mul': intrinsic function does not take 2 parameters
error X3013: Possible intrinsic functions are:
error X3013: mul(float, float)…
在编译阶段获取错误信息要比运行时获取方便得多。现在我们在生成过程中编译effect文件(.fxo),再也不需要在运行时进行这个操作了(即,我们无须再调用D3DX11CompileFromFile方法了)。但是,我们仍需要从.fxo文件中加载编译过的shader,并将它们传递给D3DX11CreateEffectFromMemory方法。这个工作可以通过使用C++的文件输入功能实现:
std::ifstream fin("fx/color.fxo",std::ios::binary);fin.seekg(0, std::ios_base::end);int size = (int)fin.tellg();fin.seekg(0, std::ios_base::beg);std::vector<char> compiledShader(size);fin.read(&compiledShader[0],size);fin.close();HR(D3DX11CreateEffectFromMemory(&compiledShader[0],size,0, md3dDevice, &mFX));
除了在颜色立方体演示程序中我们在运行时编译了shader之外,本书的其他示例都是在生成过程中编译了所有shader。
6.8.6 将effect框架作为“着色器生成器”
在本节的一开始我们提到过一个effect可以包含多个technique。那为什么我们要使用多个technique呢?下面我们用阴影绘制为例子解释一下这个问题,但不会讨论实现阴影的细节内容。显然,阴影质量越高,要求的资源就越多。为了支持用户不同等级的显卡,我们可能会提供低、中、高不同质量的阴影技术。因此,即使只有一个阴影效果,我们也会使用多个technique去实现它。我们的阴影effect文件如下所示:
// 省略了常量缓冲,顶点结构等代码...VertexOut VS(Vertexln vin) {/* Omit implementation details */}float4 LowQualityPS(VertexOut pin) : SV_Target{/* Do work common to all quality levels *//* Do low quality specific stuff *//* Do more work common to all quality levels */}float4 MediumQualityPS(VertexOut pin) : SV_Target{/* Do work common to all quality levels *//* Do medium quality specific stuff *//* Do more work common to all quality levels */}float4 HighQualityPS(VertexOut pin) : SV_Target{/* Do work common to all quality levels *//* Do high quality specific stuff *//* Do more work common to all quality levels */}technique11 ShadowsLow{pass P0{SetVertexShader(CompileShader(vs_5_0,VS()));SetPixeIShader(CompileShader(ps_5_0, LowQualityPS()));}}technique11 ShadowsMedium{pass P0{SetVertexShader(CompileShader(vs_5_0,VS()));SetPixeIShader(CompileShader(ps_5_0, MediumQualityPS()));}}technique11 ShadowsHighpass P0{SetVertexShader(CompileShader(vs_5_0,VS()));SetPixeIShader(CompileShader(ps_5_0, HighQualityPS ()));}}
C++应用程序会侦测玩家的显卡等级,选择最合适的technique进行渲染。
注意:前面的代码假设三个不同的阴影technique只在像素着色器中有所区别,所有的technique共享相同的顶点着色器。但是,每个technique都有不同的顶点着色器也是有可能的。
前面的实现中还有一个问题:即使像素着色器的代码是不同的,但是还是有一些通用的代码是重复的。建议使用条件分支语句解决这个问题。在shader中使用动态分支语句代价不菲,所以只在必要时才使用它们。其实我们真正想要的是一个条件编译,它可以生成不同的shader代码,但又不使用分支指令。幸运的是,effect框架提供了一个方法可以解决这个问题。下面是具体实现:
// 省略常量缓冲,顶点结构等...VertexOut VS(VertexIn vin) {/* 省略代码细节 */}#define LowQuality 0#define MediumQuality 1#define HighQuality 2float4 PS(VertexOut pin, uniform int gQuality) : SV_Target{/* Do work common to all quality levels */if(gQuality == LowQuality){/* Do low quality specific stuff */}elseif(gQuality == MediumQuality){/* Do medium quality specific stuff */}else{/* Do high quality specific stuff */}/* Do more work common to all quality levels */}technique11 ShadowsLow{pass P0{SetVertexShader(CompileShader(vs_5_0,VS()));SetPixeIShader(CompileShader(ps_5_0, PS(LowQuality)));}}technique11 ShadowsMedium{pass P0{SetVertexShader(CompileShader(vs_5_0,VS()));SetPixeIShader(CompileShader(ps_5_0, PS(MediumQuality)));}}techniquell ShadowsHigh{pass P0{SetVertexShader(CompileShader(vs_5_0,VS()));SetPixeIShader(CompileShader(ps_5_0, PS(HighQuality)));}}
我们在像素着色器中添加了一个额外的uniform参数,用来表示阴影质量等级。这个参数值是不同的,但对每个像素来说却是不变的,but is instead uniform/constant。Moreover,we do not change it at runtime either,like we change constant buffer variables。我们是在编译时设置这些参数的,而且这些值在编译时就是已知的,所以effect框架会基于这个值生成不同的shader变量。这样,我们不用复制代码(effect框架帮我们在编译时复制了这些代码)就可以生成低、中、高三种不同阴影质量的shader代码,而且没有用到条件分支语句。
下面的两个例子是使用shader生成器的常见情景:
1.是否需要纹理?有个应用程序需要在一些物体上施加纹理,而另一些物体不使用纹理。一个解决方法是创建两个像素着色器,一个提供纹理而另一个不提供。或者我们也可以使用shader生成技巧创建两个像素着色器,然后在C++程序中选择期望的technique。
float4 PS(VertexOut pin, uniform bool gApplyTexture) : SV_Target{/* Do common work */if(gApplyTexture){/* Apply texture */}/* Do more common work */}technique11 BasicTech{pass P0{SetVertexShader(CompileShader(vs_5_0,VS()));SetPixelShader(CompileShader(ps_5_0, PS(false)));}}technique11 TextureTech{pass P0{SetVertexShader(CompileShader(vs_5_0,VS()));SetPixelShader(CompileShader(ps_5_0,PS(true)));}}
2.使用多少个光源?一个游戏关卡可能会支持1至4个光源。光源越多,光照计算就越慢。我们可以基于光源数量设计不同的顶点着色器,或者也可以使用shader生成技巧创建四个顶点着色器,然后在C++程序中根据当前激活的光源数量选择期望的technique:
VertexOut VS(VertexOut pin, uniform int gLightCount){/* Do common work */for(int i = 0; i< gLightCount; ++i){/* do lighting work */}/* Do more common work */}technique11 Light1{P0{SetVertexShader(CompileShader(vs_5_0, VS(1)));SetPixeIShader(CompileShader(ps_5_0, PS()));}}technique11 Light2{P0{SetVertexShader(CompileShader(vs_5_0, VS(2)));SetPixeIShader(CompileShader(ps_5_0, PS()));}}technique11 Light3{P0{SetVertexShader(CompileShader(vs_5_0, VS(3)));SetPixeIShader(CompileShader(ps_5_0, PS()));}}technique11 Light4{P0{SetVertexShader(CompileShader(vs_5_0, VS(4)));SetPixeIShader(CompileShader(ps_5_0, PS()));}}
参数也可以不止一个。要将阴影质量,纹理和多个光源组合在一起,我们可以使用以下的顶点和像素着色器:
VertexOut VS(VertexOut pin, uniform int gLightCount){}float4 PS(VertexOut pin,uniform int gQuality,uniform bool gApplyTexture) : SV_Target{}
要创建一个使用低质量阴影,两个光源,不使用纹理的technique,我们可以这样写代码:
technique11 LowShadowsTwoLightsNoTextures{pass P0{SetVertexShader(CompileShader(vs_5_0, VS(2)));SetPixeIShader(CompileShader(ps_5_0,PS(LowQuality,false)));}}
6.9 颜色立方体演示程序
#include "GameApp.h"
#include "d3dUtil.h"
#include "DXTrace.h"
using namespace DirectX;const D3D11_INPUT_ELEMENT_DESC GameApp::VertexPosColor::inputLayout[2] = {{ "POSITION", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 0, D3D11_INPUT_PER_VERTEX_DATA, 0 },{ "COLOR", 0, DXGI_FORMAT_R32G32B32A32_FLOAT, 0, 12, D3D11_INPUT_PER_VERTEX_DATA, 0 }
};GameApp::GameApp(HINSTANCE hInstance, const std::wstring& windowName, int initWidth, int initHeight): D3DApp(hInstance, windowName, initWidth, initHeight), m_CBuffer()
{
}GameApp::~GameApp()
{
}bool GameApp::Init()
{if (!D3DApp::Init())return false;if (!InitEffect())return false;if (!InitResource())return false;return true;
}void GameApp::OnResize()
{D3DApp::OnResize();
}void GameApp::UpdateScene(float dt)
{static float phi = 0.0f, theta = 0.0f;phi += 0.3f * dt, theta += 0.37f * dt;m_CBuffer.world = XMMatrixTranspose(XMMatrixRotationX(phi) * XMMatrixRotationY(theta));// 更新常量缓冲区,让立方体转起来D3D11_MAPPED_SUBRESOURCE mappedData;HR(m_pd3dImmediateContext->Map(m_pConstantBuffer.Get(), 0, D3D11_MAP_WRITE_DISCARD, 0, &mappedData));memcpy_s(mappedData.pData, sizeof(m_CBuffer), &m_CBuffer, sizeof(m_CBuffer));m_pd3dImmediateContext->Unmap(m_pConstantBuffer.Get(), 0);
}void GameApp::DrawScene()
{assert(m_pd3dImmediateContext);assert(m_pSwapChain);static float black[4] = { 0.0f, 0.0f, 0.0f, 1.0f }; // RGBA = (0,0,0,255)m_pd3dImmediateContext->ClearRenderTargetView(m_pRenderTargetView.Get(), reinterpret_cast<const float*>(&black));m_pd3dImmediateContext->ClearDepthStencilView(m_pDepthStencilView.Get(), D3D11_CLEAR_DEPTH | D3D11_CLEAR_STENCIL, 1.0f, 0);// 绘制立方体m_pd3dImmediateContext->DrawIndexed(36, 0, 0);HR(m_pSwapChain->Present(0, 0));
}bool GameApp::InitEffect()
{ComPtr<ID3DBlob> blob;// 创建顶点着色器HR(CreateShaderFromFile(L"HLSL\\Cube_VS.cso", L"HLSL\\Cube_VS.hlsl", "VS", "vs_5_0", blob.ReleaseAndGetAddressOf()));HR(m_pd3dDevice->CreateVertexShader(blob->GetBufferPointer(), blob->GetBufferSize(), nullptr, m_pVertexShader.GetAddressOf()));// 创建顶点布局HR(m_pd3dDevice->CreateInputLayout(VertexPosColor::inputLayout, ARRAYSIZE(VertexPosColor::inputLayout),blob->GetBufferPointer(), blob->GetBufferSize(), m_pVertexLayout.GetAddressOf()));// 创建像素着色器HR(CreateShaderFromFile(L"HLSL\\Cube_PS.cso", L"HLSL\\Cube_PS.hlsl", "PS", "ps_5_0", blob.ReleaseAndGetAddressOf()));HR(m_pd3dDevice->CreatePixelShader(blob->GetBufferPointer(), blob->GetBufferSize(), nullptr, m_pPixelShader.GetAddressOf()));return true;
}bool GameApp::InitResource()
{// ******************// 设置立方体顶点// 5________ 6// /| /|// /_|_____/ |// 1|4|_ _ 2|_|7// | / | /// |/______|/// 0 3VertexPosColor vertices[] ={{ XMFLOAT3(-1.0f, -1.0f, -1.0f), XMFLOAT4(0.0f, 0.0f, 0.0f, 1.0f) },{ XMFLOAT3(-1.0f, 1.0f, -1.0f), XMFLOAT4(1.0f, 0.0f, 0.0f, 1.0f) },{ XMFLOAT3(1.0f, 1.0f, -1.0f), XMFLOAT4(1.0f, 1.0f, 0.0f, 1.0f) },{ XMFLOAT3(1.0f, -1.0f, -1.0f), XMFLOAT4(0.0f, 1.0f, 0.0f, 1.0f) },{ XMFLOAT3(-1.0f, -1.0f, 1.0f), XMFLOAT4(0.0f, 0.0f, 1.0f, 1.0f) },{ XMFLOAT3(-1.0f, 1.0f, 1.0f), XMFLOAT4(1.0f, 0.0f, 1.0f, 1.0f) },{ XMFLOAT3(1.0f, 1.0f, 1.0f), XMFLOAT4(1.0f, 1.0f, 1.0f, 1.0f) },{ XMFLOAT3(1.0f, -1.0f, 1.0f), XMFLOAT4(0.0f, 1.0f, 1.0f, 1.0f) }};// 设置顶点缓冲区描述D3D11_BUFFER_DESC vbd;ZeroMemory(&vbd, sizeof(vbd));vbd.Usage = D3D11_USAGE_IMMUTABLE;vbd.ByteWidth = sizeof vertices;vbd.BindFlags = D3D11_BIND_VERTEX_BUFFER;vbd.CPUAccessFlags = 0;// 新建顶点缓冲区D3D11_SUBRESOURCE_DATA InitData;ZeroMemory(&InitData, sizeof(InitData));InitData.pSysMem = vertices;HR(m_pd3dDevice->CreateBuffer(&vbd, &InitData, m_pVertexBuffer.GetAddressOf()));// ******************// 索引数组//DWORD indices[] = {// 正面0, 1, 2,2, 3, 0,// 左面4, 5, 1,1, 0, 4,// 顶面1, 5, 6,6, 2, 1,// 背面7, 6, 5,5, 4, 7,// 右面3, 2, 6,6, 7, 3,// 底面4, 0, 3,3, 7, 4};// 设置索引缓冲区描述D3D11_BUFFER_DESC ibd;ZeroMemory(&ibd, sizeof(ibd));ibd.Usage = D3D11_USAGE_IMMUTABLE;ibd.ByteWidth = sizeof indices;ibd.BindFlags = D3D11_BIND_INDEX_BUFFER;ibd.CPUAccessFlags = 0;// 新建索引缓冲区InitData.pSysMem = indices;HR(m_pd3dDevice->CreateBuffer(&ibd, &InitData, m_pIndexBuffer.GetAddressOf()));// 输入装配阶段的索引缓冲区设置m_pd3dImmediateContext->IASetIndexBuffer(m_pIndexBuffer.Get(), DXGI_FORMAT_R32_UINT, 0);// ******************// 设置常量缓冲区描述//D3D11_BUFFER_DESC cbd;ZeroMemory(&cbd, sizeof(cbd));cbd.Usage = D3D11_USAGE_DYNAMIC;cbd.ByteWidth = sizeof(ConstantBuffer);cbd.BindFlags = D3D11_BIND_CONSTANT_BUFFER;cbd.CPUAccessFlags = D3D11_CPU_ACCESS_WRITE;// 新建常量缓冲区,不使用初始数据HR(m_pd3dDevice->CreateBuffer(&cbd, nullptr, m_pConstantBuffer.GetAddressOf()));// 初始化常量缓冲区的值// 如果你不熟悉这些矩阵,可以先忽略,待读完第四章后再回头尝试修改m_CBuffer.world = XMMatrixIdentity(); // 单位矩阵的转置是它本身m_CBuffer.view = XMMatrixTranspose(XMMatrixLookAtLH(XMVectorSet(0.0f, 0.0f, -5.0f, 0.0f),XMVectorSet(0.0f, 0.0f, 0.0f, 0.0f),XMVectorSet(0.0f, 1.0f, 0.0f, 0.0f)));m_CBuffer.proj = XMMatrixTranspose(XMMatrixPerspectiveFovLH(XM_PIDIV2, AspectRatio(), 1.0f, 1000.0f));// ******************// 给渲染管线各个阶段绑定好所需资源//// 输入装配阶段的顶点缓冲区设置UINT stride = sizeof(VertexPosColor); // 跨越字节数UINT offset = 0; // 起始偏移量m_pd3dImmediateContext->IASetVertexBuffers(0, 1, m_pVertexBuffer.GetAddressOf(), &stride, &offset);// 设置图元类型,设定输入布局m_pd3dImmediateContext->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST);m_pd3dImmediateContext->IASetInputLayout(m_pVertexLayout.Get());// 将着色器绑定到渲染管线m_pd3dImmediateContext->VSSetShader(m_pVertexShader.Get(), nullptr, 0);// 将更新好的常量缓冲区绑定到顶点着色器m_pd3dImmediateContext->VSSetConstantBuffers(0, 1, m_pConstantBuffer.GetAddressOf());m_pd3dImmediateContext->PSSetShader(m_pPixelShader.Get(), nullptr, 0);// ******************// 设置调试对象名//D3D11SetDebugObjectName(m_pVertexLayout.Get(), "VertexPosColorLayout");D3D11SetDebugObjectName(m_pVertexBuffer.Get(), "VertexBuffer");D3D11SetDebugObjectName(m_pIndexBuffer.Get(), "IndexBuffer");D3D11SetDebugObjectName(m_pConstantBuffer.Get(), "ConstantBuffer");D3D11SetDebugObjectName(m_pVertexShader.Get(), "Cube_VS");D3D11SetDebugObjectName(m_pPixelShader.Get(), "Cube_PS");return true;
}
详细代码见资源
6.13 动态顶点缓冲
到目前为止,我们一直使用的是静态缓冲(static buffer),它的内容是在初始化时固定下来的。相比之下,动态缓冲(dynamic buffer)的内容可以在每一帧中进行修改。当实现一些动画效果时,我们通常使用动态缓冲区。例如,我们要模拟一个水波效果,并通过函数f(x ,z ,t)来描述水波方程,计算当时间为t时,xz平面上的每个点的高度。在这一情景中,我们必须使用“山峰与河谷”中的那种三角形网格,将每个网格点代入f(x, z , t)函数得到相应的水波高度。由于该函数依赖于时间t(即,水面会随着时间而变化),我们必须在很短的时间内(比如1/30秒)重新计算这些网格点,以得到较为平滑的动画。所以,我们必须使用动态顶点缓冲区来实时更新三角形网格顶点的高度。
前面提到,为了获得一个动态缓冲区,我们必须在创建缓冲区时将Usage标志值指定为D3D11_USAGE_DYNAMIC;同时,由于我们要向缓冲区写入数据,所以必须将CPU访问标志值指定为D3D11_CPU_ACCESS_WRITE。
D3D11_BUFFER_DESC vbd;vbd.Usage = D3D11_USAGE_DYNAMIC;vbd.ByteWidth = sizeof(Vertex) * mWaves.VertexCount();vbd.BindFlags = D3D11_BIND_VERTEX_BUFFER;vbd.CPUAccessFlags = D3D11_CPU_ACCESS_WRITE;vbd.MiscFlags = 0;HR(md3dDevice->CreateBuffer(&vbd, 0, &mWavesVB));
然后,使用ID3D11Buffer::Map函数获取缓冲区内存的起始地址指针,并向它写入数据:
HRESULT ID3D11DeviceContext::Map(ID3D11Resource *pResource ,UINT Subresource,D3D11_MAP MapType,UINT MapFlags,D3D11_MAPPED_SUBRESOURCE *pMappedResource);
1.pResource:指向要访问的用于读/写的资源的指针。缓冲是一种Direct3D 11资源,其他类型的资源,例如纹理资源,也可以使用这个方法进行访问。
2.Subresource:包含在资源中的子资源的索引。后面我们会看到如何使用这个索引,而缓冲不包含子资源,所以设置为0。
3.MapType:常用的标志有以下几个:
- D3D11_MAP_WRITE_DISCARD:让硬件抛弃旧缓冲,返回一个指向新分配缓冲的指针,通过指定这个标志,可以让我们写入新分配的缓冲的同时,让硬件绘制已抛弃的缓冲中的内容,可以防止绘制停顿。
- D3D11_MAP_WRITE_NO_OVERWRITE:我们只会写入缓冲中未初始化的部分;通过指定这个标志,可以让我们写入未初始化的缓冲的同时,让硬件绘制前面已经写入的内容,可以防止绘制停顿。
- D3D11_MAP_READ:表示应用程序(CPU)会读取GPU缓冲的的一个副本到系统内存中。
4.MapFlags:可选标志,这里不使用,所以设置为0;具体细节可参见SDK文档。
5.pMappedResource:返回一个指向D3D11_MAPPED_SUBRESOURCE的指针,这样我们就可以访问用于读/写的资源数据。
D3D11_MAPPED_SUBRESOURCE结构定义如下:
typedef struct D3D11_MAPPED_SUBRESOURCE{void *pData;UINT Row Pitch;UINT DepthPitch;}D3D11_MAPPED_SUBRESOURCE
1.pData:指向用于读/写的资源内存的指针,你必须将它转换为资源中存储的数据的格式。
2.RowPitch:资源中一行数据的字节大小。例如,对于一个2D纹理来说,这个大小为一行的字节大小。
3.DepthPitch:资源中一页数据的大小。例如,对于一个3D纹理来说,这个大小为3D纹理中一个2D图像的字节大小。
RowPitch和DepthPitch的区别是针对2D和3D资源(类似于2D和3D数组)而言的。顶点/索引缓冲本质上是1D数组,RowPitch和DepthPitch的值是相同的,都等于顶点/索引缓冲的字节大小。
下面的代码展示如何在水波演示程序中更新顶点缓冲:
D3D11_MAPPED_SUBRESOURCE mappedData;HR(md3dImmediateContext->Map(mWavesVB, 0, D3D11_MAP_WRITE_DISCARD, 0, &mappedData));Vertex* v = reinterpret_cast<Vertex*>(mappedData.pData);for(UINT i = 0; i < mWaves.VertexCount(); ++i){v[i].Pos = mWaves[i];v[i].Color = XMFLOAT4(0.0f, 0.0f, 0.0f, 1.0f);}md3dImmediateContext->Unmap(mWavesVB, 0);
当你完成缓冲区的更新操作之后,必须调用ID3D11Buffer::Unmap函数。
当使用动态缓冲区时,必然会有一些额外开销,因为这里存在一个从CPU内存向GPU内存回传数据的过程。 所以,在实际工作中应尽可能多使用静态缓冲区,少使用动态缓冲区。在Direct3D的最新版本中已经引入了一些新特性用于减少对动态缓冲区的需求。例如:
1.可以在顶点着色器中实现简单动画。
2.通过渲染到纹理(render to texture)和顶点纹理推送(vertex texture fetch)功能,可以实现完全运行在GPU上的水波模拟动画。
3.几何着色器为GPU提供了创建和销毁图元的能力,在以前没有几何着色器时,这些工作都是由CPU来完成的。
索引缓冲区可以是动态的。不过,在水波演示程序中,三角形的拓扑结构始终不变,只有顶点高度会发生变化;所以,这里只需要让顶点缓冲区变为动态缓冲区。
本章的水波演示程序使用了一个动态缓冲区来实现简单的水波效果。本书不会将重点放在水波模拟算法的实现细节上(有兴趣的读者可以参见[Lengyel02]),我们只是用它来说明动态缓冲区的用法:在CPU上更新模拟数据,然后调用Map/Unmap方法更新顶点缓冲区。
注意:在水波演示程序中,我们以线框模式渲染水波;是因为我们现在还没有讲到灯光的用法,在实体填充模式下,很难看出水波的运动效果。
注意:我们再次强调,这个示例应该在GPU上使用更高级的方式实现,比如渲染到纹理和顶点纹理推送。但是由于我们还没有讲到些技术,所以现在只能在CPU上实现,暂时使用动态顶点缓冲区来更新顶点。