§7.2Shader如何运行?
现在让我们开始使用写代码,先在FX Composer (见第6章介绍)中设计Shader,这里将使用前一节图7-8的小行星的纹理。
如果您愿意,您可以按照下面所描述的步骤,象上一章的simple shader一样编写自己的Normal Map shader。法线映射是一个相当酷的效果,但微调要占用大量的时间。你可以给模型的艺术家留下一些调整选项,编写一些不同的Normal Map shader以便可以选择为哪个材质使用哪个效果。举例来说,金属应该和石头或木材材质看起来有很大不同。
打开FX Composer 及normalmapping.fx文件,shader文件的布局类似于simpleshader.fx文件,但您可以使用更多的注释。这本书以后的shader中都使用了类似的文件结构。
基本文件布局是:
- 注释和Shader说明
- 矩阵(world,worldviewproj,viewinverse)
- 其他的全局变量如时间、测试值等
- 材质数据,首先是材质的颜色,然后是所有使用的材质与采样器
- 顶点结构,最重要的是vertexinput结构,通常使用你已经定义在引擎中的tangentvertex,通常使用如 //-----------------的注释行为每个technique分隔数据块。
- Vertex Shader
- Pixel Shader
- Technique将shader合在一起,通常使用相同的顶点shader。
为了简单,我只解释本书接下来游戏都用到的Normal Mapping 效果的顶点和像素shader,在这本书里被命名为specular20,还有一个Technique被称为specular,它以同样的方式工作在Shader Model 1.1,由于Pixel Shader 1.1有8条指令的限制,一些功能被关闭或减弱。
打开shader文件后请浏览一下文件头和参数,基本结构和上一章的simpleshader.fx是相似的。与上一章vertexinput格式看起来很类似,但它多了切线数据。在之前您已经遇到了.x和.fbx文件的问题,在这一章中后面将加以解决。假设现在您是有有效的切线数据可用于shader。幸运的是FX Composer总能为标准的测试对象(如球,茶壶,立方体等等)给出有效的切线数据。
// Vertex input structure (used for ALL techniques here!)
struct VertexInput {
float3 pos : POSITION;
float2 texCoord : TEXCOORD0;
float3 normal : NORMAL;
float3 tangent : TANGENT;
};
在simpleshader.fx您只需开始编码,一旦运行正常就无需重构Shader的代码了。听起自不错,但你shader写得越多,您会越想重用代码。一个方法是直接在shader文件中定义最常用的方法:
// Common functions
float4 TransformPosition(float3 pos)//float4 pos)
{
return mul(mul(float4(pos.xyz, 1), world), viewProj);
} // TransformPosition(.)
float3 GetWorldPos(float3 pos)
{
return mul(float4(pos, 1), world).xyz;
} // GetWorldPos(.)
float3 GetCameraPos()
{
return viewInverse[3].xyz;
}
// GetCameraPos() float3 CalcNormalVector(float3 nor)
{
return normalize(mul(nor, (float3x3)world));
} // CalcNormalVector(.)
// Get light direction
float3 GetLightDir()
{
return lightDir;
} // GetLightDir()
float3x3 ComputeTangentMatrix(float3 tangent, float3 normal)
{
// Compute the 3x3 tranform from tangent space to object space
float3x3 worldToTangentSpace;
worldToTangentSpace[0] = mul(cross(normal, tangent), world);
worldToTangentSpace[1] = mul(tangent, world);
worldToTangentSpace[2] = mul(normal, world);
return worldToTangentSpace;
} // ComputeTangentMatrix(..)
另一种方式类似于C + +的头文件,在单独的fxh文件中储存方法,参数和常量。我不喜欢这种方法,因为FX Composer 只能一次使用一个源文件,在游戏你仍要改变很多代码。
第一个函数是transformposition,它将顶点shader中的3D顶点位置转化到屏幕。除了无需再合并worldviewproj矩阵,transformposition的工作方式类似于上一章。不过你用一个世界矩阵来代替,但viewproj矩阵是新的。将world矩阵与viewproj矩阵相乘你会再次获得worldviewproj矩阵,这一步不在代码而在shader中执行是因为这样做可以节省你一个的顶点shader指令。
将world矩阵从worldviewproj矩阵分离出来能让你只关心单个矩阵。Shader 中使用的数据越多,花费的时间越长,如果你重复设置参数和开始shader将明显拖慢游戏。只建立Shader一次,然后批量改变数以千计的物体的世界矩阵要好得多,您可以一次建立一个存储了20个或更多的世界矩阵的数组,然后遍历全部,这项技术叫instancing,我用在了下面的shooter游戏中以优化性能(如果很多对象使用相同的Shader)。在较早版本的Rocket Commander游戏中我也用过,也支持固定功能管道,但要在所有的Shader模型( 1.1,2.0和3.0 )中正常工作代价不菲。不过没关系,在优化了所以其他Shader后性能很好。
其他辅助方法相当简单,浏览后你应该很快就能弄懂,除了最后一个,这是用来建立我曾谈及的切线空间矩阵的。再次看一下图7-4和7-5,看看是哪个向量。calculatetangentmatrix在顶点shader引擎中(如所有其他辅助方法),用来计算每个顶点的切线与法线向量。从vertexinput结构可以看出,Binormal向量从叉乘法线和切线向量获得。您可以用您的右手重建这个矩阵-中指是法线向量,食指是切线向量,拇指代表binormal向量,binormal向量是中指和食指的叉乘,因此互成90度夹角。
切线空间矩阵对快速将法线和光线的方向从世界空间向转化到切线空间是非常有用的,对Pixel Shader Normal Mapping计算也是必须的。您需要切线空间矩阵的原因是要确保所有向量在同一个空间。这个空间是直接指向多边形的顶部和方向向上(即z方向),就像法线向量的X和Y描述切线和binormal一样。使用切线空间是Pixel Shader中最容易和最快的方法,你必须获得正确的binormal和切线order去构建切线空间矩阵。此order和binormal的叉乘也能被转化成左手坐标系。使用单元测试以计算出哪个方式是正确的;您也可以看一下原始版本Rocket Commander游戏(左手系)中的shader,看一看与XNA版本(右手系)的区别。
Vertex shaders和矩阵
借助于所有辅助方法,现在应该可以很容易地编写顶点shader了。首先你应该定义vertexoutput结构。我这里只解释的Pixel Shader 2.0版本,因为支持Pixel Shader 1.1版本太复杂,并使用了很多汇编代码,已超出了本书的范围。如果你真的想支持的Pixel Shader 1.1,建议你找一本关于shader的书,以了解更多shader的细节和shader汇编语言,我推荐Programming Vertex and Pixel Shader,GPU Gems,或Shader X系列。Shader专家Wolfgang Engel参与了上面几本书的编写,你可以从这几本书中学到很多在以后几年内很多专业人士都会用到的shader技巧。Shader的知识如此之多导致诞生了一个新的程序员职业:shader程序员。
如果你在一个很小的团队,所有编程必须自己完成,这可能是一个麻烦,你要学习很多而你只有很少的时间,而且技术的发展又是如此之快。那就尽量保持简单,只使用最简单的Shader Model, XNA不支持固定功能的管道,也不支持Direct3D10的Shader Model 4.0,有些游戏制作人认为不太好,但这样您可以专注于创造Pixel Shader 2.0 shader(或可能使用的Pixel Shader 3.0)。
您的顶点结构需要屏幕上的空间坐标,往往还要纹理坐标,这样,diffuse和Normal贴图才可正确使用。请注意,在Pixel Shader 1.1中您必须复制纹理坐标,因为每个纹理顶点的输入在Pixel shader只能使用一次,这是Pixel Shader 1.1许多问题之一,您也不可以归一化(normalize)或使用幂(pow),也难以解压被压缩的法线贴图。幸运的是,这里你再也不用去考虑。
// vertex shader output structure
struct VertexOutput_Specular20
{
float4 pos : POSITION;
float2 texCoord : TEXCOORD0;
float3 lightVec : TEXCOORD1;
float3 viewVec : TEXCOORD2;
};
lightvec和viewvec变量只是帮助Pixel Shader计算得比较简单一点。照明计算基本上和前一章是一样的,但这次,你必须计算出切线空间的所有向量,因为比起将所有切线空间向量转化到世界空间,在切线空间中比较容易工作。这是有道理的,因为像素比顶点多很多。通过一个复杂的矩阵运算转换每一个像素会很缓慢,而只转换光线方向和看到的向量所花时间不多。
看一下整个顶点shader代码。重要的是worldtotangentspace矩阵的使用,它是由前面看到的computetangentmatrix方法计算得来的:
// Vertex shader function
VertexOutput_Specular20 VS_Specular20(VertexInput In)
{
VertexOutput_Specular20 Out = (VertexOutput_Specular20) 0;
Out.pos = TransformPosition(In.pos);
// We can duplicate texture coordinates for diffuse and Normal Map
// in the Pixel Shader 2.0.
Out.texCoord = In.texCoord;
// Compute the 3x3 tranform from tangent space to object space
float3x3 worldToTangentSpace = ComputeTangentMatrix(In.tangent, In.normal);
float3 worldEyePos = GetCameraPos();
float3 worldVertPos = GetWorldPos(In.pos);
// Transform light vector and pass it as a color (clamped from 0 to 1)
// For ps_2_0 we don't need to clamp from 0 to 1
Out.lightVec = normalize(mul(worldToTangentSpace, GetLightDir()));
Out.viewVec = mul(worldToTangentSpace, worldEyePos - worldVertPos);
// And pass everything to the pixel shader
return Out;
} // VS_Specular20(.)
支持Pixel Shader和优化
现在Pixel Shader获取vertex output并计算出每个像素的光线。首先你必须diffuse和 Normal贴图的颜色。Diffuse贴图有RGB值,如果您使用Alpha混合也许还有Alpha值。从压缩的Normal贴图获得法线向量更复杂。如果您还记得以前使用normalmapcompressor压缩法线贴图,您大概可以想到,你应该交换红色和Alpha通道而使RGB数据有效、作为XYZ向量再次可用。第一步使用了所谓的swizzle从一个纹理或shader register中获取rgba或xyzw数据并改变顺序。举例来说,abgr颠倒了rgba的顺序。对于您的情况,您所需要的只是Alpha通道(x)和绿色(y)和蓝(z)的通道,所以这里你使用.agb swizzle。
然后您使用前面介绍的公式得到浮点型的颜色值,即将向量的值减去0.5再除以0.5 ,这是和先乘以2再减去1是一样的(因为0.5*2是1)。为解决任何其他的压缩错误,您再次将向量归一化,需要一条额外的Pixel Shader指示,但它是值得的。
// Pixel Shader function float4
PS_Specular20(VertexOutput_Specular20 In) : COLOR
{
// Grab texture data
float4 diffuseTexture = tex2D(diffuseTextureSampler, In.texCoord);
float3 normalVector = (2.0 * tex2D(normalTextureSampler, In.texCoord).agb) - 1.0;
// Normalize normal to fix blocky errors
normalVector = normalize(normalVector);
// Additionally normalize the vectors
float3 lightVector = In.lightVec;//not needed:
normalize(In.lightVec);
float3 viewVector = normalize(In.viewVec);
// For ps_2_0 we don't need to unpack the vectors to -1 - 1
// Compute the angle to the light
float bump = saturate(dot(normalVector, lightVector));
// Specular factor
float3 reflect = normalize(2 * bump * normalVector - lightVector);
float spec = pow(saturate(dot(reflect, viewVector)), shininess);
//return spec;
float4 ambDiffColor = ambientColor + bump * diffuseColor;
return diffuseTexture * ambDiffColor + bump * spec * specularColor * diffuseTexture.a;
} // PS_Specular20(.)
通过normal vector (仍然在切线空间),现在您可以计算所有的照明。光线向量和view向量在vertexoutput_specular20结构中。光线向量在每一个顶点和像素中是相同的,因为只使用了定向光源,但当靠近渲染的对象时view矢量可能有很大不同。图7-11再次表明为什么重新归一化view矢量是很重要的。对Normal 映射更重要,因为你为每个像素计算光线而通过Normal贴图中的变量,从这个像素到另一个像素向量变化是很大的。
你使用和上一章计算diffuse颜色的方法计算bump值。对每一个指向光线的法线您使用较亮的颜色,反之则较暗。法线矢量和光线矢量都在切线空间,这样基本公式保持不变。您可以测试simpleshader.fx文件中的简单的灯光效果,然后用Normal映射重新组合,这很酷,因为即使这个shader更复杂,基本的照明计算仍然是简单的和可改变的。如果你看看下个章节的Normal Mapping 和parallax mapping,您会看到只有在这您要改动,其余的Normal Mapping shader保持不变。
最后,在将颜色组合在一起前你必须计算镜面高光的颜色。您还没有half vector,但你有view vector在切线空间,你知道法线的指向。代码使用了一个简化公式:只减从光线向量中减去2倍法线向量生成一个伪归一化的half vector。在simpleshader.fx顶点shader中做这种计算若放在像素shader中会很占资源,使用上面的简化方法Normal Mapping shader效果也不错,half vector是否精确并无大碍。重要的是幂(pow)方法,它产生光泽效果,你可以调整每一种材质的shininess和specularcolor的值,因为它们极大地影响了最终的输出效果(见图7-12)。
最后把颜色混在一起可能看起来有点奇怪。头两行是相当容易理解。首先,您由diffuse颜色值和bump值将环境光混合在一起,这个操作在Shader中只需一条指令,这也是为什么这样写代码的原因。
接着,镜面高光颜色是乘以高光颜色值,这显示高光,仍由bump值和最后由diffusecolor Alpha值得到。如果背离光线,bump值确保镜面高光值会变得较暗,如果使用透明纹理,diffuse color Alpha值可以帮助减弱镜面高光的值。
现在你必须在FX Composer中得到Norma Mapping Shader,你可以试着改变颜色值或不同的贴图或参数。接来来你要学习如何在应用程序得到正确的切线数据和能力,以及如何轻松地导入模型文件。
发布时间:2008/9/16 下午2:16:34 阅读次数:6605