1. 程式人生 > >第13章 使用深度和法線紋理

第13章 使用深度和法線紋理

在第12 章中,我們學習的屏幕後處理效果都只是在螢幕顏色影象上進行各種操作來實現的。然而,很多時候我們不僅需要當前螢幕的顏色資訊,還希望得到深度和法線資訊。例如,在進行邊緣檢測時,直接利用顏色資訊會使檢測到的邊緣資訊受物體紋理和光照等外部因素的影響,得到很多我們不需要的邊緣點。一種更好的方法是,我們可以在深度紋理和法線紋理上進行邊緣檢測,這些影象不會受紋理和光照的影響,而僅僅儲存了當前渲染物體的模型資訊,通過這樣的方式檢測出來的邊緣更加可靠。

在本章中,我們將學習如何在Unity 中獲取深度紋理和法線紋理來實現特定的屏幕後處理效果。

在13.1 節中,我們首先會學習如何在Unity 中獲取這兩種紋理。

在13.2 節中,我們會利用深度紋理來計算攝像機的移動速度,實現攝像機的運動模糊效果。

在13.3 節中,我們會學習如何利用深度紋理來重建螢幕畫素在世界空間中的位置,從而模擬螢幕霧效。

13.4 節會再次學習邊緣檢測的另一種實現,即利用深度和法線紋理進行邊緣檢測。

13.1 獲取深度和法線紋理

雖然在Unity 裡獲取深度和法線紋理的程式碼非常簡單,但是我們有必要在這之前首先了解它們背後的實現原理。

13.1.1 背後的原理

深度紋理實際就是一張渲染紋理,只不過它裡面儲存的畫素值不是顏色值,而是一個高精度的深度值。由於被儲存在一張紋理中,深度紋理裡的深度值範圍是[0, 1],而且通常是非線性分佈的。那麼,這些深度值是從哪裡得到的呢?要回答這個問題,我們需要回顧在第4 章學過的頂點變換的過程。總體來說,這些深度值來自於頂點變換後得到的歸一化的裝置座標( Normalized Device Coordinates , NDC )。回顧一下,一個模型要想最終被繪製在螢幕上,需要把它的頂點從模型空間變換到齊次裁剪座標系下,這是通過在頂點著色器中乘以MVP 變換矩陣得到的。在變換的最後一步,我們需要使用一個投影矩陣來變換頂點,當我們使用的是透視投影型別的攝像機時,這個投影矩陣就是非線性的,具體過程可回顧4.6.7 小節。
圖13.1 顯示了4.6.7 小節中給出的Unity 中透視投影對頂點的變換過程。圖13.1 中最左側的圖顯示了投影變換前,即觀察空間下視錐體的結構及相應的頂點位置,中間的圖顯示了應用透視裁剪矩陣後的變換結果,即頂點著色器階段輸出的頂點變換結果,最右側的圖則是底層硬體進行了透視除法後得到的歸一化的裝置座標。需要注意的是,這裡的投影過程是建立在Unity 對座標系的假定上的,也就是說,我們針對的是觀察空間為右手座標系,使用列矩陣在矩陣右側進行相乘,且變換到NDC 後z 分量範圍將在[-1, 1]之間的情況。而在類似DirectX 這樣的圖形介面中,變換後z 分量範圍將在[0, 1]之間。如果需要在其他圖形介面下實現本章的類似效果, 需要對一些計算引數做出相應變化。關於變換時使用的矩陣運算, 讀者可以參考4.6.7 小節。

圖13.2 顯示了在使用正交攝像機時投影變換的過程。同樣,變換後會得到一個範圍為[-1, 1] 的立方體。正交投影使用的變換矩陣是線性的。
在得到NDC 後,深度紋理中的畫素值就可以很方便地計算得到了,這些深度值就對應了NDC 中頂點座標的z 分量的值。由於NDC 中z 分量的範圍在[-1, 1],為了讓這些值能夠儲存在一張影象中,我們需要使用下面的公式對其進行對映:

其中, d 對應了深度紋理中的畫素值, Zndc 對應了NDC 座標中的z 分量的值。
那麼Unity 是怎麼得到這樣一張深度紋理的呢?在Unity 中,深度紋理可以直接來自於真正的深度快取,也可以是由一個單獨的Pass 渲染而得,這取決於使用的渲染路徑和硬體。通常來講,當使用延遲渲染路徑(包括遺留的延遲渲染路徑)時,深度紋理理所當然可以訪問到,因為延遲渲染會把這些資訊渲染到G-buffer 中。而當無法直接獲取深度快取時,深度和法線紋理是通過一個單獨的Pass 渲染而得的。具體實現是, Unity 會使用著色器替換( Shader Replacement )技術選擇那些渲染型別〈即SubShader 的RenderType 標籤)為Opaque 的物體,判斷它們使用的渲染佇列是否小於等於2 500 (內建的Background 、Geometry 和AlphaTest 渲染佇列均在此範圍內),如果滿足條件,就把它渲染到深度和法線紋理中。因此,要想讓物體能夠出現在深度和法線紋理中,就必須在Shader 中設定正確的RenderType 標籤。
在Unity 中,我們可以選擇讓一個攝像機生成一張深度紋理或是一張深度+法線紋理。當選擇前者,即只需要一張單獨的深度紋理時, Unity 會直接獲取深度快取或是按之前講到的著色器替換技術,選取需要的不透明物體,並使用它投射陰影時使用的Pass (即LightMode 被設定為ShadowCaster 的Pass,詳見9.4 節)來得到深度紋理。如果Shader 中不包含這樣一個Pass,那麼這個物體就不會出現在深度紋理中(當然,它也不能向其他物體投射陰影)。深度紋理的精度通常是24 位或16 位,這取決於使用的深度快取的精度。如果選擇生成一張深度+法線紋理, Unity 會建立一張和螢幕解析度相同、精度為32 位〈每個通道為8 位)的紋理,其中觀察空間下的法線資訊會被編碼進紋理的R 和G 通道,而深度資訊會被編碼進B 和A 通道。法線資訊的獲取在延遲渲染中是可以非常容易就得到的, Unity 只需要合併深度和法線快取即可。而在前向渲染中,預設情況下是不會建立法線快取的,因此Unity 底層使用了一個單獨的Pass 把整個場景再次渲染一遍來完成。這個Pass 被包含在Unity 內建的一個Unity Shader 中,我們可以在內建的
builtin_shaders-xxx/DefaultResources/Camera-DepthNormalTexture.shader 檔案中找到這個用於渲染深度和法線資訊的Pass。

13.1.2 如何獲取

在Unity 中,獲取深度紋理是非常簡單的,我們只需要告訴Unity:“嘿,把深度紋理給我!”然後再在Shader 中直接訪問特定的紋理屬性即可。這個與Unity 溝通的過程是通過在指令碼中設定攝像機的depthTextureMode 來完成的,例如我們可以通過下面的程式碼來獲取深度紋理:
	camera.depthTextureMode = DepthTextureMode.Depth;
一旦設定好了上面的攝像機模式後,我們就可以在Shader 中通過宣告 _CameraDepthTexture變數來訪問它。這個過程非常簡單,但我們需要知道這兩行程式碼的背後, Unity 為我們做了許多工作(見13.1.1 節〉。
同理,如果想要獲取深度+法線紋理,我們只需要在程式碼中這樣設定:
	camera.depthTextureMode = DepthTextureMode.DepthNormals;
然後在Shader 中通過宣告 _CameraDepthNormalsTexture 變數來訪問它。
我們還可以組合這些模式,讓一個攝像機同時產生一張深度和深度+法線紋理:
	camera.depthTextureMode |= DepthTextureMode.Depth;
	camera.depthTextureMode |= DepthTextureMode.DepthNormals;
在Unity 5 中,我們還可以在攝像機的Camera 元件上看到當前攝像機是否需要渲染深度或深度+法線紋理。當在Shader 中訪問到深度紋理 _CameraDepthTexture 後,我們就可以使用當前畫素的紋理座標對它進行取樣。絕大多數情況下,我們直接使用tex2D 函式取樣即可,但在某些平臺(例如PS3 和PSP2 )上,我們需要一些特殊處理。Unity 為我們提供了一個統一的巨集 SAMPLE_DEPTH_TEXTURE,用來處理這些由於平臺差異造成的問題。而我們只需要在Shader中使用 SAMPLE_DEPTH_TEXTURE 巨集對深度紋理進行取樣,例如:
	float d = SAMPLE_DEPTH_TEXTURE(_CarneraDepthTexture, i.uv);
其中, i.uv 是一個float2 型別的變數,對應了當前畫素的紋理座標。類似的巨集還有SAMPLE_DEPTH_TEXTURE_PROJ 和 SAMPLE_DEPTH_TEXTURE_LOD。 SAMPLE_DEPTH_TEXTURE_PROJ 巨集同樣接受兩個引數一一深度紋理和一個float3 或float4 型別的紋理座標,它的內部使用了tex2Dproj 這樣的函式進行投影紋理取樣,紋理座標的前兩個分量首先會除以最後一個分量,再進行紋理取樣。如果提供了第四個分量,還會進行一次比較,通常用於陰影的實現中。SAMPLE_DEPTH_TEXTURE PROJ 的第二個引數通常是由頂點著色器輸出插值而得的螢幕座標,例如:
	float d = SAMPLE_DEPTH_TEXTURE_PROJ(_CarneraDepthTexture, UNITY_PROJ_COORD(i.scrPos));
其中, i.scrPos 是在頂點著色器中通過呼叫ComputeScreenPos(o.pos)得到的螢幕座標。上述這些巨集的定義,讀者可以在Unity 內建的HLSLSupport.cginc 檔案中找到。
當通過紋理取樣得到深度值後,這些深度值往往是非線性的,這種非線性來自於透視投影使用的裁剪矩陣。然而,在我們的計算過程中通常是需要線性的深度值,也就是說,我們需要把投影后的深度值變換到線性空間下,例如視角空間下的深度值。那麼,我們應該如何進行這個轉換呢?實際上,我們只需要倒推頂點變換的過程即可。下面我們以透視投影為例,推導如何由深度紋理中的深度資訊計算得到視角空間下的深度值。
幸運的是, Unity 提供了兩個輔助函式來為我們進行上述的計算過程一一LinearEyeDepth 和 Linear01Depth。 LinearEyeDepth 負責把深度紋理的取樣結果轉換到視角空間下的深度值,也就是我們上面得到的Zview。而Linear01Depth 則會返回一個範圍在[0, 1]的線性深度值,也就是我們上面得到的Z
01 。這兩個函式內部使用了內建的 _ZBufferParams 變數來得到遠近裁剪平面的距離。
如果我們需要獲取深度+法線紋理,可以直接使用tex2D 函式對 _CameraDepthNormalsTexture 進行取樣,得到裡面儲存的深度和法線資訊。Unity 提供了輔助函式來為我們對這個取樣結果進行解碼,從而得到深度值和法線方向。這個函式DecodeDepthNorrnal, 它在UnityCG.cginc 裡被定義:
	inline void DecodeDepthNormal( float4 enc, out float depth, out float3 normal)
	{
		depth = DecodeFloatRG (enc.zw);
		normal= DecodeViewNormalStereo(enc);
	}
DecodeDepthNormal 的第一個引數是對深度+法線紋理的取樣結果,這個取樣結果是Unity 對深度和法線資訊編碼後的結果, 它的xy 分量儲存的是視角空間下的法線資訊, 而深度資訊被編碼進了zw 分量。通過呼叫DecodeDepthNormal 函式對取樣結果解碼後,我們就可以得到解碼後的深度值和法線。這個深度值是範圍在[0, 1]的線性深度值(這與單獨的深度紋理中儲存的深度值不同〉,而得到的法線則是視角空間下的法線方向。同樣, 我們也可以通過呼叫DecodeFloatRG 和 DecodeViewNormaLStereo 來解碼深度+法線紋理中的深度和法線資訊。
至此,我們已經學會了如何在Unity 裡獲取及使用深度和法線紋理。下面, 我們會學習如何使用它們實現各種螢幕特效。

13.1.3 檢視深度和法線紋理

很多時候, 我們希望可以檢視生成的深度和法線紋理,以便對Shader 進行除錯。Unity 5 提供了一個方便的方法來檢視攝像機生成的深度和法線紋理, 這個方法就是利用幀偵錯程式( Frame Debugger)。圖13.3 顯示了使用幀偵錯程式檢視到的深度紋理和深度+法線紋理。 使用幀偵錯程式檢視到的深度紋理是非線性空間的深度值,而深度+法線紋理都是由Unity 編碼後的結果。有時,顯示出線性空間下的深度資訊或解碼後的法線方向會更加有用。此時,我們可以自行在片元著色器中輸出轉換或解碼後的深度和法線值, 如圖13.4 所示。輸出程式碼非常簡單,我們可以使用類似下面的程式碼來輸出線性深度值:
	float depth= SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv);
	float linearDepth = LinearOlDepth(depth);
	return fixed4(linearDepth, linearDepth, linearDepth, 1.0);
或是輸出法線方向:
	fixed3 normal = DecodeViewNormalStereo(tex2D( _CameraDepthNormalsTexture, i.uv).xy);
	return fixed4 (normal * 0.5 + 0.5, 1.0);
在檢視深度紋理時,讀者得到的畫面有可能幾乎是全黑或全白的。這時候讀者可以把攝像機的遠裁剪平面的距離( Unity 預設為1000 )調小, 使視錐體的範圍剛好覆蓋場景的所在區域。這是因為,由於投影變換時需要覆蓋從近裁剪平面到遠裁剪平面的所有深度區域, 當遠裁剪平面的 距離過大時, 會導致離攝像機較近的距離被對映到非常小的深度值,如果場景是一個封閉的區域 (如圖13.4 所示〉, 那麼這就會導致畫面看起來幾乎是全黑的。相反, 如果場景是一個開放區域, 且物體離攝像機的距離較遠, 就會導致畫面兒乎是全白的。

13.2 再談運動模糊

在12.6 節中,我們學習瞭如何通過混合多張螢幕影象來模擬運動模糊的效果。但是,另一種應用更加廣泛的技術則是使用速度對映圖。速度對映圖中儲存了每個畫素的速度,然後使用這個速度來決定模糊的方向和大小。速度緩衝的生成有多種方法,一種方法是把場景中所有物體的速度渲染到一張紋理中。但這種方法的缺點在於需要修改場景中所有物體的Shader 程式碼,使其新增計算速度的程式碼並輸出到一個渲染紋理中。
《GPU Gems3》在第27 章(http:http.developer.nvidia.com/GPUGems3/gpugems3_ch27.html) 中介紹了一種生成速度對映圖的方法。這種方法利用深度紋理在片元著色器中為每個畫素計算其在世界空間下的位置,這是通過使用當前的視角*投影矩陣的逆矩陣對NDC 下的頂點座標進行變換得到的。當得到世界空間中的頂點座標後,我們使用前一幀的視角*投影矩陣對其進行變換,得到該位置在前一幀中的NDC 座標。然後,我們計算前一幀和當前幀的位置差,生成該畫素的速度。這種方法的優點是可以在一個屏幕後處理步驟中完成整個效果的模擬,但缺點是需要在片元著色器中進行兩次矩陣乘法的操作,對效能有所影響。
為了使用深度紋理模擬運動模糊,我們需要進行如下準備工作。
( 1)新建一個場景。在本書資源中, 該場景名為Scene_13_2 。在Unity 5.2 中,預設情況下場景將包含一個攝像機和一個平行光,並且使用了內建的天空盒子。在Window → Lighting → Skybox 中去掉場景中的天空盒子。
( 2 ) 我們需要搭建一個測試運動模糊的場景。在本書資源的實現中,我們構建了一個包含3 面牆的房間,並放置了4 個立方體,它們都使用了我們在9.5 節中建立的標準材質。同時, 我們把本書資源中的Translating.cs 指令碼拖曳給攝像機,讓其在場景中不斷運動。
(3)新建一個指令碼。在本書資源中,該指令碼名為MotionBlurWithDepthTexture.cs。把該指令碼拖曳到攝像機上。
( 4 )新建一個Unity Shader。在本書資源中, 該Shader 名為Chapter13-MotionBlurWithDepthTexture。
我們首先來編寫MotionBlurWithDepthTexture.cs 指令碼。開啟該指令碼,並進行如下修改。
(1)首先, 繼承12.1 節中建立的基類:
public class MotionBlurWithDepthTexture : PostEffectsBase {
( 2 )宣告該效果需要的Shader,並據此建立相應的材質:
	public Shader motionBlurShader;
	private Material motionBlurMaterial = null;

	public Material material {  
		get {
			motionBlurMaterial = CheckShaderAndCreateMaterial(motionBlurShader, motionBlurMaterial);
			return motionBlurMaterial;
		}  
	}
(3)定義運動模糊時模糊影象使用的大小:
	[Range(0.0f, 1.0f)]
	public float blurSize = 0.5f;
( 4)由於本節需要得到攝像機的視角和投影矩陣,我們需要定義一個Camera 型別的變數,以獲取該指令碼所在的攝像機元件:
	private Camera myCamera;
	public Camera camera {
		get {
			if (myCamera == null) {
				myCamera = GetComponent<Camera>();
			}
			return myCamera;
		}
	}
( 5 )我們還需要定義一個變數來儲存上一幀攝像機的視角*投影矩陣:
	private Matrix4x4 previousViewProjectionMatrix;
( 6 )由於本例需要獲取攝像機的深度紋理, 我們在指令碼的OnEnable 函式中設定攝像機的狀態:
	void OnEnable() {
		camera.depthTextureMode |= DepthTextureMode.Depth;
(7)最後, 我們實現了OnRenderlmage 函式:
	void OnRenderImage (RenderTexture src, RenderTexture dest) {
		if (material != null) {
			material.SetFloat("_BlurSize", blurSize);

			material.SetMatrix("_PreviousViewProjectionMatrix", previousViewProjectionMatrix);
			Matrix4x4 currentViewProjectionMatrix = camera.projectionMatrix * camera.worldToCameraMatrix;
			Matrix4x4 currentViewProjectionInverseMatrix = currentViewProjectionMatrix.inverse;
			material.SetMatrix("_CurrentViewProjectionInverseMatrix", currentViewProjectionInverseMatrix);
			previousViewProjectionMatrix = currentViewProjectionMatrix;

			Graphics.Blit (src, dest, material);
		} else {
			Graphics.Blit(src, dest);
		}
	}
上面的OnRenderlmage 函式很簡單, 我們首先需要計算和傳遞運動模糊使用的各個屬性。本例需要使用兩個變換矩陣一一前一幀的視角*投影矩陣以及當前幀的視角*投影矩陣的逆矩陣。因此,我們通過呼叫camera.worldToCameraMtrix 和camera.projectionMatrix 來分別得到當前攝像機的視角矩陣和投影矩陣。對它們相乘後取逆, 得到當前幀的視角*投影矩陣的逆矩陣,並傳遞給材質。然後, 我們把取逆前的結果儲存在previousViewProjectionMatrix 變數中,以便在下一幀時傳遞給材質的  _PreviousViewProjectionMatrix 屬性。
下面, 我們來實現Shader 的部分。開啟Chapter13-MotionBiurWithDepthTexture,進行如下修改。
( 1)我們首先需要宣告本例使用的各個屬性:
	Properties {
		_MainTex ("Base (RGB)", 2D) = "white" {}
		_BlurSize ("Blur Size", Float) = 1.0
	}
_MainTex 對應了輸入的渲染紋理, _BlurSize 是模糊影象時使用的引數。我們注意到,雖然在腳本里設定了材質的 _PreviousViewProjectionMatrix 和 _CurrentViewProjectionInverseMatrix 屬性,但並沒有在Properties 塊中宣告它們。這是因為Unity 沒有提供矩陣型別的屬性, 但我們仍然可以在CG 程式碼塊中定義這些矩陣, 並從指令碼中設定它們。
( 2 )在本節中, 我們使用CGINCLUDE 來組織程式碼。我們在SubShader 塊中利用CGINCLUDE 和 ENDCG 語義來定義一系列程式碼:
    SubShader {
    	CGINCLUDE
    	...
    	ENDCG
    	...
(3)宣告程式碼中需要使用的各個變數:
		sampler2D _MainTex;
		half4 _MainTex_TexelSize;
		sampler2D _CameraDepthTexture;
		float4x4 _CurrentViewProjectionInverseMatrix;
		float4x4 _PreviousViewProjectionMatrix;
		half _BlurSize;
在上面的程式碼中, 除了定義在Propertity宣告的 _MainTex 和 _BlurSize 屬性, 我們還聲明瞭其他三個變數。_CameraDepthTexture 是Unity 傳遞給我們的深度紋理,而 _CurrentViewProjectionlnverseMatrix 和 _PreviousViewProjectionMatrix 是由指令碼傳遞而來的矩陣。除此之外,我們還聲明瞭 _MainTex_TexelSize 變數,它對應了主紋理的紋素大小,我們需要使用該變數來對深度紋理的採
樣座標進行平臺差異化處理(詳見5.6.1 節〉。
( 4 )頂點著色器的程式碼和之前使用多次的程式碼基本一致,只是增加了專門用於對深度紋理取樣的紋理座標變數:
		struct v2f {
			float4 pos : SV_POSITION;
			half2 uv : TEXCOORD0;
			half2 uv_depth : TEXCOORD1;
		};
		
		v2f vert(appdata_img v) {
			v2f o;
			o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
			
			o.uv = v.texcoord;
			o.uv_depth = v.texcoord;
			
			#if UNITY_UV_STARTS_AT_TOP
			if (_MainTex_TexelSize.y < 0)
				o.uv_depth.y = 1 - o.uv_depth.y;
			#endif
					 
			return o;
		}
由於在本例中,我們需要同時處理多張渲染紋理,因此在DirectX 這樣的平臺上,我們需要處理平臺差異導致的影象翻轉問題。在上面的程式碼中,我們對深度紋理的取樣座標進行了平臺差異化處理,以便在類似DirectX 的平臺上,在開啟了抗鋸齒的情況下仍然可以得到正確的結果。 ( 5 )片元著色器是演算法的重點所在:
		fixed4 frag(v2f i) : SV_Target {
			// Get the depth buffer value at this pixel.
			float d = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv_depth);
			// H is the viewport position at this pixel in the range -1 to 1.
			float4 H = float4(i.uv.x * 2 - 1, i.uv.y * 2 - 1, d * 2 - 1, 1);
			// Transform by the view-projection inverse.
			float4 D = mul(_CurrentViewProjectionInverseMatrix, H);
			// Divide by w to get the world position. 
			float4 worldPos = D / D.w;
			
			// Current viewport position 
			float4 currentPos = H;
			// Use the world position, and transform by the previous view-projection matrix.  
			float4 previousPos = mul(_PreviousViewProjectionMatrix, worldPos);
			// Convert to nonhomogeneous points [-1,1] by dividing by w.
			previousPos /= previousPos.w;
			
			// Use this frame's position and last frame's to compute the pixel velocity.
			float2 velocity = (currentPos.xy - previousPos.xy)/2.0f;
			
			float2 uv = i.uv;
			float4 c = tex2D(_MainTex, uv);
			uv += velocity * _BlurSize;
			for (int it = 1; it < 3; it++, uv += velocity * _BlurSize) {
				float4 currentColor = tex2D(_MainTex, uv);
				c += currentColor;
			}
			c /= 3;
			
			return fixed4(c.rgb, 1.0);
		}
我們首先需要利用深度紋理和當前幀的視角*投影矩陣的逆矩陣來求得該畫素在世界空間下的座標。過程開始於對深度紋理的取樣,我們使用內建的SAMPLE_DEPTH_TEXTURE 巨集和紋理座標對深度紋理進行取樣, 得到了深度值d。由13.1.2 節可知, d 是由NDC 下的座標對映而來的。我們想要構建畫素的NDC 座標H, 就需要把這個深度值重新映射回NDC。這個對映很簡單,只需要使用原對映的反函式即可,即d * 2 - 1 。同樣, NDC 的 xy 分量可以由畫素的紋理座標對映而來( NDC 下的xyz 分量範圍均為 [ -1, 1] )。當得到NDC 下的座標H 後,我們就可以使用當前幀的視角*投影矩陣的逆矩陣對其進行變換,並把結果值除以它的w 分量來得到世界空間下的座標表示worldPos。
一旦得到了世界空間下的座標,我們就可以使用前一幀的視角*投影矩陣對它進行變換, 得到前一幀在NDC 下的坐 previousPos。然後,我們計算前一幀和當前幀在螢幕空間下的位置差,得到該畫素的速度velocity 。
當得到該畫素的速度後,我們就可以使用該速度值對它的鄰域畫素進行取樣,相加後取平均值得到一個模糊的效果。取樣時我們還使用了 _BlurSize 來控制取樣距離。
( 6 )然後,我們定義了運動模糊所需的Pass:
		Pass {      
			ZTest Always Cull Off ZWrite Off
			    	
			CGPROGRAM  
			
			#pragma vertex vert  
			#pragma fragment frag  
			  
			ENDCG  
		}
(7)最後,我們關閉了shader 的Fallback:
	FallBack Off
完成後返回編輯器,並把Chapter13-MotionBlurWithDepthTexture 拖曳到攝像機的MotionBlurWithDepthTexture.cs 指令碼中的 motionBlurShader 引數中。當然,我們可以在 MotionBlurWithDepthTexture.cs 的指令碼面版中將motionBlurShader 引數的預設值設定為Chapter13-MotionBlurWithDepthTexture , 這樣就不需要以後使用時每次都手動拖曳了。
本節實現的運動模糊適用於場景靜止、攝像機快速運動的情況,這是因為我們在計算時只考慮了攝像機的運動。因此,如果讀在把本節中的程式碼應用到一個物體快速運動而攝像機靜止的場景,會發現不會產生任何運動模糊效果。如果我們想要對快速移動的物體產生運動模糊的效果,就需要生成更加精確的速度對映圖。讀者可以在Unity 自帶的lmageEffect 包中找到更多的運動模糊的實現方法。
本節選擇在片元著色器中使用逆矩陣來重建每個畫素在世界空間下的位置。但是,這種做法往往會影響效能,在13.3 節中,我們會介紹一種更快速的由深度紋理重建世界座標的方法。

13.3 全域性霧效

霧效(Fog )是遊戲裡經常使用的一種效果。Unity 內建的霧效可以產生基於距離的線性或指數霧效。然而,要想在自己編寫的頂點/片元著色器中實現這些霧效,我們需要在Shader 中新增 #pragma multi_compile_fog 指令,同時還需要使用相關的內建巨集,例如UNITY_FOG_COORDS, UNITY_TRANSFER_FOG 和 UNITY_APPLY_FOG 等。這種方法的缺點在於,我們不僅需要為場景中所有物體新增相關的渲染程式碼,而且能夠實現的效果也非常有限。當我們需要對霧效進行一些個性化操作時,例如使用基於高度的霧效等,僅僅使用Unity 內建的霧效就變得不再可行。
在本節中,我們將會學習一種基於屏幕後處理的全域性霧效的實現。使用這種方法,我們不需要更改場景內渲染的物體所使用的Shader程式碼,而僅僅依靠一次屏幕後處理的步驟即可。這種方法的自由性很高,我們可以方便地模擬各種霧效,例如均勻的霧效、基於距離的線性/指數霧效、基於高度的霧效等。在學習完本節後,我們可以得到類似圖13.5 中的效果。

基於屏幕後處理的全域性第效的關鍵是,根據深度紋理來重建每個畫素在世界空間下的位置。儘管在13.2 節中,我們在模擬運動模糊時已經實現了這個要求,即構建出當前畫素的NDC 座標,再通過當前攝像機的視角*投影矩陣的逆矩陣來得到世界空間下的像索座標,但是,這樣的實現需要在片元著色器中進行矩陣乘法的操作,而這通常會影響遊戲效能。在本節中,我們將會學習一個快速從深度紋理中重建世界座標的方法。這種方法首先對影象空間下的視錐體射線(從攝像機出發,指向影象上的某點的射線〉進行插值,這條射線儲存了該畫素在世界空間下到攝像機的方向資訊。然後,我們把該射線和線性化後的視角空間下的深度值相乘,再加上攝像機的世界位置,就可以得到該畫素在世界空間下的位置。當我們得到世界座標後,就可以輕鬆地使用各個公式來模擬全域性霧效了。

13.3.1 重建世界座標

在開始動手寫程式碼之前,我們首先來了解如何從深度紋理中重建世界座標。我們知道,座標系中的一個頂點座標可以通過它相對於另一個頂點座標的偏移量來求得。重建畫素的世界座標也是基於這樣的思想。我們只需要知道攝像機在世界空間下的位置,以及世界空間下該畫素相對於攝像機的偏移量,把它們相加就可以得到該畫素的世界座標。整個過程可以使用下面的程式碼來表示:
其中, _WorldSpaceCameraPos 是攝像機在世界空間下的位置,這可以由Unity 的內建變數直接訪問得到。而 linearDepth * interpolatedRay 則可以計算得到該畫素相對於攝像機的偏移量, linearDepth 是由深度紋理得到的線性深度值, interpolatedRay 是由頂點著色器輸出並插值後得到的射線,它不僅包含了該畫素到攝像機的方向,也包含了距離資訊。linearDepth 的獲取我們己經在13.1.2 節中詳細解釋過了,因此,本節著重解釋 interpolatedRay 的求法。
interpolatedRay 來源於對近裁剪平面的4 個角的某個特定向量的插值,這4 個向量包含了它們到攝像機的方向和距離資訊,我們可以利用攝像機的近裁剪平面距離、FOV、橫縱比計算而得。圖13.6顯示了計算時使用的一些輔助向量。為了方便計算,我們可以先計算兩個向量——toTop 和 toRight, 它們是起點位於近裁剪平面中心、分別指向攝像機正上方和正右方的向量。它們的計算公式如下:

注意,上面求得的4 個向量不僅包含了方向資訊,它們的模對應了4 個點到攝像機的空間距離。由於我們得到的線性深度值並非是點到攝像機的歐式距離,而是在z 方向上的距離,因此,我們不能直接使用深度值和4 個角的單位方向的乘積來計算它們到攝像機的偏移量,如圖13.7 所示。想要把深度值轉換成到攝像機的歐式距離也很簡單,我們以TL 點為例,根據相似三角形原理, TL 所在的射線上,畫素的深度值和它到攝像機的實際距離的比等於近裁剪平面的距離和TL向量的模的比,即



屏幕後處理的原理是使用特定的材質去渲染一個剛好填充整個螢幕的四邊形面片。這個四邊形面片的4 個頂點就對應了近裁剪平面的4 個角。因此,我們可以把上面的計算結果傳遞給頂點著色器,頂點著色器根據當前的位置選擇它所對應的向量,然後再將其輸出,經插值後傳遞給片元著色器得到interpoIatedRay,我們就可以直接利用本節一開始提到的公式重建該畫素在世界空間下的位置了。

13.3.2 霧的計算

在簡單的霧效實現中,我們需要計算一個霧效係數 f,作為混合原始顏色和霧的顏色的混合係數:

13.3.3 實現

為了在Unity 中實現基於屏幕後處理的霧效,我們需要進行如下準備工作。
(1)新建一個場景。在本書資源中,該場景名為Scene_13_3 。在Unity 5.2 中,預設情況下場景將包含一個攝像機和一個平行光,並且使用了內建的天空盒子。在Window -> Lighting -> Skybox 中去掉場景中的天空盒子。
(2)我們需要搭建一個測試霧效的場景。在本書資源的實現中,我們構建了一個包含3 面牆的房間,並放置了兩個立方體和兩個球體,它們都使用了我們在9.5 節中建立的標準材質。同時,我們把本書資源中的Translating.cs 指令碼拖曳給攝像機,讓其在場景中不斷運動。
(3)新建一個指令碼。在本書資源中,該指令碼名為FogWithDepthTexture.cs . 把該指令碼拖曳到攝像機上。
(4)新建-個Unity Shader。在本書資源中,該Shader 名為Chapterl3-FogWithDepthTexture 。
我們首先來編寫FogWithDepthTexture.cs 指令碼。開啟該指令碼,並進行如下修改。
(1)首先,繼承12.1 節中建立的基類:
public class FogWithDepthTexture : PostEffectsBase {
(2 )宣告該效果需要的Shader,並據此建立相應的材質:
	public Shader fogShader;
	private Material fogMaterial = null;

	public Material material {  
		get {
			fogMaterial = CheckShaderAndCreateMaterial(fogShader, fogMaterial);
			return fogMaterial;
		}  
	}
(3 )在本節中,我們需要獲取攝像機的相關引數,如近裁剪平面的距離、FOV 等,同時還需要獲取攝像機在世界空間下的前方、上方和右方等方向,因此我們用兩個變數儲存攝像機的Camera 元件和Transform 元件:
	private Camera myCamera;
	public Camera camera {
		get {
			if (myCamera == null) {
				myCamera = GetComponent<Camera>();
			}
			return myCamera;
		}
	}

	private Transform myCameraTransform;
	public Transform cameraTransform {
		get {
			if (myCameraTransform == null) {
				myCameraTransform = camera.transform;
			}

			return myCameraTransform;
		}
	}
(4 )定義模擬霧效時使用的各個引數:
	[Range(0.0f, 3.0f)]
	public float fogDensity = 1.0f;

	public Color fogColor = Color.white;

	public float fogStart = 0.0f;
	public float fogEnd = 2.0f;
fogDensity 用於控制霧的濃度, fogColor 用於控制霧的顏色。我們使用的霧效模擬函式是基於高度的,因此引數fogStart 用於控制霧效的起始高度, fogEnd 用於控制霧效的終止高度。
( 5 )由於本例需要獲取攝像機的深度紋理,我們在指令碼的OnEnable 函式中設定攝像機的相應狀態:
	void OnEnable() {
		camera.depthTextureMode |= DepthTextureMode.Depth;
	}
( 6 )最後, 我們實現了OnRenderlmage 函式:
	void OnRenderImage (RenderTexture src, RenderTexture dest) {
		if (material != null) {
			Matrix4x4 frustumCorners = Matrix4x4.identity;

			float fov = camera.fieldOfView;
			float near = camera.nearClipPlane;
			float aspect = camera.aspect;

			float halfHeight = near * Mathf.Tan(fov * 0.5f * Mathf.Deg2Rad);
			Vector3 toRight = cameraTransform.right * halfHeight * aspect;
			Vector3 toTop = cameraTransform.up * halfHeight;

			Vector3 topLeft = cameraTransform.forward * near + toTop - toRight;
			float scale = topLeft.magnitude / near;

			topLeft.Normalize();
			topLeft *= scale;

			Vector3 topRight = cameraTransform.forward * near + toRight + toTop;
			topRight.Normalize();
			topRight *= scale;

			Vector3 bottomLeft = cameraTransform.forward * near - toTop - toRight;
			bottomLeft.Normalize();
			bottomLeft *= scale;

			Vector3 bottomRight = cameraTransform.forward * near + toRight - toTop;
			bottomRight.Normalize();
			bottomRight *= scale;

			frustumCorners.SetRow(0, bottomLeft);
			frustumCorners.SetRow(1, bottomRight);
			frustumCorners.SetRow(2, topRight);
			frustumCorners.SetRow(3, topLeft);

			material.SetMatrix("_FrustumCornersRay", frustumCorners);

			material.SetFloat("_FogDensity", fogDensity);
			material.SetColor("_FogColor", fogColor);
			material.SetFloat("_FogStart", fogStart);
			material.SetFloat("_FogEnd", fogEnd);

			Graphics.Blit (src, dest, material);
		} else {
			Graphics.Blit(src, dest);
		}
	}
OnRenderlmage 首先計算了近裁剪平面的四個角對應的向量, 並把它們儲存在一個矩陣型別的變數(frustumCorners)中。計算過程我們已經在13.3.1 節中詳細解釋過了,程式碼只是套用了之前講過的公式而己。我們按一定順序把這四個方向儲存到了frustumCorners 不同的行中,這個順序是非常重要的,因為這決定了我們在頂點著色器中使用哪一行作為該點的待插值向量。隨後,我們把結果和其他引數傳遞給材質,並呼叫Graphics.Blit (src, dest, material)把渲染結果顯示在螢幕上。
下面,我們來實現Shader 的部分。開啟Chapter13-FogWithDepthTexture,進行如下修改。 (1)我們首先需要宣告本例使用的各個屬性:
	Properties {
		_MainTex ("Base (RGB)", 2D) = "white" {}
		_FogDensity ("Fog Density", Float) = 1.0
		_FogColor ("Fog Color", Color) = (1, 1, 1, 1)
		_FogStart ("Fog Start", Float) = 0.0
		_FogEnd ("Fog End", Float) = 1.0
	}
( 2 )在本節中, 我們使用CGINCLUDE 來組織程式碼。我們在SubShader 塊中利用CGINCLUDE 和 ENDCG 語義來定義一系列程式碼:
SubShader {
    CGINCLUDE
    ...
    ENDCG
    ...
(3)宣告程式碼中需要使用的各個變數:
		float4x4 _FrustumCornersRay;
		
		sampler2D _MainTex;
		half4 _MainTex_TexelSize;
		sampler2D _CameraDepthTexture;
		half _FogDensity;
		fixed4 _FogColor;
		float _FogStart;
		float _FogEnd;
_FrustumCornersRay 雖然沒有在Properties 中宣告, 但仍可由指令碼傳遞給Shader。除了 Properties 中宣告的各個屬性,我們還聲明瞭深度紋理 _CameraDepthTexture, Unity 會在背後把得到的深度紋理傳遞給該值。
( 4)定義頂點著色器:
		struct v2f {
			float4 pos : SV_POSITION;
			half2 uv : TEXCOORD0;
			half2 uv_depth : TEXCOORD1;
			float4 interpolatedRay : TEXCOORD2;
		};
		
		v2f vert(appdata_img v) {
			v2f o;
			o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
			
			o.uv = v.texcoord;
			o.uv_depth = v.texcoord;
			
			#if UNITY_UV_STARTS_AT_TOP
			if (_MainTex_TexelSize.y < 0)
				o.uv_depth.y = 1 - o.uv_depth.y;
			#endif
			
			int index = 0;
			if (v.texcoord.x < 0.5 && v.texcoord.y < 0.5) {
				index = 0;
			} else if (v.texcoord.x > 0.5 && v.texcoord.y < 0.5) {
				index = 1;
			} else if (v.texcoord.x > 0.5 && v.texcoord.y > 0.5) {
				index = 2;
			} else {
				index = 3;
			}

			#if UNITY_UV_STARTS_AT_TOP
			if (_MainTex_TexelSize.y < 0)
				index = 3 - index;
			#endif
			
			o.interpolatedRay = _FrustumCornersRay[index];
				 	 
			return o;
		}
在v2f 結構體中,我們除了定義頂點位置、螢幕影象和深度紋理的紋理座標外,還定義了 interpolatedRay 變數儲存插值後的畫素向量。在頂點著色器中,我們對深度紋理的取樣座標進行了平臺差異化處理。更重要的是,我們要決定該點對應了4 個角中的哪個角。我們採用的方法是判斷它的紋理座標。我們知道,在Unity 中,紋理座標的(0, 0)點對應了左下角,而(1, 1)點對應了右上角。我們據此來判斷該頂點對應的索引,這個對應關係和我們在指令碼中對 frustumCorners 的賦值順序是一致的。實際上,不同平臺的紋理座標不一定是滿足上面的條件的,例如DirectX 和 Metal 這樣的平臺,左上角對應了(0, 0)點,但大多數情況下Unity 會把這些平臺下的螢幕影象進行翻轉,因此我們仍然可以利用這個條件。但如果在類似DirectX 的平臺上開啟了抗鋸齒, Unity就不會進行這個翻轉。為了此時仍然可以得到相應頂點位置的索引值,我們對索引值也進行了平臺差異化處理(詳見5.6.1 節〉,以便在必要時也對索引值進行翻轉。最後,我們使用索引值來獲取 _FrustumCornersRay 中對應的行作為該頂點的interpolatedRay 值。
儘管我們這裡使用了很多判斷語句,但由於屏幕後處理所用的模型是一個四邊形網格,只包含4 個頂點,因此這些操作不會對效能造成很大影響。
(5)我們定義了片元著色器來產生霧效:
		fixed4 frag(v2f i) : SV_Target {
			float linearDepth = LinearEyeDepth(SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv_depth));
			float3 worldPos = _WorldSpaceCameraPos + linearDepth * i.interpolatedRay.xyz;
						
			float fogDensity = (_FogEnd - worldPos.y) / (_FogEnd - _FogStart); 
			fogDensity = saturate(fogDensity * _FogDensity);
			
			fixed4 finalColor = tex2D(_MainTex, i.uv);
			finalColor.rgb = lerp(finalColor.rgb, _FogColor.rgb, fogDensity);
			
			return finalColor;
		}
首先,我們需要重建該畫素在世界空間中的位置。為此,我們首先使用SAMPLE_DEPTH_TEXTURE對深度紋理進行取樣,再使用LinearEyeDepth 得到視角空間下的線性深度值。之後,與interpolatedRay 相乘後再和世界空間下的攝像機位置相加,即可得到世界空間下的位置。
得到世界座標後,模擬霧效就變得非常容易。在本例中,我們選擇實現基於高度的霧效模擬,計算公式可參見13.3.2 節。我們根據材質屬性 _FogEnd 和 _FogStart 計算當前的畫素高度 worldPos.y 對應的霧效係數 fogDensity,再和引數 _FogDensity 相乘後,利用saturate 函式擷取到[0, 1]範圍內,作為最後的霧效係數。然後,我們使用該係數將霧的顏色和原始顏色進行、混合後返回。讀者也可以使用不同的公式來實現其他種類的霧效。
(6)隨後,我們定義了霧效渲染所需的Pass :
		Pass {
			ZTest Always Cull Off ZWrite Off
			     	
			CGPROGRAM  
			
			#pragma vertex vert  
			#pragma fragment frag  
			  
			ENDCG  
		}
(7 )最後,我們關閉了Shader 的Fallback:
	FallBack Off
完成後返回編輯器,並把Chapter13-FogWithDepthTexture 拖曳到攝像機的FogWithDepthTexture.cs指令碼中的fogShader 引數中。當然,我們可以在FogWithDepthTexture.cs 的指令碼面板中將fogShader 引數的預設值設定為Chapter13-FogWithDepthTexture,這樣就不需要以後使用時每次都手動拖曳了。
本節介紹的使用深度紋理重建畫素的世界座標的方法是非常有用的。但需要注意的是,這裡的實現是基於攝像機的投影型別是透視投影的前提下。如果需要在正交投影的情況下重建世界座標,需要使用不同的公式,但請讀者相信,這個過程不會比透視投影的情況更加複雜。有興趣的讀者可以嘗試自行推導,或參考這篇部落格 ( http://www.derschmale.com/2014/03/19/reconstructing-positions-from-the-depth-buffer-pt-2-perspective-and-orthographic-general-case/)來實現。

13.4 再談邊緣檢測

在12.3 節中,我們曾介紹如何使用Sobel 運算元對螢幕影象進行邊緣檢測, 實現描邊的效果。但是,這種直接利用顏色資訊進行邊緣檢測的方法會產生很多我們不希望得到的邊緣線,如圖13.8 所示。 可以看出,物體的紋理、陰影等位置也被描上黑邊,而這往往不是我們希望看到的。在本節中,我們將學習如何在深度和法線紋理上進行邊緣檢測,這些影象不會受紋理和光照的影響,而僅僅儲存了當前渲染物體的模型資訊,通過這樣的方式檢測出來的邊緣更加可靠。在學習完本節後,我們可以得到類似圖13.9 中的效果。
與12.3 節使用Sobel 運算元不同,本節將使用Roberts 運算元來進行邊緣檢測。它使用的卷積核如圖13.10 所示。 Roberts 運算元的本質就是計算左上角和右下角的差值,乘以右上角和左下角的差值,作為評估邊緣的依據。在下面的實現中,我們也會按這樣的方式,取對角方向的深度或法線值,比較它們之間的差值,如果超過某個閥值(可由引數控制),就認為它們之間存在一條邊。
首先,我們需要進行如下準備工作。
( 1)新建一個場景。在本書資源中,該場景名為Scene_13_4 。在Unity 5.2 中,預設情況下場景將包含一個攝像機和一個平行光,並且使用了內建的天空盒子。在Window→ Lighting→ Skybox 中去掉場景中的天空盒子。
( 2 )我們需要搭建一個測試霧效的場景。在本書資源的實現中,我們構建了一個包含3 面牆的房間,並放置了兩個立方體和兩個球體,它們都使用了我們在9.5 節中建立的標準材質。同時,我們把本書資源中的Translating.cs 指令碼拖曳給攝像機,讓其在場景中不斷運動。
( 3)新建一個指令碼。在本書資源中,該指令碼名為EdgeDetectNormalsAndDepth.cs。把該指令碼拖曳到攝像機上。
( 4) 新建一個Unity Shader。在本書資源中,該Shader 名為Chapter13-EdgeDetectNormalAndDepth。
我們首先來編寫EdgeDetecNormalsAndDepth.cs 指令碼。該指令碼與12.3 節中實現的EdgeDetection.cs指令碼幾乎完全一樣,只是添加了一些新的屬性。為了完整性,我們再次說明對該指令碼進行的修改。
(1)首先,繼承12.1 節中建立的基類:
	public class EdgeDetectNormalsAndDepth : PostEffectsBase {
( 2 ) 宣告該效果需要的Shader , 並據此建立相應的材質:
	public Shader edgeDetectShader;
	private Material edgeDetectMaterial = null;
	public Material material {  
		get {
			edgeDetectMaterial = CheckShaderAndCreateMaterial(edgeDetectShader, edgeDetectMaterial);
			return edgeDetectMaterial;
		}  
	}
(3 )在指令碼中提供了調整邊緣線強度描邊顏色以及背景顏色的引數。同時添加了控制取樣距離以及對深度和法線進行邊緣檢測時的靈敏度引數:
	[Range(0.0f, 1.0f)]
	public float edgesOnly = 0.0f;

	public Color edgeColor = Color.black;

	public Color backgroundColor = Color.white;

	public float sampleDistance = 1.0f;

	public float sensitivityDepth = 1.0f;

	public float sensitivityNormals = 1.0f;
sampleDistance 用於控制對深度+法線紋理取樣時,使用的取樣距離。從視覺上來看,sampleDistance 值越大,描邊越寬。sensitivityDepth 和sensitivityNormals 將會影響當鄰域的深度值或法線值相差多少時,會被認為存在一條邊界。如果把靈敏度調得很大,那麼可能即使是深度或法線上很小的變化也會形成一條邊。
( 4 )由於本例需要獲取攝像機的深度+法線紋理,我們在指令碼的OnEnable 函式中設定攝像機的相應狀態:
	void OnEnable() {
		GetComponent<Camera>().depthTextureMode |= DepthTextureMode.DepthNormals;
	}
( 5 ) 實現OnRenderlmage 函式,把各個引數傳遞給材質:
	[ImageEffectOpaque]
	void OnRenderImage (RenderTexture src, RenderTexture dest) {
		if (material != null) {
			material.SetFloat("_EdgeOnly", edgesOnly);
			material.SetColor("_EdgeColor", edgeColor);
			material.SetColor("_BackgroundColor", backgroundColor);
			material.SetFloat("_SampleDistance", sampleDistance);
			material.SetVector("_Sensitivity", new Vector4(sensitivityNormals, sensitivityDepth, 0.0f, 0.0f));

			Graphics.Blit(src, dest, material);
		} else {
			Graphics.Blit(src, dest);
		}
	}
需要注意的是,這裡我們為OnRenderlmage 函式添加了[ImageEffectOpaque]屬性。我們曾在12.1節中提到過該屬性的含義。在預設情況下,OnRenderlmage 函式會在所有的不透明和透明的Pass 執行完畢後被呼叫,以便對場最中所有遊戲物件都產生影響。但有時,我們希望在不透明的Pass (即渲染佇列小於等於2 500 的Pass,內建的Background、Geometry 和AlphaTest 渲染佇列均在此範圍內)執行完畢後立即呼叫該函式,而不對透明物體(渲染佇列為Transparent 的Pass )產生影響,此時,我們可以在OnRenderlmage 函式前新增ImageEffectOpaque 屬性來實現這樣的目的。在本例中,我們只希望對不透明物體迸行描邊,而不希望透明物體也被描邊, 因此需要新增該屬性。
下面,我們來實現Shader 的部分。開啟Chapter13-EdgeDetectNormalAndDep血,進行如下修改。
(1)我們首先需要宣告本例使用的各個屬性:
	Properties {
		_MainTex ("Base (RGB)", 2D) = "white" {}
		_EdgeOnly ("Edge Only", Float) = 1.0
		_EdgeColor ("Edge Color", Color) = (0, 0, 0, 1)
		_BackgroundColor ("Background Color", Color) = (1, 1, 1, 1)
		_SampleDistance ("Sample Distance", Float) = 1.0
		_Sensitivity ("Sensitivity", Vector) = (1, 1, 1, 1)
	}
其中,_Sensitivity 的xy 分量分別對應了法線和深度的檢測靈敏度, zw 分量則沒有實際用途。
( 2 )在本節中,我們使用CGINCLUDE 來組織程式碼。我們在SubShader 塊中利用CGINCLUDE 和 ENDCG 語義來定義一系列程式碼:
SubShader {
    CG INCLUDE
    ...
    ENDCG
    ...
(3)為了在程式碼中訪問各個屬性,我們需要在CG 程式碼塊中宣告對應的變數:
		sampler2D _MainTex;
		half4 _MainTex_TexelSize;
		fixed _EdgeOnly;
		fixed4 _EdgeColor;
		fixed4 _BackgroundColor;
		float _SampleDistance;
		half4 _Sensitivity;
		
		sampler2D _CameraDepthNormalsTexture;
在上面的程式碼中,我們聲明瞭需要獲取的深度+法線紋理 _CameraDepthNormalsTexture。由於我們需要對鄰域畫素進行紋理取樣,所以還聲明瞭儲存紋素大小的變數 _MainTex_TexelSize 。
( 4 )定義頂點著色器:
		struct v2f {
			float4 pos : SV_POSITION;
			half2 uv[5]: TEXCOORD0;
		};
		  
		v2f vert(appdata_img v) {
			v2f o;
			o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
			
			half2 uv = v.texcoord;
			o.uv[0] = uv;
			
			#if UNITY_UV_STARTS_AT_TOP
			if (_MainTex_TexelSize.y < 0)
				uv.y = 1 - uv.y;
			#endif
			
			o.uv[1] = uv + _MainTex_TexelSize.xy * half2(1,1) * _SampleDistance;
			o.uv[2] = uv + _MainTex_TexelSize.xy * half2(-1,-1) * _SampleDistance;
			o.uv[3] = uv + _MainTex_TexelSize.xy * half2(-1,1) * _SampleDistance;
			o.uv[4] = uv + _MainTex_TexelSize.xy * half2(1,-1) * _SampleDistance;
					 
			return o;
		}
我們在v2f 結構體中定義了一個維數為5 的紋理座標陣列。這個陣列的第一個座標儲存了螢幕顏色影象的取樣紋理。我們對深度紋理的取樣座標進行了平臺差異化處理, 在必要情況下對它的豎直方向進行了翻轉。陣列中剩餘的 4 個座標則儲存了使用Roberts 運算元時需要取樣的紋理座標, 我們還使用了 _SampleDistance 來控制取樣距離。通過把計算取樣紋理座標的程式碼從片元著色器中轉移到頂點著色器中, 可以減少運算, 提高效能。由於從頂點著色器到片元著色器的插值是線性的, 因此這樣的轉移並不會影響紋理座標的計算結果。
( 5 )然後,我們定義了片元著色器:
		fixed4 fragRobertsCrossDepthAndNormal(v2f i) : SV_Target {
			half4 sample1 = tex2D(_CameraDepthNormalsTexture, i.uv[1]);
			half4 sample2 = tex2D(_CameraDepthNormalsTexture, i.uv[2]);
			half4 sample3 = tex2D(_CameraDepthNormalsTexture, i.uv[3]);
			half4 sample4 = tex2D(_CameraDepthNormalsTexture, i.uv[4]);
			
			half edge = 1.0;
			
			edge *= CheckSame(sample1, sample2);
			edge *= CheckSame(sample3, sample4);
			
			fixed4 withEdgeColor = lerp(_EdgeColor, tex2D(_MainTex, i.uv[0]), edge);
			fixed4 onlyEdgeColor = lerp(_EdgeColor, _BackgroundColor, edge);
			
			return lerp(withEdgeColor, onlyEdgeColor, _EdgeOnly);
		}
我們首先使用4 個紋理座標對深度+法線紋理進行取樣,再呼叫CheckSame 函式來分別計算對角線上兩個紋理值的差值。CheckSame 函式的返回值要麼是0,要麼是1,返回0 時表明這兩點之間存在一條邊界,反之則返回1 。它的定義如下:
		half CheckSame(half4 center, half4 sample) {
			half2 centerNormal = center.xy;
			float centerDepth = DecodeFloatRG(center.zw);
			half2 sampleNormal = sample.xy;
			float sampleDepth = DecodeFloatRG(sample.zw);
			
			// difference in normals
			// do not bother decoding normals - there's no need here
			half2 diffNormal = abs(centerNormal - sampleNormal) * _Sensitivity.x;
			int isSameNormal = (diffNormal.x + diffNormal.y) < 0.1;
			// difference in depth
			float diffDepth = abs(centerDepth - sampleDepth) * _Sensitivity.y;
			// scale the required threshold by the distance
			int isSameDepth = diffDepth < 0.1 * centerDepth;
			
			// return:
			// 1 - if normals and depth are similar enough
			// 0 - otherwise
			return isSameNormal * isSameDepth ? 1.0 : 0.0;
		}
CheckSame 首先對輸入引數進行處理,得到兩個取樣點的法線和深度值。值得注意的是,這裡我們並沒有解碼得到真正的法線值,而是直接使用了 xy 分量。這是因為我們只需要比較兩個取樣值之間的差異度,而並不需要知道它們真正的法線值。然後,我們把兩個取樣點的對應值相減並取絕對值,再乘以靈敏度引數,把差異值的每個分量相加再和一個閥值比較,如果它們的和小於閥值,則返回1,說明差異不明顯,不存在一條邊界;否則返回0。最後,我們把法線和深度的檢查結果相乘,作為組合後的返回值。
當通過CheckSame 函式得到邊緣資訊後,片元著色器就利用該值進行顏色混合,這和12.3節中的步驟一致。
(6)然後,我們定義了邊緣檢測需要使用的Pass:
		Pass { 
			ZTest Always Cull Off ZWrite Off
			
			CGPROGRAM      
			
			#pragma vertex vert  
			#pragma fragment fragRobertsCrossDepthAndNormal
			
			ENDCG  
		}
(7)最後,我們關閉了該Shader 的Fallback:
	FallBack Off
完成後返回編輯器,並把Chapter13-EdgeDetectNormalAndDepth 拖曳到攝像機的EdgeDetectNormalsAndDepth.cs 指令碼中的edgeDetectShader 引數中。當然,我們可以在EdgeDetectlNormaIsAndDepth.cs 的指令碼面板中將edgeDetectShader 引數的預設值設定為Chapter13-EdgeDetectNormaIAndDepth,這樣就不需要以後使用時每次都手動拖曳了。
本節實現的描邊效果是基於整個螢幕空間進行的,也就是說,場景內的所有物體都會被新增描邊效果。但有時,我們希望只對特定的物體進行描邊,例如當玩家選中場景中的某個物體後,我們想要在該物體周圍新增一層描邊效果。這時,我們可以使用Unity 提供的Graphics.DrawMesh 或 Graphics.DrawMeshNow 函式把需要描邊的物體再次渲染一遍(在所有不透明物體渲染完畢之後),然
後再使用本節提到的邊緣檢測演算法計算深度或法線紋理中每個畫素的梯度值,判斷它們是否小於某個閥值,如果是,就在Shader 中使用clip() 函式將該畫素剔除掉,從而顯示出原來的物體顏色。

13.5 擴充套件閱讀

在本章中,我們介紹瞭如何使用深度和法線紋理實現諸如全域性霧效、邊緣檢測等效果。儘管我們只使用了深度和法線紋理,但實際上我們可以在Unity 中建立任何需要的快取紋理。這可以通過使用Unity 的著色器替換( Shader Replacement )功能(即呼叫Camera.RenderWithShader(shader, replacementTag)函式)把整個場景再次渲染一遍來得到,而在很多時候,這實際也是Unity 建立深度和法線紋理時使用的方法。
深度和法線紋理在螢幕特效的實現中往往扮演了重要的角色。許多特殊的螢幕效果都需要依靠這兩種紋理的幫助。Unity 曾在2011 年的SIGGRAPH (計算圖形學的頂級會議〉上做了一個關於使用深度紋理實現各種特效的演講
(http://blogs.unity3d.com/2011/09/08/special-effects-with-depth-talk-at-siggraph/ )。在這個演講中, Unity 的工作人員解釋瞭如何利用深度紋理來實現特定物體的描邊、角色護盾、相交線的高光模擬等效果。在Unity 的 Image Effect  ( http://docs.unity3d.com/Manual/comp-ImageEffects.html )包中,讀者也可以找到一些傳統的使用深度紋理實現螢幕特效的例子,例如螢幕空間的環境遮擋(Screen Space Ambient Occlusion, SSAO )等效果。