实现FXAA(快速近似抗锯齿)

在计算机屏幕上渲染3D场景时,可能会出现锯齿现象。由于每个像素只能属于覆盖其屏幕区域的特定对象,并从该对象获得单一颜色,因此在对象边缘会出现锯齿状和锯齿状效果。当显示细线(如电线)时也会看到同样的效果。

锯齿示例
锯齿示例

对抗锯齿

已经开发了多种抗锯齿技术来减轻这类视觉伪影。有些技术依赖于渲染更大尺寸的图像,在降采样并在屏幕上显示结果之前从场景中获取更多细节,例如超采样抗锯齿(SSAA)。为了减轻性能和内存成本,开发了各种改进方法,但仍受到一些限制。多重采样抗锯齿(MSAA)就是其中之一,在OpenGL中极易启用,但在现代延迟渲染管线(光照计算在特定渲染通道中执行)中难以使用。

其他技术使用先前帧的信息来增强当前帧的质量,这类算法称为时间抗锯齿。还有一些方法是应用于最终渲染图像的后处理效果。其中,亚像素形态抗锯齿(SMAA)是最先进的技术之一,但实现起来相当复杂。Nvidia的Timothy Lottes在2009年描述了一种更简单但仍极其高效的算法,并迅速应用于许多游戏中:快速近似抗锯齿,FXAA。

FXAA登场

FXAA易于添加到现有渲染器中:它作为最终的渲染通道[1]应用,仅将渲染图像作为输入,并输出抗锯齿版本。主要思想是检测渲染图片中的边缘并平滑它们。这种方法快速高效,但可能会模糊纹理上的细节。我将尝试逐步解释该算法[2],但首先看一个示例。

 
 
 
No AA
No AA
FXAA
FXAA

这里是特写镜头:当启用抗锯齿时,所有边缘都被平滑,同时一些纹理细节(特别是龙皮上的)也被平滑了。

对比
对比

前提条件

在我的解释中,我假设整个场景首先被渲染到纹理图像中,分辨率与窗口相同。然后渲染一个覆盖整个窗口的矩形来显示此纹理。对于此矩形的每个像素,FXAA算法在所谓的片段着色器中执行,这是一个在GPU上为每个像素执行的小程序。

亮度

FXAA着色器中的大多数计算将依赖于从纹理读取的像素的亮度,表示为0.0到1.0之间的灰度级别。为此将使用亮度,定义为公式L = 0.299 * R + 0.587 * G + 0.114 * B

这是红、绿和蓝分量的加权和,考虑了我们眼睛对每个波长范围的敏感度。此外,我们将在感知空间(而非线性空间)中使用其值,并通过平方根近似逆伽马变换[3]。因此,在着色器中定义了以下实用函数。

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

纹理过滤

在OpenGL中读取纹理时,我们通常使用在[0,1]范围内表示为浮点数的UV坐标。但纹理在每个维度上由有限数量的像素组成,每个像素具有恒定颜色;如果有人尝试在落在两个像素之间的UV坐标处读取颜色,应该发生什么?有两种主要处理方式:


示例

检测应用AA的位置

首先,需要检测边缘:为此,计算当前片段及其四个直接邻居的亮度。提取最小和最大亮度,两者之间的差值给出局部对比度值。沿边缘对比度强,因为颜色有剧烈变化。因此,如果对比度低于与最大亮度成比例的阈值,则不执行抗锯齿。此外,在暗区域锯齿不太明显,因此如果对比度低于绝对阈值,我们也不执行抗锯齿。在这些情况下,输出从纹理在当前像素读取的颜色。

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

// 当前片段的亮度
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;

// 如果亮度变化低于阈值(或者如果我们在非常暗的区域),我们不在边缘上,不执行任何AA。
if(lumaRange < max(EDGE_THRESHOLD_MIN,lumaMax*EDGE_THRESHOLD_MAX)){
    fragColor = colorCenter;
    return;
}

两个全大写常量的推荐值为EDGE_THRESHOLD_MIN = 0.0312EDGE_THRESHOLD_MAX = 0.125

对于我们的示例像素,最小值为0,最大值为1,因此范围为1,并且由于1.0 > max(1*0.125,0.0312),我们将执行AA。

估计梯度并选择边缘方向

然后对于每个检测为边缘部分的像素,我们检查边缘是垂直还是水平的。为此,使用中心亮度和八个邻居通过以下公式计算一系列局部差值,包括水平和垂直方向:

两个量中较大的一个将给出边缘的主要方向。

// 查询其余4个角落的亮度。
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;

// 角落同样处理
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);

// 局部边缘是水平还是垂直?
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));

对于我们的示例,我们有gradient1 = 0 - 0 = 0gradient2 = 1 - 0 = 1,因此向上邻居的变化更强,且gradientScaled = 0.25

最后,我们朝这个方向移动半个像素,并计算该点的平均亮度。

// 根据边缘方向选择步长(一个像素)。
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);
}

// 沿正确方向移动UV半个像素。
vec2 currentUv = In.uv;
if(isHorizontal){
    currentUv.y += stepLength * 0.5;
} else {
    currentUv.x += stepLength * 0.5;
}

对于我们的像素,平均局部亮度为0.5*(1+0) = 0.5,并且0.5偏移沿Y轴正向应用。


锯齿对比
锯齿对比
锯齿对比
锯齿对比
锯齿对比

  1. 除了屏幕上显示的UI组件,通常希望避免抗锯齿模糊按钮和标签。↩
  2. 更准确地说是v3.11。↩
  3. ≈ x^(1.0/2.22)
  4. 因为一个输入像素正好对应一个输出像素↩
  5. 即UV坐标被夹紧到(0.0,1.0)↩