1. 程式人生 > >UnityShader 浮雕凹凸貼圖BumpMap與法線貼圖NormalMap的原理及其區別

UnityShader 浮雕凹凸貼圖BumpMap與法線貼圖NormalMap的原理及其區別

效果圖:

     

                         浮雕凹凸貼圖效果 ====》 高度圖

    

                    法線貼圖凹凸效果    ====>  法線貼圖

簡介

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

凹凸對映和紋理對映非常相似。然而,紋理對映是把顏色加到多邊形上,而凹凸對映是把粗糙資訊加到多邊形上。這在多邊形的視覺上會產生很吸引人的效果。我們只需要新增一點資訊到本來需要使用大量多邊形的物體上。需要注意的是這個物體是平的,但是它看起來卻是粗糙不平的。“凹凸對映和紋理對映有什麼不同?”它們的不同之處在於——凹凸對映是一種負責光方向的紋理對映。

法線貼圖原理

要模擬一個圓球,要想越平滑,就需要更多的面數,否則會很容易地發現面和麵之間的明顯邊界。最早時的GPU是沒有fragement程式設計能力的,也就是說在這種情況下,在計算時需要逐頂點計算光照,然後每個畫素的顏色在各個頂點的顏色之間插值,也就是高洛德著色,這種情況下,面數決定一切效果,沒有什麼好辦法。而當畫素著色器出現之後,我們可以逐畫素來計算光照效果,這時候,在計算每個畫素的光照時,會計算這個畫素所在的面的法向量,而這個面的法向量也是由這個面周圍的頂點法線(也就是我們之前vertex shader中出現的normal)插值得來的,當然,如果面數很低,那麼效果也好不到哪裡去。但是,逐畫素計算光照時,我們每一個畫素都會根據該點的法向量來計算最終該點的光照結果,那麼,我們如果能夠改變這個法線的方向,不是就可以改變這個點的光照結果了呢
!那麼,把紋理取樣的思想用在這裡,我們直接用一張圖來儲存法線(或者法線偏移值,見下文),逐畫素計算時,在取樣diffuse貼圖的時候,再取樣一張法線的貼圖,就可以修改法線了,進而修改最終的效果。

為什麼法線貼圖會讓我們感覺有凹凸感呢?看下面一張圖,在現實世界中,你要相信你的眼睛,眼見為實還有點道理,在計算機世界中,一切以忽悠你為目的。在平面的情況下,我們感覺物體是凹陷還是凸起,很大一部分取決於這個面的亮度,像下面這張圖,有了這種亮度的對比,我們就很容易感覺這個按鈕有周圍的一圈凸起。
如果還是沒理解,再看一套圖片,同樣一張圖片,旋轉180度後的結果完全相反。
從遠處看,你判斷這個物體是粗糙的的唯一證據是在它表面上下的亮度有改變。你的大腦能夠獲得這些亮暗不一的圖案資訊,然後判斷出它們是表面中有凹凸的部位。左邊的一幅圖就說明了這一點。你可以發現它是一個浮雕式的表面。一些矩型和字母被印入表面,但是它們摸上去就像是一個隱藏的監控器的玻璃。如果這個影象是在適當的位置上,那麼它除了改變亮度,不需要再做任何其他的工作。 
那麼你也許會問:我是怎麼知道哪些點要亮,哪些點要暗呢?這不難。絕大多數人生活在這樣一種環境下——這個環境的大多數光源來自上方(譯者注:比如白天主要的光來自太陽,夜晚主要的光來自天花板上的日光燈)。所以向上傾的地方就會更亮,而向下傾的地方就會更暗。所以這種現象使你的眼睛看到一個物體上亮暗區域時,可以判斷出它的凹凸情況。相對亮的塊被判斷是面向上的,相對暗的塊被判斷是面向下的。所以我只需要給物體上的線條簡單得上色。 
如果你想要更多的證據,這裡還有一幅幾乎相同的圖,不同於前的是它旋轉了180度。所以它是前一幅圖倒轉的影象。那些先前看起來是凹進去的區域,現在看起來是凸出來的了。

這個時候你的大腦並沒有被完全欺騙,你腦中存留的視覺印象使你仍然有能力判斷出這是前一幅圖,只是它的光源變了,是從下往上照的,你的大腦可能強迫性地判斷出它是第一幅圖。事實上,你只要始終盯著它,並且努力地想像著光是從右下方向照射的,你就會理解它是凹的(譯者注:因為日常生活的習慣,你會很容易把這些圖形判斷成凸出的圖形,但是因為有了上一幅對照圖的印象,你可能才會特別注意到這些圖塊其實還是凹入的,只是判斷方法不符合我們日常生活習慣,因為這時大多數光不是從上方照射,而是從下往上照射)。

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


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

凹凸對映的目的是使用一張紋理來修改模型表面的法線,以便為模型提供更多的細節。這種萬法不會真的改變模型的頂點位置,
只是讓模型看起來好像是“凹凸不平”的,但可以從模型的輪廓處看出“破綻”。
有兩種主要的方法可以用來進行凹凸對映: 一種方法是使用一張高度紋理(height map)來模擬表面位移( displacement ), 然後得到一個修改後的法線值,這種方法也被稱為高度對映(height mapping);另一種方法則是使用一張法線紋理(normal map )來直接儲存表面法線,這種方法又被稱為法線對映( normal mapping )。

凹凸貼圖(Bump Map)

既然說了要研究法線貼圖,所以肯定要從老一輩的開始,首先來看一下凹凸貼圖(Bump Map)。Bump Map是最早的法線貼圖實現方式,這也是製作上最容易的一種模式,可以直接通過一張灰度圖,預設為黑色,越凸起的地方顏色越亮,這種就是可以直接在PhotoShop中畫的法線,但是這種法線貼圖的原理理解起來比較難,我只說一下我的理解,然後附上unity中的shader實現。這種技術現在貌似已經過時了,但是思想還是流傳下來了,而且這種畫灰度圖,或者通過灰度圖生成法線貼圖的方式現在仍然在使用,Unity就支援這種直接通過灰度圖生成法線貼圖。 BumpMap一種是Emboss Bump Map(浮雕凹凸貼圖), 它使用的是 Height map ,原理是在原始影象的基礎上,對 高度場影象進行復制、輕微平移、差操作 。但它存在很多嚴重的侷限性,它只能用於漫反射表面,對於鏡面高光來說是不可能的。當光源直接照射在物體表面時,如果沒有偏移,那麼物體表面就不會出現任何凹凸現象。

工作原理:

凹凸對映是補色渲染技術(Phong Shading Technique)的一項擴充套件,只是在補色渲染裡,多邊形表面上的法線將被改變這個向量用來計算該點的亮度。當你加入了凹凸對映,法線向量會略微地改變,怎麼改變則基於凹凸圖。改變法線向量就會改變多邊形的點的顏色值。就這麼簡單。 
現在,有幾種方法來達到這個目的(譯者注:這個目的指改變法線向量)。我並沒有實際編寫補色渲染和凹凸對映的程式,但是我在這裡將介紹一種我喜歡的方法來實現! 
現在我們需要將凹凸圖中的高度資訊轉換成補色渲染用到的法線的調節資訊。這個做起來不難,但是解釋起來比較費勁。


好的,我們現在將凹凸點陣圖的資訊轉換成一些小向量——一個向量對應於一個點。請看上面一副放大的凹凸圖。相對亮的點比相對暗的點更為凸出。看清楚了嗎?現在計算每個點的向量,這些向量表徵了每個點的傾斜情況,請看下圖的描繪。圖中紅色小圓點表示向量是向下的:

有很多計算向量的方法,不同的方法精確度不同,但是選擇什麼方法要取決於你所要求的精確度是個什麼層次。最通常的方法是分別計算每個點上X和Y的傾斜度:

x_gradient = pixel(x-1, y) – pixel(x+1, y) 
y_gradient = pixel(x, y-1) – pixel(x, y+1)

在得出了這兩個傾斜度後,你就可以計算多邊形點的法線了。


這裡有一個多邊形,圖上繪出了它的一條法線向量——n。除此,還有兩條向量,它們將用來調節該點法線向量。這兩條向量必須與當前被渲染的多邊形的凹凸圖對齊,換句話說,它們要與凹凸圖使用同一種座標軸。下邊的圖分別是凹凸圖和多邊形,兩副圖都顯示了U、V兩條向量(譯者注:也就是平面2D座標的兩條軸):

現在你可以看到被調節後的新法線向量了。這個調節公式很簡單: 
New_Normal = Normal + (U * x_gradient) + (V * y_gradient) 
有了新法線向量後,你就可以通過補色渲染技術計算出多邊形每個點的亮度了。

注:上面的調節公式你可以理解成:原向量+偏移向量=新向量(向量的加法)

上面的原理簡單理解就是:首先,通過灰度圖來表現凹凸,那麼,我們怎樣判斷一個點處在凹凸的邊緣呢?答案是通過斜率,比如我要對(x,y)進行取樣,怎樣求這一點的斜率呢,學過數學的都知道,我們可以通過兩點確定一條直線,進而求出這條直線的斜率。那麼我們就可以對(x-1,y)和(x+1,y)兩點進行取樣,豎向也是一樣,通過(x,y-1)和(x,y+1)進行取樣,那麼,我們就可以獲得這一點上灰度值的變化,如果灰度值不變,說明該點不在邊緣,如果灰度值有改變,那麼說明該點在邊緣,那麼我們就可以根據這個斜率值來修改法線,進而修改光照結果。

我們在PhotoShop中畫了一張簡單的的Bump Map,目前RGB通道都有資訊:


這種方法的好處是非常直觀,我們可以從高度圖中明確地知道一個模型表面的凹凸情況,但缺點是計算更加複雜,在實時計算時不能直接得到
表面法線,而是需要由畫素的灰度值計算而得,因此需要消耗更多的效能。

Bump Map型別的shader如下:

Shader "Demo/BumpMap"
{
	//屬性
	Properties{
		_Diffuse("Diffuse", Color) = (1,1,1,1)           // 漫反射顏色
		_MainTex("Base 2D", 2D) = "white"{}              // 紋理顏色
		_BumpMap("Bump Map", 2D) = "black"{}             // 高度圖
		_BumpScale ("Bump Scale", Range(0, 30.0)) = 10.0 // 凹凸程度
	}

	//子著色器	
	SubShader
	{
		Pass
		{
			//定義Tags
			Tags{ "RenderType" = "Opaque" }

			CGPROGRAM
			//使用vert函式和frag函式
			#pragma vertex vert
			#pragma fragment frag	
			//引入標頭檔案
			#include "Lighting.cginc"
			//定義Properties中的變數
			fixed4 _Diffuse;
			sampler2D _MainTex;
			//使用了TRANSFROM_TEX巨集就需要定義XXX_ST
			float4 _MainTex_ST;
			sampler2D _BumpMap;
			float4 _BumpMap_TexelSize;
			float _BumpScale;

			//定義結構體:應用階段到vertex shader階段的資料
			struct a2v
			{
				float4 vertex : POSITION;
				float3 normal : NORMAL;
				float4 texcoord : TEXCOORD0;
			};
			//定義結構體:vertex shader階段輸出的內容
			struct v2f
			{
				float4 pos : SV_POSITION;
				float3 worldNormal : TEXCOORD0;
				//轉化紋理座標
				float2 uv : TEXCOORD1;
			};

			//定義頂點shader
			v2f vert(a2v v)
			{
				v2f o;
				o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
				//把法線轉化到世界空間
				o.worldNormal = mul(v.normal, (float3x3)_World2Object);
				//通過TRANSFORM_TEX巨集轉化紋理座標,主要處理了Offset和Tiling的改變,預設時等同於o.uv = v.texcoord.xy;
				o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);
				return o;
			}

			//定義片元shader
			fixed4 frag(v2f i) : SV_Target
			{
				//unity自身的diffuse也是帶了環境光,這裡我們也增加一下環境光
				fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * _Diffuse.xyz;
				//歸一化法線,即使在vert歸一化也不行,從vert到frag階段有差值處理,傳入的法線方向並不是vertex shader直接傳出的
				fixed3 worldNormal1 = normalize(i.worldNormal);
				//取樣bump貼圖,需要知道該點的斜率,xy方向分別求,所以對於一個點需要取樣四次
				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;
				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;
				//用上面的斜率來修改法線的偏移值
				fixed3 worldNormal = fixed3(worldNormal1.x + bumpValueU * _BumpScale, worldNormal1.y + bumpValueV * _BumpScale, worldNormal1.z);
				//把光照方向歸一化
				fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);
				//根據半蘭伯特模型計算畫素的光照資訊
				fixed3 lambert = 0.5 * dot(worldNormal, worldLightDir) + 0.5;
				//最終輸出顏色為lambert光強*材質diffuse顏色*光顏色
				fixed3 diffuse = lambert * _Diffuse.xyz * _LightColor0.xyz + ambient;
				//進行紋理取樣
				fixed4 color = tex2D(_MainTex, i.uv);
				return fixed4(diffuse * color.rgb, 1.0);
			}

			ENDCG
		}

	}
		//前面的Shader失效的話,使用預設的Diffuse
		FallBack "Diffuse"
}
效果圖如下:


法線貼圖(Normal Map)


隨著GPU的發展,Geforce3的出現,帶來了真正的Normal Mapping技術,也叫作Dot3 bump mapping。這種Normal Map就是我們現在在使用的法線貼圖技術。與之前通過灰度表現介面的凹凸程度,進而修改法線的方式完全不同,這種Normal Map直接將法線儲存到了法線貼圖中,也就是說,我們從法線貼圖讀取的法線直接就可以使用了,而不是需要像上面那樣,再通過灰度漸變值來修改法線。這種法線對於製作來說,沒有灰度圖那樣直白,但是卻是真正的法線貼圖技術,所謂烘焙法線,烘焙的就是這個。

 DOT3 Bump Map(點乘凹凸貼圖),它使用的是Normal Map,這是目前圖形硬體中使用的主要方法,它不需要儲存高度,只需要將表面的實際法線作為(x,y,z)向量儲存在法線圖中,然後可以將含有法線的凹凸紋理和經過插值的光源向量在每個象素點結合起來,可以使用點乘。它的一個優點就是可以直接用來計算凹凸塊上的鏡面高光。 
   注意在max中,BumpMap用於逐畫素光照,在Gamebryo中BumpMap用於模擬頂點高度凹凸,而NormalMap才是逐畫素光照。  


雖然灰度圖不會直接被用於實時計演算法線了,但是在離線工具中卻提供了直接通過灰度圖生成法線的功能。Unity中就有這種功能: 我們把之前畫的那張灰度圖直接通過這種方式改成法線貼圖,從法線貼圖中我們就直接可以看到凹凸的效果了。在Unity裡實現法線貼圖的shader之前,首先看幾個問題:

法線貼圖是怎樣儲存的

法線紋理中儲存的就是表面的法線方向。由於法線方向的分量範圍在[-1, 1 ],而畫素的分量範圍為[0, 1],因此我們需要做一個對映,通常使用的對映就是:

這就要求,我們在Shader 中對法線紋理進行紋理來樣後,還需要對結果進行一次反對映的過程,以得到原先的法線方向。反對映的過程實際就是使用上面對映函式的逆函式:

                                                                normal = pixel x 2 -1

這個步驟,Unity已經為我們完成了,我們在計演算法線的時候,只需要呼叫UnpackNormal這個函式就可以實現區間的重新對映。

UnityCG.cginc中可以看到UnpackNormal這個函式的實現:

inline fixed3 UnpackNormalDXT5nm (fixed4 packednormal)
{
	fixed3 normal;
	normal.xy = packednormal.wy * 2 - 1;
	normal.z = sqrt(1 - saturate(dot(normal.xy, normal.xy)));
	return normal;
}

inline fixed3 UnpackNormal(fixed4 packednormal)
{
#if defined(UNITY_NO_DXT5nm)
	return packednormal.xyz * 2 - 1;
#else
	return UnpackNormalDXT5nm(packednormal);
#endif
}
當我們需要使用那些包含了法線對映的內建的Unity Shader 時,必須把使用的法線紋理按上面的方式標識成Normal map 才能得到正確結果(即便你忘了這麼做, Unity 也會在材質面板中提醒你修正這個問題〉,這是因為這些Unity Shader 都使用了內建的UnpackNormal 函式來取樣法線方向。
這裡,我們看到了兩個UnpackNormal的函式,下面的就是我們所說的直接轉化區間。而上面的那個函式,看定義來說,是為了專門解出DXT5nm格式的normal map,這種型別的normal map,只用儲存法向量中的兩個通道,然後解開的時候,需要計算一下,重新算出另一個向量方向。這樣可以實現的原理在於,儲存的向量是單位向量,長度一定的情況下,就可以通過sqrt(1 - x^2 - y^2)來求得,如下圖:
不過這是一種時間換空間的做法,以犧牲時間的代價,換來更好的壓縮比以及壓縮後的效果。關於DXT5nm,附上一篇參考文章: Normal Map的dds壓縮

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

既然知道了法線可以儲存在貼圖中,我們就再來看一下,為什麼法線貼圖中一般都儲存的是切線空間,為什麼不儲存在世界空間或者模型空間。首先看一下世界空間,如果我們的法線貼圖儲存的世界空間的法線資訊,我們可以直接解出法線的值,在世界空間進行計算,是最直接並且計算效率最高的做法,但是世界空間的法線貼圖就跟當前環境之間耦合過大了,比如同樣的兩個模型,僅僅是旋轉方向不同,也需要兩張法線貼圖,這很明顯是多餘的,於是就有人想出了基於模型空間的法線,基於模型空間,在計算時,把模型空間的法線轉換到世界空間,雖然多了一步操作,但是同一個模型可以共用法線,不用考慮旋轉等問題。但是,人們感覺模型空間的法線貼圖跟模型的耦合度還是高,那就繼續解耦吧,於是基於切線空間的法線貼圖就誕生了。下圖為模型空間與切線空間法線。

然而,由於方向是相對於座標空間來說的,那麼法線紋理中儲存的法線方向在哪個座標空間中呢?對於模型頂點自帶的法線,它們是定義在模型空間中的,因此一種直接的想法就是將修改後的模型空間中的表面法線儲存在一張紋理中,這種紋理被稱為是模型空間的法線紋理( object-space normal map )。然而,在實際製作中,我們往往會採用另一種座標空間,即模型頂點的切線空間( tangent space )來儲存法線。對於模型的每個頂點,它都有一個屬於自己的切線空間,這個切線空間的原點就是該頂點本身,而z 軸是頂點的法線方向(n), x 軸是頂點的切線方向(t),而y 軸可由法線和切線叉積而得,也被稱為是副切線( bitangent, b )或副法線,如下圖所示。


這種紋理被稱為是切線空間的法線紋理(tangent-space normal map ) 。
所謂的切線空間,跟那些比較常見的座標系,比如世界座標,模型座標一樣,也是一個座標系,用三個基向量就可以表示。我們用模型上的一個點來看,這個點的有一個法線的方向,也就是這個點所在的面的法線的方向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矩陣的構建過程:
// Declares 3x3 matrix 'rotation', filled with tangent space basis
#define TANGENT_SPACE_ROTATION \
	float3 binormal = cross( normalize(v.normal), normalize(v.tangent.xyz) ) * v.tangent.w; \
	float3x3 rotation = float3x3( v.tangent.xyz, binormal, v.normal )
float3x3是行向量構建,可以參照 這裡,然後我們就可以通過mul(rotation,v)把需要的向量從模型空間轉化到tangent空間。不過大部分內容Unity已經幫我們做好了,主要是TBN空間的建立,如果需要自己寫渲染器的話,這個是一個比較麻煩的過程,也有類似 3DMax中匯出頂點tangent值中的做法,直接在匯出的時候將tangent空間資訊匯出,儲存在頂點中。
關於法線貼圖,尤其是切線空間的相關問題,附上幾篇參考連結: CryEngine:Tangent Space Normal MapGAMASUTRA:Messing with Tangent SpaceOpenGL Tutorial:Normal MapingNormap Map中的值D3D遊戲程式設計:切線空間關於BumpMap中的TBN矩陣Tangent Space基向量計算方法
總體來說,模型空間下的法線紋理更符合人類的直觀認識,而且法線紋理本身也很直觀, 容易調整,因為不同的法線方向就代表了不同的顏色。但美術人員往往更喜歡使用切線空間下的法線紋理。那麼,為什麼他們更偏好使用這個看起來“很鱉腳”的切線空間呢?
實際上,法線本身儲存在哪個座標系中都是可以的, 我們甚至可以選擇儲存在世界空間下。
但問題是,我們並不是單純地想要得到法線,後續的光照計算才是我們的目的。而選擇哪個座標系意味著我們需要把不同資訊轉換到相應的座標系中。例如,如果選擇了切線空間, 我們需要把從法線紋理中得到的法線方向從切線空間轉換到世界空間(或其他空間)中。

總體來說,使用模型空間來儲存法線的優點如下。

  • 實現簡單, 更加直觀。我們甚至都不需要模型原始的法線和切線等資訊, 也就是說,計算更少。生成它也非常簡單, 而如果要生成切線空間下的法線紋理,由於模型的切線一般是和UV 方向相同,因此想要得到效果比較好的法線對映就要求紋理對映也是連續的。
  • 在紋理座標的縫合處和尖銳的邊角部分,可見的突變(縫隙)較少,即可以提供平滑的邊界。這是因為模型空間下的法線紋理儲存的是同一座標系下的法線資訊, 因此在邊界處通過插值得到的法線可以平滑變換。而切線空間下的法線紋理中的法線資訊是依靠紋理座標的方向得到的結果,可能會在邊緣處或尖銳的部分造成更多可見的縫合跡象。
但使用切線空間有更多優點。
  • 自由度很高。 模型空間下的法線紋理記錄的是絕對法線資訊,僅可用於建立它時的那個模型,而應用到其他模型上效果就完全錯誤了。而切線空間下的法線紋理記錄的是相對法線資訊,這意味著,即便把該紋理應用到一個完全不同的網格上,也可以得到一個合理的結果。
  • 可進行UV 動畫。比如,我們可以移動一個紋理的UV 座標來實現一個凹凸移動的效果,但使用模型空間下的法線紋理會得到完全錯誤的結果。原因同上。這種UV 動畫在水或者火山熔岩這種型別的物體上會經常用到。
  • 可以重用法線紋理。比如, 一個磚塊,我們僅使用一張法線紋理就可以用到所有的6 個面上。原因同上。
  • 可壓縮。由於切線空間下的法線紋理中法線的Z 方向總是正方向,因此我們可以僅儲存XY 方向,而推導得到Z 方向。而模型空間下的法線紋理由於每個方向都是可能的, 因此必須儲存3 個方向的值,不可壓縮。

切線空間下的法線紋理的前兩個優點足以讓很多人放棄模型空間下的法線紋理而選擇它。從上面的優點可以看出,切線空間在很多情況下都優於模型空間,而且可以節省美術人員的工作。

最後總結一下: tangent space下,其實跟我們上一節計算的斜率很像,我們計算斜率基本也是tangent值。而這裡T(x軸)使用normalize(dx/du, dy/du, dz/du),相當於計算了模型空間下x,y,z值隨著紋理u座標方向的斜率,換句話說,切線空間反映了模型空間座標xyz隨著紋理座標uv的變化率(坡度),這也正是normal map中要儲存的資訊,所以normal map中的內容正好可以使用切線空間進行儲存。

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

下圖分別給出了模型空間和切線空間下的法線紋理:


從圖7.13 中可以看出,模型空間下的法線紋理看起來是“五顏六色”的。這是因為所有法線所在的座標空間是同一個座標空間,即模型空間,而每個點儲存的法線方向是各異的,有的是(0,1, 0),經過對映後儲存到紋理中就對應了RGB(0 .5, 1, 0.5)淺綠色,有的是(0,-1 , 0),經過對映後儲存到紋理中就對應了(0 .5, 0, 0. 5)紫色。既然法線貼圖中儲存的是法線的方向,也就是說是一個Vector3型別的變數,剛好和圖片的RGB格式不謀而合。


而切線空間下的法線紋理看起來幾乎全部是淺藍色的。這是因為,每個法線方向所在的座標空間是不一樣的,即是表面每點各自的切線空間。這種法線紋理其實就是儲存了每個點在各自的切線空間中的法線擾動方向。也就是說,如果一個點的法線方向不變,那麼在它的切線空間中, 新的法線方向就是z 軸方向,即值為(0, 0, 1 ) , 經過對映後儲存在紋理中就對應了RGB(0.5, 0.5, 1 )淺藍色。而這個顏色就是法線紋理中大片的藍色。這些藍色實際上說明頂點的大部分法線是和模型本身法線一樣的,不需要改變。

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


法線一般就是這個顏色!那麼,其他的地方,如果有凹凸感,就需要調整法線的方向,那麼顏色就不一樣了。

Unity下法線貼圖Shader實現

我們就可以看一下Unity Shader中實現法線貼圖的方式。光照模型仍然採用之前的半蘭伯特光照,vertex fragemnt shader實現:

Shader "Deme/NormalMapInTangentSpace"
{
	//屬性
	Properties{
		_Diffuse("Diffuse", Color) = (1,1,1,1)             // 漫反射顏色
		_MainTex("Base 2D", 2D) = "white"{}				   // 紋理顏色
		_BumpMap("Bump Map", 2D) = "bump"{}                // 法線貼圖
		_BumpScale ("Bump Scale", Range(0.0, 30.0)) = 10.0 // 凹凸程度
	}

	//子著色器	
	SubShader
	{
		Pass
		{
			//定義Tags
			Tags{ "RenderType" = "Opaque" }

			CGPROGRAM
			//使用vert函式和frag函式
			#pragma vertex vert
			#pragma fragment frag	
			//引入標頭檔案
			#include "Lighting.cginc"
			//定義Properties中的變數
			fixed4 _Diffuse;
			sampler2D _MainTex;
			//使用了TRANSFROM_TEX巨集就需要定義XXX_ST
			float4 _MainTex_ST;
			sampler2D _BumpMap;
			float _BumpScale;

			//定義結構體:vertex shader階段輸出的內容
			struct v2f
			{
				float4 pos : SV_POSITION;
				//轉化紋理座標
				float2 uv : TEXCOORD0;
				//tangent空間的光線方向
				float3 lightDir : TEXCOORD1;
			};

			//定義頂點shader
			v2f vert(appdata_tan v)
			{
				v2f o;
				o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
				// Compute the binormal  
		//      float3 binormal = cross( normalize(v.normal), normalize(v.tangent.xyz) ) * v.tangent.w;  
		//      // Construct a matrix which transform vectors from object space to tangent space  
		//      float3x3 rotation = float3x3(v.tangent.xyz, binormal, v.normal);  
				  // Or just use the built-in macro  
				//這個巨集為我們定義好了模型空間到切線空間的轉換矩陣rotation,注意後面有個;
				TANGENT_SPACE_ROTATION;
				//ObjectSpaceLightDir可以把光線方向轉化到模型空間,然後通過rotation再轉化到切線空間
				o.lightDir = mul(rotation, ObjSpaceLightDir(v.vertex));
				//通過TRANSFORM_TEX巨集轉化紋理座標,主要處理了Offset和Tiling的改變,預設時等同於o.uv = v.texcoord.xy;
				o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);
				return o;
			}

			//定義片元shader
			fixed4 frag(v2f i) : SV_Target
			{
				//unity自身的diffuse也是帶了環境光,這裡我們也增加一下環境光
				fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * _Diffuse.xyz;

				// Get the texel in the normal map  
				fixed4 packedNormal = tex2D(_BumpMap, i.uv);  
				fixed3 tangentNormal;  
				// If the texture is not marked as "Normal map"  
		//      tangentNormal.xy = (packedNormal.xy * 2 - 1) * _BumpScale;  
		//      tangentNormal.z = sqrt(1.0 - saturate(dot(tangentNormal.xy, tangentNormal.xy)));  
                  
				 //解出切線空間法線
				 // Or mark the texture as "Normal map", and use the built-in funciton  
				tangentNormal = UnpackNormal(packedNormal);  
				tangentNormal.xy *= _BumpScale;  
				tangentNormal.z = sqrt(1.0 - saturate(dot(tangentNormal.xy, tangentNormal.xy)));  

				//normalize一下切線空間的光照方向
				float3 tangentLight = normalize(i.lightDir);
				//根據半蘭伯特模型計算畫素的光照資訊
				fixed3 lambert = 0.5 * dot(tangentNormal, tangentLight) + 0.5;
				//最終輸出顏色為lambert光強*材質diffuse顏色*光顏色
				fixed3 diffuse = lambert * _Diffuse.xyz * _LightColor0.xyz + ambient;
				//進行紋理取樣
				fixed4 color = tex2D(_MainTex, i.uv);
				return fixed4(diffuse * color.rgb, 1.0);
			}

			ENDCG
		}

	}
		//前面的Shader失效的話,使用預設的Diffuse
		FallBack "Diffuse"
}
程式碼詳解: 在頂點著色器中,我們把模型空間下切線方向、 副切線方向和法線方向按行排列來得到從模型空間到切線空間的變換矩陣rotation。需要注意的是,在計算副切線時我們使用v.tangent.w 和叉積結果進行相乘,這是因為和切線與法線方向都垂直的方向有兩個,而w 決定了我們選擇其中哪一個方向。Unity 也提供了一個內建巨集 TANGENT_SPACE_ROTATION (在UnityCG.cginc 中被定義〉來幫助我們直接計算得到rotation 變換矩陣,它的實現和上述程式碼完全一樣。然後,我們使用Unity 的內建函式ObjSpaceLightDir和 ObjSpaceViewDir 來得到模型空間下的光照和視角方向,再利用變換矩陣rotation 把它們從模型空間變換到切線空間中。

在片元著色器中,我們首先利用tex2D 對法線紋理 _BumpMap 進行取樣。正如本節一開頭所講的,法線紋理中儲存的是把法線經過對映後得到的畫素值, 因此我們需要把它們反映射回來。
如果我們沒有在Unity 裡把該法線紋理的型別設定成Normal map,就需要在程式碼中手動進行這個過程。我們首先把packedNormal 的xy 分量按之前提到的公式映射回法線方向,然後乘以 _BumpScale (控制凹凸程度〉來得到tangentNormal 的xy 分量。由於法線都是單位向量,因此tangentNormal.z 分量可以由tangentNonnal.xy 計算而得。由於我們使用的是切線空間下的法線紋理, 因此可以保證法線方向的z 分量為正。在Unity 中,為了方便Unity 對法線紋理的儲存進行優化,我們通常會把法線紋理的紋理型別標識成Normal map, Unity 會根據平臺來選擇不同的壓縮方法。這時,如果我們再使用上面的方法來計算就會得到錯誤的結果,因為此時 _BumpMap的rgb 分量並不再是切線空間下法線方向的xyz 值了。在這種情況下,我們可以使用Unity 的內建函式UnpackNormal 來得到正確的法線方向。

效果如下:

世界空間下法線切圖的計算:

我們需要在片元著色器中把法線方向從切線空間變換到世界空間下。這種方法的基本思想是:在頂點著色器中計算從切線空間到世界空間的變換矩陣,並把它傳遞給片元著色器。變換矩陣的計算可以由頂點的切線、副切線和法線在世界空間下的表示來得到。最後,我們只需要在片元著色器中把法線紋理中的法線方向從切線空間變換到世界空間下即可。儘管這種方法需要更多的計算,但在需要使用Cubemap 進行環境對映等情況下,我們就需要使用這種方法。

Shader實現如下:

Shader "Deme/NormalMapInWorldSpace"
{
	//屬性
	Properties{
		_Diffuse("Diffuse", Color) = (1,1,1,1)             // 漫反射顏色
		_MainTex("Base 2D", 2D) = "white"{}				   // 紋理顏色
		_BumpMap("Bump Map", 2D) = "bump"{}                // 法線貼圖
		_BumpScale ("Bump Scale", Range(0.0, 30.0)) = 10.0 // 凹凸程度
	}

	//子著色器	
	SubShader
	{
		Pass
		{
			//定義Tags
			Tags{ "RenderType" = "Opaque" }

			CGPROGRAM
			//使用vert函式和frag函式
			#pragma vertex vert
			#pragma fragment frag	
			//引入標頭檔案
			#include "Lighting.cginc"
			//定義Properties中的變數
			fixed4 _Diffuse;
			sampler2D _MainTex;
			//使用了TRANSFROM_TEX巨集就需要定義XXX_ST
			float4 _MainTex_ST;
			sampler2D _BumpMap;
			float _BumpScale;

			//定義結構體:vertex shader階段輸出的內容
			struct v2f
			{
				float4 pos : SV_POSITION;
				//轉化紋理座標
				float2 uv : TEXCOORD0;
				//TtoW0 、TtoW1 和TtoW2 就依次儲存了從切線空間到世界空間的變換矩陣的每一行
				//實際上,對方向向量的變換隻需要使用3 × 3大小的矩陣
				//也就是說,每一行只需要使用float3 型別的變數即可
				float4 TtoW0 : TEXCOORD1;    
				float4 TtoW1 : TEXCOORD2;    
				float4 TtoW2 : TEXCOORD3; 
			};

			//定義頂點shader
			v2f vert(appdata_tan v)
			{
				v2f o;
				o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
				
			    float3 worldPos = mul(_Object2World, v.vertex).xyz;    
				fixed3 worldNormal = UnityObjectToWorldNormal(v.normal);    
				fixed3 worldTangent = UnityObjectToWorldDir(v.tangent.xyz);    
				fixed3 worldBinormal = cross(worldNormal, worldTangent) * v.tangent.w;   
                  
				// Compute the matrix that transform directions from tangent space to world space  
				// Put the world position in w component for optimization  
				o.TtoW0 = float4(worldTangent.x, worldBinormal.x, worldNormal.x, worldPos.x);  
				o.TtoW1 = float4(worldTangent.y, worldBinormal.y, worldNormal.y, worldPos.y);  
				o.TtoW2 = float4(worldTangent.z, worldBinormal.z, worldNormal.z, worldPos.z);  

				//通過TRANSFORM_TEX巨集轉化紋理座標,主要處理了Offset和Tiling的改變,預設時等同於o.uv = v.texcoord.xy;
				o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);
				return o;
			}

			//定義片元shader
			fixed4 frag(v2f i) : SV_Target
			{
				//unity自身的diffuse也是帶了環境光,這裡我們也增加一下環境光
				fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * _Diffuse.xyz;

				 // Get the position in world space        
				float3 worldPos = float3(i.TtoW0.w, i.TtoW1.w, i.TtoW2.w);  
				// Compute the light dir in world space  
				fixed3 lightDir = normalize(UnityWorldSpaceLightDir(worldPos));  
              
				 // Get the normal in tangent space  
				fixed3 bump = UnpackNormal(tex2D(_BumpMap, i.uv));  
				bump.xy *= _BumpScale;  
				bump.z = sqrt(1.0 - saturate(dot(bump.xy, bump.xy)));  
				// Transform the narmal from tangent space to world space  
				//mul(TtoW, bump) 下面的一句等同於這句,只是矩陣用向量分行表示
				bump = normalize(half3(dot(i.TtoW0.xyz, bump), dot(i.TtoW1.xyz, bump), dot(i.TtoW2.xyz, bump)));  

				//根據半蘭伯特模型計算畫素的光照資訊
				fixed3 lambert = 0.5 * dot(bump, lightDir) + 0.5;
				//最終輸出顏色為lambert光強*材質diffuse顏色*光顏色
				fixed3 diffuse = lambert * _Diffuse.xyz * _LightColor0.xyz + ambient;
				//進行紋理取樣
				fixed4 color = tex2D(_MainTex, i.uv);
				return fixed4(diffuse * color.rgb, 1.0);
			}

			ENDCG
		}

	}
		//前面的Shader失效的話,使用預設的Diffuse
		FallBack "Diffuse"
}

程式碼詳解: 在頂點著色器中, 我們計算了世界空間下的頂點切線、副切線和法線的矢量表示, 並把它們 按列 擺放得到從切線空間到世界空間的變換矩陣。我們把該矩陣的每一行分別儲存在TtoW0 、TtoWl1和TtoW2 中, 並把世界空間下的頂點位置的xyz 分量分別儲存在了這些變數的w 分量中,以便充分利用插值暫存器的儲存空間。 在片元著色器中,我們首先從TtoW0 、TtoW1和TtoW2 的w 分量中構建世界空間下的座標。然後,使用內建的UnityWorldSpaceLightDir 函式得到世界空間下的光照和視角方向。
接著,我們使用內建的UnpackNormal 函式對法線紋理進行取樣和解碼(需要把法線紋理的格式標識成Normal map ), 並使用_BumpScale 對其進行縮放。最後,我們使用TtoW0 、TtoW1 和TtoW2儲存的變換矩陣把法線變換到世界空間下。這是通過使用點乘操作來實現矩陣的每一行和法線相乘來得到的。
從視覺表現上,在切線空間下和在世界空間下計算光照幾乎沒有任何差別。

總結

本篇文章簡單探究了一下bump map以及normal map的原理以及在Unity中的實現。法線貼圖可以很好地在低模上模擬高模的效果,雖然多采樣了一次貼圖,但是能模擬出數倍於模型本身面數的效果,極大地提升了實時渲染的效果。雖然法線貼圖也有一些弊端,因為法線貼圖只是給人造成一種凹凸的假象,所以在視角與物體平行時,看到的物體表面仍然是平的。並且還會有一些穿幫的現象,不過畢竟瑕不掩瑜,法線貼圖仍然是目前渲染中最常使用的技術之一。為了解決上面的問題,一些更加高階的貼圖技術,如視差貼圖和位移貼圖就誕生了。之後再研究這兩種更加高階一點的貼圖技術,本篇到此為止。上面給出了一些關於tangent空間求解的參考連結。最後再附上一些關於法線貼圖原理的參考。
http://blog.csdn.net/puppet_master/article/details/51858511
https://www.zhihu.com/question/23706933
http://www.cnblogs.com/flytrace/p/3387748.html
http://dna.yiihuu.com/36343.html
http://blog.csdn.net/zhuxiaoyang2000/article/details/7072232
http://blog.csdn.net/candycat1992/article/details/41605257
http://www.cnblogs.com/-867259206/p/5627565.html

源工程下載:UnityShader 浮雕凹凸貼圖BumpMap與法線貼圖NormalMap的原理及其區別