[DirectX12学习笔记] 法线贴图

法线贴图


法线贴图简介

法线贴图我们都很熟悉了,法线一般是存在切线空间里的,一般来说大多数位置的法线方向都是和面方向偏差不大,所以法线贴图总是蓝蓝的,也就是对应rgb(0,0.5,0)。
法线的三个维度的取值范围一般都是[-1,1],而贴图采样出来的范围都是[0,1],所以我们要处理一下,把采样结果乘2减1。

下面简述一下我们要怎么使用法线贴图,首先我们要知道每个顶点的切线方向(u的偏导方向),然后在顶点着色器里把切线和法线都变换到世界坐标。然后世界坐标里就可以得到插值后的切线和法线,用这两输入值作施密特正交化得到切线空间的三个轴的坐标,也就得到了切线空间到局部空间的变换矩阵,然后把贴图上采样得到的法线变换到世界坐标来计算光照。

现在问题来了,我们要怎么知道每个顶点的切线方向呢?和顶点法线类似的,三角形法线法线可以用三角形的两边叉乘得到,顶点法线是所有经过这个顶点的三角面的法线的均值。而顶点切线,就是每个经过这个顶点的面的切线均值。现在问题就变成了求三角面的切线。首先我们规定一点,切向和uv的u的方向是一致的,每个顶点信息里都存了对应的uv,根据这一点我们可以求切线。
首先我们有三角形的两个边,我们把这两边对应的顶点的uv作差,有
(Δu0, Δv0) = (u1 − u0, v1 − v0)
(Δu1, Δv1) = (u2 − u0, v2 − v0)
然后观察下图
[DirectX12学习笔记] 法线贴图
其中N是normal,T是tangent,B是bitangent,TBN就是切线空间,然后u的方向与T一致,v则和B一致,那么有
[DirectX12学习笔记] 法线贴图
写成矩阵形式
[DirectX12学习笔记] 法线贴图
Δu0、Δv0、Δu1、Δv1通过顶点uv作差得到,e0和e1就是局部坐标系下的边向量,顶点坐标作差就可以得到,现在我们就可以反解出T和B向量
[DirectX12学习笔记] 法线贴图
其中
[DirectX12学习笔记] 法线贴图
然后面的切线有了,顶点的切线就取下均值就可以得到,这个步骤可以在我们载入模型的时候完成,算好后就存在顶点的结构体里。
接下来我们考虑切线空间到世界空间的变换矩阵,因为要用这个矩阵来变换采样得到的法线到世界空间,首先我们知道T,B,N在局部空间的坐标,那么局部空间到切线空间的便便就是这三个向量拼在一起,那么切线到局部的变换则是这个矩阵的逆,又因为这个矩阵是正交阵,逆就是转置,所以切线空间到局部空间的变换矩阵是
[DirectX12学习笔记] 法线贴图
这个矩阵在乘上世界矩阵,就是切线空间到世界空间的变换矩阵,注意这里我们只用来变换向量,所以只要3x3。
[DirectX12学习笔记] 法线贴图
可以看出,切线空间到世界空间的变换矩阵其实就是世界坐标系下的T,B,N三个向量拼起来。
但是把T,B,N变换到世界坐标系下,这三个向量可能就不正交了,所以我们一般只在定点里存T和N,B则是临时算出来,T和N变换到世界坐标系后再施密特正交化,再算出B,这样就可以保证这个变换矩阵是正交阵了。

法线贴图demo

接下来我们实现一个应用了法线贴图的场景demo。

首先C++部分和之前基本上没什么区别,只要在材质里面多记录一个法线贴图的index,然后传到shader里就行。

接下来是shader部分,我们在Common.hlsl中封装一个用来将法线贴图变换到世界空间的函数。如下

//---------------------------------------------------------------------------------------
// Transforms a normal map sample to world space.
//---------------------------------------------------------------------------------------
float3 NormalSampleToWorldSpace(float3 normalMapSample, float3 unitNormalW, float3 tangentW)
{
	// Uncompress each component from [0,1] to [-1,1].
	float3 normalT = 2.0f*normalMapSample - 1.0f;

	// Build orthonormal basis.
	float3 N = unitNormalW;
	float3 T = normalize(tangentW - dot(tangentW, N)*N);
	float3 B = cross(N, T);

	float3x3 TBN = float3x3(T, B, N);

	// Transform from tangent space to world space.
	float3 bumpedNormalW = mul(normalT, TBN);

	return bumpedNormalW;
}

可以看到,这个函数做的事大致就是,先将采样结果乘2减1,变成切线空间里的向量,然后用世界坐标系下的法线和切线通过施密特正交化算出TBN,然后用TBN拼出变换矩阵,再把切向空间里的法向量变换到世界坐标系。

接下来是渲染的shader部分。

// Defaults for number of lights.
#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 code.
#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.0f;

	// Fetch the material data.
	MaterialData matData = gMaterialData[gMaterialIndex];
	
    // Transform to world space.
    float4 posW = mul(float4(vin.PosL, 1.0f), gWorld);
    vout.PosW = posW.xyz;

    // Assumes nonuniform scaling; otherwise, need to use inverse-transpose of world matrix.
    vout.NormalW = mul(vin.NormalL, (float3x3)gWorld);
	
	vout.TangentW = mul(vin.TangentU, (float3x3)gWorld);

    // Transform to homogeneous clip space.
    vout.PosH = mul(posW, gViewProj);
	
	// Output vertex attributes for interpolation across triangle.
	float4 texC = mul(float4(vin.TexC, 0.0f, 1.0f), gTexTransform);
	vout.TexC = mul(texC, matData.MatTransform).xy;
	
    return vout;
}

float4 PS(VertexOut pin) : SV_Target
{
	// Fetch the material data.
	MaterialData matData = gMaterialData[gMaterialIndex];
	float4 diffuseAlbedo = matData.DiffuseAlbedo;
	float3 fresnelR0 = matData.FresnelR0;
	float  roughness = matData.Roughness;
	uint diffuseMapIndex = matData.DiffuseMapIndex;
	uint normalMapIndex = matData.NormalMapIndex;
	
	// Interpolating normal can unnormalize it, so renormalize it.
    pin.NormalW = normalize(pin.NormalW);
	
	float4 normalMapSample = gTextureMaps[normalMapIndex].Sample(gsamAnisotropicWrap, pin.TexC);
	float3 bumpedNormalW = NormalSampleToWorldSpace(normalMapSample.rgb, pin.NormalW, pin.TangentW);

	// Uncomment to turn off normal mapping.
	//bumpedNormalW = pin.NormalW;

	// Dynamically look up the texture in the array.
	diffuseAlbedo *= gTextureMaps[diffuseMapIndex].Sample(gsamAnisotropicWrap, pin.TexC);

    // Vector from point being lit to eye. 
    float3 toEyeW = normalize(gEyePosW - pin.PosW);

    // Light terms.
    float4 ambient = gAmbientLight*diffuseAlbedo;

    const float shininess = (1.0f - roughness) * normalMapSample.a;
    Material mat = { diffuseAlbedo, fresnelR0, shininess };
    float3 shadowFactor = 1.0f;
    float4 directLight = ComputeLighting(gLights, mat, pin.PosW,
        bumpedNormalW, toEyeW, shadowFactor);

    float4 litColor = ambient + directLight;

	// Add in specular reflections.
	float3 r = reflect(-toEyeW, bumpedNormalW);
	float4 reflectionColor = gCubeMap.Sample(gsamLinearWrap, r);
	float3 fresnelFactor = SchlickFresnel(fresnelR0, bumpedNormalW, r);
    litColor.rgb += shininess * fresnelFactor * reflectionColor.rgb;
	
    // Common convention to take alpha from diffuse albedo.
    litColor.a = diffuseAlbedo.a;

    return litColor;
}

可以看到,顶点里面多存了个Tangent,我们把顶点级别的tangent从c++输入到shader,然后再顶点着色器里把normal和tangent变换到世界坐标系里面,然后到了像素着色器的时候,我们就有了差值得到的像素级别的法线和切线,把这个法线和切线以及法线贴图采样得到的结果传入上面封装好的那个变换法线的接口,就得到了世界坐标系下的法线,再用这个法线来作光照运算即可。
此外这里我们还用了法线贴图的Alpha通道来存shininess。

最后渲染结果如下
[DirectX12学习笔记] 法线贴图
现在柱子看起来不再是光滑的了,和之前的光滑的柱子比起来好了很多。
[DirectX12学习笔记] 法线贴图