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,以及有什么问题也能够在这里汇报。

相关文章
相关标签/搜索