DirectX11 With Windows SDK--40 抗锯齿:FXAA
前言
在默认的情况下渲染,会看到物体的边缘会有强烈的锯齿感,究其原因在于采样不足。但是,尝试提升采样的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
,认为这不是一个好的边界,就不进行偏移了。
亚像素抗锯齿
另一个计算步骤允许我们处理亚像素走样。例如非常细的单像素线段在屏幕上出现的锯齿。这种情况下,首先我们可以使用下面的算子来求3x3范围内,当前像素的亮度与周围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,以及有什么问题也可以在这里汇报。