2.顶点着色器
顶点着色器(vertex shader)是一个在显卡的GPU上执行的程序,它替换了固定功能管道(fixed function pipeline)中的变换(transformation)和光照(lighting)阶段(这不是百分之百的正确,因为顶点着色器可以被Direct3D运行时(Direct3D runtime)以软件模拟,如果硬件不支持顶点着色器的话)。图2.1说明了管线中顶点着色器替换的部件。
图2.1
由于顶点着色器是我们(在HLSL中)写的一个自定义程序,因此我们在图形效果方面获得了极大的自由性。我们不再受限于Direct3D的固定光照算法。此外,应用程序操纵顶点位置的能力也有了多样性,例如:布料仿真,粒子系统的点大小操纵,还有顶点混合/变形。此外,我们的顶点数据结构更自由了,并且可以在可编程管线中包含比在固定功能管线中多的多的数据。
正如作者所在群的公告所说,“拍照不在于你对相机使用的熟练程度,而是在于你对艺术的把握。”之前的介绍使读者对着色器的编写和使用都有了一定的了解,下面我们将把重心从介绍如何使用着色器转到如何实现更高级的渲染效果上来。
2.1可编程数据流模型
DirectX 8.0引入了数据流的概念,可以这样理解数据流(图2.2):
图2.2
· 一个顶点由n个数据流组成。
· 一个数据流由m个元素组成。
· 一个元素是[位置、颜色、法向、纹理坐标]。
程序中使用IDirect3DDevice9::SetStreamSource方法把一个顶点缓存绑定到一个设备数据流。
2.2顶点声明
该小节对顶点声明的描述绝大多数都取自翁云兵的《着色器和效果》,该文对顶点声明的描述是我所见到最详尽最透彻的,这里向作者表示敬意:)
到现在为止,我们已经使用自由顶点格式(flexible vertex format,FVF)来描述顶点结构中的各分量。但是,在可编程管线中,我们的顶点数据可以包含比用FVF所能表达的多的多的数据。因此,我们通常使用更具表达性的并且更强有力的顶点声明(vertex declaration)。
注意:我们仍然可以在可编程管线中使用FVF——如果我们的顶点格式可以这样描述。不管怎样,这只是为了方便,因为FVF会在内部被转换为一个顶点声明。
2.2.1 描述顶点声明
我们将一个顶点声明描述为一个D3DVERTEXELEMENT9结构的数组。D3DVERTEXELEMENT9数组中的每个元素描述了一个顶点的分量。所以,如果你的顶点结构有三个分量(例如:位置、法线、颜色),那么其相应的顶点声明将会被一个含3个元素的D3DVERTEXELEMENT9结构数组描述。
D3DVERTEXELEMENT9结构定义如下:
typedef struct _D3DVERTEXELEMENT9 {
BYTE Stream;
BYTE Offset;
BYTE Type;
BYTE Method;
BYTE Usage;
BYTE UsageIndex;
} D3DVERTEXELEMENT9;
² Stream——指定关联到顶点分量的流;
² Offset——偏移,按字节,相对于顶点结构成员的顶点分量的开始。例如,如果顶点结构是:
struct Vertex
{
D3DXVECTOR3 pos;
D3DXVECTOR3 normal;
};
……pos分量的偏移是0,因为它是第一个分量;normal分量的偏移是12,因为sizeof(pos) == 12。换句话说,normal分量以Vertex的第12个字节为开始。
² Type——指定数据类型。它可以是D3DDECLTYPE枚举类型的任意成员;完整列表请参见文档。常用类型如下:
D3DDECLTYPE_FLOAT1——浮点数值
D3DDECLTYPE_FLOAT2——2D浮点向量
D3DDECLTYPE_FLOAT3——3D浮点向量
D3DDECLTYPE_FLOAT4——4D浮点向量
D3DDECLTYPE_D3DCOLOR—D3DCOLOR类型,它扩展为RGBA浮点颜色向量(r, g, b, a),其每一分量都是归一化到区间[0, 1]了的。
² Method——指定网格化方法。我们认为这个参数是高级的,因此我们使用默认值,标识为D3DDECLMETHOD_DEFAULT。
² Usage——指定已计划的对顶点分量的使用。例如,它是否准备用于一个位置向量、法线向量、纹理坐标等,有效的用途标识符(usage identifier)是D3DDECLUSAGE枚举类型的:
typedef enum _D3DDECLUSAGE {
D3DDECLUSAGE_POSITION = 0, // Position.
D3DDECLUSAGE_BLENDWEIGHTS = 1, // Blending weights.
D3DDECLUSAGE_BLENDINDICES = 2, // Blending indices.
D3DDECLUSAGE_NORMAL = 3, // Normal vector.
D3DDECLUSAGE_PSIZE = 4, // Vertex point size.
D3DDECLUSAGE_TEXCOORD = 5, // Texture coordinates.
D3DDECLUSAGE_TANGENT = 6, // Tangent vector.
D3DDECLUSAGE_BINORMAL = 7, // Binormal vector.
D3DDECLUSAGE_TESSFACTOR = 8, // Tessellation factor.
D3DDECLUSAGE_POSITIONT = 9, // Transformed position.
D3DDECLUSAGE_COLOR = 10, // Color.
D3DDECLUSAGE_FOG = 11, // Fog blend value.
D3DDECLUSAGE_DEPTH = 12, // Depth value.
D3DDECLUSAGE_SAMPLE = 13 // Sampler data.
} D3DDECLUSAGE;
其中,D3DDECLUSAGE_PSIZE类型用于指定一个顶点的点的大小。它用于点精灵,因此我们可以基于每个顶点控制其大小。一个D3DDECLUSAGE_POSITION成员的顶点声明意味着这个顶点已经被变换,它通知图形卡不要把这个顶点送到顶点处理阶段(变形和光照)。
² UsageIndex——用于标识多个相同用途的顶点分量。这个用途索引是位于区间[0, 15]间的一个整数。例如,假设我们有三个用途为D3DDECLUSAGE_NORMAL的顶点分量。我们可以为第一个指定用途索引为0,为第二个指定用途索引为1,并且为第三个指定用途索引为2。按这种方式,我们可以通过其用途索引标识每个特定的法线。
例:假设我们想要描述的顶点格式由两个数据流组成,第一个数据流包含位置、法线、纹理坐标3个分量,第二个数据流包含位置和纹理坐标2个分量,顶点声明可以指定如下:
D3DVERTEXELEMENT9 decl[] =
{
//第一个数据流,包含分量位置、法线、纹理坐标
{ 0, 0, D3DDECLTYPE_FLOAT3, D3DDECLMETHOD_DEFAULT,D3DDECLUSAGE_
POSITION, 0 },
{ 0, 12, D3DDECLTYPE_FLOAT3, D3DDECLMETHOD_DEFAULT, D3DDECLUSAGE_
NORMAL, 0 },
{ 0, 24, D3DDECLTYPE_FLOAT2, D3DDECLMETHOD_DEFAULT, D3DDECLUSAGE_
TEXCOORD, 0 },
//第一个数据流,包含分量位置、纹理坐标
{ 1, 0, D3DDECLTYPE_FLOAT3, D3DDECLMETHOD_DEFAULT, D3DDECLUSAGE_
POSITION, 1 },
{ 1, 12, D3DDECLTYPE_FLOAT3, D3DDECLMETHOD_DEFAULT, D3DDECLUSAGE_
NORMAL, 1 },
D3DDECL_END()
};
D3DDECL_END宏用于初始化D3DVERTEXELEMENT9数组的最后一个顶点元素。
2.2.2创建顶点声明
CreateVertexDeclaration函数用于创建顶点声明,decl为指向上一小节定义的D3DVERTEXELEMENT9数组的指针,函数返回IDirect3DVertexDeclaration9指针g_Decl;
IDirect3DVertexDeclaration9 *g_Decl = NULL;
g_pd3dDevice->CreateVertexDeclaration(decl ,&g_Decl);
2.2.3设置顶点声明
g_pd3dDevice->SetVertexDeclaration(g_Decl);
至此,可编程数据流模型、顶点声明介绍完毕,在下面的例子中读者将会有更连贯的理解。
2.3用顶点着色器实现渐变动画
2.3.1渐变动画(Morphing)
Morphing渐变是20世纪90年代出现的一种革命性的计算机图形技术,该技术使得动画序列平滑且易于处理,即使在低档配置的计算机系统上也能正常运行。
渐变是指随时间的变化把一个形状改变为另一个形状。对我们而言,这些形状就是Mesh网格模型。渐变网格模型的处理就是以时间轴为基准,逐渐地改变网格模型顶点的坐标,从一个网格模型的形状渐变到另外一个。请看图2.3:
图2.3
我们在程序中使用两个网格模型——源网格模型和目标网格模型,设源网格模型中顶点1的坐标为A(Ax,Ay,Az),目标网格模型中对应顶点1的坐标为B(Bx,By,Bz),要计算渐变过程中时间点t所对应的顶点1的坐标C(Cx,Cy,Cz),我们使用如下方法:
T为源网格模型到目标网格模型渐变所花费的全部时间,得到时间点t占整个过程T的比例为:
S = t / T
那么顶点1在t时刻对应的坐标C为:
C = A * (1-S)+ B * S
这样,在渲染过程中我们根据时间不断调整S的值,就得到了从源网格模型(形状一)到目标网格模型(形状二)的平滑过渡。
接下来将在程序里使用顶点着色器实现我们的渐变动画。
2.3.2渐变动画中的顶点声明
程序中,我们设定一个顶点对应两个数据流,这两个数据流分别包含了源网格模型的数据和目标网格模型的数据。渲染过程中,我们在着色器里根据两个数据流中的顶点数据以及时间值确定最终的顶点信息。
个数据流包含分量如下:
源网格模型数据流:顶点位置、顶点法线、纹理坐标;
目标网格模型数据流:顶点位置、顶点法线;
注意目标网格模型数据流没有包含纹理坐标,因为纹理对于两个网格模型都是一样的,所以仅使用源网格模型的纹理就可以了。
顶点声明指定如下:
D3DVERTEXELEMENT9 decl[] =
{
//源网格模型数据流,包含分量位置、法线、纹理坐标
{ 0, 0, D3DDECLTYPE_FLOAT3, D3DDECLMETHOD_DEFAULT,D3DDECLUSAGE_
POSITION, 0 },
{ 0, 12, D3DDECLTYPE_FLOAT3, D3DDECLMETHOD_DEFAULT, D3DDECLUSAGE_
NORMAL, 0 },
{ 0, 24, D3DDECLTYPE_FLOAT2, D3DDECLMETHOD_DEFAULT, D3DDECLUSAGE_
TEXCOORD, 0 },
//目标网格模型数据流,包含分量位置、纹理坐标
{ 1, 0, D3DDECLTYPE_FLOAT3, D3DDECLMETHOD_DEFAULT, D3DDECLUSAGE_
POSITION, 1 },
{ 1, 12, D3DDECLTYPE_FLOAT3, D3DDECLMETHOD_DEFAULT, D3DDECLUSAGE_
NORMAL, 1 },
D3DDECL_END()
};
2.3.3渐变动画中的顶点着色器
下面给出顶点着色器源码,代码存储于vs.txt中,该顶点着色器根据源网格模型数据流和目标网格模型数据流中的信息以及时间标尺值计算出顶点最终位置信息,并对顶点做了坐标变换和光照处理。代码中给出了详细的注释,帮助读者理解。
//全局变量
//世界矩阵、观察矩阵、投影矩阵的合矩阵,用于顶点的坐标变换
matrix WVPMatrix;
//光照方向
vector LightDirection;
//存储2.3.1小节提到的公式S = t / T中的时间标尺S值
//注意到Scalar是一个vector类型,我们在Scalar.x中存储了S值,Scalar.y中存储的则是(1-S)值
vector Scalar;
//输入
struct VS_INPUT
{
//对应源网格模型数据流中的顶点分量:位置、法线、纹理坐标
vector position : POSITION;
vector normal : NORMAL;
float2 uvCoords : TEXCOORD;
//对应目标网格模型数据流中的顶点分量:位置、法线
vector position1 : POSITION1;
vector normal1 : NORMAL1;
};
//输出
struct VS_OUTPUT
{
vector position : POSITION;
vector diffuse : COLOR;
float2 uvCoords : TEXCOORD;
};
//入口函数
VS_OUTPUT Main(VS_INPUT input)
{
VS_OUTPUT output = (VS_OUTPUT)0;
//顶点最终位置output.position取决于源网格模型数据流中位置信息input.position和目标网格模型数据流中位置信息input.position1以及时间标尺Scalar的值
//对应2.3.1小节中的公式C = A * (1-S)+ B * S
output.position = input.position*Scalar.x + input.position1*Scalar.y;
//顶点坐标变换操作
output.position = mul(output.position, WVPMatrix);
//计算顶点最终法线值
vector normal = input.normal*Scalar.x + input.normal1*Scalar.y;
//逆光方向与法线的点积,获得漫射色彩
output.diffuse = dot((-LightDirection), normal);
//存储纹理坐标
output.uvCoords = input.uvCoords;
return output;
}
以上是本例用到的顶点着色器,在接下来的应用程序中,我们将给三个着色器全局变量赋值:
² WVPMatrix;
世界矩阵、观察矩阵、投影矩阵的合矩阵,用于顶点的坐标变换;
² LightDirection
光照方向;
² Scalar
存储2.3.1小节提到的公式S = t / T中的时间标尺S值;
注意到Scalar是一个vector类型,我们在Scalar.x中存储了S值,Scalar.y中存储的则是(1-S)值;
2.3.4应用程序
我们在应用程序中执行以下操作:
· 加载两个两个Mesh模型:源网格模型,目标网格模型;
· 创建、设置顶点声明;
· 创建、设置顶点着色器;
· 为着色器全局赋值;
· 把两个Mesh模型数据分别绑定到两个数据流中;
· 渲染Mesh模型;
下面是应用程序代码:
…
/*********************声明变量*****************/
//两个指向LPD3DXMESH的指针,分别用于存储源网格模型和目标网格模型;
LPD3DXMESH g_SourceMesh;
LPD3DXMESH g_TargetMesh;
//顶点声明指针
IDirect3DVertexDeclaration9 *g_Decl = NULL;
//顶点着色器
IDirect3DVertexShader9 *g_VS = NULL;
//常量表
ID3DXConstantTable* ConstTable = NULL;
//常量句柄
D3DXHANDLE WVPMatrixHandle = 0;
D3DXHANDLE ScalarHandle = 0;
D3DXHANDLE LightDirHandle = 0;
…
/***************程序初始化*****************/
//加载源、目标网格模型
Load_Meshes();
//顶点声明
D3DVERTEXELEMENT9 MorphMeshDecl[] =
{
//1st stream is for source mesh - position, normal, texcoord
{ 0, 0, D3DDECLTYPE_FLOAT3, D3DDECLMETHOD_DEFAULT, D3DDECLUSAGE_POSITION, 0 },
{ 0, 12, D3DDECLTYPE_FLOAT3, D3DDECLMETHOD_DEFAULT, D3DDECLUSAGE_NORMAL, 0 },
{ 0, 24, D3DDECLTYPE_FLOAT2, D3DDECLMETHOD_DEFAULT, D3DDECLUSAGE_TEXCOORD, 0 },
//2nd stream is for target mesh - position, normal
{ 1, 0, D3DDECLTYPE_FLOAT3, D3DDECLMETHOD_DEFAULT, D3DDECLUSAGE_POSITION, 1 },
{ 1, 12, D3DDECLTYPE_FLOAT3, D3DDECLMETHOD_DEFAULT, D3DDECLUSAGE_NORMAL, 1 },
D3DDECL_END()
};
//创建顶点着色器
ID3DXBuffer* shader = NULL;
ID3DXBuffer* errorBuffer = NULL;
D3DXCompileShaderFromFile("vs.txt",
0,
0,
"Main", // entry point function name
"vs_1_1",
D3DXSHADER_DEBUG,
&shader,
&errorBuffer,
&ConstTable);
if(errorBuffer)
{
::MessageBox(0, (char*)errorBuffer->GetBufferPointer(), 0, 0);
ReleaseCOM(errorBuffer);
}
//创建顶点着色器
g_pd3dDevice->CreateVertexShader((DWORD*)shader->GetBufferPointer(), &g_VS);
//创建顶点声明
g_pd3dDevice->CreateVertexDeclaration(MorphMeshDecl ,&g_Decl);
//得到各常量句柄
WVPMatrixHandle = ConstTable->GetConstantByName(0, "WVPMatrix");
ScalarHandle = ConstTable->GetConstantByName(0, "Scalar");
LightDirHandle = ConstTable->GetConstantByName(0, "LightDirection");
//为着色器全局变量LightDirection赋值
ConstTable->SetVector(g_pd3dDevice, LightDirHandle, &D3DXVECTOR4(0.0f, -1.0f, 0.0f, 0.0f));
//设置各着色器变量为默认值
ConstTable->SetDefaults(g_pd3dDevice);
…
/*******************渲染*******************/
g_pd3dDevice->Clear( 0, NULL, D3DCLEAR_TARGET | D3DCLEAR_ZBUFFER,
D3DCOLOR_XRGB(153,153,153), 1.0f, 0 );
g_pd3dDevice->BeginScene();
//为着色器全局变量WVPMatrix赋值
D3DXMATRIX matWorld, matView, matProj;
g_pd3dDevice->GetTransform(D3DTS_WORLD, &matWorld);
g_pd3dDevice->GetTransform(D3DTS_VIEW, &matView);
g_pd3dDevice->GetTransform(D3DTS_PROJECTION, &matProj);
D3DXMATRIX matWVP;
matWVP = matWorld * matView * matProj;
ConstTable->SetMatrix(g_pd3dDevice, WVPMatrixHandle, &matWVP);
//为着色器全局变量Scalar赋值,注意程序中获取时间标尺值Scalar的方法
float DolphinTimeFactor = (float)(timeGetTime() % 501) / 250.0f;
float Scalar =
(DolphinTimeFactor<=1.0f)?DolphinTimeFactor:(2.0f-DolphinTimeFactor);
ConstTable->SetVector(g_pd3dDevice,ScalarHandle,&D3DXVECTOR4(1.0f-Scalar, Scalar, 0.0f, 0.0f));
//设置顶点着色器和顶点声明
g_pd3dDevice->SetVertexShader(g_VS);
g_pd3dDevice->SetVertexDeclaration(g_Decl);
//绑定目标网格模型的定点缓存到第二个数据流中
IDirect3DVertexBuffer9 *pVB = NULL;
g_TargetMesh->GetVertexBuffer(&pVB);
g_pd3dDevice->SetStreamSource(1, pVB, 0,
D3DXGetFVFVertexSize(g_TargetMesh->GetFVF()));
ReleaseCOM(pVB);
//绑定源网格模型的顶点缓存到第一个数据流中
g_SourceMesh->GetVertexBuffer(&pVB);
g_pd3dDevice->SetStreamSource(0, pVB, 0,
D3DXGetFVFVertexSize(g_TargetMesh->GetFVF()));
ReleaseCOM(pVB);
//绘制Mesh网格模型
DrawMesh(g_SourceMesh, g_pMeshTextures0, g_VS, g_Decl);
g_pd3dDevice->EndScene();
g_pd3dDevice->Present( NULL, NULL, NULL, NULL );
…
2.3.5对应用程序的一点说明
程序中我们使用SetStreamSource方法把源网格模型和目标网格模型中的顶点缓存分别绑定到两个设备数据流,但是Direct3D对数据流中的数据的真正引用只有在调用诸如DrawPrimitive、DrawIndexedPrimitive之类的绘制方法时才发生,因此在绘制Mesh网格模型时我们不能再使用传统的DrawSubmit方法,而是使用了DrawIndexedPrimitive,下面就如何调用DrawIndexedPrimitive绘制Mesh模型进行说明,该部分内容和HLSL着色器关系不大,在这里列出仅仅是为了大家理解程序的完整性,读者完全可以跳过本节不看。
使用DrawIndexedPrimitive绘制Mesh模型的步骤如下:
1. 加载网格模型后使用OptimizeInPlace方法对Mesh进行优化;
2. 一旦优化了网格模型,你就可以查询ID3DXMesh对象,得到一个D3DXATTRIBUTERANGE数据类型的数组,我们称之为属性列表,该数据类型被定义如下:
typedef struct_D3DXATTRIBUTERANGE{
DWORD AttribId; //子集编号
DWORD FaceStart; //这两个变量用于圈定本子集中的多边形
DWORD FaceCount;
DWORD VertexStart; //这两个变量用于圈定本子集中的顶点
DWORD VertexCount;
} D3DXATTRIBUTERANGE;
我们属性列表中的每一项都代表一个被优化后Mesh的一个子集,D3DXATTRIBUTERANGE结构的各字段描述了该子集的信息。
1. 得到属性数据后,我们就调用DrawIndexedPrimitive方法可以精美地渲染子集了。
下面是绘制Mesh模型的程序代码:
在Load_Meshes()函数的最后,我们使用OptimizeInPlace方法对源网格模型和目标网格模型进行优化,其他加载材质和纹理的操作和之前一样,相信大家能够理解:
…
//优化源网格模型
g_SourceMesh->OptimizeInplace(D3DXMESHOPT_ATTRSORT, NULL, NULL, NULL, NULL);
…
//优化目标网格模型
g_TargetMesh->OptimizeInplace(D3DXMESHOPT_ATTRSORT, NULL, NULL, NULL, NULL);
…
在Draw_Mesh()函数中,渲染模型,注意程序是如何配合属性表调用DrawIndexedPrimitive方法进行绘制的:
…
//分别得到指向Mesh模型顶点缓存区和索引缓存区的指针
IDirect3DVertexBuffer9 *pVB = NULL;
IDirect3DIndexBuffer9 *pIB = NULL;
pMesh->GetVertexBuffer(&pVB);
pMesh->GetIndexBuffer(&pIB);
//得到Mesh模型的属性列表
DWORD NumAttributes;
D3DXATTRIBUTERANGE *pAttributes = NULL;
pMesh->GetAttributeTable(NULL, &NumAttributes);
pAttributes = new D3DXATTRIBUTERANGE[NumAttributes];
pMesh->GetAttributeTable(pAttributes, &NumAttributes);
//设置顶点着色器和顶点声明
g_pd3dDevice->SetVertexShader(pShader);
g_pd3dDevice->SetVertexDeclaration(pDecl);
//设置数据流
g_pd3dDevice->SetStreamSource(0, pVB, 0, D3DXGetFVFVertexSize(pMesh->GetFVF()));
g_pd3dDevice->SetIndices(pIB);
//遍历属性列表并配合其中的信息调用DrawIndexPrimitive绘制各个子集
for(DWORD i=0;i<NumAttributes;i++)
{
if(pAttributes[i].FaceCount)
{
//Get material number
DWORD MatNum = pAttributes[i].AttribId;
//Set texture
g_pd3dDevice->SetTexture(0, pTextures[MatNum]);
//Draw the mesh subset
g_pd3dDevice->DrawIndexedPrimitive(D3DPT_TRIANGLELIST, 0,
pAttributes[i].VertexStart,
pAttributes[i].VertexCount,
pAttributes[i].FaceStart * 3,
pAttributes[i].FaceCount);
}
}
//Free resources
ReleaseCOM(pVB);
ReleaseCOM(pIB);
delete [] pAttributes;
…
编译运行程序,效果如图2.4所示,你将看到屏幕上白色的海豚上下翻腾,同时感受到顶点着色器为渲染效果所带来的巨大改善。
图2.4