1. 程式人生 > >[OpenGL] 紋理高階篇 - 法線貼圖

[OpenGL] 紋理高階篇 - 法線貼圖

        

概念引入

        對於三維渲染中的物體而言,出色的光影渲染往往能夠給畫面帶來質的飛躍提升。由光照方程可見,物體表面的法線對於最終的光照計算結果起著重要的作用,而物體的表面的頂點/面數則對光照沒有太大的影響——這為我們的一個想法提供了可能性,也就是說,我們可不可以通過高模來獲取法線,然後用低模渲染物體,並把高模的法線應用到物體上。此時,經過光照計算,呈現在我們眼前的就是高模下的光照細節表現,讓我們感覺模型似乎很精細。

Normal mapping in practice

                                                    (圖片來自網路)

        基於這個想法,法線貼圖誕生了。我們把法線存在一張圖片裡,通過紋理對映建立法線和畫素的對應關係,就能基本還原高模的法線分佈。比起高模,法線貼圖能夠在畫面效果不打折扣的條件下,大大減少記憶體視訊記憶體的佔用量,並提高渲染效率。

以下是不使用法線貼圖,使用頂點法線進行渲染的結果:

以下是使用法線貼圖進行渲染的結果:

將鏡頭拉近,我們可以觀察到豐富的表面細節:

法線的儲存與讀取

        法線是一個向量,經過歸一化計算後,分佈在[-1,1]之間,為了把它壓縮到[0,1]之內,需要做一個簡單的線性對映:

        color = (N + 1) / 2

        那麼類似的,從法線貼圖中解析法線的時候,要做一個逆運算:

        N = 2 * color - 1

        但是,對於解析後得到的法線,我們仍然不能直接使用它,因為出於一種不成文的規矩,我們的法線貼圖中的法線並不是記錄在世界座標系下的,而是儲存在一個特殊的座標系下,即切線空間中。

        切線空間是什麼?對於一個網格模型,我們逐頂點來分析,每個頂點都有著自己的切線空間,如下圖所示,我們可以將其稱為TBN空間。其中N代表該點處的法線,T(tangent)和B(binormal)都是該點處的切線。由於一個點處的切線有無數條,我們指定T切線是沿著紋理的u座標方向的,B切線是沿著紋理的v座標方向的。

        那麼,對於法線紋理中的法線,它是在TBN空間儲存的,具體可能是下圖的樣子。由圖可見圖中有兩個法線(N),一個是黑色的N,另一個是藍色的N',要注意區分這兩者。前者是實際使用的模型中,垂直於當前點的那個法線(點法線),而後者是從法線紋理中讀取的法線(畫素法線),也就是說,讀取的法線不總是垂直於點,而是在原法線的基礎上有一點偏移。

       在法線貼圖中,我們使用切線空間來儲存,這也就意味著我們可以很容易從其推匯出法線的偏移資訊,而這個偏移資訊是和具體的模型無關的,也就是說,當我們使用切線空間下的法線貼圖時,我們可以將一張貼圖應用於不同模型上,無論是球形,圓柱體或是正方體,甚至是更為複雜的模型。

TBN空間下的法線

計算TBN矩陣

       也許上面的解析不一定能夠完全理解,那麼可以試著一起動手計算一遍TBN矩陣,來更好地認識前文提及的一些概念。

       也就是說,我們需要在給出模型按照三角形排布的點集時,自動計算出它的法線以及兩條切線。此時,我們的輸入是網格中三角形三個點的模型空間座標,以及uv紋理座標

       (1) 計算面法線

        在已知三個點座標的情況下,面法線的計算非常簡單,只需要求三角形中兩個向量的叉積即可。在這個計算過程中,我們可能需要考慮到的一個問題是,垂直於一個面片的法線有兩個方向,我們需要保證我們求出的法線是實際我們需要的那個方向的法線。

        在OpenGL中,對於組成三角形的三個點對應的法線方向有著這麼一個規定,根據輸入的三個點的方向,按照右手定則,讓四指方向指向三個點的流動方向,大拇指的朝向即為法線方向。所以在計演算法線時,我們也需要按照這一規律進行計算。

        (2) 計算面切線

        由於第二條切線可以通過叉乘得到,在這裡我們只計算切線T。由於切線T取得沿著紋理座標u方向,所以我們實際上需要計算向量u。

       如上圖,向量e0和e1可以用模型空間下的座標來表示,即:

       e0 = vertex1.position - vetex0.position

       e1 = vertex2.position - vertex0.position

      也可以使用TBN空間作為基向量來表示:

       e0 = t1 * T + b1 * B

       e1 = t2 * T + b2 * B

       其中,t1,t2,b1,b2是向量之間的u,v差值。

       聯立以上方程組,可以求解出T,B兩個向量。我們最終保留切線T的計算結果。

        (3) 將面法/切線轉換到點法/切線

        由於我們的計算是基於面來計算的,所以我們得到的實際上是面法線和麵切線。但是,我們傳入頂點著色器中,應該為頂點法線和頂點切線。此處我們還需要經過一次處理,即對於每個點,求其鄰接面的面法線/切線的平均值。

       我們最終計算的程式碼如下:

struct VertexData
{
    QVector3D position;
    QVector3D tangent;
    QVector3D normal;
    QVector2D texture; // texcoord
    int adjoinPlane = 0;
};

void CalNormalAndTangent(VertexData& vertex0, VertexData& vertex1, VertexData& vertex2)
{
    float u0 = vertex0.texture.x();
    float v0 = vertex0.texture.y();

    float u1 = vertex1.texture.x();
    float v1 = vertex1.texture.y();

    float u2 = vertex2.texture.x();
    float v2 = vertex2.texture.y();

    float t1 = u1 - u0;
    float b1 = v1 - v0;

    float t2 = u2 - u0;
    float b2 = v2 - v0;

    QVector3D e0 = vertex1.position - vertex0.position;
    QVector3D e1 = vertex2.position - vertex0.position;

    float k = t1 * b2 - b1 * t2;

    QVector3D tangent;
    tangent = k * QVector3D(b2 * e0.x() - b1 * e1.x(),b2 * e0.y() - b1 * e1.y(),b2 * e0.z() - b1 * e1.z());

    QVector3D normal;
    normal = QVector3D::crossProduct(e0, e1);

    QVector<VertexData*> vertexArr = { &vertex0, &vertex1, &vertex2};
    for(int i = 0;i < vertexArr.size();i++)
    {
        vertexArr[i]->adjoinPlane++;
        float ratio = 1.0f / vertexArr[i]->adjoinPlane;
        vertexArr[i]->normal = vertexArr[i]->normal * (1 - ratio) + normal * ratio;
        vertexArr[i]->tangent = vertexArr[i]->tangent * (1 - ratio) + tangent * ratio;
    }
}

將法線貼圖從切線空間轉換到世界空間

        為了能夠應用法線的計算,我們需要統一計算的座標空間,我們有兩個選擇,一個是在切線空間下進行光照的計算,這意味著我們要把光照方向等向量轉換到切線空間,另一個是在世界空間上進行光照計算,這意味著我們要把法線轉換到世界座標系。但無論怎樣,我們都需要TBN矩陣參與座標空間的轉換運算。

       在此我們介紹將法線從切線空間轉換到世界空間的方法。

       (1) 頂點著色器

      在這裡,我們所做的事情包括像往常一樣的把頂點座標轉換到投影空間,並記錄頂點的世界座標,將世界座標、紋理座標、法線和切線傳遞到片元著色器。需要注意的是,我們之前求得的法線和切線都是模型座標系下的,我們也同樣要將它們轉換到世界座標系進行計算。

uniform mat4 ModelMatrix;
uniform mat4 IT_ModelMatrix;
uniform mat4 ViewMatrix;
uniform mat4 ProjectMatrix;

attribute vec4 a_position;
attribute vec3 a_normal;
attribute vec3 a_tangent;
attribute vec2 a_texcoord;

varying vec2 v_texcoord;
varying vec3 v_tangent;
varying vec3 v_normal;

varying vec3 worldPos;
void main()
{
    gl_Position = ModelMatrix * a_position;
    worldPos = vec3(gl_Position);
    gl_Position = ViewMatrix * gl_Position;
    gl_Position = ProjectMatrix * gl_Position;

    v_texcoord = a_texcoord;
    v_normal = mat3(IT_ModelMatrix) * a_normal;
    v_tangent = mat3(ModelMatrix) * a_tangent;
}

        (2) 片元著色器

        我們首先讀取法線,然後將法線進行空間的轉換,再像平時一樣做光照計算。其中,為了保證T一定垂直於N,需要在片元著色器中做一次矯正。然後通過叉乘得到B,以獲取TBN矩陣。


uniform sampler2D brick_N;
uniform sampler2D brick_D;

uniform vec3 LightLocation;
uniform vec3 cameraPos;
varying vec3 worldPos;

varying vec3 v_tangent;
varying vec3 v_normal;
varying vec2 v_texcoord;

vec3 UnpackNormal(vec3 normal)
{
    vec3 N = normalize(v_normal);
    vec3 T = normalize(v_tangent - N * v_tangent * N);
    vec3 B = cross(N, T);
    mat3 TBN = mat3(T,B,N);
    normal = normalize(2 * normal - 1);
    normal = normalize(TBN * normal);
    return normal;
}

void main()
{
    vec3 normal = texture2D(brick_N, v_texcoord);
    normal = UnpackNormal(normal);

    vec3 lightDir = normalize(LightLocation - worldPos);
    vec3 ViewDir = normalize(cameraPos - worldPos);
    float diffuse = 0.7 * clamp(dot(normal, lightDir), 0, 1);
    float ambient = 0.2;
    vec3 reflectDir = normalize(reflect(-lightDir,normal));
    float specular = pow(clamp(dot(reflectDir,ViewDir),0,1),5.0);

    vec3 color = texture2D(brick_D, v_texcoord);
    vec3 finalColor = color * ( specular +diffuse + ambient);

    gl_FragColor = vec4(finalColor, 1);
}