(注:【D3D11游戏编程】学习笔记系列由CSDN作者BonChoix所写,转载请注明出处:http://blog.csdn.net/BonChoix,谢谢~)
历代的D3D教程中,介绍基本绘图时都会拿立方体作为例子,这次也不例外~ 立方体虽然简单,但正所谓麻雀虽小,五脏俱全。绘制立方体的过程其实已经包含了所有D3D渲染程序中最基本的、必不可少的步骤。因此,从绘制立方体开始学习D3D绘图,既简单易懂,又可以掌握绘图程序的核心步骤,效率自然会很高。
在学习D3D11绘图前,有几个重要的概念尤其需要深刻理解,它们是所有D3D11程序的基础。在没有掌握这些基本概念的情况下是不可能学会D3D11编程的。下面对这些概念逐一进行介绍。
一、 Vertex Shader和Pixel Shader
这两个是写D3D11程序时必不可少的最基本的Shader。最名字上也可以直接看出,Vertex Shader(顶点着色器)是针对每个顶点进行操作的,它的输入是一个顶点,包含用户指定的顶点的基本信息,输出是该顶点转换后的信息。最常见的顶点着色器即对顶点的位置坐标、纹理坐标、法线等信息进行变换,并返回新的顶点,传递给下一个阶段。一般针对顶点着色器的输入与输出,程序员各指定相应的结构来存储相应的顶点信息,如:
struct VertexIn { float3 pos : POSITION; float4 color : COLOR; };
就是一个简单的输入结构,float3和float4是HLSL内置类型,即用来存放3个float和4个float,此外还有其他很多类型诸如float4x4(4x4矩阵)等,详细可参考HLSL相关资料。pos和color即相应的成员变量名,"POSITION”和"COLOR",同来对相应的成员变量进行语义说明,"POSITION"很显然指位置坐标,"COLOR"即顶点的颜色。这些语义说明在C++程序中创建输入布局(InputLayout)时会用来,并且必须要一致,这个在本文稍后介绍InputLayout时还会解释。
struct VertexOut { float4 posH : SV_POSITION; float4 color : COLOR; };
这个结构即顶点着色器的输出结构,与输入基本类似,但pos对应地变成了float4类型,这个是齐次坐标下的位置,即经过投影变换后的坐标,这时的顶点坐标必须是float4类型,还有一点重要的是,它对应的语义说明SV_POSITION,也是固定的,”SV“即"System Value",系统值,在像素着色器阶段会需要这个值来进行裁剪操作。除了系统值必须固定外,其他的语义值都是可以任意指定的(虽然可以任意指定,但一般情况下显然是以变量真实作用为依据嘛)。
有了该两个结构,顶点着色器函数如下:
VertexOut VS(VertexIn vin) { VertexOut vout; vout.posH = mul(float4(vin.pos,1.f),g_worldViewProj); vout.color = vin.color; return vout; }
该函数名是可以任意指定的,其参数为输入顶点结构,输出为相应的输出顶点结构。在这个函数里面,它实现的是一个最简单的功能,即坐标变换和颜色的简单传递。这里用到了几个HLSL内置函数:mul用来实现向量、矩阵的相乘,其维数是可度的,比如float3和float3x3相乘,float4和float4x4相乘,也可以进行相同维数的矩阵的相乘。由于输出坐标要求为float4类型,因些相乘时需要float4和float4x4。这里float4通过复制输入顶点后接第4个参数1.f作为构造函数生成,g_worldViewProj为一个全局变量,类型为float4x4,用于进行世界、视角、投影变换,三个变量合成到一个矩阵中实现。
以上就是一个最简单的顶点着色器,下面来看像素着色器。显然,像素着色器是针对逐个像素进行的。在上一篇文章介绍3D渲染管线时提到过,在光栅化阶段,一个三角形在其所覆盖的每个像素处使用插值来计算相应顶点的各个属性,然后把插值后的顶点传递给像素着色器。在简单的没有Geometry Shader和Tessellator时,顶点着色器的输出就是像素着色器的输入,像素着色器最终的输出是该像素处对应片段的颜色值,即float4类型。如下即是一个最简单的像素着色器函数:
float4 PS(VertexOut pin):SV_TARGET { return pin.color; }
该函数参顶点着色器输出结构作为参数,输出为float4类型,注意函数名后面的语义说明:SV_TARGET,它是函数返回值的说明,显然这也是一个系统值,不可更改,它作为相应片段的颜色值传递给下一个阶段(Output Merger)。函数功能相当简单,即简单的颜色值的传递。
常量缓冲区
常量缓冲区在HLSL在用来存放全局变量,供着色器使用,在HLSL中即”cbuffer"。如上面例子当中g_worldViewProj就是一个常量缓冲区中的全局变量。定义如下:
cbuffer PerFrame { float4x4 g_worldViewProj; };
在着色器中,不同的全局变量有着不同的更新频率。比如g_worldViewProj,每个物体都有其相应的变换矩阵,因此在绘制每个物体前都要更新其对应的矩阵,即这个变量为针对单个物体的;此外,比如光源的属性:位置、方向、颜色等,这些信息在绘制每一帧场景过程中是保持不变的,即这些变量更新频率为“每帧”。在写着色器程序时,把相同更新频率的全局变量放在一起,定义在同一个常量缓存中,这样当更新变量时,定义在一起的所有变量全部一起更新。这样可以提高程序的效率,也是常量缓冲区的根本目的。如下几个全局变量的定义:
cbuffer PerFrame { float4x4 g_worldViewProj; }; cbuffer PerFrame { float3 g_lightDir; float3 g_lightPos; float3 g_lightColor; };
二、Effect框架
Effect框架是微软提供给我们的一个开源的代码库,用来把shader代码和相应的渲染状态合理的组织到一起,来实现一个针对性的特效。比如渲染水面、云彩、阴影等不同特效时,需要用到不同的渲染状态,这样每个特效都可以写成单独的Effect。
在D3D11中,Effect框架是通过源码的形式提供的,因此要使用它,需要我们手动进行编译,生成库文件。关于编译Effect及相应的配置IDE的详细步骤,请参见我的上几篇博文:《【D3D11游戏编程】学习笔记四:准备工作》。
一个Effect至少包含一个顶点着色器、一个像素着色器及所需要的全局变量。此外,还包含以下几个必不可少的成员:
1. technique11
一个特效可以通过多种不同的方法实现,每个方法可以作为一个technique11。一个典型的例子即,针对不同用户的不同的机器配置,游戏开发者需要定义不同等级的渲染效果,一般即高端、中端、低端等。这些不同的方法实现的是同一个特效,但渲染方法不同,因此每个方法可作为一个technique11。
2. pass
每个technique11至少包含一个pass,一个pass至少包含一个顶点着色器和一个像素着色器及其相应渲染状态(渲染状态也可以在C++程序是指定)。一个pass即是对特效的一次渲染,多大数特效都可以通过一个pass来实现,个别特效需要多个pass,然后把每次渲染结果通过相应的方式进行混合,形成最终的结果。比如“运动模糊”效果,可以通过多个pass,每个pass渲染的图像之间进行适当的偏移,最终混合起来实现运动物体“拖尾“的效果。
如下是一个完整的Effect:
cbuffer PerFrame { float4x4 g_worldViewProj; }; struct VertexIn { float3 pos : POSITION; float4 color : COLOR; }; struct VertexOut { float4 posH : SV_POSITION; float4 color : COLOR; }; VertexOut VS(VertexIn vin) { VertexOut vout; vout.posH = mul(float4(vin.pos,1.f),g_worldViewProj); vout.color = vin.color; return vout; } float4 PS(VertexOut pin):SV_TARGET { return pin.color; } technique11 BasicDraw { Pass p0 { SetVertexShader(CompileShader(vs_5_0,VS())); SetPixelShader(CompileShader(ps_5_0,PS())); } }
包含一个technique11,名为”BasicDraw",该technique11包含一个pass,该pass使用上面定义的顶点着色器和像素着色器。
在C++程序中,Effect对应的接口为ID3DX11Effect,technique11为ID3D11EffectTechnique。此外,作为C++程序与Shader程序的衔接,即更新Shader中的全局变量,在C++中定义了各种类型的接口:ID3DX11EffectMatrixVariable、ID3DX11EffectVectorVariable、ID3DX11EffectVariable等,分别对应Shader中的float4x4类型、float4类型及raw数据,即无类型。这些接口通过如下方式获得:
g_fxWorldViewProj = g_effect->GetVariableByName("g_worldViewProj")->AsMatrix();
这里的“g_worldViewProj”即shader中的全局变量名字。之后就可以在C++程序中通过更改g_fxWorldViewProj接口来更新shader中相应的全局变量了:
g_fxWorldViewProj->SetMatrix(reinterpret_cast<float*>(&worldViewProj));
三、输入布局(Input Layout)
在绘制每个物体时,都需要定义该物体的顶点的结构,即位置坐标、颜色、纹理坐标、法线等信息,一般来说即定义相应的结构,以包含顶点的所有信息。然后针对物体建模,创建其所有顶点及索引,放到相应的缓冲区中提供给GPU使用。
如果你熟悉d3d9的话,肯定会非常了解“灵活顶点格式”这个概念,即Flexible Vertex Format(FVF)。在定义好顶点结构后,还需要为该结构指定相应的FVF。这里的FVF与d3d11中的Input Layout是类似的。通常,我们为物体定义好所有的顶点后,以数组的形式存放在顶点缓存中提供给GPU。不同的程序中,顶点的结构千变万化,GPU在面对整个顶点缓存时,并不知道里面放的是什么内容,因此顶点缓存中全部是void*类型数据。为了让GPU能够有目的地从中读取用户提供的顶点信息,必须要知道每个顶点在缓冲区中是以什么样的格式存放的。
比如在C/C++程序中,针对类型不确定的内存,我们通常用void*来代表。但在读取阶段,我们必须要知道它里面存放的数据类型,如果是int型,我们则4字节4字节的读取,并赋给int型变量;如果是char型,则单字节读取,赋给char型变量。同时,GPU在读取顶点信息时,也需要知道里面存放的真实数据类型,这正是我们定义input Layout和d3d9中指明FVF的主接目的。
我们以下面这个顶点定义为例子:
struct Vertex { XMFLOAT3 pos; XMFLOAT4 color; };
该顶点存放两个信息,位置坐标(XMFLOAT3类型)和颜色值(XMFLOAT4类型)。如果GPU知道顶点是这种结果的话,在读取顶点信息时,则可以先读取3个float作为顶点坐标,再读取4个float作为颜色值。注意,这里定义的顶点结构与对应Effect中的输入顶点结构是对应的,在上面介绍shader时的例子中见过。
在顶点结构中,针对每个成员,我们要定义一个相应的D3D11_INPUT_ELEMENT_DESC。这个结构定义了该成员相应的有关信息,定义如下:
typedef struct D3D11_INPUT_ELEMENT_DESC { LPCSTR SemanticName; UINT SemanticIndex; DXGI_FORMAT Format; UINT InputSlot; UINT AlignedByteOffset; D3D11_INPUT_CLASSIFICATION InputSlotClass; UINT InstanceDataStepRate; } D3D11_INPUT_ELEMENT_DESC;
SemanticName指的是我们在着色器中定义输入顶点结构时对应的语义说明,比如对于位置坐标,为“POSITION”;SemanticIndex为该语义说明的序号,是这样的,在一个顶点结构中,不同的成员可以使用相同的语义说明,这时要用不同的序号来区分他们,相应的序号就是这个SemanticIndex。比如一个顶点有两层纹理坐标,tex1和tex2,它们的语义说明都为"TEXTCOORD",相应的序号则为0和1;Format为该成员的数据类型,对于坐标即为DXGI_FORMAT_R32G32B32_FLOAT(3个float);InputSlot为输入槽,一般情况下我们指定为0。此外,在个别情况下,不同的顶点信息可以通过不同的输入槽提供给GPU,这时通过该成员来指定其输入槽序号,多个输入槽的使用在以后会有学习;AlignedByteOffset,即该成员在顶点结构中字节偏移量,对于这里的位置坐标,为第一个成员,因此偏移为0。对于颜色值,由于位置坐标占据了12个字节,因此颜色值对应的偏移为12,依次类推;InputaSlotClass,对于一般情况,为D3D11_INPUT_PER_VERTEX_DATA,这时相应的最后一个成员InstanceDataStepRate就要设为0。
顶点结构中对应的每个成员,都对应一个D3D11_INPUT_ELEMENT_DESC,所有的D3D11_INPUT_ELEMENT_DESC存放在一个数组当中。因此,对于上面定义的顶点结构,我们可以如下数组:
D3D11_INPUT_ELEMENT_DESC inputDesc[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 } };
定义好这个,下一步就是用它来创建Input Layout了,相应的函数如下:
HRESULT ID3D11Device::CreateInputLayout( [in] const D3D11_INPUT_ELEMENT_DESC *pInputElementDescs, [in] UINT NumElements, [in] const void *pShaderBytecodeWithInputSignature, [in] SIZE_T BytecodeLength, [out] ID3D11InputLayout **ppInputLayout );
第一个参数即我们刚定义好的D3D11_INPUT_ELEMENT_DESC数组;第二个参数为数组成员个数,这里为2;第三个和第四个成员需要用到编译好的Effect程序,获取方式如下:
//这里用到了编译好的shader的输入信息(InputSignature),可以从Effect相应的Technique中获取 D3DX11_PASS_DESC passDesc = {0}; g_effect->GetTechniqueByName("BasicDraw")->GetPassByIndex(0)->GetDesc(&passDesc); g_device->CreateInputLayout(inputDesc,2,passDesc.pIAInputSignature,passDesc.IAInputSignatureSize,&g_inputLayout);
g_effect为事先创建好的ID3DX11Effect*类型变量,代表编译好的Effect,通过它获取相应technique11中相应的pass的描述信息,通过pass描述信息即可获取我们这里需要的两个参数,如上代码中所示,意指shader中输入结构信息,及该信息的字节长度;最后一个参数即我们要创建的input layout接口的地址。
到这里,Input Layout就创建好了。
四、顶点缓存和索引缓存
在D3D11程序中,我们要绘制的每一个物体,它对应的所有顶点及索引信息,都需要存放在相应的缓存中。因此我们需要在初始化阶段为顶点和索引来创建相应的缓存。两个缓存创建方式十分类似,即先定义缓存描述,再创建缓存数据(即顶点数组和索引数组),最后通过描述和缓存数据来创建缓存。顶点、索引缓冲共同同一个接口类型:ID3D11Buffer,因此其缓存描述也是一样的,该结构定义如下:
typedef struct D3D11_BUFFER_DESC { UINT ByteWidth; D3D11_USAGE Usage; UINT BindFlags; UINT CPUAccessFlags; UINT MiscFlags; UINT StructureByteStride; } D3D11_BUFFER_DESC;
ByteWidth为缓存大小,字节为单位;Usage,对于不变化的顶点、索引缓存,我们设为为D3D11_USAGE_VERTEX_IMMUTABLE,此外,针对CPU对缓存的读、写权限,这个成员有多个类型,比如D3D11_USAGE_DEFAULT(CPU不可读写)、D3D11_USAGE_DYNAMIC(CPU可读写),D3D11_USAGE_STAGING(CPU可读,即可拷贝);BindFlags,针对顶点缓存为D3D11_BIND_VERTEX_BUFFER,针对索引缓存为D3D11_BIND_INDEX_BUFFER;CPUAccessFlags,对于不允许CPU读、写的缓存,比如我们这次的例子,设为0,同时下一次成员也设为0;最后一个参数我们暂时也不需要,设为0。
为了指定缓存数据,我们用到如下结构:
typedef struct D3D11_SUBRESOURCE_DATA { const void *pSysMem; UINT SysMemPitch; UINT SysMemSlicePitch; } D3D11_SUBRESOURCE_DATA;
第一个成员即我们创建好的顶点、索引的数组;后面两个参数对于顶点、索引缓存用不到,设为0.
如下为创建顶点缓存的代码:
//首先创建描述 D3D11_BUFFER_DESC vbDesc = {0}; vbDesc.ByteWidth = 8*sizeof(Vertex); vbDesc.BindFlags = D3D11_BIND_VERTEX_BUFFER; vbDesc.Usage = D3D11_USAGE_DEFAULT; //然后给出数据:立方体的8个顶点 Vertex vertices[8] = { {XMFLOAT3(-1.f,-1.f,-1.f),reinterpret_cast<const float*>(&Blue)}, {XMFLOAT3(-1.f, 1.f,-1.f),reinterpret_cast<const float*>(&Cyan)}, {XMFLOAT3( 1.f, 1.f,-1.f),reinterpret_cast<const float*>(&Red)}, {XMFLOAT3( 1.f,-1.f,-1.f),reinterpret_cast<const float*>(&Yellow)}, {XMFLOAT3(-1.f,-1.f, 1.f),reinterpret_cast<const float*>(&Green)}, {XMFLOAT3(-1.f, 1.f, 1.f),reinterpret_cast<const float*>(&Silver)}, {XMFLOAT3( 1.f, 1.f, 1.f),reinterpret_cast<const float*>(&Black)}, {XMFLOAT3( 1.f,-1.f, 1.f),reinterpret_cast<const float*>(&Magenta)} }; D3D11_SUBRESOURCE_DATA vbData = {0}; vbData.pSysMem = vertices; //根描述和数据创建顶点缓存 hr = g_device->CreateBuffer(&vbDesc,&vbData,&g_VB);
创建索引缓存的代码类型,这里不再列出,可以参见文章末尾附带的源代码。
. 以上这些就是d3d11应用程序中最基本的概念,在任何一个绘制程序中都要用到它们。作为我们这次的例子,在创建好Effect、Input Layout及顶点、索引缓存后,初始化工作就基本完成了。以下工作主要就是每帧的更新及渲染操作。
更新
在这个例子中,每帧的更新主要为计算变换矩阵,并更新到shader中去。变换主要分别世界变换、视角变换和投影变换。这里的世界变换我们进行的是沿Y轴和X轴的旋转,以更好的观察立方体,获得旋转矩阵的函数如下:
XMMATRIX rotation1 = XMMatrixRotationY(phy); XMMATRIX rotation2 = XMMatrixRotationX(theta); XMMATRIX world = rotation1 * rotation2;
phy和theta分别为旋转的角度大小,两个矩阵相乘合成一个旋转矩阵,即世界变换矩阵。
获得视角变换矩阵的函数如下:
XMVECTOR eyePos = XMVectorSet(0.f,2.f,-5.f,1.f); XMVECTOR lookAt = XMVectorSet(0.f,0.f,0.f,1.f); XMVECTOR up = XMVectorSet(0.f,1.f,0.f,1.f); XMMATRIX view = XMMatrixLookAtLH(eyePos,lookAt,up);
该函数通过指定观察位置、观察点及相机的上方向量来确定视角矩阵view。
获得投影变换矩阵的函数如下:
XMMATRIX proj = XMMatrixPerspectiveFovLH(XM_PI*0.25f,g_winWidth*1.f/g_winHeight,1.f,1000.f);
该函数通过指定上、下视野角度、投影屏幕宽、高比、近、远平面距离来确定投影矩阵,这跟上一篇中介绍3D渲染管线中的投影变换时的基本信息是一致的。
最后,把得到的三个变换矩阵相乘,得到一个合成的矩阵,并赋给shader中的全局变量。
渲染
现在该渲染立方体了,步骤如下:
首先当然是清空后缓冲区及深度、模板缓冲区,与上次的初始化程序中一样;
其实是指定绘制物体对应的Input Layout、顶点缓冲、索引缓冲及相应的拓扑类型:
g_deviceContext->IASetInputLayout(g_inputLayout); //指定顶点缓存 UINT stride = sizeof(Vertex); UINT offset = 0; g_deviceContext->IASetVertexBuffers(0,1,&g_VB,&stride,&offset); //指定索引缓存 g_deviceContext->IASetIndexBuffer(g_IB,DXGI_FORMAT_R32_UINT,0); //指定图元拓扑类型 g_deviceContext->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
这里解释下顶点缓存与索引缓存的指定,函数原型如下:
void IASetVertexBuffers( [in] UINT StartSlot, [in] UINT NumBuffers, [in] ID3D11Buffer *const *ppVertexBuffers, [in] const UINT *pStrides, [in] const UINT *pOffsets );
指定顶点缓存时是可以同时指定多个缓存的,从函数名也可以看得出来。第一个参数为起始的输入槽,我们暂时只用到一个槽,因此这个参数设为0;第二个参数为指定的顶点缓存个数,我们这里只有一个顶点缓存,设为1;第三个参数为所有顶点缓存组成的数组,我们有一个,于是直接传递只含该缓存的数组即可(即缓存的地址);第四个参数为每个缓存中定义的结构的字节长度,对于我们这次的结构Vertex,大小为sizeof(Vertex),因此传递包含该大小的数组即可;最后一个参数为每个缓存的偏移量组成的数组,显然这里为包含一个0的数组。结合上面的代码对这个函数进行理解就很容易了。
对于索引,函数更好理解,
void IASetIndexBuffer( [in] ID3D11Buffer *pIndexBuffer, [in] DXGI_FORMAT Format, [in] UINT Offset );
第一个参数即传递的索引缓存,第二个参数为索引数据类型,对于使用UINT(unsigned int)类型的索引,对应的类型为DXGI_FORMAT_R32_UINT;第三个参数用于指定使用索引的起始位置。我们这里从索引开始处开始使用,一般情况下都是这样,因此设为0.
指定好以上这些之后,最后就剩下绘制部分了。d3d11中绘制过程是这样的:首先获得我们要绘制的物体对应的technique11,然后针对该technique11包含的所有pass,逐个进行渲染。代码如下:
ID3DX11EffectTechnique *tech = g_effect->GetTechniqueByName("BasicDraw"); D3DX11_TECHNIQUE_DESC techDesc; tech->GetDesc(&techDesc); for(UINT i=0; i<techDesc.Passes; ++i) { tech->GetPassByIndex(i)->Apply(0,g_deviceContext); g_deviceContext->DrawIndexed(36,0,0); }
到此,整个渲染过程完成。
最后总结一下d3d11绘制的基本过程:
1. 编写Shader代码,完成Effect文件
2. 编译Effect并获得ID3DX11Effect接口,以及shader中所有的全局变量在C++中的对应接口;
3. 根据定义的顶点结构信息,定义相应的Input Layout;
4. 创建顶点缓存和索引缓存;
5. 更新,其中包括更新Shader全局变量
6. 绘制图形:
6.1 清屏
6.2 指定Input Layout、顶点缓存、索引缓存、图元拓扑类型;
6.3 选择technique11,逐个pass渲染
6.4 显示
5、6步骤作为整个程序的主循环反复执行,直到程序结束。
以下是这次整个程序的源代码及可执行文件:
GetSampleCode();