DirectX11 With Windows SDK--40 抗鋸齒:FXAA

X_Jun發表於2022-05-31

前言

在預設的情況下渲染,會看到物體的邊緣會有強烈的鋸齒感,究其原因在於取樣不足。但是,嘗試提升取樣的SSAA會增大渲染的負擔;而硬體MSAA與延遲渲染又不能協同工作。為此我們可以考慮使用後處理的方式來進行抗鋸齒的操作。在這一章中,我們將會討論一種常見的後處理抗鋸齒方法:FXAA。

DirectX11 With Windows SDK完整目錄

歡迎加入QQ群: 727623616 可以一起探討DX11,以及有什麼問題也可以在這裡彙報。

FXAA

FXAA(Fast approXimate AntiAliasing) 抗鋸齒演算法是由NVIDIA的Timothy Lottes開發的,核心思想是從影像中分析出哪些畫素屬於邊緣,然後嘗試找出邊緣的長度,並根據畫素所處邊緣的位置對其進行抗鋸齒處理。

未開抗鋸齒

image

FXAA

image

作為一種後處理抗鋸齒方法,它可以很方便地加入到你的程式當中,只需要一個全屏Pass即可。在完成前面渲染後,將該影像作為輸入,然後經過FXAA演算法處理後就能得到抗鋸齒的結果。該演算法並不是從幾何體或者線段的角度出發,而僅僅是通過獲取當前畫素及周圍的畫素的亮度資訊,以此嘗試尋找邊緣並進行平滑處理。

目前能找到的FXAA最新的版本也都是10多年前的FXAA 3.11了,它有如下兩種實現:

  • FXAA 3.11 Quality:該版本通常用於PC,注重抗鋸齒質量
  • FXAA 3.11 Console:該版本通常用於以前的主機,注重效率

本文將圍繞FXAA 3.11 Quality的實現展開說明,不過原始碼可能是為了效率,可能是用了別的什麼工具把程式碼打亂了一些,然後將迴圈也暴力程式碼展開了,可讀性弄的很差。對於現在的硬體來說應該也沒什麼必要,這裡我們將程式碼進行重新整理以提升可讀性為主。

Luma(亮度)

首先我們需要求出當前畫素的亮度,類似於將RGB轉成灰度圖的形式。在假定我們使用線性空間的紋理來儲存場景的渲染影像情況下, 假設所有畫素的顏色分量值最終限定於0-1的範圍內,我們可以使用下面這種常用的公式得到luma:

\[L=0.2126*R+0.7152*G+0.0722*B \]

判斷當前畫素是否需要應用AA

現在我們先只考慮求出當前畫素和與它直接相鄰的四個畫素的亮度。找到其中的最大值與最小值,這兩個值的差可以得到區域性對比度。當小於一個與最大亮度成正比關係的閾值時,當前畫素不會執行抗鋸齒。此外,我們也不希望在暗部(如陰影)區域進行抗鋸齒的操作,如果區域性對比度小於一個絕對的閾值時,也不會執行抗鋸齒操作。這時候我們就可以提前輸出該畫素的顏色。

image

float2 posM = texCoord;
float4 color = g_TextureInput.SampleLevel(g_Sampler, texCoord, 0);

//    N
//  W M E
//    S
float lumaM = LinearRGBToLuminance(color.rgb);
float lumaS = LinearRGBToLuminance(g_TextureInput.SampleLevel(g_Sampler, posM, 0, int2(0, 1)).rgb);
float lumaE = LinearRGBToLuminance(g_TextureInput.SampleLevel(g_Sampler, posM, 0, int2(1, 0)).rgb);
float lumaN = LinearRGBToLuminance(g_TextureInput.SampleLevel(g_Sampler, posM, 0, int2(0, -1)).rgb);
float lumaW = LinearRGBToLuminance(g_TextureInput.SampleLevel(g_Sampler, posM, 0, int2(-1, 0)).rgb);

//
// 計算對比度,確定是否應用抗鋸齒
//

// 求出5個畫素中的最大/最小相對亮度,得到對比度
float lumaRangeMax = max(lumaM, max(max(lumaW, lumaE), max(lumaN, lumaS)));
float lumaRangeMin = min(lumaM, min(min(lumaW, lumaE), min(lumaN, lumaS)));
float lumaRange = lumaRangeMax - lumaRangeMin;
// 如果亮度變化低於一個與最大亮度呈正相關的閾值,或者低於一個絕對閾值,說明不是處於邊緣區域,不進行任何抗鋸齒操作
bool earlyExit = lumaRange < max(g_qualityEdgeThresholdMin, lumaRangeMax * g_qualityEdgeThreshold);

// 未達到閾值就提前結束
if (g_EarlyOut && earlyExit)
    return color;

g_qualityEdgeThresholdg_qualityEdgeThresholdMin的設定參考如下:

// 所需區域性對比度的閾值控制
// 0.333 - 非常低(更快)
// 0.250 - 低質量
// 0.166 - 預設
// 0.125 - 高質量
// 0.063 - 非常高(更慢)
float  g_qualityEdgeThreshold;

// 對暗部區域不進行處理的閾值
// 0.0833 - 預設
// 0.0625 - 稍快
// 0.0312 - 更慢
float  g_qualityEdgeThresholdMin;

確定邊界是水平的還是豎直的

為了確定邊界的情況,現在我們需要利用中間畫素的luma跟周圍8個畫素的luma。我們使用下面的公式來求出水平和豎直方向的變化程度。若豎直方向的總體變化程度比水平方向的總體變化程度大,說明當前邊界是水平的。

//
// 確定邊界是區域性水平的還是豎直的
//

//           
//  NW N NE          
//  W  M  E
//  WS S SE     
//  edgeHorz = |(NW - W) - (W - WS)| + 2|(N - M) - (M - S)| + |(NE - E) - (E - SE)|
//  edgeVert = |(NE - N) - (N - NW)| + 2|(E - M) - (M - W)| + |(SE - S) - (S - WS)|
float lumaNW = LinearRGBToLuminance(g_TextureInput.SampleLevel(g_SamplerLinearClamp, posM, 0, int2(-1, -1)).rgb);
float lumaSE = LinearRGBToLuminance(g_TextureInput.SampleLevel(g_SamplerLinearClamp, posM, 0, int2(1, 1)).rgb);
float lumaNE = LinearRGBToLuminance(g_TextureInput.SampleLevel(g_SamplerLinearClamp, posM, 0, int2(1, -1)).rgb);
float lumaSW = LinearRGBToLuminance(g_TextureInput.SampleLevel(g_SamplerLinearClamp, posM, 0, int2(-1, 1)).rgb);

float lumaNS = lumaN + lumaS;
float lumaWE = lumaW + lumaE;
float lumaNESE = lumaNE + lumaSE;
float lumaNWNE = lumaNW + lumaNE;
float lumaNWSW = lumaNW + lumaSW;
float lumaSWSE = lumaSW + lumaSE;

// 計算水平和垂直對比度
float edgeHorz = abs(lumaNWSW - 2.0 * lumaW) + abs(lumaNS - 2.0 * lumaM) * 2.0 + abs(lumaNESE - 2.0 * lumaE);
float edgeVert = abs(lumaSWSE - 2.0 * lumaS) + abs(lumaWE - 2.0 * lumaM) * 2.0 + abs(lumaNWNE - 2.0 * lumaN);

// 判斷是 區域性水平邊界 還是 區域性垂直邊界
bool horzSpan = edgeHorz >= edgeVert;

例如:

//  NW N NE     0 0 0       
//  W  M  E     1 1 0
//  WS S SE     1 1 1
// edgeHorz = |(NW - W) - (W - WS)| + 2|(N - M) - (M - S)| + |(NE - E) - (E - SE)|
//          = 1 + 2 * 1 + 1
//          = 4
// edgeVert = |(NE - N) - (N - NW)| + 2|(E - M) - (M - W)| + |(SE - S) - (S - WS)|
//          = 0 + 2 * 1 + 0
//          = 2
// edgeHorz > edgeVert,屬於水平邊界

對於這種單畫素的線也能有良好的處理:

// 0 1 0
// 0 1 0
// 0 1 0
// edgeHorz = 0
// edgeVert = 8
// edgeHorz < edgeVert,屬於豎直邊界

至於位於角上的情況:

// 0 0 0
// 0 1 1
// 0 1 1

由於我們只分為水平和豎直邊界,對這種情況我們也先歸類到其中一種情況後續再處理

計算梯度、確定邊界方向

現在我們只是知道了屬於邊界的型別,還需要確定邊界的過渡是怎樣的,比如對水平邊界來說有兩種情況:

image

我們可以求上方向和下方向的梯度,找到變化絕對值最大的作為該畫素的梯度。

//
// 計算梯度、確定邊界方向
//
float luma1 = horzSpan ? lumaN : lumaW;
float luma2 = horzSpan ? lumaS : lumaE;

float gradient1 = luma1 - lumaM;
float gradient2 = luma2 - lumaM;
// 求出對應方向歸一化後的梯度,然後進行縮放用於後續比較
float gradientScaled = max(abs(gradient1), abs(gradient2)) * 0.25f;
// 哪個方向最陡峭
bool is1Steepest = abs(gradient1) >= abs(gradient2);

最後,我們沿著這個梯度移動半個畫素大小,然後計算這個點的平均luma。

image

//
// 當前畫素沿梯度方向移動半個texel
//
float lengthSign = horzSpan ? g_TexelSize.y : g_TexelSize.x;
lengthSign = is1Steepest ? -lengthSign : lengthSign;

float2 posB = posM.xy;

// 半texel偏移
if (!horzSpan)
    posB.x += lengthSign * 0.5;
if (horzSpan)
    posB.y += lengthSign * 0.5;

//
// 計算與posB相鄰的兩個畫素的luma的平均值
//
float luma3 = luma1 + lumaM;
float luma4 = luma2 + lumaM;
float lumaLocalAvg = luma3;
if (!is1Steepest)
    lumaLocalAvg = luma4;
lumaLocalAvg *= 0.5f;

嘗試第一次邊緣方向的探索

接下來我們沿著邊界方向的兩邊進行搜尋。第一次搜尋我們嘗試向兩邊步進1個畫素,獲取這兩個位置的luma,然後計算luma與posB處的平均luma值的差異。如果這個差異值大於區域性梯度,說明我們到達了這個邊界的一側並停下,否則繼續增加指定倍率的水平texel的偏移

// 沿邊界向兩邊偏移
// 0    0    0
// <-  posB ->
// 1    1    1
float2 offset;
offset.x = (!horzSpan) ? 0.0 : g_TexelSize.x;
offset.y = (horzSpan) ? 0.0 : g_TexelSize.y;
// 負方向偏移
float2 posN = posB - offset * s_SampleDistances[0];
// 正方向偏移
float2 posP = posB + offset * s_SampleDistances[0];

// 對偏移後的點獲取luma值,然後計算與中間點luma的差異
float lumaEndN = LinearRGBToLuminance(g_TextureInput.SampleLevel(g_SamplerLinearClamp, posN, 0).rgb);
float lumaEndP = LinearRGBToLuminance(g_TextureInput.SampleLevel(g_SamplerLinearClamp, posP, 0).rgb);
lumaEndN -= lumaLocalAvg;
lumaEndP -= lumaLocalAvg;

// 如果端點處的luma差異大於區域性梯度,說明到達邊緣的一側
bool doneN = abs(lumaEndN) >= gradientScaled;
bool doneP = abs(lumaEndP) >= gradientScaled;
bool doneNP = doneN && doneP;

// 如果沒有到達非邊緣點,繼續沿著該方向延伸
if (!doneN)
    posN -= offset * s_SampleDistances[1];
if (!doneP)
    posP += offset * s_SampleDistances[1];

image

對上圖來說,紅框處算出的gradiantScaled = 0.25lumaEndN = 0.5 - 0.5 = lumaEndP = 0.0 < gradiantScaled(由於使用的是雙線性插值,lumaEndNlumaEndP經過插值後的結果為0.5),因此我們可以繼續往兩邊遍歷。

繼續遍歷

假設存在一個點沒有到達邊緣一側,我們就繼續執行遍歷。左側的點在進行第二次步進後,算出的lumaEndN = abs(0 - 0.5) = 0.5 > gradiantScaled,說明此時已經到達邊緣一側,左側的點可以停下,右側的點則可能要經過多次步進才停下。假設每次都是以1個texel的單位步進(s_SampleDistances的元素都為1.0),這時候的狀態可能為:

image

// 繼續迭代直到兩邊都到達邊緣的一側,或者達到迭代次數
if (!doneNP)
{
    [unroll]
    for (int i = 2; i < FXAA_QUALITY__PS; ++i)
    {
        if (!doneN)
            lumaEndN = LinearRGBToLuminance(g_TextureInput.SampleLevel(g_SamplerLinearClamp, posN.xy, 0).rgb) - lumaLocalAvg;
        if (!doneP)
            lumaEndP = LinearRGBToLuminance(g_TextureInput.SampleLevel(g_SamplerLinearClamp, posP.xy, 0).rgb) - lumaLocalAvg;

        doneN = abs(lumaEndN) >= gradientScaled;
        doneP = abs(lumaEndP) >= gradientScaled;
        doneNP = doneN && doneP;

        if (!doneN)
            posN -= offset * s_SampleDistances[i];
        if (!doneP)
            posP += offset * s_SampleDistances[i];
        // 兩邊都到達邊緣的一側就停下
        if (doneNP)
            break;
    }
}

但是在有限的迭代次數的情況下,每次都只移動1個畫素很可能出現還沒有到達邊緣的情況。為此我們可以考慮隨著迭代次數的增加,加大對當前畫素的偏移量。在FXAA的原始碼中提供了許多預設的偏移量,其中最高質量和最低質量的偏移如下:

//   FXAA 質量 - 低質量,中等抖動
#if (FXAA_QUALITY__PRESET == 10)
#define FXAA_QUALITY__PS 3 
static const float s_SampleDistances[FXAA_QUALITY__PS] = { 1.5, 3.0, 12.0 };
#endif

//   FXAA 質量 - 高
#if (FXAA_QUALITY__PRESET == 39)
#define FXAA_QUALITY__PS 12
static const float s_SampleDistances[FXAA_QUALITY__PS] = { 1.0, 1.0, 1.0, 1.0, 1.0, 1.5, 2.0, 2.0, 2.0, 2.0, 4.0, 8.0 };
#endif

其低質量提供了6個子級別,中等質量提供10個子級別,最高質量只有1個子級別。隨著質量的提升,迭代次數增大,用的偏移量也越來越精細。

估算UV的畫素偏移量

接下來計算posB到兩個端點的距離,並找到距離最近的端點。然後我們會計算 到最近端點的距離 與 兩個端點距離的比值,用來決定UV的偏移程度。若離端點越接近,UV的偏移程度越大。

// 分別計算到兩個端點的距離
float distN = horzSpan ? (posM.x - posN.x) : (posM.y - posN.y);
float distP = horzSpan ? (posP.x - posM.x) : (posP.y - posM.y);

// 看當前點到哪一個端點更近,取其距離
bool directionN = distN < distP;
float dist = min(distN, distP);

// 兩端點間的距離
float spanLength = (distP + distN);

// 朝著最近端點移動的畫素偏移量
float pixelOffset = -dist / spanLength + 0.5f;

比如說對某一畫素,負方向端點的距離為2,正方向端點的距離為4,那麼當前畫素離負方向的端點更近,算出來的偏移畫素單位為pixelOffset = -2.0 / (2 + 4) + 0.5 = 0.16666

然後我們需要進行額外的檢查,確保端點計算到的亮度變化和當前畫素的亮度變化一致,否則我們可能步進地太遠了,從而不使用偏移。

// 當前畫素的luma是否小於posB相鄰的兩個畫素的luma的平均值
bool isLumaMSmaller = lumaM < lumaLocalAvg;

// 判斷這是否為一個好的邊界
bool goodSpanN = (lumaEndN < 0.0) != isLumaMSmaller;
bool goodSpanP = (lumaEndP < 0.0) != isLumaMSmaller;
bool goodSpan = directionN ? goodSpanN : goodSpanP;

// 如果不是的話,不進行偏移
float pixelOffsetGood = goodSpan ? pixelOffset : 0.0;

image

可以看到,(lumaM = 0) < (lumaLocalAvg = 0.5)true,且((lumaEndN = 1 - 0.5) < 0.0)false,從而goodSpanN = true。因此可以進行偏移。

image

對於上圖,左端點是因為abs(0.5 - 0.75) >= (gradient = 0.5) * 0.25而停下的。而(lumaM = 0.5) < (lumaLocalAvg = 0.75)true((lumaEndP = 0.5 - 0.75) < 0.0)也為true,從而goodSpanN = false,認為這不是一個好的邊界,就不進行偏移了。

亞畫素抗鋸齒

另一個計算步驟允許我們處理亞畫素走樣。例如非常細的單畫素線段在螢幕上出現的鋸齒。這種情況下,首先我們可以使用下面的運算元來求3x3範圍內,當前畫素的亮度與周圍8畫素的加權平均亮度的變化來反映與周圍的對比度:

image

// 求3x3範圍畫素的亮度變化
//      [1  2  1]
// 1/12 [2 -12 2]
//      [1  2  1]
float subpixNSWE = lumaNS + lumaWE;
float subpixNWSWNESE = lumaNWSW + lumaNESE;
float subpixA = (2.0 * subpixNSWE + subpixNWSWNESE) * (1.0 / 12.0) - lumaM;
// 基於這個亮度變化計算亞畫素偏移量
float subpixB = saturate(abs(subpixA) * (1.0 / lumaRange));
float subpixC = (-2.0 * subpixB + 3.0) * subpixB * subpixB;
float subpix = subpixC * subpixC * g_QualitySubPix;

// 選擇最大的偏移
float pixelOffsetSubpix = max(pixelOffsetGood, subpix);

在只考慮亞畫素偏移量的情況下,亮度變化越大,畫素偏移量也越大。

現在假定g_QualitySubPix = 0.75

image

回到上面這張圖,之前算出的pixelOffset = 0.16666,然後對於亞畫素,subpixA = 2/3 - 1/2 = 1/6subpixB = 1/3subpixC = 7/27subpix = 49/729 * 3/4 = 0.0503。其中兩者的最大值為0.16666,故這裡沒有檢測到亞畫素走樣的問題。

image

至於這張圖,pixelOffset = -2.0 / (2 + 3) + 0.5 = 0.1subpix = 0.411。顯然在這裡檢測到了亞畫素走樣的問題。

最終的讀取

我們以原畫素位置,沿著梯度的方向進行最後的偏移,然後進行最終的紋理取樣,將取樣後的顏色作為當前畫素的最終顏色。模糊後的顏色與梯度方向畫素的顏色與偏移程度有關:

if (!horzSpan)
    posM.x += pixelOffsetSubpix * lengthSign;
if (horzSpan)
    posM.y += pixelOffsetSubpix * lengthSign;
return float4(g_TextureInput.SampleLevel(g_Sampler, posM, 0).xyz, lumaM);

最終模糊的效果大致如下:

image

image

可以看到模糊的程度取決於邊緣的長度及所處的位置,以及亞畫素走樣的情況。

演示

在本示例程式中,我們可以嘗試調整FXAA的相關引數,並結合除錯來檢視哪些畫素會被處理,且取樣偏移程度如何(紅色偏移程度小,從紅到黃到綠偏移程度逐漸變大)。

image

FXAA的一個缺點在於,移動場景的時候我們可以發現部分高頻區域會出現閃爍現象(感受一下“粒子加速器”)。

image

另一個缺點在於,由於FXAA主要是根據對比度來決定當前畫素是否需要處理,對於複雜場景來說,有很多畫素並不是我們想要處理的,卻依然被模糊了。下面展示了低閾值導致的過度模糊問題:

imageimageimage

左:原圖;中:FXAA;右:FXAA除錯

這部分可以通過調參進行控制,但模糊現象也是難以完全避免的。

此外,FXAA也可以跟其它抗鋸齒演算法結合,如本示例提供的MSAA。

總體來看,FXAA 3.11 Quality 對需要模糊的畫素至少得采樣9次,且隨著每次迭代額外增加2次取樣。但對於現在的硬體而言,跑一次的用時不到0.1ms還是比較可觀的。但對電腦使用者而言可能需要效果更好的抗鋸齒演算法,因此FXAA可能更多應用於移動端。在以後的章節(至少不是下一章,目前的每一章可以當做一個獨立的技術專題,並不會有過多的前置依賴)我們可能會探討時間性的抗鋸齒演算法。

參考

DirectX11 With Windows SDK完整目錄

歡迎加入QQ群: 727623616 可以一起探討DX11,以及有什麼問題也可以在這裡彙報。

相關文章