Unity Shader入門精要學習筆記 - 第7章 基礎紋理

轉自 馮樂樂的 《Unity Shader 入門精要》

紋理最初的目的就是使用一張圖片來控制模型的外觀。使用紋理映射技術,我們可以把一張圖“黏”在模型表面,逐紋素地控制模型的顏色。

在美術人員建模的時候,通常會在建模軟件中利用紋理展開技術把紋理映射座標存儲在每個頂點上。紋理映射座標定義了該頂點在紋理中對應的2D座標。通常,這些座標使用一個二維座標(u,v)來表示,其中u是橫向座標,而v是縱向座標。因此,紋理映射座標也被稱爲UV座標。

儘管紋理的大小可以是多種多樣的,例如可以是256×256或者1028×1028,但頂點UV座標的範圍通常都被歸一化到[0,1]範圍內。需要注意的是,紋理採樣時使用的紋理座標不一定是在[0,1]範圍內。實際上,這種不在[0,1]範圍內的紋理座標有時會非常有用。與之關係緊密的是紋理的平鋪模式,它將決定渲染引擎在遇到不在[0,1]範圍內的紋理座標時如何進行紋理採樣。

在OpenGL 裏,紋理空間的原點位於左下角,而在Directx 中,原點位於左上角。幸運的是,Unity 在絕大多數情況下爲我們處理好了這個差異問題,也就是說,即便遊戲的目標平臺可能既有OpenGL 風格的,也有DirectX 風格的,但我們在Unity 中使用的通常只有一種座標系。Unity 使用的紋理空間是符合OpenGL 傳統的,也就是說,原點位於紋理左下角。如下圖

單張紋理

我們通常會使用一張紋理來代替物體的漫反射顏色。可以得到類似下圖中的效果

在本例中,我們仍然使用Blinn-Phong光照模型來計算光照。

我們新建一個Shader,代碼如下:

 

Shader "Unity Shader Book/Chapter 7/Single Texture"{
	Properties{
		_Color ("Color Tint", Color) = (1,1,1,1)
		_MainTex("MainTex", 2D) = "white" {}
		_Specular("Specular", Color) = (1,1,1,1)
		_Gloss("Gloss",Range(8.0,256)) = 20
	}
	
	SubShader{
		Pass{
			Tags {"LightMode" = "ForwardBase"}
			
			CGPROGRAM
			#pragma vertex vert
			#pragma fragment frag
			
			#include "Lighting.cginc"
			
			fixed4 _Color;
			sampler2D _MainTex;
			//與其他屬性類型不同的是,我們還需要爲紋理類型的屬性聲明一個float4類型
			//的變量_MainTex_ST。其中,_MainTex_ST的名字不是任意起的。在Unity中,
			//我們需要使用紋理名_ST的方式來聲明某個紋理的屬性。其中ST是縮放和平移的縮寫。
			//_MainTex_ST可以讓我們得到該紋理的縮放和平移值
			//_MainTex_ST.xy存儲的是縮放值,_MainTex_ST.zw存儲的是偏移值
			float4 _MainTex_ST;
			fixed4 _Specular;
			float _Gloss;
			
			//頂點着色器的輸入結構體
			struct a2v{
				float4 vertex : POSITION;
				float3 normal : NORMAL;
				//unity會將模型的第一組紋理座標存儲到該變量中。
				float4 texcoord : TEXCOORD0;
			};
			
			//頂點着色器的輸出結構體
			struct v2f{
				float4 pos : SV_POSITION;
				float3 worldNormal : TEXCOORD0;
				float3 worldPos : TEXCOORD1;
				//用來存儲紋理座標
				float2 uv : TEXCOORD2;
			};
			
			//頂點着色器
			//在頂點着色器中,我們使用紋理的屬性值_MainTex_ST來對頂點紋理座標進行變換,
			//得到最終的紋理座標。計算過程是,首先使用縮放屬性_MainTex_ST.xy對頂點紋理座標
			//進行縮放,然後再使用偏移屬性MainTex_ST.zw對結果進行偏移
			v2f vert(a2v v){
				v2f o;
				o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
				o.worldNormal = UnityObjectToWorldNormal(v.normal);
				o.worldPos = mul(_Object2World, v.vertex).xyz;
				o.uv = v.texcoord.xy * _MainTex_ST.xy + _MainTex_ST.zw;
				//可以使用內置函數來代替
				//o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);
				return o;
			}
			
			fixed4 frag(v2f i) : SV_Target{
				//計算了世界空間下的法線方向和光照方向
				fixed3 worldNormal = normalize(i.worldNormal);
				fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
				//使用tex2D函數對紋理進行採樣,第一個參數是需要被採樣的紋理,第二個是float2類型的紋理座標
				//我們使用採樣結果和顏色屬性_Color的乘積來作爲材質的反射率albedo
				fixed3 albedo = tex2D(_MainTex,i.uv).rgb * _Color.rgb;
				//使用albedo來計算漫反射光照的結果
				fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;
				
				fixed3 diffuse  = _LightColor0.rgb * albedo * max(0,dot(worldNormal, worldLightDir));
				
				fixed3 viewDir = normalize(UnityWorldSpaceViewDir(i.worldPos));
				fixed3 halfDir = normalize(worldLightDir + viewDir);
				fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(max(0,dot(worldNormal,halfDir)),_Gloss);
				
				return fixed4(ambient + diffuse + specular, 1.0);
			}
			ENDCG
		}
	}
	Fallback "Specular"
}


在我們向Unity 中導入一張紋理資源後,可以在它的材質面板上調整屬性,如下圖所示:

 

紋理面板中的第一個屬性是紋理類型。在本節中,我們使用的是Texture類型,在下面的法線紋理一節中,我們會使用Normal map類型。而在後面的章節中,我們還會看到Cubemap 等高級紋理類型。我們之所以要爲導入的紋理選擇合適的類型,是因爲只有這樣才能讓Unity 知道我們的意圖,爲Unity Shader 傳遞正確的紋理,並在一些情況下可以讓Unity 對該紋理進行優化。

當把紋理類型設置爲Texture後,下面會有一個Alpha from Grayscale 複選框,如果勾選了它,那麼透明通道的值將會由每個像素的灰度值生成。在這裏我們不需要勾選它。

下面一個屬性非常重要——Wrap Mode。它決定了當紋理座標超過[0,1]範圍後將會被平鋪。Wrap Mode 有兩種模式:一種是Repeat,在這種模式下,如果紋理座標超過了1,那麼它的整數部分將會被捨棄,而直接使用小數部分進行採樣,這樣的結果是紋理將會不斷重複;另一種是Clamp,在這種模式下,如果紋理座標大於1,那麼將會截取到1,如果小於0,那麼將會截取到0.下圖給出了兩種模式下平鋪一張紋理的效果:

需要注意的是,想要紋理得到這樣的效果,我們必須使用紋理的屬性在Unity Shader 中對頂點紋理座標進行相應的變換。也就是說,代碼中需要包含類似這樣的代碼:

 

o.uv = u.texcoord.xy * _MainTex_ST.xy + _MainTex_ST.zw;
// Or just call the built-in function
o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);

我們還可以在材質面板中調整紋理的偏移量,下圖給出了兩種模式下調整紋理偏移量的一個例子。

 


上圖展示了再紋理的偏移屬性爲(0.2,0.6)時分別使用兩種Wrap Mode 的結果。

紋理導入面板中的下一個屬性是Filter Mode 屬性,它決定了當紋理由於變換而產生拉伸時將會採用哪種濾波模式。Fliter Mode 支持3種模式:Point,Bilinear 以及 Trilinear 。它們得到的圖片濾波效果依次提升,但需要耗費的性能也依次增大。紋理濾波會影響放大或縮小紋理時得到的圖片質量。例如,當我們把一張64×64 大小的紋理貼在一個512×512大小的平面上時,就需要放大紋理,3種濾波模式下的結果如下圖所示:

紋理縮小的過程比放大更加複雜一些,此時原紋理中的多個像素將會對應一個目標像素。紋理縮放更加複雜的原因在於我們往往需要處理抗鋸齒問題,一個最常用的方法就是使用多級漸遠紋理技術。多級漸遠紋理技術將原紋理提前用濾波處理來得到很多更小的圖像,形成了一個圖像金字塔,每一層都是對上一層圖像降採樣的結果。這樣在實時運行時,就可以快速得到結果像素,例如當物體遠離攝像機時,可以直接使用較小的紋理。但缺點是需要使用一定的空間用於存儲這些多級漸遠紋理,通常會多佔33%的內存空間。這是一種典型的用空間換取時間的方法。在Unity 中,我們可以在紋理導入面板中,首先將紋理類型選擇Advanced,再勾選Generate Mip Maps 即可開啓多級漸遠紋理技術。同時,我們還可以選擇生成多級漸遠紋理時是否使用線性空間以及採用的濾波器等。如下圖所示

下圖給出了一個從傾斜的角度觀察一個網格結構的地板時,使用不同Filter Mode (同時也使用了多級漸遠紋理技術)得到的效果。

在內部實現上,Point模式使用了最近鄰濾波,在放大或縮小時,它的採樣像素數目通常只有一個,因此圖像會看起來有種像素風格的效果。而Bilinear 濾波則使用了線性濾波,對於每個目標像素,它會找到4ge鄰近像素,然後對它們進行線性插值混合得到最終像素,因此圖像看起來像被模糊了。而Trilinear 濾波幾乎是和Bilinear 一樣的,只是Trilinear 還會在多級漸遠紋理之間進行混合。如果一張紋理沒有使用多級漸遠紋理技術,那麼Trilinear 得到的結果是和Bilinear 就一樣的。通常,我們會選擇Bilinear濾波模式。需要注意的是,有時我們不希望紋理看起來是模糊的,例如對於一些類似棋盤的紋理,我們希望他就是像素風的,這時我們可能會選擇Point 模式。

最後,我們來講一下紋理的最大尺寸和紋理模式。當我們在爲不同平臺發佈遊戲時,需要考慮目標平臺的紋理尺寸和質量問題。Unity 允許我們爲不同的平臺目標選擇不同的分辨率,如下圖所示:

如果導入的紋理大小超過了Max Texture Size 中的設置值,那麼Unity 將會把該紋理縮放爲這個最大分辨率。理想情況下,導入的紋理可以是非正方形的,但長寬的大小應該是2的冪,例如2、4、8、16、32、64等。如果使用了非2的冪大小的紋理,那麼這些紋理往往會佔用更多的內存空間,而且GPU讀取該紋理的速度也會有所下降。有一些平臺甚至不支持這種NPOT紋理,這時Unity 在內部會把它縮放成最近的2的冪大小。出於性能和空間的考慮,我們應該儘量使用2的冪大小的紋理。

而Format 決定了Unity 內部使用哪種格式來存儲該紋理。如果我們將Texture Type 設置爲 Advanced,那麼會有更多的Format 供我們選擇。需要知道的是,使用的紋理格式精度越高,佔用的內存空間越大,但得到的效果也越好。我們可以從紋理導入面板的最下方看到存儲該紋理需要佔用的內存空間(如果開啓了多級漸遠紋理技術,也會增加紋理的內存佔用)。當遊戲使用了大量的Truecolor 類型的紋理時,內存可能會迅速增加,因此對於一些不需要使用很高精度的紋理(例如用於漫反射顏色的紋理),我們應該儘量使用壓縮格式。

 

 凹凸映射

紋理的另一種常見的應用就是凹凸映射。凹凸映射的目的是使用一張紋理來修改模型表面的法線,以便爲模型提供更多的細節。這種方法不會真的改變模型的頂點位置,只是讓模型看起來好像是“凹凸不平”的,但可以從模型的輪廓處看出“破綻”。

有兩種主要的方法可以用來進行凹凸映射:一種方法是使用一張高度紋理來模擬表面位移,然後得到一個修改後的法線值,這種方法也被稱爲高度映射;另外一種方法則是使用一張法線紋理來直接存儲表面法線,這種方法又被稱爲法線映射。儘管我們常常將凹凸映射和法線映射當成是相同的技術,但讀者需要知道它們之間的不同。

我們先來看第一種技術,即 使用一張高度圖來實現凹凸映射。高度圖中存儲的是強度值,它用於表示模型表面局部的海拔高度。因此顏色越淺表明該位置的表面越向外凸起,而顏色越深表面該位置越向裏凹。這種方法的好處是非常直觀,我們可以從高度圖中明確地知道一個模型表面的凹凸情況,但缺點是計算更加複雜,在實時計算時不能直接得到表面法線,而是需要像素的灰度值計算而得,因此需要消耗更多的性能。下圖給出了一張高度圖。

高度圖通常會和法線映射一起使用,用於給出表面凹凸的額外信息。也就是說,我們通常會使用法線映射來修改光照。

 

而法線紋理中存儲的就是表面的法線方向。由於法線放的分量範圍在[-1, 1],而像素的分量範圍爲[0,1],因此,我們需要做一個映射,通常使用的映射就是 pixel = (normal + 1) / 2

這就要求,我們在Shader中對法線紋理進行紋理採樣後,還需要對結果進行因此反映射的過程,以得到原先的法線方向。反映射的過程實際就是使用上面映射函數的逆函數:normal = pixel×2 - 1

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

這種紋理被稱爲是切線空間的法線紋理。下圖給出了模型空間和切線空間下的法線紋理。

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

總體來說,模型空間下的法線紋理更符合人類的直觀認識,而且法線紋理本身也很直觀,容易調整,因爲不同的法線方向就代表了不同的顏色。但美術人員往往更喜歡使用切線空間下的法線紋理。

實際上,法線本身存儲在哪個座標系中都是可以的,我們甚至可以選擇存儲在世界空間下。但問題是,我們並不是單純地想要得到法線,後續的光照計算纔是我們的目的。而選擇哪個座標系意味着我們需要把不同信息轉換到響應的座標系中。例如,如果選擇了切線空間,我們需要把從法線紋理中得到的法線方向從切線空間轉換到世界空間中。

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

實現簡單,更加直觀。我們甚至都不需要模型原始的法線和切線信息,也就是說,計算更少。生成它也非常簡單,而如果要生成切線空間下的法線紋理,由於模型的切線一般是和UV方向相同,因此想要得到效果比較好的法線映射就要求紋理映射也是連續的。

在紋理座標的縫合處和尖銳的邊角部分,可見的突變較少,即可以提供平滑的邊界。這是因爲模型空間下的法線紋理存儲的是同一個座標系下的法線信息,因此在邊界處通過插值得到的法線可以平滑變換。而切線空間下的法線紋理中的法線信息是依靠紋理座標的方向得到的結果,可能會在邊緣處或尖銳的部分造成更多可見的縫合跡象。

但使用切線空間有更多的優點。

自由度很高。模型空間下的法線紋理記錄的是絕對法線信息,僅可用於創建它的那個模型,而應用到其他模型上效果就完全錯誤了。而切線空間下的法線紋理記錄的是相對法線信息,這意味着,即便把該紋理應用到一個完全不同的網格上,也可以得到一個合理的結果。

可進行UV動畫。比如,我們可以移動一個紋理的UV座標來實現一個凹凸移動的效果,但使用模型空間下的法線紋理會得到完全錯誤的結果。原因同上。這種UV動畫在水或者火山熔岩這種類型的物體上會經常用到。

可以重用法線紋理。比如,一個磚塊,我們僅用一張法線紋理就可以用到所有的6個面上。原因同上。

可壓縮。由於切線空間下的法線紋理中法線的Z方向總是正方向,因此我們可以僅存儲XY方向,而推導得到Z方向。而模型空間下的法線紋理由於每個方向都是可能的,因此必須存儲3個方向的值,不可壓縮。

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

 

我們需要在計算光照模型中統一各個方向適量所在的座標空間。由於法線紋理中存儲的法線是切線空間的方向,因此我們通常有兩種選擇:一種選擇是在切線空間下進行光照計算,此時我們需要把光照方向、視角方向變換到切線空間下;另一種選擇是在世界空間下進行光照計算,此時我們需要把採樣得到的法線方向變換到世界空間下,再和世界空間下的光照和視角方向進行計算。從效率上來說,第一種方法要優於第二種方向,因爲我們可以在頂點着色器中就完成對光照方向和視角方向的變換,而第二種方向由於要先對法線紋理進行採樣,所以變換的過程必須在片元着色器中實現,這意味着我們需要在片元着色器中進行一次矩陣操作。但從通用性角度來說,第二種方法要優於第一種方法,因爲我們有時需要在世界空間下進行一些計算,例如在使用Cubemap進行環境映射時,我們需要使用世界空間下的反射方向對Cubemap進行採樣。如果同時要進行法線映射,我們就需要把法線方法變換到世界空間下。

1、在切線空間下計算

我們首先來實現第一種方法,即在切線空間下計算光照模型。基本思路是:在片元着色器中通過紋理採樣得到切線空間下的法線,然後再與切線空間下的視角方向、光照方向扥進行計算,得到最終的光照結果。爲此,我們首先需要在頂點着色器中把視角方向和光照方向從模型空間變換到切線空間中,即我們需要知道從模型空間到切線空間的變換矩陣。這個變換矩陣的逆矩陣,即從切線空間到模型空間的變換矩陣是非常容易求得的,即我們再頂點着色器中按切線(x軸)、副切線(y軸)、法線(z軸)的順序按列排列即可得到。我們已經知道,如果一個變換中僅存在平移和旋轉變換,那麼這個變換的逆矩陣就等於它的轉置矩陣,而從切線空間到模型空間的變換正是符合這樣要求的變換。因此,從模型空間到切線空間的變換矩陣就是從切線空間到模型空間的變換矩陣的轉置矩陣,我們把切線(x軸)、副切線(y軸)、法線(z軸)的順序按行排列即可得到。

爲此,我們寫了一個新的Shader

 

Shader "Unity Shaders Book/Chapter 7/Normal Map In Tangent Space"{
	Properties{
		_Color ("Color Tint", Color) = (1,1,1,1)
		_MainTex("MainTex", 2D) = "white" {}
		//法線紋理,使用"bump"作爲默認值。
		//"bump"是Unity內置的法線紋理,當沒有提供任何法線紋理時,"bump"就對應了模型自帶的法線信息。
		_BumpMap("Normal Map", 2D) = "bump" {}
		//_BumpScale是用於控制凹凸程度,0意味着該法線紋理不會對光照產生任何影響
		_BumpScale("Bump Scale", Float) = 1.0
		_Specular("Specular", Color) = (1,1,1,1)
		_Gloss("Gloss", Range(8.0, 256)) = 20
	}
	
	SubShader{
		Pass{
			Tags {"LightMode" = "ForwardBase"}
			
			CGPROGRAM
			#pragma vertex vert
			#pragma fragment frag
			
			#include "Lighting.cginc"
			
			fixed4 _Color;
			sampler2D _MainTex;
			float4 _MainTex_ST;
			sampler2D _BumpMap;
			float4 _BumpMap_ST;
			float _BumpScale;
			fixed4 _Specular;
			float _Gloss;
			
			//頂點着色器輸入結構體
			struct a2v{
				float4 vertex : POSITION;
				float3 normal : NORMAL;
				float4 tangent : TANGENT;
				float4 texcoord : TEXCOORD0;
			};
			
			//頂點着色器的輸出結構
			struct v2f{
				float4 pos : SV_POSITION;
				float4 uv : TEXCOORD0;
				float3 lightDir : TEXCOORD1;
				float3 viewDir : TEXCOORD2;
			};
			
			//由於我們使用了兩張紋理,因此需要存儲兩個紋理座標。爲此,我們把v2f中的uv變量
			//的類型定義爲float4,其中xy分量存儲了_MainTex的紋理座標,而zw分量存儲了_BumpMap
			//的紋理座標。然後,我們把模型空間下的切線方向、副切線方向和法線方向按行排列來得到
			//從模型空間到切線空間的變換矩陣 rotation。需要注意的是,在計算副切線時我們使用
			//v.tangent.w 和叉積結果進行相乘,這是因爲和切線與法線方向都垂直的方向有兩個,而w
			//決定了我們選擇其中哪一個方向。
			v2f vert(a2v v){
				v2f o;
				o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
				
				o.uv.xy = v.texcoord.xy * _MainTex_ST.xy + _MainTex_ST.zw;
				o.uv.zw = v.texcoord.xy * _BumpMap_ST.xy + _BumpMap_ST.zw;
				
				
				//float3 binormal = cross(normalize(v.normal),normalize(v.tangent.xyz)) * v.tangent.w;
				//float3×3 rotation = float3×3(v.tangent.xyz, binormal,v.normal);
				//以上代碼可以用 TANGENT_SPACE_ROTATION 代替
				
				TANGENT_SPACE_ROTATION;
				
				//我們使用內置函數ObjSpaceLightDir 和 ObjSpaceViewDir 來得到模型空間下
				//的光照和視角方向,再利用變換矩陣rotation把它們從模型空間變換到切線空間中
				o.lightDir = mul(rotation, ObjSpaceLightDir(v.vertex)).xyz;
				o.viewDir = mul(rotation,ObjSpaceViewDir(v.vertex)).xyz;
				
				return o;
			}
			
			fixed4 frag(v2f i) : SV_Target{
				fixed3 tangentLightDir = normalize(i.lightDir);
				fixed3 tangentViewDir = normalize(i.viewDir);
				//先利用tex2D對法線紋理_BumpMap 進行採樣
				fixed4 packedNormal = tex2D(_BumpMap, i.uv.zw);
				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 function
				tangentNormal = UnpackNormal(packedNormal);
				//利用_BumpScale 控制凹凸程度
				tangentNormal.xy *= _BumpScale;
				//由於法線都是單位矢量,因此tangentNormal.z 可以由tangentNormal.xy計算而得
				tangentNormal.z = sqrt(1.0 - saturate(dot(tangentNormal.xy,tangentNormal.xy)));
				
				fixed3 albedo = tex2D(_MainTex,i.uv).rgb * _Color.rgb;
				fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;
				fixed3 diffuse = _LightColor0.rgb * albedo * max(0, dot(tangentNormal, tangentLightDir));
				
				fixed3 halfDir = normalize(tangentLightDir + tangentViewDir);
				fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(max(0, dot(tangentNormal,halfDir)),_Gloss);
				return fixed4(ambient + diffuse + specular, 1.0);
			}
			ENDCG
		}
	}
	Fallback "Specular"
}


下圖給出了不同的BumpScale 屬性值下得到的結果

 

 

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

爲此,我們寫一個新的Shader

 

Shader "Unity Shaders Book/Chapter 7/Normal Map In World Space"{
	Properties{
		_Color ("Color Tint", Color) = (1,1,1,1)
		_MainTex("MainTex", 2D) = "white" {}
		_BumpMap("Normal Map", 2D) = "bump" {}
		_BumpScale("Bump Scale", Float) = 1.0
		_Specular("Specular", Color) = (1,1,1,1)
		_Gloss("Gloss", Range(8.0, 256)) = 20
	}
	
	SubShader{
		Pass{
			Tags {"LightMode" = "ForwardBase"}
			
			CGPROGRAM
			#pragma vertex vert
			#pragma fragment frag
			
			#include "Lighting.cginc"
			
			fixed4 _Color;
			sampler2D _MainTex;
			float4 _MainTex_ST;
			sampler2D _BumpMap;
			float4 _BumpMap_ST;
			float _BumpScale;
			fixed4 _Specular;
			float _Gloss;
			
			//頂點着色器輸入結構體
			struct a2v{
				float4 vertex : POSITION;
				float3 normal : NORMAL;
				float4 tangent : TANGENT;
				float4 texcoord : TEXCOORD0;
			};
			
			//頂點着色器的輸出結構
			struct v2f{
				float4 pos : SV_POSITION;
				float4 uv : TEXCOORD0;
				float4 TtoW0 : TEXCOORD1;
				float4 TtoW1 : TEXCOORD2;
				float4 TtoW2 : TEXCOORD3;
			};
			
		
			v2f vert(a2v v){
				v2f o;
				o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
				
				o.uv.xy = v.texcoord.xy * _MainTex_ST.xy + _MainTex_ST.zw;
				o.uv.zw = v.texcoord.xy * _BumpMap_ST.xy + _BumpMap_ST.zw;
				//我們計算了世界空間下的頂點切線、副切線和法線的矢量表示,
				//並把它們按列擺放得到從切線空間到世界空間的變換矩陣
				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;
				
				//然後把上述矩陣的每一行分別存儲在TtoW0、TtoW1、TtoW2中,並把世界空間下
				//的頂點位置的xyz分量分別存儲再了這些變量的w分量中,以便充分利用插值寄存器的存儲空間。
				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);
				
				return o;
			}
			
			fixed4 frag(v2f i) : SV_Target{
				float3 worldPos = float3(i.TtoW0.w,i.TtoW1.w,i.TtoW2.w);
				fixed3 lightDir = normalize(UnityWorldSpaceLightDir(worldPos));
				fixed3 viewDir = normalize(UnityWorldSpaceViewDir(worldPos));
				
				fixed3 bump = UnpackNormal(tex2D(_BumpMap,i.uv.zw));
				bump.xy *= _BumpScale;
				bump.z = sqrt(1.0 - saturate(dot(bump.xy,bump.xy)));
				bump = normalize(half3(dot(i.TtoW0.xyz,bump),dot(i.TtoW1.xyz,bump),dot(i.TtoW2.xyz,bump)));
				
				fixed3 albedo = tex2D(_MainTex,i.uv).rgb * _Color.rgb;
				fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;
				fixed3 diffuse = _LightColor0.rgb * albedo * max(0, dot(bump, lightDir));
				
				fixed3 halfDir = normalize(lightDir + viewDir);
				fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(max(0, dot(bump,halfDir)),_Gloss);
				return fixed4(ambient + diffuse + specular, 1.0);
			}
			ENDCG
		}
	}
	Fallback "Specular"
}

 

 

 

上面我們提到了當把法線紋理的紋理類型標識成Normalmap時,可以使用Unity的內置函數UnpackNormal 來得到正確的法線方向,如下圖所示:

 

當我們需要使用那些包含了法線映射的內置的Unity Shader 時,必須把使用的法線紋理按上面的方式標識成Normal map 才能得到正確的結果(即便你忘了這麼做,Unity 也會在材質面板中提醒你修正這個問題),這是因爲這些Unity Shader 都使用了內置的UnpackNormal 函數來採樣法線方向。

簡單來說,這麼做可以讓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
}

 

從代碼中可以看出,在某些平臺上由於使用了DXT5nm的壓縮格式,因此需要針對這種格式對法線進行解碼。在DXT5nm格式的法線紋理中,紋素的a通道(即w分量)對應了法線的x分量,g通道對應了法線的y分量,而紋理的r和b通道則會被捨棄,法線的z分量可以由xy分量推導而得。爲什麼之前的普通紋理不能按這種方式壓縮,而法線多久需要使用DXT5nm格式來進行壓縮呢?這是因爲,按我們之前的處理方式,法線紋理被當成一個和普通紋理無異的圖,但實際上,它只有兩個通道是真正必不可少的,因爲第三個通道的值可以用另外兩個推導出來(法線是單位向量,並且切線空間下的法線方向的z分量始終爲正)。使用這種壓縮方法就可以減少法線紋理佔用的內存空間。

 

 

當我們把紋理類型設置成Normal map 後,還有一個複選框是Create from Grayscale,用於從高度圖中生成法線紋理的。高度圖本身記錄的是相對高度,是一張灰度圖,白色表示相對更高,黑色表示相對更低。當我們把一張高度圖導入Unity後,除了需要把它的紋理類型設置成Normal map 外,還需要勾選Create from Grayscale,這樣就可以得到類似下圖的結果。然後,我們就可以把它和切線空間下的法線紋理同等對待了。

當勾選了Create from Grayscale 後,還多出了兩個選項——Bumpiness 和 Filtering。其中Bumpiness 用於控制凹凸程度,而Filtering 決定我們使用哪種方式來計算凹凸程度,它有兩種選項:一種是Smooth ,這使得生產的法線紋理比較平滑;另一種是Sharp,它會使用Sobel 濾波來生成法線。Sobel濾波的實現非常簡單,我們只需要在一個3×3的濾波器中計算x和y方向上的倒數,然後從中得到法線即可。具體方法是:對於高度圖中的每個像素,我們考慮它與水平方向和豎直方向上的像素差,把它們的差當成該點對應的法線在x和y方向上的位移,然後使用之前提到的映射函數存儲成到法線紋理的r和g分量即可。

 

漸變紋理

由上圖可以看出,使用漸變紋理的方式可以自由地控制物體的漫反射光照。不同的漸變紋理有不同的特性。例如,在座標的圖中,我們使用一張從紫色調到淺黃色調的漸變紋理。而中間的圖使用的漸變紋理是從黑色逐漸向淺灰色靠攏,而且中間的分界線部分微微發紅,這是因爲畫家在插圖中往往會在陰影處使用這樣是色調;右側的漸變紋理則被用於卡通風格的渲染,這種漸變紋理中的色調通常是突變的,即沒有平滑過渡,以此來模擬卡通中的陰影色塊。

我們寫一個新的Shader來模擬實現

 

Shader "Unity Shaders Book/Chapter 7/Ramp Texture"{
	Properties{
		_Color("Color Tint", Color) = (1,1,1,1)
		_RampTex("Ramp Tex", 2D) = "white" {}
		_Specular("Specular", Color) = (1,1,1,1)
		_Gloss("Gloss", Range(8.0, 256)) = 20
	}
	SubShader{
		Pass{
			Tags {"LightMode" = "ForwardBase"}
		CGPROGRAM
		#pragma vertex vert
		#pragma fragment frag
		
		#include "Lighting.cginc"
		
		fixed4 _Color;
		sampler2D _RampTex;
		float4 _RampTex_ST;
		fixed4 _Specular;
		float _Gloss;
		
		//頂點着色器的輸入結構體
		struct a2v{
			float4 vertex : POSITION;
			float3 normal : NORMAL;
			float4 texcoord : TEXCOORD0;
		};
		
		struct v2f{
			float4 pos : SV_POSITION;
			float3 worldNormal : TEXCOORD0;
			float3 worldPos : TEXCOORD1;
			float2 uv : TEXCOORD2;
		};
		
		v2f vert(a2v v){
			v2f o;
			o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
			o.worldNormal = UnityObjectToWorldNormal(v.normal);
			o.worldPos = mul(_Object2World, v.vertex).xyz;
			//使用內置的TRANSFORM_TEX宏來計算經過平鋪和偏移後的紋理座標
			o.uv = TRANSFORM_TEX(v.texcoord, _RampTex);
			return o;
		}
		
		fixed4 frag(v2f i) : SV_Target{
			fixed3 worldNormal = normalize(i.worldNormal);
			fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
			
			fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;
			//使用半蘭伯特模型
			fixed halfLambert = 0.5 * dot(worldNormal,worldLightDir) + 0.5;
			//使用halfLambert來構建一個紋理座標,並用這個紋理座標對漸變紋理_RampTex進行採樣
			fixed3 diffuseColor = tex2D(_RampTex, fixed2(halfLambert,halfLambert)).rgb * _Color.rgb;
			
			fixed3 diffuse = _LightColor0.rgb * diffuseColor;
			
			fixed3 viewDir = normalize(UnityWorldSpaceViewDir(i.worldPos));
			fixed3 halfDir = normalize(worldLightDir + viewDir);
			fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(max(0,dot(worldNormal,halfDir)),_Gloss);
			
			return fixed4(ambient + diffuse + specular, 1.0);
		}
		
		ENDCG
		}
		
	}
	Fallback "Specular"
}

 

需要注意的是,我們需要把漸變紋理的Wrap Mode 設爲Clamp 模式,以防止對紋理進行採樣時由於浮點數精度而造成的問題。下圖給出了Wrap Mode 分別爲Repeat 和 Clamp 模式的效果對比。

 

可以看出,左圖中在高光區域由一些黑點。這是由浮點精度造成的,當我們使用fixed2(halfLambert,halfLambert)對漸變紋理進行採樣時,雖然理論上halfLambert的值才[0,1]之間,但可能會有1.00001這樣的值出現。如果我們使用的是Repeat模式,此時就會捨棄整數墨粉,只保留小數部分,得到的值就是0.00001,對應了漸變圖中最左邊的值,即黑色。因此,就會出現圖中這樣在高光區域反而有黑點的情況。我們只需要把漸變紋理的Wrap Mode 設爲 Clamp 模式就可以解決這種問題。

 

遮罩紋理

遮罩允許我們可以保護某些區域,使他們免於某些修改。例如,在之前的實現中,我們都是把高光反射應用到模型表面的所有地方,即所有的像素都使用同樣大小的高光強度和高光指數,但有時,我們希望模型表面某些區域的反光強烈一些,而某些區域弱一些。爲了得到更加細膩的效果,我們就可以使用一張遮罩紋理來控制光照。另一種常見的應用是在製作地形材質時需要混合多張圖片,例如草地的紋理、表現石子的紋理、表現裸露土地的紋理等,使用遮罩紋理可以控制如何混合這些紋理。

使用遮罩紋理的一般流程是:通過採用得到遮罩紋理的紋素值,然後使用其中某個通道的值來與某種表面屬性進行相乘,這樣,當該通道的值爲0時,可以保護表面不受該屬性的影響。總而言之,使用遮罩紋理可以讓美術人員更加精準地控制模型表面的各種性質。

下圖顯示了只包含漫反射、未使用遮罩的高光反射和使用遮罩的高光反射的對比結果:

我們使用的遮罩紋理如下圖所示,可以看出,遮罩紋理可以讓我們更加精細地控制光照細節,得到更細膩的效果。

我們新建一個Shader 來實現上述效果。

 

Shader "Unity Shaders Book/Chapter 7/Mask Texture"{
	Properties{
		_Color("Color Tint", Color) = (1,1,1,1)
		_MainTex("Main Tex",2D) = "white" {}
		_BumpMap("Normal Map",2D) = "bump" {}
		_BumpScale("Bump Scale",Float) = 1.0
		//我們需要使用的高光反射遮罩紋理
		_SpecularMark("Specular Mask",2D) = "white"{}
		//用於控制遮罩影響度的係數
		_SpecularScale("Specular Scale", Float) = 1.0
		_Specular("Specular", Color) = (1,1,1,1)
		_Gloss("Gloss",Range(8.0,256)) = 20
	}
	
	SubShader{
		Pass{
			Tags {"LightMode" = "ForwardBase"}
			CGPROGRAM
			#pragma vertex vert
			#pragma fragment frag
			#include "Lighting.cginc"
			
			fixed4 _Color;
			sampler2D _MainTex;
			float4 _MainTex_ST;
			sampler2D _BumpMap;
			float _BumpScale;
			sampler2D _SpecularMark;
			float _SpecularScale;
			fixed4 _Specular;
			float _Gloss;

			struct a2v{
			float4 vertex : POSITION;
			float3 normal : NORMAL;
			float4 tangent : TANGENT;
			float4 texcoord : TEXCOORD0;
			};
			
			struct v2f{
				float4 pos : SV_POSITION;
				float2 uv : TEXCOORD0;
				float3 lightDir : TEXCOORD1;
				float3 viewDir : TEXCOORD2;
			};
			
			v2f vert(a2v v){
				v2f o;
				o.pos = mul(UNITY_MATRIX_MVP,v.vertex);
				o.uv.xy = v.texcoord.xy * _MainTex_ST.xy + _MainTex_ST.zw;
				
				TANGENT_SPACE_ROTATION;
				o.lightDir = mul(rotation,ObjSpaceLightDir(v.vertex)).xyz;
				o.viewDir = mul(rotation,ObjSpaceViewDir(v.vertex)).xyz;
				
				return o;
			}
			
			fixed4 frag(v2f i) : SV_Target{
				fixed3 tangentLightDir = normalize(i.lightDir);
				fixed3 tangentViewDir = normalize(i.viewDir);
				
				fixed3 tangentNormal = UnpackNormal(tex2D(_BumpMap, i.uv));
				tangentNormal.xy *= _BumpScale;
				tangentNormal.z = sqrt(1.0 - saturate(dot(tangentNormal.xy,tangentNormal.xy)));
				
				fixed3 albedo = tex2D(_MainTex, i.uv).rgb * _Color.rgb;
				fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;
				fixed3 diffuse = _LightColor0.rgb*albedo*max(0,dot(tangentNormal,tangentLightDir));
				
				fixed3 halfDir = normalize(tangentLightDir + tangentViewDir);
				//在計算高光反射時,我們首先對遮罩紋理_SpecularMark進行採樣
				//由於本例使用的遮罩紋理中每個紋素的rgb分量其實都是一樣的,
				//表面了該點對應的高光反射強度,在這裏我們選擇使用r分量來計算掩碼值
				fixed specularMask = tex2D(_SpecularMark,i.uv).r * _SpecularScale;
				fixed3 specular = _LightColor0.rgb*_Specular.rgb*pow(max(0,dot(tangentNormal,halfDir)),_Gloss)*specularMask;
				return fixed4(ambient + diffuse + specular, 1.0);
			}
			ENDCG
		}
	}
	Fallback "Specular"
	
}


在真實的遊戲製作過程中,遮罩紋理已經不止限於保護某些區域使它們免於某些修改,而是可以存儲任何我們希望逐像素控制的表面屬性。通常,我們會充分利用一張紋理的RGBA四個通道,用於存儲不同的屬性。例如,我們可以把高光反射的強度存儲在R通道,把邊緣光照的強度存儲在G通道,把高光反射的指數部分存儲在B通道,最後把自發光強度存儲在A通道。

 

2018年10月10日修正:

1、感謝qq_41179365指出代碼中有缺漏,已修改編譯通過

發佈了38 篇原創文章 · 獲贊 31 · 訪問量 8萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章