DirectX11 With Windows SDK--25 法線貼圖

前言

在很早以前的紋理映射中,紋理存放的元素是像素的顏色,經過紋理座標映射到目標像素以獲取其顏色。可是咱們的法向量依然只是定義在頂點上,對於三角形面內一點的法向量,也只是經過比較簡單的插值法計算出相應的法向量值。這對平整的表面比較有用,但沒法表現出內部粗糙的表面。在這一章,你將瞭解如何獲取更高精度的法向量以描述一個粗糙平面。html

DirectX11 With Windows SDK完整目錄git

Github項目源碼github

歡迎加入QQ羣: 727623616 能夠一塊兒探討DX11,以及有什麼問題也能夠在這裏彙報。ide

法線貼圖

法線貼圖是指紋理中實際存放的元素一般是通過壓縮後的法向量,用於表現一個表面凹凸不平的特性,它是凹凸貼圖的一種實現方式。函數

開啓法線貼圖後的效果

關閉法線貼圖後的效果

法線貼圖中存放的法向量\((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的格式存儲,咱們也能夠直接把它當作圖片來打開觀察。spa

前面說到大部分法向量的z份量會比x, y份量大,致使整個圖看起來會偏藍。code

法線貼圖的壓縮與解壓

通過初步壓縮後的法線貼圖的佔用空間爲原來的1/4(不考慮文件頭),就算每一個份量只有256種表示,也足夠表示出16777216種不一樣的法向量了。假如如今咱們已經有未通過壓縮的法線貼圖,那要怎麼進行初步壓縮呢?orm

對於一個單位法向量來講,其任意一個份量的取值也無非就是落在[-1, 1]的區間上。如今咱們要將其映射到[0, 255]的區間上,能夠用下面的公式來進行壓縮:

\[f(x) = (0.5x + 0.5) * 255\]

而若是如今拿到的是24位法向量,要進行還原,則能夠用下面的公式:

\[ f^-1(x) = \frac{2x}{255} - 1\]

固然,通過還原後的法向量是有部分的精度損失了,至少可以映射回[-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)。

圖片展現了一個三角形與所處的切線空間,咱們能夠這樣定義向量E0E1

\[\mathbf{e_0} = \mathbf{V_1} - \mathbf{V_0}\]
\[\mathbf{e_1} = \mathbf{V_2} - \mathbf{V_0}\]

如今T軸和B軸都是待求的單位向量,能夠列出下述關係:

\[(\Delta u_0, \Delta v_0) = (u_1 - u_0, v_1 - v_0)\]
\[(\Delta u_1, \Delta v_1) = (u_2 - u_0, v_2 - v_0)\]
\[\mathbf{e_0} = \Delta u_0\mathbf{T} + \Delta v_0\mathbf{B}\]
\[\mathbf{e_1} = \Delta u_1\mathbf{T} + \Delta v_1\mathbf{B}\]

把它用矩陣來描述:

\[ \begin{bmatrix} \mathbf{e_0} \\ \mathbf{e_1} \end{bmatrix} = \begin{bmatrix} \Delta u_0 & \Delta v_0 \\ \Delta u_1 & \Delta v_1 \end{bmatrix} \begin{bmatrix} \mathbf{T} \\ \mathbf{B} \end{bmatrix} \]

繼續細化:

\[ \begin{bmatrix} e_{0x} & e_{0y} & e_{0z} \\ e_{1x} & e_{1y} & e_{1z} \end{bmatrix} = \begin{bmatrix} \Delta u_0 & \Delta v_0 \\ \Delta u_1 & \Delta v_1 \end{bmatrix} \begin{bmatrix} T_x & T_y & T_z \\ B_x & B_y & B_z \end{bmatrix} \]

爲了計算TB矩陣,須要在等式兩邊左乘uv矩陣的逆:

\[ {\begin{bmatrix} \Delta u_0 & \Delta v_0 \\ \Delta u_1 & \Delta v_1 \end{bmatrix}}^{-1} \begin{bmatrix} e_{0x} & e_{0y} & e_{0z} \\ e_{1x} & e_{1y} & e_{1z} \end{bmatrix} = \begin{bmatrix} T_x & T_y & T_z \\ B_x & B_y & B_z \end{bmatrix} \]

對於一個二階矩陣頂點求逆,咱們不考慮過程。已知有矩陣\(\mathbf{A} = \begin{bmatrix} a & b \\ c & d \end{bmatrix}\),那麼它的逆矩陣爲:

\[ \mathbf{A}^{-1} = \frac{1}{ad-bc}\begin{bmatrix} d & -b \\ -c & a \end{bmatrix} \]

所以上面的方程最終變成:

\[ \begin{bmatrix} T_x & T_y & T_z \\ B_x & B_y & B_z \end{bmatrix} = \frac{1}{\Delta u_0 \Delta v_1 - \Delta v_0 \Delta u_1} \begin{bmatrix} \Delta v_1 & - \Delta v_0 \\ -\Delta u_1 & \Delta u_0 \end{bmatrix} \begin{bmatrix} e_{0x} & e_{0y} & e_{0z} \\ e_{1x} & e_{1y} & e_{1z} \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)

求解過程以下:
\[ \mathbf{e_0} = \mathbf{V_1} - \mathbf{V_0} = (0.15, 0, 0.25) \]
\[ \mathbf{e_1} = \mathbf{V_2} - \mathbf{V_0} = (0.4, 0, 0.25) \]
\[ (\Delta u_0, \Delta v_0) = (u_1 - u_0, v_1 - v_0) = (0.3, -0.5) \]
\[ (\Delta u_1, \Delta v_1) = (u_2 - u_0, v_2 - v_0) = (0.8, -0.5) \]
\[ \begin{bmatrix} T_x & T_y & T_z \\ B_x & B_y & B_z \end{bmatrix} = \frac{1}{0.3 \times (-0.5) - (-0.5) \times 0.8} \begin{bmatrix} -0.5 & 0.5 \\ -0.8 & 0.3 \end{bmatrix} \begin{bmatrix} 0.15 & 0 & 0.25 \\ 0.4 & 0 & 0.25 \end{bmatrix} = \begin{bmatrix} 0.5 & 0 & 0 \\ 0 & 0 & -0.5 \end{bmatrix} \]

因爲位置座標和紋理座標的不一致性,致使求出來的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份量能夠留做他用,這裏暫不討論。

施密特向量正交化

一般頂點提供的NT一般是相互垂直的,而且都是單位向量,咱們能夠經過計算\(\mathbf{B} = \mathbf{N} \times \mathbf{T}\)來獲得副法線向量B,使得頂點能夠不須要存放副法線向量B。可是通過插值計算後的NT可能會致使不是相互垂直,咱們最好仍是要經過施密特正交化來得到實際的切線空間。

如今已知互不垂直的N向量和T向量,咱們但願求出與N向量垂直的T'向量,須要將T向量投影到N向量上。

從上面的圖咱們能夠知道最終求得的T'

\[ \mathbf{T'} = \lVert \mathbf{T} - (\mathbf{T} \cdot \mathbf{N}) \mathbf{N} \rVert \]

B' 最終也能夠肯定下來
\[ \mathbf{B'} = \mathbf{N} \times \mathbf{T'}\]

這樣T', B', N相互垂直,能夠構成TBN座標系。在後面的着色器實現中咱們也會用到這部份內容。

切線空間的變換

一開始的切線空間能夠用一個單位矩陣來表示,切線向量正是處在這個空間中。緊接着就是須要對其進行一次到局部對象(具體到某個三角形)切線空間的變換:

\[ \mathbf{M}_{object} = \begin{bmatrix} T_x & T_y & T_z \\ B_x & B_y & B_z \\ N_x & N_y & N_z \end{bmatrix} \]

而後切線向量隨同世界矩陣一同進行變換來到世界座標系,所以咱們能夠把它寫成:

\[ \mathbf{n}_{world} = \mathbf{n}_{tangent}\mathbf{M}_{object}\mathbf{M}_{world} \]

注意:

  1. 對切線向量進行矩陣變換,咱們只須要使用3x3的矩陣便可。
  2. 法線向量變換到世界矩陣須要用世界矩陣求逆的轉置進行校訂,而對切線向量只須要用世界矩陣變換便可。下圖演示了將寬度拉伸爲原來2倍後,法線和切線向量的變化:

HLSL代碼

爲了使用法線貼圖,咱們須要完成下列步驟:

  1. 獲取該紋理所須要用到的法線貼圖,在C++端爲其建立一個ID3D11Texture2D。這裏不考慮如何製做一張法線貼圖。
  2. 對於一個網格模型來講,頂點數據須要包含位置、法向量、切線向量、紋理座標四個元素。一樣這裏不討論模型的製做,在本教程使用的是Geometry所生成的網格模型
  3. 在頂點着色器中,將頂點法向量和切線向量從局部座標系變換到世界座標系
  4. 在像素着色器中,使用通過插值的法向量和切線向量來爲每一個三角形表面的像素點構建TBN座標系,而後將切線空間的法向量變換到世界座標系中,這樣最終求得的法向量用於光照計算。

如今咱們的Basic.hlsli沿用的是第23章動態天空盒的部分,變化以下:

Texture2D gDiffuseMap : register(t0);
Texture2D gNormalMap : register(t1);
TextureCube gTexCube : register(t2);
SamplerState gSam : 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 = gNormalMap.Sample(gSam, 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完整目錄

Github項目源碼

歡迎加入QQ羣: 727623616 能夠一塊兒探討DX11,以及有什麼問題也能夠在這裏彙報。

相關文章
相關標籤/搜索