分类: C/C++
2008-02-20 13:02:24
第十一章 可编程渲染管道以及高级着色语言入门
翻译:clayman
仅供个人学习之用,勿用于任何商业用途,转载请注明作者^_^
至今为止,我们都在使用固定功能的管道(fixed-function pipeline)进行渲染。回到那段古老的日子(DirectX 8.0之前),这是唯一渲染物体的方法。固定功能的渲染管道本质上就是一系列用来控制如何渲染特定类型数据的规则以及行为。虽然在某些方面这已经够用了,但却限制了许多开发人员使用高级功能的能力。举个例子,使用固定功能的管道,灯光是根据每个顶点而不是每个象素来计算的。由于渲染管道是“固定”的,因此根本没欧办法来改变许多渲染选项,刚才说的灯光就是一个例子。
DirectX 8.0带来了可编程的管道。随着这个版本带来的革命性功能,开发人员几乎可以控制管道的所有方面。他们可以使用称为顶点着色器(Vertex Shaders)的功能控制顶点的处理过程,使用像素着色器(Pixel Shader)控制像素的处理。这些着色程序相当强大,但使用起来却不是太方便,因为这些程序都是使用汇编语言来写的。
在DirectX 9中发布了高级着色语言(High Level Shader Language, HLSL)。HLSL是一种和C很类似的语言,可以编译为着色器代码,却相当的利于开发人员阅读、维护以及编写。在带来了方便的同时,保留了可编程管道的强大力量。这一章,我们将讨论HLSL的基本内容,包括:
n 使用可编程管道
n 顶点变换
n 使用像素着色器
使用可编程管道渲染三角形
说了这么多,到底什么是“固定功能的管道”呢?固定功能的管道控制了一切:如何渲染顶点,如何对他们进行变换,如何照亮物体,几乎包含了所有方面。当你设置device的顶点格式时,实际上是在告诉device根据所给的格式,使用特定方法来渲染这些顶点。
这样设计,最大的缺点就在于对于图形卡所支持的每一种功能,都必须设计和实现一个固定功能的API与之相对应。但是,由于如今显卡的发展速度之快(甚至超过了CPU的发展速度),原来的API可能很快就过时了。就算设计了数量庞大并且难懂的API来完成这些任务,还有一个潜在的问题:开发者不知道使用这些API时,到底发生了什么。对程序员来说,获得完全的控制才是他们想要的 。
本书的第一个例子讨论了如何显示一个旋转的三角形。现在来看看使用可编程管道如何来完成同样的任务。创建一个新工程,做好各种所需的准备,添加如下变量:
private VertexBuffer vb = null;
private Effect effect = null;
private VertexDeclaration decl = null;
private Matrix worldMatrix;
private Matrix viewMatrix;
private Matrix projMatrix;
private float angle =
自然,使用顶点缓冲来储存顶点数据。由于不使用device进行变换,所以需要为可编程管道单独保存这些变换矩阵。第二、三个变量则是新内容。Effect对象就是用来处理HLSL的主要对象。Vertex Declaration类则与固定功能管道中的VertexFormat枚举类似。他会告诉Direct3D运行时应该从顶点缓冲中读取的数据大小和类型。
由于这个技术会使用一些相对比较新的图形卡功能,完全有可能你的显卡不支持它。如果这样,那么你将不得不使用DirectX SDK中的参考设备(reference device)。参考设备将以纯软件的方式来实现所有API,可能会相当慢。这一次,初始化的代码将复杂一点
public bool InitializeGraphics()
{
PresentParameters presentParams = new PresentParameters();
presentParams.Windowed = true;
presentParams.SwapEffect = SwapEffect.Discard;
presentParams.AutoDepthStencilFormat = DepthFormat.D16;
bool canDoShaders = true;
Caps hardware = Manager.GetDeviceCaps(0,DeviceType.Hardware);
if(hardware.VertexShaderVersion >= new Version(1,1))
{
CreateFlags flags = CreateFlags.SoftwareVertexProcessing;
if(hardware.DeviceCaps.SupportsHardwareTransformAndLight)
flags = CreateFlags.HardwareVertexProcessing;
if(hardware.DeviceCaps.SupportsPureDevice)
flags |= CreateFlags.PureDevice;
device = new Device(0,DeviceType.Hardware,this,flags,presentParams);
}
else
{
canDoShaders = false;
device = new Device(0,DeviceType.Reference,this,CreateFlags.SoftwareVertexProcessing,presentParams);
}
vb = new VertexBuffer(typeof(CustomVertex.PositionOnly),3,device,Usage.Dynamic | Usage.WriteOnly,CustomVertex.PositionOnly.Format,Pool.Managed);
projMatrix = Matrix.PerspectiveFovLH((float)Math.PI/4, this.Width/this.Height,
viewMatrix = Matrix.LookAtLH(new Vector3(0,0,
VertexElement[] elements = new VertexElement[]
{
new VertexElement(0,0,DeclarationType.Float3,DeclarationMethod.Default,DeclarationUsage.Position,0),
VertexElement.VertexDeclarationEnd
};
decl = new VertexDeclaration(device,elements);
return canDoShaders;
}
这段代码假设使用默认的适配器进行渲染。为了简单,这里实际山还省略了许多因该做的枚举。创建设备之前,应该先检查所创建设备的能力,因此,先获得Caps结构。这个程序我们只使用可编程的管道进行渲染,因此,必须确保显卡至少支持第一代的顶点着色器。随着新版本的API发布,顶点和相色着色器的版本也是不断更新的。比如,DirectX 9就允许使用顶点和像素着色器的3.0版本。第一代的着色器自然是1.0版,不过在DirectX 9中已经使用1.1来代替这个版本,所以我们在这里检测是否支持它。
假设你的显卡支持这些功能,那么就能创建一个“最理想”的设备了。默认使用software vertex processing,但是如果可以使用hardware vertex processing以及pure device,那么就使用这些功能。如果显卡不支持着色器,那么就使用参考设备。
接下来创建顶点缓冲。对于渲染一个三角形来说,只需要3个带有位置信息的顶点就可以了。
private void OnVertexBufferCreate(object sender, EventArgs e)
{
VertexBuffer buffer = (VertexBuffer)sender;
CustomVertex.PositionOnly[] verts = new CustomVertex.PositionOnly[3];
verts[0].Position = new Vector3(
verts[1].Position = new Vector3(
verts[2].Position = new Vector3(
buffer.SetData(verts,0,LockFlags.None);
}
创建了顶点缓冲之后,保存即将用到的观察和投影矩阵。用和以前一样的方法创建这些矩阵。最后,来到顶点声明的部分。顶点申明告诉DirectX关于这些即将传入到编程管道中的顶点的所有所需信息。在创建vertex declaration对象时,把所用的device和一个vertex element数组作为参数,vertex element数组中的每一个成员都描述了顶点数据中的一个元素(component)。来看看 vertex elment的构造函数:
public VertexElement(short stream, short offset, DeclarationType declType, DeclarationMethod declMethod, DeclarationUsage declUsage, byte usageIndex);
其中,第一个参数是流所使用的顶点数据。当对device调用SetStreamSource方法时,就把顶点缓冲中的数据分配为一个流,作为第一个参数。至今为止,我们都把所有数据保存在一个顶点缓冲中,并转换为一个流来使用,但是,完全有可能把来自多个顶点缓冲中的数据分配为多个流来渲染一个对象。由于在0号流中只有一个顶点缓冲,所以直接把这个参数设置为0。
第二个参数是缓冲中数据开始的偏移位置。这里,顶点缓冲中只有一种类型的数据,自然值就为0。但是,如果包含了多种元素(component),则需要相应的进行偏移。举个例子,第一种元素是位置信息(三个float值),第二种元素是法线(同样也是三个float值),那么第一个元素的偏移值为0(因为它是第一个元素),同时,法线元素的偏移值就为12(3个float占用12字节)。
第三个参数用来通知Direct3D所要使用的数据类型。由于只需要位置信息,所以可以使用Float3(我们将在后面讨论这个类型)。
第四个参数描述了这个声明所使用的方法。在大多数情况下(除非你使用高要求的图元),使用默认值就可以了。
第五个参数描述了每个元素的usage,比如位置、法线、颜色等等。使用三个浮点数来描述位置。最后一个参数是用来控制usage数据的,允许你指定多种usage类型。大多数情况下使用0就可以了。
需要特别注意的是,vertex element数组必须使用VertexElement.VertexDeclarationEnd作为他的最后一个元素。好了,对于最简单的情况来说,我们使用编号为0的流,使用3个腹点值来表示位置。在创建了vertex elemen数组之后,就可以创建vertex declaration对象了。最后,返回一个布尔值,true表示可以硬件支持使用着色器,false则使用参考设备。
在处理完初始化方法之后, 你需要处理它的返回值了,更新main方法:
static void
{
using(Form1 frm = new Form1())
{
frm.Show();
if(!frm.InitializeGraphics())
{
MessageBox.Show("your card does not support shaders, This application will run in ref mode instead");
}
Application.Run(new Form1());
}
}
还有什么没完成呢?只剩下渲染场景了。这里和贯穿本书的所有渲染方法一样,重载OnPaint方法:
protected override void OnPaint(PaintEventArgs e)
{
device.Clear(ClearFlags.Target | ClearFlags.ZBuffer,Color.CornflowerBlue,
UpdateWorld();
device.BeginScene();
device.SetStreamSource(0,vb,0);
device.VertexDeclaration = decl;
device.DrawPrimitives(PrimitiveType.TriangleList,0,1);
device.EndScene();
device.Present();
this.Invalidate();
}
Clearn了设备之后,调用UpdateWorld方法,这个方法只是简单的增加旋转角度以及修改世界矩阵而已:
private void UpdateWorld()
{
worldMatrix = Matrix.RotationAxis(new Vector3(angle/((float)Math.PI *
angle +=
}
好了,除了用vertex devlaration属性代替vertex format属性以外,以上大部分的代码都是很熟悉的,现在运行程序看看,除了一个蓝色的屏幕外,什么也没有。为什么?Direct3D运行时并不知道你想做什么。你需要为编程管道编写一个“程序”。
给工程添加一个名为“simple.fx”的空白文件。你将使用这个文件来保存HLSL程序。 让我们来添加HLSL代码吧:
struct VS_OUTPUT
{
float4 pos : POSITION;
float4 diff : COLOR0;
};
float4x4 WorldViewProj : WORLDVIEWPROJECTION;
float Time =
VS_OUTPUT Transform(
float4 Pos : POSITION)
{
VS_OUTPUT Out = (VS_OUTPUT)0;
Out.pos = mul(Pos, WorldViewProj);
Out.diff.r = 1 - Time;
Out.diff.b = Time * WorldViewProj[2].yz;
Out.diff.ga = Time * WorldViewProj[0].xy;
return Out;
}
你看,HLSL实际上和C很类似。先来看看这段代码干了些什么吧。首先声明了顶点程序输出数据的结构。这里变量的声明有一点点特别。每个变量的后面都添加了一个语义标识符。语义表示了如何把这些变量与图形流水线相连接。
问什么顶点缓冲只包含了位置信息,但是输出结构里却要同时包含位置和颜色信息呢?只有输出结构包含了位置和颜色数据(以及相应的语义),Direct3D才知道使用顶点程序返回的值来渲染,而不是使用顶点缓冲中的值。
接下来,有两个“全局”变量;综合了世界,观察,投影变换的矩阵,以及一个用来改变颜色的时间变量。程序运行时,每一帧都必须更新这几个变量。
最后,就是实际的顶点程序方法了。它把我们刚才所创建的结构作为返回值,接受一个顶点位置作为参数。这个方法将被所有顶点调用一次。需要注意的是输入值同样包含了语义,以便让Direct3D知道在处理什么类型的数据。
这个方法之内的代码实际上是很简单的。你声明了作为返回值的变量——Out。把顶点和变换矩阵相乘,完成坐标变换。你使用了内置函数mul,因为矩阵的类型是float4*4,而位置是float4。使用标准的相乘符号会导致类型不匹配。
之后,使用了一个公式对颜色的每一个因素作恒定的变换。注意看我们是如何设置颜色的每个元素的,首先是红色,接下来是蓝色,最后是绿色和alpha。位置和颜色都设置好之后,就可以填充返回的结构了。
(此处省略介绍HLSL语法的若干内容,建议大家去看《Cg教程——可编程实时图像权威指南》)
Rendering Shader Programs with Techniques
现在完成了顶点程序的编写,如何使用它呢?顶点程序中并没有一个“入口点”,只有一个方法而言。如何调用这个方法呢?很简单,你只需要使用一个称为“technique”的东西就可以了。一个technique由一个或多个pass组成。每一个pass都能设置device state同时设置顶点和像素着色器来使用你的HLSL代码。来看看可用于这个程序的technique,在simple.fx文件中添加代码:
technique TransformDiffuse
{
pass P0
{
CullMode = None;
VertexShader = compile vs_1_1 Transform();
PixelShader = NULL;
}
}
这里,你声明了一个称为TransformDiffuse的technique。这个名字仅仅是做修饰而以,没有什么特别的用处。在这个technique里,声明了一个pass。裁剪模式被设置为null,可以同时看到三角形的正面和背面。还记得以前写过的程序吗,这个任务是通过编写C#来完成的。使用pass来保存device state是一个不错的地方,所以在这里设置裁剪模式是很合适的。
接下来,你想对即将进行渲染的顶端使用这个顶点程序,因此,使用vs_1_1标志来编译它。这个标志符应该根据你显卡的能力来进行选择。由于以及知道了拟定设备至少支持vertex shader1.1,所以使用这个标志。如果使用vertex shader2.0,那么标志就设置为vs_2_0。而相应的pixel shader标志就设置为ps_2_0。
由于没有像素程序,因此把PixelShader成员设置为null就可以了。Pass到这里就完成了。接下来需要更新C#代码了。
首先,我们将使用Effect对象,应为他是处理HLSL的主要对象。在InitializeGraphics方法中添加如下代码(在创建device之后):
effect = Effect.FromFile(device,@"..\..\simple.fx",null,ShaderFlags.None,null);
effect.Technique = "TransformDiffuse";
我们通过一个文件来创建effect对象。同时设置了technique成员,因为整个程序都使用同一个technique。Effect对象会完成所有对HLSL的处理工作。另外还有两个需要每帧都更新的变量。在UpdateWorld方法的最后添加如下代码:
Matrix worldViewProj = worldMatrix * viewMatrix * projMatrix;
effect.SetValue("Time", (float)Math.Sin(angle /
effect.SetValue("WorldViewProj", worldViewProj);
这里,储存并合并了矩阵。同时更新HLSL中的变量。最后要做的只剩下更新渲染对象的代码了,在DrawPrimitives方法中添加代码:
int numPasses = effect.Begin(0);
for (int i = 0; i < numPasses; i++)
{
effect.Pass(i);
device.DrawPrimitives(PrimitiveType.TriangleList, 0, 1);
}
effect.End();
(注意,新版的DirectX已经改为了
int numPasses = effect.Begin(0);
for (int i = 0; i < numPasses; i++)
{
effect.BeginPass(i);
device.DrawPrimitives(PrimitiveType.TriangleList, 0, 1);
effect.EndPass();
}
effect.End();
)
由于已经为effect设置了technique,所以当渲染的时候,需要调用Begin方法。这个方法的参数允许你选择是否保存一个特定的状态,对于这个例子来说,不是太重要。这个方法同时还返回了当前technique中pass的数量。就算只有一个pass在technique里,最好还是使用一个循环来保证渲染了所有希望的technique。最后在渲染之前,还必须调用effect对象的Pass方法。这个方法唯一的参数就是你所使用的pass索引。这个方法让设备为渲染指定的pass做好准备,更新设备状态,同时设置顶点和像素着色器方法。最后嗲用DrawPrimitive方法。渲染完成之后,还必须调用effect对象的End方法。好了,现在运行程序看看吧。
使用可编程管道渲染Mesh
简单三角形例子唯一的特点就是:简单。你不可能只使用一个顶点程序和一个三角就完成一个游戏,因此,这一节我们将渲染整个mesh。删除文件里关于顶点和顶点缓冲的声明,添加mesh对象:
private Mesh mesh = null;
对于渲染mesh来说,有一个方便的地方就是在渲染时会自动设置vertex declaration。自然,删除所有处理顶点声明和顶点缓冲的代码。同时,更新InitializeGraphics创建mesh。创建了mesh和effect对象之后,添加代码:
mesh = Mesh.Teapot(device);
最后,修改OnPaint方法,使用DrawSubset方法代替DrawPrimitives:
mesh.DrawSubset(0);
你看这里并没有多少需要修改的地方,现在运行程序,可以得到一个和三角形颜色效果差不多的茶壶。由于茶壶所以位置的颜色都一样,导致它看起来不太像一个茶壶。还记得我们以前也遇到过这种情况吗,添加一个方向灯情况就会好的多。
由于本章的目的是不使用固定管道来进行渲染,所以不使用device的lights属性。我们将更新HLSH代码来实现一个白色的方向灯。在HLSL代码中添加如下变量;
float3 LightDir = {
接下来,使用以下代码代替现有的方法:
VS_OUTPUT Transform(
float4 Pos : POSITION,
float3
{
VS_OUTPUT Out = (VS_OUTPUT)0;
float4 transformedNormal = mul(
Out.diff.rb =
Out.diff.ga =
Out.diff *= dot(transformedNormal, LightDir);
Out.pos = mul(Pos, WorldViewProj);
return Out;
}
注意我们使用了一个新的参数作为法线数据。你必须保证每个使用这个technique的顶点都有法线数据,否则将导致异常。Mesh类创建的茶壶已经包含了法线信息,所以这里不会出什么问题。接下来,需要把法线数据和顶点转换到同一个坐标系中。对不同坐标系下的顶点和法线进行光照计算是没有意义的。
如果你看过SDK中关于实现方向光的数学原理,就知道它实际上是使用顶点法线和灯光方向的点积再乘以光的颜色来实现的。可以把rgba混合值都设置为1,并且通过计算得到最后的值。
这个程序现在因该渲染了一个旋转的白色茶壶。添加了灯光之后,茶壶看起来要真实多了。你可以任意修改灯光的颜色来看看。
使用HLSL编写Pixel Shader
至今为止,我们之使用了顶点程序来控制顶点,然而,这只是可编程管道的一半而已。如何处理每一个独立像素的颜色呢?我们还是使用第五章MeshFile的例子作为基础,把它更新为使用编成管道的程序。使用这个例子的原因是他不仅渲染了顶点,还使用纹理来着色,而这正是使用pixel shader的地方。
打开项目知道,线添加一些必要的变量:
private Effect effect = null;
private Matrix worldMatrix;
private Matrix viewMatrix;
private Matrix projMatrix;
同样根据上一节的方法,更新initializeGraphics方法,检查显卡是否支持编成管道。这一次我们需要同时检查对vertex shader和pixel shader的支持。按照上一节中的方法,更改以下代码:
if((hardware.VertexShaderVersion >= new Version(1,1)) && (hardware.PixelShaderVersion >= new Version(1,1)))
……………
effect = Effect.FromFile(device,@"..\..\simple.fx",null,ShaderFlags.None,null);
effect.Technique = "TransformTexture";
projMatrix = Matrix.PerspectiveFovLH((float)Math.PI/4,this.Width / this.Height,
viewMatrix = Matrix.LookAtLH(new Vector3(0,
这里和之前的方法很类似。由于SetupCamera方法使用了固定管道中的变换和光照,所以可以把它完全删除了。自然,还需要修改OnPaint方法。接下来,更新DrawMesh方法,使用HLSL来进行渲染。
同样,每帧调用这个方法的时候都会更新合并之后的变换矩阵以及HLSL中的变量。接下来使用technique中的每一个pass渲染每一个mesh中的子集。好了,可以添加HLSL代码了,添加“simple.fx”文件:
float4x4 WorldViewProj : WORLDVIEWPROJECTION;
sampler TextureSampler;
void Transform(
in float4 inputPosition : POSITION,
in float2 inputTexCoord : TEXCOORD0,
out float4 outputPosition : POSITION,
out float2 outputTexCoord : TEXCOORD0
)
{
// Transform our position
outputPosition = mul(inputPosition, WorldViewProj);
// Set our texture coordinates
outputTexCoord = inputTexCoord;
}
void TextureColor(
in float2 textureCoords : TEXCOORD0,
out float4 diffuseColor : COLOR0)
{
// Get the texture color
diffuseColor = tex2D(TextureSampler, textureCoords);
};
technique TransformTexture
{
pass P0
{
// shaders
VertexShader = compile vs_1_1 Transform();
PixelShader = compile ps_1_1 TextureColor();
}
}
你可能马上就注意到了这里的着色程序和先前的不同。这次我们没有使用一个结构来作为返回值,而是把要返回的值作为方法的out参数。对于顶点程序来说,我们只关心顶点位置和纹理坐标。接下来的则是pixel shader。他接受当前像素的纹理坐标,并返回这个像素颜色用于渲染。这里,我们只使用了在这个纹理坐标上的默认颜色,因此使用了内建的tex2D方法。它在给定坐标对纹理进行采样,并且返回这个位置的颜色。这几乎是最简单的pixel shader了。运行程序看看吧。
但是,pixel shader并没有完成什么用固定管道不能完成的功能。让我们来做点有趣的工作吧。你将在这个HLSL中使用多个technique,添加如下代码:
void InverseTextureColor(
in float2 textureCoords : TEXCOORD0,
out float4 diffuseColor : COLOR0)
{
// Get the inverse texture color
diffuseColor =
};
technique TransformInverseTexture
{
pass P0
{
// shaders
VertexShader = compile vs_1_1 Transform();
PixelShader = compile ps_1_1 InverseTextureColor();
}
}
这里的代码和第一个pixel shader差不多,唯一的区别就是使用
protected override void OnKeyPress(KeyPressEventArgs e)
{
switch(e.KeyChar)
{
case '1':
effect.Technique = "TransformTexture";
break;
case '2':
effect.Technique = "TransformInverseTexture";
break;
}
base.OnKeyPress (e);
}
现在运行程序看看两种不同的效果吧。在源码中,我们还包含了只允许或不允许蓝色通道的遮罩效果。