版本記錄
版本號(hào) | 時(shí)間 |
---|---|
V1.0 | 2018.01.19 |
前言
OpenGL 圖形庫項(xiàng)目中一直也沒用過,最近也想學(xué)著使用這個(gè)圖形庫,感覺還是很有意思,也就自然想著好好的總結(jié)一下,希望對(duì)大家能有所幫助。下面內(nèi)容來自歡迎來到OpenGL的世界。
1. OpenGL 圖形庫使用(一) —— 概念基礎(chǔ)
2. OpenGL 圖形庫使用(二) —— 渲染模式、對(duì)象、擴(kuò)展和狀態(tài)機(jī)
3. OpenGL 圖形庫使用(三) —— 著色器、數(shù)據(jù)類型與輸入輸出
4. OpenGL 圖形庫使用(四) —— Uniform及更多屬性
5. OpenGL 圖形庫使用(五) —— 紋理
6. OpenGL 圖形庫使用(六) —— 變換
7. OpenGL 圖形庫的使用(七)—— 坐標(biāo)系統(tǒng)之五種不同的坐標(biāo)系統(tǒng)(一)
8. OpenGL 圖形庫的使用(八)—— 坐標(biāo)系統(tǒng)之3D效果(二)
9. OpenGL 圖形庫的使用(九)—— 攝像機(jī)(一)
10. OpenGL 圖形庫的使用(十)—— 攝像機(jī)(二)
11. OpenGL 圖形庫的使用(十一)—— 光照之顏色
12. OpenGL 圖形庫的使用(十二)—— 光照之基礎(chǔ)光照
13. OpenGL 圖形庫的使用(十三)—— 光照之材質(zhì)
14. OpenGL 圖形庫的使用(十四)—— 光照之光照貼圖
15. OpenGL 圖形庫的使用(十五)—— 光照之投光物
16. OpenGL 圖形庫的使用(十六)—— 光照之多光源
17. OpenGL 圖形庫的使用(十七)—— 光照之復(fù)習(xí)總結(jié)
18. OpenGL 圖形庫的使用(十八)—— 模型加載之Assimp
19. OpenGL 圖形庫的使用(十九)—— 模型加載之網(wǎng)格
20. OpenGL 圖形庫的使用(二十)—— 模型加載之模型
21. OpenGL 圖形庫的使用(二十一)—— 高級(jí)OpenGL之深度測試
22. OpenGL 圖形庫的使用(二十二)—— 高級(jí)OpenGL之模板測試Stencil testing
23. OpenGL 圖形庫的使用(二十三)—— 高級(jí)OpenGL之混合Blending
24. OpenGL 圖形庫的使用(二十四)—— 高級(jí)OpenGL之面剔除Face culling
25. OpenGL 圖形庫的使用(二十五)—— 高級(jí)OpenGL之幀緩沖Framebuffers
26. OpenGL 圖形庫的使用(二十六)—— 高級(jí)OpenGL之立方體貼圖Cubemaps
27. OpenGL 圖形庫的使用(二十七)—— 高級(jí)OpenGL之高級(jí)數(shù)據(jù)Advanced Data
28. OpenGL 圖形庫的使用(二十八)—— 高級(jí)OpenGL之高級(jí)GLSL Advanced GLSL
29. OpenGL 圖形庫的使用(二十九)—— 高級(jí)OpenGL之幾何著色器Geometry Shader
30. OpenGL 圖形庫的使用(三十)—— 高級(jí)OpenGL之實(shí)例化Instancing
31. OpenGL 圖形庫的使用(三十一)—— 高級(jí)OpenGL之抗鋸齒Anti Aliasing
32. OpenGL 圖形庫的使用(三十二)—— 高級(jí)光照之高級(jí)光照Advanced Lighting
33. OpenGL 圖形庫的使用(三十三)—— 高級(jí)光照之Gamma校正Gamma Correction
34. OpenGL 圖形庫的使用(三十四)—— 高級(jí)光照之陰影 - 陰影映射Shadow Mapping
35. OpenGL 圖形庫的使用(三十五)—— 高級(jí)光照之陰影 - 點(diǎn)陰影Point Shadows
法線貼圖基本
我們的場景中已經(jīng)充滿了多邊形物體,其中每個(gè)都可能由成百上千平坦的三角形組成。我們以向三角形上附加紋理的方式來增加額外細(xì)節(jié),提升真實(shí)感,隱藏多邊形幾何體是由無數(shù)三角形組成的事實(shí)。紋理確有助益,然而當(dāng)你近看它們時(shí),這個(gè)事實(shí)便隱藏不住了。現(xiàn)實(shí)中的物體表面并非是平坦的,而是表現(xiàn)出無數(shù)(凹凸不平的)細(xì)節(jié)。
例如,磚塊的表面。磚塊的表面非常粗糙,顯然不是完全平坦的:它包含著接縫處水泥凹痕,以及非常多的細(xì)小的空洞。如果我們?cè)谝粋€(gè)有光的場景中看這樣一個(gè)磚塊的表面,問題就出來了。下圖中我們可以看到磚塊紋理應(yīng)用到了平坦的表面,并被一個(gè)點(diǎn)光源照亮。
光照并沒有呈現(xiàn)出任何裂痕和孔洞,完全忽略了磚塊之間凹進(jìn)去的線條;表面看起來完全就是平的。我們可以使用specular貼圖根據(jù)深度或其他細(xì)節(jié)阻止部分表面被照的更亮,以此部分地解決問題,但這并不是一個(gè)好方案。我們需要的是某種可以告知光照系統(tǒng)給所有有關(guān)物體表面類似深度這樣的細(xì)節(jié)的方式。
如果我們以光的視角來看這個(gè)問題:是什么使表面被視為完全平坦的表面來照亮?答案會(huì)是表面的法線向量。以光照算法的視角考慮的話,只有一件事決定物體的形狀,這就是垂直于它的法線向量。磚塊表面只有一個(gè)法線向量,表面完全根據(jù)這個(gè)法線向量被以一致的方式照亮。如果每個(gè)fragment都是用自己的不同的法線會(huì)怎樣?這樣我們就可以根據(jù)表面細(xì)微的細(xì)節(jié)對(duì)法線向量進(jìn)行改變;這樣就會(huì)獲得一種表面看起來要復(fù)雜得多的幻覺:
每個(gè)fragment使用了自己的法線,我們就可以讓光照相信一個(gè)表面由很多微小的(垂直于法線向量的)平面所組成,物體表面的細(xì)節(jié)將會(huì)得到極大提升。這種每個(gè)fragment使用各自的法線,替代一個(gè)面上所有fragment使用同一個(gè)法線的技術(shù)叫做法線貼圖(normal mapping
)或凹凸貼圖(bump mapping)
。應(yīng)用到磚墻上,效果像這樣:
你可以看到細(xì)節(jié)獲得了極大提升,開銷卻不大。因?yàn)槲覀冎恍枰淖兠總€(gè)fragment的法線向量,并不需要改變所有光照公式。現(xiàn)在我們是為每個(gè)fragment傳遞一個(gè)法線,不再使用插值表面法線。這樣光照使表面擁有了自己的細(xì)節(jié)。
法線貼圖
為使法線貼圖工作,我們需要為每個(gè)fragment提供一個(gè)法線。像diffuse貼圖和specular貼圖一樣,我們可以使用一個(gè)2D紋理來儲(chǔ)存法線數(shù)據(jù)。2D紋理不僅可以儲(chǔ)存顏色和光照數(shù)據(jù),還可以儲(chǔ)存法線向量。這樣我們可以從2D紋理中采樣得到特定紋理的法線向量。
由于法線向量是個(gè)幾何工具,而紋理通常只用于儲(chǔ)存顏色信息,用紋理儲(chǔ)存法線向量不是非常直接。如果你想一想,就會(huì)知道紋理中的顏色向量用r、g、b元素代表一個(gè)3D向量。類似的我們也可以將法線向量的x、y、z元素儲(chǔ)存到紋理中,代替顏色的r、g、b元素。法線向量的范圍在-1到1之間,所以我們先要將其映射到0到1的范圍:
vec3 rgb_normal = normal * 0.5 + 0.5; // 從 [-1,1] 轉(zhuǎn)換至 [0,1]
將法線向量變換為像這樣的RGB顏色元素,我們就能把根據(jù)表面的形狀的fragment的法線保存在2D紋理中。教程開頭展示的那個(gè)磚塊的例子的法線貼圖如下所示:
這會(huì)是一種偏藍(lán)色調(diào)的紋理(你在網(wǎng)上找到的幾乎所有法線貼圖都是這樣的)。這是因?yàn)樗蟹ň€的指向都偏向z軸(0, 0, 1)這是一種偏藍(lán)的顏色。法線向量從z軸方向也向其他方向輕微偏移,顏色也就發(fā)生了輕微變化,這樣看起來便有了一種深度。例如,你可以看到在每個(gè)磚塊的頂部,顏色傾向于偏綠,這是因?yàn)榇u塊的頂部的法線偏向于指向正y軸方向(0, 1, 0),這樣它就是綠色的了。
在一個(gè)簡單的朝向正z軸的平面上,我們可以用這個(gè)diffuse紋理和這個(gè)法線貼圖來渲染前面部分的圖片。要注意的是這個(gè)鏈接里的法線貼圖和上面展示的那個(gè)不一樣。原因是OpenGL讀取的紋理的y(或V)坐標(biāo)和紋理通常被創(chuàng)建的方式相反。鏈接里的法線貼圖的y(或綠色)元素是相反的(你可以看到綠色現(xiàn)在在下邊);如果你沒考慮這個(gè),光照就不正確了(譯注:如果你現(xiàn)在不再使用SOIL了,那就不要用鏈接里的那個(gè)法線貼圖,這個(gè)問題是SOIL載入紋理上下顛倒所致,它也會(huì)把法線在y方向上顛倒)。加載紋理,把它們綁定到合適的紋理單元,然后使用下面的改變了的像素著色器來渲染一個(gè)平面:
uniform sampler2D normalMap;
void main()
{
// 從法線貼圖范圍[0,1]獲取法線
normal = texture(normalMap, fs_in.TexCoords).rgb;
// 將法線向量轉(zhuǎn)換為范圍[-1,1]
normal = normalize(normal * 2.0 - 1.0);
[...]
// 像往常那樣處理光照
}
這里我們將被采樣的法線顏色從0到1重新映射回-1到1,便能將RGB顏色重新處理成法線,然后使用采樣出的法線向量應(yīng)用于光照的計(jì)算。在例子中我們使用的是Blinn-Phong
著色器。
通過慢慢隨著時(shí)間慢慢移動(dòng)光源,你就能明白法線貼圖是什么意思了。運(yùn)行這個(gè)例子你就能得到本教程開始的那個(gè)效果:
你可以在這里找到這個(gè)簡單demo的源代碼及其頂點(diǎn)和像素著色器。
然而有個(gè)問題限制了剛才講的那種法線貼圖的使用。我們使用的那個(gè)法線貼圖里面的所有法線向量都是指向正z方向的。上面的例子能用,是因?yàn)槟莻€(gè)平面的表面法線也是指向正z方向的。可是,如果我們?cè)诒砻娣ň€指向正y方向的平面上使用同一個(gè)法線貼圖會(huì)發(fā)生什么?
光照看起來完全不對(duì)!發(fā)生這種情況是平面的表面法線現(xiàn)在指向了y,而采樣得到的法線仍然指向的是z。結(jié)果就是光照仍然認(rèn)為表面法線和之前朝向正z方向時(shí)一樣;這樣光照就不對(duì)了。下面的圖片展示了這個(gè)表面上采樣的法線的近似情況:
你可以看到所有法線都指向z方向,它們本該朝著表面法線指向y方向的。一個(gè)可行方案是為每個(gè)表面制作一個(gè)單獨(dú)的法線貼圖。如果是一個(gè)立方體的話我們就需要6個(gè)法線貼圖,但是如果模型上有無數(shù)的朝向不同方向的表面,這就不可行了(譯注:實(shí)際上對(duì)于復(fù)雜模型可以把朝向各個(gè)方向的法線儲(chǔ)存在同一張貼圖上,你可能看到過不只是藍(lán)色的法線貼圖,不過用那樣的法線貼圖有個(gè)問題是你必須記住模型的起始朝向,如果模型運(yùn)動(dòng)了還要記錄模型的變換,這是非常不方便的;此外就像作者所說的,如果把一個(gè)diffuse紋理應(yīng)用在同一個(gè)物體的不同表面上,就像立方體那樣的,就需要做6個(gè)法線貼圖,這也不可取)。
另一個(gè)稍微有點(diǎn)難的解決方案是,在一個(gè)不同的坐標(biāo)空間中進(jìn)行光照,這個(gè)坐標(biāo)空間里,法線貼圖向量總是指向這個(gè)坐標(biāo)空間的正z方向;所有的光照向量都相對(duì)與這個(gè)正z方向進(jìn)行變換。這樣我們就能始終使用同樣的法線貼圖,不管朝向問題。這個(gè)坐標(biāo)空間叫做切線空間(tangent space)
。
切線空間
法線貼圖中的法線向量在切線空間中,法線永遠(yuǎn)指著正z方向。切線空間是位于三角形表面之上的空間:法線相對(duì)于單個(gè)三角形的本地參考框架。它就像法線貼圖向量的本地空間;它們都被定義為指向正z方向,無論最終變換到什么方向。使用一個(gè)特定的矩陣我們就能將本地/切線空寂中的法線向量轉(zhuǎn)成世界或視圖坐標(biāo),使它們轉(zhuǎn)向到最終的貼圖表面的方向。
我們可以說,上個(gè)部分那個(gè)朝向正y的法線貼圖錯(cuò)誤的貼到了表面上。法線貼圖被定義在切線空間中,所以一種解決問題的方式是計(jì)算出一種矩陣,把法線從切線空間變換到一個(gè)不同的空間,這樣它們就能和表面法線方向?qū)R了:法線向量都會(huì)指向正y方向。切線空間的一大好處是我們可以為任何類型的表面計(jì)算出一個(gè)這樣的矩陣,由此我們可以把切線空間的z方向和表面的法線方向?qū)R。
這種矩陣叫做TBN矩陣這三個(gè)字母分別代表tangent、bitangent和normal向量。這是建構(gòu)這個(gè)矩陣所需的向量。要建構(gòu)這樣一個(gè)把切線空間轉(zhuǎn)變?yōu)椴煌臻g的變異矩陣,我們需要三個(gè)相互垂直的向量,它們沿一個(gè)表面的法線貼圖對(duì)齊于:上、右、前;這和我們?cè)?a target="_blank" rel="nofollow">攝像機(jī)教程中做的類似。
已知上向量是表面的法線向量。右和前向量是切線(Tagent)
和副切線(Bitangent)
向量。下面的圖片展示了一個(gè)表面的三個(gè)向量:
計(jì)算出切線和副切線并不像法線向量那么容易。從圖中可以看到法線貼圖的切線和副切線與紋理坐標(biāo)的兩個(gè)方向?qū)R。我們就是用到這個(gè)特性計(jì)算每個(gè)表面的切線和副切線的。需要用到一些數(shù)學(xué)才能得到它們;請(qǐng)看下圖:
上圖中我們可以看到邊E2紋理坐標(biāo)的不同,E2是一個(gè)三角形的邊,這個(gè)三角形的另外兩條邊是ΔU2和ΔV2,它們與切線向量T和副切線向量B方向相同。這樣我們可以把邊E1和E2用切線向量T和副切線向量B的線性組合表示出來(譯注:注意T和B都是單位長度,在TB平面中所有點(diǎn)的T、B坐標(biāo)都在0到1之間,因此可以進(jìn)行這樣的組合):
我們也可以寫成這樣:
E是兩個(gè)向量位置的差,ΔU和ΔV是紋理坐標(biāo)的差。然后我們得到兩個(gè)未知數(shù)(切線T和副切線B)和兩個(gè)等式。你可能想起你的代數(shù)課了,這是讓我們?nèi)ソ覶和B。
上面的方程允許我們把它們寫成另一種格式:矩陣乘法
嘗試會(huì)意一下矩陣乘法,它們確實(shí)是同一種等式。把等式寫成矩陣形式的好處是,解T和B會(huì)因此變得很容易。兩邊都乘以ΔUΔV的逆矩陣等于:
這樣我們就可以解出T和B了。這需要我們計(jì)算出delta紋理坐標(biāo)矩陣的擬陣。我不打算講解計(jì)算逆矩陣的細(xì)節(jié),但大致是把它變化為,1除以矩陣的行列式,再乘以它的伴隨矩陣(Adjugate Matrix)
。
有了最后這個(gè)等式,我們就可以用公式、三角形的兩條邊以及紋理坐標(biāo)計(jì)算出切線向量T和副切線B。
如果你對(duì)這些數(shù)學(xué)內(nèi)容不理解也不用擔(dān)心。當(dāng)你知道我們可以用一個(gè)三角形的頂點(diǎn)和紋理坐標(biāo)(因?yàn)榧y理坐標(biāo)和切線向量在同一空間中)計(jì)算出切線和副切線你就已經(jīng)部分地達(dá)到目的了(譯注:上面的推導(dǎo)已經(jīng)很清楚了,如果你不明白可以參考任意線性代數(shù)教材,就像作者所說的記住求得切線空間的公式也行,不過不管怎樣都得理解切線空間的含義)。
1. 手工計(jì)算切線和副切線
這個(gè)教程的demo場景中有一個(gè)簡單的2D平面,它朝向正z方向。這次我們會(huì)使用切線空間來實(shí)現(xiàn)法線貼圖,所以我們可以使平面朝向任意方向,法線貼圖仍然能夠工作。使用前面討論的數(shù)學(xué)方法,我們來手工計(jì)算出表面的切線和副切線向量。
假設(shè)平面使用下面的向量建立起來(1、2、3和1、3、4,它們是兩個(gè)三角形):
// positions
glm::vec3 pos1(-1.0, 1.0, 0.0);
glm::vec3 pos2(-1.0, -1.0, 0.0);
glm::vec3 pos3(1.0, -1.0, 0.0);
glm::vec3 pos4(1.0, 1.0, 0.0);
// texture coordinates
glm::vec2 uv1(0.0, 1.0);
glm::vec2 uv2(0.0, 0.0);
glm::vec2 uv3(1.0, 0.0);
glm::vec2 uv4(1.0, 1.0);
// normal vector
glm::vec3 nm(0.0, 0.0, 1.0);
我們先計(jì)算第一個(gè)三角形的邊和deltaUV
坐標(biāo):
glm::vec3 edge1 = pos2 - pos1;
glm::vec3 edge2 = pos3 - pos1;
glm::vec2 deltaUV1 = uv2 - uv1;
glm::vec2 deltaUV2 = uv3 - uv1;
有了計(jì)算切線和副切線的必備數(shù)據(jù),我們就可以開始寫出來自于前面部分中的下列等式:
GLfloat f = 1.0f / (deltaUV1.x * deltaUV2.y - deltaUV2.x * deltaUV1.y);
tangent1.x = f * (deltaUV2.y * edge1.x - deltaUV1.y * edge2.x);
tangent1.y = f * (deltaUV2.y * edge1.y - deltaUV1.y * edge2.y);
tangent1.z = f * (deltaUV2.y * edge1.z - deltaUV1.y * edge2.z);
tangent1 = glm::normalize(tangent1);
bitangent1.x = f * (-deltaUV2.x * edge1.x + deltaUV1.x * edge2.x);
bitangent1.y = f * (-deltaUV2.x * edge1.y + deltaUV1.x * edge2.y);
bitangent1.z = f * (-deltaUV2.x * edge1.z + deltaUV1.x * edge2.z);
bitangent1 = glm::normalize(bitangent1);
[...] // 對(duì)平面的第二個(gè)三角形采用類似步驟計(jì)算切線和副切線
我們預(yù)先計(jì)算出等式的分?jǐn)?shù)部分f,然后把它和每個(gè)向量的元素進(jìn)行相應(yīng)矩陣乘法。如果你把代碼和最終的等式對(duì)比你會(huì)發(fā)現(xiàn),這就是直接套用。最后我們還要進(jìn)行標(biāo)準(zhǔn)化,來確保切線/副切線向量最后是單位向量。
因?yàn)橐粋€(gè)三角形永遠(yuǎn)是平坦的形狀,我們只需為每個(gè)三角形計(jì)算一個(gè)切線/副切線,它們對(duì)于每個(gè)三角形上的頂點(diǎn)都是一樣的。要注意的是大多數(shù)實(shí)現(xiàn)通常三角形和三角形之間都會(huì)共享頂點(diǎn)。這種情況下開發(fā)者通常將每個(gè)頂點(diǎn)的法線和切線/副切線等頂點(diǎn)屬性平均化,以獲得更加柔和的效果。我們的平面的三角形之間分享了一些頂點(diǎn),但是因?yàn)閮蓚€(gè)三角形相互并行,因此并不需要將結(jié)果平均化,但無論何時(shí)只要你遇到這種情況記住它就是件好事。
最后的切線和副切線向量的值應(yīng)該是(1, 0, 0)
和(0, 1, 0)
,它們和法線(0, 0, 1)組成相互垂直的TBN矩陣。在平面上顯示出來TBN應(yīng)該是這樣的:
每個(gè)頂點(diǎn)定義了切線和副切線向量,我們就可以開始實(shí)現(xiàn)正確的法線貼圖了。
2. 切線空間法線貼圖
為讓法線貼圖工作,我們先得在著色器中創(chuàng)建一個(gè)TBN矩陣。我們先將前面計(jì)算出來的切線和副切線向量傳給頂點(diǎn)著色器,作為它的屬性:
#version 330 core
layout (location = 0) in vec3 position;
layout (location = 1) in vec3 normal;
layout (location = 2) in vec2 texCoords;
layout (location = 3) in vec3 tangent;
layout (location = 4) in vec3 bitangent;
在頂點(diǎn)著色器的main函數(shù)中我們創(chuàng)建TBN矩陣:
void main()
{
[...]
vec3 T = normalize(vec3(model * vec4(tangent, 0.0)));
vec3 B = normalize(vec3(model * vec4(bitangent, 0.0)));
vec3 N = normalize(vec3(model * vec4(normal, 0.0)));
mat3 TBN = mat3(T, B, N)
}
我們先將所有TBN向量變換到我們所操作的坐標(biāo)系中,現(xiàn)在是世界空間,我們可以乘以model矩陣。然后我們創(chuàng)建實(shí)際的TBN矩陣,直接把相應(yīng)的向量應(yīng)用到mat3構(gòu)造器就行。注意,如果我們希望更精確的話就不要講TBN向量乘以model矩陣,而是使用法線矩陣,但我們只關(guān)心向量的方向,不會(huì)平移也和縮放這個(gè)變換。
從技術(shù)上講,頂點(diǎn)著色器中無需副切線。所有的這三個(gè)TBN向量都是相互垂直的所以我們可以在頂點(diǎn)著色器中使用T和N向量的叉乘,自己計(jì)算出副切線:
vec3 B = cross(T, N)
;
現(xiàn)在我們有了TBN矩陣,如果來使用它呢?通常來說有兩種方式使用它,我們會(huì)把這兩種方式都說明一下:
我們直接使用TBN矩陣,這個(gè)矩陣可以把切線坐標(biāo)空間的向量轉(zhuǎn)換到世界坐標(biāo)空間。因此我們把它傳給片段著色器中,把通過采樣得到的法線坐標(biāo)左乘上TBN矩陣,轉(zhuǎn)換到世界坐標(biāo)空間中,這樣所有法線和其他光照變量就在同一個(gè)坐標(biāo)系中了。
我們也可以使用TBN矩陣的逆矩陣,這個(gè)矩陣可以把世界坐標(biāo)空間的向量轉(zhuǎn)換到切線坐標(biāo)空間。因此我們使用這個(gè)矩陣左乘其他光照變量,把他們轉(zhuǎn)換到切線空間,這樣法線和其他光照變量再一次在一個(gè)坐標(biāo)系中了。
我們來看看第一種情況。我們從法線貼圖重采樣得來的法線向量,是以切線空間表達(dá)的,盡管其他光照向量是以世界空間表達(dá)的。把TBN傳給像素著色器,我們就能將采樣得來的切線空間的法線乘以這個(gè)TBN矩陣,將法線向量變換到和其他光照向量一樣的參考空間中。這種方式隨后所有光照計(jì)算都可以簡單的理解。
把TBN矩陣發(fā)給像素著色器很簡單:
out VS_OUT {
vec3 FragPos;
vec2 TexCoords;
mat3 TBN;
} vs_out;
void main()
{
[...]
vs_out.TBN = mat3(T, B, N);
}
在像素著色器中我們用mat3作為輸入變量:
in VS_OUT {
vec3 FragPos;
vec2 TexCoords;
mat3 TBN;
} fs_in;
有了TBN矩陣我們現(xiàn)在就可以更新法線貼圖代碼,引入切線到世界空間變換:
normal = texture(normalMap, fs_in.TexCoords).rgb;
normal = normalize(normal * 2.0 - 1.0);
normal = normalize(fs_in.TBN * normal);
因?yàn)樽詈蟮膎ormal現(xiàn)在在世界空間中了,就不用改變其他像素著色器的代碼了,因?yàn)楣庹沾a就是假設(shè)法線向量在世界空間中。
我們同樣看看第二種情況。我們用TBN矩陣的逆矩陣將所有相關(guān)的世界空間向量轉(zhuǎn)變到采樣所得法線向量的空間:切線空間。TBN的建構(gòu)還是一樣,但我們?cè)趯⑵浒l(fā)送給像素著色器之前先要求逆矩陣:
vs_out.TBN = transpose(mat3(T, B, N));
注意,這里我們使用transpose
函數(shù),而不是inverse
函數(shù)。正交矩陣(每個(gè)軸既是單位向量同時(shí)相互垂直)的一大屬性是一個(gè)正交矩陣的置換矩陣與它的逆矩陣相等。這個(gè)屬性和重要因?yàn)槟婢仃嚨那蟮帽惹笾脫Q開銷大;結(jié)果卻是一樣的。
在像素著色器中我們不用對(duì)法線向量變換,但我們要把其他相關(guān)向量轉(zhuǎn)換到切線空間,它們是lightDir和viewDir。這樣每個(gè)向量還是在同一個(gè)空間(切線空間)中了。
void main()
{
vec3 normal = texture(normalMap, fs_in.TexCoords).rgb;
normal = normalize(normal * 2.0 - 1.0);
vec3 lightDir = fs_in.TBN * normalize(lightPos - fs_in.FragPos);
vec3 viewDir = fs_in.TBN * normalize(viewPos - fs_in.FragPos);
[...]
}
第二種方法看似要做的更多,它還需要在像素著色器中進(jìn)行更多的乘法操作,所以為何還用第二種方法呢?
將向量從世界空間轉(zhuǎn)換到切線空間有個(gè)額外好處,我們可以把所有相關(guān)向量在頂點(diǎn)著色器中轉(zhuǎn)換到切線空間,不用在像素著色器中做這件事。這是可行的,因?yàn)?code>lightPos和viewPos
不是每個(gè)fragment運(yùn)行都要改變,對(duì)于fs_in.FragPos
,我們也可以在頂點(diǎn)著色器計(jì)算它的切線空間位置。基本上,不需要把任何向量在像素著色器中進(jìn)行變換,而第一種方法中就是必須的,因?yàn)椴蓸映鰜淼姆ň€向量對(duì)于每個(gè)像素著色器都不一樣。
所以現(xiàn)在不是把TBN矩陣的逆矩陣發(fā)送給像素著色器,而是將切線空間的光源位置,觀察位置以及頂點(diǎn)位置發(fā)送給像素著色器。這樣我們就不用在像素著色器里進(jìn)行矩陣乘法了。這是一個(gè)極佳的優(yōu)化,因?yàn)轫旤c(diǎn)著色器通常比像素著色器運(yùn)行的少。這也是為什么這種方法是一種更好的實(shí)現(xiàn)方式的原因。
out VS_OUT {
vec3 FragPos;
vec2 TexCoords;
vec3 TangentLightPos;
vec3 TangentViewPos;
vec3 TangentFragPos;
} vs_out;
uniform vec3 lightPos;
uniform vec3 viewPos;
[...]
void main()
{
[...]
mat3 TBN = transpose(mat3(T, B, N));
vs_out.TangentLightPos = TBN * lightPos;
vs_out.TangentViewPos = TBN * viewPos;
vs_out.TangentFragPos = TBN * vec3(model * vec4(position, 0.0));
}
在像素著色器中我們使用這些新的輸入變量來計(jì)算切線空間的光照。因?yàn)榉ň€向量已經(jīng)在切線空間中了,光照就有意義了。
將法線貼圖應(yīng)用到切線空間上,我們會(huì)得到混合教程一開始那個(gè)例子相似的結(jié)果,但這次我們可以將平面朝向各個(gè)方向,光照一直都會(huì)是正確的:
glm::mat4 model;
model = glm::rotate(model, (GLfloat)glfwGetTime() * -10, glm::normalize(glm::vec3(1.0, 0.0, 1.0)));
glUniformMatrix4fv(modelLoc 1, GL_FALSE, glm::value_ptr(model));
RenderQuad();
看起來是正確的法線貼圖:
// Std. Includes
#include <string>
// GLEW
#define GLEW_STATIC
#include <GL/glew.h>
// GLFW
#include <GLFW/glfw3.h>
// GL includes
#include <learnopengl/shader.h>
#include <learnopengl/camera.h>
// GLM Mathemtics
#include <glm/glm.hpp>
#include <glm/gtc/matrix_transform.hpp>
#include <glm/gtc/type_ptr.hpp>
// Other Libs
#include <SOIL.h>
// Properties
const GLuint SCR_WIDTH = 800, SCR_HEIGHT = 600;
// Function prototypes
void key_callback(GLFWwindow* window, int key, int scancode, int action, int mode);
void scroll_callback(GLFWwindow* window, double xoffset, double yoffset);
void mouse_callback(GLFWwindow* window, double xpos, double ypos);
void Do_Movement();
GLuint loadTexture(GLchar* path);
void RenderQuad();
// Camera
Camera camera(glm::vec3(0.0f, 0.0f, 3.0f));
GLfloat deltaTime = 0.0f;
GLfloat lastFrame = 0.0f;
// The MAIN function, from here we start our application and run our Game loop
int main()
{
// Init GLFW
glfwInit();
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
glfwWindowHint(GLFW_RESIZABLE, GL_FALSE);
GLFWwindow* window = glfwCreateWindow(SCR_WIDTH, SCR_HEIGHT, "LearnOpenGL", nullptr, nullptr); // Windowed
glfwMakeContextCurrent(window);
// Set the required callback functions
glfwSetKeyCallback(window, key_callback);
glfwSetCursorPosCallback(window, mouse_callback);
glfwSetScrollCallback(window, scroll_callback);
// Options
glfwSetInputMode(window, GLFW_CURSOR, GLFW_CURSOR_DISABLED);
// Initialize GLEW to setup the OpenGL Function pointers
glewExperimental = GL_TRUE;
glewInit();
// Define the viewport dimensions
glViewport(0, 0, SCR_WIDTH, SCR_HEIGHT);
// Setup some OpenGL options
glEnable(GL_DEPTH_TEST);
// Setup and compile our shaders
Shader shader("normal_mapping.vs", "normal_mapping.frag");
// Load textures
GLuint diffuseMap = loadTexture("../../../resources/textures/brickwall.jpg");
GLuint normalMap = loadTexture("../../../resources/textures/brickwall_normal.jpg");
// Set texture units
shader.Use();
glUniform1i(glGetUniformLocation(shader.Program, "diffuseMap"), 0);
glUniform1i(glGetUniformLocation(shader.Program, "normalMap"), 1);
// Light position
glm::vec3 lightPos(0.5f, 1.0f, 0.3f);
// Game loop
while (!glfwWindowShouldClose(window))
{
// Set frame time
GLfloat currentFrame = glfwGetTime();
deltaTime = currentFrame - lastFrame;
lastFrame = currentFrame;
// Check and call events
glfwPollEvents();
Do_Movement();
// Clear the colorbuffer
glClearColor(0.1f, 0.1f, 0.1f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
// Configure view/projection matrices
shader.Use();
glm::mat4 view = camera.GetViewMatrix();
glm::mat4 projection = glm::perspective(camera.Zoom, (float)SCR_WIDTH / (float)SCR_HEIGHT, 0.1f, 100.0f);
glUniformMatrix4fv(glGetUniformLocation(shader.Program, "view"), 1, GL_FALSE, glm::value_ptr(view));
glUniformMatrix4fv(glGetUniformLocation(shader.Program, "projection"), 1, GL_FALSE, glm::value_ptr(projection));
// Render normal-mapped quad
glm::mat4 model;
model = glm::rotate(model, (GLfloat)glfwGetTime() * -10, glm::normalize(glm::vec3(1.0, 0.0, 1.0))); // Rotates the quad to show normal mapping works in all directions
glUniformMatrix4fv(glGetUniformLocation(shader.Program, "model"), 1, GL_FALSE, glm::value_ptr(model));
glUniform3fv(glGetUniformLocation(shader.Program, "lightPos"), 1, &lightPos[0]);
glUniform3fv(glGetUniformLocation(shader.Program, "viewPos"), 1, &camera.Position[0]);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, diffuseMap);
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, normalMap);
RenderQuad();
// render light source (simply re-renders a smaller plane at the light's position for debugging/visualization)
model = glm::mat4();
model = glm::translate(model, lightPos);
model = glm::scale(model, glm::vec3(0.1f));
glUniformMatrix4fv(glGetUniformLocation(shader.Program, "model"), 1, GL_FALSE, glm::value_ptr(model));
RenderQuad();
// Swap the buffers
glfwSwapBuffers(window);
}
glfwTerminate();
return 0;
}
// RenderQuad() Renders a 1x1 quad in NDC
GLuint quadVAO = 0;
GLuint quadVBO;
void RenderQuad()
{
if (quadVAO == 0)
{
// positions
glm::vec3 pos1(-1.0, 1.0, 0.0);
glm::vec3 pos2(-1.0, -1.0, 0.0);
glm::vec3 pos3(1.0, -1.0, 0.0);
glm::vec3 pos4(1.0, 1.0, 0.0);
// texture coordinates
glm::vec2 uv1(0.0, 1.0);
glm::vec2 uv2(0.0, 0.0);
glm::vec2 uv3(1.0, 0.0);
glm::vec2 uv4(1.0, 1.0);
// normal vector
glm::vec3 nm(0.0, 0.0, 1.0);
// calculate tangent/bitangent vectors of both triangles
glm::vec3 tangent1, bitangent1;
glm::vec3 tangent2, bitangent2;
// - triangle 1
glm::vec3 edge1 = pos2 - pos1;
glm::vec3 edge2 = pos3 - pos1;
glm::vec2 deltaUV1 = uv2 - uv1;
glm::vec2 deltaUV2 = uv3 - uv1;
GLfloat f = 1.0f / (deltaUV1.x * deltaUV2.y - deltaUV2.x * deltaUV1.y);
tangent1.x = f * (deltaUV2.y * edge1.x - deltaUV1.y * edge2.x);
tangent1.y = f * (deltaUV2.y * edge1.y - deltaUV1.y * edge2.y);
tangent1.z = f * (deltaUV2.y * edge1.z - deltaUV1.y * edge2.z);
tangent1 = glm::normalize(tangent1);
bitangent1.x = f * (-deltaUV2.x * edge1.x + deltaUV1.x * edge2.x);
bitangent1.y = f * (-deltaUV2.x * edge1.y + deltaUV1.x * edge2.y);
bitangent1.z = f * (-deltaUV2.x * edge1.z + deltaUV1.x * edge2.z);
bitangent1 = glm::normalize(bitangent1);
// - triangle 2
edge1 = pos3 - pos1;
edge2 = pos4 - pos1;
deltaUV1 = uv3 - uv1;
deltaUV2 = uv4 - uv1;
f = 1.0f / (deltaUV1.x * deltaUV2.y - deltaUV2.x * deltaUV1.y);
tangent2.x = f * (deltaUV2.y * edge1.x - deltaUV1.y * edge2.x);
tangent2.y = f * (deltaUV2.y * edge1.y - deltaUV1.y * edge2.y);
tangent2.z = f * (deltaUV2.y * edge1.z - deltaUV1.y * edge2.z);
tangent2 = glm::normalize(tangent2);
bitangent2.x = f * (-deltaUV2.x * edge1.x + deltaUV1.x * edge2.x);
bitangent2.y = f * (-deltaUV2.x * edge1.y + deltaUV1.x * edge2.y);
bitangent2.z = f * (-deltaUV2.x * edge1.z + deltaUV1.x * edge2.z);
bitangent2 = glm::normalize(bitangent2);
GLfloat quadVertices[] = {
// Positions // normal // TexCoords // Tangent // Bitangent
pos1.x, pos1.y, pos1.z, nm.x, nm.y, nm.z, uv1.x, uv1.y, tangent1.x, tangent1.y, tangent1.z, bitangent1.x, bitangent1.y, bitangent1.z,
pos2.x, pos2.y, pos2.z, nm.x, nm.y, nm.z, uv2.x, uv2.y, tangent1.x, tangent1.y, tangent1.z, bitangent1.x, bitangent1.y, bitangent1.z,
pos3.x, pos3.y, pos3.z, nm.x, nm.y, nm.z, uv3.x, uv3.y, tangent1.x, tangent1.y, tangent1.z, bitangent1.x, bitangent1.y, bitangent1.z,
pos1.x, pos1.y, pos1.z, nm.x, nm.y, nm.z, uv1.x, uv1.y, tangent2.x, tangent2.y, tangent2.z, bitangent2.x, bitangent2.y, bitangent2.z,
pos3.x, pos3.y, pos3.z, nm.x, nm.y, nm.z, uv3.x, uv3.y, tangent2.x, tangent2.y, tangent2.z, bitangent2.x, bitangent2.y, bitangent2.z,
pos4.x, pos4.y, pos4.z, nm.x, nm.y, nm.z, uv4.x, uv4.y, tangent2.x, tangent2.y, tangent2.z, bitangent2.x, bitangent2.y, bitangent2.z
};
// Setup plane VAO
glGenVertexArrays(1, &quadVAO);
glGenBuffers(1, &quadVBO);
glBindVertexArray(quadVAO);
glBindBuffer(GL_ARRAY_BUFFER, quadVBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(quadVertices), &quadVertices, GL_STATIC_DRAW);
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 14 * sizeof(GLfloat), (GLvoid*)0);
glEnableVertexAttribArray(1);
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 14 * sizeof(GLfloat), (GLvoid*)(3 * sizeof(GLfloat)));
glEnableVertexAttribArray(2);
glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, 14 * sizeof(GLfloat), (GLvoid*)(6 * sizeof(GLfloat)));
glEnableVertexAttribArray(3);
glVertexAttribPointer(3, 3, GL_FLOAT, GL_FALSE, 14 * sizeof(GLfloat), (GLvoid*)(8 * sizeof(GLfloat)));
glEnableVertexAttribArray(4);
glVertexAttribPointer(4, 3, GL_FLOAT, GL_FALSE, 14 * sizeof(GLfloat), (GLvoid*)(11 * sizeof(GLfloat)));
}
glBindVertexArray(quadVAO);
glDrawArrays(GL_TRIANGLES, 0, 6);
glBindVertexArray(0);
}
// This function loads a texture from file. Note: texture loading functions like these are usually
// managed by a 'Resource Manager' that manages all resources (like textures, models, audio).
// For learning purposes we'll just define it as a utility function.
GLuint loadTexture(GLchar* path)
{
//Generate texture ID and load texture data
GLuint textureID;
glGenTextures(1, &textureID);
int width, height;
unsigned char* image = SOIL_load_image(path, &width, &height, 0, SOIL_LOAD_RGB);
// Assign texture to ID
glBindTexture(GL_TEXTURE_2D, textureID);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, image);
glGenerateMipmap(GL_TEXTURE_2D);
// Parameters
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glBindTexture(GL_TEXTURE_2D, 0);
SOIL_free_image_data(image);
return textureID;
}
#pragma region "User input"
bool keys[1024];
bool keysPressed[1024];
// Moves/alters the camera positions based on user input
void Do_Movement()
{
// Camera controls
if (keys[GLFW_KEY_W])
camera.ProcessKeyboard(FORWARD, deltaTime);
if (keys[GLFW_KEY_S])
camera.ProcessKeyboard(BACKWARD, deltaTime);
if (keys[GLFW_KEY_A])
camera.ProcessKeyboard(LEFT, deltaTime);
if (keys[GLFW_KEY_D])
camera.ProcessKeyboard(RIGHT, deltaTime);
}
// Is called whenever a key is pressed/released via GLFW
void key_callback(GLFWwindow* window, int key, int scancode, int action, int mode)
{
if (key == GLFW_KEY_ESCAPE && action == GLFW_PRESS)
glfwSetWindowShouldClose(window, GL_TRUE);
if (key >= 0 && key <= 1024)
{
if (action == GLFW_PRESS)
keys[key] = true;
else if (action == GLFW_RELEASE)
{
keys[key] = false;
keysPressed[key] = false;
}
}
}
GLfloat lastX = 400, lastY = 300;
bool firstMouse = true;
// Moves/alters the camera positions based on user input
void mouse_callback(GLFWwindow* window, double xpos, double ypos)
{
if (firstMouse)
{
lastX = xpos;
lastY = ypos;
firstMouse = false;
}
GLfloat xoffset = xpos - lastX;
GLfloat yoffset = lastY - ypos;
lastX = xpos;
lastY = ypos;
camera.ProcessMouseMovement(xoffset, yoffset);
}
void scroll_callback(GLFWwindow* window, double xoffset, double yoffset)
{
camera.ProcessMouseScroll(yoffset);
}
#pragma endregion
3. 復(fù)雜物體
我們已經(jīng)說明了如何通過手工計(jì)算切線和副切線向量,來使用切線空間和法線貼圖。幸運(yùn)的是,計(jì)算這些切線和副切線向量對(duì)于你來說不是經(jīng)常能遇到的事;大多數(shù)時(shí)候,在模型加載器中實(shí)現(xiàn)了一次就行了,我們是在使用了Assimp的那個(gè)加載器中實(shí)現(xiàn)的。
Assimp有個(gè)很有用的配置,在我們加載模型的時(shí)候調(diào)用aiProcess_CalcTangentSpace
。當(dāng)aiProcess_CalcTangentSpace
應(yīng)用到Assimp的ReadFile
函數(shù)時(shí),Assimp會(huì)為每個(gè)加載的頂點(diǎn)計(jì)算出柔和的切線和副切線向量,它所使用的方法和我們本教程使用的類似。
const aiScene* scene = importer.ReadFile(
path, aiProcess_Triangulate | aiProcess_FlipUVs | aiProcess_CalcTangentSpace
);
我們可以通過下面的代碼用Assimp獲取計(jì)算出來的切線空間:
vector.x = mesh->mTangents[i].x;
vector.y = mesh->mTangents[i].y;
vector.z = mesh->mTangents[i].z;
vertex.Tangent = vector;
然后,你還必須更新模型加載器,用以從帶紋理模型中加載法線貼圖。wavefront
的模型格式(.obj)導(dǎo)出的法線貼圖有點(diǎn)不一樣,Assimp的aiTextureType_NORMAL
并不會(huì)加載它的法線貼圖,而aiTextureType_HEIGHT
卻能,所以我們經(jīng)常這樣加載它們:
vector<Texture> specularMaps = this->loadMaterialTextures(
material, aiTextureType_HEIGHT, "texture_normal"
);
當(dāng)然,對(duì)于每個(gè)模型的類型和文件格式來說都是不同的。同樣了解aiProcess_CalcTangentSpace
并不能總是很好的工作也很重要。計(jì)算切線是需要根據(jù)紋理坐標(biāo)的,有些模型制作者使用一些紋理小技巧比如鏡像一個(gè)模型上的紋理表面時(shí)也鏡像了另一半的紋理坐標(biāo);這樣當(dāng)不考慮這個(gè)鏡像的特別操作的時(shí)候(Assimp就不考慮)結(jié)果就不對(duì)了。
運(yùn)行程序,用新的模型加載器,加載一個(gè)有specular和法線貼圖的模型,看起來會(huì)像這樣:
你可以看到在沒有太多點(diǎn)的額外開銷的情況下法線貼圖難以置信地提升了物體的細(xì)節(jié)。
使用法線貼圖也是一種提升你的場景的表現(xiàn)的重要方式。在使用法線貼圖之前你不得不使用相當(dāng)多的頂點(diǎn)才能表現(xiàn)出一個(gè)更精細(xì)的網(wǎng)格,但使用了法線貼圖我們可以使用更少的頂點(diǎn)表現(xiàn)出同樣豐富的細(xì)節(jié)。下圖來自Paolo Cignoni
,圖中對(duì)比了兩種方式:
高精度網(wǎng)格和使用法線貼圖的低精度網(wǎng)格幾乎區(qū)分不出來。所以法線貼圖不僅看起來漂亮,它也是一個(gè)將高精度多邊形轉(zhuǎn)換為低精度多邊形而不失細(xì)節(jié)的重要工具。
最后一件事
關(guān)于法線貼圖還有最后一個(gè)技巧要討論,它可以在不必花費(fèi)太多性能開銷的情況下稍稍提升畫質(zhì)表現(xiàn)。
當(dāng)在更大的網(wǎng)格上計(jì)算切線向量的時(shí)候,它們往往有很大數(shù)量的共享頂點(diǎn),當(dāng)發(fā)下貼圖應(yīng)用到這些表面時(shí)將切線向量平均化通常能獲得更好更平滑的結(jié)果。這樣做有個(gè)問題,就是TBN向量可能會(huì)不能互相垂直,這意味著TBN矩陣不再是正交矩陣了。法線貼圖可能會(huì)稍稍偏移,但這仍然可以改進(jìn)。
使用叫做格拉姆-施密特正交化過程(Gram-Schmidt process)
的數(shù)學(xué)技巧,我們可以對(duì)TBN向量進(jìn)行重正交化,這樣每個(gè)向量就又會(huì)重新垂直了。在頂點(diǎn)著色器中我們這樣做:
vec3 T = normalize(vec3(model * vec4(tangent, 0.0)));
vec3 N = normalize(vec3(model * vec4(normal, 0.0)));
// re-orthogonalize T with respect to N
T = normalize(T - dot(T, N) * N);
// then retrieve perpendicular vector B with the cross product of T and N
vec3 B = cross(T, N);
mat3 TBN = mat3(T, B, N)
這樣稍微花費(fèi)一些性能開銷就能對(duì)法線貼圖進(jìn)行一點(diǎn)提升。看看最后的那個(gè)附加資源: Normal Mapping Mathematics
視頻,里面有對(duì)這個(gè)過程的解釋。
附加資源
- Tutorial 26: Normal Mapping:ogldev的法線貼圖教程。
- How Normal Mapping Works:TheBennyBox的講述法線貼圖如何工作的視頻。
- Normal Mapping Mathematics:TheBennyBox關(guān)于法線貼圖的數(shù)學(xué)原理的教程。
- Tutorial 13: Normal Mapping:opengl-tutorial.org提供的法線貼圖教程。
后記
本篇已結(jié)束,下一篇關(guān)于視差貼圖。