筆記十六——利用深度紋理實現更多特效

學習教材:《UnityShader入門精要》——馮樂樂

部分計算圖例為《UnityShader入門精要》書中截圖

代碼和實例截圖均為個人實際操作得到


利用深度紋理實現運動模糊

之前實現的運動模糊是通過混合多張屏幕圖像來模擬運動模糊。另一種實現運動模糊的方式是通過速度映射圖,而且該方式應用更加廣泛。速度映射圖中存儲每個像素的速度,使用該速度決定模糊的方向和大小。生成速度緩衝可以將場景中所有物體的速度渲染到一張紋理中,不過該方法需要修改場景中所有物體的Shader代碼,使其添加計算速度的代碼並輸出到下一個渲染紋理中。

有一種方法是通過深度紋理在片元著色器中為每個像素計算其在世界空間下的位置,該過程是通過當前視角x投影矩陣的逆矩陣對NDC下的頂點坐標進行變換得到。再將該位置與上一幀的視角x投影矩陣運算,得到該位置在上一幀的投影空間中的位置,計算上一幀該位置和當前幀的位置差,生成該像素的速度。這種方法的優點在於在一個屏幕後處理特效中就能完成整個效果模擬,缺點是在片元著色器中需要進行兩次矩陣運算,消耗部分性能。

完整代碼:

public class Chapter13_MotionBlurWithDepthTexture : PostEffectsBase{public Shader motionShader;private Material motionBlurMaterial = null;public Material material{ get { motionBlurMaterial = CheckShaderAndCreateMaterial(motionShader, motionBlurMaterial); return motionBlurMaterial; }}[Range(0.0f, 1.0f)]public float blurSize = 1.0f;//定義Camera類型變數,以獲取該腳本所在的攝像機組件//得到攝像機位置,構建觀察空間變換矩陣private Camera myCamera;public Camera camera{ get { if (myCamera == null) { myCamera = GetComponent<Camera>(); } return myCamera; }}//定義一個保存上一幀視角*投影矩陣private Matrix4x4 previousViewProjectionMatrix;//定義攝像機狀態,獲取深度紋理void OnEable(){ camera.depthTextureMode |=DepthTextureMode.Depth;}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); }}}

Shader代碼:

Shader "Custom/Chapter13_MotionBlurWithDepthTexture" {Properties{ _MainTex("Maintex",2D)="white"{} _BlurSize("BlurSize",Float)=1.0}SubShader{ CGINCLUDE #include "UnityCG.cginc" sampler2D _MainTex; half4 _MainTex_TexelSize; sampler2D _CameraDepthTexture; float4x4 _PreviousViewProjectionMatrix; float4x4 _CurrentViewProjectionInverseMatrix; half _BlurSize; struct v2f{ float4 pos:POSITION; half2 uv:TEXCOORD0; half2 uv_depth:TEXCOORD1; }; v2f vert(appdata_img v){ v2f o; o.pos=UnityObjectToClipPos(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; } fixed4 frag(v2f i):SV_Target{ //得到深度緩衝中該像素點的深度值 float d=SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture,i.uv_depth); //得到深度紋理映射前的深度值 float4 H=float4(i.uv.x*2-1,i.uv.y*2-1,d*2-1,1); //通過轉換矩陣得到頂點的世界空間的坐標值 float4 D=mul(_CurrentViewProjectionInverseMatrix,H); float4 worldPos=D/D.w; float4 currentPos=H; float4 previousPos=mul(_PreviousViewProjectionMatrix,worldPos); previousPos/=previousPos.w; float2 velocity=(currentPos.xy-previousPos.xy)/2.0f; float2 uv=i.uv; float4 c=tex2D(_MainTex,uv); //得到像素速度後,對鄰域像素進行採樣,並使用BlurSize控制採樣間隔 //得到的像素點進行平均後,得到模糊效果 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); } ENDCG Pass{ ZTest Always Cull Off ZWrite Off CGPROGRAM #pragma vertex vert #pragma fragment frag ENDCG }}FallBack Off}

實例效果:

可以看到,這裡的模糊效果是整個場景圖像都有模糊效果,而之前採用幀緩衝圖像進行混合所產生的模糊效果在某一方向上是模糊效果,並不是整個場景的圖像都有模糊效果。但是如果在一個物體運動,而攝像機靜止的場景中不會產生模糊效果,這是由於整個速度計算依賴於攝像機的視角變化。而上一節中實現的運動模糊,只要攝像機視野中物體發生了相對運動都可以產生模糊效果。

全局霧效

霧效在遊戲中是一種常見的特效。比如吃雞里就有大霧天,Unity內置的霧效可以生成基於距離的線性或指數霧效。如果在頂點/片元著色器實現這樣的霧效,需要在Shader中添加#pragma multi _compile _fog指令,同時需要使用相關內置宏,比如 UNITY _FOG _COORDS, UNITY _TRANSFER _FOG, UNITY _APPLY _FOG等。使用這種方法,需要為場景中所有物體添加渲染代碼,操作比較繁瑣。

使用基於屏幕後處理的全局霧效,不需要為場景中所有的物體添加渲染代碼,僅通過屏幕後處理就可以實現,而且可以模擬均勻霧效、基於距離的線性/指數霧效,基於高度的霧效。

基於屏幕後處理的全局霧效關鍵在於通過深度紋理重建每個像素在世界空間的位置 ,在上一節中,通過深度紋理的採樣,反映射得到NDC坐標,再通過視角x投影坐標的逆矩陣運算得到世界空間下的位置,這樣的實現需要在片元著色器中進行矩陣運算。

有一種快速從深度紋理中重建世界坐標的方法。該方法首先對圖像空間下的視椎體射線(從攝像機出發,指向圖像上的某點)進行插值,這條射線記錄了該像素點在世界空間下到攝像機的方向信息。將該射線和線性化後的視角空間下的深度值相乘,加上攝像機的世界位置,得到世界空間下的像素點的位置。得到該位置後,可以使用公式來模擬全局霧效。

從深度紋理中計算世界坐標

像素的世界坐標是通過在世界空間下像素相對於攝像機的偏移量+世界空間下攝像機的位置得到,用代碼表示:

float4 worldPos=_WorldSpaceCameraPos+linearDepth*interpolatedRay;//_WorldSpaceCameraPos:攝像機世界空間位置坐標,由Unity內置變數即可得到//linearDepth:由深度紋理得到的線性深度值//interpolatedRay:由定點著色器輸出插值得到的射線,包含了像素到攝像機的方向和距離信息

interpolatedRay計算過程:

interpolatedRay是對攝像機的近裁剪平面的四個頂點的特定向量的插值。先來計算這四個頂點的特定向量,包含了頂點到攝像機的距離和方向信息。計算過程中用到的圖:

先計算toTop和toRight向量:

halfHeight=Near*tan(FOV/2) //Near:近裁剪平面距離 FOV:視椎體豎直方向的張角 toTop=camera.up x halfHeighttoRight=camera.right x halfHeight*aspect //aspect:橫縱比

再得到TL、TR、BL、BR四個向量的值:

TL=camera.forward*Near+toTop-toRight;TR=camera.forward*Near+toTop+toRight;BL=camera.forward*Near-toTop-toRight;BR=camera.forward*Near-toTop+toRight;

以上四個向量得到了關於近裁剪平面四個頂點到攝像機的方向和距離信息,由於採樣得到的深度值z是相對與攝像機的垂直距離,因此還不能直接使用上述四個向量的單位向量與深度值相乘來得到該方向上具有深度值的距離,因此需要計算出具有深度值的某點到攝像機的直線距離,以TL點為例,由相似原理:

depth/dist=Near/|TL| dist=depth*(|TL|/Near)

由於四點對稱,其他三個點都可以使用同一個因子與該方向的單位向量相乘得到能與對應深度值直接相乘的向量

scale=|TL|/Near Ray_TL=TL/|TL|*scaleRay_TR=TR/|TR|*scaleRay_BL=BL/|BL|*scaleRay_BR=BR/|BR|*scale

這樣得到可以直接與深度值相乘前的特定向量。屏幕後處理使用特定材質渲染一個剛好填充整個屏幕的四邊形面片。面片的4個頂點對應近裁剪平面的4個角。將上面的計算結果傳遞給頂點著色器,再由頂點著色器選擇對應的向量,輸出傳遞給片元著色器就得到了經過插值的interpolatedRay,然後計算像素點在世界空間的位置。

霧的計算

簡單霧效的計算通過混合因子將霧的顏色與原始顏色混合。

float3 afterFog=f*fogColor+(1-f)*originalColor

混合因子f有線性、指數和指數平方,給定距離為z:

//線性 f=(Dmax-|z|)/(Dmax-Dmin) //Dmax與Dmin為受影響的最大和最小距離 //指數 f=e^-(D*|z|) //D為控制霧濃度參數//指數平方 f=e^-(D*|z|)^2 //D為控制霧效濃度參數

可以採用線性霧效計算方式,計算基於高度的霧效:

f=(H_end-y)/(H_end-H_start) //H_end和H_start為高度起始位置

實例代碼:

public class Chapter13_FogWithDepthTexture :PostEffectsBase{public Shader fogWithDepthShader;private Material fogMaterial;public Material material{ get { fogMaterial = CheckShaderAndCreateMaterial(fogWithDepthShader, fogMaterial); return fogMaterial; }}private Camera myCamera;public Camera camera{ get { if (myCamera == null) myCamera = GetComponent<Camera>(); return myCamera; }}private Transform myTransform;public Transform cameraTransform{ get { if (myTransform == null) myTransform = camera.transform; return myTransform; }}//定義模擬霧效的參數[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;void OnEnable(){ camera.depthTextureMode |=DepthTextureMode.Depth;}void OnRenderImage(RenderTexture src,RenderTexture dest){ if (material != null) { Matrix4x4 frustumCorners = Matrix4x4.identity; float fov = camera.fieldOfView; float near = camera.nearClipPlane; float far = camera.farClipPlane; float aspect = camera.aspect; float halfHeight = near*Mathf.Tan(fov*0.5f*Mathf.Deg2Rad); Vector3 toTop = cameraTransform.up*halfHeight; Vector3 toRight = cameraTransform.right*halfHeight*aspect; Vector3 topLeft = cameraTransform.forward*near + toTop - toRight; float scale = topLeft.magnitude/near; topLeft.Normalize(); topLeft *= scale; Vector3 topRight = cameraTransform.forward*near + toTop + toRight; topRight.Normalize(); topRight *= scale; Vector3 bottomLeft = cameraTransform.forward*near - toTop - toRight; bottomLeft.Normalize(); bottomLeft *= scale; Vector3 bottomRight = cameraTransform.forward*near - toTop + toRight; bottomRight.Normalize(); bottomRight *= scale; frustumCorners.SetRow(0, bottomLeft); frustumCorners.SetRow(1, bottomRight); frustumCorners.SetRow(2, topRight); frustumCorners.SetRow(3, topLeft); material.SetMatrix("_FrustumCornerRay", 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); }}}

Shader代碼:

Shader "Custom/Chapter13_FogWithDepthTexture" {Properties{ _MainTex("MainTex",2D)="white"{} _FogDensity("Fog Density",Float)=1.0 _FogColor("FogColor",Color)=(1,1,1,1) _FogStart("Fog Start",Float)=0.0 _FogEnd("FogEnd",Float)=2.0}SubShader{ CGINCLUDE #include "UnityCG.cginc" sampler2D _MainTex; half4 _MainTex_TexelSize; sampler2D _CameraDepthTexture; half _FogDensity; fixed4 _FogColor; float _FogStart; float _FogEnd; float4x4 _FrustumCornerRay; 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=UnityObjectToClipPos(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; } 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=_FrustumCornerRay[index]; return o; } 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; } ENDCG Pass{ ZTest Always ZWrite Off Cull Off CGPROGRAM #pragma vertex vert #pragma fragment frag ENDCG }}FallBack Off}

實例效果:

參數設置:

實例效果中可以看到,在沒有遊戲對象的區域是不會產生霧效的,這是因為計算的基礎是深度紋理,如果場景中為空,那麼不會產生深度值,所以不會有霧效效果。

關於最後的效果這裡其實還有一點值得討論,從截圖中可以看到,距離攝像機較遠的物體在同一高度下相較於離攝像機較近的物體實際上霧效效果會更淺,再來看代碼中,這裡的 worldPos 的計算:

worldPos=_WorldSpaceCameraPos+linearDepth*i.interpolatedRay.xyz;

是將屏幕圖像的像素點分成四個部分,將該像素點的線性深度值與對應區域的頂點的世界坐標相乘,因此同一高度下,深度值更大的像素點那麼計算得到的worldPos.y的值會更大,顏色混合因子的結果也會更傾向於屏幕圖像的原來顏色,因此霧效效果也就更加淺。

利用深度紋理實現邊緣檢測

前面的章節中通過邊緣檢測運算元(Sobel運算元)實現了對屏幕圖像進行邊緣檢測,進行描邊效果。但是當產生的屏幕圖像中的物體具有色彩豐富的紋理和陰影時,這種基於顏色的描邊效果會使紋理和陰影也出現描邊效果,比如

這樣物體的紋理和陰影也會被描上黑邊,給人一種很髒的感覺。由於深度紋理僅僅保存了當前渲染物體的模型信息,如果在深度紋理上進行描邊效果則會更加可靠和乾淨。

之前使用的是Sobel運算元進行邊緣檢測,下面要實現的效果採用Roberts運算元,該運算元的卷積核(關於圖像處理中的卷積計算,可以參考這篇博客(cnblogs.com/freeblues/p)):

該運算元計算對角的差值,然後再將結果相乘來作為判斷是否存在邊界的判定依據。實現過程中也採用該判斷方式。

代碼實例:

public class Chapter13_EdgeDetectNormalsAndDepth : PostEffectsBase{public Shader edgeDetectShader;private Material edgeDetectMaterial;public Material material{ get { edgeDetectMaterial = CheckShaderAndCreateMaterial(edgeDetectShader, edgeDetectMaterial); return edgeDetectMaterial; }}[Range(0.0f, 1.0f)]public float edgeOnly = 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;}//默認情況下,OnRenderImage()會在所有的不透明和透明Pass執行完成後調用,以便對所有物體都產生影響//當希望在不透明物體的Pass完成後立即調用,不對透明物體產生影響,可以添加[ImageEffectOpaque]特性實現[ImageEffectOpaque]void OnRenderImage(RenderTexture src,RenderTexture dest){ if (material != null) { material.SetFloat("_EdgeOnly",edgeOnly); 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); }}}

Shader代碼:

Shader "Custom/Chapter13_EdgeDetectWithNormalsAndDepth" {Properties{ _MainTex("MainTex",2D)="white"{} _EdgeOnly("EdgeOnly",Float)=1.0 _EdgeColor("EdgeColor",Color)=(0,0,0,1) _BackgroundColor("BackgroundColor",Color)=(1,1,1,1) _SampleDistance("SampleDistance",Float)=1.0 _Sensitivity("Sensitivity",Vector)=(1,1,1,1)}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=UnityObjectToClipPos(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; } //檢測給定的採樣點之間是否存在一條分界線 half CheckSame(half4 center,half4 sample){ half2 centerNormal=center.xy; float centerDepth=DecodeFloatRG(center.zw); half2 sampleNormal=sample.xy; float sampleDepth=DecodeFloatRG(sample.zw); //檢測兩者法線值的差異,如果兩者法線值足夠接近,那麼說明不存在分界線 //法線值並沒有進行解碼,因為只需要知道兩者的差異,不需要準確的解碼值 half2 diffNormal=abs(centerNormal-sampleNormal)*_Sensitivity.x; int isSameNormal=(diffNormal.x+diffNormal.y)<0.1; //檢測兩者深度值值的差異,如果兩者深度值足夠接近,那麼說明不存在分界線 float diffDepth=abs(centerDepth-sampleDepth)*_Sensitivity.y; int isSameDepth=diffDepth<0.1*centerDepth; //只有兩者的法線和深度值差異均在閾值範圍內,才可以看做是不存在分界線 return isSameNormal*isSameDepth?1.0:0.0; } fixed4 fragRobertsCrossDepthAndNormal(v2f i):SV_Target{ //根據Roberts運算元對深度法線圖對應像素的周圍像素採樣 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); //通過計算得到edge,設置非邊界像素的顏色為原色還是背景色的著色方案 fixed4 withEdgeColor=lerp(_EdgeColor,tex2D(_MainTex,i.uv[0]),edge); fixed4 withBackgroundColor=lerp(_EdgeColor,_BackgroundColor,edge); //使用_EdgeOnly控制非邊界像素的顏色混合結果 return lerp(withBackgroundColor,withEdgeColor,_EdgeOnly); } ENDCG Pass{ ZTest Always ZWrite Off Cull Off CGPROGRAM #pragma vertex vert #pragma fragment fragRobertsCrossDepthAndNormal ENDCG }}FallBack Off}

原圖效果:

顏色邊緣檢測效果:

深度法線紋理邊緣檢測效果:

可以看到,利用深度法線紋理的描邊效果要比之前的乾淨許多,只有物體之間存在明顯邊界的地方才會被描邊,不會收到物體自身紋理和陰影的影響,深度法線紋理邊緣檢測效果的第二張圖為非邊緣部分著色為背景色(白色)的效果,這樣就完全變成線條速寫的風格了。

值得討論的地方,回看Shader代碼,為什麼在做判定的時候,需要同時對深度和法線值的插值都做判斷?原因在於同一個物體出現拐角的地方,兩側的法線差值會很大,而他們的深度值可能不會相差很大,而兩個平行的平面之間,各自平面上的像素點法線的差值可能會很小,而深度值的差異可能會很大,所以只有法線和深度值的差異都很小時,才能認為他們在同一個面上,不存在邊界。同時檢測法線和深度值的差異不會發生漏掉邊界的情況。


相關參考

《UnityShader入門精要》 馮樂樂

相關學習鏈接

關於ZTest和ZWrite :

cnblogs.com/ljx12138/p/

Unity著色器訓練營(1):

入門篇 http://forum.china.unity3d.com/thread-27522-1-1.html

小小的頂點變換能實現大大的效果

(出處: Unity官方中文論壇)

分享Shader實現思路和源代碼的專欄:

zhuanlan.zhihu.com/myas

zhuanlan.zhihu.com/Meow

推薦閱讀:

unity中動態批處理限制的頂點數是根據什麼來計算的?
如何進行Unity3D與Android消息傳遞?
Unity3d 拖拽賦值組件與通過Find賦值組件的優點與缺點?
unity如何寫出前後端通用的代碼?
學習 Unity3D 開發,有哪些資源(論壇或網站)?

TAG:Unity游戏引擎 | shader |