前言
在很早之前的紋理映射中,紋理存放的元素是像素的顏色,通過紋理坐標映射到目標像素以獲取其顏色。但是我們的法向量依然只是定義在頂點上,對於三角形面內一點的法向量,也只是通過比較簡單的插值法計算出相應的法向量值。這對平整的表面比較有用,但無法表現出內部粗糙的表面。在這一章,你將了解如何獲取更高精度的法向量以描述一個粗糙平面。
DirectX11 With Windows SDK完整目錄
歡迎加入QQ群: 727623616 可以一起探討DX11,以及有什么問題也可以在這里匯報。
法線貼圖
法線貼圖是指紋理中實際存放的元素通常是經過壓縮后的法向量,用於表現一個表面凹凸不平的特性,它是凹凸貼圖的一種實現方式。
開啟法線貼圖后的效果

關閉法線貼圖后的效果

法線貼圖中存放的法向量\((x, y, z)\)分別對應原來的\((r, g, b)\)。每個像素都存放了對應的一個法向量,經過壓縮后使用24 bit即可表示。實際情況則是一張法線貼圖里面的每個像素使用了32 bit來表示,剩余的8 bit(位於Alpha值)要么可以不使用,要么用來表示高度值或者鏡面系數。而未經壓縮的法線貼圖通常為每個像素存放4個浮點數,即使用128 bit來表示。
下面展示了一張法線貼圖,每個像素點位置存放了任意方向的法向量。可以看到這里為法線貼圖建立了一個TBN坐標系(左手坐標系),其中T軸(Tangent Axis)對應原來的x軸,B軸(Binormal Axis)對應原來的y軸,N軸(Normal Axis)對應原來的z軸。建立坐標系的目的在后面再詳細描述。觀察這些法向量,它們都有一個共同的特點,就是都朝着N軸的正方向散射,這樣使得大多數法向量的z分量是最大的。

由於壓縮后的法線貼圖通常是以R8G8B8A8的格式存儲,我們也可以直接把它當做圖片來打開觀察。

前面說到大部分法向量的z分量會比x, y分量大,導致整個圖看起來會偏藍。
法線貼圖的壓縮與解壓
經過初步壓縮后的法線貼圖的占用空間為原來的1/4(不考慮文件頭),就算每個分量只有256種表示,也足夠表示出16777216種不同的法向量了。假如現在我們已經有未經過壓縮的法線貼圖,那要怎么進行初步壓縮呢?
對於一個單位法向量來說,其任意一個分量的取值也無非就是落在[-1, 1]的區間上。現在我們要將其映射到[0, 255]的區間上,可以用下面的公式來進行壓縮:
而如果現在拿到的是24位法向量,要進行還原,則可以用下面的公式:
當然,經過還原后的法向量是有部分的精度損失了,至少能夠映射回[-1, 1]的區間上。
通常情況下我們能拿到的都是經過壓縮后的法線貼圖,但是還原工作還是需要由自己來完成。
float3 normalT = gNormalMap.Sample(sam, pin.Tex);
經過上面的采樣后,normalT的每個分量會自動從[0, 255]映射到[0, 1],但還不是最終[-1, 1]的區間。因此我們還需要完成下面這一步:
normalT = 2.0f * normalT - 1.0f;
這里的1.0f會擴展成float3(1.0f, 1.0f, 1.0f)以完成減法運算。
注意:如果你想要使用壓縮紋理格式(對原來的R8G8B8A8進一步壓縮)來存儲法線貼圖,可以使用BC7(
DXGI_FORMAT_BC7_UNORM)來獲得最佳性能。在DirectXTex中有大量從BC1到BC7的紋理壓縮/解壓函數。
紋理/切線空間
這里開始就會產生一個疑問了,為什么需要切線空間?
在只有2D的紋理坐標系僅包含了U軸和V軸,但現在我們的紋理中存放的是法向量,這些法向量要怎么變換到局部物體上某一個三角形對應位置呢?這就需要我們對當前法向量做一次矩陣變換(平移和旋轉),使它能夠來到局部坐標系下物體的某處表面。由於矩陣變換涉及到的是坐標系變換,我們需要先在原來的2D紋理坐標系加一條坐標軸(N軸),與T軸(原來的U軸)和B軸(原來的V軸)相互垂直,以此形成切線空間。
一開始法向量處在單位切線空間,而需要變換到目標3D三角形的位置也有一個對應的切線空間。對於一個立方體來說,一個面的兩個三角形可以共用一個切線空間。

利用頂點位置和紋理坐標求TBN坐標系
現在假設我們的頂點只包含了位置和紋理坐標這兩個信息,有這樣一個三角形,它們的頂點為V0(x0, y0, z0), V1(x1, y1, z1), V2(x2, y2, z2),紋理坐標為(u0, v0), (u1, v1), (u2, v2)。

圖片展示了一個三角形與所處的切線空間,我們可以這樣定義向量E0和E1:
現在T軸和B軸都是待求的單位向量,可以列出下述關系:
把它用矩陣來描述:
繼續細化:
為了計算TB矩陣,需要在等式兩邊左乘uv矩陣的逆:
對於一個二階矩陣頂點求逆,我們不考慮過程。已知有矩陣\(\mathbf{A} = \begin{bmatrix} a & b \\ c & d \end{bmatrix}\),那么它的逆矩陣為:
因此上面的方程最終變成:
這里可以找一個例子嘗試一下:
V0坐標為(0, 0, -0.25), 紋理坐標為(0, 0.5)
V1坐標為(0.15, 0, 0), 紋理坐標為(0.3, 0)
V2坐標為(0.4, 0, 0), 紋理坐標為(0.8, 0)
求解過程如下:
由於位置坐標和紋理坐標的不一致性,導致求出來的T向量和B向量很有可能不是單位向量。僅當位置坐標的變化率與紋理坐標的變化率相同時才會得到單位向量。這里我們將其進行標准化即可。
但如果對紋理坐標進行了變換,有可能導致T軸和B軸不相互垂直。比如嘗試用球體網格模型某個三角形面內的一點映射到球面上一點。
頂點切線空間
上面的運算得到的切線空間是基於單個三角形的,可以看到其運算過程還是比較復雜,而且交給着色器來進行運算的話還會產生大量的指令。
我們可以為頂點添加法向量N和切線向量T用於構建基於頂點的切線空間。很早之前提到法向量是與該頂點共用的所有三角形的法向量取平均值所得到的。切線向量也一樣,它是與該頂點共用的所有三角形的切線向量取平均值所得到的。
現在Vertex.h定義了我們的新頂點類型:
struct VertexPosNormalTangentTex
{
DirectX::XMFLOAT3 pos;
DirectX::XMFLOAT3 normal;
DirectX::XMFLOAT4 tangent;
DirectX::XMFLOAT2 tex;
static const D3D11_INPUT_ELEMENT_DESC inputLayout[4];
};
這里的tangent是一個4D向量,考慮到要和微軟DXTK定義的頂點類型保持一致,多出來的w分量可以留作他用,這里暫不討論。
施密特向量正交化
通常頂點提供的N和T通常是相互垂直的,並且都是單位向量,我們可以通過計算\(\mathbf{B} = \mathbf{N} \times \mathbf{T}\)來得到副法線向量B,使得頂點可以不需要存放副法線向量B。但是經過插值計算后的N和T可能會導致不是相互垂直,我們最好還是要通過施密特正交化來獲得實際的切線空間。
現在已知互不垂直的N向量和T向量,我們希望求出與N向量垂直的T'向量,需要將T向量投影到N向量上。

從上面的圖我們可以知道最終求得的T' 為
B' 最終也可以確定下來
這樣T', B', N相互垂直,可以構成TBN坐標系。在后面的着色器實現中我們也會用到這部分內容。
切線空間的變換
一開始的切線空間可以用一個單位矩陣來表示,切線向量正是處在這個空間中。緊接着就是需要對其進行一次到局部對象(具體到某個三角形)切線空間的變換:
然后切線向量隨同世界矩陣一同進行變換來到世界坐標系,因此我們可以把它寫成:
注意:
- 對切線向量進行矩陣變換,我們只需要使用3x3的矩陣即可。
- 法線向量變換到世界矩陣需要用世界矩陣求逆的轉置進行校正,而對切線向量只需要用世界矩陣變換即可。下圖演示了將寬度拉伸為原來2倍后,法線和切線向量的變化:
HLSL代碼
為了使用法線貼圖,我們需要完成下列步驟:
- 獲取該紋理所需要用到的法線貼圖,在C++端為其創建一個
ID3D11Texture2D。這里不考慮如何制作一張法線貼圖。 - 對於一個網格模型來說,頂點數據需要包含位置、法向量、切線向量、紋理坐標四個元素。同樣這里不討論模型的制作,在本教程使用的是
Geometry所生成的網格模型 - 在頂點着色器中,將頂點法向量和切線向量從局部坐標系變換到世界坐標系
- 在像素着色器中,使用經過插值的法向量和切線向量來為每個三角形表面的像素點構建TBN坐標系,然后將切線空間的法向量變換到世界坐標系中,這樣最終求得的法向量用於光照計算。
現在我們的Basic.hlsli沿用的是第23章動態天空盒的部分,變化如下:
Texture2D g_DiffuseMap : register(t0);
Texture2D g_NormalMap : register(t1);
TextureCube g_TexCube : register(t2);
SamplerState g_Sam : register(s0);
// 使用的是第23章的常量緩沖區,省略...
// 省略和之前一樣的結構體...
struct VertexPosNormalTangentTex
{
float3 PosL : POSITION;
float3 NormalL : NORMAL;
float4 TangentL : TANGENT;
float2 Tex : TEXCOORD;
};
struct InstancePosNormalTangentTex
{
float3 PosL : POSITION;
float3 NormalL : NORMAL;
float4 TangentL : TANGENT;
float2 Tex : TEXCOORD;
matrix World : World;
matrix WorldInvTranspose : WorldInvTranspose;
};
struct VertexPosHWNormalTangentTex
{
float4 PosH : SV_POSITION;
float3 PosW : POSITION; // 在世界中的位置
float3 NormalW : NORMAL; // 法向量在世界中的方向
float4 TangentW : TANGENT; // 切線在世界中的方向
float2 Tex : TEXCOORD;
};
float3 NormalSampleToWorldSpace(float3 normalMapSample,
float3 unitNormalW,
float4 tangentW)
{
// 將讀取到法向量中的每個分量從[0, 1]還原到[-1, 1]
float3 normalT = 2.0f * normalMapSample - 1.0f;
// 構建位於世界坐標系的切線空間
float3 N = unitNormalW;
float3 T = normalize(tangentW.xyz - dot(tangentW.xyz, N) * N); // 施密特正交化
float3 B = cross(N, T);
float3x3 TBN = float3x3(T, B, N);
// 將凹凸法向量從切線空間變換到世界坐標系
float3 bumpedNormalW = mul(normalT, TBN);
return bumpedNormalW;
}
上面的NormalSampleToWorldSpace函數用於將法向量從切線空間變換到世界空間,位於Basic.hlsli。它接受了3個參數:從法線貼圖采樣得到的向量,變換到世界坐標系的法向量和切線向量。
然后是頂點着色器:
// NormalMapObject_VS.hlsl
#include "Basic.hlsli"
// 頂點着色器
VertexPosHWNormalTangentTex VS(VertexPosNormalTangentTex vIn)
{
VertexPosHWNormalTangentTex vOut;
matrix viewProj = mul(g_View, g_Proj);
vector posW = mul(float4(vIn.PosL, 1.0f), g_World);
vOut.PosW = posW.xyz;
vOut.PosH = mul(posW, viewProj);
vOut.NormalW = mul(vIn.NormalL, (float3x3) g_WorldInvTranspose);
vOut.TangentW = mul(vIn.TangentL, g_World);
vOut.Tex = vIn.Tex;
return vOut;
}
// NormalMapInstance_VS.hlsl
#include "Basic.hlsli"
// 頂點着色器
VertexPosHWNormalTangentTex VS(InstancePosNormalTangentTex vIn)
{
VertexPosHWNormalTangentTex vOut;
matrix viewProj = mul(g_View, g_Proj);
vector posW = mul(float4(vIn.PosL, 1.0f), vIn.World);
vOut.PosW = posW.xyz;
vOut.PosH = mul(posW, viewProj);
vOut.NormalW = mul(vIn.NormalL, (float3x3) vIn.WorldInvTranspose);
vOut.TangentW = mul(vIn.TangentL, vIn.World);
vOut.Tex = vIn.Tex;
return vOut;
}
相比之前的像素着色器,現在它多了對法線映射的處理:
// 法線映射
float3 normalMapSample = g_NormalMap.Sample(g_Sam, pIn.Tex).rgb;
float3 bumpedNormalW = NormalSampleToWorldSpace(normalMapSample, pIn.NormalW, pIn.TangentW);
求得的法向量bumpedNormalW將用於光照計算。
現在完整的像素着色器代碼如下:
// NormalMap_PS.hlsl
#include "Basic.hlsli"
// 像素着色器(3D)
float4 PS(VertexPosHWNormalTangentTex pIn) : SV_Target
{
// 若不使用紋理,則使用默認白色
float4 texColor = float4(1.0f, 1.0f, 1.0f, 1.0f);
if (g_TextureUsed)
{
texColor = g_DiffuseMap.Sample(g_Sam, pIn.Tex);
// 提前進行裁剪,對不符合要求的像素可以避免后續運算
clip(texColor.a - 0.1f);
}
// 標准化法向量
pIn.NormalW = normalize(pIn.NormalW);
// 求出頂點指向眼睛的向量,以及頂點與眼睛的距離
float3 toEyeW = normalize(g_EyePosW - pIn.PosW);
float distToEye = distance(g_EyePosW, pIn.PosW);
// 法線映射
float3 normalMapSample = g_NormalMap.Sample(g_Sam, pIn.Tex).rgb;
float3 bumpedNormalW = NormalSampleToWorldSpace(normalMapSample, pIn.NormalW, pIn.TangentW);
// 初始化為0
float4 ambient = float4(0.0f, 0.0f, 0.0f, 0.0f);
float4 diffuse = float4(0.0f, 0.0f, 0.0f, 0.0f);
float4 spec = float4(0.0f, 0.0f, 0.0f, 0.0f);
float4 A = float4(0.0f, 0.0f, 0.0f, 0.0f);
float4 D = float4(0.0f, 0.0f, 0.0f, 0.0f);
float4 S = float4(0.0f, 0.0f, 0.0f, 0.0f);
int i;
[unroll]
for (i = 0; i < 5; ++i)
{
ComputeDirectionalLight(g_Material, g_DirLight[i], bumpedNormalW, toEyeW, A, D, S);
ambient += A;
diffuse += D;
spec += S;
}
[unroll]
for (i = 0; i < 5; ++i)
{
ComputePointLight(g_Material, g_PointLight[i], pIn.PosW, bumpedNormalW, toEyeW, A, D, S);
ambient += A;
diffuse += D;
spec += S;
}
[unroll]
for (i = 0; i < 5; ++i)
{
ComputeSpotLight(g_Material, g_SpotLight[i], pIn.PosW, bumpedNormalW, toEyeW, A, D, S);
ambient += A;
diffuse += D;
spec += S;
}
float4 litColor = texColor * (ambient + diffuse) + spec;
// 反射
if (g_ReflectionEnabled)
{
float3 incident = -toEyeW;
float3 reflectionVector = reflect(incident, pIn.NormalW);
float4 reflectionColor = g_TexCube.Sample(g_Sam, reflectionVector);
litColor += g_Material.Reflect * reflectionColor;
}
// 折射
if (g_RefractionEnabled)
{
float3 incident = -toEyeW;
float3 refractionVector = refract(incident, pIn.NormalW, g_Eta);
float4 refractionColor = g_TexCube.Sample(g_Sam, refractionVector);
litColor += g_Material.Reflect * refractionColor;
}
litColor.a = texColor.a * g_Material.Diffuse.a;
return litColor;
}
所有的着色器將共用Basic.hlsli。而對BasicEffect的變化(和C++的交互)這里我們不討論。
下面的動畫演示了法線貼圖的對比效果(GIF畫質有點渣):

至此進階篇就告一段落了。
DirectX11 With Windows SDK完整目錄
歡迎加入QQ群: 727623616 可以一起探討DX11,以及有什么問題也可以在這里匯報。

