DirectX11 With Windows SDK–40 抗鋸齒:FXAA
- 2022 年 5 月 31 日
- 筆記
- DirectX 11, DirectX11 With Windows SDK, DirectX11 高級篇, Windows SDK
前言
在默認的情況下渲染,會看到物體的邊緣會有強烈的鋸齒感,究其原因在於取樣不足。但是,嘗試提升取樣的SSAA會增大渲染的負擔;而硬體MSAA與延遲渲染又不能協同工作。為此我們可以考慮使用後處理的方式來進行抗鋸齒的操作。在這一章中,我們將會討論一種常見的後處理抗鋸齒方法:FXAA。
DirectX11 With Windows SDK完整目錄
歡迎加入QQ群: 727623616 可以一起探討DX11,以及有什麼問題也可以在這裡彙報。
FXAA
FXAA(Fast approXimate AntiAliasing) 抗鋸齒演算法是由NVIDIA的Timothy Lottes開發的,核心思想是從影像中分析出哪些像素屬於邊緣,然後嘗試找出邊緣的長度,並根據像素所處邊緣的位置對其進行抗鋸齒處理。
未開抗鋸齒
FXAA
作為一種後處理抗鋸齒方法,它可以很方便地加入到你的程式當中,只需要一個全螢幕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:
\]
判斷當前像素是否需要應用AA
現在我們先只考慮求出當前像素和與它直接相鄰的四個像素的亮度。找到其中的最大值與最小值,這兩個值的差可以得到局部對比度。當小於一個與最大亮度成正比關係的閾值時,當前像素不會執行抗鋸齒。此外,我們也不希望在暗部(如陰影)區域進行抗鋸齒的操作,如果局部對比度小於一個絕對的閾值時,也不會執行抗鋸齒操作。這時候我們就可以提前輸出該像素的顏色。
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_qualityEdgeThreshold
和g_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
由於我們只分為水平和豎直邊界,對這種情況我們也先歸類到其中一種情況後續再處理
計算梯度、確定邊界方向
現在我們只是知道了屬於邊界的類型,還需要確定邊界的過渡是怎樣的,比如對水平邊界來說有兩種情況:
我們可以求上方向和下方向的梯度,找到變化絕對值最大的作為該像素的梯度。
//
// 計算梯度、確定邊界方向
//
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。
//
// 當前像素沿梯度方向移動半個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];
對上圖來說,紅框處算出的gradiantScaled = 0.25
,lumaEndN = 0.5 - 0.5 = lumaEndP = 0.0 < gradiantScaled
(由於使用的是雙線性插值,lumaEndN
和lumaEndP
經過插值後的結果為0.5),因此我們可以繼續往兩邊遍歷。
繼續遍歷
假設存在一個點沒有到達邊緣一側,我們就繼續執行遍歷。左側的點在進行第二次步進後,算出的lumaEndN = abs(0 - 0.5) = 0.5 > gradiantScaled
,說明此時已經到達邊緣一側,左側的點可以停下,右側的點則可能要經過多次步進才停下。假設每次都是以1個texel的單位步進(s_SampleDistances
的元素都為1.0
),這時候的狀態可能為:
// 繼續迭代直到兩邊都到達邊緣的一側,或者達到迭代次數
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;
可以看到,(lumaM = 0) < (lumaLocalAvg = 0.5)
為true
,且((lumaEndN = 1 - 0.5) < 0.0)
為false
,從而goodSpanN = true
。因此可以進行偏移。
對於上圖,左端點是因為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
,認為這不是一個好的邊界,就不進行偏移了。
亞像素抗鋸齒
另一個計算步驟允許我們處理亞像素走樣。例如非常細的單像素線段在螢幕上出現的鋸齒。這種情況下,首先我們可以使用下面的運算元來求3×3範圍內,當前像素的亮度與周圍8像素的加權平均亮度的變化來反映與周圍的對比度:
// 求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
。
回到上面這張圖,之前算出的pixelOffset = 0.16666
,然後對於亞像素,subpixA = 2/3 - 1/2 = 1/6
,subpixB = 1/3
,subpixC = 7/27
,subpix = 49/729 * 3/4 = 0.0503
。其中兩者的最大值為0.16666
,故這裡沒有檢測到亞像素走樣的問題。
至於這張圖,pixelOffset = -2.0 / (2 + 3) + 0.5 = 0.1
,subpix = 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);
最終模糊的效果大致如下:
可以看到模糊的程度取決於邊緣的長度及所處的位置,以及亞像素走樣的情況。
演示
在本示常式序中,我們可以嘗試調整FXAA的相關參數,並結合調試來查看哪些像素會被處理,且取樣偏移程度如何(紅色偏移程度小,從紅到黃到綠偏移程度逐漸變大)。
FXAA的一個缺點在於,移動場景的時候我們可以發現部分高頻區域會出現閃爍現象(感受一下「粒子加速器」)。
另一個缺點在於,由於FXAA主要是根據對比度來決定當前像素是否需要處理,對於複雜場景來說,有很多像素並不是我們想要處理的,卻依然被模糊了。下面展示了低閾值導致的過度模糊問題:
左:原圖;中:FXAA;右:FXAA調試
這部分可以通過調參進行控制,但模糊現象也是難以完全避免的。
此外,FXAA也可以跟其它抗鋸齒演算法結合,如本示例提供的MSAA。
總體來看,FXAA 3.11 Quality 對需要模糊的像素至少得取樣9次,且隨著每次迭代額外增加2次取樣。但對於現在的硬體而言,跑一次的用時不到0.1ms還是比較可觀的。但對電腦用戶而言可能需要效果更好的抗鋸齒演算法,因此FXAA可能更多應用於移動端。在以後的章節(至少不是下一章,目前的每一章可以當做一個獨立的技術專題,並不會有過多的前置依賴)我們可能會探討時間性的抗鋸齒演算法。
參考
DirectX11 With Windows SDK完整目錄
歡迎加入QQ群: 727623616 可以一起探討DX11,以及有什麼問題也可以在這裡彙報。