Unity Shader-法線貼圖(Normal)及其原理


簡介

 

以前經常聽說“模型不好看啊,怎么辦啊?”答曰“加法線”,”做了個高模,准備烘一下法線貼圖”,“有的美術特別屌,直接畫法線貼圖”.....法線貼圖到底是個什么鬼,當年天真的我真的被這個圖形學的奇淫雜技忽悠了,然而畢竟本人還算有點刨根問底的精神,決定研究一下法線貼圖的原理以及Unity下的實現。本人才疏學淺,如有錯誤,歡迎指正。

 

法線貼圖是目前游戲開發中最常見的貼圖之一。我們知道,一般情況下,模型面數越高,可以表現的細節越多,效果也越好。但是,由於面數多了,頂點數多了,計算量也就上去了,效果永遠是和性能成反比的。怎么樣用盡可能簡單模型來做出更好的效果就成了大家研究的方向之一。紋理映射是最早的一種,通過紋理直接貼在模型表面,提供了一些細節,但是普通的紋理貼圖只是影響最終像素階段輸出的顏色值,不能讓模型有一些凹凸之類的細節表現。而法線貼圖就是為了解決上面的問題,給我們提供了通過低面數模型來模擬高面數模型的效果,增加細節層次感,效果與高模相差不多,但是大大降低了模型的面數。

 

法線貼圖原理

 
要模擬一個圓球,要想越平滑,就需要更多的面數,否則會很容易地發現面和面之間的明顯邊界。最早時的GPU是沒有fragement編程能力的,也就是說在這種情況下,在計算時需要逐頂點計算光照,然后每個像素的顏色在各個頂點的顏色之間插值,也就是高洛德着色,這種情況下,面數決定一切效果,沒有什么好辦法。而當像素着色器出現之后,我們可以逐像素來計算光照效果,這時候,在計算每個像素的光照時,會計算這個像素所在的面的法向量,而這個面的法向量也是由這個面周圍的頂點法線(也就是我們之前vertex shader中出現的normal)插值得來的,當然,如果面數很低,那么效果也好不到哪里去。但是, 逐像素計算光照時,我們每一個像素都會根據該點的法向量來計算最終該點的光照結果,那么,我們如果能夠改變這個法線的方向,不是就可以改變這個點的光照結果了呢!那么,把紋理采樣的思想用在這里,我們直接用一張圖來存儲法線(或者法線偏移值,見下文),逐像素計算時,在采樣diffuse貼圖的時候,再采樣一張法線的貼圖,就可以修改法線了,進而修改最終的效果。
 
為什么法線貼圖會讓我們感覺有凹凸感呢?看下面一張圖,在現實世界中,你要相信你的眼睛,眼見為實還有點道理,在計算機世界中,一切以忽悠你為目的。在平面的情況下,我們感覺物體是凹陷還是凸起,很大一部分取決於這個面的亮度,像下面這張圖,有了這種亮度的對比,我們就很容易感覺這個按鈕有周圍的一圈凸起。

如果還是沒理解,再看一套圖片,同樣一張圖片,旋轉180度后的結果完全相反。不信可以去截圖放到MSPaint里面轉一下試試,反正我是試了....

既然一個面的光照條件(亮度)的改變,就可以讓我們感覺這個面有凹凸感,那么上面說的,通過改變法線來改變面上某點的光照條件,進而忽悠觀察者,讓他們感覺這個面有凹凸感的方法就行得通了。

假如下面是我們的低面數模型,上面是我們的高面數模型,上面的模型在計算光照時,由於面數多,每個面的法線方向不同,所以各個面的光照計算結果都不同,就有凹凸的感覺了,而下面的低模,只有一個面,整個面的光照條件都是一致的,就沒有凹凸的感覺了。我們如果把上面的高模的法線信息保存下來,類似紋理貼圖那樣,存在一張圖里,再給低模使用,低模就可以有跟高模一樣的法線,進而在計算光照時達到和高模類似的效果,這也就是常說的烘法線的原理。

 

凹凸貼圖(Bump Map)

 
既然說了要研究法線貼圖,所以肯定要從老一輩的開始,首先來看一下凹凸貼圖(Bump Map)。Bump Map是最早的法線貼圖實現方式,這也是制作上最容易的一種模式,可以直接通過一張灰度圖,默認為黑色,越凸起的地方顏色越亮,這種就是可以直接在PhotoShop中畫的法線,但是這種法線貼圖的原理理解起來比較難,我只說一下我的理解,然后附上unity中的shader實現。這種技術現在貌似已經過時了,但是思想還是流傳下來了,而且這種畫灰度圖,或者通過灰度圖生成法線貼圖的方式現在仍然在使用,Unity就支持這種直接通過灰度圖生成法線貼圖。
 
首先,通過灰度圖來表現凹凸,那么,我們怎樣判斷一個點處在凹凸的邊緣呢?答案是通過斜率,比如我要對(x,y)進行采樣,怎樣求這一點的斜率呢,學過數學的都知道,我們可以通過兩點確定一條直線,進而求出這條直線的斜率。那么我們就可以對(x-1,y)和(x+1,y)兩點進行采樣,豎向也是一樣,通過(x,y-1)和(x,y+1)進行采樣,那么,我們就可以獲得這一點上灰度值的變化,如果灰度值不變,說明該點不在邊緣,如果灰度值有改變,那么說明該點在邊緣,那么我們就可以根據這個斜率值來修改法線,進而修改光照結果。
 
我又掏出了我十分不熟練的PhotoShop,畫了一張傳說中的Bump Map,恩,感覺還不錯,目前RGB通道都有信息,反正只是實驗,和shader對應就好了:
Bump Map類型的shader如下(僅僅是實驗基於灰度的Bump,完全不實用....)
[csharp]  view plain  copy
 
  1. //Bump Map  
  2. //by:puppet_master  
  3. //2016.12.13  
  4. Shader "ApcShader/BumpMap"  
  5. {  
  6.     //屬性  
  7.     Properties{  
  8.         _Diffuse("Diffuse", Color) = (1,1,1,1)  
  9.         _MainTex("Base 2D", 2D) = "white"{}  
  10.         _BumpMap("Bump Map", 2D) = "black"{}  
  11.         _BumpScale ("Bump Scale", Range(0.1, 30.0)) = 10.0  
  12.     }  
  13.   
  14.     //子着色器    
  15.     SubShader  
  16.     {  
  17.         Pass  
  18.         {  
  19.             //定義Tags  
  20.             Tags{ "RenderType" = "Opaque" }  
  21.   
  22.             CGPROGRAM  
  23.             //引入頭文件  
  24.             #include "Lighting.cginc"  
  25.             //定義Properties中的變量  
  26.             fixed4 _Diffuse;  
  27.             sampler2D _MainTex;  
  28.             //使用了TRANSFROM_TEX宏就需要定義XXX_ST  
  29.             float4 _MainTex_ST;  
  30.             sampler2D _BumpMap;  
  31.             float4 _BumpMap_TexelSize;  
  32.             float _BumpScale;  
  33.   
  34.             //定義結構體:應用階段到vertex shader階段的數據  
  35.             struct a2v  
  36.             {  
  37.                 float4 vertex : POSITION;  
  38.                 float3 normal : NORMAL;  
  39.                 float4 texcoord : TEXCOORD0;  
  40.             };  
  41.             //定義結構體:vertex shader階段輸出的內容  
  42.             struct v2f  
  43.             {  
  44.                 float4 pos : SV_POSITION;  
  45.                 float3 worldNormal : TEXCOORD0;  
  46.                 //轉化紋理坐標  
  47.                 float2 uv : TEXCOORD1;  
  48.             };  
  49.   
  50.             //定義頂點shader  
  51.             v2f vert(a2v v)  
  52.             {  
  53.                 v2f o;  
  54.                 o.pos = mul(UNITY_MATRIX_MVP, v.vertex);  
  55.                 //把法線轉化到世界空間  
  56.                 o.worldNormal = mul(v.normal, (float3x3)_World2Object);  
  57.                 //通過TRANSFORM_TEX宏轉化紋理坐標,主要處理了Offset和Tiling的改變,默認時等同於o.uv = v.texcoord.xy;  
  58.                 o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);  
  59.                 return o;  
  60.             }  
  61.   
  62.             //定義片元shader  
  63.             fixed4 frag(v2f i) : SV_Target  
  64.             {  
  65.                 //unity自身的diffuse也是帶了環境光,這里我們也增加一下環境光  
  66.                 fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * _Diffuse.xyz;  
  67.                 //歸一化法線,即使在vert歸一化也不行,從vert到frag階段有差值處理,傳入的法線方向並不是vertex shader直接傳出的  
  68.                 fixed3 worldNormal1 = normalize(i.worldNormal);  
  69.                 //采樣bump貼圖,需要知道該點的斜率,xy方向分別求,所以對於一個點需要采樣四次  
  70.                 fixed bumpValueU = tex2D(_BumpMap, i.uv + fixed2(-1.0 * _BumpMap_TexelSize.x, 0)).r - tex2D(_BumpMap, i.uv + fixed2(1.0 * _BumpMap_TexelSize.x, 0)).r;  
  71.                 fixed bumpValueV = tex2D(_BumpMap, i.uv + fixed2(0, -1.0 * _BumpMap_TexelSize.y)).r - tex2D(_BumpMap, i.uv + fixed2(0, 1.0 * _BumpMap_TexelSize.y)).r;  
  72.                 //用上面的斜率來修改法線的偏移值  
  73.                 fixed3 worldNormal = fixed3(worldNormal1.x * bumpValueU * _BumpScale, worldNormal1.y * bumpValueV * _BumpScale, worldNormal1.z);  
  74.   
  75.                 //把光照方向歸一化  
  76.                 fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);  
  77.                 //根據半蘭伯特模型計算像素的光照信息  
  78.                 fixed3 lambert = 0.5 * dot(worldNormal, worldLightDir) + 0.5;  
  79.                 //最終輸出顏色為lambert光強*材質diffuse顏色*光顏色  
  80.                 fixed3 diffuse = lambert * _Diffuse.xyz * _LightColor0.xyz + ambient;  
  81.                 //進行紋理采樣  
  82.                 fixed4 color = tex2D(_MainTex, i.uv);  
  83.                 return fixed4(diffuse * color.rgb, 1.0);  
  84.             }  
  85.   
  86.             //使用vert函數和frag函數  
  87.             #pragma vertex vert  
  88.             #pragma fragment frag     
  89.   
  90.             ENDCG  
  91.         }  
  92.   
  93.     }  
  94.         //前面的Shader失效的話,使用默認的Diffuse  
  95.         FallBack "Diffuse"  
  96. }  
效果如下:
這個過程還是很有意思的,Unity為我們封裝了太多東西,尤其是surface shader,只需要一句unpacknormal,然后把輸出賦給o.normal就ok了,我們基本不需要做什么,但是底層的實現對於學習來說還是很必要的。
 

法線貼圖(Normal Map)

 
隨着GPU的發展,Geforce3的出現,帶來了真正的Normal Mapping技術,也叫作Dot3 bump mapping。這種Normal Map就是我們現在在使用的法線貼圖技術。與之前通過灰度表現界面的凹凸程度,進而修改法線的方式完全不同,這種Normal Map直接將法線存儲到了法線貼圖中,也就是說,我們從法線貼圖讀取的法線直接就可以使用了,而不是需要像上面那樣,再通過灰度漸變值來修改法線。這種法線對於制作來說,沒有灰度圖那樣直白,但是卻是真正的法線貼圖技術,所謂烘焙法線,烘焙的就是這個。
 
雖然灰度圖不會直接被用於實時計算法線了,但是在離線工具中卻提供了直接通過灰度圖生成法線的功能。Unity中就有這種功能:
我們把之前畫的那張灰度圖直接通過這種方式改成法線貼圖,從法線貼圖中我們就直接可以看到凹凸的效果了。在Unity里實現法線貼圖的shader之前,首先看幾個問題,也是困擾了我一段時間的幾個問題。
 

法線貼圖是怎樣存儲的

 
既然法線貼圖中存儲的是法線的方向,也就是說是一個Vector3類型的變量,剛好和圖片的RGB格式不謀而合。但是向量畢竟要靈活得多,我們正常的RGBA貼圖,一個通道是8位,可以表示的大小在(0,255),那么反過來除一下,貼圖中可以存儲的向量的精度就是0.0039,也就是說並不是真正意義上的浮點類型,精度要小得多,不過對於一般情況下,這種精度也足夠了。再一個問題,就是向量是有方向滴,而貼圖中只能存儲的都是正數,所以,還需要一個映射的過程。映射在圖形學中真是很多見呢,比如計算 半蘭伯特光照時,就通過把(0,1)的光照區間轉化到了(0.5,1)提高了光的亮度,使效果更好。在法線貼圖中,可以用0代表向量中的-1,用255代表向量中的1,不過,在shader中,貼圖的顏色一般也是(0,1)區間,所以,我們在計算時只需要把從法線貼圖中采樣得到的法線值進行映射,將其從(0,1)區間轉化到(-1,1)區間。
 
這個步驟,Unity已經為我們完成了,我們在計算法線的時候,只需要調用UnpackNormal這個函數就可以實現區間的重新映射。從UnityCG.cginc中可以看到UnpackNormal這個函數的實現:
[csharp]  view plain  copy
 
  1. inline fixed3 UnpackNormalDXT5nm (fixed4 packednormal)  
  2. {  
  3.     fixed3 normal;  
  4.     normal.xy = packednormal.wy * 2 - 1;  
  5.     normal.z = sqrt(1 - saturate(dot(normal.xy, normal.xy)));  
  6.     return normal;  
  7. }  
  8.   
  9. inline fixed3 UnpackNormal(fixed4 packednormal)  
  10. {  
  11. #if defined(UNITY_NO_DXT5nm)  
  12.     return packednormal.xyz * 2 - 1;  
  13. #else  
  14.     return UnpackNormalDXT5nm(packednormal);  
  15. #endif  
  16. }  
做法很簡單,乘2 減1大法好,轉化區間沒煩惱(什么鬼....)
這里,我們看到了兩個UnpackNormal的函數,下面的就是我們所說的直接轉化區間。而上面的那個函數,看定義來說,是為了專門解出DXT5nm格式的normal map,這種類型的normal map,只用存儲法向量中的兩個通道,然后解開的時候,需要計算一下,重新算出另一個向量方向。這樣可以實現的原理在於,存儲的向量是單位向量,長度一定的情況下,就可以通過sqrt(1 - x^2 - y^2)來求得,如下圖:
不過這是一種時間換空間的做法,以犧牲時間的代價,換來更好的壓縮比以及壓縮后的效果。關於DXT5nm,附上一篇參考文章: Normal Map的dds壓縮
 

為什么法線貼圖存儲在切線空間

 
既然知道了法線可以存儲在貼圖中,我們就再來看一下,為什么法線貼圖中一般都存儲的是切線空間,為什么不存儲在世界空間或者模型空間。首先看一下世界空間,如果我們的法線貼圖存儲的世界空間的法線信息,我們可以直接解出法線的值,在世界空間進行計算,是最直接並且計算效率最高的做法,但是世界空間的法線貼圖就跟當前環境之間耦合過大了,比如同樣的兩個模型,僅僅是旋轉方向不同,也需要兩張法線貼圖,這很明顯是多余的,於是就有人想出了基於模型空間的法線,基於模型空間,在計算時,把模型空間的法線轉換到世界空間,雖然多了一步操作,但是同一個模型可以共用法線,不用考慮旋轉等問題。但是,人們感覺模型空間的法線貼圖跟模型的耦合度還是高,那就繼續解耦吧,於是基於切線空間的法線貼圖就誕生了。下圖為模型空間與切線空間法線。
 
 
所謂的切線空間,跟那些比較常見的坐標系,比如世界坐標,模型坐標一樣,也是一個坐標系,用三個基向量就可以表示。我們用模型上的一個點來看,這個點的有一個法線的方向,也就是這個點所在的面的法線的方向N,這個方向是確定的,我們可以用它作為Z軸。而剩下的兩個軸,剛好就在這個面上,互相垂直,但是這兩個軸的可選種類就多了,因為在這個面上任意兩個向量都可以表示這個面。目前最常用的方式是以該點的uv二維坐標系表達該點的切線(tangent)和該點的次法線(binormal)所構成的切平面。它的法線既處處都垂直於它的表面。我們用展uv的方式,將紋理展開攤平,那么所有的法線就都垂直於這個紋理平面,法線就是z軸,而uv set,准確地說是該點uv朝着下一個頂點uv的方向向量分別作為tangent和binormal軸,也就是x,y軸。但是這樣做有一個弊端,就是x軸和y軸之間不互相垂直,計算Tangent空間的公式如下:
 
T = normalize(dx/du, dy/du, dz/du)
N = T × normalize(dx/dv, dy/dv, dz/dv)
B = N × T
 
很遺憾我們在在Unity里面看不到全部源代碼,不過從shader的定義中可以看到B的求解以及TBN矩陣的構建過程:
[csharp]  view plain  copy
 
  1. // Declares 3x3 matrix 'rotation', filled with tangent space basis  
  2. #define TANGENT_SPACE_ROTATION \  
  3.     float3 binormal = cross( normalize(v.normal), normalize(v.tangent.xyz) ) * v.tangent.w; \  
  4.     float3x3 rotation = float3x3( v.tangent.xyz, binormal, v.normal )  
float3x3是行向量構建,可以參照 這里,然后我們就可以通過mul(rotation,v)把需要的向量從模型空間轉化到tangent空間。不過大部分內容Unity已經幫我們做好了,主要是TBN空間的創建,如果需要自己寫渲染器的話,這個是一個比較麻煩的過程,也有類似 3DMax中導出頂點tangent值中的做法,直接在導出的時候將tangent空間信息導出,存儲在頂點中。
 
 
最后總結一下: tangent space下,其實跟我們上一節計算的斜率很像,我們計算斜率基本也是tangent值。而這里T(x軸)使用normalize(dx/du, dy/du, dz/du),相當於計算了模型空間下x,y,z值隨着紋理u坐標方向的斜率,換句話說,切線空間反映了模型空間坐標xyz隨着紋理坐標uv的變化率(坡度),這也正是normal map中要存儲的信息,所以normal map中的內容正好可以使用切線空間進行存儲。
 

為什么法線貼圖都是藍色的

 
既然我們知道了法線貼圖中存儲的是切線空間的法線。而法線貼圖所對應的表面,絕大部分的位置肯定是平滑的,只有需要凹凸變化的地方才會有變化,那么大部分地方的法線方向不變,也就是在切線空間的(0,0,1),這個值按照上面介紹的映射關系,從(-1,1)區間變換到(0,1)區間:(0*0.5+0.5,0*0.5+0.5,1*0.5+0.5)= (0.5,0.5,1),再轉化為顏色的(0,255)區間,最終就變成了(127,127,255)。好了,打開photoshop,看一下這個顏色值是什么:
法線一般就是這個顏色嘛!那么,其他的地方,如果有凹凸感,就需要調整法線的方向,那么顏色就不一樣了。
 

Unity下法線貼圖Shader實現

 
解決了上面幾個問題之后,我們就可以看一下Unity Shader中實現法線貼圖的方式。光照模型仍然采用之前的半蘭伯特光照,vertex fragemnt shader實現(surface版本的就兩句話,也就不寫了):
[csharp]  view plain  copy
 
  1. //Bump Map  
  2. //by:puppet_master  
  3. //2016.12.14  
  4. Shader "ApcShader/NormalMap"  
  5. {  
  6.     //屬性  
  7.     Properties{  
  8.         _Diffuse("Diffuse", Color) = (1,1,1,1)  
  9.         _MainTex("Base 2D", 2D) = "white"{}  
  10.         _BumpMap("Bump Map", 2D) = "bump"{}  
  11.         _BumpScale ("Bump Scale", Range(0.1, 30.0)) = 10.0  
  12.     }  
  13.   
  14.     //子着色器    
  15.     SubShader  
  16.     {  
  17.         Pass  
  18.         {  
  19.             //定義Tags  
  20.             Tags{ "RenderType" = "Opaque" }  
  21.   
  22.             CGPROGRAM  
  23.             //引入頭文件  
  24.             #include "Lighting.cginc"  
  25.             //定義Properties中的變量  
  26.             fixed4 _Diffuse;  
  27.             sampler2D _MainTex;  
  28.             //使用了TRANSFROM_TEX宏就需要定義XXX_ST  
  29.             float4 _MainTex_ST;  
  30.             sampler2D _BumpMap;  
  31.             float _BumpScale;  
  32.   
  33.             //定義結構體:vertex shader階段輸出的內容  
  34.             struct v2f  
  35.             {  
  36.                 float4 pos : SV_POSITION;  
  37.                 //轉化紋理坐標  
  38.                 float2 uv : TEXCOORD0;  
  39.                 //tangent空間的光線方向  
  40.                 float3 lightDir : TEXCOORD1;  
  41.             };  
  42.   
  43.             //定義頂點shader  
  44.             v2f vert(appdata_tan v)  
  45.             {  
  46.                 v2f o;  
  47.                 o.pos = mul(UNITY_MATRIX_MVP, v.vertex);  
  48.                 //這個宏為我們定義好了模型空間到切線空間的轉換矩陣rotation,注意后面有個;  
  49.                 TANGENT_SPACE_ROTATION;  
  50.                 //ObjectSpaceLightDir可以把光線方向轉化到模型空間,然后通過rotation再轉化到切線空間  
  51.                 o.lightDir = mul(rotation, ObjSpaceLightDir(v.vertex));  
  52.                 //通過TRANSFORM_TEX宏轉化紋理坐標,主要處理了Offset和Tiling的改變,默認時等同於o.uv = v.texcoord.xy;  
  53.                 o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);  
  54.                 return o;  
  55.             }  
  56.   
  57.             //定義片元shader  
  58.             fixed4 frag(v2f i) : SV_Target  
  59.             {  
  60.                 //unity自身的diffuse也是帶了環境光,這里我們也增加一下環境光  
  61.                 fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * _Diffuse.xyz;  
  62.                 //直接解出切線空間法線  
  63.                 float3 tangentNormal = UnpackNormal(tex2D(_BumpMap, i.uv));  
  64.                 //normalize一下切線空間的光照方向  
  65.                 float3 tangentLight = normalize(i.lightDir);  
  66.                 //根據半蘭伯特模型計算像素的光照信息  
  67.                 fixed3 lambert = 0.5 * dot(tangentNormal, tangentLight) + 0.5;  
  68.                 //最終輸出顏色為lambert光強*材質diffuse顏色*光顏色  
  69.                 fixed3 diffuse = lambert * _Diffuse.xyz * _LightColor0.xyz + ambient;  
  70.                 //進行紋理采樣  
  71.                 fixed4 color = tex2D(_MainTex, i.uv);  
  72.                 return fixed4(diffuse * color.rgb, 1.0);  
  73.             }  
  74.   
  75.             //使用vert函數和frag函數  
  76.             #pragma vertex vert  
  77.             #pragma fragment frag     
  78.   
  79.             ENDCG  
  80.         }  
  81.   
  82.     }  
  83.         //前面的Shader失效的話,使用默認的Diffuse  
  84.         FallBack "Diffuse"  
  85. }  
結果:


總結

本篇文章簡單探究了一下bump map以及normal map的原理以及在Unity中的實現。法線貼圖可以很好地在低模上模擬高模的效果,雖然多采樣了一次貼圖,但是能模擬出數倍於模型本身面數的效果,極大地提升了實時渲染的效果。雖然法線貼圖也有一些弊端,因為法線貼圖只是給人造成一種凹凸的假象,所以在視角與物體平行時,看到的物體表面仍然是平的。並且還會有一些穿幫的現象,不過畢竟瑕不掩瑜,法線貼圖仍然是目前渲染中最常使用的技術之一。為了解決上面的問題,一些更加高級的貼圖技術,如視差貼圖和位移貼圖就誕生了。之后再研究這兩種更加高級一點的貼圖技術,本篇到此為止。上面給出了一些關於tangent空間求解的參考鏈接。最后再附上一些關於法線貼圖原理的參考。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM