切線空間(Tangent Space) 的計算與應用

轉:http://windsmoon.com/2017/11/28/%E5%88%87%E7%BA%BF%E7%A9%BA%E9%97%B4-Tangent-Space-%E7%9A%84%E8%AE%A1%E7%AE%97%E4%B8%8E%E5%BA%94%E7%94%A8/?utm_medium=social&utm_source=ZHShareTargetIDMore

概要

本篇文章主要講解計算機圖形學中切線空間是如何計算的,且會以法線貼圖的例子來驗證切線空間是否計算正確,以及展現切線空間的用途.

本文需要讀者掌握一定的 3D 座標空間變換和簡單光照相關的知識,以及法線貼圖的基本知識(但切線空間不僅僅只用於法線貼圖)。

認識切線空間

什麼是切線空間

切線空間 (Tangent Space) 與 世界空間 (World Space) 和 觀察空間 (View Space) 一樣,都是一個座標空間,它是由頂點所構成的平面的 UV 座標軸以及表面的法線所構成,一般用 T (Tangent), B (Bitangent), N (Normal) 三個字母表示,即切線,副切線,法線, TT 對應 UV 中的 UU, BB 對應 UV 中的 VV,下圖是切線空間的示意圖:

這裏可能會有一個疑問,就是爲什麼 TT 對應 UV 中的 UU, BB 對應 UV 中的 VV 。理論上,只要 TT 和 BB 垂直且都位於三角形的平面內,就可以達到使用切線空間的目的,因爲這樣我們總可以把所有需要的數據變換到同一個座標空間下,但由於我們知道 UV 座標的值,所以用 UV 座標來對應 TT 和 BB 計算出數據了。

爲什麼要有切線空間

要理解爲什麼要有切線空間,可以從法線貼圖入手。衆所周知,絕大部分的法線貼圖,顏色都是偏藍色的,這是因爲法線貼圖中存儲的法線向量大部分都是朝向或者接近 z 軸的,即 (0,0,1)(0,0,1),換算到 RGB 中,就是偏向藍色,即 (0.5,0,5,1)(0.5,0,5,1) (後面的 Shader 中有算法),這種貼圖就是切線空間 (Tangent Space)下的貼圖。這顯然存在一個問題,想象一個位於世界座標原點且沒有進行任何變換的立方體,表面法線方向就有 6 個,因爲有 6 個不同朝向的面(確切的說,可能是 12 個面,因爲一個矩形一般由兩個三角形組成),而且每個面完全相同,所以這時候我應該只需要一個面的法線貼圖就可以了。但其實這時再用這種偏藍色的法線貼圖就不行了,因爲立方體的上表面在世界空間的法線方向爲 (0,1,0)(0,1,0),而在法線貼圖中採樣出來的法線基本都是接近於 (0,0,1)(0,0,1) 的,使用錯誤的法線會得到錯誤的光照結果。所以這時候需要做一張包含立方體所有面的法線信息的法線貼圖,也就是模型空間 (Object Space)下的法線貼圖,而這種貼圖看起來就不單單是偏藍色了,而是包含了多種顏色。

這樣看起來好像也沒什麼問題,但其實用切線空間下的法線貼圖要比用模型空間下的法線貼圖要有一些優勢:

  • 可以複用:比如上文提到的立方體,如果每個面都完全相同,則可以只製作一個面的法線貼圖,然後就可以複用到所有面上,類似的複用需求還有很多,這可以減小內存佔用和包體大小。
  • 紋理可以壓縮:因爲切線空間下,一般來說法線方向不會是朝向表面內部,即法線貼圖中的 z 值不會是負數,而我們使用的法線又是歸一化的,所以完全可以根據 x 和 y 的值來推導出 z 的值,所以貼圖中只需要存儲 x 和 y 的值即可,可進行紋理壓縮。
  • 待補充

綜上所述,一般的法線貼圖都是使用切線空間的,而直接使用切線空間下的法線貼圖又會出現之前提到的立方體的那個問題,所以我們在使用前需要先進行切線空間相關的變換,把所需要的數據變換到同一個座標空間下再進行計算(可以全部變換到世界空間也可以全部變換到切線空間)。

切線空間的計算

求切線和副切線

要進行切線空間相關的計算,需要先求出構成切線空間三個軸的單位基向量,然後就可以構造出從切線空間變換到世界空間的矩陣,從而進行之後的計算。

切線空間的計算可以通過前面的示意圖來理解,這裏爲了方便,再放一次:

設:

\Delta U_{1}= U_{2} - U_{1}

\Delta U_{2}= U_{3} - U_{1}

\Delta V_{1}= V_{2} - V_{1}

\Delta V_{2}= V_{3} - V_{1}

 

則由圖和共面向量基本定理可知:

E_{1} = \Delta U_{1}T + \Delta V_{1}B

E_{2} = \Delta U_{2}T + \Delta V_{2}B

\Rightarrow (E_{1}x, E_{1}y, E_{1}z) = \Delta U_{1}(T_{x},T_{y},T_{z}) + \Delta V_{1}(B_{x},B_{y},B_{z})

\Rightarrow (E_{2}x, E_{2}y, E_{2}z) = \Delta U_{2}(T_{x},T_{y},T_{z}) + \Delta V_{2}(B_{x},B_{y},B_{z})

觀察這兩個等式,我們發現這其實可以寫成矩陣乘法的形式,如下所示:

\begin{pmatrix} E_{1}x & E_{1}y & E_{1}z\\ E_{2}x & E_{2}y & E_{2}z \end{pmatrix} = \begin{pmatrix} \Delta U_{1} & \Delta V_{1}\\\Delta U_{2} & \Delta V_{2} \end{pmatrix} \begin{pmatrix} T_{x} & T_{y} & T_{z}\\B_{x} & B_{y} & B_{z} \end{pmatrix}

如果你求解一下等號右邊的矩陣乘法,你就會發現,他就是我們在上面得到的等式。根據這個矩陣形式的等式,我們不難求解 TB 矩陣,只需要兩邊同時左乘 ΔUΔV 的逆矩陣,再進行計算即可,步驟如下:

\begin{pmatrix} \Delta U_{1} & \Delta V_{1}\\\Delta U_{2} & \Delta V_{2} \end{pmatrix}^{-1}\begin{pmatrix} E_{1}x & E_{1}y & E_{1}z\\ E_{2}x & E_{2}y & E_{2}z \end{pmatrix} = \begin{pmatrix} T_{x} & T_{y} & T_{z}\\B_{x} & B_{y} & B_{z} \end{pmatrix}

逆矩陣的計算公式爲 矩陣的行列式的值的倒數再乘以它的伴隨矩陣 (Adjugate Matrix, 如果對這些概念不熟悉需要讀者自行查閱),其實伴隨矩陣的求解並不容易,不過 二階矩陣的伴隨矩陣 有一個簡單的公式,即 主對角線的元素互換,副對角線的元素乘以 −1 ,所以最終結果如下所示:

\begin{pmatrix} T_{x} & T_{y} & T_{z}\\B_{x} & B_{y} & B_{z} \end{pmatrix} =\frac{1 }{\Delta U_{1}\Delta V_{2}-\Delta U_{2}\Delta V_{1}} \begin{pmatrix} \Delta V_{2} & -\Delta V_{1}\\-\Delta U_{2} & \Delta U_{1} \end{pmatrix}\begin{pmatrix} E_{1}x & E_{1}y & E_{1}z\\ E_{2}x & E_{2}y & E_{2}z \end{pmatrix}

似乎我們還缺少 E1和 E2 的信息,但其實這個信息是已知的,因爲他們就是三角形的兩個邊,而三角形的頂點座標是我們知道的,所以求出 T 和 B 所需的數據我們都已經有了,只需要代入公式就可以了。

設:

ratio=\frac{1}{\Delta U_{1}\Delta V_{2}-\Delta U_{2}\Delta V_{1}}

則:

T_{x}=ratio*(\Delta V_{2}E_{1}x-\Delta V_{1}E_{2}x)

T_{y}=ratio*(\Delta V_{2}E_{1}y-\Delta V_{1}E_{2}y)

T_{z}=ratio*(\Delta V_{2}E_{1}z-\Delta V_{1}E_{2}z)

B 也可以如此求解,但其實只需要用 T 和 法線向量 叉乘 即可。

歸一化

因爲 E1 和 E2是用頂點座標表示的,而 U 和 V 是紋理座標,他們的座標單位是不同的,所以我們求出的結果自然不太可能是已經歸一化了的,而我們使用座標空間轉換矩陣的時候需要的是歸一化的座標,所以我們需要進行歸一化。

法線貼圖的例子

本節將以一個法線貼圖的例子,來展示切線空間是如何工作的,在這個例子中,我只計算了漫反射等顏色(因爲除了法線貼圖外我只找到一張漫反射的貼圖,但足夠演示用了,不過光照效果看起來未必會很好),下面兩張圖是我使用的漫反射貼圖和法線貼圖:

 

計算頂點數據

爲了方便展示,我準備了一個立方體的頂點數據,一共有36個頂點(6個面,每個面2個三角形),爲了這篇文章的編寫方便,我採用直接繪製頂點而非索引的方式,並且之後的一些計算會有些暴力。

36個頂點數據如下所示,每一行分別爲頂點座標(3個),法線向量(3個),以及紋理座標(2個),每6行爲一個面。

float vertices[] = {

-0.5f, -0.5f, -0.5f, 0.0f, 0.0f, -1.0f, 0.0f, 0.0f,

0.5f, -0.5f, -0.5f, 0.0f, 0.0f, -1.0f, 1.0f, 0.0f,

0.5f, 0.5f, -0.5f, 0.0f, 0.0f, -1.0f, 1.0f, 1.0f,

0.5f, 0.5f, -0.5f, 0.0f, 0.0f, -1.0f, 1.0f, 1.0f,

-0.5f, 0.5f, -0.5f, 0.0f, 0.0f, -1.0f, 0.0f, 1.0f,

-0.5f, -0.5f, -0.5f, 0.0f, 0.0f, -1.0f, 0.0f, 0.0f,

-0.5f, -0.5f, 0.5f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f,

0.5f, -0.5f, 0.5f, 0.0f, 0.0f, 1.0f, 1.0f, 0.0f,

0.5f, 0.5f, 0.5f, 0.0f, 0.0f, 1.0f, 1.0f, 1.0f,

0.5f, 0.5f, 0.5f, 0.0f, 0.0f, 1.0f, 1.0f, 1.0f,

-0.5f, 0.5f, 0.5f, 0.0f, 0.0f, 1.0f, 0.0f, 1.0f,

-0.5f, -0.5f, 0.5f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f,

-0.5f, 0.5f, 0.5f, -1.0f, 0.0f, 0.0f, 1.0f, 0.0f,

-0.5f, 0.5f, -0.5f, -1.0f, 0.0f, 0.0f, 1.0f, 1.0f,

-0.5f, -0.5f, -0.5f, -1.0f, 0.0f, 0.0f, 0.0f, 1.0f,

-0.5f, -0.5f, -0.5f, -1.0f, 0.0f, 0.0f, 0.0f, 1.0f,

-0.5f, -0.5f, 0.5f, -1.0f, 0.0f, 0.0f, 0.0f, 0.0f,

-0.5f, 0.5f, 0.5f, -1.0f, 0.0f, 0.0f, 1.0f, 0.0f,

0.5f, 0.5f, 0.5f, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f,

0.5f, 0.5f, -0.5f, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f,

0.5f, -0.5f, -0.5f, 1.0f, 0.0f, 0.0f, 0.0f, 1.0f,

0.5f, -0.5f, -0.5f, 1.0f, 0.0f, 0.0f, 0.0f, 1.0f,

0.5f, -0.5f, 0.5f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f,

0.5f, 0.5f, 0.5f, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f,

-0.5f, -0.5f, -0.5f, 0.0f, -1.0f, 0.0f, 0.0f, 1.0f,

0.5f, -0.5f, -0.5f, 0.0f, -1.0f, 0.0f, 1.0f, 1.0f,

0.5f, -0.5f, 0.5f, 0.0f, -1.0f, 0.0f, 1.0f, 0.0f,

0.5f, -0.5f, 0.5f, 0.0f, -1.0f, 0.0f, 1.0f, 0.0f,

-0.5f, -0.5f, 0.5f, 0.0f, -1.0f, 0.0f, 0.0f, 0.0f,

-0.5f, -0.5f, -0.5f, 0.0f, -1.0f, 0.0f, 0.0f, 1.0f,

-0.5f, 0.5f, -0.5f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f,

0.5f, 0.5f, -0.5f, 0.0f, 1.0f, 0.0f, 1.0f, 1.0f,

0.5f, 0.5f, 0.5f, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f,

0.5f, 0.5f, 0.5f, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f,

-0.5f, 0.5f, 0.5f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f,

-0.5f, 0.5f, -0.5f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f

};

 

 

我們還要給每一行再增加6個數據,即 TT 和 BB 各 3 個座標。上面一共有 288 個浮點數,讓我直接再寫 216 個會累死我的,所以切線空間的數據我直接用代碼算出來了,實際使用過程中也許在導入模型的時候就可以直接導入切線空間了,當然沒有也沒關係,因爲我已經講了如何計算切線空間,並且這裏也給出了一個例子。下面的代碼就是計算切線空間的代碼,就如之前說的,很暴力,我並沒有多寫幾個循環來減少幾行代碼,何必呢。

float tbnFloats[216]; // 36 個頂點的切線和副切線向量一共有 216 個浮點數

// 一個立方體一共有 12 個三角形面(每 2 個構成一個立方體面)

for (int i = 0; i < 12; ++i)

{

Vector3 tbn;

int firstIndex = i * 24; // 三角形第 1 個頂點座標起始索引

int secondIndex = firstIndex + 8; // 三角形第 2 個頂點座標起始索引

int thirdIndex = secondIndex + 8; // 三角形第 3 個頂點座標起始索引

// 求得一個三角形的三個頂點座標

Vector3 pos1(vertices[firstIndex], vertices[firstIndex + 1], vertices[firstIndex + 2]);

Vector3 pos2(vertices[secondIndex], vertices[secondIndex + 1], vertices[secondIndex + 2]);

Vector3 pos3(vertices[thirdIndex], vertices[thirdIndex + 1], vertices[thirdIndex + 2]);

// 求得一個三角形的三個頂點對應的 UV 座標

Vector2 uv1(vertices[firstIndex + 6], vertices[firstIndex + 7]);

Vector2 uv2(vertices[secondIndex + 6], vertices[secondIndex + 7]);

Vector2 uv3(vertices[thirdIndex + 6], vertices[thirdIndex + 7]);

// 求出三角形的兩條邊的向量以及 UV 座標之間的差向量,用於代入公式

// 需要注意的是,當表示 UV 座標時,x 對應 U,y 對應 V

Vector3 edge1 = pos2 - pos1;

Vector3 edge2 = pos3 - pos1;

Vector2 deltaUV1 = uv2 - uv1;

Vector2 deltaUV2 = uv3 - uv1;

// 計算切線和副切線向量

// 其實這裏就是套用上面求出來的公式

Vector3 tangent;

Vector3 bitTangent;

GLfloat f = 1.0f / (deltaUV1.x * deltaUV2.y - deltaUV2.x * deltaUV1.y);

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 = glm::normalize(tangent);

bitTangent.x = f * (-deltaUV2.x * edge1.x + deltaUV1.x * edge2.x);

bitTangent.y = f * (-deltaUV2.x * edge1.y + deltaUV1.x * edge2.y);

bitTangent.z = f * (-deltaUV2.x * edge1.z + deltaUV1.x * edge2.z);

bitTangent = glm::normalize(bitTangent);

// 將每個三角形頂點的切線和副切線數據放到數組裏

int startTBNIndex = i * 18;

tbnFloats[startTBNIndex + 0] = tangent.x;

tbnFloats[startTBNIndex + 1] = tangent.y;

tbnFloats[startTBNIndex + 2] = tangent.z;

tbnFloats[startTBNIndex + 3] = bitTangent.x;

tbnFloats[startTBNIndex + 4] = bitTangent.y;

tbnFloats[startTBNIndex + 5] = bitTangent.z;

tbnFloats[startTBNIndex + 6] = tangent.x;

tbnFloats[startTBNIndex + 7] = tangent.y;

tbnFloats[startTBNIndex + 8] = tangent.z;

tbnFloats[startTBNIndex + 9] = bitTangent.x;

tbnFloats[startTBNIndex + 10] = bitTangent.y;

tbnFloats[startTBNIndex + 11] = bitTangent.z;

tbnFloats[startTBNIndex + 12] = tangent.x;

tbnFloats[startTBNIndex + 13] = tangent.y;

tbnFloats[startTBNIndex + 14] = tangent.z;

tbnFloats[startTBNIndex + 15] = bitTangent.x;

tbnFloats[startTBNIndex + 16] = bitTangent.y;

tbnFloats[startTBNIndex + 17] = bitTangent.z;

}

 

 

接着我們將兩個數組合併成一個新的頂點數組:

float finishVertices[504];

// 一共 36 個頂點,按照特定順序合併即可

for (int i = 0; i < 36; ++i)

{

int finishStartIndex = i * 14;

int verticesStartIndex = i * 8;

int tbnStartIndex = i * 6;

finishVertices[finishStartIndex + 0] = vertices[verticesStartIndex + 0];

finishVertices[finishStartIndex + 1] = vertices[verticesStartIndex + 1];

finishVertices[finishStartIndex + 2] = vertices[verticesStartIndex + 2];

finishVertices[finishStartIndex + 3] = vertices[verticesStartIndex + 3];

finishVertices[finishStartIndex + 4] = vertices[verticesStartIndex + 4];

finishVertices[finishStartIndex + 5] = vertices[verticesStartIndex + 5];

finishVertices[finishStartIndex + 6] = vertices[verticesStartIndex + 6];

finishVertices[finishStartIndex + 7] = vertices[verticesStartIndex + 7];

finishVertices[finishStartIndex + 8] = tbnFloats[tbnStartIndex + 0];

finishVertices[finishStartIndex + 9] = tbnFloats[tbnStartIndex + 1];

finishVertices[finishStartIndex + 10] = tbnFloats[tbnStartIndex + 2];

finishVertices[finishStartIndex + 11] = tbnFloats[tbnStartIndex + 3];

finishVertices[finishStartIndex + 12] = tbnFloats[tbnStartIndex + 4];

finishVertices[finishStartIndex + 13] = tbnFloats[tbnStartIndex + 5];

}

 

 

這樣我們就有一個新的包含 504 個浮點數的數組了,把數組抽象成行列的形式,每個頂點一行,每一行從左到右的形式是這樣的:頂點座標(3個),法線向量(3個),以及紋理座標(2個),切線向量(3個),副切線向量(3個)。

但其實我這裏多了一步,就是當我們求出切線後,只需要讓其和三角形表面法線 叉乘 即可,因爲他們都是互相垂直的,不過我這裏沒這麼寫。

不使用切線空間

這個例子使用 OpenGL 編寫,我沒有全部給出代碼,比如如何將這些數據傳給 Shader 等,這些對於本篇文章並不重要,也不是本篇文章所要講的,我在這裏會直接給出相關的 Shader 片段,我覺得這就足夠了。例子裏只有一個立方體,一個平行光,且平行光垂直於立方體朝向世界座標 Z 軸的一面,而法線貼圖採用切線空間下的法線貼圖,也就是看起來偏藍色的法線貼圖,這意味着大部分法線值都是偏向正 Z 軸的。

首先我們不使用法線貼圖,只使用頂點數組裏的頂點法線,來觀察一下它的樣子,如下圖所示:

 

然後我們加入法線貼圖,但不使用切線空間,直接從法線貼圖中採樣法線向量,再來看下它的樣子,如下圖所示:

 

通關觀察可以發現,上面兩張圖中前者是相對正常的,因爲整個世界裏只有一個垂直於亮面的平行光,所以只能看到一個面有顏色,其它面都是黑色。而後者中,除了垂直於平行光的面,其餘面也是有顏色的,這顯然是不對的,因爲按照物理法則,其餘幾個面不應該被任何光照到(我也沒有添加環境光),所以應該是黑色的。之所以有這樣錯誤的效果,是因爲這個立方體六個面都用的相同的漫反射貼圖和法線貼圖,每一個面不管朝向哪裏,採樣出來的都是偏向正 Z 軸的值,所以 Shader 代碼自然會認爲這個面中大部分片段就是面向正 Z 軸的,而我們的平行光正好是照着負 Z 軸,所以這時每個面看起來都有了顏色,這也是我在前面提到的法線貼圖的一個問題。

另外,如果你仔細發現,你會看到後者大面積對着屏幕的那一面要比前者大面積對着屏幕的那一面要稍微更有立體感,因爲後者我使用了法線貼圖,這是法線貼圖最基本的作用。但這裏的確不明顯,因爲我爲了方便演示,並沒有花時間調整出好的光照效果,畢竟這篇文章不是演示法線貼圖的,而是用另一個方式去驗證切線空間是否計算正確。

使用切線空間

爲了解決前面的問題,我們需要使用切線空間。切線空間有兩種方式可以得到正確的光照結果:

  • 將數據變換到 世界空間 來計算
  • 將數據變換到 切線空間 來計算

很多人喜歡在世界空間中計算,因爲將所有數據轉換到世界空間再進行計算,是非常直觀的,對於我們在討論的問題也是如此。但這裏我們使用第二種方式來計算,原因是它更高效。

如果我們使用第一種方式,我們需要將每個從法線貼圖中採樣出來的法線變換到世界空間,這一步是在 片段着色器 中完成的,因爲必須知道每個片段對應的的法線值,而不能簡單的在頂點着色器中採樣出來然後再插值到片段着色器中。如果我們使用第二種方式,我們會在 頂點着色器 中把所需要的數據,在這個例子中有平行光方向向量,頂點座標,觀察座標(因爲這個例子只有一個漫反射貼圖,所以其實這個數據並沒什麼卵用)變換到切線空間,然後在片段着色器中只需要採樣出法線向量,不需要再進行其他轉換就可以直接進行計算了。而一般來說片段着色器執行的次數遠大於頂點着色器執行的次數,所以第二種方式一般來說更高效。

當然這裏你可能有一個疑問,我們將一些數據從世界空間轉換到切線空間,會涉及到矩陣的求逆,這一步是開銷比較大的。理論上說,是的,但實際上,我們利用一個性質,即 正交矩陣的逆矩陣等於它的轉置矩陣 就可以做到高效求逆矩陣,你在後面會看到。

頂點 Shader

首先我們將頂點數組傳入頂點着色器,然後構造 TBN 矩陣 來把一些數據變換到切線空間,最後再傳入到片段着色器裏。我先列出頂點着色器中所需要的數據(除傳入的頂點數據外,其餘數據都是在世界空間下)

#version 330 core

layout (location = 0) in vec3 vertexPosition; // 頂點座標

layout (location = 1) in vec3 vertexNormal; // 頂點法線

layout (location = 2) in vec2 textureCoordinate; // 頂點紋理採樣座標

layout (location = 3) in vec3 tangent; // 頂點切線

layout (location = 4) in vec3 bitTangent; // 頂點副切線

// 這是 OpenGL 中的 uniform 緩存,就是把一次渲染中不變的通用數據從外部代碼傳給 Shader

layout (std140) uniform CameraInfo

{

vec3 viewPosition; // 攝像機位置(觀察位置)

};

// 平行光的數據

struct DirectionalLight

{

vec3 direction; // 方向

vec3 diffuseColor; // 漫反射顏色

};

uniform mat4 mvpMatrix;

uniform mat4 modelMatrix;

uniform DirectionalLight directionalLight;

 

 

然後我們還需要定義輸出給片段着色器的數據:

out V_OUT

{

vec2 textureCoordinate; // 紋理座標

vec3 vertexPosition; // 切線空間頂點座標

vec3 normal; // 發現向量

vec3 viewPosition; // 切線空間觀察座標

vec3 directionalLightDirection; // 切線空間平行光方向

} v_out;

 

 

這些數據定義好後,我們就可以着手編寫轉換各個數據到切線空間的代碼了:

void main()

{

// 計算頂點的世界座標

vec4 vertexPositionVector = vec4(vertexPosition, 1.f);

gl_Position = mvpMatrix * vertexPositionVector;

// 計算法線矩陣(這個矩陣可以使法線的座標空間變換更精確,詳細信息可以查閱【法線矩陣】 或 【Normal Transform】)

mat3 normalMatrix = transpose(inverse(mat3(modelMatrix)));

// 求 TBN 矩陣,三個向量均變換到世界空間

vec3 T = normalize(normalMatrix * tangent);

vec3 B = normalize(normalMatrix * bitTangent);

vec3 N = normalize(normalMatrix * vertexNormal);

// 求 TBN 矩陣的逆矩陣,因爲 TBN 矩陣由三個互相垂直的單位向量組成,所以它是一個正交矩陣

// 正如前面所說,正交矩陣的逆矩陣等於它的轉置,所以無需真的求逆矩陣

// 詳情可查閱 【正交矩陣】 或 【Orthogonal Matrix】

mat3 inverseTBN = transpose(mat3(T, B, N));

// 將一些數據從世界空間變換到切線空間(並非所有數據都需要變換),然後傳給片段着色器

v_out.directionalLightDirection = inverseTBN * directionalLight.direction;

v_out.vertexPosition = inverseTBN * vec3(gl_Position);

v_out.viewPosition = inverseTBN * viewPosition;

v_out.textureCoordinate = textureCoordinate;

v_out.normal = N;

}

 

 

寫到這裏我發現,我本來想只放出 Shader 片段的,但最後還是把整個頂點着色器的代碼都寫上了。我在裏面添加了詳細的註釋,應該不會有什麼很困惑的地方。

片段 Shader

由於我們將數據都變換到了切線空間下,那麼片段着色器在計算的時候就方便多了,因爲它們都在同一個空間下了。同樣我們先定義所需要的數據:

#version 330 core

out vec4 f_color; // 輸出的顏色

// 這個跟頂點着色器中的 out 一致

in V_OUT

{

vec2 textureCoordinate;

vec3 vertexPosition;

vec3 normal;

vec3 viewPosition;

vec3 directionalLightDirection;

} v_out;

struct Material

{

sampler2D diffuseTexture; // 漫反射貼圖

sampler2D normalTexture; // 法線貼圖

};

// 跟頂點着色器中的一致

struct DirectionalLight

{

vec3 direction;

vec3 diffuseColor;

};

uniform Material material; // 材質

uniform DirectionalLight directionalLight; // 平行光信息

 

 

最後計算最終的顏色:

vec3 viewDirection; // 觀察方向

vec3 CaculateDiractionalLightColor()

{

// 從法線貼圖中採樣出數據,並轉換成法線值

// 轉過算法爲:貼圖中存儲 0 到 1 的值,而法線值是 -1 到 1

vec3 normal = vec3(texture(material.normalTexture, v_out.textureCoordinate));

normal = normalize(normal * 2.0 - 1.0);

// 計算漫反射

float diffuseRatio = max(dot(-v_out.directionalLightDirection, normal), 0.0);

vec3 diffuseColor = directionalLight.diffuseColor * diffuseRatio * vec3(texture(material.diffuseTexture0, v_out.textureCoordinate));

// 因爲這個例子只用了漫反射貼圖和法線貼圖,所以其餘如鏡面反射或者環境光等就不計算了

return diffuseColor;

}

void main()

{

viewDirection = normalize(v_out.vertexPosition - v_out.viewPosition);

f_color = vec4(CaculateDiractionalLightColor(), 1.0); // 輸出最終顏色

}

 

 

例子的結果

最終的結果如下圖所示:

 

從圖中可以看到,除了正對着平行光的一面外,其餘面在凹凸的地方會有一點顏色,而其他地方依然是黑色。這是因爲對於這個磚牆的圖來說,在法線貼圖中磚的凹凸處所對應的法線向量顯然不是 (0,0,1)(0,0,1) ,所以在這個使用了切線空間的例子中,平行於平行光方向的面轉換到切線空間後,可以直接對法線貼圖進行採樣,而磚牆的大部分面積採樣出來的法線向量是 (0,0,1)(0,0,1) ,所以對於平行於平行光方向的牆面來說,大部分像素的法線向量都垂直於平行光照射的方向,所以計算出的顏色自然爲0,而磚牆的凹凸處的法線值不垂直於平行光照射的方向,所以會得到一些顏色,這應該足以說明我們的切線空間計算結果是正確的。

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