文章目錄

接續上一篇跟著LearnOpenGL學著OpenGL的記錄,這次從Normal Mapping開始實作

Advanced Lighting - Normal Mapping 法線貼圖


這章節介紹現在大家都很熟悉的法線貼圖的原理與實作,

normal_mapping_normal_map.png

首先法線貼圖一般會偏藍色,因為這種貼圖預設表面的法線方向是朝向Z(B通道)軸,所以RGB中的B通道的值會是最大的。而凹凸的表面會讓法線朝xy方向做不同程度的偏移,於是就會看到如上圖般的貼圖。

normal_mapping_ground_normals.png

而直接將法線貼圖採樣出的值當成world space的法線會造成計算上的錯誤,因為並不是所有表面都朝向world space的Z的(譬如上圖來說,表面其實是朝向Y方向),因此,這個法線貼圖的Z其實並不是world space的Z,而是tagent space(切線空間)的Z。tagent space的Z是模型上每個三角面的法線方向

求TBN矩陣

要將法線貼圖的tagent space正確轉換到world space在計算,就會需要矩陣,這時候要搬出之前實作攝影機的LookAt矩陣時提到的

只要利用三個各自垂直的向量(right, upm front)做出一個矩陣後,將該矩陣乘上當前座標的平移矩陣,我們就可以將該座標轉移到任意空間

要實作的矩陣 - 就是可以將tangent space的座標轉換到world space的LookAt矩陣,因為建立矩陣時需要的三個各自垂直的向量分別是Tangent、Bitangent、Normal,一般就稱為TBN矩陣,原文還有實作手動計算這三個向量的方式,但我太懶了,直接在assimp導入模型的時候把計算模型上每個頂點的Tangent跟Normal的flag打開,然後把normals跟tangents都塞到GL_ARRAY_BUFFER中直接在shader內使用。

Unity的話預設的模型import setting都是讓使用者有tangent跟normal能用的,如果是custom shader的話只要在attribute的定義中有寫出來就能取到,shader graph的話只要有用到相關node就會自己產了。

//Unity Code
struct Attributes
{
    float4 positionOS : POSITION;
    float3 normalOS : NORMAL;
    float4 tangentOS : TANGENT;
    float2 texcoord : TEXCOORD0;
.........

因為assimp在import模型時已經得出tangent跟normal,直接用T跟N向量求外積就可以得到Bitangent了

//GLSL
vec3 T = normalize(vec3(_model * vec4(aTangent, 0.0)));
vec3 N = normalize(vec3(_model * vec4(aNormal, 0.0)));
vec3 B = cross(N, T);
mat3 TBN = mat3(T, B, N);

在vertex shader內求得TBN矩陣後,在pixel shader內將normal map採樣後得到的值轉換到world space,接下來的lighting model的部分就不需要額外處理了。

//GLSL
vec3 normalTS= texture(_normalMap, uv).rgb;
normalTS= normalTS* 2.0 - 1.0;
//TBN矩陣將tangent space轉到world space下
vec3 normalWS = TBN * normalTS;

//原本的光照模型計算, NdotL...etc
float ndotl = .....

上面的空間轉換等同於unity shader中內建的TransformTangentToWorld (SpaceTransfrom.hlsl)

最後放上目前用OpenGL實作normal mapping的前後差異

Untitled.png

Untitled.png

Parallax Mapping 視差貼圖


單單使用法線貼圖來影響光照,製造出表面的凹突感有時候仍然不太夠,於是這章節介紹parallax mapping,這種技巧讓物體表面可以有更明顯的高低起伏,不過有一些使用上的限制與效能的花費。

原理

Parallax Mapping的親戚是使用高度圖的displacement mapping(地形系統常用到),但一個效果良好的displacement mapping需要極大量的面數或者搭配tessellation來使用,parallax mapping則不需要,這方法單純透過採樣貼圖來製造出凹圖感的錯覺。

parallax_mapping_plane_height.png

如上圖,假設今天有個平面,對於某pixel來說,觀察者看向該平面的像素會是A點,但對於一個儲存了表面高低起伏的heightmap來說,觀察者看向平面的像素應該要是B點,parallax mapping的重點就是沿著上圖中黃色的view direction來偏移採樣heightmap的UV值,讓最終採樣heightmap的結果會趨近於點B。我覺得非常神奇。

parallax_mapping_incorrect_p.png

至於偏移的方式則是根據點A採樣到的height高度多寡,所以當heightmap本身有劇烈的高低起伏的時候,就會發生偏移後的點與理想上的點B相差甚遠的情況,這個時候就會產生鋸齒。

parallax_mapping_depth.png

另外,因為對於渲染每個mesh的pixel shader來說,一個平面光柵化後沒有在畫面上的部分,是不會顯示的,所以相對於displacement map讓每個頂點浮起來,parallax mapping其實在計算時是把heightmap往下推當成depth map在做計算的,每個畫面上表現出凹凸的部分會是在平面之下(平面光柵化後有cover到pixel的部分)。

計算TBN的逆矩陣

由於上面幾個圖中的view vector都是在tangent space下的(之於每個三角面表面的view direction),要求得圖中的view vector我們可以反過來利用TBN的逆矩陣將world space的view direction轉換為tangent space。

//pixel shader內求tangent space的viewDir
mat3 invTBN = transpose(TBN);
vec3 posTS = invTBN * positionWS;
vec3 cameraPosTS = invTBN * _cameraPos;
vec3 viewDirTS = normalize(cameraPosTS - posTS);
....
....

因為TBN矩陣內的座標本身各自垂直且是normalized的,TBN的transpose矩陣會等同於TBN的逆矩陣

(Unity中有TransformWorldToTangent function可以使用)

視差貼圖效果優化

多次採樣

parallax_mapping_steep_parallax_mapping_diagram.png

單純從透過採樣一次height map來做uv的偏移,在height map的落差較明顯或者當從接近水平的角度看像平面時,這種計算的誤差就會非常的大。於是後來出現了進行多次sample來取最接近點的方法。基於多次sample衍生很多種不同的方法,parallax occlusion mapping就是其中一種。

Untitled.png

這種做法的缺點就是採樣數不夠的時候,仍然會有明顯鋸齒(如上圖,可以看到石磚向鬆餅一樣疊起來),但採樣數很多的時候對效能又有不小的負擔。

我試著照著LearnOpenGL的code來實作,即使sample數很高(30+)在我的場景上仍然會看到不少鋸齒,後來果斷放棄直接照抄unity的實作,效果好不少。

Untitled.png

(Unity的Parallax Mapping + Faster Relief Mapping,sample次數32),在極端角度仍有不錯的效果

鋸齒/效能優化

對於視差貼圖來說,每個pixel的sample數不用全都寫死,可以該pixel與相機的角度和距離去控制,當相機越是水平於平面時,需要較多的sample數,但是當相機由上往下垂直看向平面時,其實只需要很低的sample數,距離太遠的時候也不需要太高的sample數。

float remapViewDistance01 = (distance - minDistance) / (maxDistance - minDistance);
float stepScale =  1.0 - max(0, min(dot(normalize(_cameraPos - positionWS), normalize(normalWS)), 1));
stepScale *= stepScale;
float viewDistanceScale = (1.0 - max(0, min(remapViewDistance01, 1)));
stepScale *= viewDistanceScale;

//根據角度與距離mask去對sample count做線性內插
float steps =  ceil(mix(_parallaxSteps* 0.2, _parallaxSteps, stepScale ));

另外,採樣的heightmap也是很重要的,上面有提過heightmap劇烈的高低起伏是造成採樣結果出現誤差的主因,因此有個小撇布就是用模糊的方式把heightmap抹的糊一點,這樣劇烈的起伏就被抹平了…..畢竟細節使用normal mapping去補充即可,parallax mapping負責大致的起伏就好。

Untitled.png

Untitled.png

可以看出當heightmap中的高度分界很明顯的時候 較容易出現鋸齒。適度的模糊可以降低鋸齒的發生(我是有點用的太糊了XD),模糊的方式很簡單,丟到PS或者其他軟體開高斯模糊濾鏡就搞定了,只是要注意模糊後的邊界是否無接縫。

這部分技巧參考自Unity TA的影片

最後放上僅有normal mapping與加上parallax mapping的地板

Untitled.png

Untitled.png

結論

我自己之前寫unity的custom shader時,normal map的部分都是直接拔官方的code,所以沒有自己去實作過。趁著這個機會溫故知新也是滿不錯的,TBN熟悉之後順便可以整理一下自己的custom shader中normal map那部份的code,畢竟unity的lit shader內關於normal map的code因為有很多local keyword分來分去所以有一點亂。