Skip to content
zilch edited this page May 18, 2021 · 1 revision

FXAA算法演义

如要研究图像抗锯齿算法,FXAA自然是逃不开的其中之一。FXAA由Nvidia的Timothy Lottes在2009年提出,经过若干时间演化,到目前的版本为FXAA3.11。本文将主要依据[1] Timothy Lottes, FXAA White Paper, 2009[2] SIGGRAPH 2011 presentation on FXAA 3.11,然后再参考若干网上的文章([3])来深入分析FXAA算法。

由于FXAA算法在发展过程中衍生出了诸多变种版本,本文将着重分析其中的3个版本,从中我们可以窥出FXAA在变化过程中关于图像质量与算法性能的取舍之道。顺带,尝试揣度一下原作者在推演这个算法时的心路历程。

本文将包含以下内容:

  • FXAA的大致思路
  • FXAA初代算法
  • FXAA3.11 - Quality 质量版
  • FXAA3.11 - Console 主机版(性能版)
  • FXAA相关公式背后的思考与意义
  • Timothy Lottes 的心路历程(伪)

本文将会使用Unity SRP实现上述的算法,但文中仅会放一点关键的Shader代码,助于理解。

1. FXAA思路简介

FXAA拥有一种很朴素的算法思想。 它的思路很简单,既然我们要进行图像抗锯齿,那总得区分出来哪些像素是锯齿,哪些像素不是。根据图像锯齿的成因和日常的经验我们知道,它通常出现在与背景呈现高对比度的物体边缘(视觉比较明显)。

如下图所示(看最左图就好了)

在观察了一些常见的图像锯齿之后,Timothy就想,那我是不是可以通过计算像素和其周围像素的亮度对比,来判定一个像素是不是边缘像素呢?针对非边缘的像素,我们什么都不做,而对于边缘像素,我们可以让其和周围的像素进行Blend混合,从而起到模糊的效果。这样一来,不就达到抗锯齿了吗?Good Idea!

所以FXAA算法的核心思路就两点:

  1. 边缘判定算法
  2. 边缘像素的混合因子计算

诸多变种,无非就是在这两点上做文章。

2. FXAA初代目

算法取自Timothy于2009发布的FXAA White Paper,暂且认为是初代目吧。

2.1 边缘检测算法

FXAA1.0使用的边缘检测算法如下:

  • 读取上下左右4个方向 + 自身的像素亮度
  • 筛选出其中的最大值lumaMax和最小值lumaMin
  • 令对比度lumaContrast = lumaMax - lumaMin
  • 当对比度超过一定阈值时,便认为当前像素为边缘像素

这个思路其实非常的朴素。一般人都能理解,就算没接触过相关Paper,自己捣鼓一下应该也是能想到的。基于这个思路,我们尝试写下第一段代码。相关的字母意思如下:

  • N - 北即上
  • S - 南即下
  • W - 西即左
  • E - 东即右
  • M - 中即自身

图示如下:

对比度计算如下:

//采集uv的上下左右中共计5个像素的RGB和亮度
FXAACrossData cross = SampleCross(tex,uv,offset);
//计算对比度
half lumaMinNS = min(cross.N.a,cross.S.a);
half lumaMinWE = min(cross.W.a,cross.E.a);
half lumaMin = min(cross.M.a,min(lumaMinNS,lumaMinWE));
half lumaMaxNS = max(cross.N.a,cross.S.a);
half lumaMaxWE = max(cross.W.a,cross.E.a);
half lumaMax = max(cross.M.a,max(lumaMaxNS,lumaMaxWE));
half lumaContrast = lumaMax - lumaMin;

为了测试这段代码,我们搭建一个场景,往场景中丢一根棍子,将画面放大10倍后,可以看到棍子边缘出现经典的锯齿。

将前面计算出的lumaContrast直接作为颜色返回,可以看到画面变成如下样子:

白色为高对比度像素,黑色为低对比度像素,物件的边缘被很好的勾勒了出来。接下来我们给定一个对比度阈值,当lumaContrast大于该阈值时,便认为是边缘像素。简单的代码示意如下:

#define FXAA_ABSOLUTE_LUMA_THRESHOLD 0.05
bool isEdge = lumaContrast > FXAA_ABSOLUTE_LUMA_THRESHOLD;

然后我们把边缘像素描红返回,效果如下:

可以看到该算法将棍子投下的阴影渐变区域也识别为了边缘。

这也是FXAA的为人诟病的缺点之一,即只要是对比度高的像素,它都视作边缘像素进行处理。这可能会使图像丢失一些局部高频信息,使得画面不够锐利。

为了缓解这个问题,FXAA1.0在阈值判断这里额外加入一个修正参数FXAA_RELATIVE_LUMA_THRESHOLD,其关系如下:

float edgeThreshold = max(FXAA_ABSOLUTE_LUMA_THRESHOLD,lumaMax * FXAA_RELATIVE_LUMA_THRESHOLD);
bool isEdge = lumaContrast > edgeThreshold;

用中文来表示就是

最终阈值 = max(绝对阈值, lumaMax * 相对阈值比例)

这个修正带来以下效果:

  • 明亮的地方需要更高的周边对比度才能被判定为边缘

2.2 计算混合因子

对于非边缘像素,我们什么都不做,原样返回颜色即可。这个叫做Early Exit

对于边缘像素,接下来要让其和周边像素进行混合,以起到模糊边缘的效果。这里有两个问题需要解决:

  • 上下左右4个方向,到底与哪个方向混合?
  • 混合的比例是多少?

2.2.1 边缘横纵判定

针对第一个问题,我们首先判定该像素所在的边缘到底横向还是纵向。有的人就问了,那斜着的边呢?实际上斜边是宏观上的视觉,当我们从像素的微观角度去看时,像素只有三种情况:

  • 在横边上
  • 在纵边上
  • 在角上

如上图,1在纵边,2在横边,3在角上。

Timothy心想,这可太简单了,我只要计算上下两个像素的亮度差以及左右两个像素的亮度差,如果前者大,那就是横边,反之则为纵边。如果一样呢?那先随便吧。伪代码如下:

float lumaGradV = abs(lumaN - lumaS); 
float lumaGradH = abs(lumaE - lumaW);
bool isHorz = lumaGradV > lumaGradH;

但随即他就发现这种方式有个缺陷,即对于单像素的线,无法正确判定其走向。参考下图情形:

对于1位置的像素,lumaGradV将与lumaGradH相等。为了解决这个问题,Timothy对以上算法做了些许的改进如下。

首先计算像素在S、N、W、E 4个方向的亮度变化梯度:

float lumaGradS = lumaS - lumaM;
float lumaGradN = lumaN - lumaM;
float lumaGradW = lumaW - lumaM;
float lumaGradE = lumaE - lumaM;

然后对垂直和水平方向的梯度分别相加,取绝对值,比较它们的大小:

float lumaGradV = abs(lumaGradS + lumaGradN);
float lumaGradH = abs(lumaGradW + lumaGradE);
bool isHorz = lumaGradV > lumaGradH;

这样就成功的解决单像素线的问题。

(实际上2009 White Paper里还考虑了对角线的4个像素,但这里我们暂且不提)

2.2.2 边缘法线计算

成功判定了边缘像素横纵状态后,我们接下来需要计算其法线朝向。

看上图,对于像素1,其边缘法向应当是朝左。对于像素2,其边缘法线则是朝上。边缘法线即表征了目标混合像素的方向。

这个就简单了,哪个方向的梯度大,法线就朝哪边。于是我们有:

float2 normal = float2(0,0);
if(isHorz){
    normal.y = sign(abs(lumaGradN) - abs(lumaGradS));
}else{
    normal.x = sign(abs(lumaGradE) - abs(lumaGradW));
}

(normal + 1) * 0.5作为颜色输出到画面,我们有如下效果:

4种颜色分别代表了边缘法线N、S、E、W4个朝向。

2.2.3 混合因子计算

现在混合方向有了,接下来就要计算混合因子了。咱们不管三七二十一,先填个0.5看看吧。

不出所料,效果很糟糕。不仅物体边缘如马赛克一般,好好的阴影渐变也被整的一塌糊涂。

仔细思考一下,我们期望的混合因子应当符合以下两个要素:

  • 需要在"高对比度的像素之间"构造出渐变。例如lumaN和lumaS对比度很高,那么lumaM就应该将自己修正为接近 (lumaN + lumaS) / 2。
  • 不破坏像素之间正常的渐变关系。(例如上图中的软阴影)

于是Timothy首先对NSEW4个方向的像素亮度求平均,计算出中间像素的期望亮度:

half lumaL = (lumaN + lumaS + lumaE + lumaW) * 0.25;

然后考察中间像素的实际亮度与期望亮度之差:

half lumaDeltaML = abs(lumaM - lumaL);

这个差值如果是0,说明当前的中间像素亮度已经完美符合,不用任何修改。否则,令

float blend = lumaDeltaML / lumaContrast;

lumaContrast在前面已经说过,是N、S、W、E、M,5个像素中最大亮度-最小亮度。易知,blend范围位于0~1

最终颜色采样代码如下:

half4 finalColor = SampleLinear(tex,pixelCoord + normal * blendL);

以上公式意味着:

中间像素亮度(lumaM)与期望亮度(lumaL)的差值越大,混合因子(blend)越趋向1,也即中间像素的颜色越往其亮度的最大梯度方向(normal)偏移

改进混合因子后,抗锯齿效果如下图:

软阴影部分完全没受到影响,物体边缘也呈现出了模糊渐变效果。整体上来看还算OK的,但也可以发现一些不足之处:

  • 物体边缘有些"过于"模糊了(相比较于MSAA而言)。

这个不难理解。因为如我们前面所述的边缘查找算法里,边缘像素是呈"一对"存在的。

1,2在黑色阵营里是边缘像素,那么1,2normal朝向的白色像素,在白色阵营里自然也属于边缘像素。

经过Blend之后,边缘渐变至少会覆盖两个像素宽度,于是就显得有些"厚"。

但看在FXAA1.0如此廉价的份上,这个缺点我们暂且接受了。

Timothy兴致勃勃的发布了这第一个版本,不出所料,有人抱怨FXAA在抗了锯齿的同时,让图像变糊了。 玩家才不管你廉不廉价,你的画面但凡有点瑕疵,他们都能挑的出毛病来。 于是Timothy紧急之下,先加进去两个参数缓一缓:

float blend = max(0,lumaDeltaML / lumaContrast - FXAA_QUALITY_SUBPIX_TRIM) * FXAA_QUALITY_SUBPIX_CAP;
  • FXAA_QUALITY_SUBPIX_TRIM 用来把混合因子整体往0靠靠,这样边缘就不会那么的糊了。
  • FXAA_QUALITY_SUBPIX_CAP 用来把混合因子进行一个整体的scale(0.x),也是为了不那么糊,至少保留住一点高频信息吧。

但我觉得以上两个参数实际效果还是比较弱的,属于"反正参数都给你了,你自己调吧"系列。

到此为止,FXAA初代目就完成了。5次纹理采样 + 不算复杂的计算,我们就得到了一个廉价的全屏后处理抗锯齿技术。

下面说说FXAA的最佳使用规范:

  • 最好在完成所有后处理效果之后再执行FXAA
  • 最好在sRGB空间执行
  • 需要在LDR空间执行

这些规范不难理解。因为FXAA图像抗锯齿是基于"视觉的"而不是基于物理的。因此在我们进行亮度计算时,必须时基于"视觉"的亮度,而不是"物理"的亮度。另一方面,HDR到LDR的ToneMap不是线性的,所以即便在HDR空间中执行FXAA,ToneMap后也会失效。

3. FXAA3.11 Quality

打自2009年发布了FXAA初代目,Timothy就对其存在的一些瑕疵耿耿于怀。作为一个精益求精的人,他潜心研究,于是在两年后的SIGGRAPH上发布了FXAA 3.11

FXAA3.11与FXAA初代目相比,最大的不同就是改进了混合因子算法。Timothy觉得,要真正解决边缘模糊"太厚"的问题,就要深入分析锯齿产生的本质原因。

我们知道,几何物体的边缘锯齿是由于采样不足而引起的。如上图所示,一个采样点,如果落入几何内部,那么整个像素就都是红色。如果落到几何体外部,那么整个像素都是蓝色。 SSAA和MSAA是通过在一个像素内部增加额外的采样点,然后进行颜色混合达到抗锯齿目的的。

不妨思考一下,如果我们在一个像素内部生成无穷的采样点,那么最终混合的颜色应当怎么计算呢?

一根线,穿过一个像素方块,将其劈成两半。一半归入蓝色阵营,一半归入红色阵营。很明显,我们可以将两部分面积分别作为蓝色和红色的权重,进行加权平均计算,最终得到混合色。

当我们观察锯齿的一段局部线条区域时:

容易发现,红色面积权重是随着线条方向(朝右)逐渐减小的。当红色权重少于0.5时,自然像素就翻转成蓝色了。

基于这个推理,Timothy心生一计,发明了边缘搜索算法。原理如下:

  • 首先确定边缘像素的横纵向
  • 往边缘两侧按照给定的步长进行边缘终点搜索
  • 确定了边缘线段的两个端点后,可以计算出当前像素在线段中的位置
  • 根据像素在线段中所处的位置来计算出混合因子

于是以上算法归纳出两个待解决的问题:

  1. 如何判定搜索的目标像素为边缘终点?
  2. 边缘上每个点的位置与混合因子满足怎样的关系?

3.1 边缘终点判定

首先我们可以将搜索的起始点定为像素往normal方向偏移0.5个单位的位置。如下图:

使用线性插值采样,那么采样结果将是边缘两侧像素的平均亮度。不如将起始点亮度记为lumaStart。假设我们往两侧一个个像素进行判定,直到发现亮度与lumaStart的差值超过一定阈值,便认为到头了。

那么这个阈值多少合理呢?写死肯定是不合理的。它应当跟搜索起始点两侧像素的对比度(lumaStartContrast)呈正相关。Timothy给出的估计为:

float searchEndThreshold = lumaStartContrast * 0.25

0.25只是一个经验性的系数。

有人问Timothy,为什么要往normal方向偏移0.5个像素进行采样呢?Timothy微微一笑说,假如我们不进行偏移,比如直接从黑色像素中心往两边搜索,那么左侧很显然无法查找到正确的端点。

所谓边缘,是由边缘两侧的像素对比所产生的。在搜索的时候一定要同时考虑边缘两侧的像素。

这时候又有人问,那为什么不在搜索的通过计算两侧的亮度差来确认终点,而是要计算两侧的平均亮度呢? Timothy说你仔细想想,当然是为了少一次采样啊。计算差要采样两个像素做减法,而我用平均,可以直接使用Bilinear Filter一次采样足矣。

3.2 混合因子计算

如我们前面所述:

当我们观察锯齿的一段局部线条区域时,容易发现,红色面积权重是随着线条方向(朝右)逐渐减小的。当红色权重少于0.5时,自然像素就翻转成蓝色了。

边缘像素的锯齿翻转,均是其中一方面积权重越过0.5时发生。为了完美拟合这种现象,我们需要将边缘终点处的混合因子设为0.5

因此对于一条锯齿化边缘线,我们是可以假设几何体的真实边缘正好穿过线段终点像素外侧边的中点,如下图所示,蓝框为我们考察的锯齿边缘区域,黄色线条为我们预测的真实几何体边缘。

于是从图中很明显可以看出,锯齿边缘线的中点处,权重值正好为1。从边缘线的黑色一侧看来,混合因子应当从中点处开始,向右侧从1渐变到0.5。

这里我们还能给出另外两种锯齿形态:

依旧从黑色像素一侧来看:

  • 第二种锯齿形态情况下,混合因子应当从中点开始同时向两侧渐变到0.5。
  • 第三种形态,则黑色一侧无需渐变。

从以上的几种情况总结下来,混合因子的计算方式已经呼之欲出了:

  • 设边缘线长为edgeLength(可以通过搜索到的两个端点位置相减得到)
  • 计算出距离当前像素比较近的一个端点,记为targetP,距离记为dst
  • 考察targetP与当前像素是否属于同一阵营。
  • 如果是,则blend为0,即当前像素保持原因,不进行混合。
  • 如果否,则blend = abs(0.5 - dst/edgeLength)

这样这个Blend的变化就完美符合了我们前面的理论推理。

那么如何考察边缘端点与当前像素是否属同一阵营呢?

不妨假设搜索结束时,已经得到距离当前像素较近的边缘端点亮度为lumaEnd。我们知道,搜索起始点的亮度lumaStart为两个阵营的平均亮度。

因此可以将当前像素亮度(lumaM)与阵营平均亮度做比较: lumaM - lumaStart

同时将端点亮度(lumaEnd)也与阵营平均亮度做比较: lumaEnd - lumaStart

如果两者符号一致,则属于同一阵营,否则属于不同阵营。于是blend计算公式如下:

float blend;
if((lumaM - lumaStart) * (lumaEnd - lumaStart) > 0){
    blend = 0;
}else{
    blend = 0.5 - dst/edgeLength;
}

3.3 搜索步长优化

在进行边缘搜索时,一个个像素搜索是不现实的,我们需要一个策略来将采样次数控制到限定范围内。很容易想到的一种方式是,给定一个最大搜索次数MAX_EDGE_SEARCH_SAMPLE_COUNT,然后在搜索时逐渐增大步长。

FXAA3.11 Quality原算法里提供了很多组预设,可以参考GitHub上的源码

例如Preset12的定义如下

#define FXAA_MAX_EAGE_SEARCH_SAMPLE_COUNT 5
static half edgeSearchSteps[FXAA_MAX_EAGE_SEARCH_SAMPLE_COUNT] = {1,1.5,2,4,12};

意思是最多执行5步搜索(每步同时往两个方向进行采样,最多会有10次采样),edgeSearchSteps数组定义了每步搜索的步长。自然给予的最大搜索步骤越多、步长越小,边缘的渐变就能更细腻。 通过使用不同的参数,我们可以在图像质量与性能之间寻求平衡。

到此为止, Timothy觉得自己应该是大功告成了。他把预设12的参数输进去,一跑,发现结果如下:

水平边缘的抗锯齿效果很不错,边缘渐变层足够"薄",一举解决了FXAA初代目令边缘太模糊的问题。

可是垂直方向却出现了一些瑕疵点。这是怎么回事呢?

3.4 额外对角线采样

打开边缘法线调试效果,如下:

原来垂直向的噪点是由于混合方向计算不正确引起的。仔细深入分析了一下,Timothy终于找到了原因。在早先我们计算边缘法线的时候,只采样了N,S,E,W4个方向的像素亮度。再看一下当时的计算方式:

float2 normal = float2(0,0);
if(isHorz){
    normal.y = sign(abs(lumaGradN) - abs(lumaGradS));
}else{
    normal.x = sign(abs(lumaGradE) - abs(lumaGradW));
}

如果仅有这4个方向,不足以分析如下的情况:

考察像素1,其lumaGradN(上)lumaGradE(右)梯度相同,lumaGradS(下)lumaGradE(左)梯度相同,因此假如只考虑上下左右4个方向的像素亮度,我们是无法计算出像素1的边缘法线的,这时候就要额外考虑其4个对角线像素的亮度。

于是Timothy将边缘法线计算公式修改如下:

lumaGradH = abs(lumaNW + lumaNE - 2 * lumaN) //横-上
+ 2 * abs(lumaW + lumaE - 2 * lumaM) //横-中
+ abs(lumaSW + lumaSE - 2 * lumaS); //横-下

lumaGradV = abs(lumaNW + lumaSW - 2 * lumaW) //纵-左
+ 2 * abs(lumaN + lumaS - 2 * lumaM) //纵-中
+ abs(lumaNE + lumaSE - 2 * lumaE); //纵-右

意思的在水平方向,我们同时计算第一行,第二行和第三行的梯度,对应纵向是第一、二、三列,将他们加权求和后进行对比来确认边缘法线朝向。如此这般就可以得到正确结果了。

增加对角线采样后的效果:

可以发现垂直边缘的瑕疵点消失了。至此终于大功告成!

4. FXAA3.11 Console

边缘搜索,即便是只进行5步的搜索,也需要10次采样(至多),再加上需要进行对角线采样,因此累计采样次数竟多达19次! 这个开销还是比较大的。

老板说,Timothy啊,我们的游戏要上xbox360和ps3的,你这个质量版跑不动啊。能不能整个采样次数少一点的版本呢?

对主机版的需求:

  • 将采样次数控制在尽量少
  • 依旧要满足边缘渐变足够"薄"

Timothy说你这不是既要马儿跑,又要马儿不吃草吗?

Timothy心想,既然如此,那我就放飞一下自我吧。也不去判断什么局部边缘的横纵轴了,我直接利用局部亮度信息,去估算一下该局部区域边缘的斜率(切线走向)。具体怎么做呢?

4.1 估算边缘切线走向

首先在当前像素的四个角进行4次采样,如下图所示。

注意,这里只偏移0.5个像素位,并非采样对角像素。因此通过Bilinear插值采样是能够一次性获得4个像素的平均亮度的。

不妨记4个角采样到的亮度为lumaNW,lumaNE,lumaSW,lumaSE

Timothy给出的边缘走向估计如下:

float2 dir;
dir.x = (lumaSW + lumaSE) - (lumaNW + lumaNE);
dir.y = (lumaNW + lumaSW) - (lumaNE + lumaSE);
dir = normalize(dir);

这个估计是什么意思呢?

根据Bilinear插值的原理,易推导出:

dir.x = (lumaSW + lumaSE) - (lumaNW + lumaNE);

其实等于以下的滤波核:

-1/4 | -1/2 | -1/4
 0   | 0    | 0   
 1/4 | 1/2  | 1/4

它表征了当前像素上方3个像素和下方3个像素的对比强度,以此作为边缘切线在x轴向的投影分量,这个是合理的。

y轴也是同理。

4.2 边缘像素判定

既然决定只采样4个角,那么局部亮度对比度的计算也只能从这4个采样点中去估计了。局部对比度计算公式如下:

float lumaMax = max(lumaSW,lumaSE,lumaNW,lumaNE,lumaM);
float lumaMin = min(lumaSW,lumaSE,lumaNW,lumaNE,lumaM);
float lumaContrast = lumaMax - lumaMin;

当对比度大于阈值时,视为边缘像素。 阈值方式同初代目版本。

4.3 混合

在估算出切线走向后,接下来要想办法进行混合。为了让边缘能够Sharp一些,不像初代目那般边缘模糊,Timothy决定不向normal方向进行混合。

他决计在切线的正反两个方向,偏移一定距离(0~1),各进行一次采样,求平均作为当前像素的颜色。这样求得的颜色,就在切线方向起到了一个渐变过渡作用。

不难看出,偏移距离决定了当前像素颜色在最终颜色里所占的比重。

很明显,Timothy的这个思路是优先照顾那些45度角的锯齿边缘(亲儿子),抛弃那些接近于水平或者垂直的锯齿边。

对于这种接近水平/垂直的边缘,沿切线方向进行少量偏移采样极难覆盖到敌方阵营的像素。因而也就几乎失去了混合的效果。

为了补救这个问题,Timothy额外加入了两次采样。这两次采样的偏移距离根据切线的斜率来决定。越趋于水平/垂直,那么偏移距离就越远,企图以此覆盖到敌对阵营。

计算公式如下:

float dirAbsMinTimesC = min(abs(dir.x),abs(dir.y)) * FXAA_SHARPNESS;
float2 dir2 = clamp(dir / dirAbsMinTimesC,-2,2) * 2;
  • FXAA_SHARPNESS是一个暴露给用户的可调节参数

我们知道,切线为45度角的情况下,dir.x == dir.y ~= 0.7 ,因此随着切线角度变化,dirAbsMinTimesC的取值范围为[0 , 0.7 * FXAA_SHARPNESS],于是dir2分量的范围为 -2/FXAA_SHARPNESS ~ -4 or 2/FXAA_SHARPNESS ~ 4

因此随着FXAA_SHARPNESS增大,dir2会越保守,越靠近当前像素,边缘就会越锐利。

需要额外注意的是,由于我们估计的只是局部边缘切线,在dir2较大的情况下,可能会采样到差异很大的像素。因此需要针对额外的两次采样做一下噪点过滤。过滤规则为,若额外两次采样的亮度,超出局部亮度范围[lumaMin,lumaMax],那就丢弃,只使用前两次的采样结果。

这样我们对最终合法的采样结果(2或4次采样)求平均作为当前像素颜色即可。

但总得来说,这只能算是"补救"。FXAA Console针对水平/垂直的边缘抗锯齿效果的确比较勉强。 这一点Timothy自己也承认了。他在SIGGRAPH 2011 presentation里关于Console版本的评价是:

(Advantages) Very fast, reduces contrast on pixel and sub-pixel aliasing

(Disadvantages) Not very good on near horizontal or vertical edges

水平和垂直的FXAA-Console抗锯齿效果图如下,局部会出现一些不自然的过渡点。

对于斜边或者曲线的抗锯齿效果则很不错

5. 最终大比拼

三种方式对比

-- 采样次数 缺陷
FXAA初代 5 边缘太模糊
FXAA3.11 Quality 9 + N * 2(N至少>=3才有比较好的效果) 相对而言比较吃性能
FXAA3.11 Console 9 水平/垂直边缘抗锯齿较弱

同一个场景对比(画面放大6倍后):

参考文献

[1] Timothy Lottes, FXAA White Paper, 2009

[2] SIGGRAPH 2011 presentation on FXAA 3.11

[3] Simon Rodriguez, Implementing FXAA, 2016