问题

三角形的最主要问题是它是平的。如果你使用两个三角形绘制一堵巨大的墙并在墙上附上一个漂亮的纹理,结果是令人失望的平的。

你可以将三角形分割成更小的三角形以添加细节,这需要定义每个顶点的3D位置,但这样做会消耗太多的资源。

解决方案

你可以使用凹凸映射代替上述这个丑陋的方法。凹凸映射通过改变三角形每个像素的颜色给观察者留下三角形表面高低起伏的印象。

如果你看一张平整的红色的塑料板的图片,会发现所有像素几乎是一个颜色的。但是如果是一个粗糙表面的图片,比方说一块红砖,像素会有不同的红色,让观察者知道这块砖的表面是粗糙的。

这就是你想模拟的效果。在一个粗糙表面上,像素反光情况不同导致颜色是不同的。这是因为你可以把一块砖分成上千个小的表面,每个面都有不同的方向或者说不同的法线,如图5-29左边所示。对应每个不同的法线,你可以获取不同的光照情况 (见第6章)。

image

图5-29 一个有着上千个平面的砖块,每个面都有不同的法线

你可以不使用如图5-29左边所示的上百个三角形绘制砖块,而是如右图所示只用两个三角形绘制。在这两个三角形的每个像素上,你将稍微改变一下默认的法线,这会让所有像素有不同的光照并导致产生不同的颜色。通过这种方式,如果砖块位置或光线方向改变时,凹凸映射可以添加很多细节。

要获得最好的效果,你不能随机改变法线的方向,而是应该以正确的方式进行,如右图所示,注意右图的法线与左图是一样的。通常每个法线的X,Y和Z 坐标都存储在一张图像的R,G和B通道中,这叫做纹理的凹凸映射(或法线映射)。

所以,不要将砖块分成上千个三角形,而是使用两张纹理绘制有两个三角形的矩形:一张纹理包含砖块的颜色,另一张包含法线偏离量的信息。你需要使用pixel shader查询每个像素的颜色和法线偏移量,并使用这个法线计算光照进一步调整颜色的亮度。

本教程的凹凸映射是一个平面,平面是个简化的例子,因为它的法线向量是不变的。

工作原理

首先你需要定义一个新的三角形表示平面。下面的代码定义两个带纹理的三角形,这足以绘制一个大矩形了:

private VertexPositionTexture[] InitVertices() 
{
    VertexPositionTexture[] vertices = new VertexPositionTexture[6]; 
    vertices[0] = new VertexPositionTexture(new Vector3(-5, 0, 10), new Vector2(0, 2)); 
    vertices[1] = new VertexPositionTexture(new Vector3(-5, 0, -10), new Vector2(0, 0)); 
    vertices[2] = new VertexPositionTexture(new Vector3(5, 0, 10), new Vector2(1, 2)); 
    vertices[3] = new VertexPositionTexture(new Vector3(-5, 0, -10),new Vector2(0, 0)); 
    vertices[4] = new VertexPositionTexture(new Vector3(5, 0, -10), new Vector2(1, 0)); 
    vertices[5] = new VertexPositionTexture(new Vector3(5, 0, 10), new Vector2(1, 2)); 
    return vertices; 
} 

因为你要从纹理进行采样,所以还要指定纹理坐标(见教程5-2)。因为Y坐标都是0,所以你定义的平面在XZ平面上,所以你知道默认法线向量是朝上的。

注意:这个假设只有在这种情况下才是对的,下一个教程会介绍一个更通用的方法。

定义好顶点后,就可以开始编写fx代码了。

XNA-to-HLSL变量

所有的3D effects都需要传递世界矩阵、观察矩阵和投影矩阵,这样vertex shader才能将顶点的3D位置转换到2D屏幕位置。要显示凹凸映射的效果,你还应该可以改变光线方向。

如前所述,你还要传入两张纹理:一张用来采样颜色,一张用来查询每个像素的法线。

float4x4 xWorld;
float4x4 xView; 
float4x4 xProjection; 
float3 xLightDirection; 

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 SBMVertexToPixel
{
    float4 Position : POSITION; 
    float2 TexCoord : TEXCOORD0; 
}; 

struct SBMPixelToFrame
{
    float4 Color : COLOR0; 
};

vertex shader会输出每个顶点的2D屏幕位置和纹理坐标,pixel shader计算最终颜色。

Vertex Shader

vertex shader非常简单,因为它只需将3D坐标转换到2D屏幕空间并将纹理坐标传递给pixel shader:

SBMVertexToPixel SBMVertexShader(float4 inPos: POSITION0, float2 inTexCoord: TEXCOORD0)
{
    SBMVertexToPixel Output = (SBMVertexToPixel)0; 
    float4x4 preViewProjection = mul(xView, xProjection); 
    float4x4 preWorldViewProjection = mul(xWorld, preViewProjection); 
    Output.Position = mul(inPos, preWorldViewProjection); 
    Output.TexCoord = inTexCoord; 
    return Output; 
} 

3D位置通过世界矩阵、视矩阵、投影矩阵的组合转换到2D屏幕空间,纹理坐标只是直接传递到pixel shader。

Pixel Shader

对于三角形的每个像素,pixel shader都会从颜色纹理中查询颜色值。但这次pixel shader 还要采样凹凸贴图,查询指定像素偏离默认法线的方向。

法线向量是一个3D向量,由三个坐标轴定义。有关法线更多的信息可见教程5-7,要知道法线如何影响光线,可见教程6-1。

对凹凸映射的每个像素,法线坐标是保存在三个颜色通道中的。但是,这三个坐标变化范围应该在–1和1 (也就是说可以是±x,±y和±z方向)之间,但颜色的范围只是在[0,1]范围中。所以,你需要将[0,1]范围中的值映射到[–1,1]之间,这可以先将这个值减去0.5(映射到 [–0.5, 0.5]范围内)在乘以2 (映射到[–1,1]范围内)。这一步是在pixel shader中的第三行代码中进行的:

SBMPixelToFrame SBMPixelShader(SBMVertexToPixel PSIn) : COLOR0
{
    SBMPixelToFrame Output = (SBMPixelToFrame)0; 
    float3 bumpMapColor = tex2D(BumpMapSampler, PSIn.TexCoord).rbg; 
    float3 normalFromBumpMap = (bumpMapColor - 0.5f)*2.0f; 
    float lightFactor = dot(-normalize(normalFromBumpMap), normalize(xLightDirection));
    float4 texColor = tex2D(TextureSampler, PSIn.TexCoord); 
    Output.Color = lightFactor*texColor; 
    return Output; 
} 

在凹凸映射中,三个颜色通道表示默认法线的偏移量。当默认法线不偏离时,对应的像素颜色是浅蓝(R=0.5, G=0.5, B=1,译者注:见上面的算法,以rbg顺序读取颜色,然后使用公式乘2减1,即R=0.5, G=0.5, B=1结果是(0,1,0))。在这个例子中的两个三角型是在XZ平面中,所以法线朝上,即(0,1,0)的方向。

凹凸映射中像素的颜色不是(R=0.5, G=0.5, B=1)表示法线需要偏离(0,1,0)方向。本例中,你想让法线朝X或Z方向偏离。可见下一个教程学习一个通用的方法。

这也是为什么这个例子中使用蓝色通道对应法线的Y坐标,红色和绿色通道对应 X和Z 坐标(这也是使用RBG swizzle的理由,译者注:swizzle是一种指定输入哪个通道的方法,例如float4 b(1, 2, 3, 4);则float4 a = b.xxxx(); // a is now 1, 1, 1, 1;a = b.wwwz(); // a is now 4, 4, 4, 3;a = b.yzzw(); // a is now 2, 3, 3, 4;a = b.wzyx(); // a is now 4, 3, 2, 1;你也可以使用诸如float4 a = b.rrrr();获得同样的效果,但混用颜色通道和坐标通道不可以,即不可以写成 float4 a = b.rryy();)。如果从凹凸映射采样的颜色等于(0.5,0.5,1),则normalFromBumpMap为(0,1,0),这意味着法线不偏离(都朝上)。如果bumpMapColor = (0.145,0.5,0.855),那么normalFromBumpMap为(-0.71,0.71,0)这意味着法线会向左偏离45度。

一旦知道了每个像素的法线,就可以在点乘法线和光线方向之前归一化(使之长度为1)这两者法线和光线方向。如教程5-6解释的那样,点乘的结果表示当前像素的亮度,然后将这个亮度乘以颜色。

定义Technique

最后在fx文件中添加technique定义:

technique SimpleBumpMapping
{
    pass Pass0
    {
        VertexShader = compile vs_2_0 SBMVertexShader(); 
        PixelShader = compile ps_2_0 SBMPixelShader();
    }
} 
代码

把以上三部分添加到fx文件就是完整代码了。下面是设置参数的XNA代码,绘制了两个三角形:

protected override void Draw(GameTime gameTime) 
{
    device.Clear(ClearOptions.Target | ClearOptions.DepthBuffer, Color.Black, 1, 0); 
    
    //set effect parameters 
    effect.CurrentTechnique = effect.Techniques["SimpleBumpMapping"]; 
    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); 
    
    //render two triangles 
    effect.Begin(); 
    foreach (EffectPass pass in effect.CurrentTechnique.Passes)
    {
        pass.Begin(); 
        device.VertexDeclaration = myVertexDeclaration; 
        device.DrawUserPrimitives<VertexPositionTexture>(PrimitiveType.TriangleList, vertices, 0, 2); 
        pass.End(); 
    }
    effect.End(); 
    base.Draw(gameTime); 
} 

image