本系列文章主要翻译和参考自《Real-Time 3D Rendering with DirectX and HLSL》一书(感谢原书作者),同时会加上一点个人理解和拓展,文章中如有错误,欢迎指正。
这里是书中的代码和资源。
从这篇文章开始,我们终于结束了第一部分长篇累牍的介绍,要进入真正的实战阶段啦,可以开始自己动手实现一个shader效果。如果对这篇文章中所提到的工具和编程环境不太了解的童鞋可以参考本系列的第三篇文章。
本文索引:
你可能已经习惯了以编写”Hello,World!”作为学习一门新的编程语言的例子,在这个例子中一般会输出一段非常简单的文本字符串。这里,我们将同样延续这个传统,编写第一个shader程序”Hello, Shader!”,这个例子中将会在目标模型上呈现一种纯色。
启动NVIDIA的FX Composer并新建一个项目。打开Asset面板,右键点击Material图标,选择Add Material from New Effect。然后从Effect对话框中选择HLSL FX选项,如下图所示:
点击”next”,并选择Empty template并将文件命名为HelloShader.fx,参考下图:
点击”Finish”按钮完成创建过程。如果一切顺利,就可以看见新创建的”HelloShader.fx”文件出现在Editor面板中,在左边的Asset面板中还可以看到已经关联和创建好的HelloShaders和HelloShaders_Material对象。虽然你选择的是Empty effect模板,但这个模板所创建出的文件并不是完全空的,NVIDIA FX Composer已经为你提供了一些基础代码。这些代码已经非常接近这篇文章最终要完成的代码,但他是为DirectX9准备的,所以我们需要删除这些代码,并用下面的代码来替换,之后我们会一步步在这个代码的基础上深入。
代码段Listing4.1 HelloShader.fx
cbuffer CBufferPerObject
{
float4x4 WorldViewProjection : WORLDVIEWPROJECTION;
}
RasterizerState DisableCulling
{
CullMode = NONE;
};
float4 vertex_shader(float3 objectPosition : POSITION) : SV_Position
{
return mul(float4(objectPosition, 1), WorldViewProjection);
}
float4 pixel_shader() : SV_Target
{
return float4(1, 0, 0, 1);
}
technique10 main10
{
pass p0
{
SetVertexShader(CompileShader(vs_4_0, vertex_shader()));
SetGeometryShader(NULL);
SetPixelShader(CompileShader(ps_4_0, pixel_shader()));
SetRasterizerState(DisableCulling);
}
}
Direct3D渲染管线的各个阶段通过已编译的shader文件进行程序化。例如,你可以将顶点shader放在一个文件中(这个文件通常是以.hlsl结尾)并将一个像素着色器放在另一个文件中。在这种设定下,每个文件只能包含一个shader。相对的,HLSL Effect文件可以使你将多个shader文件、方法、render state合并到一个文件中。这个文件类型就是我们在上面的listing1.4代码段中所给出的fx文件。
在HelloShaders.fx文件的开始部分,你可以看到那段代码的开头是cbuffer。这代表一个Constant Buffer,这个缓冲区的目的在于存储一个或多个shader的常量。shader常量是从CPU传入shader的,在一个draw call调用内在整个基础数据的处理过程中,这个常量的值都是不会变的。也就是说cbuffer是用来保存常变量的。从GPU的角度来说,他们在这次draw call的调用内,在对基础数据的处理过程中是不变的。但从CPU的角度说这次draw call到下次draw call的调用中这个值已经发生了变化。(也就是说CPU在每次调用draw call之前可以改变这个值,但这个值传入GPU后GPU是不会对其做改变的)
在整个HelloShaders.fx文件中,只有一个cbuffer,并且其中只包含了一个常量WorldViewProjection,其类型为float4x4。这是一个C语言风格的变量声明,其数据类型为4*4的矩阵,矩阵中每个变量都是一个单精度的浮点数。这个特殊的变量代表了一系列串行矩阵World-View-Projection。也就是说这个矩阵可以将你的点从本地坐标系转到世界坐标系再到视图坐标系,最后是齐次坐标系。你可以分别对你的顶点乘以世界,视图,齐次坐标系矩阵,最后会发现乘出来的结果和乘以这个矩阵的值是一样的。但你声明这个矩阵的意义正在于,通过声明这个矩阵你可以传入更少的输入数据并通过执行更少的shader指令达到同样的目的。
注意在变量的声明中,WORLDVIEWPROJECTION这串跟在冒号后面的字符。这是一串语义字符暗示了在CPU中这个变量的意义以及我们应该如何使用这个变量。语义字符可以使开发者从字面意思明白你所命名的这个变量在shader中的意义。在上面的代码中,你也可以将那个float4x4变量命名为WVP或者WorldViewProj而不用过多关注你的命名与CPU那边的联系,因为通过关联语义字符,你的变量已经可以从CPU中获得变量数据。现在有很多的语义字符存在,他们都是可选的。但在NVIDIA的FX Composer这个环境中,你最好还是使用WORLDVIEWPROJECTION这个语义,你需要将你shader中的变量关联起来,来为Effect在每帧中接受数据提供帮助。
缓冲区命名含义 |
在上面的HelloShaders effect中,我们将常量缓冲区命名为CBufferPerObject。尽管名字本身并没有什么神奇的效果,但他至少说明了在每一帧的更新中这个缓冲区中的常量将会用来做什么。以PerObject命名的缓冲区说明CPU会对关联到这个Effect的所有物体进行缓冲区数据更新,这意味着每个使用这个Effect的模型对应的这个值都会不同。 |
同样的,一个以CBufferPerFrame命名的缓冲区意味着每一帧缓冲区内的数据将会只更新一次,并允许多个关联到这个shader的物体共享缓冲区内的数据用以渲染。 |
因此,你需要将数据组织在不同的缓冲区中以提高更新效率。当CPU将所有的shader常量定义在一个缓冲区中,就必须每一帧都更新整个缓冲区。因此,最好是根据缓冲区更新的频率来为他们划分缓冲区。 |
shader不可以定义那些在D3D渲染管线中不可变成阶段的行为,但是你可以通过render state对象对其进行自定义。例如,光栅化阶段可以通过RasterizerState对象进行自定义。有很多光栅化状态可供选择,这些具体内容将会在之后的文章中介绍(不是这篇)。现在,我们只需要知道RasterizerState对象DisableCulling,如下:
代码段Listging4.2 RasterizerState声明 (HelloShader.fx文件中):
RasterizerState DisableCulling { CullMode = NONE; };
我们在第二篇文章中大概的介绍了顶点环绕顺序和背面剔除。默认情况下,DirectX将以逆时针环绕的顶点面视为背面并将不绘制他们。所以NVIDIA FX Composer中自带的模型从背面看起来像是一个缺口。如果不修改或者禁用剔除模式,Direct3D将会把我们认为是正面的面也剔除掉。因此,当你使用这个工具进行shader变成时,最好指定剔除模式为CullingMode = NONE。
注意: |
使用NVIDIA FX Composer这个工具时,之所以要将剔除模式关闭是因为这个工具同时支持d3d和opengl两种渲染接口。这两种渲染接口默认的对于指定正面方向的顶点环绕方向是不一样的,d3d以左手坐标系下顺时针为正向,opengl以左手坐标系下逆时针为正向,而NVIDIA FX Composer工具支持的是opengl中的环绕模式。 |
接下来我们将分析HelloShader.fx文件中的顶点shader,如下所示:
代码段Listing4.3
float4 vertex_shader(float3 objectPosition : POSITION) : SV_Position { return mul(float4(objectPosition, 1), WorldViewProjection);
}
这段代码看起来很像C语言的风格,但也有一些关键地方是不一样的。首先,需要注意这段顶点shader还没有写完。每个顶点进入这段shader的时候是在本地空间中,而WorldViewProjection矩阵会将他们转换到齐次空间中。也就是说,这段shader只是完成了最基本的顶点shader的任务。
输入到顶点shader中的数据是float3类型,这是HLSL的一种数据类型,以三个单精度浮点数存储了三个空间坐标方向上的位置信息,他的名字是objectPosition也同时表明了这个数据是在本地坐标空间下的。注意关联到objectPosition变量上的POSITION语义。他表示这个变量存储的是一个顶点位置信息。他的概念类似上文中我们所提到的shader常量,是用来传达参数的使用目的的。语义还可以用来在shader的两个阶段中做输入和输出的关联(例如指令装配阶段和顶点着色阶段)。最简单的情况的,顶点shader至少有一个关联有POSITION语义的输入,并至少返回一个关联着SV_POSITION语义的返回值。
注意: |
带有SV_前缀的语义符都是系统值(system-value)语义符,从Direct3D10被引入。这些语义符在渲染管线中都特定的含义。例如,SV_Position表示所关联的输出值包含了一个已经转换到光栅阶段可使用的顶点坐标。另外non-system-value的语义符也存在,其包含了一套标准的语义符,这些语义符是通用的,在渲染管线中并没有明确的释义。 |
顶点shader的函数体中,我们调用了HLSL的固有函数mul。这个函数将两个矩阵参数相乘。如果第一个参数是一个向量,它会被当成行向量处理(同时第二个参数也会被当成行优先矩阵处理)。相对的,如果第一个参数是一个矩阵,那么第二个参数如果是向量则会被当成列向量处理。大部分时候,我们使用第一种形式,即行优先矩阵做转换,也就是mul(vector, matrix)。
需要注意的是,在上面的代码中mul函数的第一个参数,我们用objectPosition(一个float3)和数字1构建了一个float4。之所以要这样是因为第一个参数的列数必须和第二个参数的行数相等(参考矩阵乘法规则)。由于这里我们转换过来的position参数只有三维,所以必须通过硬编码的方式为最后一维加入数字1(即w为1)。在做这样的硬编码转换时为w赋值为1表示这个四维数是一个位置信息,为w赋值为0表示这个四维数是一个方向信息。
接下来我们将介绍像素shader,代码如下:
代码段Listing4.4 HelloShaders.fx文件中的像素shader
float4 pixel_shader() : SV_Target { return float4(1, 0, 0, 1);
}
这段shader的返回值是一个float4并且被关联了SV_Target语义。这表示这个输出值将会被存储在与输出混合阶段相关联的渲染目标(Render Target)中。实际上,渲染目标一个对应到屏幕上的纹理,被称为后台缓冲区(back buffer)。这个名称来源于双重缓冲技术(double buffering),该技术利用两个缓冲区来减少撕裂感和渲染错误,也用于当要几乎同时地显示来自两个或多个帧中的像素。所有输出都会被渲染到后台缓冲区中,而播放设备真正播放的是前台缓冲区(front buffer)中的内容。当渲染结束时,两个缓冲区会互换所以最新帧的内容将会被显示出来。交换缓冲区的频率必须和显示器刷新频率保持一致,同样,也是为了避免渲染错误。
像素着色器的输出是一个32bit的色彩和8bit的通道值,通道包括红色、绿色、蓝色、alpha(RGBA)。所有数值都以浮点格式提供,阈值[0.0, 1.0]对应整形阈值[0, 255]。在上面的例子中,我们为红色通道赋值为1,意味着每个输出像素都会被渲染成纯红色。我们没有使用颜色混合,所以alpha通道并不会造成影响。
注意: |
在HelloShader中的像素shader并没有明显的接受输入参数,但不要被它混淆了。经过齐次空间剔除的像素位置会直接从光栅阶段传入像素着色器。然后,这些都是在后台进行的,并不需要再像素着色器中显示声明。 |
之后的文章中还将介绍可以向像素着色器中传入更多参数。 |
HelloShader effect中最后一部分是technique,这部分将之前所有的shader片段结合起来。如下:
代码段Listing4.5
technique10 main10
{
pass p0
{
SetVertexShader(CompileShader(vs_4_0, vertex_shader()));
SetGeometryShader(NULL);
SetPixelShader(CompileShader(ps_4_0, pixel_shader()));
SetRasterizerState(DisableCulling);
}
}
technique通过执行一系列的effect pass来实现渲染效果。每个pass会设置一些渲染状态(render state)并将shader关联到他所依赖的渲染管线各个阶段中。在HelloShaders这个例子中,我们仅仅写了一个techniques(名为 main0),其中包含了一个pass(名为p0)。但其实,effects中可以包含一个或多个techniques,每个technique可以包含一个或多个passes。现在,我们写的大部分effect只会包含一个technique,多techniques多pass的effect在原书的第四部分”Part IV , Intermediate-Level Rendering Topics”中有讲解。
注意在这个例子中使用的关键字technique10。这个关键字是Direct3D中使用的关键字,不同于DirectX9中对应的关键字techniques,DX9中的关键字不带有版本后缀。Direct3D 11使用的关键字是technique11。不巧的是NVIDIA FX Composer并不支持Direct3D 11。但刚开始研究shader编程的时候并不需要使用到Direct3D 11中的特性,所以这对我们接下来的shader变成研究也不会造成什么阻碍。在原书的第三部分”Part III, Rendering with DirectX”中会开始介绍DirectX 11的相关技术。
接下来,我们注意看下SetVertexShader函数中的vs_4_0参数和SetPixelShader函数中的ps_4_0参数。这个数值决定了在编译第二个参数——shader的具体函数时要使用什么配置文件。shader的配置文件类似于shader模型(shader model),它定义了用来支持对应shader的图形系统的处理能力。在原书写作的时候,已经存在5种主要的(和很多种小型的)shader模型版本,最新的shader模型是shader model 5。每一个新的shader模型都以一系列方式扩展了前一个版本的功能。通常新版本的shader模型都潜在增加了shader的复杂度。Direct3D 10引入了shader模型4,也是我们在所有的Direct3D10的technique里面使用的模型。shader model 5是Direct3D 11引入的,并且我们将在所有的Direct3D 11 techniques里面使用这个shader模型。
现在,所有的代码已经准备完毕,可以尝试输出shader的可视化效果了。首先需要编译effect,通过点击Build,Rebuild All完成构建,然后点击Compile完成编译。当然,你也可以通过使用快捷键F6完成构建(Rebuild All),使用Ctrl+F7完成编译(编译选中的Effect)。确定每次更改代码后都完成这两个步骤。
接下来确定你目前使用的是Direct3D 10 API接口,如果不是的话,从主工具栏中下拉选择(位于工具栏的右侧)。如下图所示:
然后打开Render面板,该面板默认位于编辑器右下角。在场景中创建一个模型,可以通过点击主菜单中的”Create->Plane”或直接点击工具栏中的对应模型按钮来创建。最后,将你的HelloShaders_Material从编辑器的Material面板拖拽到Render面板中的模型上。效果如下图所示:
可以改变shader中每个通道的颜色来观察显示结果。
在这个部分,我们将使用C变成风格的structs来重写HelloShader Effect。数据结构体提供了一种将输入或输出数据整合在在一个结构体中的方法从而避免太多参数分离开。
在编辑器中创建一个新的effect和material。你可以如上文那样重新创建一个fx文件,也可以通过直接复制已有的fx文件,并在这个文件的基础上做些修改。
代码段Listing4.6 HelloStructs.fx
cbuffer CBufferPerObject
{
float4x4 WorldViewProjection : WORLDVIEWPROJECTION;
}
RasterizerState DisableCulling
{
CullMode = NONE;
};
struct VS_INPUT
{
float4 ObjectPosition : POSITION;
};
struct VS_OUTPUT
{
float4 Position : SV_Position;
};
VS_OUTPUT vertex_shader(VS_INPUT IN)
{
VS_OUTPUT OUT = (VS_OUTPUT) 0;
OUT.Position = mul(IN.ObjectPosition, WorldViewProjection);
return OUT;
}
float4 pixel_shader() : SV_Target
{
return float4(1, 0, 0, 1);
}
technique10 main10
{
pass p0
{
SetVertexShader(CompileShader(vs_4_0, vertex_shader()));
SetGeometryShader(NULL);
SetPixelShader(CompileShader(ps_4_0, pixel_shader()));
SetRasterizerState(DisableCulling);
}
}
HelloShader.fx和HelloStructs.fx两个文件并没有太大的区别,但他们之间的基本原理是一样的。一些公用共同部分的概念也是一样的,比如CBufferPerObject、DisableCulling、main10和其中的pass以及像素着色器的内容。新加入的内容是两个structs,VS_INPUT和VS_OUTPUT。从名字就可以看出这两个结构体代表的是顶点着色其的输入和输出数据结构。结构体里面的内容也和HelloShaders.fx文件中的输入输出数据是一样的,不同的是,HelloShaders.fx中输入参数是float3而HelloStructs.fx文件中定义的输入参数是float4。这样就不需要通过硬编码的方式为w轴向加入值1。而语义部分也是直接在结构体中进行关联,不需要再在函数中关联了。
在顶点着色器中使用了C语言风格的类型转换对VS_INPUT输入参数进行了初始化,这不是必须的,但这是一种很好的编程习惯。
最后,需要注意像素着色器的输入参数类型和顶点着色器的输出类型是一样的。在这个例子中,虽然像素着色器没有输入参数,但是对将来有输入参数的像素着色器一定会遵循这个原则。
之前提过DisableCulling,其实其剔除模式除了NONE,还包括FRONT、BACK。
使用平面模型并将剔除模式修改为FRONT,可以看到如下效果:
所以在FX Composer工具中顶点的环绕顺序是以左手坐标系下逆时针方向为正(与opengl中对于顶点环绕面片的正向判定方式一样)。
这篇文章主要从一个很简单的shader入手提到一些写shader最基本的东西,可以说,为shader的整体编程框架做了一个大致的介绍。中间介绍了struct的使用,struct在简单的shader参数中可能作用并不明显,但如果输入输出参数内容增多,struct将不可避免,而且struct的使用也避免了在第一次完成shader之后还要多顶点和像素着色器函数的签名做修改。