遊戲後期特效第四發 -- 屏幕空間環境光遮蔽(SSAO)
寫在前面
專欄斷更了這麼久, 實在慚愧. 這段時間又是期末考試又是回家過節, 實在是沒時間整理乾貨來分享. 之前說好的不及時更新就賠錢, @Cathy Chen童鞋已經拿到10大洋的紅包了.
不過從今天開始我就有大把的時間來繼續研究圖形學啦, 因此會保證更新速度的 ~
延續了我對Depth Buffer的一貫興趣, 本文將介紹SSAO的基本原理及包括Temporal Coherence SSAO, Selective Temporal Filtering SSAO在內的優化演算法.
何為"SSAO"
Screen Space Ambient Occlusion (以下簡稱SSAO), 屏幕空間環境光遮蔽. 在具體介紹SSAO之前, 本文先介紹更加廣義的Ambient Occlusion (AO).
簡單來說, Ambient Occlusion(以下簡稱"AO")是一種基於全局照明中的環境光(Ambient Light)參數和環境幾何信息來計算場景中任何一點的光照強度係數的演算法. AO描述了表面上的任何一點所接受到的環境光被周圍幾何體所遮蔽的百分比, 因此使得渲染的結果更加富有層次感, 對比度更高.

圖片來自Wiki. 因為老人的皺紋處對外界暴露的部分較少, 使用AO後被遮蔽的部分較多, 渲染後顯得更加暗一些, 增加了皺紋的層次感和質感.
AO的計算公式如下:
代表點
的法線,
代表點
切平面正方向的任意單位向量,
是可見函數, 如果點
在
方向被遮擋則為1, 否則為0.
由此可見, 計算AO係數是一個頗為昂貴的操作. 一般離線渲染器都會採用Ray-Tracing(光線追蹤)或是簡化的Ray-Marching(所謂光線行進)演算法, 模擬若干條射線以計算遮蔽百分比. 很明顯這種方式不可能應用到實時圖形渲染中. 儘管目前有一些實時計算AO的新技術, 但是其性能距離普及還有很長的路要走.

那麼我們能否Trade Off, 用差一點的渲染結果來獲得更高的運行效率呢? 答案是肯定的, 而且方法還遠不止一種. 本文將重點放在SSAO上.
顧名思義, "Screen Space"意味著SSAO並不是場景的預處理, 而是屏幕後期處理. 其原理是在片元著色器中對於屏幕上的每個像素模擬若干個位置隨機的採樣點, 用被遮蔽的採樣點數量百分比來近似表示光照強度係數.
SSAO的實現
SSAO的實現可分為三個步驟: 計算AO, 模糊/濾波, 與Color Buffer混合.
1. 計算AO
計算AO的核心問題在於如何取採樣點並判斷這些採樣點是否被遮蔽. 我們首先解決第一個問題. 在此我們使用一種指向法線方向的半球形採樣塊(Sample Kernel), 並在採樣塊中生成採樣點. 距離原點越遠的點, AO貢獻越小. 採樣塊如下圖所示:

那麼我們轉入第二個問題: 如何判斷下圖中的採樣點遮蔽情況呢?

一種方法是將採樣點全部投影到View Plane上, 相當於獲取採樣點的UV坐標, 並同時獲取Depth Buffer中該UV坐標處的深度值. 隨後比較採樣點的深度和場景中該點的深度. 如果採樣點的深度更大, 說明其被場景遮蔽. 最終將所有採樣點的AO貢獻求和, 即是該點的AO值. 計算公式如下:
其中, 函數V前面已經介紹過. 函數D是一個[0, 1]之間的單調遞減函數, 距離原點越近的採樣點對AO的貢獻越大. 一般使用指數函數.


上圖為求得的AO值. 顏色越深代表AO越大.
以下是循環採樣部分代碼(ii為循環變數):
half3 randomDirection = RandomSample[ ii ];float2 uv_offset = randomDirection.xy * scale;float randomDepth = depth - ( randomDirection.z * _Radius );float sampleDepth;float3 sampleNormal;DecodeDepthNormal ( tex2D ( _CameraDepthNormalsTexture, i.uv + uv_offset ), sampleDepth, sampleNormal );sampleDepth *= _ProjectionParams.z;float diff = saturate( randomDepth - sampleDepth );if ( diff > _ZDiff ) occlusionAmount += pow (1 - diff, _Attenuation);
到此我們發現了一個問題: 上面求得的AO結果非常不理想. 圖中有非常明顯的條帶狀陰影, 給人的感覺像是在圖上輕輕地抹了一層均勻的油漆. 產生這種現象的原因很簡單 - 為了滿足實時渲染的性能要求, 我們必須限制採樣點的數目.
但是, 對於這種現象我們有一個Trick ---- 可以引入雜訊, 將每個採樣點以原點法線方向為旋轉軸旋轉隨機的角度. 這樣的新採樣點會變得極其不規則, 更加離散化. 將低頻的條紋轉化成高頻的雜訊.
half3 randomVector = tex2D ( _RandomTexture, i.uv_random ).xyz * 2.0 - 1.0;half3 randomDirection = reflect ( RandomSample[ ii ], randomVector );

2. 模糊/濾波
"油漆"好還是"沙子"好?
都不好!
"油漆"顯得平淡無奇, "沙子"讓人眼花繚亂. 中國人講究中庸之道, 也就是說 ---- 我們需要一個"中頻"的AO!
在此介紹兩種方法. 第一種方法是直接模糊. 比較常用的是高斯模糊. 關於高斯模糊的資料有很多, 本文不再贅述.
第二種方法在採樣原理上和高斯模糊別無二致, 只是採樣係數由靜態變為動態: 原點與採樣點的UV坐標距離, 法線和深度關係共同決定採樣係數, 距離越遠採樣係數越小, 法線和深度的差距越大則採樣係數也越大. 這樣的模糊使得結果更加趨近於中頻, 進一步減弱了閃爍(Flickering)的效果.
3. 與Color Buffer混合.
一般加入Gamma Correction使得陰影更有層次感, 即最終結果為:
tex2D ( _MainTex, i.uv ) * pow ( ( 1 - occlusion ), 2.2 );

上圖為SSAO處理後的最終結果.
SSAO的問題與優化策略
SSAO技術的基本原理已經介紹完了, 下面我們來談談SSAO可能遇到的問題, 以及相應的解決方案:
1. 採樣塊的問題
上文的SSAO實現方案其實是假定了使用Deferred Rendering, 深度和法線都可以非常容易得獲取到, 因此我們的半球形採樣塊可以沿著頂點的法線方向擺放. 但是如果獲取法線比較困難, 我們可以將半球形退化成球形, 這也正是2007年Crysis中SSAO的實現方案.

2. 重複計算的問題
SSAO最為耗時的操作是模擬多個採樣點並計算其AO貢獻值, 因此我們應該想辦法避免重複計算, 盡量使用以前的結果. 這裡可以使用Reverse Reprojection(反向二次投影), 保存上一幀的AO計算信息, 使得當前幀中相對上一幀沒有變化的點可以利用舊的AO信息, 避免重複計算. 這種方式稱為SSAO with Temporal Coherence(時間相干性), 簡稱為TSSAO. 具體的實現方式將在下文中進行闡述.
3. 濾波與精確計算之間的矛盾
這也是一個Trade Off ---- 反正運算結果都是要套用濾波來過濾掉噪點, 那麼最開始計算的時候就可以想辦法在保證質量不受太大影響的前提下, 盡量提升效率. 舉一個例子:
假設有兩個麵餅師傅, 第一個師傅的工作是揉出來5個直徑為1.2cm的面球, 第二個師傅的工作是把這5個面球放在一起拍成一個麵餅.
我們不討論這種工作方式是否合理, 只是在此情況下第一個師傅確實不用對1.2cm吹毛求疵, 只要差不多就行了.
明白了這個道理, 就會發現可以在計算AO的時候使用降採樣, 這樣能成平方倍地降低SSAO的時間複雜度. 關於降採樣網上有很多資料, 我的專欄第一篇文章也對此有過介紹.
TSSAO
1. Reverse Reprojection
交替使用兩張Render Texture, 一張代表當前幀, 另一張代表上一幀. 對於當前幀上任何一個Pixel都可以根據其UV坐標重建其世界坐標, 然後根據上一幀的View-Projection矩陣的逆矩陣來轉化成上一幀的相應UV坐標. 如果兩幀上對應的Pixel的Depth與世界坐標差距不大, 那麼當前幀就可以利用上一幀對應Pixel的信息, 免去重複計算.
對於靜態場景我們有如下公式:
t表示NDC坐標, P表示Projection矩陣, V表示View矩陣.
inline float4 UV2WorldPos(float2 uv, float4x4 iv){ float depth = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, uv); float2 invClipSize = float2(_CurP._11, _CurP._22); float2 spos = (uv * 2 - 1) / invClipSize; float depthEye = LinearEyeDepth(depth); float3 vpos = float3(spos, -1) * depthEye; return mul(iv, float4(vpos, 1.0));}inline float2 WorldPos2UV(float4 worldPos, float4x4 vp){ float4 vpos = mul(vp, worldPos); float2 projOffset = float2(_CurP._13, _CurP._23); float2 screenPos = vpos.xy / vpos.w + projOffset; return 0.5 * screenPos + 0.5;}inline float2 GetMotionVector(float2 uv){ float4 worldPos = UV2WorldPos(uv, _CurIV); float2 curUv = WorldPos2UV(worldPos, _CurVP); float2 preUv = WorldPos2UV(worldPos, _PrevVP); return curUv - preUv;}
但是對於動態場景上述公式不再成立. 因為SSAO要做到Scene Independent, 同時還必須記錄前後兩幀的關聯, 因此在此我們可以用一個Render Texture單獨記錄每個像素在前一幀與當前幀的世界坐標的差. 具體的原因詳見下文.
2. 新的AO計算公式
在TSSAO中我們使用兩個Render Texture分別作為上一幀和當前幀的AO Buffer. 首先計算當前幀的AO貢獻值:
表示的是之前已經計算過的採樣點數量, k表示每一幀應該計算的採樣點數量.
隨後, 利用當前幀和上一幀的AO貢獻值共同計算當前幀的AO值:
最後, 更新的值:
在此說明一下為什麼要設置n的上限. 這裡主要有兩個原因, 第一是如果之前的計算結果不老化, 當前幀的AO貢獻值會越來越小, 演算法的反應會越來越慢. 第二是反向二次投影本身是有誤差的, 隨著投影次數的增加誤差會變得非常大, 因此必須限制被使用的結果數量, 適當捨棄掉過老的數據.
3. 檢測不合法像素
很容易想到的一個判定條件是深度檢測: 如果新舊兩個像素的深度差距過大, 那麼說明場景 已經改變, 當前像素的AO值已經不正確, 必須全部捨棄. 相對深度關係檢測條件如下:
但是, SSAO考慮的不僅僅是當前點, 還有它周邊的環境. 舉一個例子: 在一個靜態的地面上放置著一個動態的立方體. 這個立方體隨著時間不規則運動. 地面與立方體地面棱邊的外交界處的AO值自然明顯高出地面上其他點的值, 但是立方體的移動會使得地面上相應區域的AO值不再有效 ---- 雖然地面沒有在動, 地面上的點能夠通過深度關係檢測.

這裡我們用到了另一個Trick ---- 我們在計算當前幀AO的循環中可以同時做出以下判定:
只要有一個採樣點不滿足上述條件, 則說明其對應原點的周邊環境已經發生改變, 其AO值自然也應該重新計算. 對於的計算方法也很簡單: 根據
和在第一步中記錄的世界坐標的差就可以直接得出了.
只要像素被判定為不合法, 則其會被重置為0, 即之前的所有AO運算結果全部捨棄.
int ww = w;if ( abs ( 1 - texelDepth / preDepth ) >= EPS ) ww = 0;float scale = _Parameters.x / depth;for ( int ii = 0; ii < _SampleCount; ii++ ){ float3 randomDirection = RAND_SAMPLES [ w + ii ]; randomDirection *= -sign ( dot ( texelNormal, randomDirection ) ); randomDirection += texelNormal * 0.3; float2 uv_offset = randomDirection.xy * scale; float randomDepth = texelDepth - randomDirection.z * _Parameters.x; float sampleDepth; float3 sampleNormal; GetDepthNormal ( i.uv + uv_offset, sampleDepth, sampleNormal ); sampleDepth *= _ProjectionParams.z; float diff = randomDepth - sampleDepth; if ( diff > _Parameters.y ) occlusion += saturate ( cos ( dot ( randomDirection, texelNormal ) ) ) * pow ( 1 - diff, _Parameters.z ); if ( abs ( randomDirection - abs ( randomDirection + GetMotionVector ( i.uv + uv_offset ) - GetMotionVector ( i.uv ) - texelPosition ) ) >= EPS )}
4. 濾波
可以在SSAO的濾波基礎上加上收斂度的條件. 收斂度定義為:
收斂度越大, 說明當前像素越為"安全", 隨著時間的改變越小, 因此採樣係數也越大.
Selective Temporal Filtering (STF)
這項技術應用在了BattleField3中. 首先它是基於TSSAO的, 不同的是其AO計算方式: 在當前幀的AO貢獻值和歷史數據中間做插值.
這樣做帶來的一個小問題就是"老化" ---- 舊的數據不能及時清理出去, 這就導致場景移動比較快的時候, AO Buffer會存在鬼影的問題.
但是一個更為嚴重的問題是"閃爍" ---- BattleField3的場景中有大量花草樹木, 樹葉的晃動使得大量像素被頻繁檢測為失效, 重新計算AO, 這與未失效的部分構成了鮮明的對比.
DICE的解決方案非常Trick: 他們發現存在鬼影的像素和存在閃爍的像素是互斥的. 因此他們想辦法甄別這兩種像素, 並對於可能產生鬼影的像素關掉Temporal Filtering. 因此這項技術被稱為Selective Temporal Filtering.
具體的方法是檢測連續性: 對於任何一個Pixel, 連續在x或y方向選擇兩個像素, 判斷這三個像素的深度是否連續. 如果連續則可能產生鬼影, 否則可能產生閃爍.



後記
SSAO原理並不複雜, 只是在實際應用場景中會有各種各樣的Trick以應對個性化的需要. 文中主要講解了SSAO的基本原理與TSSAO的優化原理, 並舉了BattleField3的STF為例.
引用
Nehab, Sander, Lawrence, Tatarchuk, Isidoro.Accelerating Real-Time Shading with Reverse Reprojection Caching. In ACMSIGGRAPH/Eurographics Symposium on Graphics Hardware 2007.
Mattausch, Oliver, Daniel Scherzer, and Michael Wimmer. "High‐Quality Screen‐Space Ambient Occlusion using Temporal Coherence." Computer Graphics Forum. Vol. 29. No. 8. Blackwell Publishing Ltd, 2010.
BAVOIL L., SAINZ M.: Multi-layer dual-resolutionscreen-space ambient occlusion. In SIGGRAPH 』09: SIGGRAPH2009: Talks (New York, NY, USA, 2009), ACM, pp. 1–1. 3, 7
Screen space ambient occlusion. (2016, December 29). In Wikipedia, The Free Encyclopedia. Retrieved 19:24, January 28, 2017, from Screen space ambient occlusion
Bavoil, L., & Sainz, M. (2008). Screen space ambient occlusion. NVIDIA developer information: http://developers. nvidia. com, 6.
推薦閱讀:
