[Nebula3 帧渲染Frame系统]
[补充说明 .fx 文件]
这里简要说明一下 fx 文件, fx 文件是渲染管线的配置文件,
主要由三部分组成:变量声明,管道状态technique, pass,渲染函数,
1. 变量声明在文件开始,这些变量可以在运行时操作,格式如下,
type id : tag = {initiliaze value} ;
其中 type 必须是 HLSL 可识别的类型, id 必须唯一, tag 用来表示变量的用途, 后面是初始值
2. 管道状态, 一个 fx 文件可以配置多个 technique, 一个 technique 可以配置多个 pass 特殊如下,
technique (tch_uniqueId)
{
pass (pass_uniqueId)
{
Texture[0] = <diffuseTexture>;
VertexShader = compile vs_2_2 vsMain()
PiexlShader = compile ps_2_2 psMain()
}
}
配置的 technique , pass 可以根据 显卡的支持,以及应用程序的需求,选择性的激活相应的 technique 和 pass
3. 渲染函数, 这个就是传统意义上的着色程序,定点着色,片段着色等
创建
至此我们大概知道了 fx 目的是干嘛的,然后通过如下的方法使用,先需要创建一个Effect对应的对象,
LPD3DXEFFECT anEffect;
D3DXCreateEffectFromFile(gDevice,"fxfname.fx",NULL,NULL,0,NULL,&anEffect,NULL)
然后 找到对应的 technique, 可以用如下的方法
D3DXHANDLE hTech;
anEffect->FindNextValidTechnique(NULL,&hTech);
N3 把每个 technique 分解到不同的 特性组, 激活的时候根据特效组选择相应的 , 具体实现如下:
technique niqueId <string Mask = "feature">
{
pass uniqueId
{
}
}
D3DXHANDLE hFeatureAnnotation = this->d3d9Effect->GetAnnotationByName(this->hTechnique, "Mask");
hr = this->d3d9Effect->GetString(hFeatureAnnotation, &strPtr);
让一个 technique 和 一个 Mask 标注的 feature 绑定,然后通过 feature 来激活 technique。
使用
对于 D3DXEFFECT 按照 如下结构使用:
if (SUCCEEDED(anEffect->SetTechnique(hTech)))
{
UINT numPasses;
nEffect->Begin(&passes,0);
for (UINT i=0;i<numPasses;i++)
{
anEffect->BeginPass(i); // Set the pass
// render geometry e.g. DrawIndexedPrimitive
anEffect->EndPass();
}
anEffect->End();
}
交互
对于与EFFECT的交互,设置Effect文件中定义的变量,可以通过如下的接口:
anEffect->SetTexture("t0",texture1);
anEffect->SetMatrix("world",&worldMatrix);
anEffect->SetMatrix("camera",&viewMatrix);
anEffect->SetVector("var1", v);
这里有一个非常有用的特性,就是变量可以设置成共享的,共享的范围是一个 effect pool, 这样只用设置一次变量,pool 中的所有 effect 就都有了它的值。
总结
effect文件完全把渲染管线的状态设置解耦出来了,以前渲染流程中的各种状态都是根据资源类型先在代码中写死了的。现在全部在配置文件中。
dx的 .x 文件是可以包含 .fx 的。mesh 和 renderstate 的对应关系。
最后这里推荐几个用来编写fx和shader的软件
dx 的 effectEdit
nv 的 fx composer
atm 的 rendermonkey
[补充说明 shader]
着色程序无处不在,这里从概念触发,补充一下基本定义。
在渲染场景的时候,显卡需要处理场景的几何数据和纹理信息。在很久很久以前,显卡基本没有封装几个算法来处理这些传递给他的这些数据,这个时代就是固定管线时代,简称FFP(fixed function pipeline),在这个时代,程序只是在渲染前,选择显卡中封装的固定管线,然后设置显卡的各种可控状态,用这种方式导致的问题就是,很难创造一些独特画面的游戏,因为程序员控制不了渲染数据的处理过程,只能用固定的方法,推送数据给显卡。为了解决这个问题,尝试对显卡深度的自由定制,皮克斯科技有限公司在2001年,成功的推出了第一款具备着色能力的渲染硬件。
着色通常是通过两种方式,顶点着色用来操作顶点数据,像素着色用来操作像素数据。sm5.0时代已经多了一种数据,叫做几何数据,就是很多顶点组成的具备一定几何意义的顶点序列。在着色时代,着色代码被加载到显存,直接干预显卡的渲染管线。开始的时候,着色代码是以汇编的形式出现,在推出一段时间后,就出现了几种类C的高级语言,这些高级语言可以被编译成显卡识别的汇编代码。比如微软给dx定制的HLSL(High-Level Shading Language) 和 OpenGL 定制的 GLSL( OpenGL Shading Language)。显卡的硬件厂商也提供了一些高级语言,比如Nvidia的 Cg 以及 ATI的 ASHLI ( Advanced Shading Language Interface)。大家期望在将来只会由一个通用的着色语言(HLSL和Cg实际上是同一个语言,只是因为各个公司的品牌目的而叫的不同的名字)。 这里集中在DirectX上的渲染描述,所以在下面的描述中,shaders 都是以 HLSL语言编写,应用于Direct3D上。
DirectX10 需要特别说明一点, DirectX10 提供的是 SM 4.0, 只在Vista平台上面运行,提供了如下的几个特性:
- 统一的着色片段 - 不区分是定点着色还是像素着色
- 几何着色 - 能够在GPU中生成新的顶点数据
- 资源虚拟化 - 显卡也支持虚拟显存,也就是显卡在显存不够的时候,直接使用主机的内存
Direct3D 渲染管线
为了知道怎么编写着色代码,我们需要先知道这些着色代码是怎么和3D渲染管线结合。
Wolfgang Engel 写了非常多的关于shader的号文章,强烈建议你去GameDev上看下他的文章,了解下shaders是怎么切入渲染管线的, 而且如果你想在shading这一块做深入的研究和探索的话,也强烈建议你去拜读一下他的书《Programming Vertex and Pixel Shaders》。
一个简化版本的渲染管线如下:
- 应用程序
- 场景管理
- 网格顶点和曲面
- 顶点 操作
- 变换和光照
- 裁剪
- 像素操作
- 单元和光栅化
- 着色和多层纹理操作
- 雾化,Alpha测试,深度缓存,抗锯齿
- 显示设备绘图
- 应用程序 是指游戏程序本身,游戏程序本身根据游戏逻辑管理实体对象,维护游戏场景,对网格数据进行细分,并对场景进行高层裁剪等。
- 通过前面的步骤,就会提交顶点和纹理等一些数据到显卡,显卡根据应用程序传递给它的相关矩阵进行顶点变换和顶点光照计算,接着显卡会裁剪掉任何视口之外的多边形和几何片段。
- 这些变换后的数据接着被单元和光栅化后被传递给像素操作后最终传递给显示设备进行绘制操作。
这里面提到了一些不那么明显的名词,单元化和光栅化,我们知道顶点数据是一个三维的坐标,而计算机显示的时候是平面的,所以这里面就有一个比较重要的对应关系,而且单独的一个顶点也不能在设备中进行关联绘制,顶点都是属于某一个基本单元,一个三角形或者一个曲面,所以在经历了顶点操作后,像素颜色的具体操作前,需要把相关的顶点关联起来,组成他们本来的几何基本单元,这样,我们才知道怎么绘制每一个顶点。在顶点组成基本的几何单元后,要把这个几何单元绘制到屏幕上,【为了简化这里直接把单元限定在三角形】还需要一个映射和插值过来,比如一个三维的几何三角形,我们需要先把三个顶点映射到屏幕上,然后三个顶点在屏幕上构建的三角形区域也就是我们看到的三维三角形的一个二维映射,而根据三个顶点,我们还需要插值很多像素出来,填满整个三角形。这样由三个3维顶点组成的几何三角形,就被映射到了屏幕上的一个三角区域。整个过程中,有两个很重要的步骤,把顶点组成三角形,并同时把三个顶点映射到屏幕上,在屏幕上的三个点,构建一个像素区域,像素区域中的每个像素的相关颜色数据都需要根据已知的三个顶点所在的像素数据插值而来。而且每一个像素都可以反推到一个或者多个三维的顶点。这么一长段就是非常直观的表达,单元和光栅化操作,对于光栅化其实还有更深入的一些操作,比如像素点坐标系的变化,从视口坐标到设备坐标等。
有了前面的细说,我们看下着色是在管线的那些地图起作用:
- 应用程序
- 场景管理
- 网格顶点和曲面
- 顶点 操作
- 变换和光照 T&L or VERTEX SHADER <------
- 裁剪
- 像素操作
- 单元和光栅化
- 着色和多层纹理操作 or PIXEL SHADER <-----
- 雾化,Alpha测试,深度缓存,抗锯齿
- 显示设备绘图
从上面的示例我们可以知道,如果我们写一个顶点着色片段,我们就可以控制管线当中的 T&L操作,而且提供了顶点着色片段后,我们必须对顶点数据进行这些操作,不能再直接把数据传递给显卡了。像素着色片段可以操作三角形中的每一个像素并且控制贴图等操作。注意像素着色这个步骤是出现在alpha 测试前的,所以我们处理的像素在经历像素着色后有可能会在后面的测试中被丢弃。
我们不能控制管线中的所有步骤,在将来可能会出现其他类型的着色片段,让我们在管线中取得更多的控制权,比如,几何着色片段,【这个已经有了,在sm4.0中】
重要注意事项
- 顶点着色片段一次只操作一个顶点
- 顶点着色片段不能增加顶点
- 像素着色片段一次只操作一个像素
- 像素着色片段不能增加像素
在游戏中使用着色片段
Direct3D 有一个非常便利的 effects 类,用来解析和使用.fx文件。 这个运行你在运行时编辑和激活着色片段。不同的显卡支持的特性不一样,所以很难写一些在所有平台上通用而且复杂的着色片段,但是在使用effect文件后,你可以写一系列的shader,然后根据不同的显卡激活相应的shader。DX的SDK带了一个叫 Effect Edit的工具,可以加载和查看effect文件,这个非常便利,可以让你即刻看到变更的显示效果。当你深入学习后,你可能需要更加专业和强大的工具,这个时候你可以看下 ATI 公司的免费IDE软件 RenderMonkey, 这个软件可以导出.fx文件。
对顶点着色和像素着色有了一些初步认识后,下面将分别深入两个,进行一些研究。
[补充说明顶点着色片段 Vertex Shader]
这里讲更加深入的描述shaders中的Vertex Shader。顶点着色能够操作顶点相关的数据。 这小节分为如下几个小节:
- 顶点数据
- 应用顶点Shader
- 顶点着色例子
- 例子1 简单的顶点坐标变换
- 例子2 绘制一个飘动的旗子
- 例子3 顶点的光照计算
- 总结
顶点数据
顶点数据的格式依赖于我们自己的应用程序。比如我们为每个顶点传递一个position, 一个 normal 以及对于需要贴图的几何结构传递纹理的uv和动态光照参数,我们也可以只给每个顶点一个坐标以及颜色数据。用固定管线,我们可以声明一个自定义的顶点数据的结构体,然后通过Direct3D的FVF声明,构造一个结构体对应的标志位来表达我们的顶点结构体的格式,让固定管线认识我们的顶点结构体的格式。 这里我们既然是用着色程序,我们就可以如下使用顶点声明顶点结构体:
struct TVertex
{
D3DXVECTOR3 position;
D3DXVECTOR3 Normal;
D3DXVECTOR3 Tex;
};
然后我们用DirectX3D的FVF方式构建一个结构体的格式描述,
const D3DVERTEXELEMENT9 dec[4] =
{
{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},
D3DDECL_END()
};
上述的描述结构体中,每一行都对应我们顶点结构体中的一个成员变量,声明的格式为:
{ Stream, Offset, Type, Method, Usage, UsageIndex }
格式的细节说明参考DX的文档中关于 D3DVERTEXELEMENT9的说明。其中的Type对应到D3DDECLTYPE中枚举的类型之一,其中Method描述的是tessellator怎么处理顶点数据,其中Usage是告诉数据项的用途,用途可以是 坐标, 法线, UV坐标,切线,融合权重等。注意,如果我们想再顶点数据中传递我们自己的数据,我们可以用D3DDECLUSAGE_TEXCOORD来描述数据项的用途。在格式描述的最后一行需要必须放在一个D3DDECL_END。
有了上面的顶点数据格式描述后,我们需要在设备中创建一个声明,如下声明:
IDirect3DVertexDeclaration9 m_vertexDeclaration;
gDevice->CreateVertexDeclaration(dec,&m_vertexDeclaration);
如果上述的调用成功后(对于那些返回HRESULT的D3D函数,你最好是每个都检查一下返回值),m_vertexDeclaration就会指向相应的Direct3D对象。有个顶点数据格式声明,在我们进行具体的渲染,把顶点数据的个buffer传递给设备的时候,我们需要用m_vertexDeclaration告诉设备我们顶点buffer的格式。也就是进行如下调用:
gDevice->SetStreamSource( 0, m_vb,0, sizeof(TVertex));
gDevice->SetVertexDeclaration(m_vertexDeclaration);
// Render
应用顶点Shader
我们可以用接口 D3DXCompileShaderFromFile 从文件编译写好的.fx文件,然后用接口 CreateVertexShader 创建VertexShader,最后通过接口 SetVertexShader 为渲染过程设置顶点Shader,并通过接口 SetVertexShaderConstantX 与Shader进行交互,设置Shader中的常量。这里就不细节描述这个应用过程了,因为前面已经说过怎么用effect文件来处理这个过程,而且用effect文件更优。
顶点着色例子
既然已经知道怎么声明顶点数据,以及怎么应用顶点着色代码到渲染过程中,接下来我们将关注一下着色代码本身。
在开始编写HLSL的时候,你可能会发现C与HLSL中的最大区别是在内在类型上,在着色代码中我们用一些类似float2, float3的类型,不过HLSL也是对C的大部分类型是支持的,比如float, int, bool, double等,着色代码中,大部分C的操作符也是可以用的。对于循环有一点点不一样,HLSL中没有最大值的指令。
顶点着色有两类输入数据,uniform数据代表的是常理,会存储在常理寄存器中,还有一个varying数据,这个数据会存储在输入寄存器中。在编写HLSL的时候,我们需要告诉编译器数据的类型,好让编译器把输入放入正确类型的寄存器中。这种类型的区别,我们是通过使用输入语义来表达的,比如NORMAL, POSITION, COLOR 等。对于 Uniform数据,他不需要指定语义,因为他是放在常理寄存器中。下面我们看一些具体的代码片段。
顶点着色例子1 顶点坐标变换
这个着色代码,包含在一个effect文件中,通过下面的链接 SimpleVShader.fx. 下载:你可以把她加载到 DirectX的Effect Edit中,如果是2004Dec版本后的DX,以及么有Effect Edit,可以找替代软件RenderMonkey 或者 fx composer 。用老版本的Effect Edit ,你可以在界面左边看到effect文件的代码,而在右边看到effect文件应用到一个老虎模型上的效果。这样就可以有非常直观的视角观察着色代码的渲染效果。如下图所示:
// transformations provided by the app, constant Uniform data
float4x4 matWorldViewProj: WORLDVIEWPROJECTION;
// the format of our vertex data
struct VS_OUTPUT
{
float4 Pos : POSITION;
};
// Simple Vertex Shader - carry out transformation
VS_OUTPUT VS(float4 Pos : POSITION)
{
VS_OUTPUT Out = (VS_OUTPUT)0;
Out.Pos = mul(Pos,matWorldViewProj);
return Out;
}
第一行定义了一个输入常量 MatWorldViewProj, 这是一个4*4的矩阵。把顶点从模型空间变换到视点空间,需要对顶点依次执行下面的变换,显示世界矩阵,变换到世界坐标系,然后是视点矩阵和投影矩阵,更多细节参考关于矩阵的章节。我们可以为上述的每一个矩阵定义一个输入常量,我们也可以为上述矩阵的乘积定义一个矩阵传给着色代码。语义 WORLDVIEWPROJECTION 就是告诉Effect Edit程序,这个常量可以由应用程序设置。
前面提到了,顶点着色接受一个顶点数据作为输入,顶点早色对输入的顶点进行一些修改,然后把数据返回到渲染管线,所以我们也需要显示的定义出我们着色代码的输出结构体,我们定义了一个结构体 VS_OUTPUT ,用来存储一个3D坐标,并且用语义 POSITION 告诉编译器这个varying数据放到正常的寄存器中作为顶点的坐标数据。其他的类型的语义还有PSIZE, FOG, COLORn and TEXCOORDn., n一般不要超过8,以前的显卡最多就支持8个。
然后接下来是顶点着色的入口函数,在函数中,我们必须清晰的写出所有输入和输出参数,如代码中所示,我们的顶点着色函数,输出一个 VS_OUTPUT 结构体,并且接受一个 POSITION。
下面的一行,我们生命了一个类型的输出变量,并且用0初始化它。然后我们通过矩阵 matWorldViewProj对顶点执行标准的 world * view * projection 变换。最后返回变换后的顶点数据。上面的着色代码其实是一个非常简单的着色程序。
顶点着色例子2 飘动的旗子
这个例子展示了怎么操作顶点数据,来实现一个旗子飘动的效果。通过下面的链接 VShader3.zip下载着色代码和纹理。
在这个例子中,我们只是操作了一下顶点的左边,就制作了非常逼真的旗子飘动效果。为了实现这种飘动,我们需要一个连续变化的输入作为角度,然后传递给sin函数,这里我选择的是时间。当然你可以使用任何你想要的变量来作为输入,并且你也可以在着色代码外计算这个变量,然后当做uniform传递进来。
effect文件第一部分什么一些全局的Uniform变量,并且通过语义把它们链接到effect Edit上,类似在Fx Composer 中生命变量的UI编辑配置,比如: float4x4 matWorld : WORLD; 声明了一个叫做matWorld的变量,并且告诉effect edit要把世界矩阵设置到变量里面,相当于要在游戏中做如下的调用: dxEffect->SetMatrix("matWorld",&mat))).
我们希望着色代码输出变换后的顶点坐标以及一个相应的纹理坐标, 所以我们定义如下的输出结构体:
struct VS_OUTPUT
{
float4 Pos : POSITION;
float2 tex : TEXCOORD0;
};
我们的着色程序接受顶点变换前的坐标和一个相应的纹理坐标作为输入,然后输出变换后的坐标,同时把UV坐标直接输出。函数头如下:
VS_OUTPUT VS(float4 Pos : POSITION,float2 tex : TEXCOORD0)
接下来我们需要一个连续性的输入作为角度值,来进行sin求值,因此我们用一个模除,把值变到 0 到 360度,代码如下:
float angle=(time%360)*2;
在例子代码中,我对角度值做了一点点缩放,让旗子飘动得更快,你可以尝试不同的缩放值,查看不一样的效果。你也可以传递其他的uniform变量来做角度值,查看不一样的效果。
这里使用的3D模型是一个平面网格,位于x, y平面上,z值指向平面里面,所以让旗子飘动的话,只需改变顶点的z值。如下代码所示:
Pos.z = sin( Pos.x+angle);
上述的代码就会给出一个波浪效果,如下:
这个飘动只会在一个方向上,为了更逼真一点,我们可以让z值依赖 ,x 和 y 两个维度,如下计算:
Pos.z += sin( Pos.y/2+angle);
上述对于y进行了一个除2操作,只是一个普通的参数,你可以参数不同的值,来尝试不同的效果。如下:
上面的飘动效果已经有所改善,看起啦更加真实一些。然而还有一个可以改善的是,旗子的左边被绑定到旗杆上,这个时候要求左边不能飘动,这个时候,
可以做如下的一个变更,让x值越小的时候,z值的变化也越小。
Pos.z *= Pos.x * 0.09f;
这个时候就会得到如下效果:
顶点着色例子3 顶点光照
下面看一下稍微复杂一点点的着色代码,在这个例子中,我们会对顶点做一些光照计算和顶点贴图计算,你可以在这个链接 VShader2.fx.下载代码。
effect edit 装载指定的模型和纹理,灯光后,操作场景中代表灯光的黄色箭头,可以实时观察灯光对场景的影响,因为我们需要计算光照,所以我们需要声明和初始化一些光照相关的参数,我用float4来存储需要的灯光的red, green, blue 和 alpha。如下所示:
// light intensity
float4 I_a = { 0.1f, 0.1f, 0.1f, 0.1f }; // ambient
float4 I_d = { 1.0f, 1.0f, 1.0f, 1.0f }; // diffuse
float4 I_s = { 1.0f, 1.0f, 1.0f, 1.0f }; // specular
除了上述的变量,我们也需要定义模型材质对于光源的一些反射参数,如下:
// material reflectivity
float4 k_a : MATERIALAMBIENT = { 1.0f, 1.0f, 1.0f, 1.0f }; // ambient
float4 k_d : MATERIALDIFFUSE = { 1.0f, 1.0f, 1.0f, 1.0f }; // diffuse
float4 k_s : MATERIALSPECULAR= { 1.0f, 1.0f, 1.0f, 1.0f }; // specular
float n : MATERIALPOWER = 32.0f; // power
现在回到具体的着色计算的代码段,我们的着色代码需要输出顶点变换后的坐标,计算后的diffuse 和 specular颜色值,和顶点的UV坐标。所以我们定义一个如下的输出结构体:
struct VS_OUTPUT
{
float4 Pos : POSITION;
float4 Diff : COLOR0;
float4 Spec : COLOR1;
float2 Tex : TEXCOORD0;
};
着色代码的输入为,模型空间的顶点坐标,顶点的法线,以及顶点的UV坐标,在顶点着色阶段,我们没有用UV坐标做任何计算,只是简单的把它作为输出参数传递给像素着色。着色入口如下所示:
VS_OUTPUT VS(
float3 Pos : POSITION,
float3 Norm : NORMAL,
float2 Tex : TEXCOORD0)
接下来是顶点着色的函数体,和前面的例子一样,我们创建一个输出变量的实例,并用0初始化这个结构体。和前面的例子不一样的地方是,我们定义了三个单独的矩阵,分别是世界矩阵,视点矩阵(也叫视图矩阵),和投影矩阵,而不是传递一个模型视点投影矩阵。接下来的两行,我们把顶点坐标从模型空间变到视点空间:
float4x4 WorldView = mul(World, View);
float3 P=mul(float4(Pos, 1), (float4x3)WorldView); // position (view space)
讲解光照计算的公式已经超出了本文的范围,有很多地方,很详细的描述了光照计算的具体原理,参见这个 Implementing Lighting Models with HLSL链接。当讨论像素着色的时候,也会再次讨论光照计算公式。这里先贴上光照计算的着色代码:
float3 N = normalize(mul(Norm, (float3x3)WorldView)); // normal (view space)
float3 R = normalize(2 * dot(N, L) * N - L); // reflection vector
float3 V = -normalize(P); // view direction
Out.Pos = mul(float4(P, 1), Projection); // position (projected)
Out.Diff = I_a * k_a + I_d * k_d * max(0, dot(N, L)); // diffuse + ambient
Out.Spec = I_s * k_s * pow(max(0, dot(R, V)), n/4); // specular
这里不讲解光照的理论,我们这里只是对上述代码做一个简要的流程说明,上述代码中,我们把光照计算放在视点空间中进行,其中N代表在视图空间的法线,R是视图空间的发射向量。其中函数 Normalize 对向量执行正规化操作,而dot 执行向量的点乘。
代码中的最后三行填充输出结构体,首先把顶点左边从视图空间变到投影空间,然后把ambient和diffuse的和当做diffuse颜色。其中Ambient不依赖于光源的法向也不依赖顶点表面材质,只是一个简单的强度化颜色值。其中Diffuse依赖于模型表面,等于intensity * colour,并且与顶点和光源的角度值成比例关系。缩放的比例因子由顶点法向和顶点到光源的向量的点乘决定。最后specular的颜色值由 pow(x,y) 计算。
总结
- 通过顶点着色片段,我们可以把顶点计算算法插入显卡的渲染管线代替显卡本身硬件实现的算法
- 顶点着色,截取渲染管线中的顶点相关的数据作为输入,然后对顶点数据进行修改后,可以把数据继续输出到显卡的渲染管线
- 顶点着色代码一次只处理一个顶点
- 顶点着色中,我们可以修改顶点相关的所有数据,因为我们的着色代码是替换了显卡渲染管线的T&L操作,所以顶点着色代码最少要把顶点坐标从模型空间变换到视图空间
- 我们可以定义一些uniform 数据,作为顶点着色中的常量,并且显卡也会把这些数据放入常量寄存器,这些unifrom数据,是可以在应用程序中运行时被设值的。在着色代码中,我们也可以定义一些varying 数据,比如顶点坐标,颜色值等,不过我们必须为这些数据指定语义,好让编辑器知道数据的用途。
希望上面的几条,以及让你对使用和编写顶点着色程序有一个大概的了解。关于顶点着色的内容还是很丰富的,如果你要更加深入的了解相关信息,建议你阅读Wlfgame Engel的树《Programming Vertex and Pixel Shaders》( Resources )。
当顶点数据从着色代码出来后,会依次进入显卡渲染管线中的,裁剪阶段,单元化阶段,和光栅化阶段,然后才会进入像素着色阶段。进入像素着色阶段后,我们就可以写像素着色代码来代替显卡的像素着色算法了。下面的一小节就会变数,像素着色 : Pixel Shaders。
[补充说明像素着色片段 Pixel Shader]
阅读者节前,需要先了解和。着色程序中,顶点着色和像素着色有很多共同的概念,这本小节中,只会讨论他们之间不同的地方。本小节分为如下段落:
- 介绍
- 像素着色的输入
- 像素着色的输出
- 应用像素着色片段
- 像素着色的例子
- 像素着色例子1 Ambient Light
- 像素着色例子2 Diffuse Light
- 总结
介绍
像素着色允许你操作渲染管线中关于像素的材质上色和贴图上色的这个过程。像素着色片段接受采样后的像素点数据,包括像素点的颜色,z值(深度),以及贴图数据。你也可以接受渲染管线前面一些阶段存在的数据,比如法线等。在像素着色代码中你可以写相关的纹理操作指令。
像素着色的输入
和顶点着色一样,我们可以用语义来声明变量的用途,像素着色中可以使用的语义包括:
- VFACE - 图元
- VPOS - 像素坐标 (x, y)
- COLORn - 颜色值
- TEXCOORDn 纹理坐标(贴图坐标)
其中n是一个整数,标明的相关寄存器的索引值。其中TEXCOORDn也可以用来传递纹理坐标意外的自定义数据
像素着色的输出
当我们结束着色代码对像素数据的处理后,我们需要输出像素相关的一些数据,同样,这里我们也使用语义来告诉变量的用途,让后面的渲染管线继续执行。
- COLORn - 标明是render target n 的颜色数据
- DEPTH - 深度值(z值)
应用像素着色片段
可以用接口 D3DXCompileShaderFromFile,编译像素着色代码,然后用接口 CreatePixelShader 创建像素着色片段,用接口SetPixelShader. 设置管线的像素着色入口,可以通过接口 SetPixelShaderConstantX. 与像素着色片段进行交互。当然我们更加愿意通过effect文件来应用像素着色片段。
像素着色的例子
下面我们看一些非常简单的例子,逐像素光照。
像素着色例子1 - Ambient Light ( 环境光照)
Ambient light 就是场景中的自然光,它来自于场景中所有对象对光线的多次反射,环境光,没有方向,没有坐标,所以它对场景中所有几何体进行同样的光照。计算环境光,只需要考虑环境光的强度,以及光的颜色值。下面就是环境光的计算公式:
I = A intensity * A colour
其中, A intensity是一个浮点数,标明的是光的强度,A colour是一个颜色值,包括红,绿,蓝,和alpha四个分量。
我们的着色代码简单的输出一个颜色值,所以我们定义如下的输出结构体:
struct PSOutput
{
float4 colour : COLOR;
};
我们的像素着色片段看起来如下:
PSOutput PS()
注意,从一个着色代码返回值,有多种反射,如果我们的输出只是一个颜色值,而不是一个结构体,我们可以如下声明我们的着色函数:
float 4 PS(): COLOR
在这里例子中,我们用结构体的方式返回颜色数据,下个例子我们用直接输出的方式返回颜色数据。因此在这个例子中,着色代码中,我们需要创建一个输出结构体的实例,并用0初始化,如下所示:
PSOutput Out=(PSOutput)0;
然后我们把环境光的强度和颜色值,在代码中硬编码如下:
float Aintensity=0.8f;
float4 Acolour=float4(1.0,0.5,0.5,1.0);
然后是环境光的计算,把计算的结果放入结构体,并返回:
Out.colour=Aintensity*Acolour;
return Out;
这样一个简单的像素着色就已经完成了。你可以从这个链接下载代码,然后把它放入effect edit观察效果。
像素着色例子2 - Diffuse Light (漫反射光照)
在1760年,Lambert最早用一个光照模型来描述场景中的有向光。在这个光照模型中,物体表面的光照计算与观察者的位置无关,因此,这个光照模型通常被用来模拟一些粗糙材质的表面的光照。反射光的强度依赖于光的方向和物体表面法线方向之间的夹角。例如,如果光直射物体表面,并且物体表面是一个平面,这样物体就会被完整的照亮。但是,如果光照的方向和物体表面的朝向相反的话,物体表面就没有光照。
如果L 是光照向量(光照射的方向), N 是物体表面的法线,当L 和N 的方向一致的时候:也就是物体表面与光照方向垂直 cos(theta)=1,就会发生最大强度的漫反射。两个方向之间的角度越小,反射强度也越小,因此反射光的强度与 cos(theta)成比例。 为了实现漫反射的这种特性,我们用如下的点成:
N.L = ||N||.||L||*cos(theta)
如果光照的方向向量和物体表面的法线向量是单位向量(在具体应用中,我们肯定会保证这一点),上述公式就会被退化为:
N.L=cos(theta)
然后我们就可以在着色低吗中用如下的公式计算我们像素点的漫反射的颜色值:
Dintensity * Dcolour * N.L
为了完成这个例子,我们将写一个顶点着色片段,一个像素着色片段,展示怎么在顶点着色片段中计算好我们在像素着色片段中用到的相关数据。你可以从下面的链接下载着色的effect文件 PShaderExample2.fx.。
顶点着色,在顶点着色中,我们接受如下的结构体作为输入:
struct VS_OUTPUT{
float4 Pos : POSITION;
float3 Light : TEXCOORD0;
float3 Norm : TEXCOORD1;
};
然后对顶点坐标进行标准变换,然后把光照方向向量归一化,然后把顶点法线变换到世界坐标系,并进行归一化,具体代码如下:
VS_OUTPUT VS(float4 Pos : POSITION, float3 Normal : NORMAL)
{
VS_OUTPUT Out = (VS_OUTPUT)0;
Out.Pos = mul(Pos, matWorldViewProj); // transform Position
Out.Light = normalize(vecLightDir); // normalised light vector
Out.Norm = -normalize(mul(Normal, matWorld)); // transform Normal and normalize
return Out;
}
注意上述代码中的 matWorldViewProj 是应用程序设置的,并且指定了语义 WORLDVIEWPROJECTION ,告诉effect Edit对变量填充正确的值。
像素着色,在这个像素着色中,我们值输出一个简单的颜色值,但是从渲染管线接受一个光照向量以及一个法线向量作为输入,注意到这里我们使用的是 TEXCOORDn 语义。这一次我们没有用结构体来做着色函数的返回对象,而是直接返回一个单一的颜色值。
float4 PS(float3 Light: TEXCOORD0, float3 Norm : TEXCOORD1) : COLOR
在这个着色代码中,我们综合考虑环境光和漫反射光,这样具体代码如下:
float4 result=Dintensity*Dcolour*(dot(Norm,Light));
// Add the diffuse result to the ambient below for return
return Aintensity*Acolour+result;
上述计算环境光的时候,我们在代码中硬编码了环境光的强度和颜色值,在具体的应用中,你肯定不会这样写,你肯定会定义相应的uniform变量,然后由应用程序传递进来。
总结
像素着色运行你操作渲染管线的像素,颜色数据的计算阶段。他们对于逐像素光照计算是非常便利的,而且也非常方便应用其他高级的像素操作指令。希望上面的描述已经让你对像素着色有了一个基本的了解,这个地方,也希望在不久的将来能够增加一些像素着色的例子。
进阶阅读
如果要了解更多细节,和高级应用,可以看一下Wlfgame Engel的书,去下面的链接 books 可以发现一些推荐的相关书籍。
如果对于fx文件以及shader不怎么了解,建议阅读下面两个文章:
http://www.toymaker.info/Games/html/effects_files.html
http://www.toymaker.info/Games/html/shaders.html
这里推荐一下这个网站,这个是提赛德大学的游戏开发技能课程的老师建立的一个个人网站。网站内容非常丰富。值得收藏。