分类: C/C++
2008-02-20 13:04:22
第十二章 使用Hight Level Shader Language(上)
翻译:clayman
仅供个人学习只用,勿用于任何商业用途,转载请注明作者^_^
至今为止,我们的对于编程管道和HLSL的学习还根本没有表现出它的强大来。所实现的功能都可以通过固定管道来完成。然而,还有很多高级功能是固定管道不能完成的,只有使用HLSL才能完成。
这一章我们将深入学习HLSL,教你如何使用这门技术来完成固定管道不能完成的任务。包括:
n 简单的顶点动画
n 简单的颜色动画
n 纹理混合
n 光照模型以及纹理
n per-vertex lighting和per-pixel lighting的区别
使用简单的公式模拟动画
为了节约时间,我们将使用上一章中的渲染茶壶的文件作为开始。我们将稍微修改一下代码,来让你知道适用编程管道实现一些简单的动画是多么容易。我们将让mesh在渲染时不停晃动。
打开文件之后,首先要做的就是把茶壶换为一个球体。并不是不能对茶壶进行动画,只不过球体的效果会更好而已。修改如下代码:
mesh = Mesh.Sphere(device, 球体尺寸比较大,因此修改一个观察矩阵:
viewMatrix = Matrix.LookAtLH(new Vector3(0,0, 现在运行程序,可以看到一个被单一方向光照亮了不停在旋转的球体,非常单调。我们如何才能让他动画起来呢?在着色器代码中,把唯一一行变换顶点的代码作如下修改:
float4 tempPos = Pos;
tempPos.y += cos(Pos + (Time * Out.pos = mul(tempPos, WorldViewProj);
注意到这里再次使用了Time变量。由于之前的代码没有声明过这个变量,所以还要添加代码:
Float Time = 除此之外,还必须每帧更新这个变量,在UpdateWorld方法中添加对这个变量的更新:
effect.SetValue("Time",angle);
但是,这段着色代码到底作了些什么呢?首先,使用一个临时变量来保存输入顶点的位置。然后使用当前顶点位置加上固定更新的时间变量的余弦值作为这个临时变量的y值。由于余弦值是在-1到1之间重复变化的,因此,这种效果会不断重复。在动画了顶点之后,对临时变量进行变换而不是变换原来的输入位置。
虽然这样的更新过后效果看起来好了很多,但是,我们还能做的更好。为什么不对颜色也做一些动画呢?使用以下代码代替原来的Out.diff初始化。
Out.diff = sin(Pos + Time);
显然,这段代码和之前动画顶点的差不多。唯一的区别就是这次使用了正弦值。这个例子的目的只是告诉你我们如何让一个枯燥的物体很快就运动起来。HLSL允许通过对主要代码做最少的修改就改变整个渲染效果。
至今为止,我们的对于编程管道和HLSL的学习还根本没有表现出它的强大来。所实现的功能都可以通过固定管道来完成。然而,还有很多高级功能是固定管道不能完成的,只有使用HLSL才能完成。
这一章我们将深入学习HLSL,教你如何使用这门技术来完成固定管道不能完成的任务。包括:
n 简单的顶点动画
n 简单的颜色动画
n 纹理混合
n 光照模型以及纹理
n per-vertex lighting和per-pixel lighting的区别
使用简单的公式模拟动画
为了节约时间,我们将使用上一章中的渲染茶壶的文件作为开始。我们将稍微修改一下代码,来让你知道适用编程管道实现一些简单的动画是多么容易。我们将让mesh在渲染时不停晃动。
打开文件之后,首先要做的就是把茶壶换为一个球体。并不是不能对茶壶进行动画,只不过球体的效果会更好而已。修改如下代码:
mesh = Mesh.Sphere(device,
球体尺寸比较大,因此修改一个观察矩阵:
viewMatrix = Matrix.LookAtLH(new Vector3(0,0,
现在运行程序,可以看到一个被单一方向光照亮了不停在旋转的球体,非常单调。我们如何才能让他动画起来呢?在着色器代码中,把唯一一行变换顶点的代码作如下修改:
float4 tempPos = Pos;
tempPos.y += cos(Pos + (Time *
Out.pos = mul(tempPos, WorldViewProj);
注意到这里再次使用了Time变量。由于之前的代码没有声明过这个变量,所以还要添加代码:
Float Time =
除此之外,还必须每帧更新这个变量,在UpdateWorld方法中添加对这个变量的更新:
effect.SetValue("Time",angle);
但是,这段着色代码到底作了些什么呢?首先,使用一个临时变量来保存输入顶点的位置。然后使用当前顶点位置加上固定更新的时间变量的余弦值作为这个临时变量的y值。由于余弦值是在-1到1之间重复变化的,因此,这种效果会不断重复。在动画了顶点之后,对临时变量进行变换而不是变换原来的输入位置。
虽然这样的更新过后效果看起来好了很多,但是,我们还能做的更好。为什么不对颜色也做一些动画呢?使用以下代码代替原来的Out.diff初始化。
Out.diff = sin(Pos + Time);
显然,这段代码和之前动画顶点的差不多。唯一的区别就是这次使用了正弦值。这个例子的目的只是告诉你我们如何让一个枯燥的物体很快就运动起来。HLSL允许通过对主要代码做最少的修改就改变整个渲染效果。
至今为止,我们的对于编程管道和HLSL的学习还根本没有表现出它的强大来。所实现的功能都可以通过固定管道来完成。然而,还有很多高级功能是固定管道不能完成的,只有使用HLSL才能完成。
这一章我们将深入学习HLSL,教你如何使用这门技术来完成固定管道不能完成的任务。包括:
n 简单的顶点动画
n 简单的颜色动画
n 纹理混合
n 光照模型以及纹理
n per-vertex lighting和per-pixel lighting的区别
使用简单的公式模拟动画
为了节约时间,我们将使用上一章中的渲染茶壶的文件作为开始。我们将稍微修改一下代码,来让你知道适用编程管道实现一些简单的动画是多么容易。我们将让mesh在渲染时不停晃动。
打开文件之后,首先要做的就是把茶壶换为一个球体。并不是不能对茶壶进行动画,只不过球体的效果会更好而已。修改如下代码:
mesh = Mesh.Sphere(device,
球体尺寸比较大,因此修改一个观察矩阵:
viewMatrix = Matrix.LookAtLH(new Vector3(0,0,
现在运行程序,可以看到一个被单一方向光照亮了不停在旋转的球体,非常单调。我们如何才能让他动画起来呢?在着色器代码中,把唯一一行变换顶点的代码作如下修改:
float4 tempPos = Pos;
tempPos.y += cos(Pos + (Time *
Out.pos = mul(tempPos, WorldViewProj);
注意到这里再次使用了Time变量。由于之前的代码没有声明过这个变量,所以还要添加代码:
Float Time =
除此之外,还必须每帧更新这个变量,在UpdateWorld方法中添加对这个变量的更新:
effect.SetValue("Time",angle);
但是,这段着色代码到底作了些什么呢?首先,使用一个临时变量来保存输入顶点的位置。然后使用当前顶点位置加上固定更新的时间变量的余弦值作为这个临时变量的y值。由于余弦值是在-1到1之间重复变化的,因此,这种效果会不断重复。在动画了顶点之后,对临时变量进行变换而不是变换原来的输入位置。
虽然这样的更新过后效果看起来好了很多,但是,我们还能做的更好。为什么不对颜色也做一些动画呢?使用以下代码代替原来的Out.diff初始化。
Out.diff = sin(Pos + Time);
显然,这段代码和之前动画顶点的差不多。唯一的区别就是这次使用了正弦值。这个例子的目的只是告诉你我们如何让一个枯燥的物体很快就运动起来。HLSL允许通过对主要代码做最少的修改就改变整个渲染效果。
使用纹理混合来决定颜色(Determining Color by Blending Textures)
现实世界里,并不是所有的程序都随机的方式来产生颜色。一个模型通常都有一张或多张高细节的纹理。当需要把多张纹理混合起来时因该怎么做呢?当然,可以使用固定管道来完成这个任务。但是,如果你使用的模型和我们一直适用的tiny.x一样该怎么办呢?这个模型只有一张纹理,如何把两张纹理混合起来呢?
为了方便,我们再从上一章渲染mesh的例子开始。注意此,我们的注意力将放在如何添加第二张纹理,并通过代码把两张纹理混合到一起,计算出最后的颜色。
特别提示:关于pixel shader中对结构的限制
由于这里我们将要编写pixel shader,因此,了解在pixel shader1.1中对“结构体”的限制是很重要的。在1.4之前的版本,整个着色程序中只允许有12个结构体。对于1.4来说,限制是28个,仍然不是太多。对于2.0来说,支持更多复杂操作以及更高的结构数量。可以通过检查显卡的特性来看到底支持多少。
知道了这个限制,我们纹理混合的方法将分为独立的两种:一个是相对相对简单的pixel shader,用于之支持1.1的显卡;另一个复杂版本则用于支持2.0以上的显卡。
自然,从简单的1.1版本开始。在开始编写着色程序之前,更新一下代码。我们需要第二张纹理来进行混合。在类中添加纹理变量:
private Textur skyTexture;
由于加载的模型只有一张纹理,我们还必须删除使用用材质和纹理数组的代码,具体修改请看源码。
接下来,创建将用于混合到模型上的第二张纹理。你需要手动把SDK中的skybox.jpg文件复制到工程目录下。当然,也可以选择任何你喜欢的图片。在LoadMesh方法中创建这张纹理。
meshTexture = TextureLoader.FromFile(device, @"..\..\" + mtrl[i].TextureFilename);
skyTexture = TextureLoader.FromFile(device,@"..\..\skybox_top.jpg");
好了,现在可以编写代码来混合这两张纹理了。在HLSL文件中添加如下代码:
float Time;
Texture meshTexture;
Texture skyTexture;
sampler TextureSampler = sampler_state{texture =
sampler SkyTextureSample = sample_state{texture =
时间变量没有什么需要解释的。来看看两个Texture变量。这里不使用SetTexture方法来设定纹理状态,着色的代码将控制纹理并进行合适的采样。完全可以删除程序中的SetTexture方法了。两个sampler变量决定了如何对纹理进行采样。这里,使用一个线性mipmap过滤器对实际的纹理进行采样。你也可以使用minify以及magnify过滤器,以及这些采样状态下的大量clamping types。但这里,mipmap过滤器就可以了。
在使用这个两个纹理变量之前,还需要对他们赋值。在C#代码中,加载了mesh之后的地方添加代码:
加载了mesh之后就应该创建纹理,所以放在这个地方是合适的。但是,就算加载了mesh,创建了纹理,还有一个基本问题没有解决。模型只包含一组纹理坐标。使用以下的vertex shader程序代替原来的代码。
void TransformV1_1(
in float4 inputPosition : POSITION,
in float4 inputTexCoord : TEXCOORD0,
out float4 outputPosition : POSITION,
out float4 outputTexCoord : TEXCOORD0,
out float4 outputSecondTexCoord : TEXCOORD1
)
{
//Transform our position
outputPosition = mul(inputPosition,WorldViewProj);
//set our texture coordinates
outputTexCoord = inputTexCoord;
outputSecondTexCoord = inputTexCoord;
}
可以看到,这段程序把顶点位置和一组纹理坐标作为输入,而输出时则返回变换之后的顶点位置,以及两组纹理坐标。实际上,这段代码为每个顶点复制了一遍纹理坐标。接下来,使用一下代码代替原来的pixel shader:
void TextureColorV1_1(
in float4 P : POSITION,
in float2 textureCoords : TEXCOORD0,
in float2 textureCoords2 : TEXCOORD1,
out float4 diffuseColor : COLOR0)
{
//Get the texture color
float4 diffuseColor1 = tex2D(TextureSampler, textureCoords);
float4 diffuseColor2 = tex2D(SkyTextureSampler, textureCoords2);
diffuseColor = lerp(diffuseColor1, diffuseColor2, Time);
};
这段代码接收从vertex shader返回的顶点位置以及两组纹理坐标作为参数,返回每一个像素的颜色。这里,我们对两张加载的纹理进行采样(使用同样的纹理坐标),之后,对他们进行线性的茶汁计算(使用lerp函数)。Time变量决定了每张纹理的可见度。由于程序中还没有设置Time变量,因此,在DrawMesh方法中添加如下代码:
effect.SetValue("Time", (float)Math.Abs(Math.Sin(angle)));
你可能会问为什么在这里进行数学运算,为什么不让着色程序来做呢?由于GPU更善于进行这种运算,你自然希望使它GPU来进行计算,而不是CPU。但是,这里的pixel shader限制你这样做。如果我们在shader里进行数学运算,那么只支持pixel shader1.1的显卡就不能运行它了。
在测试混合效果之前,还需要做一件事。Vertex以及pixel shader的名称都改变了,technique也不再有效。修改代码:
technique TransformTexture
{
pass P0
{
// shaders
VertexShader = compile vs_1_1 TransformV1_1();
PixelShader = compile ps_1_1 TextureColorV1_1();
}
}
Technique本身的名字并没有改变,因此现在可以运行程序看看了。
如果你的显卡支持pixel shader2.0怎么办呢?接下来,我们将修改代码,让他同时支持pixel shader1.1以及2.0。首先在C#代码中添加变量:
private bool canDo2_0Shaders = false;
默认情况下,我们假设你的显卡不支持2.0。在创建了设备之后,再来检查pixel shader2.0是否可用。确保在创建Effect对象前添加如下代码:
canDo2_0Shaders = device.DeviceCaps.PixelShaderVersion >= new Version(2,0);
由于我们将添加第二组technique来处理第二组着色器,所以我们将根据所能支持的最高着色模型(shader model)版本来选择使用哪一个technique:
effect.Technique = canDo2_0Shaders ? "transformTextrue2_0" : "TransformTexture";
这里,如果支持2.0我们就使用新的technique,否则,还是用刚才的technique。在编写这个shader之前,还有一个需要更新的地方。修改对Time变量的更新:
if(CanDo2_0Shaders)
effect.SetValue("Time",angle);
else
effect.SetValue("Time", (float)Math.Abs(Math.Sin(angle)));
你看,在高级的着色程序中,我们将让图形卡来完成数学运算。接下来添加新的HLSL代码:
首先你注意到的一定是vertex shader变的更简单了。我们只是简单的进行了位置变换,传递了原来的纹理坐标,省略了原来的复制步骤。Pixel shader也同样变简单了。使用同一组纹理坐标来对不同的纹理进行采样。在pixel shader2.0之前,着色器只能只能对纹理坐标进行一次“读取”,所以我们之前编写的程序必须复制一遍坐标。同时,你还应该看到我们使用了同样的公式来进行插值计算。而对于technique来说,我们使用了ps_2_0来编译piexl shader程序。
从画面上来说,你几乎看不出是哪一个shader在运行。如今,大多数的应用程序都应该支持最新的图形卡,并且使用各种特性来让画面变的光彩夺目。但是,同时也应该尽可能的让硬件不支持这些特性的机器能通过其他一些方法,大概的模拟出这些效果。
光照纹理
在前一章,讨论了如何使用方向光来照亮茶壶。当时所用的茶壶并没有纹理,而且灯光都是通过顶点程序来计算的。下面我们将看到使用pixel shader也能很容易的完成这个任务。还是从上一章渲染mesh的例子开作为起点。我们将对它添加灯光效果。
首先,在HLSL代码中作如下更新:
float4x4 WorldMatrix : WORLD;
float4 DiffuseDirection;
Texture meshTexture;
sampler TextureSampler = sampler_state{texture =
struct VS_OUTPUT
{
float4 Pos : POSITION;
float2 TexCoord : TEXCOORD0;
float3 Light : TEXCOORD1;
float3
};
这次,我们额外保存了世界变换矩阵。在后面的讨论中你会知道为什么需要这样做。此外还有一个保存灯光方向的变量,以及用于读取纹理的纹理和采样状态。这里的输出结构有些特别。不仅返回位置和纹理坐标,还返回两个新的纹理坐标。实际上,这些变量根本不会用作纹理坐标,他们只是用来把所需数据从vertex shader传递到pixel shader。
接下来,在C#代码中为这几个变量作一些更新。在DrawMesh方法中,添加如下代码:
effect.SetValue("WorldMatrix",worldMatrix);
effect.SetValue("DiffuseDirection",new Vector4(0,0,1,1));
在初始化的方法中,加载了mesh之后,设置纹理:
好了,简单的更新之后,可以开始编写HLSL代码实现灯光了。选择白色以外的灯光,这样效果看起来会明显一些。先来看看vertex shader:
VS_OUTPUT Transform(
float4 inputPosition : POSITION,
float3 inputNormal :
float2 inputTexCoord : TEXCOORD0
)
{
VS_OUTPUT Out = (VS_OUTPUT)0;
Out.Pos = mul(inputPosition,WorldViewProj);
Out.TexCoord = inputTexCoord;
Out.Light = DiffuseDirection;
Out.Normal = normalize(mul(inputNormal,WorldMatrix));
return Out;
}
首先,变换坐标,保存纹理坐标。接下来,把灯关方向保存为第二组纹理坐标。注意,Light是一个float4的变量,而纹理坐标是float3,所以这里包含了对较大数据类型中xyz重组(swizzle)。因为漫射方向是3维的矢量,所以这里忽略w元素不会有任何问题。同样,把法线保存为第三张纹理坐标。但是在这之前,还需要把发现变换到世界坐标系中。没有这一步,灯光计算则会失败。
接下来编写piexl shader:
float4 TextureColor(
float2 textureCoords : TEXCOORD0,
float3 lightDirection : TEXCOORD1,
float3 normal : TEXCOORD2) : COLOR0
{
// Get the texture color
float4 textureColor = tex2D(TextureSampler, textureCoords);
// Make our diffuse color purple for now
float4 diffuseColor = {
// Return the combined color after calculating the effect of
// the diffuse directional light
return textureColor * (diffuseColor * saturate(dot(lightDirection, normal)));
};
Pixel shader把vertex shader输出的所有纹理坐标作为参数,同时返回当前像素的颜色。第一步是简单的对纹理采样。接下来,设置灯光颜色。通过计算灯关方向与法线的亮度来计算光照强度。Saturate方法把返回值调整到0-1之间,这样就不会有太暗或者太亮的地方。最后,把纹理颜色与灯光的漫射颜色相乘,获得最后的颜色。运行程序看看吧。