法線貼圖技術原理與實踐

法線貼圖

Blinn-Phong光照模型中的法線

在Blinn-Phong光照模型中,法線用於計算漫反射diffuse顏色和鏡面高光specular顏色。有兩種着色模型,逐頂點的高洛德着色(Gouraud Shading)和逐像素的Phong Shading。使用Gouraud Shading時,在vertex shader中,使用每個頂點的法線計算該頂點的diffuse和specular,然後在片元上插值。使用Phong Shading時,在fragment shader中,使用插值後的逐片元的法線計算該片元的diffuse和specular。使用頂點級的法線是很粗糙的,因爲對於低模,頂點數量有限,導致物體上使用法線計算出來的明暗的變化沒那麼細緻;而使用插值後的法線雖然效果要好一些,但是仍然缺少細節,因爲法線是插值得到的,頂點之間的片元的法線只能是從頂點法線光滑過渡得到,無法體現出豐富的方向。如果使用高模,則由於頂點數足夠多,通過光照可以得到比較豐富的明暗細節。

法線貼圖讓每個片元都有獨立的法線

高模雖然頂點夠多,細節夠豐富,但是渲染開銷太大。其實我們僅僅通過增加法線數量就可以得到比較豐富的明暗細節,在一定程度上達到高模的渲染效果。因此誕生了法線貼圖技術,將高模的法線細節保存到一張貼圖上,給低模使用。在片元着色器中,通過採樣法線貼圖,讓每個片元都有自己獨立的法線。這樣每個片元都可以獨立計算出光照明暗。效果比法線插值好得多。

法線存儲在什麼座標空間?

既然要把法線存儲到貼圖中,那麼使用哪個座標空間的法線呢?

世界空間

因爲光照往往在世界空間計算,一種想法是把世界空間的法線保存到法線貼圖中。然而這樣做法線的方向就定死了,物體只要一旋轉或非等比縮放,其頂點法線向量的世界座標就要變化,這樣就不能使用預先計算好的世界空間的法線了。

模型空間

那麼使用模型空間的法線行不行呢?畢竟頂點法線數據就是模型空間的。這樣仍然不夠靈活。比如一個立方體的六個面就需要六張法線貼圖,即便他們的細節是一樣的,但是對於每個面,其在模型空間中法線的朝向是不同的。

切線空間

圖形學的前輩們想到了一個靈活的方法,將法線存儲到切線空間。那麼什麼是切線空間呢?切線空間是每個頂點上的獨立的座標系,有TBN三個軸,其中N爲頂點的法線,而T,B分別爲頂點的切線和副切線。對於TBN座標系,T是X軸,B是Y軸,N是Z軸。每個頂點的TBN座標系都不一樣。如何在模型空間中確定每個頂點的TBN座標系的朝向呢?(TBN座標系原點所在的位置就是頂點的位置,一般不需要關心,因爲我們只變換方向)。對於N好辦,就是頂點的法線方向。而T和B互相垂直,且位於和N垂直的平面內,這個平面內互相垂直的向量有無數組,那麼如何確定T和B呢?
切線空間
考慮到頂點的UV座標恰好沿着互相垂直的兩個軸定義,所以我們可以沿着UV座標變化的方向確定T和B的方向。下面會說如何計算頂點的切線和副切線。另外某些文章會說切線空間是定義在三角形上的,這個沒錯,法線和切線本身就是針對面來說的,一個點並不存在法線。不過爲了使用VS和FS進行光照計算,我們才平均出了頂點法線,切線也是一樣的道理。如果某套渲染系統是以面爲單位進行渲染的,我們只需要計算出面的法線和切線。

將向量在切線空間和世界空間之間變換

TBN座標系中,N是座標系的z軸,因此N的值就是(0,0,1)。儘管每個頂點具有不同的模型空間的法線,但切線空間中的頂點法線N就是Z軸,因爲切線空間就是這麼定義的。同樣,TBN座標系中,切線T是x軸(1,0,0),副切線B是y軸(0,1,0)。可以將切線空間理解爲模型空間的子座標系,而每個頂點都有一個獨立的切線空間座標系。理解了這一點,才能理解如何構建切線空間和世界空間之間的變換矩陣。但是並非切線空間中所有的片元的法線都是對齊Z軸的,只有當片元法線和頂點法線方向一致是纔是,其他的片元法線方向是偏離開Z軸的。

從切線空間變換到世界空間

切線空間中,切線T是X軸,副切線B是Y軸,法線N是Z軸,這個三個向量都是單位向量。而我們定義在模型數據中的頂點的法線和切線都是在模型空間中的,而副切線可以由法線和切線的叉乘得到。根據空間變換矩陣的構造方法,對於向量右乘約定,構造一個3x3向量空間變換矩陣,將空間B中表示的空間A的三個軸,填入3x3矩陣的三個列,就得到了空間A到空間B的變換矩陣。(參考:談一談3D編程中的矩陣)因爲切線空間的這三個向量都是單位向量,而他們在模型空間中的值可認爲是將單位向量從切線空間變換到模型空間後得到,那麼將模型空間的T,B,N作爲矩陣的三個列即可得到切線空間到模型空間的變換矩陣。同樣,爲了得到切線空間到世界空間的變換矩陣,我們先將模型空間下的頂點切線,副切線和法線變換到世界空間,然後將世界空間的這三個向量填入矩陣的三列,就得到了切線空間到世界空間的變換矩陣。
glsl中的例子:

	vec3 worldNormal = normalize(a_Normal * mat3(u_world2Object));
    vec3 worldTangent = normalize(u_object2World*a_Tangent).xyz;
    vec3 worldBinormal = cross(worldNormal, worldTangent) * a_Tangent.w;

    //將TBN向量按列放入矩陣,構造出tangentToWorld矩陣
    //注意glsl中mat3是列主的
    mat3 tangentToWorld = mat3(worldTangent, worldBinormal, worldNormal);

從世界空間變換到切線空間

因爲我們構造的是座標系的變換矩陣,這是一個純旋轉矩陣,也是正交矩陣。因此逆矩陣就是轉置矩陣。所以從世界空間變換到切線空間的矩陣,就是從切線空間變換到世界空間的矩陣的轉置矩陣。對於向量右乘約定下的矩陣,我們將世界空間中的T,B,N向量按行填入矩陣即可。

	//將TBN向量按行放入矩陣,構造出worldToTangent矩陣
    //注意glsl中mat3是列主的
    mat3 worldToTangent = mat3(worldTangent.x, worldBinormal.x, worldNormal.x,
                               worldTangent.y, worldBinormal.y, worldNormal.y, 
                               worldTangent.z, worldBinormal.z, worldNormal.z);

存儲切線空間法線到貼圖

切線空間法線貼圖中存的是什麼?

需要認識到,切線空間的法線貼圖中存儲的是切線空間的法線。但是這並不是低模頂點的那些TBN空間中的N,那些N其實都是z軸,是(0,0,1),如果只有那些,是沒有意義的。法線貼圖的意義在於提供了遠多於頂點數目的法線。比如可以從高模得到這些法線,然後將其轉換到切線空間,這些法線就不是低模某個頂點的TBN空間的Z軸了,如果變換到這個頂點的TBN中,他就是一個偏離Z軸的方向。所以說,法線貼圖存放的是對法線的擾動,如果某個片元沒有擾動,法線值就是(0,0,1),代表了頂點原本的法線。如果有擾動,法線值在TBN空間中就是一個從Z軸偏移的方向,在模型空間中就是一個從頂點法線偏移的方向。

法線和RGB之間的映射

因爲法線向量x,y,z的範圍爲[-1,1],而貼圖顏色R,G,B的範圍是[0,1],因此需要進行一個簡單的映射,即 R|G|B = (x|y|z + 1)*0.5

爲什麼切線空間的法線貼圖是偏藍色?

對於某個片元,當法線貼圖沒有對其法線進行擾動時,切線空間的法線值是(0,0,1),映射到RGB是(0.5,0.5,1),這是一種淺藍色。當存在擾動時,基本上也只是x,y分量有一些偏移,幅度不會特別大,因此整個切線空間的法線貼圖看上去是偏藍色的。

法線貼圖壓縮

因爲切線空間的法線都是朝向平面外的,z值總爲正,因此可以只保存x,y,然後通過z=sqrt(1-(x*x+y*y))計算出z。

計算模型的頂點切線

模型數據不一定自帶頂點切線數據,所以往往需要自己計算出頂點的切線。類似於法線的計算,我們先計算出三角面的切線,然後對於頂點,計算其所屬的各個面的切線的平均值。那麼如何計算出三角面的切線呢?簡單介紹一下原理。
計算切線
在上圖中,紅色向量爲切線T,綠色爲副切線B。因爲T和B是TB平面的座標軸,因此可以用T和B線性表出E1和E2:
E1 = deltaU1 * T + deltaV1 * B
E2 = deltaU2 * T + deltaV2 * B
而在模型空間中,E1和E2可以由頂點座標計算出來,代入上式左邊後,得到了一組線性方程,可以轉化成矩陣乘法並通過計算逆矩陣就可以獲得T和B。
參考代碼:

	static _calcFaceTangent(p0, p1, p2, uv0, uv1, uv2){
        let edge1 = Vector3.sub(p1, p0, new Vector3());
        let edge2 = Vector3.sub(p2, p0, new Vector3());
        let deltaUV1 = Vector3.sub(uv1, uv0, new Vector3());
        let deltaUV2 = Vector3.sub(uv2, uv0, new Vector3());
        let f = 1.0 / (deltaUV1.x * deltaUV2.y - deltaUV2.x * deltaUV1.y);
        let tangent = new Vector3();
        tangent.x = f * (deltaUV2.y * edge1.x - deltaUV1.y * edge2.x);
        tangent.y = f * (deltaUV2.y * edge1.y - deltaUV1.y * edge2.y);
        tangent.z = f * (deltaUV2.y * edge1.z - deltaUV1.y * edge2.z);
        tangent.normalize();

        //compute binormal
        let binormal = new Vector3();
        binormal.x = f * (-deltaUV2.x * edge1.x + deltaUV1.x * edge2.x);
        binormal.y = f * (-deltaUV2.x * edge1.y + deltaUV1.x * edge2.y);
        binormal.z = f * (-deltaUV2.x * edge1.z + deltaUV1.x * edge2.z);
        binormal.normalize();

        //計算tangent和binormal的叉積,如果得到的向量和normal是反向的
        //則將tangent.w設置爲-1,在shader裏面用這個w將計算出來的binormal反向
        //注:這兒計算的並不會反向,但是如果是外部導入的切線,計算時的座標系的手向不同是可能反向的
        //保留這段代碼主要是演示作用,此處計算的tanget的w總是1

        let crossTB = new Vector3();
        Vector3.cross(tangent, binormal, crossTB);
        let normal = new Vector3();
        Vector3.cross(edge1, edge2, normal);
        if(Vector3.dot(crossTB, normal)<0){
            tangent.w = -1;          
        } else {
            tangent.w = 1;
        }

        return tangent;
    }

實際上只需要計算切線即可,副切線在shader中通過法線和切線的叉乘計算。但是一般會根據手向性計算出切線的w值爲-1或1。用於計算副切線時確定方向。

使用法線貼圖計算光照

經過上面的討論,我們弄明白了切線空間是什麼,法線貼圖中存儲的是啥,爲什麼使用切線空間,以及如何在切線空間和世界空間之間進行變換。並且我們還簡單瞭解瞭如何計算出模型的頂點切線。現在我們只需從法線貼圖中獲取到片元的法線,然後將光照需要的向量變換到一個統一的空間,就可以基於法線貼圖進行光照計算。那麼一般有兩個方案,在切線空間計算以及在世界空間計算。

在切線空間中計算

因爲法線貼圖中存儲的就是切線空間的法線,因此只要將光線方向和視線方向變換到切線空間就可以進行光照計算。而變換光線和視線可以在VS中完成,效率較高。然後在FS中,從法線貼圖中獲取切線空間的法線,並在切線空間中計算光照。這個方法中,需要將世界空間的光線方向和視線方向變換到切線空間,因此需要計算世界空間到切線空間的變換矩陣,這在上面已經討論過。另外從法線貼圖中獲取法線,需要從RGB空間轉換到向量空間,而法線在存儲時進行了向量到RGB的映射,因此此處需要反映射。另外如果法線貼圖是壓縮存儲的,還需要解壓。
WebGL1.0/OpenGL ES2.x glsl shader代碼:

  • vertex shader
attribute vec4 a_Position;
attribute vec3 a_Normal;
attribute vec4 a_Tangent;
attribute vec2 a_Texcoord;
    
uniform mat4 u_mvpMatrix;
uniform mat4 u_object2World;
uniform mat4 u_world2Object;
uniform vec4 u_texMain_ST; // Main texture tiling and offset
uniform vec4 u_normalMap_ST; // Normal map tiling and offset
uniform vec3 u_worldCameraPos; // world space camera position
uniform vec4 u_worldLightPos;   // World space light direction or position, if w==0 the light is directional

varying vec3 v_tangentLightDir; // tangent space light dir
varying vec3 v_tangentViewDir; // tangent space view dir
varying vec4 v_texcoord;
varying float v_atten;

void main(){
    gl_Position = u_mvpMatrix * a_Position;   
    v_texcoord.xy = a_Texcoord.xy * u_texMain_ST.xy + u_texMain_ST.zw;
    v_texcoord.zw = a_Texcoord.xy * u_normalMap_ST.xy + u_normalMap_ST.zw;

    vec3 worldNormal = normalize(a_Normal * mat3(u_world2Object));
    vec3 worldTangent = normalize(u_object2World*a_Tangent).xyz;
    vec3 worldBinormal = cross(worldNormal, worldTangent) * a_Tangent.w;

    //將TBN向量按行放入矩陣,構造出worldToTangent矩陣
    //注意glsl中mat3是列主的
    mat3 worldToTangent = mat3(worldTangent.x, worldBinormal.x, worldNormal.x,
                               worldTangent.y, worldBinormal.y, worldNormal.y, 
                               worldTangent.z, worldBinormal.z, worldNormal.z);

    vec4 worldPos = u_object2World*a_Position;
    vec3 worldViewDir = normalize(u_worldCameraPos - worldPos.xyz);
    v_tangentViewDir = worldToTangent * worldViewDir;

    vec3 worldLightDir;
    v_atten = 1.0;
    if(u_worldLightPos.w==1.0){ //點光源
        vec3 lightver = u_worldLightPos.xyz - worldPos.xyz;
        float dis = length(lightver);
        worldLightDir = normalize(lightver);
        vec3 a = vec3(0.01);
        v_atten = 1.0/(a.x + a.y*dis + a.z*dis*dis);
    } else {
        worldLightDir = normalize(u_worldLightPos.xyz);
    }
    v_tangentLightDir = worldToTangent * worldLightDir;
}
  • fragment shader
#ifdef GL_ES
precision mediump float;
#endif

uniform vec3 u_LightColor; // Light color

uniform sampler2D u_texMain;
uniform sampler2D u_normalMap;

uniform vec3 u_colorTint;
#ifdef USE_AMBIENT
uniform vec3 u_ambient; // scene ambient
#endif
uniform vec3 u_specular; // specular
uniform float u_gloss; //gloss

varying vec3 v_tangentLightDir; // tangent space light dir
varying vec3 v_tangentViewDir; // tangent space view dir
varying vec4 v_texcoord;
varying float v_atten;

void main(){        
    vec3 tangentLightDir = normalize(v_tangentLightDir);
    vec3 tangentViewDir = normalize(v_tangentViewDir);

#ifdef PACK_NORMAL_MAP
    vec4 packedNormal = texture2D(u_normalMap, v_texcoord.zw);
    vec3 tangentNormal;
    tangentNormal.xy = packedNormal.xy * 2.0 - 1.0;
    tangentNormal.z = sqrt(1.0 - clamp(dot(tangentNormal.xy, tangentNormal.xy), 0.0, 1.0));
#else
    vec3 tangentNormal = texture2D(u_normalMap, v_texcoord.zw).xyz * 2.0 - 1.0;
#endif
    
    vec3 albedo = texture2D(u_texMain, v_texcoord.xy).rgb * u_colorTint;
    vec3 diffuse = u_LightColor * albedo * max(0.0, dot(tangentNormal, tangentLightDir));

#ifdef LIGHT_MODEL_PHONG
    vec3 reflectDir = normalize(reflect(-tangentLightDir, tangentNormal));
    vec3 specular = u_LightColor * u_specular * pow(max(0.0, dot(reflectDir,tangentViewDir)), u_gloss);
#else
    vec3 halfDir = normalize(tangentLightDir + tangentViewDir);
    vec3 specular = u_LightColor * u_specular * pow(max(0.0, dot(tangentNormal,halfDir)), u_gloss);
#endif    

#ifdef USE_AMBIENT
    vec3 ambient = u_ambient * albedo;
    gl_FragColor = vec4(ambient + (diffuse + specular) * v_atten, 1.0);
#else
    gl_FragColor = vec4((diffuse + specular) * v_atten, 1.0);
#endif
}

在世界空間中計算

在切線空間計算光照很方便,但是有時需要在世界空間做某些計算,例如採樣cube map實現反射,這樣就需要在世界空間進行光照。那麼我們需要將所有的向量統一到世界空間,因爲光線方向,視線方向通常都是在世界空間計算出來的,我們主要需要把法線貼圖中的切線空間的法線變換到世界空間,因此我們需要切線空間到世界空間的變換矩陣,這在上面也已經推導過了,我們在VS中計算這個矩陣,並將這個矩陣傳到FS中。而其他的計算方法和在切線空間是一樣的。這裏直接給出參考的WebGL1.0/OpenGLES2.x的glsl shader代碼:

  • vertex shader
attribute vec4 a_Position;
attribute vec3 a_Normal;
attribute vec4 a_Tangent;
attribute vec2 a_Texcoord;
    
uniform mat4 u_mvpMatrix;
uniform mat4 u_object2World;
uniform mat4 u_world2Object;
uniform vec4 u_texMain_ST; // Main texture tiling and offset
uniform vec4 u_normalMap_ST; // Normal map tiling and offset

// Tangent to World 3x3 matrix and worldPos
// 每個vec4的xyz是矩陣的一行,w存放了worldPos
varying vec4 v_TtoW0;
varying vec4 v_TtoW1;
varying vec4 v_TtoW2;
varying vec4 v_texcoord;

void main(){
    gl_Position = u_mvpMatrix * a_Position;   
    v_texcoord.xy = a_Texcoord.xy * u_texMain_ST.xy + u_texMain_ST.zw;
    v_texcoord.zw = a_Texcoord.xy * u_normalMap_ST.xy + u_normalMap_ST.zw;

    vec3 worldNormal = normalize(a_Normal * mat3(u_world2Object));
    vec3 worldTangent = normalize(u_object2World*a_Tangent).xyz;
    vec3 worldBinormal = cross(worldNormal, worldTangent) * a_Tangent.w;    
    vec4 worldPos = u_object2World*a_Position;
    
    //TBN向量按列放入矩陣,構造出 TangentToWorld矩陣,使用三個向量保存矩陣的三行,傳入fs
    //同時將worldPos存入三個向量的w中
    v_TtoW0 = vec4(worldTangent.x, worldBinormal.x, worldNormal.x, worldPos.x);
    v_TtoW1 = vec4(worldTangent.y, worldBinormal.y, worldNormal.y, worldPos.y);
    v_TtoW2 = vec4(worldTangent.z, worldBinormal.z, worldNormal.z, worldPos.z);
}
  • fragment shader
#ifdef GL_ES
precision mediump float;
#endif

uniform vec3 u_LightColor; // Light color

uniform sampler2D u_texMain;
uniform sampler2D u_normalMap;

uniform vec3 u_worldCameraPos; // world space camera position
uniform vec4 u_worldLightPos;   // World space light direction or position, if w==0 the light is directional

uniform vec3 u_colorTint;
#ifdef USE_AMBIENT
uniform vec3 u_ambient; // scene ambient
#endif
uniform vec3 u_specular; // specular
uniform float u_gloss; //gloss

varying vec4 v_TtoW0;
varying vec4 v_TtoW1;
varying vec4 v_TtoW2;
varying vec4 v_texcoord;

void main(){    
    vec3 worldPos = vec3(v_TtoW0.w, v_TtoW1.w, v_TtoW2.w);

    vec3 worldViewDir = normalize(u_worldCameraPos - worldPos.xyz);

    vec3 worldLightDir;
    float atten = 1.0;
    if(u_worldLightPos.w==1.0){ //點光源
        vec3 lightver = u_worldLightPos.xyz - worldPos.xyz;
        float dis = length(lightver);
        worldLightDir = normalize(lightver);
        vec3 a = vec3(0.01);
        atten = 1.0/(a.x + a.y*dis + a.z*dis*dis);
    } else {
        worldLightDir = normalize(u_worldLightPos.xyz);
    }

#ifdef PACK_NORMAL_MAP
    vec4 packedNormal = texture2D(u_normalMap, v_texcoord.zw);
    vec3 normal;
    normal.xy = packedNormal.xy * 2.0 - 1.0;
    normal.z = sqrt(1.0 - clamp(dot(normal.xy, normal.xy), 0.0, 1.0));
#else
    vec3 normal = texture2D(u_normalMap, v_texcoord.zw).xyz * 2.0 - 1.0;
#endif
    //Transform the normal from tangent space to world space
    normal = normalize(vec3(dot(v_TtoW0.xyz, normal), dot(v_TtoW1.xyz, normal), dot(v_TtoW2.xyz, normal)));
    
    vec3 albedo = texture2D(u_texMain, v_texcoord.xy).rgb * u_colorTint;
    vec3 diffuse = u_LightColor * albedo * max(0.0, dot(normal, worldLightDir));

#ifdef LIGHT_MODEL_PHONG
    vec3 reflectDir = normalize(reflect(-worldLightDir, normal));
    vec3 specular = u_LightColor * u_specular * pow(max(0.0, dot(reflectDir,worldViewDir)), u_gloss);
#else
    vec3 halfDir = normalize(worldLightDir + worldViewDir);
    vec3 specular = u_LightColor * u_specular * pow(max(0.0, dot(normal,halfDir)), u_gloss);
#endif    

#ifdef USE_AMBIENT
    vec3 ambient = u_ambient * albedo;
    gl_FragColor = vec4(ambient + (diffuse + specular) * atten, 1.0);
#else
    gl_FragColor = vec4((diffuse + specular) * atten, 1.0);
#endif
}

參考資料

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章