DirectX11 With Windows SDK--40 抗锯齿:FXAA

前言

在默认的情况下渲染,会看到物体的边缘会有强烈的锯齿感,究其原因在于采样不足。但是,尝试提升采样的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,以及有什么问题也可以在这里汇报。

posted @ 2022-05-31 00:35  X_Jun  阅读(1609)  评论(1编辑  收藏  举报
levels of contents