前面學習的屏幕后處理技術都只是在屏幕顏色圖像上進行各種操作,但當希望得到深度和法線信息時,就無能為力;邊緣檢測 ,另外一種更好的辦法就是利用深度和法線信息可以準確的得到邊緣信息。
一.獲取深度和法線紋理的原理
在 Unity 里獲取深度和法線紋理的代碼非常簡單, 但是我們有必要在這之前首先了解它。
1.1.原理
深度紋理實際上就是一張渲染紋理,深度值范圍在【0,1】,而且是非線性分布的。 這些深度值來自頂點變換后得到的歸一化設備坐標(Normalized Device Coordinates NDC)?;仡欉@個過程:一個模型要想最終被繪制在屏幕上,需要把頂點從模型空間變換到其次裁剪坐標系。在頂點著色器中乘以MVP變換矩陣得到的。在變換的最后一步,我們需要使用一個投影矩陣來變換頂點。當我們使用的是透視投影類型的攝像機時,這個投影矩陣就是非線性的。
下圖顯示了之前給出的Unity中透視投影對頂點的變換過程,下圖最左側的圖顯示了投影變換前,即觀察空間下視錐體的結果以及相應的頂點位置,中間的圖顯示了應用透視裁剪矩陣后的變換結果,即頂點著色器階段輸出的頂點變換結果,最右側的圖則是底層硬件進行了透視除法后得到的歸一化的設備坐標。需要注意的是,這里的投影過程是建立在Unity對坐標系的假定上的,也就是說,我們針對的是觀察空間為右手坐標系,使用列矩陣在矩陣右側進行相乘,且變換到NDC后z分量范圍將在[-1,1]之間的情況。而類似DirectX 這樣的圖形接口中,變換后z分量范圍將在[0,1]之間。


在得到NDC后,深度紋理中的像素值就可以很方便的計算得到了,深度值對應了NDC中的頂點坐標Z分量的值。由于其值是在【-1,1】之間,需要一個公式對其映射,原來就用過類似的。乘以一半在加0.5。

其中, d 對應了深度紋理中的像素值, Zndc 對應了NDC 坐標中的z 分量的值。
那么Unity 是怎么得到這樣一張深度紋理的呢?
在Unity 中,深度紋理可以直接來自于真正的深度緩存,也可以是由一個單獨的Pass 渲染而得,這取決于使用的渲染路徑和硬件。通常來講,當使用延遲渲染路徑(包括遺留的延遲渲染路徑)時,深度紋理理所當然可以訪問到,因為延遲渲染會把這些信息渲染到G-buffer 中。而當無法直接獲取深度緩存時,深度和法線紋理是通過一個單獨的Pass 渲染而得的。具體實現(xiàn)是, Unity 會使用著色器替換( Shader Replacement )技術選擇那些渲染類型〈即SubShader 的RenderType 標簽)為Opaque 的物體,判斷它們使用的渲染隊列是否小于等于2500 (內(nèi)置的Background 、Geometry 和AlphaTest 渲染隊列均在此范圍內(nèi)),如果滿足條件,就把它渲染到深度和法線紋理中。因此,要想讓物體能夠出現(xiàn)在深度和法線紋理中,就必須在Shader 中設置正確的RenderType 標簽。
在Unity 中,我們可以選擇讓一個攝像機生成一張深度紋理或是一張深度+法線紋理。當選擇前者,即只需要一張單獨的深度紋理時, Unity 會直接獲取深度緩存或是按之前講到的著色器替換技術,選取需要的不透明物體,并使用它投射陰影時使用的Pass (即LightMode 被設置為ShadowCaster 的Pass,詳見9.4 節(jié))來得到深度紋理。如果Shader 中不包含這樣一個Pass,那么這個物體就不會出現(xiàn)在深度紋理中(當然,它也不能向其他物體投射陰影)。深度紋理的精度通常是24 位或16 位,這取決于使用的深度緩存的精度。
如果選擇生成一張深度+法線紋理, Unity 會創(chuàng)建一張和屏幕分辨率相同、精度為32 位〈每個通道為8 位)的紋理,其中觀察空間下的法線信息會被編碼進紋理的R 和G 通道,而深度信息會被編碼進B 和A 通道。法線信息的獲取在延遲渲染中是可以非常容易就得到的, Unity 只需要合并深度和法線緩存即可。而在前向渲染中,默認情況下是不會創(chuàng)建法線緩存的,因此Unity 底層使用了一個單獨的Pass 把整個場景再次渲染一遍來完成。這個Pass 被包含在Unity 內(nèi)置的一個Unity Shader 中,我們可以在內(nèi)置的builtin_shaders-xxx/DefaultResources/Camera-DepthNormalTexture.shader 文件中找到這個用于渲染深度和法線信息的Pass。
這樣看實時獲得法線紋理還是一個很耗時的操作。
1.2.如何獲取
Unity中,獲取深度紋理 在腳本中設置攝像機的depthTextureMode來完成的
camera.depthTextureMode = DepthTextureMode.Depth;
一旦設置好了上面的攝像機模式后,我們就可以在Shader 中通過聲明 _CameraDepthTexture變量來訪問它。這個過程非常簡單,但我們需要知道這兩行代碼的背后, Unity 為我們做了許多工作 上一節(jié)說明了。
同樣的 要想獲得深度+法線紋理,腳本中設置
camera.depthTextureMode = DepthTextureMode.DepthNormals;
然后在Shader 中通過聲明 _CameraDepthNormalsTexture 變量來訪問它。
我們還可以組合這些模式,讓一個攝像機同時產(chǎn)生一張深度和深度+法線紋理:
camera.depthTextureMode |= DepthTextureMode.Depth;
camera.depthTextureMode |= DepthTextureMode.DepthNormals;
在Unity5中,我們還可以在攝像機的Camera組件上看到當前攝像機是否需要渲染深度或深度+法線紋理。當在Shader中訪問到深度紋理_CameraDepthTexture 后,我們就可以使用當前像素的紋理坐標對它進行采樣。絕大多數(shù)情況下,我們直接使用tex2D函數(shù)采樣即可,但在某些平臺上,我們需要一些特殊處理。Unity為我們提供了一個統(tǒng)一的宏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 宏同樣接受兩個參數(shù)一一深度紋理和一個float3 或float4 類型的紋理坐標,它的內(nèi)部使用了tex2Dproj 這樣的函數(shù)進行投影紋理采樣,紋理坐標的前兩個分量首先會除以最后一個分量,再進行紋理采樣。如果提供了第四個分量,還會進行一次比較,通常用于陰影的實現(xiàn)中。SAMPLE_DEPTH_TEXTURE PROJ 的第二個參數(shù)通常是由頂點著色器輸出插值而得的屏幕坐標,例如:
float d = SAMPLE_DEPTH_TEXTURE_PROJ(_CarneraDepthTexture,UNITY_PROJ_COORD(i.scrPos));
其中, i.scrPos 是在頂點著色器中通過調用ComputeScreenPos(o.pos)得到的屏幕坐標。
當通過紋理采樣得到深度值后,這些深度值往往是非線性的,這種非線性來自于透視投影使用的裁剪矩陣。然而,在我們的計算過程中通常是需要線性的深度值,也就是說,我們需要把投影后的深度值變換到線性空間下,例如視角空間下的深度值。那么,我們應該如何進行這個轉換呢?實際上,我們只需要倒推頂點變換的過程即可。
Unity提供了兩個輔助函數(shù)來為我們進行上述的計算過程——LinearEyeDepth 和 Linear01Depth。LinearEyeDepth 負責把深度紋理的采樣結果轉換到視角空間下的深度值,也 就是我們上面得到的Z(visw)。而 Linear01Depth 則會返回一個范圍在[0, 1]的線性深度值,也就是我們上面得到的Z(01),這兩個函數(shù)內(nèi)部使用了內(nèi)置的_ZBufferParams變量來得到遠近裁剪平面的距離。
如果我們需要獲取深度+法線紋理,可以直接使用tex2D函數(shù)對_CameraDepthNormalsTexture 進行采樣,得到里面存儲的深度和法線信息。Unity提供了輔助函數(shù)來為我們隊這個采樣結果進行解碼,從而得到深度值和法線方向。這個函數(shù)是DecodeDepthNormal,它在UnityCG.cginc里被定義:
inline void DecodeDepthNormal(float4 enc, out float depth,out float3 normal){
depth = DecodeFloatRG(enc.zw);
normal = DecodeViewNormalStereo(enc);
}
DecodeDepthNormal 的第一個參數(shù)是對深度+法線紋理的采樣結果,這個采樣結果是Unity對深度和法線信息編碼后的結果,它的xy分量存儲的是視角空間下的法線信息,而深度信息被編碼進了zw分量。通過調用DecodeDepthNormal 函數(shù)對采樣結果解碼后,我們就可以得到解碼后的深度值和法線。這個深度值是范圍在[0, 1]的線性深度值(這與單獨的深度紋理中存儲的深度值不同),而得到的法線則是視角空間下的法線方向。同樣,我們也可以通過調用DecodeFloatRG 和 DecodeViewNormalStereo來解碼深度+法線紋理中的深度和法線信息。
1.2.查看深度和法線紋理
利用Frame Debugger 可以查看到深度紋理和深度+法線紋理。下圖顯示了幀調試器查看到的深度紋理和深度+法線紋理。

使用幀調試器查看到的深度紋理是非線性空間的深度值,而深度+法線紋理都是由Unity編碼后的結果。有時,顯示出線性空間下的深度信息或解碼后的法線方向會更加有用。此時,我們可以自行在片元著色器中輸出轉換或解碼后的深度和法線值,如下圖所示。

輸出代碼非常簡單,我們可以使用類似下面的代碼來輸出線性深度值:
float depth = SMAPLE_DEPTH_TEXTURE(_CameraDepthTexture,i.uv);
float linearDepth = Linear01Depth(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)調小,使視錐體的范圍剛好覆蓋場景的所在區(qū)域。這是因為,由于投影變換時需要覆蓋從近裁剪平面到遠裁剪平面的所有深度區(qū)域,當遠裁剪平面的距離過大時,會導致離攝像機較近的距離被映射到非常小的深度值,如果場景是一個封閉的區(qū)域,那么這就會導致畫面看起來幾乎是全黑的。相反,如果場景是一個開放區(qū)域,且物體離攝像機的距離較遠,就會導致畫面幾乎是全白的。
二.再談邊緣檢測
之前我們曾介紹如何使用Sobel算子對屏幕圖像進行邊緣檢測,實現(xiàn)描邊的效果。但是,這種直接利用顏色信息進行邊緣檢測的方法會產(chǎn)生很多我們不希望得到的邊緣線,如下圖所示。

我們使用Robert算子來進行邊緣檢測。它使用的卷積核如下圖所示。

構建一個包含3面墻的房間,放置兩個立方體和兩個球體。
攝像機上添加EdgeDetectNormalsAndDepth.cs 腳本
pulic class EdgeDetectNormalsAndDepth: PostEffectsBase{
public Shader edgeDetectShader;
private Material edgeDetectMaterial = null;
public Material material{
get{
edgeDetectMaterial = CheckShaderAndCreateMaterial(edgeDetectShader, edgeDetectMaterial);
return edgeDetechMaterial;
}
}
[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;
void OnEnable() {
GetComponent<Camera>().depthTextureMode |= DepthTextureMode.DepthNormals;
}//需要獲取攝像機的深度 +法線紋理
[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);
}
}
}
這里我們?yōu)镺nRenderlmage 函數(shù)添加了[ImageEffectOpaque]屬性。我們曾在12.1節(jié)中提到過該屬性的含義。在默認情況下,OnRenderlmage 函數(shù)會在所有的不透明和透明的Pass 執(zhí)行完畢后被調用,以便對場最中所有游戲對象都產(chǎn)生影響。但有時,我們希望在不透明的Pass (即渲染隊列小于等于2 500 的Pass,內(nèi)置的Background、Geometry 和AlphaTest 渲染隊列均在此范圍內(nèi))執(zhí)行完畢后立即調用該函數(shù),而不對透明物體(渲染隊列為Transparent 的Pass )產(chǎn)生影響,此時,我們可以在OnRenderlmage 函數(shù)前添加ImageEffectOpaque 屬性來實現(xiàn)這樣的目的。在本例中,我們只希望對不透明物體迸行描邊,而不希望透明物體也被描邊, 因此需要添加該屬性。
Shader 代碼
Shader "Unlit/Chapter13-MyEdgeDetectNormalAndDepth"
{
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 分量則沒有實際用途。
}
SubShader {
CGINCLUDE
#include "UnityCG.cginc"
sampler2D _MainTex;
half4 _MainTex_TexelSize;//存儲紋素大小的變量
fixed _EdgeOnly;
fixed4 _EdgeColor;
fixed4 _BackgroundColor;
float _SampleDistance;
half4 _Sensitivity;
sampler2D _CameraDepthNormalsTexture;
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
//存儲 Roberts 算子
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;
}
//CheckSame 函數(shù)來分別計算對角線上兩個紋理值的差值 返回0代表有邊界
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;
}
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);
}
ENDCG
Pass {
ZTest Always Cull Off ZWrite Off
CGPROGRAM
#pragma vertex vert
#pragma fragment fragRobertsCrossDepthAndNormal
ENDCG
}
}
FallBack Off
}
實現(xiàn)的描邊效果是基于整個屏幕空間進行的,也就是說,場景內(nèi)所有物體都會被添加描邊效果。但有時,我們希望只對特定的物體進行描邊,例如當玩家渲染場景中的某個物體后,我們想要在該物體周圍添加一層描邊效果。這時,我們需要使用Unity提供的Graphics.DrawMesh 或 Graphics.DrawMeshNow 函數(shù)把需要描邊的物體再次渲染一次(在所有不透明物體渲染完畢后),然后再使用本節(jié)提到的邊緣檢測算法計算深度或法線紋理中每個像素的梯度值,判斷它們是否小于某個閾值,如果是,就再Shader 中使用clip函數(shù)將該像素剔除掉,從而顯示原來的物體顏色。