问题
虽然前一个教程中具有不变法线的平面物体工作良好,但如果对一个曲面或有转角的表面进行凹凸映射仍会遇到麻烦。
主要问题是包含在凹凸映射中的偏离法线是在切线空间中的,这意味着它与默认法线有联系。
为了形象化的说明这个问题,设想绘制一个圆柱体,如图5-30所示。左图表示圆柱体顶点的默认法线。
图5-30 圆柱体的默认法线和凹凸映射法线
想象一下你想对这个圆柱体使用凹凸映射。例如,你想对圆柱体的所有像素使用包含 (–1,0,1)方向法线的凹凸映射。这个法线相对于默认法线向左偏转45度。
获取这个法线正确的方式是位于默认法线的起点沿着这个默认法线向左旋转45度。看一下顶点法线0,4和6,对于圆柱体的每个像素这个方向是不同的,因为这个方向取决于像素的默认法线!如果对每个法线都进行这样的处理,你最终会获得如右图所示的结果。这些法线都相对于原始法线向左偏离45度。
但是,如果你想使用这个法线计算光照,需要找到法线(–1,0,1)对应于世界空间的方向。这是必须的,因为你需要使用两个向量进行光照计算,而这两个向量需定义在同一个空间中。你想在XNA程序中指定光线在世界空间中的方向。
解决方案
法线映射中的三个颜色通道包含了切线空间中的法线坐标,即相对于默认法线而言,这个坐标表示默认法线的偏离量。每个像素的局部坐标系是不同的,因为这取决于默认法线,而在一个曲面上默认法线的方向是不同的。这个局部坐标系叫做切线空间。
技巧:要理解切线空间,可以想象你站在如图5-30所示的圆柱体像素上,沿着法线方向。现在将你的上方(沿着默认法线方向)作为Z轴,你的右边就是X轴,前方就是Y轴。
这个切线坐标系统如图5-31中“a”图的三个灰色箭头表示。注意像素的默认法线在切线空间中是Z轴(见前面的技巧)。切线空间的x和y轴要与z轴互相垂直,z垂直与物体(因为这是默认法线方向),x轴和y轴与物体相切,分别对应切线和副法线。
从凹凸贴图采样的三个颜色通道包含定义在切线空间的法线数据。由图中的黑色箭头表示,对应偏移量为X=0.3,Y=0和Z=0.8的法线。在“a”中,你看到了一个偏向x轴的新法线。
最后要计算像素的发光,这需要获取定义在切线空间中的法线和定义在世界空间中的光线方向的点乘。而要计算两个向量的点乘,这两个向量必须在同一个空间中。所以你要么将光线方向从世界空间转换到切线空间,要么将法线从切线空间转换到世界空间。在本教程中,我们先使用第二种方法。教程的最后一段会使用第一种方法。
图5-31 在切线空间(a),模型空间(b)和世界空间(c)中的偏离法线
工作原理
对每个像素,你都要采样凹凸贴图获取相对于切线空间的偏离法线。最后要知道这个法线在世界空间中的坐标,然后才能与光线方法进行点乘。
切线空间到世界空间的转换
从凹凸贴图采样的法线是定义在切线空间中的,例如一个圆塔是圆柱形的,如图5-31所示。每个像素的偏离法线需要首先转换到模型空间,即塔的坐标空间。这会得到模型空间中的默认法线。
技巧:要理解塔的坐标空间,想象一下你站在塔的初始位置,例如塔内部的中心位置。现在,你的上方是y轴,右方是x轴,后方是z轴。你想得到的偏离法线是由塔的坐标系统指定的。
这个变换由图5-31中的从“a”指向“b”的箭头表示。在“a”中,切线空间中的法线坐标是(0.3,0,0.8),而在“b”中模型空间中的法线坐标是(0.2,0,0.85)(由两个系统的坐标决定)。
你可以使用一个包含旋转的世界矩阵绘制一个塔(例如,绘制一个比萨斜塔)。在本例中,你获取的法线需要移除这个旋转才能获得法线的绝对(世界)方向。这个变换由图5-31中的从“b”指向“c”的箭头表示(注意这个图中的模型坐标实际上是世界坐标的旋转)。最后,你获得了世界坐标中的法线。
定义自定义顶点格式
要将法线从切线空间转换到模型空间,你需要乘以正确的变换矩阵。你需要对每个像素都做这样的操作。要创建这个矩阵,首先要知道每个顶点在切线空间的x,y和z轴(法线,切线和副法线) 。
因为默认法线和z轴是不同的,而且x轴和y轴必须垂直于z轴,所以通常每个顶点这三个轴是不同的。因为三个轴正交,所以你只需知道任意两个,就可以通过叉乘它们获取第三个轴。一个轴(z)是默认法线,可以使用教程5-7中的代码获取,切线向量由模型提供,或者在简单的情况中(例如一个圆柱体)由自己定义。
注意:本例中,vertex shader会通过叉乘法线和切线向量计算出副法线向量。这样的话,顶点只需存储法线和切线向量。你也可以编写一个自定义模型处理器计算每个顶点的副法线并将它存储在顶点中,只需使用MeshHelper. CalculateTangentFrames帮你完成这个繁重的工作。这样做的好处是显卡无需在每帧计算副法线向量了。
首先你需要定义一个自定义顶点格式存储每个顶点的3D位置,纹理坐标,法线和切线。可见教程5-14获取自定义顶点格式的细节。
public struct VertPosTexNormTan { public Vector3 Position; public Vector2 TexCoords; public Vector3 Normal; public Vector3 Tangent; public VertPosTexNormTan(Vector3 Position, Vector2 TexCoords, Vector3 Normal, Vector3 Tangent) { this.Position = Position; this.TexCoords = TexCoords; this.Normal = Normal; this.Tangent = Tangent; } public static readonly VertexElement[] VertexElements = { new VertexElement(0, 0, VertexElementFormat.Vector3, VertexElementMethod.Default, VertexElementUsage.Position, 0), new VertexElement(0, sizeof(float)*3, VertexElementFormat.Vector2, VertexElementMethod.Default, VertexElementUsage.TextureCoordinate, 0), new VertexElement(0, sizeof(float)*(3+2), VertexElementFormat.Vector3, VertexElementMethod.Default, VertexElementUsage.Normal, 0), new VertexElement(0, sizeof(float)*(3+2+3), VertexElementFormat.Vector3, VertexElementMethod.Default, VertexElementUsage.Tangent, 0), }; public static readonly int SizeInBytes = sizeof(float) * (3 + 2 + 3 + 3); }
每个顶点需要存储一个Vector3表示位置,一个Vector2表示纹理坐标,两个Vector3表示法线和切线,一共11个浮点数,而副法线在vertex shader中计算。
定义每个顶点的法线和切线
本例中,你定义了一些三角形创建一座塔(一个圆柱形的墙),使用教程5-7的代码生成法线数据。
如前所述,切线方向垂直于法线,与塔表明相切。本例中你定义一个垂直的塔,所以你知道向上方向不与塔相交,并垂直于塔的所有法线,这就是切线方向。
下面的代码定义一个圆柱形。对于每个生成的顶点计算出3D位置,并将(0,1,0)向上方向做为切线方向。
private void InitVertices() { List<VertPosTexNormTan> verticesList = new List<VertPosTexNormTan>(); int detail = 20; float radius = 2; float height = 8; for (int i = 0; i < detail + 1; i++) { float angle = MathHelper.Pi * 2.0f / (float)detail * (float)i; Vector3 baseVector = Vector3.Transform(Vector3.Forward, Matrix.CreateRotationY(angle)); Vector3 posLow = baseVector * radius; posLow.Y = -height / 2.0f; Vector3 posHigh = posLow; posHigh.Y += height; Vector2 texCoordLow = new Vector2(angle / (MathHelper.Pi * 2.0f), 1); Vector2 texCoordHigh = new Vector2(angle / (MathHelper.Pi * 2.0f), 0); verticesList.Add(new VertPosTexNormTan(posLow, texCoordLow, Vector3.Zero, new Vector3(0, 1, 0))); verticesList.Add(new VertPosTexNormTan(posHigh, texCoordHigh, Vector3.Zero, new Vector3(0, 1, 0))); } vertices = verticesList.ToArray(); }
接下来,基于顶点生成索引,所有的代码已在教程5-7中解释了:
vertices = InitVertices(); indices = InitIndices(vertices); vertices = GenerateNormalsForTriangleList(vertices, indices);
设置了顶点和索引,下面可以处理. fx文件了。
XNA-to-HLSL变量
所有的3Dhaders都需要传递World,View和Projection矩阵。因为没有光线凹凸映射不起作用,所以还要设置光线方向。最后,xTexStretch 变量让你可以定义砖块纹理的平铺次数。
你需要一张颜色纹理采样颜色,还需要包含定义在切线空间中的法线信息的凹凸贴图。
vertex shader总是要把每个顶点的3D位置转换到2D屏幕坐标,还要传递纹理坐标。要让pixel shader将法线从切线空间转换到世界空间,vertex shader还要计算Tangent-to-World矩阵传递到pixel shader。
float4x4 xWorld; float4x4 xView; float4x4 xProjection; float3 xLightDirection; float xTexStretch; Texture xTexture; sampler TextureSampler = sampler_state { texture = <xTexture> ; magfilter = LINEAR; minfilter = LINEAR; mipfilter=LINEAR; AddressU = wrap; AddressV = wrap; }; Texture xBumpMap; sampler BumpMapSampler = sampler_state { texture = <xBumpMap> ; magfilter = LINEAR; minfilter = LINEAR; mipfilter=LINEAR; AddressU = wrap; AddressV = wrap; }; struct BMVertexToPixel { float4 Position : POSITION; float2 TexCoord : TEXCOORD0; float3x3 TTW : TEXCOORD1; }; struct BMPixelToFrame { float4 Color : COLOR0; }
注意:这个代码使用了语义TEXCOORD1传递了一个3 × 3矩阵。这会被编译,但背景使用了TEXCOOR2和TEXCOORD3,所以你不能再使用它们了。
Vertex Shader
vertex shader将3D位置转换到2D屏幕坐标并将纹理坐标传递到pixel shader:
BMVertexToPixel BMVertexShader(float4 inPos: POSITION0, float3 inNormal: NORMAL0, float2 inTexCoord: TEXCOORD0, float3 inTangent: TANGENT0) { BMVertexToPixel Output = (BMVertexToPixel)0; float4x4 preViewProjection = mul(xView, xProjection); float4x4 preWorldViewProjection = mul(xWorld, preViewProjection); Output.Position = mul(inPos, preWorldViewProjection); Output.TexCoord = inTexCoord; return Output; }
接下来你要在vertex shader中添加一些代码计算Tangent-to-World矩阵。如前所述,这个转换包含两个过程。首先将法线的坐标从切线空间转换到模型空间,然后转换到世界空间。
首先定义一个tangentToObject矩阵,要定义这个矩阵,你需要知道坐标轴的基本向量——切线空间的法线,切线和副法线。
vertex shader接受每个顶点的法线和切线。如前所述,你可以通过叉乘两者计算出副法线,因为副法线向量是与法线和切线正交的。
float3 Binormal = normalize(cross(inTangent,inNormal));
计算了副法线后就可以构建Tangent-to-Object矩阵了,因为这个矩阵的三行分别代表这三个向量,这些向量都需要被归一化:
float3x3 tangentToObject; tangentToObject[0] = normalize(Binormal); tangentToObject[1] = normalize(inTangent); tangentToObject[2] = normalize(inNormal);
技巧:归一化矩阵的每行后,因为这三个向量是正交的,所以这个矩阵的逆矩阵等于它的转置矩阵,你可以通过将矩阵中的元素关于左上角至右下角的元素做镜像获得它的转置矩阵,这样可以更容易地计算逆矩阵。
你要做的就是通过将Tangent-to-Object矩阵和Object-to-World矩阵相乘把两者组合起来。Object-to-World矩阵通常就是世界矩阵(例如包含塔的旋转),所以最后的代码是:
float3x3 tangentToWorld = mul(tangentToObject, xWorld); Output.TTW = tangentToWorld;
将这个矩阵传递到pixel shader。现在pixel shader可以很容易地通过把向量乘以这个矩阵把向量从切线空间转换到世界矩阵!
Pixel Shader
在vertex shader做完了全部准备工作,pixel shader看起来就很简单了:
BMPixelToFrame BMPixelShader(BMVertexToPixel PSIn) : COLOR0 { BMPixelToFrame Output = (BMPixelToFrame)0; float3 bumpColor = tex2D(BumpMapSampler, PSIn.TexCoord*xTexStretch); float3 normalT = (bumpColor - 0.5f)*2.0f; float3 normalW = mul(normalT, PSIn.TTW); float lightFactor = dot(-normalize(normalW),normalize(xLightDirection)); float4 texColor = tex2D(TextureSampler, PSIn.TexCoord*xTexStretch); Output.Color = lightFactor*texColor; return Output; }
首先采样凹凸映射,这个颜色中包含三个颜色通道对应在切线空间中的偏离法线的坐标。
在前一个教程中的“Pixel Shader”一节中提到,颜色通道的区间是0至1,而法线坐标的区间是–1到1。所以首先将这个值减0.5映射到[-0.5,0.5]区间再乘以2映射到[–1,1]区间。计算结果存储在normalT变量中。 normalT变量包含定义在切线空间中的偏离法线的数据。
normalT值为(0,0,1)表示偏离法线等于默认法线(即切线空间中的z轴方向)。normalT值为(0,0.7,0.7)表示法线在默认法线和切线(切线空间的y轴方向)间的45度角方向。
将normalT 乘以Tangent-to-World 矩阵就可以完成从切线空间到世界空间的转换,获取normalW的值,这个值就是世界空间中的法线。
注意:你可以立即将法线从切线空间转换到世界空间,因为 Tangent-to-World矩阵是Tangent-to-Object矩阵和Object-to-World矩阵的组合。
最后,当你知道了世界空间中的法线后,就可以将这个向量乘以光线向量了,如教程6-1中解释的,这个点乘给出了光线强度,把这个强度乘以颜色,因为每个像素的法线不同,所以每个像素的反光程度也不同。
代码
HLSL部分
shader要计算Tangent-to-World矩阵传递给pixel shader使用:
BMVertexToPixel BMVertexShader(float4 inPos: POSITION0, float3 inNormal: NORMAL0, float2 inTexCoord: TEXCOORD0, float3 inTangent: TANGENT0) { BMVertexToPixel Output = (BMVertexToPixel)0; float4x4 preViewProjection = mul(xView, xProjection); float4x4 preWorldViewProjection = mul(xWorld, preViewProjection); Output.Position = mul(inPos, preWorldViewProjection); Output.TexCoord = inTexCoord; float3 Binormal = cross(inTangent,inNormal); float3x3 tangentToObject; tangentToObject[0] = normalize(Binormal); tangentToObject[1] = normalize(inTangent); tangentToObject[2] = normalize(inNormal); float3x3 tangentToWorld = mul(tangentToObject, xWorld); Output.TTW = tangentToWorld; return Output; }
pixel shader采样凹凸贴图并将颜色映射到[–1,1]区间获取切线空间的法线,再乘以Tangent-to-World矩阵获取世界空间中的法线,然后点乘光线方向:
BMPixelToFrame BMPixelShader(BMVertexToPixel PSIn) : COLOR0 { BMPixelToFrame Output = (BMPixelToFrame)0; float3 bumpColor = tex2D(BumpMapSampler, PSIn.TexCoord*xTexStretch); float3 normalT = (bumpColor - 0.5f)*2.0f; float3 normalW = mul(normalT, PSIn.TTW); float lightFactor = dot(-normalize(normalW),normalize(xLightDirection)); float4 texColor = tex2D(TextureSampler, PSIn.TexCoord*xTexStretch); Output.Color = lightFactor*texColor; return Output; }
XNA部分
XNA项目要提供每个顶点的切线,切线向量垂直于法线,生成这些顶点的方法前面已经写过了。
最后需要设置effect参数并绘制三角形,代码如下:
effect.CurrentTechnique = effect.Techniques["BumpMapping"]; effect.Parameters["xWorld"].SetValue(Matrix.Identity); effect.Parameters["xView"].SetValue(fpsCam.ViewMatrix); effect.Parameters["xProjection"].SetValue(fpsCam.ProjectionMatrix); effect.Parameters["xTexture"].SetValue(myTexture); effect.Parameters["xBumpMap"].SetValue(myBumpMap); effect.Parameters["xLightDirection"].SetValue(lightDirection); effect.Parameters["xTexStretch"].SetValue(4.0f); effect.Begin(); foreach (EffectPass pass in effect.CurrentTechnique.Passes) { pass.Begin(); device.VertexDeclaration = myVertexDeclaration; device.DrawUserIndexedPrimitives<VertPosTexNormTan>(PrimitiveType.TriangleList, vertices, 0, vertices.Length, indices, 0, indices.Length/3); pass.End(); } effect.End();
相反的方法
前面的代码显示了如何构建tangentToWorld矩阵,这样每个像素都可以将它的法线从切线空间转换到世界空间。这一步需要在与光线方向点乘前完成,因为光线方向是在世界空间中的。
你也可以换种方法:你可以将光线方向从世界空间转换到切线空间并点乘切线空间中的法线。这很有趣,因为每个像素的光线方向是一样的,所以可以在vertex shader中转换光线方向并将它传递给pixel shader。通过这种方法,pixel shader可以立即进行点乘计算而无需计算任何转换!
注意:使用这个方法,每个三角形只需要进行三次光线向量转换,而不是对三角形的每个像素的法线进行转换。
你将转换到切线空间中的的光线向量传递到pixel shader,而不是传递tangentToWorld矩阵:
struct BMVertexToPixel { float4 Position : POSITION; float2 TexCoord : TEXCOORD0; float3 LightDirT: TEXCOORD1; };
在vertex shader中,你已经计算了tangentToWorld矩阵。这次,你想将光线向量从世界空间转换到切线空间,所以需要求tangentToWorld矩阵的逆矩阵。
计算逆矩阵是一个复杂的运算。幸运的是,tangentToWorld矩阵是由三个正交、归一化(副法线,切线和法线)的向量构成的。通常,你在乘法操作中以一个向量作为第一个参数,一个矩阵作为第二个矩阵:
float3 vectorW = mul(vectorT, tangentToWorld);
在三个正交归一化向量的特殊情况下,你可以通过改变乘法的顺序获取逆矩阵转换一个向量:
float3 vectorT = mul(tangentToWorld, vectorW);
注意:这是因为由三个正交并归一化的向量构成的矩阵的逆矩阵等于它的转置矩阵。
这正是你想获得的结果:将光线向量从世界空间转换到切线空间:
Output.LightDirT = mul(tangentToWorld, xLightDirection);
计算好切线空间的光线方向后,把把它传递到pixel shader。因为从映射中采样的法线和光线方向都是在切线空间中,你可以直接点乘两者:
float3 bumpColor = tex2D(BumpMapSampler, PSIn.TexCoord*xTexStretch); float3 normalT = (bumpColor - 0.5f)*2.0f; float lightFactor = dot(-normalize(normalT), normalize(PSIn.LightDirT));