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的光照疊加部分:

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

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

inline UnityGI UnityGI_Base(UnityGIInput data, half occlusion, half3 normalWorld)
{
    UnityGI o_gi;
    ResetUnityGI(o_gi);

    // Base pass with Lightmap support is responsible for handling ShadowMask / blending here for performance reason
    #if defined(HANDLE_SHADOWS_BLENDING_IN_GI)
        half bakedAtten = UnitySampleBakedOcclusion(data.lightmapUV.xy, data.worldPos);
        float zDist = dot(_WorldSpaceCameraPos - data.worldPos, UNITY_MATRIX_V[2].xyz);
        float fadeDist = UnityComputeShadowFadeDistance(data.worldPos, zDist);
        data.atten = UnityMixRealtimeAndBakedShadows(data.atten, bakedAtten, UnityComputeShadowFade(fadeDist));
    #endif

    o_gi.light = data.light;
    o_gi.light.color *= data.atten;

    #if UNITY_SHOULD_SAMPLE_SH
        o_gi.indirect.diffuse = ShadeSHPerPixel(normalWorld, data.ambient, data.worldPos);
    #endif

    #if defined(LIGHTMAP_ON)
        // Baked lightmaps
        half4 bakedColorTex = UNITY_SAMPLE_TEX2D(unity_Lightmap, data.lightmapUV.xy);
        half3 bakedColor = DecodeLightmap(bakedColorTex);

        #ifdef DIRLIGHTMAP_COMBINED
            fixed4 bakedDirTex = UNITY_SAMPLE_TEX2D_SAMPLER (unity_LightmapInd, unity_Lightmap, data.lightmapUV.xy);
            o_gi.indirect.diffuse += DecodeDirectionalLightmap (bakedColor, bakedDirTex, normalWorld);

            #if defined(LIGHTMAP_SHADOW_MIXING) && !defined(SHADOWS_SHADOWMASK) && defined(SHADOWS_SCREEN)
                ResetUnityLight(o_gi.light);
                o_gi.indirect.diffuse = SubtractMainLightWithRealtimeAttenuationFromLightmap (o_gi.indirect.diffuse, data.atten, bakedColorTex, normalWorld);
            #endif

        #else // not directional lightmap
            o_gi.indirect.diffuse += bakedColor;

            #if defined(LIGHTMAP_SHADOW_MIXING) && !defined(SHADOWS_SHADOWMASK) && defined(SHADOWS_SCREEN)
                ResetUnityLight(o_gi.light);
                o_gi.indirect.diffuse = SubtractMainLightWithRealtimeAttenuationFromLightmap(o_gi.indirect.diffuse, data.atten, bakedColorTex, normalWorld);
            #endif

        #endif
    #endif

    #ifdef DYNAMICLIGHTMAP_ON
        // Dynamic lightmaps
        fixed4 realtimeColorTex = UNITY_SAMPLE_TEX2D(unity_DynamicLightmap, data.lightmapUV.zw);
        half3 realtimeColor = DecodeRealtimeLightmap (realtimeColorTex);

        #ifdef DIRLIGHTMAP_COMBINED
            half4 realtimeDirTex = UNITY_SAMPLE_TEX2D_SAMPLER(unity_DynamicDirectionality, unity_DynamicLightmap, data.lightmapUV.zw);
            o_gi.indirect.diffuse += DecodeDirectionalLightmap (realtimeColor, realtimeDirTex, normalWorld);
        #else
            o_gi.indirect.diffuse += realtimeColor;
        #endif
    #endif

    o_gi.indirect.diffuse *= occlusion;
    return o_gi;
}

雖然代碼看起來比較長,不過絕大部分都不是我們關心的重點,我們重點在於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關鍵代碼:

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

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

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

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

float aovalue = 0;
for (int s = 0; s < (int)_SampleDirCount; s++)
{
	float3 sampleDir = _SampleDir[s];
	sampleDir = normalize(sampleDir);
	float3 objDir = i.objTangent * sampleDir.x + i.objBiNormal * sampleDir.y + i.objNormal * sampleDir.z;
	float currentLength = _AOTracingRadius;
	for (int j = 0; j < (int)_TriangleCount; j++)
	{
		float3 p0 = _TriangleX[j].xyz;
		float3 p1 = _TriangleY[j].xyz;
		float3 p2 = _TriangleZ[j].xyz;
		float raylength;
		bool result = RayTriangleTest(objDir, i.objPos, p0, p1, p2, raylength);
		if (result && raylength < currentLength)
		{
			currentLength = raylength;
		}
	}
	float ao = clamp(currentLength, 0, _AOTracingRadius) / _AOTracingRadius;
	aovalue += ao;
}
aovalue /= _SampleDirCount;
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的實現,首先,我們生成一系列的隨機採樣點,爲了讓遮蔽效果更好,我們儘量保證在靠近採樣點的位置分佈更多的隨機點:

private void GenerateAOSampleKernel()
{
    if (SampleKernelCount == sampleKernelList.Count)
        return;
    sampleKernelList.Clear();
    for(int i = 0; i < SampleKernelCount; i++)
    {
        var vec = new Vector4(Random.Range(-1.0f, 1.0f), Random.Range(-1.0f, 1.0f), Random.Range(0, 1.0f), 1.0f);
        vec.Normalize();
        var scale = (float)i / SampleKernelCount;
        //使分佈符合二次方程的曲線
        scale = Mathf.Lerp(0.01f, 1.0f, scale * scale);
        vec *= scale;
        sampleKernelList.Add(vec);
    }
}

關鍵Shader部分代碼如下:

fixed4 frag_ao (v2f i) : SV_Target
{
	fixed4 col = tex2D(_MainTex, i.uv);
	
	float linear01Depth;
	float3 viewNormal;
	
	float4 cdn = tex2D(_CameraDepthNormalsTexture, i.uv);
	DecodeDepthNormal(cdn, linear01Depth, viewNormal);
	
	float3 viewPos = linear01Depth * i.viewRay;
	float3 viewDir = normalize(viewPos);
	viewNormal = normalize(viewNormal);
	
	int sampleCount = _SampleKernelCount;

	float oc = 0.0;
	for(int i = 0; i < sampleCount; i++)
	{
		float3 randomVec = _SampleKernelArray[i].xyz;
		//如果隨機點的位置與法線反向,那麼將隨機方向取反,使之保證在法線半球
		randomVec = dot(randomVec, viewNormal) < 0 ? -randomVec : randomVec;
		
		float3 randomPos = viewPos + randomVec * _SampleKeneralRadius;
		float3 rclipPos = mul((float3x3)unity_CameraProjection, randomPos);
		float2 rscreenPos = (rclipPos.xy / rclipPos.z) * 0.5 + 0.5;
		
		float randomDepth;
		float3 randomNormal;
		float4 rcdn = tex2D(_CameraDepthNormalsTexture, rscreenPos);
		DecodeDepthNormal(rcdn, randomDepth, randomNormal);
		float range = abs(randomDepth - linear01Depth) * _ProjectionParams.z < _SampleKeneralRadius ? 1.0 : 0.0;
		float ao = randomDepth + _DepthBiasValue < linear01Depth  ? 1.0 : 0.0;
		oc += ao * range;
	}
	oc /= sampleCount;
	oc = max(0.0, 1 - oc * _AOStrength);

	col.rgb = oc;
	return col;
}

我們沒有使用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#部分代碼如下:

/********************************************************************
 FileName: ScreenSpaceAOEffect.cs
 Description: SSAO屏幕空間環境光遮蔽效果
 history: 6:10:2018 by puppet_master
 https://blog.csdn.net/puppet_master
*********************************************************************/
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[ExecuteInEditMode]
public class ScreenSpaceAOEffect : MonoBehaviour
{
    private Material ssaoMaterial = null;
    private Camera currentCamera = null;
    private List<Vector4> sampleKernelList = new List<Vector4>();

    [Range(0, 0.002f)]
    public float DepthBiasValue = 0.002f;
    [Range(0.010f, 1.0f)]
    public float SampleKernelRadius = 1.0f;
    [Range(4, 32)]
    public int SampleKernelCount = 16;
    [Range(0.0f, 5.0f)]
    public float AOStrength = 1.0f;
    [Range(0, 2)]
    public int DownSample = 0;

    [Range(1, 4)]
    public int BlurRadius = 1;
    [Range(0, 0.2f)]
    public float BilaterFilterStrength = 0.2f;

    public bool OnlyShowAO = false;

    public enum SSAOPassName
    {
        GenerateAO = 0,
        BilateralFilter = 1,
        Composite = 2,
    }

    private void Awake()
    {
        var shader = Shader.Find("AO/ScreenSpaceAOEffect");
        ssaoMaterial = new Material(shader);
        currentCamera = GetComponent<Camera>();
    }

    private void OnEnable()
    {
        currentCamera.depthTextureMode |= DepthTextureMode.DepthNormals;
    }

    private void OnDisable()
    {
        currentCamera.depthTextureMode &= ~DepthTextureMode.DepthNormals;
    }

    private void OnRenderImage(RenderTexture source, RenderTexture destination)
    {
        GenerateAOSampleKernel();

        var aoRT = RenderTexture.GetTemporary(source.width >> DownSample, source.height >> DownSample, 0);

        ssaoMaterial.SetMatrix("_InverseProjectionMatrix", currentCamera.projectionMatrix.inverse);
        ssaoMaterial.SetFloat("_DepthBiasValue", DepthBiasValue);
        ssaoMaterial.SetVectorArray("_SampleKernelArray", sampleKernelList.ToArray());
        ssaoMaterial.SetFloat("_SampleKernelCount", sampleKernelList.Count);
        ssaoMaterial.SetFloat("_AOStrength", AOStrength);
        ssaoMaterial.SetFloat("_SampleKeneralRadius", SampleKernelRadius);
        Graphics.Blit(source, aoRT, ssaoMaterial, (int)SSAOPassName.GenerateAO);

        var blurRT = RenderTexture.GetTemporary(source.width >> DownSample, source.height >> DownSample, 0);
        ssaoMaterial.SetFloat("_BilaterFilterFactor", 1.0f - BilaterFilterStrength);

        ssaoMaterial.SetVector("_BlurRadius", new Vector4(BlurRadius, 0, 0, 0));
        Graphics.Blit(aoRT, blurRT, ssaoMaterial, (int)SSAOPassName.BilateralFilter);

        ssaoMaterial.SetVector("_BlurRadius", new Vector4(0, BlurRadius, 0, 0));
        if (OnlyShowAO)
        {
            Graphics.Blit(blurRT, destination, ssaoMaterial, (int)SSAOPassName.BilateralFilter);
        }
        else
        {
            Graphics.Blit(blurRT, aoRT, ssaoMaterial, (int)SSAOPassName.BilateralFilter);
            ssaoMaterial.SetTexture("_AOTex", aoRT);
            Graphics.Blit(source, destination, ssaoMaterial, (int)SSAOPassName.Composite);
        }

        RenderTexture.ReleaseTemporary(aoRT);
        RenderTexture.ReleaseTemporary(blurRT);
    }

    private void GenerateAOSampleKernel()
    {
        if (SampleKernelCount == sampleKernelList.Count)
            return;
        sampleKernelList.Clear();
        for(int i = 0; i < SampleKernelCount; i++)
        {
            var vec = new Vector4(Random.Range(-1.0f, 1.0f), Random.Range(-1.0f, 1.0f), Random.Range(0, 1.0f), 1.0f);
            vec.Normalize();
            var scale = (float)i / SampleKernelCount;
            //使分佈符合二次方程的曲線
            scale = Mathf.Lerp(0.01f, 1.0f, scale * scale);
            vec *= scale;
            sampleKernelList.Add(vec);
        }
    }

}

Shader部分代碼如下:

/********************************************************************
 FileName: ScreenSpaceAOEffect.cs
 Description: SSAO屏幕空間環境光遮蔽效果
 history: 6:10:2018 by puppet_master
 https://blog.csdn.net/puppet_master
*********************************************************************/
Shader "AO/ScreenSpaceAOEffect"
{
	Properties
	{
		_MainTex ("Texture", 2D) = "black" {}
	}
	CGINCLUDE
	#include "UnityCG.cginc"
	
	struct appdata
	{
		float4 vertex : POSITION;
		float2 uv : TEXCOORD0;
	};
	
	struct v2f
	{
		float2 uv : TEXCOORD0;
		float4 vertex : SV_POSITION;
		float3 viewRay : TEXCOORD1;
	};
	
	#define MAX_SAMPLE_KERNEL_COUNT 32
	sampler2D _MainTex;
	sampler2D _CameraDepthNormalsTexture;
	float4x4 _InverseProjectionMatrix;
	float _DepthBiasValue;
	float4 _SampleKernelArray[MAX_SAMPLE_KERNEL_COUNT];
	float _SampleKernelCount;
	float _AOStrength;
	float _SampleKeneralRadius;
	
	float4 _MainTex_TexelSize;
	float4 _BlurRadius;
	float _BilaterFilterFactor;
	
	sampler2D _AOTex;
	
	float3 GetNormal(float2 uv)
	{
		float4 cdn = tex2D(_CameraDepthNormalsTexture, uv);
		return DecodeViewNormalStereo(cdn);
	}
	
	half CompareNormal(float3 normal1, float3 normal2)
	{
		return smoothstep(_BilaterFilterFactor, 1.0, dot(normal1, normal2));
	}
	
	v2f vert_ao (appdata v)
	{
		v2f o;
		o.vertex = UnityObjectToClipPos(v.vertex);
		o.uv = v.uv;
		float4 clipPos = float4(v.uv * 2 - 1.0, 1.0, 1.0);
		float4 viewRay = mul(_InverseProjectionMatrix, clipPos);
		o.viewRay = viewRay.xyz / viewRay.w;
		return o;
	}
	
	fixed4 frag_ao (v2f i) : SV_Target
	{
		fixed4 col = tex2D(_MainTex, i.uv);
		
		float linear01Depth;
		float3 viewNormal;
		
		float4 cdn = tex2D(_CameraDepthNormalsTexture, i.uv);
		DecodeDepthNormal(cdn, linear01Depth, viewNormal);
		float3 viewPos = linear01Depth * i.viewRay;
		viewNormal = normalize(viewNormal) * float3(1, 1, -1);
		
		int sampleCount = _SampleKernelCount;
	
		float oc = 0.0;
		for(int i = 0; i < sampleCount; i++)
		{
			float3 randomVec = _SampleKernelArray[i].xyz;
			//如果隨機點的位置與法線反向,那麼將隨機方向取反,使之保證在法線半球
			randomVec = dot(randomVec, viewNormal) < 0 ? -randomVec : randomVec;
			
			float3 randomPos = viewPos + randomVec * _SampleKeneralRadius;
			float3 rclipPos = mul((float3x3)unity_CameraProjection, randomPos);
			float2 rscreenPos = (rclipPos.xy / rclipPos.z) * 0.5 + 0.5;
			
			float randomDepth;
			float3 randomNormal;
			float4 rcdn = tex2D(_CameraDepthNormalsTexture, rscreenPos);
			DecodeDepthNormal(rcdn, randomDepth, randomNormal);
			float range = abs(randomDepth - linear01Depth) * _ProjectionParams.z < _SampleKeneralRadius ? 1.0 : 0.0;
			float ao = randomDepth + _DepthBiasValue < linear01Depth  ? 1.0 : 0.0;
			oc += ao * range;
		}
		oc /= sampleCount;
		oc = max(0.0, 1 - oc * _AOStrength);
	
		col.rgb = oc;
		return col;
	}
	
	fixed4 frag_blur (v2f i) : SV_Target
	{
		float2 delta = _MainTex_TexelSize.xy * _BlurRadius.xy;
		
		float2 uv = i.uv;
		float2 uv0a = i.uv - delta;
		float2 uv0b = i.uv + delta;	
		float2 uv1a = i.uv - 2.0 * delta;
		float2 uv1b = i.uv + 2.0 * delta;
		float2 uv2a = i.uv - 3.0 * delta;
		float2 uv2b = i.uv + 3.0 * delta;
		
		float3 normal = GetNormal(uv);
		float3 normal0a = GetNormal(uv0a);
		float3 normal0b = GetNormal(uv0b);
		float3 normal1a = GetNormal(uv1a);
		float3 normal1b = GetNormal(uv1b);
		float3 normal2a = GetNormal(uv2a);
		float3 normal2b = GetNormal(uv2b);
		
		fixed4 col = tex2D(_MainTex, uv);
		fixed4 col0a = tex2D(_MainTex, uv0a);
		fixed4 col0b = tex2D(_MainTex, uv0b);
		fixed4 col1a = tex2D(_MainTex, uv1a);
		fixed4 col1b = tex2D(_MainTex, uv1b);
		fixed4 col2a = tex2D(_MainTex, uv2a);
		fixed4 col2b = tex2D(_MainTex, uv2b);
		
		half w = 0.37004405286;
		half w0a = CompareNormal(normal, normal0a) * 0.31718061674;
		half w0b = CompareNormal(normal, normal0b) * 0.31718061674;
		half w1a = CompareNormal(normal, normal1a) * 0.19823788546;
		half w1b = CompareNormal(normal, normal1b) * 0.19823788546;
		half w2a = CompareNormal(normal, normal2a) * 0.11453744493;
		half w2b = CompareNormal(normal, normal2b) * 0.11453744493;
		
		half3 result;
		result = w * col.rgb;
		result += w0a * col0a.rgb;
		result += w0b * col0b.rgb;
		result += w1a * col1a.rgb;
		result += w1b * col1b.rgb;
		result += w2a * col2a.rgb;
		result += w2b * col2b.rgb;
		
		result /= w + w0a + w0b + w1a + w1b + w2a + w2b;
		return fixed4(result, 1.0);
	}
	
	fixed4 frag_composite(v2f i) : SV_Target
	{
		fixed4 ori = tex2D(_MainTex, i.uv);
		fixed4 ao = tex2D(_AOTex, i.uv);
		ori.rgb *= ao.r;
		return ori;
	}

	ENDCG
	
	SubShader
	{
		
		Cull Off ZWrite Off ZTest Always

		//Pass 0 : Generate AO 
		Pass
		{
			CGPROGRAM
			#pragma vertex vert_ao
			#pragma fragment frag_ao
			ENDCG
		}
		
		//Pass 1 : Bilateral Filter Blur
		Pass
		{
			CGPROGRAM
			#pragma vertex vert_ao
			#pragma fragment frag_blur
			ENDCG
		}
		
		//Pass 2 : Composite AO
		Pass
		{
			CGPROGRAM
			#pragma vertex vert_ao
			#pragma fragment frag_composite
			ENDCG
		}
	}
}

關於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代碼如下:

inline float2 RotateDirections(float2 dir, float2 rot) {
	return float2(dir.x * rot.x - dir.y * rot.y,
				  dir.x * rot.y + dir.y * rot.x);
}

inline float Falloff2(float distance, float radius)
{
	float a = distance / radius;
	return clamp(1.0 - a * a, 0.0, 1.0);
}

float3 GetViewPos(v2f i, float2 uv)
{
	float linear01Depth;
	float3 viewNormal;
	float4 cdn = tex2D(_CameraDepthNormalsTexture, uv);
	DecodeDepthNormal(cdn, linear01Depth, viewNormal);
	float3 viewPos = linear01Depth * i.viewRay;
	return viewPos;
}

// Reconstruct view-space position from UV and depth.
// p11_22 = (unity_CameraProjection._11, unity_CameraProjection._22)
// p13_31 = (unity_CameraProjection._13, unity_CameraProjection._23)
float3 ReconstructViewPos(float2 uv)
{
	float3x3 proj = (float3x3)unity_CameraProjection;
	float2 p11_22 = float2(unity_CameraProjection._11, unity_CameraProjection._22);
	float2 p13_31 = float2(unity_CameraProjection._13, unity_CameraProjection._23);
	float depth;
	float3 viewNormal;
	float4 cdn = tex2D(_CameraDepthNormalsTexture, uv);
	DecodeDepthNormal(cdn, depth, viewNormal);
	depth *= _ProjectionParams.z;
	return float3((uv * 2.0 - 1.0 - p13_31) / p11_22 * (depth), depth);
}

inline float2 GetRayMarchingDir(float angle)
{
	float sinValue, cosValue;
	sincos(angle, sinValue, cosValue);
	return RotateDirections(float2(cosValue, sinValue), float2(1.0, 0));
}

fixed4 frag_ao (v2f i) : SV_Target
{
	float2 InvScreenParams = _ScreenParams.zw - 1.0;
	fixed4 col = tex2D(_MainTex, i.uv);
	float3 viewPos = ReconstructViewPos(i.uv);

	float4 cdn = tex2D(_CameraDepthNormalsTexture, i.uv);
	float3 viewNormal =  DecodeViewNormalStereo(cdn) * float3(1.0, 1.0, -1.0);

	float rayMarchingRadius = min(_SampleRadius / viewPos.z, _MaxPixelRadius);
	float rayMarchingStepSize =  rayMarchingRadius / _RayMarchingStep;
	float rayAngleSize = 2.0 * UNITY_PI / _RayAngleStep;	
	
	float oc = 0.0;
	for(int j = 0; j < _RayAngleStep; j++)
	{
		float2 rayMarchingDir = GetRayMarchingDir(j * rayAngleSize);
		float oldangle = _AngleBiasValue;
		float2 deltauv = round(1 + rayMarchingDir * rayMarchingStepSize) * InvScreenParams;
		
		for(int k = 1; k < _RayMarchingStep; k++)
		{
			float2 uv = k * deltauv + i.uv;
			float3 sviewPos = ReconstructViewPos(uv);

			float3 svdir = sviewPos - viewPos;
			float l = length(svdir);
			float angle = UNITY_PI * 0.5 - acos(dot(viewNormal, normalize(svdir)));
			if (angle > oldangle)
			{
				float value = sin(angle) - sin(oldangle);
				float atten = Falloff2(l, _AORadius);
				oc += value * atten;
				oldangle = angle;
			}
		}
	}
	oc *= 1.0 / (_RayAngleStep) * _AOStrength;
	oc = 1.0 - oc;

	col.rgb = oc;
	return col;
}

直接使用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的開頭又有東西寫啦!

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