基于形态学的方法 FXAA

推荐阅读「Implementing FXAA」,相较 nvidia 原版以及 Three.js 的实现可读性要高很多。 其他实现还有 clay.gl 中的 fxaa3.glsl

算法思路

FXAA 和 MLAA 以及 SMAA 一样,都是基于几何反走样的方法,基本步骤来自原始论文,对应下面的 8 张图:

  1. non-linear RGB 输入
  2. 基于亮度的边缘检测,红色部分
  3. 检测到的边缘按水平和垂直方向进行分类
  4. Given edge orientation, the highest contrast pixel pair 90 degrees to the edge is selected, in blue/green
  5. 沿边缘方向向两侧(红色代表负向,蓝色正向)检测,找到亮度变化大的一端
  6. Given the ends of the edge, pixel position on the edge is transformed into to a sub-pixel shift 90 degrees perpendicular to the edge to reduce the aliasing, red/blue for -/+ horizontal shift and gold/skyblue for -/+ vertical shift
  7. The input texture is re-sampled given this sub-pixel offset
  8. 使用一个低通滤波器进行模糊处理

屏幕快照 2019-02-02 上午11.39.36.png

下面结合具体实现。

边缘检测

和 MLAA & SMAA 一样,首先需要检测边缘。

为了更好的通用性,FXAA 使用 sRGB 空间的颜色作为输入,并根据局部的亮度对比度来确定一个像素是否是边缘像素。这也就是表示 FXAA 一般应该发生在 Tone Mapping 之后,或者也可以把 Tone Mapping 和 FXAA 整合成一个 pass。

RGB 提取亮度,之前在 HDR Tone Mapping 中已经介绍过了。另外需要注意,这里进行了 gamma 校正转换到 sRGB 空间,虽然只是估算(sqrt 约等于 pow(1/2.2)):

float rgb2luma(vec3 rgb){
    return sqrt(dot(rgb, vec3(0.299, 0.587, 0.114)));
}

边缘检测部分很简单:

vec3 colorCenter = texture(screenTexture,In.uv).rgb;

// 当前 fragment亮度
float lumaCenter = rgb2luma(colorCenter);

// 上下左右四个邻居亮度
float lumaDown = rgb2luma(textureOffset(screenTexture,In.uv,ivec2(0,-1)).rgb);
float lumaUp = rgb2luma(textureOffset(screenTexture,In.uv,ivec2(0,1)).rgb);
float lumaLeft = rgb2luma(textureOffset(screenTexture,In.uv,ivec2(-1,0)).rgb);
float lumaRight = rgb2luma(textureOffset(screenTexture,In.uv,ivec2(1,0)).rgb);

// 找到最大最小值
float lumaMin = min(lumaCenter,min(min(lumaDown,lumaUp),min(lumaLeft,lumaRight)));
float lumaMax = max(lumaCenter,max(max(lumaDown,lumaUp),max(lumaLeft,lumaRight)));

// 计算差值
float lumaRange = lumaMax - lumaMin;

// 低于阈值,跳过后续 FXAA 处理
if(lumaRange < max(EDGE_THRESHOLD_MIN,lumaMax*EDGE_THRESHOLD_MAX)){
    fragColor = colorCenter;
    return;
}

边缘形态

相较 MLAA 多达 16 种的边缘形态,边缘形态只简单考虑水平和垂直两种:
例如 horizontal: |(upleft - left) - (left - downleft)| + 2 * |(up - center) - (center - down)| + |(upright - right) - (right - downright)|

// 对角线
float lumaDownLeft = rgb2luma(textureOffset(screenTexture,In.uv,ivec2(-1,-1)).rgb);
float lumaUpRight = rgb2luma(textureOffset(screenTexture,In.uv,ivec2(1,1)).rgb);
float lumaUpLeft = rgb2luma(textureOffset(screenTexture,In.uv,ivec2(-1,1)).rgb);
float lumaDownRight = rgb2luma(textureOffset(screenTexture,In.uv,ivec2(1,-1)).rgb);

// 水平垂直方向
float lumaDownUp = lumaDown + lumaUp;
float lumaLeftRight = lumaLeft + lumaRight;

// 4个角
float lumaLeftCorners = lumaDownLeft + lumaUpLeft;
float lumaDownCorners = lumaDownLeft + lumaDownRight;
float lumaRightCorners = lumaDownRight + lumaUpRight;
float lumaUpCorners = lumaUpRight + lumaUpLeft;

// 水平垂直方向计算差值
float edgeHorizontal =  abs(-2.0 * lumaLeft + lumaLeftCorners)  + abs(-2.0 * lumaCenter + lumaDownUp ) * 2.0    + abs(-2.0 * lumaRight + lumaRightCorners);
float edgeVertical =    abs(-2.0 * lumaUp + lumaUpCorners)      + abs(-2.0 * lumaCenter + lumaLeftRight) * 2.0  + abs(-2.0 * lumaDown + lumaDownCorners);

// 判断方向,水平 or 垂直
bool isHorizontal = (edgeHorizontal >= edgeVertical);

由于边缘可能经过像素两侧(左右,上下),需要进一步判断:

// 两侧邻居
float luma1 = isHorizontal ? lumaDown : lumaLeft;
float luma2 = isHorizontal ? lumaUp : lumaRight;
// 当前方向上的差值
float gradient1 = luma1 - lumaCenter;
float gradient2 = luma2 - lumaCenter;

// 哪边差值更大?
bool is1Steepest = abs(gradient1) >= abs(gradient2);

// 作为阈值供后续查找端点时使用
float gradientScaled = 0.25*max(abs(gradient1),abs(gradient2));

// 纹素尺寸
float stepLength = isHorizontal ? inverseScreenSize.y : inverseScreenSize.x;

// 平均亮度
float lumaLocalAverage = 0.0;

if(is1Steepest){
    // 反向
    stepLength = - stepLength;
    lumaLocalAverage = 0.5*(luma1 + lumaCenter);
} else {
    lumaLocalAverage = 0.5*(luma2 + lumaCenter);
}

// 沿边缘方向移动半个纹素
vec2 currentUv = In.uv;
if(isHorizontal){
    currentUv.y += stepLength * 0.5;
} else {
    currentUv.x += stepLength * 0.5;
}

例如当前 fragment 在红圈处,现在我们找到了 currentUv(绿叉处)
image.png
下一步需要找到边缘的首尾。

查找线段端点

这一步相较 MLAA 就更简单了,不需要考虑 crossing edge 的多种形态,只需要沿边缘方向向两侧查找:

// 一个纹素偏移量
vec2 offset = isHorizontal ? vec2(inverseScreenSize.x,0.0) : vec2(0.0,inverseScreenSize.y);
// 当前边缘点两侧
vec2 uv1 = currentUv - offset;
vec2 uv2 = currentUv + offset;

// 两侧亮度与均值的差值
float lumaEnd1 = rgb2luma(texture(screenTexture,uv1).rgb);
float lumaEnd2 = rgb2luma(texture(screenTexture,uv2).rgb);
lumaEnd1 -= lumaLocalAverage;
lumaEnd2 -= lumaLocalAverage;

// 大于该方向上的阈值,认为到达了边缘的端点处
bool reached1 = abs(lumaEnd1) >= gradientScaled;
bool reached2 = abs(lumaEnd2) >= gradientScaled;
bool reachedBoth = reached1 && reached2;

// 没有到达边缘,继续向两侧查找
if(!reached1){
    uv1 -= offset;
}
if(!reached2){
    uv2 += offset;
}

GLSL 中并不支持递归,因此只能通过有限次的循环实现,为了提升查找效率,会逐渐加大偏移量的步长 QUALITY。另外,如果不使用 for 循环的话也可以嵌套多层,fxaa3.glsl 中就是这么实现的。

if(!reachedBoth){
    for(int i = 2; i < ITERATIONS; i++){
 			  // 省略检测代码,同上
        // QUALITY 5次之后会增加,例如 1.5, 2.0...
        if(!reached1){
            uv1 -= offset * QUALITY(i);
        }
        if(!reached2){
            uv2 += offset * QUALITY(i);
        }
        // 到达端点跳出
        if(reachedBoth){ break; }
    }
}

现在我们找到了线段的端点(蓝色)uv1 uv2:
image.png

混合

找到端点后,需要知道 currentUv 离哪个端点更近,以及线段总长度。这样就可以计算出覆盖率,本质上就是一个线性插值渐变的过程:

// 两侧线段长度
float distance1 = isHorizontal ? (In.uv.x - uv1.x) : (In.uv.y - uv1.y);
float distance2 = isHorizontal ? (uv2.x - In.uv.x) : (uv2.y - In.uv.y);

// 离哪个端点更近?
bool isDirection1 = distance1 < distance2;
float distanceFinal = min(distance1, distance2);

// 线段总长度
float edgeThickness = (distance1 + distance2);

// 找到
float pixelOffset = - distanceFinal / edgeThickness + 0.5;

这里有一点需要注意,为了防止 近端点亮度大于均值而中心亮度小于均值这种情况的出现,需要保证整体亮度变化方向一致。如果不一致则不应用偏移量:

// 中心和平均亮度比较
bool isLumaCenterSmaller = lumaCenter < lumaLocalAverage;

// 近端点 和 中心到平均亮度的变化不一致
bool correctVariation = ((isDirection1 ? lumaEnd1 : lumaEnd2) < 0.0) != isLumaCenterSmaller;

// 如果不一致则不应用偏移量
float finalOffset = correctVariation ? pixelOffset : 0.0;

Subpixel antialiasing

关于 Sub-Pixel 这篇「Understanding Sub-Pixel (LCD Screen) Anti-Aliased Font Rendering🔗

The first circle just turns the whole RGB triplet completely on or completely off. The second circle varies the strength of each RGB triplet to generate intermediate shades of grey. But the third circle varies the strength of each display element separately to achieve the best possible rendition of a sharp-edged circle


开启后的效果图:
Antialias-vrs-Cromapixel.svg

the amount of sub-pixel aliasing removal,会影响锐度:

//   1.00 - upper limit (softer)
//   0.75 - default amount of filtering
//   0.50 - lower limit (sharper, less sub-pixel aliasing removal)
//   0.25 - almost off
//   0.00 - completely off
FxaaFloat fxaaQualitySubpix,

取 sub-pixel 和之前计算出的偏移量的较大者:

SUBPIXEL_QUALITY = 0.75 // fxaaQualitySubpix

// 3x3 neighborhood,加权平均(上下左右2,四个角1)
float lumaAverage = (1.0/12.0) * (2.0 * (lumaDownUp + lumaLeftRight) + lumaLeftCorners + lumaRightCorners);
// Ratio of the delta between the global average and the center luma, over the luma range in the 3x3 neighborhood.
float subPixelOffset1 = clamp(abs(lumaAverage - lumaCenter)/lumaRange,0.0,1.0);
float subPixelOffset2 = (-2.0 * subPixelOffset1 + 3.0) * subPixelOffset1 * subPixelOffset1;
// Compute a sub-pixel offset based on this delta.
float subPixelOffsetFinal = subPixelOffset2 * subPixelOffset2 * SUBPIXEL_QUALITY;

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

最终效果

取偏移后的纹素颜色作为最终输出:

// 计算偏移量
vec2 finalUv = In.uv;
if(isHorizontal){
    finalUv.y += finalOffset * stepLength;
} else {
    finalUv.x += finalOffset * stepLength;
}

// 取偏移后的纹素输出
vec3 finalColor = texture(screenTexture,finalUv).rgb;
fragColor = finalColor;

优点很明显,实现简单开销小(相比 SMAA 所需的三个 pass,FXAA 只需要一个),但是效果不是十分优秀。
image.png

以上是 nvidia 原作者提出的第一版 FXAA 的实现,随后他提出了目前广为使用的第三版,也是很多引擎中采用的 FXAA 3.x。

FXAA 的后续改进

在阅读 clay.gl 源码时发现目前使用的 fxaa 实现相对简单,来自 glsl-fxaa 🔗

A WebGL implementation of Fast Approximate Anti-Aliasing (FXAA v2). This is a screen-space technique. The code was originally from Geeks3D.com and cleaned up by Armin Ronacher for WebGL.

dependent texture reads



在这个版本的实现中,介绍到使用了一种优化方式:

This FXAA shader uses 9 dependent texture reads. For various mobile GPUs (particularly iOS), we can optimize the shader by making 5 of the texture2D calls non-dependent. To do this, the coordinates have to be computed in the vertex shader and passed along:

那么啥是 **dependent texture reads **呢?来自 sf 的一个回答:
https://stackoverflow.com/questions/1054096/what-is-a-dependent-texture-read

An important implication is that the texture coordinates (where you look up from) is not determined until the middle of execution of the shader… there’s no kind of static analysis you can do on the shader (even knowing the values of all parameters) that will tell you what the coordinates will be ahead of time

因此为了在编译时而非运行时就知道获取纹理坐标,在 vs 中计算好 4 个邻居坐标并通过 varying 传递给 fs:

// tex.vs

void texcoords(vec2 fragCoord, vec2 resolution,
			out vec2 v_rgbNW, out vec2 v_rgbNE,
			out vec2 v_rgbSW, out vec2 v_rgbSE,
			out vec2 v_rgbM) {
	vec2 inverseVP = 1.0 / resolution.xy;
	v_rgbNW = (fragCoord + vec2(-1.0, -1.0)) * inverseVP;
	v_rgbNE = (fragCoord + vec2(1.0, -1.0)) * inverseVP;
	v_rgbSW = (fragCoord + vec2(-1.0, 1.0)) * inverseVP;
	v_rgbSE = (fragCoord + vec2(1.0, 1.0)) * inverseVP;
	v_rgbM = vec2(fragCoord * inverseVP);
}

#pragma glslify: export(texcoords)

但是上面那个问答中也提到了,这种过于深入硬件体层实现的优化在新款 GPU 中已经是不需要的了。
毕竟 glsl-fxaa 也是写于 4、5 年前的,同样的还有 https://github.com/mattdesl/three-shader-fxaa
下面来看看 FXAA 3.x 的实现方式。

3.x

由于原作者的博客已经失效了,目前只能找到这一篇历史文档:http://archive.is/Y7Ns,其中说到作者实现了第一版(也就是上面的基础实现)之后,又优化了第二、三版,但是目前我只找到了 FXAA3,也就是当前应用广泛的实现。一开始光看没有注释的源码有些难以理解,直观感觉就是相较于第一版要精简一些。后来找到了作者在 SIGGRAPH 2011 上的一份 Slide 🔗,其中详细介绍了算法的思路,才算真正理解。

屏幕快照 2019-02-03 下午9.16.00.png

首先按照图中定义的 2 tap filter,x 轴方向需要反向。
然后取 xy 亮度变化小的那个方向,进行缩放。
最后限定 filter width 为 -8 到 8 之间。

mediump vec2 dir;
// x 轴方向需要反向
dir.x = -((lumaNW + lumaNE) - (lumaSW + lumaSE));
dir.y =  ((lumaNW + lumaSW) - (lumaNE + lumaSE));

float dirReduce = max((lumaNW + lumaNE + lumaSW + lumaSE) *
	(0.25 * FXAA_REDUCE_MUL), FXAA_REDUCE_MIN);

// 取得 xy 较小的变化方向进行缩放
float rcpDirMin = 1.0 / (min(abs(dir.x), abs(dir.y)) + dirReduce);

// filter width FXAA_SPAN_MAX = 8
dir = min(
	vec2(FXAA_SPAN_MAX, FXAA_SPAN_MAX),
	max(
  	vec2(-FXAA_SPAN_MAX, -FXAA_SPAN_MAX),
		dir * rcpDirMin // 缩放
  )
) * inverseVP;

屏幕快照 2019-02-03 下午9.13.39.png

If full‐width filter width estimation is too large, then there is a chance the filter kernel will sample from regions off the local edge. In this case noise will be introduced by the filter kernel. This step attempts to remove this noise.

顺便把 subpixel 反走样也做了,取 0,1/3,2/3 和 1 处四个的纹素:

// 2-tap sub-pixel 反走样 1/3 和 2/3
vec3 rgbA = 0.5 * (
  texture2D(tex, fragCoord * inverseVP + dir * (1.0 / 3.0 - 0.5)).xyz +
  texture2D(tex, fragCoord * inverseVP + dir * (2.0 / 3.0 - 0.5)).xyz);
// 4-tap 再加上 0 和 1
vec3 rgbB = rgbA * 0.5 + 0.25 * (
  texture2D(tex, fragCoord * inverseVP + dir * -0.5).xyz +
  texture2D(tex, fragCoord * inverseVP + dir * 0.5).xyz);

float lumaB = dot(rgbB, luma);
// 如果 4-tap filter 引入的噪声导致超出了亮度的范围,只返回 2-tap 结果
if ((lumaB < lumaMin) || (lumaB > lumaMax))
	color = vec4(rgbA, texColor.a);
else
	color = vec4(rgbB, texColor.a);
return color;