抗锯齿技术之FXAA


  如果要平滑地显示一条直线,理论上需要无限大的分辨率。当前显示器的效果还远远没有达到匹配眼睛分辨率的效果。如果想要在网格屏幕上捕捉一条直线,在直线跨像素时,必然会产生一个极小的阶梯型效果。如果多个阶梯出现在同一个区域,眼睛马上就能发现错误。为了尽可能减少这种跳变的影响,出现了抗锯齿技术(anti-aliasing)。
  当前已经开发出了多种抗锯齿技术。最开始,使用超大分辨率的离屏缓冲渲染图像,再降采样到屏幕上的方法,比如超采样抗锯齿(SSAA)。由于 SSAA 的巨大开销,后续又开发了许多算法来降低性能和内存的成本。这些算法中最著名的是多采样抗锯齿(MSAA),但是 MSAA 在现代的延迟渲染框架中的使用成本是无法忍受的。
  还有一类技术使用先前帧的信息来提升当前帧的质量,这类算法被称为时间抗锯齿(TXAA),个人认为这是抗锯齿的发展方向。但是由于实现复杂,且效果没达到最理想的程度,仍需要进一步的发展。
  现在最热门的抗锯齿算法采用位于后处理的方法,它们采用分析的方法,对需要的部分进行抗锯齿优化,而且完美契合延迟渲染。2009 年 AMD 公司研发出基于 CPU 的形态抗锯齿(MLAA),宣告后处理抗锯齿登上舞台(概念早已存在)。2009 年,Nvidia 公司的 Timothy Lottes 提出了一种简单且有效的抗锯齿算法——快速近似抗锯齿(FXAA)。FXAA 已经被集成到高于 300 的驱动程序版本中,与 FXAA 类似的还有 SMAA,SMAA 比 FXAA 效果更好。SMAA 和 FXAA 原理类似,以后会抽时间实现 SMAA。
  但是,后处理抗锯齿也不能说没有缺点。比如无法正确判断边缘时会反而会带来锯齿,后处理抗锯齿对边缘信息的解析可能会因为一个像素之差而完全不同。因此后处理抗锯齿会恶化甚至引入更多边缘闪烁的现象。更多关于抗锯齿算法的比较与分析请见Intro to Anti-aliasing


FXAA

  FXAA 是一种后处理抗锯齿技术,可以非常简单地添加到已有的渲染框架中:FXAA 将即将渲染的图形作为输入,然后输出消除锯齿的版本。FXAA 依靠边缘检测来消除锯齿,但是也会造成细节的模糊。以下是一个比较:

亮度计算

  FXAA 是基于边缘检测的抗锯齿算法,依赖于像素亮度信息的变化。现在将颜色值转化为亮度有好几个算法。如下所示,这里需要的是亮度的变化,所以任意一个算法都可以使用。为了模拟符合显示器的亮度曲线,有时会增加一个根号来计算 gamma 校正,但这会增加计算量;也有一种方法是直接使用绿色值作为像素的亮度值,因为绿色是亮度值组成最大的部分(所有公式中,都在百分之60左右),而且计算量极低,这也是 Nvidia 官方版本的使用方法。

float rgb2luma(vec3 rgb) {
    return rgb.g; // 使用官方版本
    // return dot(rgb, vec3(0.2126, 0.7152, 0.0722)); // 最流行的亮度计算
    // return dot(rgb, vec3(0.299, 0.587, 0.114)); // 曾经最流行的方法
    // return sqrt(0.299 * rgb.r * rgb.r + 0.587 * rgb.g * rgb.g + 0.114 * rgb.b * rgb.b); // 更精确的计算
    // return sqrt(dot(rgb, vec3(0.299, 0.587, 0.114))); // 添加了 gamma 校正的计算
}

检测 AA 边缘

  glsl 1.3 开始增加了一族新的采样函数 textureGather(),它可以返回采样点周围四个纹素的指定同一个通道的值组成一个 vec4 返回。方位如下所示:


texture_gather

  首先,需要检测边缘,为此,先计算当前纹理位置和周围邻居的亮度值。下面是亮度的定义式,其中 RcpFrame 表示单个纹素的宽度的倒数:

// RcpFrame = 1.0f / textureSize(tex),但这个值是使用 uniform 变量传入的
vec4 luma4A = textureGather(tex, pos);
vec4 luma4B = textureGather(tex, pos, ivec2(-1, -1));
float lumaE = luma4A.z //右侧亮度
float lumaN = luma4A.x //上方亮度
float lumaNE = luma4A.y //右上角亮度 
float lumaSW = luma4B.w //左下角亮度 
float lumaS = luma4B.z //下方亮度
float lumaW = luma4B.x //左侧亮度
float lumM = luma4A.w // 中间亮度
float lumaSE = rgb2luma(textureLodOffset(tex, posM, ivec2(1, -1), RcpFrame.xy)); // 右下方亮度
float lumaNW = rgb2luma(textureLodOffset(tex, posM, ivec2(-1, 1), RcpFrame.xy)); // 左上方亮度

  一次 textureGather 可以取四个点的亮度值,则取两次就可以覆盖中心点右上角和左下角 6 个点的亮度值。至于左上和右下角,直接采样即可。
  然后提取直接五个点的最小和最大亮度,使用两者之间的差异作为一个局部对比。边缘变化越明显,则局部差异越大。然后设定一个阈值,如果差值大于阈值则执行抗锯齿操作。这个阈值通常使用最大亮度的 0.15 倍左右,可以自行调整。如果边缘并不明显,则直接返回正中心的像素取值。

// 阈值与最大亮度系数
#define fxaaQualityEdgeThreshold 0.166
// 最低判断阈值
#define fxaaQualityEdgeThresholdMin 0.0833

// 计算最大最小亮度
float maxSM = max(lumaS, lumaM);
float minSM = min(lumaS, lumaM);
float maxESM = max(lumaE, maxSM);
float minESM = min(lumaE, minSM);
float maxWN = max(lumaN, lumaW);
float minWN = min(lumaN, lumaW);
float rangeMax = max(maxWN, maxESM); // 最大亮度
float rangeMin = min(minWN, minESM); // 最小亮度
float rangeMaxScaled = rangeMax * fxaaQualityEdgeThreshold; // 如果亮度较高,则上阈值生效
float range = rangeMax - rangeMin;
float rangeMaxClamped = max(fxaaQualityEdgeThresholdMin, rangeMaxScaled); // 如果亮度较低,则设定的固定下阈值生效
bool earlyExit = range < rangeMaxClamped;
if(earlyExit)
    return textureLod(t, p, 0.0); // 返回中心像素

边缘检测

  当前,中心点周围的像素都是边缘的一部分,但仍然需要检查边缘的方向。关于边缘检测可以参考:sobel 算子计算梯度。系数有所不同,因为这里必须考虑中心像素点的影响,而且在这里不在乎水平边缘具体指哪个边缘,通过测试效果不错。

float lumaNS = lumaN + lumaS;
float lumaWE = lumaW + lumaE;
float edgeHorz1 = (-2.0 * lumaM) + lumaNS;
float edgeVert1 = (-2.0 * lumaM) + lumaWE;
float lumaNESE = lumaNE + lumaSE;
float lumaNWNE = lumaNW + lumaNE;
float edgeHorz2 = (-2.0 * lumaE) + lumaNESE;
float edgeVert2 = (-2.0 * lumaN) + lumaNWNE;
float lumaNWSW = lumaNW + lumaSW;
float lumaSWSE = lumaSW + lumaSE;
float edgeHorz3 = (-2.0 * lumaW) + lumaNWSW;
float edgeVert3 = (-2.0 * lumaS) + lumaSWSE;
// 整合
float edgeHorz4 = (abs(edgeHorz1) * 2.0) + abs(edgeHorz2);
float edgeVert4 = (abs(edgeVert1) * 2.0) + abs(edgeVert2);
float edgeHorz = abs(edgeHorz3) + edgeHorz4;
float edgeVert = abs(edgeVert3) + edgeVert4;

选择边缘方向

  这一步是确定边缘的方向,方向只有两种选择:水平或者竖直。然后计算垂直边缘方向的梯度,因为梯度越大说明变化越剧烈,对这一侧进行抗锯齿可以达到很好的效果。之所以没有对两边都进行抗锯齿操作,是因为梯度较小的那一侧不是那么明显,FXAA 会对边缘增加一定的模糊,这么做也是在进行抗锯齿过程中减少模糊的妥协

bool horzSpan = edgeHorz >= edgeVert;
float luma1 = horzSpan ? lumaS : lumaW;
float luma2 = horzSpan ? lumaN : lumaE;
float lengthSign = horzSpan ? RcpFrame.y : RcpFrame.x; // 记录像素偏移量和方向
float gradient1 = luma1 - lumaM;
float gradient2 = luma2 - lumaM;
bool gradientBigger1 = abs(gradient1) >= abs(gradient2);
float gradient = max(abs(gradient1), abs(gradient2)); // 这是一个绝对值
if(gradientBigger1) lengthSign = -lengthSign;

  当前纹理坐标位置还在中心像素点内,所以要增加一个 0.5 像素的偏移,保证纹理坐标在边缘不超过 0.5 像素的位置。

vec2 posB = pos;
if(!horzSpan) posB.x += lengthSign * 0.5;
if( horzSpan) posB.y += lengthSign * 0.5;

第一次迭代

  只发现一个像素的边缘是不够的,必须要向边缘的两个方向进行探索。第一次迭代每次探索一个像素的宽度,然后在新坐标处的亮度值和上一步得到的平均亮度进行比较。在这里会定义一个局部梯度,这个局部亮度一般设定为获得亮度的 0.25 倍,也是一个经验值。如果差值大于局部梯度,就说明已经到达这个方向的边缘,就跳出迭代。FXAA_QUALITY__P0 表示一次迭代跨越的像素数量。

// 计算第一次迭代的位置
vec2 offNP; // 偏移值记录
offNP.x = (!horzSpan) ? 0.0 : fxaaQualityRcpFrame.x;
offNP.y = ( horzSpan) ? 0.0 : fxaaQualityRcpFrame.y;
vec2 posN;
posN.x = posB.x - offNP.x * FXAA_QUALITY__P0;
posN.y = posB.y - offNP.y * FXAA_QUALITY__P0;
vec2 posP;
posP.x = posB.x + offNP.x * FXAA_QUALITY__P0;
posP.y = posB.y + offNP.y * FXAA_QUALITY__P0;
// 计算第一次迭代的亮度
float lumaEnd1 = rgb2luma(TextureLod(tex, posN, 0.0));
float lumaEnd2 = rgb2luma(TextureLod(tex, posP, 0.0));
// 比较是否满足跳出条件
float lumaAverage1 = (luma1 + lumaM) * 0.5f;
float lumaAverage2 = (luma2 + lumaM) * 0.5f;
float lumAverage = gradientBigger1 ? lumaAverage1 : lumaAverage2; 
lumaEndN -= lumAverage;
lumaEndP -= lumAverage;
float gradientScaled = gradient * 0.25; // 参考量
bool doneN = abs(lumaEndN) >= gradientScaled; // 如果偏移得到的亮度在平均亮度周围轻微波动,就可以继续进行迭代,否则跳出
bool doneP = abs(lumaEndP) >= gradientScaled;
bool bothDone = doneN && doneP;

继续迭代

  持续迭代,直到找到边缘结尾,或者最大迭代次数。为了加快速度,FXAA_QUALITY__P(i) 在五次迭代之后,会逐步增加像素数量。如果性能实在紧缺,可以提前设置减少最大迭代次数,Nvidia 定义了 12 个值,如下所示:

#define FXAA_QUALITY__PS 12 // 迭代次数
#define FXAA_QUALITY__P0 1.0
#define FXAA_QUALITY__P1 1.0
#define FXAA_QUALITY__P2 1.0
#define FXAA_QUALITY__P3 1.0
#define FXAA_QUALITY__P4 1.0
#define FXAA_QUALITY__P5 1.5
#define FXAA_QUALITY__P6 2.0
#define FXAA_QUALITY__P7 2.0
#define FXAA_QUALITY__P8 2.0
#define FXAA_QUALITY__P9 2.0
#define FXAA_QUALITY__P10 4.0
#define FXAA_QUALITY__P11 8.0

  有了值,就可以循环迭代,如下所示:

if(!bothDone) {
    if(!doneN) lumaEndN = FxaaLuma(FxaaTexTop(tex, posN.xy));
    if(!doneP) lumaEndP = FxaaLuma(FxaaTexTop(tex, posP.xy));
    if(!doneN) lumaEndN = lumaEndN - lumaAverage * 0.5;
    if(!doneP) lumaEndP = lumaEndP - lumaAverage * 0.5;
    doneN = abs(lumaEndN) >= gradientScaled;
    doneP = abs(lumaEndP) >= gradientScaled;
    if(!doneN) posN.x -= offNP.x * fxaa_quality(i);
    if(!doneN) posN.y -= offNP.y * fxaa_quality(i);
    doneNP = (!doneN) || (!doneP);
    if(!doneP) posP.x += offNP.x * fxaa_quality(i);
    if(!doneP) posP.y += offNP.y * fxaa_quality(i);
    bothDone = doneN && doneP;
}

计算偏移量

  按照上面的迭代,会得到两个边缘的极值。那就可以用来计算边缘长度、近端边缘对总长度的比值,这个比值量可以用来判断中间像素在边缘的位置是中间还是末端。因为越接近末端,纹理坐标的偏移量就越大。这里,只对靠近末端的一侧进行模糊。

float dstN = posM.x - posN.x;
float dstP = posP.x - posM.x;
if(!horzSpan) dstN = posM.y - posN.y;
if(!horzSpan) dstP = posP.y - posM.y;
bool goodSpanN = (lumaEndN < 0.0) != lumaMLTZero; // 剔除掉中心像素亮度低的情况
float spanLength = (dstP + dstN);
bool goodSpanP = (lumaEndP < 0.0) != lumaMLTZero;
float spanLengthRcp = 1.0 / spanLength;

bool directionN = dstN < dstP;
float dst = min(dstN, dstP);
bool goodSpan = directionN ? goodSpanN : goodSpanP;
float pixelOffset = (dst * (-spanLengthRcp)) + 0.5; // 肯定大于0

  考虑一种情况,如果中间像素的亮度比周围的亮度小,说明中间像素很大可能是一个边缘的末端。那么靠近末端的计算可能会出错,这时候需要剔除这种情况。

子像素抗锯齿

  当栅格化后的物体小于一像素时,就产生了子像素失真。子像素失真最常见于非常细小的物体,例如场景中的塔尖、电话线或电线,甚至是距离屏幕足够远的一把剑。虽然这类失真也可以算是几何失真的一种,但在抗锯齿算法的设计中需要被特殊对待。如下图所示,太细的直线会被出现像素间隔。


subpixel_aa

  在这些情况下,在3×3邻域上计算平均亮度。在减去中心亮度并从第一步除以亮度范围之后,这给出了子像素偏移。平均值和中心值之间的对比度差异越小,与整个邻域范围相比,区域越均匀(即没有单个像素点),偏移越小。然后选择 offset 的最大值即可。关于子像素抗锯齿可以关注Intro to Anti-aliasing,博主也还在学习中。

// 计算 3x3 的总体平均值
float lumaAverage3x3 = (1.0 / 12.0) * (2.0 * (lumaNS + lumaWE) + lumaNESE + lumaNWNE + lumaNWSW + lumaSWSE);

float subPixelOffset1 = clamp(abs(lumaAverage3x3 - lumaM) / range,0.0,1.0);
float subPixelOffset2 = (-2.0 * subPixelOffset1 + 3.0) * subPixelOffset1 * subPixelOffset1;
float fxaaQualitySubpix = 0.75;
float subPixelOffsetFinal = subPixelOffset2 * subPixelOffset2 * fxaaQualitySubpix;

// Pick the biggest of the two offsets.
finalOffset = max(finalOffset,subPixelOffsetFinal);

FXAA 完成

  最后在边缘正交的方向上得到偏移量,最后读取采样点位置。这里,纹理一定要设定为线性采样,否则前面所有的工作都白费了。

if(!horzSpan) posM.x += finalOffset * lengthSign;
if( horzSpan) posM.y += finalOffset * lengthSign;

return vec4(textureLod(tex, posM, 0.0).rgb, 1.0);

  这是一张检测边缘的图,看起来准确度还是不错的。


fxaa_lines

最后

  这个例子思想很简单,但是结果难以证明。但是关键可以用较小的代价得到比较好效果,即使会造成渲染界面变得模糊,也不妨碍 FXAA 深受喜爱。不仅有硬件支持,各大游戏也都是建议开启 FXAA。
  最后,吐槽一下。写博文真是累人的事情,在材料都准备好,并且有所参考的情况下还花了一整天加一个下午。以后这种技术还是避免贴代码,毕竟重点在实现原理嘛。这个算法已经被写入我的个人渲染器——gleam:sample12_FXAA,欢迎点击。


Nvidia 的官方代码请参考:NVIDIA FXAA 3.11 by TIMOTHY LOTTES
Nvidia 的 FXAA 白皮书:FXAA_WhitePaper

About the Author

发表评论

您的电子邮箱地址不会被公开。 必填项已用*标注