Unity Shader-Ambient Occlusion環境光遮蔽(AO貼圖,GPU AO貼圖烘焙,SSAO,HBAO)

前言

十一放假很開心,正好趕上觀望了了許久的《尼爾·機械紀元》打折啦。窩在家裏搞了三天三夜,終於E結局通關啦!!!真的好久沒玩過這麼好玩的遊戲了,於是乎我的廢話應該會多不少,畢竟,寫blog的另一個目的就是記錄玩過的好玩的遊戲,2333。

最開始聽說這個遊戲的時候,只是被2B小姐姐的人設吸引了,畢竟小姐姐還是很漂亮的。而且遊戲類型也是我喜歡的類型,看起來打擊感還不錯,加上最近打《地鐵》和《消失的光芒》玩出3D眩暈症了,就差嗑兩片暈車藥再打了,正好搞一個動作型的遊戲換換口味。

玩起來我才發現遊戲的音樂太好聽啦,最近簡直每天在循環停不下來,場景也是體積光,SSAO之類的高級效果大量使用。

但是遊戲通關之後,我才發現遊戲的故事本身就很讓人深思,加上豐富的支線劇情。我不敢說玩的遊戲很多,但是的確實本人最近玩過的畫面,音樂,戰鬥,劇情都很給力的一款遊戲了。

好在三週目打完之後,加上讀檔幾次,終於打出了E結局,還算比較完美。然而遊戲竟然有26個結局,等到重溫時爭取都打出來。

唉,一不小心就忍不住想貼貼貼,畢竟遊戲太好玩了,但是到此爲止了。我不想劇透,下面纔是本文的正題。

簡介

《尼爾·機械紀元》中有一個關卡--複製之街。剛一進這個場景,我不由得發出一聲驚歎,“我靠,這場景貼圖是不是丟了?”

不過這個場景風格也是蠻不錯的,同時也讓我想起了一個略微進階一些的圖形技術,環境光遮蔽(AO)。整個場景看起來沒有表示顏色的albedo,但是場景的陰影效果和AO效果還是存在的,這讓場景的層次細節在即使沒有顏色的情況下也可以展現出來,形成了一種特殊的風格。

環境光遮蔽對效果的提升有多重要,看一下頑皮狗在《Uncharted 2: HDR Lighting》的一個對比圖,可以看到,左側的車底遮擋了大部分光線,形成了陰影看起來很自然,而右側的車感覺就像飄在上面一樣,看起來比較假:

環境光遮蔽(Ambient Occlusion),最經常聽到的應該是它的縮寫AO。既然名字本身就帶Ambient,說明其本身是對於環境光強度的一種控制,所以有必要來先了解一下環境光的計算。

光照是可以線性疊加的,一般來說最終的光照結果 = 直接光照 +  間接光照。我們計算物體的直接光照效果時,可以直接通過BRDF計算,而環境光屬於間接光照,要想計算真正的環境光,需要在該點法線方向所對應的半球積分計算,在離線渲染的情況下也只能通過蒙特卡洛積分等方式近似計算,對於光線追蹤的方式渲染的情況,自然可以得到比較好的效果,但是即使現在的RTX似乎也不能真正地實時跑光線追蹤,所以在實時渲染領域,環境光一般使用的就是環境貼圖(SkyBox,Reflection Probe),球諧光照(Spherical Harmonic Lighting),光照貼圖(Light Map,需要用離線烘焙),甚至直接加一個固定的環境光值(簡單粗暴,比如Unity中的UNITY_LIGHTMODEL_AMBIENT宏)。普通光源的遮擋效果也就是陰影,我們可以通過Shadow Map,模板陰影等來實現,但是對於環境光的遮擋效果,半球上的光線自然沒有方法用普通的Shadow Map方式來計算了。所以研究怎樣遮擋環境光的強度的就叫環境光遮蔽。

環境光遮蔽主要用來控制物體和物體相交,夾角,褶皺等位置遮擋漫反射光線的效果,簡單來說就是某一點對於環境的暴露比例,如果是平面,那麼沒有遮蔽;如果是夾角,褶皺等那麼周圍的面就會遮蔽一部分環境光,就導致該點的環境光相對較弱。如果環境光沒有遮蔽效果,那麼不管褶皺還是平面,環境光照結果是一致的。而環境光遮蔽可以使褶皺,夾角等位置的光照效果變弱(比如一根管子,在管口的位置應該比較亮,而越向內,應該越暗),提高暗部陰影效果達到一種近似自陰影的效果,提升畫面的層次感,增加細節。

在瞭解了環境光遮蔽的基本概念之後,本文主要實現幾種主流的環境光遮蔽效果,AO貼圖(使用預烘焙的貼圖,實現離線的基於GPU的烘焙AO貼圖的工具),SSAO(屏幕空間環境光遮蔽),HBAO(水平基準環境光遮蔽)。

AO Map-環境光遮蔽貼圖

首先看一下最簡單的AO貼圖的使用,這也是性能最好的方式,但是這並不代表這種方法整體性能好,只是在運行時使用了預計算的結果。而AO貼圖的生成是使用光線追蹤的方式,反而是這幾種AO方式種耗時最長但是效果相對更好的一種,畢竟只要一離線,時間什麼的都是次要的。

使用美術工具烘焙AO貼圖

AO貼圖技術已經比較古老了,現有的各種3D軟件基本都支持AO的烘焙,如3dsMax,Maya等。我今天使用的是Substance Painter,這個功能很強大的軟件,而且相比於前兩者,烘焙比較方便,但是據說效果沒有前兩者好。不過這些都不重要,畢竟怎麼烘焙,那是美術同學的事情。

使用Substance Painter的烘焙選項,支持直接烘焙Mesh,烘焙面板如下:

我們用一個小獅子的模型導入Substance Painter中,然後使用低模烘焙一發AO貼圖,同時工具也支持帶有法線貼圖的低模烘焙AO貼圖,可以把法線細節的AO效果也烘焙出來。烘焙的貼圖如下,左側爲直接烘焙,右側爲帶有法線貼圖之後烘焙的AO效果:

如果抓幀哪個遊戲看到某個類似的通道,在褶皺處偏黑的,可能就是AO貼圖啦。得到AO貼圖之後,下面就需要看一下AO貼圖的使用了。

AO貼圖的使用

上面說過,光照是可以線性疊加的,全局光照 = 直接光照 + 間接光照。Unity也不例外,下面是Unity官方Shader的光照疊加部分:

  1. half3 color = diffColor * (gi.diffuse + light.color * diffuseTerm)
  2. + specularTerm * light.color * FresnelTerm (specColor, lh)
  3. + surfaceReduction * gi.specular * FresnelLerp (specColor, grazingTerm, nv);

不考慮菲尼爾項的話,就是direct diffuse + direct specular + gi diffuse + gi specular,前兩者通過BRDF計算,而後兩者就是所謂的環境光,我們看一下Unity官方的GI Shader源代碼:

  1. inline UnityGI UnityGI_Base(UnityGIInput data, half occlusion, half3 normalWorld)
  2. {
  3. UnityGI o_gi;
  4. ResetUnityGI(o_gi);
  5. // Base pass with Lightmap support is responsible for handling ShadowMask / blending here for performance reason
  6. #if defined(HANDLE_SHADOWS_BLENDING_IN_GI)
  7. half bakedAtten = UnitySampleBakedOcclusion(data.lightmapUV.xy, data.worldPos);
  8. float zDist = dot(_WorldSpaceCameraPos - data.worldPos, UNITY_MATRIX_V[2].xyz);
  9. float fadeDist = UnityComputeShadowFadeDistance(data.worldPos, zDist);
  10. data.atten = UnityMixRealtimeAndBakedShadows(data.atten, bakedAtten, UnityComputeShadowFade(fadeDist));
  11. #endif
  12. o_gi.light = data.light;
  13. o_gi.light.color *= data.atten;
  14. #if UNITY_SHOULD_SAMPLE_SH
  15. o_gi.indirect.diffuse = ShadeSHPerPixel(normalWorld, data.ambient, data.worldPos);
  16. #endif
  17. #if defined(LIGHTMAP_ON)
  18. // Baked lightmaps
  19. half4 bakedColorTex = UNITY_SAMPLE_TEX2D(unity_Lightmap, data.lightmapUV.xy);
  20. half3 bakedColor = DecodeLightmap(bakedColorTex);
  21. #ifdef DIRLIGHTMAP_COMBINED
  22. fixed4 bakedDirTex = UNITY_SAMPLE_TEX2D_SAMPLER (unity_LightmapInd, unity_Lightmap, data.lightmapUV.xy);
  23. o_gi.indirect.diffuse += DecodeDirectionalLightmap (bakedColor, bakedDirTex, normalWorld);
  24. #if defined(LIGHTMAP_SHADOW_MIXING) && !defined(SHADOWS_SHADOWMASK) && defined(SHADOWS_SCREEN)
  25. ResetUnityLight(o_gi.light);
  26. o_gi.indirect.diffuse = SubtractMainLightWithRealtimeAttenuationFromLightmap (o_gi.indirect.diffuse, data.atten, bakedColorTex, normalWorld);
  27. #endif
  28. #else // not directional lightmap
  29. o_gi.indirect.diffuse += bakedColor;
  30. #if defined(LIGHTMAP_SHADOW_MIXING) && !defined(SHADOWS_SHADOWMASK) && defined(SHADOWS_SCREEN)
  31. ResetUnityLight(o_gi.light);
  32. o_gi.indirect.diffuse = SubtractMainLightWithRealtimeAttenuationFromLightmap(o_gi.indirect.diffuse, data.atten, bakedColorTex, normalWorld);
  33. #endif
  34. #endif
  35. #endif
  36. #ifdef DYNAMICLIGHTMAP_ON
  37. // Dynamic lightmaps
  38. fixed4 realtimeColorTex = UNITY_SAMPLE_TEX2D(unity_DynamicLightmap, data.lightmapUV.zw);
  39. half3 realtimeColor = DecodeRealtimeLightmap (realtimeColorTex);
  40. #ifdef DIRLIGHTMAP_COMBINED
  41. half4 realtimeDirTex = UNITY_SAMPLE_TEX2D_SAMPLER(unity_DynamicDirectionality, unity_DynamicLightmap, data.lightmapUV.zw);
  42. o_gi.indirect.diffuse += DecodeDirectionalLightmap (realtimeColor, realtimeDirTex, normalWorld);
  43. #else
  44. o_gi.indirect.diffuse += realtimeColor;
  45. #endif
  46. #endif
  47. o_gi.indirect.diffuse *= occlusion;
  48. return o_gi;
  49. }

雖然代碼看起來比較長,不過絕大部分都不是我們關心的重點,我們重點在於UnityGI_Base和UnityGI_IndirectSpecular兩個函數的倒數第二行代碼,通過一個occlusion值調製了一下最終gi diffuse和gi specular的結果,而這個occlusion就是我們從AO貼圖中採樣得到的結果。實際上AO貼圖的原理就是簡單粗暴地將AO貼圖採樣出來的值乘以到GI的輸出上,這樣AO貼圖中黑色的部分就會抑制環境光的強度達到環境光遮蔽的效果。

最後,我們對比一下使用AO貼圖後的效果。使用上面兩張烘焙貼圖左的貼圖,不帶法線貼圖烘焙後的結果。左側爲僅顯示環境光效果,中間爲帶有AO貼圖的效果,右側爲無AO貼圖的效果:

使用的右側的AO貼圖,烘焙時使用了法線貼圖。左側爲僅顯示環境光效果,中間爲帶有AO貼圖的效果,右側爲無AO貼圖的效果:

可見,使用了AO貼圖後,在小獅子的眼窩,嘴巴,爪子下,接縫,球底等部分環境光都降低了,使獅子的細節表現更加豐富,而最右側的無AO貼圖的效果則整體光照效果偏平,沒有過渡。

Bake AO-GPU AO貼圖烘焙

上面我們看到了AO貼圖的使用,也使用了美術工具烘焙出了AO貼圖,下面,爲了更進一步理解AO貼圖,我們來研究一下離線的AO貼圖是怎樣生成的,並且使用Shader實現一個非常簡易的AO烘焙工具(娛樂而已,並不實用)。

AO烘焙的原理

離線烘焙,我們無需考慮過多近似的方法苟,直接使用光線追蹤即可。關於光線追蹤,之前看到好多知乎的大佬們都在玩,其實我之前也小玩了一下,實現過一版簡易的方法。實際上主要就是參考了《RayTracing In one Weekend》這篇文章。不過,那時使用的是C++,純單核CPU實現,速度慢到令人髮指。所以,這次我決定改變一下思路,實現一個基於GPU的烘焙。

上面說過,AO所描述的就是一點對於周圍環境暴露的比例。那麼很簡單,我們對於模型上的每一個點(對應展開uv到貼圖上的每個像素點),向其法線所在的半球空間發射無數的光線,如果碰撞到了其他的三角形,就認爲被遮擋了,當然,還需要判斷是否超出了遮擋的範圍,也就是我們控制AO半球的直徑,爲了效果更好,我們也可以再乘以一個距離的權重。

理想很美好,但是現實很殘酷,我們沒有辦法發射無數的光線,所以我們只能用近似的方法模擬,也就是所謂的蒙特卡洛方法,當樣本數達到一定量級的時候,概率也可以作爲結果。

AO烘焙的實現

下面看一下實現,首先,我們要烘焙一張貼圖,那麼最重要的就是怎樣把貼圖直接展開到uv上並且顯示出來,其實也比較簡單,我們可以在vertex shader中得到uv,但是我們不進行正常的mvp變換,而是直接把uv座標的位置作爲輸出的位置,就可以把模型展開的uv再渲染到RT上,vertex關鍵代碼:

  1. v2f vert (appdata v)
  2. {
  3. v2f o;
  4. float2 uv = v.uv;
  5. uv.y = 1 - v.uv.y;
  6. o.vertex = float4(uv * 2 - 1, 0, 1);
  7. return o;
  8. }

還是上面的小獅子模型,這下被拍扁到屏幕上了,好慘:

不過接下來,我們就可以比較容易地實現AO的烘焙了。首先,我們圍繞半球空間構建一系列的隨機採樣點,然後將這些點通過模型的tbn基座標轉化到模型空間,然後對於每個採樣點的方向,計算該方向與其他所有三角形是否相交且小於遮擋半徑。最終平均多個採樣點的結果,得到最終的AO貼圖。

關鍵部分代碼如下,此處將三角形的頂點信息直接傳遞到了uniform中,但是目前有頂點數限制,1300頂點以上烘焙顯卡就會崩潰,不過實驗的話,已經足夠啦。

  1. float aovalue = 0;
  2. for (int s = 0; s < (int)_SampleDirCount; s++)
  3. {
  4. float3 sampleDir = _SampleDir[s];
  5. sampleDir = normalize(sampleDir);
  6. float3 objDir = i.objTangent * sampleDir.x + i.objBiNormal * sampleDir.y + i.objNormal * sampleDir.z;
  7. float currentLength = _AOTracingRadius;
  8. for (int j = 0; j < (int)_TriangleCount; j++)
  9. {
  10. float3 p0 = _TriangleX[j].xyz;
  11. float3 p1 = _TriangleY[j].xyz;
  12. float3 p2 = _TriangleZ[j].xyz;
  13. float raylength;
  14. bool result = RayTriangleTest(objDir, i.objPos, p0, p1, p2, raylength);
  15. if (result && raylength < currentLength)
  16. {
  17. currentLength = raylength;
  18. }
  19. }
  20. float ao = clamp(currentLength, 0, _AOTracingRadius) / _AOTracingRadius;
  21. aovalue += ao;
  22. }
  23. aovalue /= _SampleDirCount;
  24. aovalue = pow(aovalue, _AOStrength);

烘焙後的效果,左側爲帶有AO貼圖效果,右側爲無AO的效果:

關於三角形與射線相交的代碼,可以參考《射線和三角形的相交檢測(ray triangle intersection test) 》這篇blog,這位大佬寫得非常清楚啦,膜拜一波。

關於AO烘焙,不想花太多的時間去做優化了,畢竟目前美術工具烘焙AO已經很成熟了,下面纔是本文的重點,屏幕空間的AO算法,SSAO和HBAO。

SSAO-屏幕空間環境光遮蔽

屏幕空間環境光遮蔽(Screen Space Ambient Occlusion),簡稱SSAO。最早是07年CryTek開始在《孤島危機2》中提出的一項技術。由於環境光遮蔽的計算比較複雜,即使是使用蒙特卡洛積分的方式,僅僅是進行隨機採樣進行計算,如果逐物體計算也是不太可能的,而屏幕空間計算可以保證計算複雜度與場景複雜度解耦,只計算屏幕對應像素的環境光遮蔽,再配合jitter以及降低分辨率等計算,使實時近似的環境遮蔽成爲可能,並且真正在遊戲中運用。從此這項技術便一發不可收拾,成爲了各大遊戲必備的選項,並且後續衍生出了各種進化版本如HBAO,SSDO,TSSAO等基於屏幕空間計算的環境光遮蔽效果。(當年的CryEngine真的是引領了一大波渲染技術的熱潮啊)。

SSAO的原理

關於SSAO相關的一些原理,可以參考《Comparative Study of SSAO Methods》這篇論文,文中比對了各種SSAO的計算。下文中幾張SSAO的原理圖引用自該論文。

前面說過,環境光本身是基於當前點法線半球上的積分計算,想真正求積分是不可能滴。近似求積分的話,最容易的就是蒙特卡洛積分,說的通俗易懂一點的話,就是概率,當樣本數達到一定程度之後,我們就不求計算精確的值,而是直接使用一些樣本進行採樣。那麼,我們在法線的半球上計算環境光遮蔽的因子時,我們就可以採用概率的方式。在法線的半球上設置一系列隨機的採樣點,然後遍歷每一個採樣點,判斷採樣點的深度值是否小於該點對應的深度,如果小於說明這個採樣點沒有被平面擋住,遍歷完成後除以總採樣點數,就可以得到當前半球上環境光顯示的百分比,1-環境光百分比得到最終的環境光遮蔽值。

不過SSAO技術在最早在CryTek使用的時候並非是在半球上進行的計算,而是在一個球形內進行的概率計算,如下圖,P爲當前像素點,在該點一定範圍的球形分佈着隨機採樣點,綠色爲未被遮蔽的,紅色爲被遮蔽的:

CryTek使用球形進行計算的話有一個好處就是無需屏幕空間法線,對於前向渲染的話僅有深度就可以實現SSAO的計算,無需額外考慮全屏Normal,但是也有一個不好的地方,在於整個球進行概率計算的話,不管怎麼樣,都會有50%的點被遮蔽。這也是導致CryTek最早版本的SSAO效果很奇怪的原因,對於褶皺處AO效果很明顯,但是平面上也會被計算出遮蔽值(圖片來自《Finding Next Gen – CryEngine 2》):

所以後續的版本就採用了更加精確的方式,即只在法線對應方向的半球上進行遮蔽概率計算,使環境光遮蔽效果大大提升。如下圖所示,P點爲當前像素點,n爲對應法線方向,所在半球上分佈着隨機採樣點,其中綠色的採樣點爲未被遮蔽的,紅色的爲爲被遮蔽的:

SSAO的優缺點

SSAO還是有很多優點的:

1.速度較快,相對於離線的AO烘焙,至少使實時計算AO成爲了可能。

2.無需預處理,不需要預先烘焙AO貼圖,降低美術工作成本,降低貼圖數量(少一個通道也是省啊,乾點啥不好)。

3.支持動態AO遮擋,動態物體沒有辦法烘焙AO,只能使用SSAO。

4.SS系列的共性優點,與場景複雜度無關,僅與屏幕分辨率有關。

5.無CPU消耗,純GPU邏輯,易集成,有Depth Normal即可,與當今的延遲渲染非常契合。甚至NVIDIA顯卡自身都可以開。

SSAO的缺點:

1.GPU瓶頸,雖然不耗CPU,但是這個真的是超級費GPU,手機上本人曾經測試小米8開啓後幀率直接下降15幀左右!

2.SS系列的共性缺點,屏幕外面的東西如果遮擋了,是沒有效果的。比如車底,本身不在DepthBuffer中,取不到信息,自然也就算不出遮擋以及AO了。不過好在這個問題不像SSR那樣明顯。

3.前向渲染不划算,和SSR一樣,這個SSAO需要全屏幕的Depth以及Normal,全場景先來一遍你懂得。

SSAO的實現

要使用SSAO,需要有全屏幕的深度來反算視空間位置;而爲了保證採樣點集中在法線所在半球,需要有全屏幕的法線圖。如果是延遲渲染,那麼這兩個都可以免費得到,通過DepthTexture和GBuffer得到。但是前向渲染下我們就只能通過CameraDepthNormalTexture來得到深度+法線紋理。

下面看一下SSAO的實現,首先,我們生成一系列的隨機採樣點,爲了讓遮蔽效果更好,我們儘量保證在靠近採樣點的位置分佈更多的隨機點:

  1. private void GenerateAOSampleKernel()
  2. {
  3. if (SampleKernelCount == sampleKernelList.Count)
  4. return;
  5. sampleKernelList.Clear();
  6. for(int i = 0; i < SampleKernelCount; i++)
  7. {
  8. var vec = new Vector4(Random.Range(-1.0f, 1.0f), Random.Range(-1.0f, 1.0f), Random.Range(0, 1.0f), 1.0f);
  9. vec.Normalize();
  10. var scale = (float)i / SampleKernelCount;
  11. //使分佈符合二次方程的曲線
  12. scale = Mathf.Lerp(0.01f, 1.0f, scale * scale);
  13. vec *= scale;
  14. sampleKernelList.Add(vec);
  15. }
  16. }

關鍵Shader部分代碼如下:

  1. fixed4 frag_ao (v2f i) : SV_Target
  2. {
  3. fixed4 col = tex2D(_MainTex, i.uv);
  4. float linear01Depth;
  5. float3 viewNormal;
  6. float4 cdn = tex2D(_CameraDepthNormalsTexture, i.uv);
  7. DecodeDepthNormal(cdn, linear01Depth, viewNormal);
  8. float3 viewPos = linear01Depth * i.viewRay;
  9. float3 viewDir = normalize(viewPos);
  10. viewNormal = normalize(viewNormal);
  11. int sampleCount = _SampleKernelCount;
  12. float oc = 0.0;
  13. for(int i = 0; i < sampleCount; i++)
  14. {
  15. float3 randomVec = _SampleKernelArray[i].xyz;
  16. //如果隨機點的位置與法線反向,那麼將隨機方向取反,使之保證在法線半球
  17. randomVec = dot(randomVec, viewNormal) < 0 ? -randomVec : randomVec;
  18. float3 randomPos = viewPos + randomVec * _SampleKeneralRadius;
  19. float3 rclipPos = mul((float3x3)unity_CameraProjection, randomPos);
  20. float2 rscreenPos = (rclipPos.xy / rclipPos.z) * 0.5 + 0.5;
  21. float randomDepth;
  22. float3 randomNormal;
  23. float4 rcdn = tex2D(_CameraDepthNormalsTexture, rscreenPos);
  24. DecodeDepthNormal(rcdn, randomDepth, randomNormal);
  25. float range = abs(randomDepth - linear01Depth) * _ProjectionParams.z < _SampleKeneralRadius ? 1.0 : 0.0;
  26. float ao = randomDepth + _DepthBiasValue < linear01Depth ? 1.0 : 0.0;
  27. oc += ao * range;
  28. }
  29. oc /= sampleCount;
  30. oc = max(0.0, 1 - oc * _AOStrength);
  31. col.rgb = oc;
  32. return col;
  33. }

我們沒有使用Tangent進行旋轉來保證採樣點在法線半球,而是直接使用屏幕空間的法線與隨機採樣方向點乘,如果二者方向相反,說明沒在同一半球,再將其取反。在最終計算深度進行比較時,我們增加了一個BiasValue,爲了防止在平面上產生自陰影的問題,同時也需要增加一個深度差的比較,我們需要保證深度差小於採樣半徑,否則可能會出現相距很遠的物體也產生遮蔽的情況,如下圖,左側爲沒有添加距離判斷的情況,人物距離後面的牆面已經很遠了,但是仍然產生了不正常的遮蔽效果:

最後,在計算遮蔽權重時,我們並非直接計算非0即1的遮蔽值,而是乘以了一個距離的權重,使AO有一個更好的漸變效果,AO的效果如下,在臺階的拐角處,夾縫等地方都有比較明顯的環境光遮蔽的效果:

但是仔細觀察上圖,我們會發現AO貼圖中有一些噪點,並不平滑,有些許顆粒感。這和我們之前使用Dither RayMarching的體積光,屏幕空間反射的道理一樣,都是因爲我們引入了隨機噪聲。所以下一步就是需要使用一個濾波的Pass對AO的結果進行去噪。最簡單的方式肯定就是高斯模糊了,但是如果們使用高斯模糊的話,整個圖片就都會被模糊掉了,如下圖:

顯然,這不是我們想要的效果,所以我們需要引入一種能夠保持明顯邊界,而又可以去噪的濾波。也就是所謂的雙邊濾波(Bilateral Filter)。雙邊濾波可以在模糊的同時保持圖像中的邊緣信息。除了考慮正常高斯濾波的空域信息(domain)外,還要考慮另外的一個圖像本身攜帶的值域信息(range)。這個值域信息的選擇並非唯一的,可以是採樣點間像素顏色的差異,可以是採樣點像素對應的法線信息,可以是採樣點像素對應的深度信息。使用雙邊濾波可以實現一些好玩的效果,比如用於美顏的磨皮濾鏡。關於詳細的雙邊濾波,實現,這裏不再贅述,可以參考本人之前的blog-《UnityShader-BilateralFilter(雙邊濾波,磨皮濾鏡)》。還是上面的AO效果,使用雙邊濾波進行去噪後的結果如下:

可見,使用雙邊濾波後的AO效果已經達到了可以接受的程度。最後,我們要做的就是將AO貼圖與原始圖像進行混合,用AO值來調製原始圖像的顏色。如果是延遲渲染,自然我們可以在GBuffer渲染之後進行SSAO計算,然後在最終光照時將SSAO運用到環境光遮蔽上,但是對於前向渲染來說,我們沒有辦法這麼做(當然,如果每個Shader裏面都ComputeScreenPos的話也不是不可以,但是我想基本沒有人想這麼幹吧),直接將SSAO運用到整個圖像上也可以達到很好的效果了。

下面看一下使用AO前後的效果對比,原始的場景如下,僅有一盞主平行光源,開啓ShadowMap的效果,畫面整體偏平,沒有細節過渡:

開啓SSAO之後,在樓梯折角,縫隙,草根,牆角等地方光照強度都降低了,使畫面細節大大增加(AO強度開得大了點,不過,我喜歡^_^):

下面附上SSAO代碼,C#部分代碼如下:

  1. /********************************************************************
  2. FileName: ScreenSpaceAOEffect.cs
  3. Description: SSAO屏幕空間環境光遮蔽效果
  4. history: 6:10:2018 by puppet_master
  5. https://blog.csdn.net/puppet_master
  6. *********************************************************************/
  7. using System.Collections;
  8. using System.Collections.Generic;
  9. using UnityEngine;
  10. [ExecuteInEditMode]
  11. public class ScreenSpaceAOEffect : MonoBehaviour
  12. {
  13. private Material ssaoMaterial = null;
  14. private Camera currentCamera = null;
  15. private List<Vector4> sampleKernelList = new List<Vector4>();
  16. [Range(0, 0.002f)]
  17. public float DepthBiasValue = 0.002f;
  18. [Range(0.010f, 1.0f)]
  19. public float SampleKernelRadius = 1.0f;
  20. [Range(4, 32)]
  21. public int SampleKernelCount = 16;
  22. [Range(0.0f, 5.0f)]
  23. public float AOStrength = 1.0f;
  24. [Range(0, 2)]
  25. public int DownSample = 0;
  26. [Range(1, 4)]
  27. public int BlurRadius = 1;
  28. [Range(0, 0.2f)]
  29. public float BilaterFilterStrength = 0.2f;
  30. public bool OnlyShowAO = false;
  31. public enum SSAOPassName
  32. {
  33. GenerateAO = 0,
  34. BilateralFilter = 1,
  35. Composite = 2,
  36. }
  37. private void Awake()
  38. {
  39. var shader = Shader.Find("AO/ScreenSpaceAOEffect");
  40. ssaoMaterial = new Material(shader);
  41. currentCamera = GetComponent<Camera>();
  42. }
  43. private void OnEnable()
  44. {
  45. currentCamera.depthTextureMode |= DepthTextureMode.DepthNormals;
  46. }
  47. private void OnDisable()
  48. {
  49. currentCamera.depthTextureMode &= ~DepthTextureMode.DepthNormals;
  50. }
  51. private void OnRenderImage(RenderTexture source, RenderTexture destination)
  52. {
  53. GenerateAOSampleKernel();
  54. var aoRT = RenderTexture.GetTemporary(source.width >> DownSample, source.height >> DownSample, 0);
  55. ssaoMaterial.SetMatrix("_InverseProjectionMatrix", currentCamera.projectionMatrix.inverse);
  56. ssaoMaterial.SetFloat("_DepthBiasValue", DepthBiasValue);
  57. ssaoMaterial.SetVectorArray("_SampleKernelArray", sampleKernelList.ToArray());
  58. ssaoMaterial.SetFloat("_SampleKernelCount", sampleKernelList.Count);
  59. ssaoMaterial.SetFloat("_AOStrength", AOStrength);
  60. ssaoMaterial.SetFloat("_SampleKeneralRadius", SampleKernelRadius);
  61. Graphics.Blit(source, aoRT, ssaoMaterial, (int)SSAOPassName.GenerateAO);
  62. var blurRT = RenderTexture.GetTemporary(source.width >> DownSample, source.height >> DownSample, 0);
  63. ssaoMaterial.SetFloat("_BilaterFilterFactor", 1.0f - BilaterFilterStrength);
  64. ssaoMaterial.SetVector("_BlurRadius", new Vector4(BlurRadius, 0, 0, 0));
  65. Graphics.Blit(aoRT, blurRT, ssaoMaterial, (int)SSAOPassName.BilateralFilter);
  66. ssaoMaterial.SetVector("_BlurRadius", new Vector4(0, BlurRadius, 0, 0));
  67. if (OnlyShowAO)
  68. {
  69. Graphics.Blit(blurRT, destination, ssaoMaterial, (int)SSAOPassName.BilateralFilter);
  70. }
  71. else
  72. {
  73. Graphics.Blit(blurRT, aoRT, ssaoMaterial, (int)SSAOPassName.BilateralFilter);
  74. ssaoMaterial.SetTexture("_AOTex", aoRT);
  75. Graphics.Blit(source, destination, ssaoMaterial, (int)SSAOPassName.Composite);
  76. }
  77. RenderTexture.ReleaseTemporary(aoRT);
  78. RenderTexture.ReleaseTemporary(blurRT);
  79. }
  80. private void GenerateAOSampleKernel()
  81. {
  82. if (SampleKernelCount == sampleKernelList.Count)
  83. return;
  84. sampleKernelList.Clear();
  85. for(int i = 0; i < SampleKernelCount; i++)
  86. {
  87. var vec = new Vector4(Random.Range(-1.0f, 1.0f), Random.Range(-1.0f, 1.0f), Random.Range(0, 1.0f), 1.0f);
  88. vec.Normalize();
  89. var scale = (float)i / SampleKernelCount;
  90. //使分佈符合二次方程的曲線
  91. scale = Mathf.Lerp(0.01f, 1.0f, scale * scale);
  92. vec *= scale;
  93. sampleKernelList.Add(vec);
  94. }
  95. }
  96. }

Shader部分代碼如下:

  1. /********************************************************************
  2. FileName: ScreenSpaceAOEffect.cs
  3. Description: SSAO屏幕空間環境光遮蔽效果
  4. history: 6:10:2018 by puppet_master
  5. https://blog.csdn.net/puppet_master
  6. *********************************************************************/
  7. Shader "AO/ScreenSpaceAOEffect"
  8. {
  9. Properties
  10. {
  11. _MainTex ("Texture", 2D) = "black" {}
  12. }
  13. CGINCLUDE
  14. #include "UnityCG.cginc"
  15. struct appdata
  16. {
  17. float4 vertex : POSITION;
  18. float2 uv : TEXCOORD0;
  19. };
  20. struct v2f
  21. {
  22. float2 uv : TEXCOORD0;
  23. float4 vertex : SV_POSITION;
  24. float3 viewRay : TEXCOORD1;
  25. };
  26. #define MAX_SAMPLE_KERNEL_COUNT 32
  27. sampler2D _MainTex;
  28. sampler2D _CameraDepthNormalsTexture;
  29. float4x4 _InverseProjectionMatrix;
  30. float _DepthBiasValue;
  31. float4 _SampleKernelArray[MAX_SAMPLE_KERNEL_COUNT];
  32. float _SampleKernelCount;
  33. float _AOStrength;
  34. float _SampleKeneralRadius;
  35. float4 _MainTex_TexelSize;
  36. float4 _BlurRadius;
  37. float _BilaterFilterFactor;
  38. sampler2D _AOTex;
  39. float3 GetNormal(float2 uv)
  40. {
  41. float4 cdn = tex2D(_CameraDepthNormalsTexture, uv);
  42. return DecodeViewNormalStereo(cdn);
  43. }
  44. half CompareNormal(float3 normal1, float3 normal2)
  45. {
  46. return smoothstep(_BilaterFilterFactor, 1.0, dot(normal1, normal2));
  47. }
  48. v2f vert_ao (appdata v)
  49. {
  50. v2f o;
  51. o.vertex = UnityObjectToClipPos(v.vertex);
  52. o.uv = v.uv;
  53. float4 clipPos = float4(v.uv * 2 - 1.0, 1.0, 1.0);
  54. float4 viewRay = mul(_InverseProjectionMatrix, clipPos);
  55. o.viewRay = viewRay.xyz / viewRay.w;
  56. return o;
  57. }
  58. fixed4 frag_ao (v2f i) : SV_Target
  59. {
  60. fixed4 col = tex2D(_MainTex, i.uv);
  61. float linear01Depth;
  62. float3 viewNormal;
  63. float4 cdn = tex2D(_CameraDepthNormalsTexture, i.uv);
  64. DecodeDepthNormal(cdn, linear01Depth, viewNormal);
  65. float3 viewPos = linear01Depth * i.viewRay;
  66. viewNormal = normalize(viewNormal) * float3(1, 1, -1);
  67. int sampleCount = _SampleKernelCount;
  68. float oc = 0.0;
  69. for(int i = 0; i < sampleCount; i++)
  70. {
  71. float3 randomVec = _SampleKernelArray[i].xyz;
  72. //如果隨機點的位置與法線反向,那麼將隨機方向取反,使之保證在法線半球
  73. randomVec = dot(randomVec, viewNormal) < 0 ? -randomVec : randomVec;
  74. float3 randomPos = viewPos + randomVec * _SampleKeneralRadius;
  75. float3 rclipPos = mul((float3x3)unity_CameraProjection, randomPos);
  76. float2 rscreenPos = (rclipPos.xy / rclipPos.z) * 0.5 + 0.5;
  77. float randomDepth;
  78. float3 randomNormal;
  79. float4 rcdn = tex2D(_CameraDepthNormalsTexture, rscreenPos);
  80. DecodeDepthNormal(rcdn, randomDepth, randomNormal);
  81. float range = abs(randomDepth - linear01Depth) * _ProjectionParams.z < _SampleKeneralRadius ? 1.0 : 0.0;
  82. float ao = randomDepth + _DepthBiasValue < linear01Depth ? 1.0 : 0.0;
  83. oc += ao * range;
  84. }
  85. oc /= sampleCount;
  86. oc = max(0.0, 1 - oc * _AOStrength);
  87. col.rgb = oc;
  88. return col;
  89. }
  90. fixed4 frag_blur (v2f i) : SV_Target
  91. {
  92. float2 delta = _MainTex_TexelSize.xy * _BlurRadius.xy;
  93. float2 uv = i.uv;
  94. float2 uv0a = i.uv - delta;
  95. float2 uv0b = i.uv + delta;
  96. float2 uv1a = i.uv - 2.0 * delta;
  97. float2 uv1b = i.uv + 2.0 * delta;
  98. float2 uv2a = i.uv - 3.0 * delta;
  99. float2 uv2b = i.uv + 3.0 * delta;
  100. float3 normal = GetNormal(uv);
  101. float3 normal0a = GetNormal(uv0a);
  102. float3 normal0b = GetNormal(uv0b);
  103. float3 normal1a = GetNormal(uv1a);
  104. float3 normal1b = GetNormal(uv1b);
  105. float3 normal2a = GetNormal(uv2a);
  106. float3 normal2b = GetNormal(uv2b);
  107. fixed4 col = tex2D(_MainTex, uv);
  108. fixed4 col0a = tex2D(_MainTex, uv0a);
  109. fixed4 col0b = tex2D(_MainTex, uv0b);
  110. fixed4 col1a = tex2D(_MainTex, uv1a);
  111. fixed4 col1b = tex2D(_MainTex, uv1b);
  112. fixed4 col2a = tex2D(_MainTex, uv2a);
  113. fixed4 col2b = tex2D(_MainTex, uv2b);
  114. half w = 0.37004405286;
  115. half w0a = CompareNormal(normal, normal0a) * 0.31718061674;
  116. half w0b = CompareNormal(normal, normal0b) * 0.31718061674;
  117. half w1a = CompareNormal(normal, normal1a) * 0.19823788546;
  118. half w1b = CompareNormal(normal, normal1b) * 0.19823788546;
  119. half w2a = CompareNormal(normal, normal2a) * 0.11453744493;
  120. half w2b = CompareNormal(normal, normal2b) * 0.11453744493;
  121. half3 result;
  122. result = w * col.rgb;
  123. result += w0a * col0a.rgb;
  124. result += w0b * col0b.rgb;
  125. result += w1a * col1a.rgb;
  126. result += w1b * col1b.rgb;
  127. result += w2a * col2a.rgb;
  128. result += w2b * col2b.rgb;
  129. result /= w + w0a + w0b + w1a + w1b + w2a + w2b;
  130. return fixed4(result, 1.0);
  131. }
  132. fixed4 frag_composite(v2f i) : SV_Target
  133. {
  134. fixed4 ori = tex2D(_MainTex, i.uv);
  135. fixed4 ao = tex2D(_AOTex, i.uv);
  136. ori.rgb *= ao.r;
  137. return ori;
  138. }
  139. ENDCG
  140. SubShader
  141. {
  142. Cull Off ZWrite Off ZTest Always
  143. //Pass 0 : Generate AO
  144. Pass
  145. {
  146. CGPROGRAM
  147. #pragma vertex vert_ao
  148. #pragma fragment frag_ao
  149. ENDCG
  150. }
  151. //Pass 1 : Bilateral Filter Blur
  152. Pass
  153. {
  154. CGPROGRAM
  155. #pragma vertex vert_ao
  156. #pragma fragment frag_blur
  157. ENDCG
  158. }
  159. //Pass 2 : Composite AO
  160. Pass
  161. {
  162. CGPROGRAM
  163. #pragma vertex vert_ao
  164. #pragma fragment frag_composite
  165. ENDCG
  166. }
  167. }
  168. }

關於TSSAO其實是SSAO的一種優化,主體的思想是沒有變的,主要是使用了Reverse Reprojection技術加速計算,這個等以後玩Temporal的時候再說啦。下面看一種與SSAO本身實現差異較大的一種AO實現。

HBAO-水平基準環境光遮蔽

HBAO,是NVIDIA提出的另一種實現SSAO的方式,全稱Horizon-Based Ambient Occlusion爲水平基準環境光遮蔽。這個技術最早是在08年時提出的,在CryTek之後。而後在最近幾年吸取了Scalable Ambient Obscurance等方法的優點,在14年左右進化成了HBAO+,一度成爲了當時效果最好的環境光遮蔽。不過當時基於距離場的方法還沒火哈。15年的時候被遊戲評測爆吹了一頓,《SSAO進化之巔峯—水平基準環境光遮蔽HBAO+》,當年我看到這個文章的時候就是,“哇塞,看不懂,收藏,告辭“,其實現在HBAO+我也沒懂,今天要玩的就是最普通的HBAO,甚至是簡化版本的HBAO,但是個人感覺效果已經比32隨機採樣點的SSAO效果要好。

HBAO實現原理

HBAO的實現原理首先可以參考08年Siggraph上NVIDIA分享的PPT《Image Space Horizon-Based Ambient Occlusion》以及《ShaderX7》,書中有一整章講Ambient Occlusion的章節,當然還有SSAO中提到的那篇對比各種SSAO實現方式的論文。注意:本文實現的並非正統HBAO,感興趣的可以去看原論文。我只是玩了個簡化的版本,可能原理上是錯誤的,但是簡單,粗暴,效果差不太遠。(對我來說,苟出一個省一些的效果,要遠比基於“物理”更重要。)

SSAO中判斷是否遮擋是通過深度來判斷的,而HBAO做得更加徹底,直接將屏幕空間的一個方向對應的深度信息作爲高度信息,沿着這個方向進行Ray Marching判斷是否遮擋。關於Ray Marching,之前我們在體積光,屏幕空間反射都使用過Ray Marching,不過都是通過屏幕空間深度反算視空間位置,在視空間進行的Ray Marching,而HBAO的Ray Marching方向略有不同,是直接在屏幕空間進行Ray Marching,如下圖所示(來自上文NVIDIA的PPT):

首先,在屏幕空間任意一點,將其周圍360的角度進行均分,每個方向分別做RayMarching,入圖中左半部分,分爲四個方向進行Ray Marching。而對於每個方向來說,則如右圖所示,沿着Ray Marching的方向爲Image Plane所示的方向,每步進一次,採樣一次深度信息判斷角度:

如圖,P點爲當前像素點,+X方向爲Ray Marching的方向,S0爲第一個採樣點,該點的角度值大於Bias值(預先設定的閾值)即認爲遮擋,而第二個採樣點S1,角度小於PS0,不遮擋不計,而S2的角度大於了S0的角度,計入遮擋,S3同理。這樣的好處在於可以處理類似S1這樣的假遮擋點,使最終的AO結果更加精確。最終每個遮擋點根據距離權重計入,每個方向進行遮擋計算的和,除以方向數,就得到最終的遮擋結果了。

原論文的HBAO,所指的夾角是Z軸(或XY平面)和PS之間的夾角,還需要考慮進來真正的頂點所對應的法線方向以及tangent平面,即最終是AO值 = sin h(Horizon Angle,atan(H.z /  H.xy)) - sin t(Tangent Angle,atan(T.z / T.xy)),爲了簡化,本人直接使用P點對應平面與PS之間的夾角進行計算,再一步轉化就可以用P點對應Normal與PS之間夾角進行計算,測試也可以得到不錯的效果。

HBAO效果實現

HBAO關鍵部分Shader代碼如下:

  1. inline float2 RotateDirections(float2 dir, float2 rot) {
  2. return float2(dir.x * rot.x - dir.y * rot.y,
  3. dir.x * rot.y + dir.y * rot.x);
  4. }
  5. inline float Falloff2(float distance, float radius)
  6. {
  7. float a = distance / radius;
  8. return clamp(1.0 - a * a, 0.0, 1.0);
  9. }
  10. float3 GetViewPos(v2f i, float2 uv)
  11. {
  12. float linear01Depth;
  13. float3 viewNormal;
  14. float4 cdn = tex2D(_CameraDepthNormalsTexture, uv);
  15. DecodeDepthNormal(cdn, linear01Depth, viewNormal);
  16. float3 viewPos = linear01Depth * i.viewRay;
  17. return viewPos;
  18. }
  19. // Reconstruct view-space position from UV and depth.
  20. // p11_22 = (unity_CameraProjection._11, unity_CameraProjection._22)
  21. // p13_31 = (unity_CameraProjection._13, unity_CameraProjection._23)
  22. float3 ReconstructViewPos(float2 uv)
  23. {
  24. float3x3 proj = (float3x3)unity_CameraProjection;
  25. float2 p11_22 = float2(unity_CameraProjection._11, unity_CameraProjection._22);
  26. float2 p13_31 = float2(unity_CameraProjection._13, unity_CameraProjection._23);
  27. float depth;
  28. float3 viewNormal;
  29. float4 cdn = tex2D(_CameraDepthNormalsTexture, uv);
  30. DecodeDepthNormal(cdn, depth, viewNormal);
  31. depth *= _ProjectionParams.z;
  32. return float3((uv * 2.0 - 1.0 - p13_31) / p11_22 * (depth), depth);
  33. }
  34. inline float2 GetRayMarchingDir(float angle)
  35. {
  36. float sinValue, cosValue;
  37. sincos(angle, sinValue, cosValue);
  38. return RotateDirections(float2(cosValue, sinValue), float2(1.0, 0));
  39. }
  40. fixed4 frag_ao (v2f i) : SV_Target
  41. {
  42. float2 InvScreenParams = _ScreenParams.zw - 1.0;
  43. fixed4 col = tex2D(_MainTex, i.uv);
  44. float3 viewPos = ReconstructViewPos(i.uv);
  45. float4 cdn = tex2D(_CameraDepthNormalsTexture, i.uv);
  46. float3 viewNormal = DecodeViewNormalStereo(cdn) * float3(1.0, 1.0, -1.0);
  47. float rayMarchingRadius = min(_SampleRadius / viewPos.z, _MaxPixelRadius);
  48. float rayMarchingStepSize = rayMarchingRadius / _RayMarchingStep;
  49. float rayAngleSize = 2.0 * UNITY_PI / _RayAngleStep;
  50. float oc = 0.0;
  51. for(int j = 0; j < _RayAngleStep; j++)
  52. {
  53. float2 rayMarchingDir = GetRayMarchingDir(j * rayAngleSize);
  54. float oldangle = _AngleBiasValue;
  55. float2 deltauv = round(1 + rayMarchingDir * rayMarchingStepSize) * InvScreenParams;
  56. for(int k = 1; k < _RayMarchingStep; k++)
  57. {
  58. float2 uv = k * deltauv + i.uv;
  59. float3 sviewPos = ReconstructViewPos(uv);
  60. float3 svdir = sviewPos - viewPos;
  61. float l = length(svdir);
  62. float angle = UNITY_PI * 0.5 - acos(dot(viewNormal, normalize(svdir)));
  63. if (angle > oldangle)
  64. {
  65. float value = sin(angle) - sin(oldangle);
  66. float atten = Falloff2(l, _AORadius);
  67. oc += value * atten;
  68. oldangle = angle;
  69. }
  70. }
  71. }
  72. oc *= 1.0 / (_RayAngleStep) * _AOStrength;
  73. oc = 1.0 - oc;
  74. col.rgb = oc;
  75. return col;
  76. }

直接使用HBAO,在步進次數和方向數足夠大(8-16左右)時,個人感覺不適用濾波操作,效果也可以接受:

無AO的效果:

開啓HBAO效果:

僅顯示AO效果:

可以控制_RayMarchingStep和_RayAngleStep兩個值控制步進次數和步進方向分割。另外,既然是RayMarching,我們在體積光和屏幕空間反射的老套路就又可以使用了,通過Dither + 模糊實現Jitter Ray Marching來大大降低光線追蹤的消耗。此處的模糊我們仍然使用雙邊濾波,在去噪的同時保持邊緣。上圖中的步進次數爲8x8,計算相當的費。而如果我們把次數改爲3x3效果就很差了:

已經無法很明確地區分出AO部分,下面我們把採樣方向和每次步進的起始位置加上Dither值後,仍然是3x3的採樣:

效果很奇怪,但是這只是中間結果,下面我們加入雙邊濾波去噪,3x3採樣後的效果:

與上面8x8採樣效果雖然還是差了一些,但是已經不會出現錯誤的情況,但是計算量極大地降低了。

總結

本文主要實現了目前遊戲中主要的幾種環境光遮蔽的方法。基於AO貼圖的遮蔽,通過GPU烘焙AO貼圖,屏幕空間環境光遮蔽SSAO,水平基準環境光遮蔽(HBAO)。幾種技術各有優缺點,技術本身不分好壞,只有適合自己項目的。實際上,這些AO技術通常會同時使用,最常見的就是AO貼圖和各種屏幕空間的AO算法同時使用。《Making it Large, Beautiful, Fast and Consistent: Lessons Learned Developing Just Cause 2》所介紹的,正當防衛這款遊戲中,就包含了三種AO,除上述兩種外,還有一種稱之爲AO Volumes的技術,簡單來說就是爲了彌補Bake AO無法實現動態物體遮擋的問題,使用一個圓柱或者立方體實現一個假的AO遮擋效果,這種技術用於人物腳底,或者車底,可以增加不少細節效果。關於AO,實際上還有很多很多進階的技術,如Bent Normal,AAO,TSSAO,VXAO,UE4的Distance Filed Ambient Occlusion等等,有機會再玩啦。。

這篇blog拖了很久才寫完,主要最近遊戲買的有點多,加上週末要看英雄聯盟的比賽,感覺有點頹廢。不過好在剛好又通關了一個很不錯的遊戲《心靈殺手(Alan Wake)》,下篇blog的開頭又有東西寫啦!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章