DirectX 12 – Normal Mapping 法线映射

19.6 在着色器中进行法线映射

我们将法线映射的过程总结为以下几点:

1. 使用图形软件创建我们需要的法线映射贴图并将其存储为图片文件。在程序初始化时基于这些文件创建2D贴图。

2. 为每一个三角形计算切线向量T。对于网格模型中的顶点v,所有使用/共享该顶点的三角形的切线向量的平均值就是该顶点的切线向量。(在demo中,我们所使用的模型基本为简单的几何体,所以我们可以直接求出切线向量。但是如果使用的是3D模型软件所制作的复杂模型,那么我们需要进行平均值计算)。

3. 在顶点着色器中,将顶点的法线向量与切线向量转换至世界空间,之后在像素着色器中使用这些计算结果。

4. 使用插值后的切线向量与法线向量为三角形上的每一个像素点构建TBN坐标系。之后我们将使用这个坐标系将法线映射贴图中的法线向量从切线空间转换为世界空间。然后我们便得到了该像素在世界空间中的法线向量,其可以用于我们的光照计算模型。

为了实现法线映射贴图,我们将以下函数加入了Common.hlsl文件:

// HLSL
// 将法线映射贴图中的法线向量转换至世界空间
float3 NormalSampleToWorldSpace(float3 normalMapSample,
    float3 unitNormalW,
    float3 tangentW)
{
    // 将向量的范围从[0,1]映射到[-1,1]
    float3 normalT = 2.f * normalMapSample - 1.f;

    // 构建坐标轴
    float3 N = unitNormalW;
    float3 T = normalize(tangentW - dot(tangentW, N) * N);
    float3 B = cross(N, T);
    float3x3 TBN = float3x3(T, B, N);

    // 从切线空间转换为世界空间
    float3 bumpedNormalW = mul(normalT, TBN);

    return bumpedNormalW;
}

我们将在像素着色器中使用该函数:

// HLSL
float3 normalMapSample = gNormalMap.Sample(samLinear, pin.TexC).rgb;
float3 bumpedNormalW = NormalSampleToWorldSpace(
    normalMapSample,
    pin.NormalW,
    pin.TangentW);

大家可能会对下面两行代码心存疑问:

// HLSL
float3 N = unitNormalW;
float3 T = normalize(tangentW - dot(tangentW, N) * N);

在结果插值之后,三角形上每个像素的切线向量与法线向量可能不再互相垂直。而在代码中需要将切线向量T在法线向量N方向上的坐标值减去,以保证向量T与向量N互相垂直(如下图所示)。不过我们需要保证unitNormalW是单位向量。

当我们从法线映射贴图中拿到法线向量后,将使用该向量用于各种包含法线的计算。以下为整个法线映射贴图的HLSL代码。

// HLSL
// 默认光源数量
#ifndef NUM_DIR_LIGHTS
    #define NUM_DIR_LIGHTS 3
#endif

#ifndef NUM_POINT_LIGHTS
    #define NUM_POINT_LIGHTS 0
#endif

#ifndef NUM_SPOT_LIGHTS
    #define NUM_SPOT_LIGHTS 0
#endif

#include "Common.hlsl"

struct VertexIn
{
    float3 PosL : POSITION;
    float3 NormalL : NORMAL;
    float2 TexC : TEXCOORD;
    float3 TangentU : TANGENT;
};

struct VertexOut
{
    float4 PosH : SV_POSITION;
    float3 PosW : POSITION;
    float3 NormalW : NORMAL;
    float3 TangentW : TANGENT;
    float2 TexC : TEXCOORD;
};

VertexOut VS(VertexIn vin)
{
    VertexOut vout = (VertexOut)0.f;

    // 读取材质material的数据
    MaterialData matData = gMaterialData[gMaterialIndex];

    // 转换至世界空间
    float4 posW = mul(float4(vin.PosL, 1.f), gWorld);
    vout.PosW = posW.xyz;

    // 假设并没有发生不对称缩放;否则,需要使用世界矩阵的逆转置矩阵
    vout.NormalW = mul(vin.NormalL, (float3x3)gWorld);
    vout.TangentW = mul(vin.TangentU, (float3x3)gWorld);

    // 转换至齐次剪裁空间
    vout.PosH = mul(posW, gViewProj);

    // 输出顶点属性
    float4 texC = mul(float4(vin.TexC, 0.f, 1.f), gTexTransform);
    vout.TexC = mul(texC, matData.MatTransform).xy;

    return vout;
}

float4 PS(VertexOut pin) : SV_Target
{
    // 读取材质material数据
    MaterialData matData = gMaterialData[gMaterialIndex];
    float4 diffuseAlbedo = matData.DiffuseAlbedo;
    float3 fresnelR0 = matData.FresnelR0;
    float roughness = matData.Roughness;
    uint diffuseMapIndex = matData.DiffuseMapIndex;
    uint normalMapIndex = matData.NormalMapIndex;

    // 插值后的法线向量可能不是单位向量,所以需要进行标准化
    pin.NormalW = normalize(pin.NormalW);

    float4 normalMapSample = gTextureMaps[normalMapIndex].Sample(
        gsamAnisotropicWrap, pin.TexC);

    float4 bumpedNormalW = NormalSampleToWorldSpace(
        normalMapSample.rgb, pin.NormalW, pin.TangentW);

    // 动态读取贴图数组中的贴图
    diffuseAlbedo *= gTextureMaps[diffuseMapIndex].Sample(
        gsamAnisotropicWrap, pin.TexC);

    // 由像素点指向camera的向量
    float3 toEyeW = normalize(gEyePosW - pin.PosW);

    // 环境光
    float4 ambient = gAmbientLight * diffuseAlbedo;

    // α通道存储着每个像素的光滑度
    const float shininess = (1.f - roughness) * normalMapSample.a;
    
    Material mat = { diffuseAlbedo, fresnelR0, shininess };
    float3 shadowFactor = 1.f;
    float4 directLight = ComputeLighting(gLights, mat, pin.PosW, 
        bumpedNormalW, toEye, shadowFactor);
    float4 litColor = ambient + directLight;

    // 添加镜面反射
    float3 r = reflect(-toEyeW, bumpedNormalW);
    float4 reflectionColor = gCubeMap.Sample(gsamLinearWrap, r);
    float3 fresnelFactor = SchlickFresnel(fresnelR0, bumpedNormalW, r);
    litColor.rgb += shininess * fresnelFactor * reflectionColor.rgb;

    // 读取α通道的值
    litColor.a = diffuseAlbedo.a;

    return litColor; 
}

我们可以看到,向量bumpedNormalW不但在计算光照时使用,还在使用environment map模拟镜面反射使用。此外,在法线映射贴图的α通道中我们存储了每个像素的光滑度(Roughness的反义词),如下图所示。

上图中,白色的部分表示光滑度为1.0,而黑色的部分表示光滑度为0.0.这样我们就能通过贴图来控制所有像素的粗糙度了。

留下评论

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据