Post

自定义管线:FXAA抗锯齿 (翻译十七)

深入探索快速近似抗锯齿(FXAA)算法的实现,包括亮度计算、边缘检测、混合策略和性能优化。

自定义管线:FXAA抗锯齿 (翻译十七)
  • 计算并存储像素亮度,或回退到绿色通道。
  • 查找并混合高对比度像素。
  • 检测并平滑长边缘。
  • 结合FXAA和渲染缩放。

FXAA后处理效果

帧缓冲区的有限分辨率会在最终图像中引入视觉锯齿伪影。这些通常被称为锯齿或阶梯效应,在与像素网格不对齐的线条上可见。除此之外,小于像素的特征要么出现要么不出现,这会在它们移动时产生时间闪烁伪影。

在上一个教程中,我添加了通过最多将渲染缩放加倍然后下采样来应用SSAA的能力。这在一定程度上平滑了锯齿,并将分辨率加倍用于检测然后平滑微小特征。虽然加倍渲染缩放可以提高视觉质量,但它也要求GPU处理四倍的片段,因此非常昂贵,通常不适合实时使用。可以使用小于2的渲染缩放,但单独使用它并不能大幅提高质量。

增加分辨率的替代方案是对原始图像应用后处理效果,平滑所有锯齿伪影。已经开发了各种此类算法,它们以总是以AA结尾的缩写词而闻名,例如FXAA、MLAA、SMAA和TAA。在本教程中,我将实现FXAA,这是最简单和最快的方法。

第一个后处理抗锯齿解决方案是形态抗锯齿,缩写为MLAA。它分析图像以检测视觉特征的边缘,然后选择性地模糊这些边缘。FXAA是受MLAA启发的一种更简单的方法。它代表快速近似抗锯齿。它由NVIDIA的Timothy Lottes开发。与MLAA相比,它牺牲质量换取速度。虽然对FXAA的一个常见抱怨是它模糊得太多,但这取决于使用哪个变体以及如何调整它。我将创建最新版本——FXAA 3.11——特别是还会调查长边缘的高质量变体。

启用FXAA

虽然FXAA是后处理效果,但它会像渲染缩放一样全局影响图像质量,所以我将把它的配置添加到CameraBufferSettings。最初我只需要一个切换来启用它,但之后会添加一些更多的配置选项。因此,我将把所有FXAA设置分组到一个新的CameraBufferSettings.FXAA结构中。

1
2
3
4
5
6
7
8
9
10
11
12
13
using System;
using UnityEngine;

[Serializable]
public struct CameraBufferSettings {
    ...
    [Serializable]
    public struct FXAA {
        public bool enabled;
    }

    public FXAA fxaa;
}
为渲染管线启用FXAA
为渲染管线启用FXAA

同样,就像我对渲染缩放所做的那样,我将使每个相机可以控制是否使用FXAA,方法是向CameraSettings添加一个切换来控制是否允许FXAA。默认情况下应该不允许。这确保FXAA不会应用于场景窗口、材质预览或反射探头。

1
public bool allowFXAA = false;
为相机启用FXAA
为相机启用FXAA

由于FXAA是后处理效果,应用它是PostFXStack的责任。这意味着FXAA只有在使用后处理效果时才会工作。此外,FXAA配置必须传递给栈,所以向PostFXStack.Setup添加一个参数并将其复制到字段。

1
2
3
4
5
6
7
8
9
10
CameraBufferSettings.FXAA fxaa;
...
public void Setup (
    ...
    CameraBufferSettings.BicubicRescalingMode bicubicRescaling,
    CameraBufferSettings.FXAA fxaa
) {
    this.fxaa = fxaa;
    ...
}

CameraRenderer.Render中将FXAA配置传递给它,在应用相机的切换到全局切换之后。

1
2
3
4
5
6
bufferSettings.fxaa.enabled &= cameraSettings.allowFXAA;
postFXStack.Setup(
    context, camera, bufferSize, postFXSettings, useHDR, colorLUTResolution,
    cameraSettings.finalBlendMode, bufferSettings.bicubicRescaling,
    bufferSettings.fxaa
);

请注意,我可以直接修改缓冲区设置结构字段,因为它包含RP设置结构的副本,而不是对原始结构的引用。

FXAA Pass

我需要一个pass来应用FXAA,所以将它添加到PostFXStack着色器,以及在PostFXStack.Pass枚举中添加相应的条目。该pass是Final Rescale的副本,重命名为FXAA并将其片段函数设置为FXAAPassFragment。除此之外,我将把FXAA着色器代码放在一个单独的FXAAPass HLSL文件中,并仅在pass本身中包含它。

1
2
3
4
5
6
7
8
9
10
11
12
Pass {
    Name "FXAA"

    Blend [_FinalSrcBlend] [_FinalDstBlend]

    HLSLPROGRAM
        #pragma target 3.5
        #pragma vertex DefaultPassVertex
        #pragma fragment FXAAPassFragment
        #include "FXAAPass.hlsl"
    ENDHLSL
}

创建新的FXAAPass.hlsl文件,最初仅包含FXAAPassFragment函数,它返回源像素而不修改。

1
2
3
4
5
6
7
8
#ifndef CUSTOM_FXAA_PASS_INCLUDED
#define CUSTOM_FXAA_PASS_INCLUDED

float4 FXAAPassFragment (Varyings input) : SV_TARGET {
    return GetSource(input.screenUV);
}

#endif

我必须在颜色分级之后应用FXAA,原因与必须在颜色分级之后进行最终重新缩放相同。由于我现在有多种情况,其中Final pass不再是真正的最终pass,让我们将其重命名为Apply Color Grading,因为这就是它实际做的事情。在着色器和Pass枚举中都这样做。将其片段函数重命名为ApplyColorGradingPassFragment

让我们也将PostFXStack.DoColorGradingAndToneMapping方法重命名为DoFinal,因为它现在做的远不止是颜色分级和色调映射。

当启用FXAA时,我必须首先执行颜色分级,然后在其上应用FXAA。因此,我必须将颜色分级结果存储在临时渲染纹理中。向PostFXStack添加一个着色器属性标识符。

1
2
colorGradingResultId = Shader.PropertyToID("_ColorGradingResult"),
finalResultId = Shader.PropertyToID("_FinalResult"),

DoFinal中,在我继续进行最终绘制阶段之前,立即检查是否启用了FXAA。如果启用了FXAA,立即执行颜色分级并将结果存储在新的临时LDR纹理中。

1
2
3
4
5
6
7
8
9
10
11
12
13
buffer.SetGlobalVector(colorGradingLUTParametersId,
    new Vector4(1f / lutWidth, 1f / lutHeight, lutHeight - 1f)
);
if (fxaa.enabled) {
    buffer.GetTemporaryRT(
        colorGradingResultId, bufferSize.x, bufferSize.y, 0,
        FilterMode.Bilinear, RenderTextureFormat.Default
    );
    Draw(sourceId, colorGradingResultId, Pass.ApplyColorGrading);
}
if (bufferSize.x == camera.pixelWidth) {
    DrawFinal(sourceId, Pass.ApplyColorGrading);
}

就像我对调整后的渲染缩放所做的那样,我必须确保颜色分级的最终混合模式设置为One Zero。由于这现在可能发生在两个地方,让我们简单地在开始绘制之前始终重置它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
buffer.SetGlobalFloat(finalSrcBlendId, 1f);
buffer.SetGlobalFloat(finalDstBlendId, 0f);
if (fxaa.enabled) {
    ...
}
if (bufferSize.x == camera.pixelWidth) {
    DrawFinal(sourceId, Pass.ApplyColorGrading);
}
else {
    buffer.GetTemporaryRT(
        finalResultId, bufferSize.x, bufferSize.y, 0,
        FilterMode.Bilinear, RenderTextureFormat.Default
    );
    //buffer.SetGlobalFloat(finalSrcBlendId, 1f);
    //buffer.SetGlobalFloat(finalDstBlendId, 0f);
    ...
}

接下来,如果缓冲区没有缩放,我再次必须检查是否启用了FXAA。如果是,则最终绘制使用FXAA pass和颜色分级结果,之后必须释放颜色分级结果。否则,颜色分级就是最终pass,就像以前一样。

1
2
3
4
5
6
7
8
9
if (bufferSize.x == camera.pixelWidth) {
    if (fxaa.enabled) {
        DrawFinal(colorGradingResultId, Pass.FXAA);
        buffer.ReleaseTemporaryRT(colorGradingResultId);
    }
    else {
        DrawFinal(sourceId, Pass.ApplyColorGrading);
    }
}

在调整渲染缩放的情况下,我仍然必须首先渲染到中间最终结果纹理。如果启用了FXAA,我用FXAA pass对颜色分级结果进行常规绘制来完成此操作,之后释放颜色分级结果。否则,就是对原始源应用颜色分级的常规绘制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
else {
    buffer.GetTemporaryRT(
        finalResultId, bufferSize.x, bufferSize.y, 0,
        FilterMode.Bilinear, RenderTextureFormat.Default
    );
    if (fxaa.enabled) {
        Draw(colorGradingResultId, finalResultId, Pass.FXAA);
        buffer.ReleaseTemporaryRT(colorGradingResultId);
    }
    else {
        Draw(sourceId, finalResultId, Pass.ApplyColorGrading);
    }
    ...
}

此时,我的RP仍然产生与以前相同的结果,但是当启用FXAA时,帧调试器将显示一个额外的FXAA pass绘制步骤。

亮度

FXAA通过选择性地降低图像的对比度来工作,平滑视觉上明显的锯齿和孤立像素。对比度是通过比较像素的感知强度来确定的。由于目标是减少我感知到的伪影,FXAA只关心感知亮度,即经过伽马调整的亮度,称为luma(亮度)。像素的确切颜色并不重要,重要的是它们的luma。因此,FXAA分析灰度图像。这意味着当不同颜色之间的luma相似时,它们之间的硬过渡不会被大幅平滑。只有视觉上明显的过渡才会受到强烈影响。

FXAAPass添加一个GetLuma函数,它返回某些UV坐标的luma值。最初让它返回源的线性亮度。然后让FXAA pass返回它。请注意,FXAA在颜色分级和色调映射之后处理LDR数据,所以这代表最终图像的luma。

1
2
3
4
5
6
7
float GetLuma (float2 uv) {
    return Luminance(GetSource(uv));
}

float4 FXAAPassFragment (Varyings input) : SV_TARGET {
    return GetLuma(input.screenUV);
}
原始颜色和线性亮度
原始颜色和线性亮度
原始颜色和线性亮度

因为我对暗色变化的感知比亮色更敏感,所以我必须对亮度应用伽马调整以获得适当的luma值。伽马值2足够准确,我通过取线性亮度的平方根来获得它。

1
2
3
float GetLuma (float2 uv) {
    return sqrt(Luminance(GetSource(uv)));
}
伽马2.0 luma
伽马2.0 luma

使用绿色作为亮度

FXAA通过检测对比度和边缘来工作,这需要每个片段进行多次采样。为每个样本计算luma会使其过于昂贵。因为我在视觉上对绿色最敏感,所以计算luma的一个常见替代方案是直接使用绿色通道。这会降低质量,但避免了点积和平方根运算。

1
2
3
float GetLuma (float2 uv) {
    return GetSource(uv).g;
}
绿色作为luma
绿色作为luma

在Alpha通道中存储亮度

计算luma产生的结果比仅依赖绿色通道要好得多,但我不想在每次采样源时都计算它。一个解决方案是在应用颜色分级时计算一次。我必须将luma存储在某个地方,为此我可以使用颜色分级结果纹理的alpha通道。但是,如果之后需要存储在alpha通道中的透明度,例如在分层相机时,这是不可能的。

由于alpha通道通常未使用,我将向PostFXStack添加另一个pass,既应用颜色分级又计算luma,同时保留原始pass。

1
2
3
4
5
6
7
8
9
Pass {
    Name "Apply Color Grading With Luma"

    HLSLPROGRAM
        #pragma target 3.5
        #pragma vertex DefaultPassVertex
        #pragma fragment ApplyColorGradingWithLumaPassFragment
    ENDHLSL
}

新的片段函数是ApplyColorGradingPassFragment的副本,还计算luma并将其存储在alpha通道中。

1
2
3
4
5
6
float4 ApplyColorGradingWithLumaPassFragment (Varyings input) : SV_TARGET {
    float4 color = GetSource(input.screenUV);
    color.rgb = ApplyColorGradingLUT(color.rgb);
    color.a = sqrt(Luminance(color.rgb));
    return color;
}

我现在需要两个版本的FXAA pass,一个用于alpha通道包含luma的情况,一个用于luma不可用的情况。我将保留当前的FXAA pass,并在luma可用时添加另一个FXAA With Luma pass。在这种情况下,我将定义FXAA_ALPHA_CONTAINS_LUMA,而不是为它创建单独的片段函数。这是可行的,因为我在pass块本身中包含了FXAAPass,所以我在包含文件之前添加定义。

1
2
3
4
5
6
7
8
9
10
11
12
13
Pass {
    Name "FXAA With Luma"

    Blend [_FinalSrcBlend] [_FinalDstBlend]

    HLSLPROGRAM
        #pragma target 3.5
        #pragma vertex DefaultPassVertex
        #pragma fragment FXAAPassFragment
        #define FXAA_ALPHA_CONTAINS_LUMA
        #include "FXAAPass.hlsl"
    ENDHLSL
}

现在我可以使用条件编译使GetLuma返回适当的颜色通道:当luma存储在其中时返回alpha,否则返回绿色。

1
2
3
4
5
6
7
float GetLuma (float2 uv) {
#if defined(FXAA_ALPHA_CONTAINS_LUMA)
    return GetSource(uv).a;
#else
    return GetSource(uv).g;
#endif
}

保留Alpha

我更喜欢计算luma,所以这将是默认值。我只有在必须保持alpha通道中的数据不变时才会切换到绿色,无论原因是什么。这取决于渲染图像的用途,因此必须为每个相机配置。为此向CameraSettings添加一个保留alpha的切换选项,默认情况下禁用。

1
public bool keepAlpha = false;
保留alpha切换
保留alpha切换

CameraRenderer.Render中设置后处理效果栈时传递此切换。它与HDR切换相关,因为两个设置都处理纹理数据的性质,所以将其放在HDR切换参数之前。

1
2
3
4
5
postFXStack.Setup(
    context, camera, bufferSize, postFXSettings, cameraSettings.keepAlpha, useHDR,
    colorLUTResolution, cameraSettings.finalBlendMode,
    bufferSettings.bicubicRescaling, bufferSettings.fxaa
);

然后在PostFXStack中跟踪切换。

1
2
3
4
5
6
7
8
9
10
11
12
bool keepAlpha, useHDR;
...
public void Setup (
    ScriptableRenderContext context, Camera camera, Vector2Int bufferSize,
    PostFXSettings settings, bool keepAlpha, bool useHDR, int colorLUTResolution,
    ...
) {
    ...
    this.keepAlpha = keepAlpha;
    this.useHDR = useHDR;
    ...
}

现在当启用FXAA时,DoFinal必须使用适当的pass。如果我必须保留alpha,那么我坚持使用当前的pass,否则我可以切换到在alpha通道中包含luma的颜色分级和FXAA pass。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
if (fxaa.enabled) {
    ...
    Draw(
        sourceId, colorGradingResultId,
        keepAlpha ? Pass.ApplyColorGrading : Pass.ApplyColorGradingWithLuma
    );
}
if (bufferSize.x == camera.pixelWidth) {
    if (fxaa.enabled) {
        DrawFinal(
            colorGradingResultId, keepAlpha ? Pass.FXAA : Pass.FXAAWithLuma
        );
        buffer.ReleaseTemporaryRT(colorGradingResultId);
    }
    ...
}
else {
    ...
    if (fxaa.enabled) {
        Draw(
            colorGradingResultId, finalResultId,
            keepAlpha ? Pass.FXAA : Pass.FXAAWithLuma
        );
        buffer.ReleaseTemporaryRT(colorGradingResultId);
    }
    ...
}

你可以通过切换相机的Keep Alpha设置来检查这是否有效。当必须保留alpha时,我的RP被迫回退到依赖绿色而不是luma,这将产生更暗的灰度图像。目前保留alpha的唯一原因是当多个相机以透明方式堆叠时。

子像素混合

FXAA通过混合具有高对比度的相邻像素来工作。所以这不是图像的简单均匀模糊。首先,必须计算源像素周围的局部对比度——从最低到最高luma的范围。其次——如果有足够的对比度——必须根据对比度选择混合因子。第三,必须调查局部对比度梯度以确定混合方向。最后,在原始像素与其适当邻居之间执行混合。

亮度邻域

局部对比度是通过采样源像素邻域中像素的luma来找到的。为了使这变得容易,向GetLuma添加两个可选的偏移参数,这样它可以沿U和V维度以像素为单位偏移。

1
2
3
4
float GetLuma (float2 uv, float uOffset = 0.0, float vOffset = 0.0) {
    uv += float2(uOffset, vOffset) * GetSourceTexelSize().xy;
    ...
}

除了源像素,我还必须采样其直接相邻的邻居,我将用罗盘方向来识别它们。所以我最终得到五个luma值:中间源像素加上北、东、南和西。

邻域采样
邻域采样

定义一个LumaNeighborhood结构来跟踪所有这些,并添加一个GetLumaNeighborhood函数来返回该邻域。在片段pass中调用它,最初仍然只返回中间luma值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct LumaNeighborhood {
    float m, n, e, s, w;
};

LumaNeighborhood GetLumaNeighborhood (float2 uv) {
    LumaNeighborhood luma;
    luma.m = GetLuma(uv);
    luma.n = GetLuma(uv, 0.0, 1.0);
    luma.e = GetLuma(uv, 1.0, 0.0);
    luma.s = GetLuma(uv, 0.0, -1.0);
    luma.w = GetLuma(uv, -1.0, 0.0);
    return luma;
}

float4 FXAAPassFragment (Varyings input) : SV_TARGET {
    LumaNeighborhood luma = GetLumaNeighborhood(input.screenUV);
    return luma.m;
}

要确定此邻域中的luma范围,我需要知道其最高和最低luma值是什么。计算它们并将它们也存储在邻域结构中。

1
2
3
4
5
6
7
8
9
10
11
struct LumaNeighborhood {
    float m, n, e, s, w;
    float highest, lowest;
};

LumaNeighborhood GetLumaNeighborhood (float2 uv) {
    ...
    luma.highest = max(max(max(max(luma.m, luma.n), luma.e), luma.s), luma.w);
    luma.lowest = min(min(min(min(luma.m, luma.n), luma.e), luma.s), luma.w);
    return luma;
}

你可以通过将它们用于片段函数的结果来观察这些值和其他值,但我不显示为此所需的临时代码更改。

Image
邻域中的中间、最高和最低luma;放大显示
邻域中的中间、最高和最低luma;放大显示
邻域中的中间、最高和最低luma;放大显示

现在还向邻域添加luma范围,它是最高luma减去最低luma。

1
2
3
4
5
6
7
8
9
10
11
12
struct LumaNeighborhood {
    float m, n, e, s, w;
    float highest, lowest, range;
};

LumaNeighborhood GetLumaNeighborhood (float2 uv) {
    ...
    luma.highest = max(max(max(max(luma.m, luma.n), luma.e), luma.s), luma.w);
    luma.lowest = min(min(min(min(luma.m, luma.n), luma.e), luma.s), luma.w);
    luma.range = luma.highest - luma.lowest;
    return luma;
}
邻域中的luma范围
邻域中的luma范围

请注意,luma范围在视觉上将图像中的边缘显示为线条。线条有两个像素宽,因为每个边缘的两侧都有一个像素。边缘的luma对比度越高,它显得越亮。

固定阈值

我不需要混合每个像素,只需要混合那些邻域具有足够高对比度的像素。做出这种区分的最简单方法是引入对比度阈值。如果邻域luma范围没有达到此阈值,则像素不需要混合。我将其命名为固定阈值,因为还有一个相对阈值。

向我的CameraBufferSettings.FXAA结构添加一个滑块来配置固定阈值。原始FXAA算法也有此阈值,具有以下代码文档:

1
2
3
4
// 修剪算法不处理暗区域。
//   0.0833 - 上限(默认,可见未过滤边缘的开始)
//   0.0625 - 高质量(更快)
//   0.0312 - 可见极限(更慢)

虽然文档提到它修剪暗区域,但它基于对比度修剪,所以无论是亮还是暗。我将使用原始FXAA文档指示的相同范围。

1
2
3
4
5
6
public struct FXAA {
    public bool enabled;

    [Range(0.0312f, 0.0833f)]
    public float fixedThreshold;
}

让我们也使用与原始相同的默认值,我在CustomRenderPipelineAsset中设置它。

1
2
3
4
5
6
7
CameraBufferSettings cameraBuffer = new CameraBufferSettings {
    allowHDR = true,
    renderScale = 1f,
    fxaa = new CameraBufferSettings.FXAA {
        fixedThreshold = 0.0833f
    }
};
固定阈值滑块
固定阈值滑块

接下来,向PostFXStack添加一个_FXAAConfig着色器属性标识符。

1
int fxaaConfigId = Shader.PropertyToID("_FXAAConfig");

我将FXAA配置作为向量发送到GPU,最初只在其第一个分量中包含固定阈值。如果启用了FXAA,则在DoFinal中执行此操作。

1
2
3
4
5
6
7
8
if (fxaa.enabled) {
    buffer.SetGlobalVector(fxaaConfigId, new Vector4(fxaa.fixedThreshold, 0f));
    buffer.GetTemporaryRT(
        colorGradingResultId, bufferSize.x, bufferSize.y, 0,
        FilterMode.Bilinear, RenderTextureFormat.Default
    );
    ...
}

FXAAPass添加_FXAAConfig向量,以及一个CanSkipFXAA函数,它接受LumaNeighborhood并返回其范围是否小于固定阈值。然后如果我可以跳过FXAA,则在FXAAPassFragment中返回零。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
float4 _FXAAConfig;
...

bool CanSkipFXAA (LumaNeighborhood luma) {
    return luma.range < _FXAAConfig.x;
}

float4 FXAAPassFragment (Varyings input) : SV_TARGET {
    LumaNeighborhood luma = GetLumaNeighborhood(input.screenUV);
    if (CanSkipFXAA(luma)) {
        return 0.0;
    }
    return luma.range;
}
固定阈值设置为最小和最大
固定阈值设置为最小和最大
固定阈值设置为最小和最大

FXAA跳过的像素现在是纯黑色。低对比度区域现在在视觉上被消除了。保留多少取决于阈值。

相对阈值

FXAA还有第二个阈值,它相对于每个邻域的最亮luma。邻域越亮,对比度必须越高才重要。原始FXAA代码对其值有以下文档:

1
2
3
4
5
6
// 应用算法所需的最小局部对比度量。
//   0.333 - 太少(更快)
//   0.250 - 低质量
//   0.166 - 默认
//   0.125 - 高质量
//   0.063 - 过度(更慢)

也向CameraBufferSettings.FXAA添加一个此相对阈值的滑块。

1
2
[Range(0.063f, 0.333f)]
public float relativeThreshold;

再次在CustomRenderPipelineAsset中使用与原始相同的默认值。

1
2
3
4
fxaa = new CameraBufferSettings.FXAA {
    fixedThreshold = 0.0833f,
    relativeThreshold = 0.166f
}

然后在PostFXStack.DoFinal中将其放入FXAA配置向量的第二个分量中。

1
2
3
buffer.SetGlobalVector(fxaaConfigId, new Vector4(
    fxaa.fixedThreshold, fxaa.relativeThreshold
));
两个阈值滑块
两个阈值滑块

要应用相对阈值而不是固定阈值,更改FXAAPass中的CanSkipFXAA,使其检查luma范围是否小于由最高luma缩放的第二个阈值。

1
2
3
bool CanSkipFXAA (LumaNeighborhood luma) {
    return luma.range < _FXAAConfig.y * luma.highest;
}
相对阈值设置为最小和最大
相对阈值设置为最小和最大
相对阈值设置为最小和最大

要应用两个阈值,比较最大的一个。

1
2
3
bool CanSkipFXAA (LumaNeighborhood luma) {
    return luma.range < max(_FXAAConfig.x, _FXAAConfig.y * luma.highest);
}
两个阈值都设置为最小和最大
两个阈值都设置为最小和最大
两个阈值都设置为最小和最大

从现在开始,我将始终使用最低阈值,以便影响最多的像素。

混合因子

提高边缘视觉质量的唯一正确方法是提高图像的分辨率。但是,FXAA只能使用原始图像数据。它能做的最好的事情是猜测缺失的子像素数据。它通过混合中间像素与其邻居之一来实现这一点。在最极端的情况下,这将是两个像素的简单平均值,但确切的混合因子是依赖于像素对比度及其邻居平均值的滤波器的结果。我将分步骤可视化这一点。

首先创建一个GetSubpixelBlendFactor函数,它返回邻域中四个邻居的平均值。将其用于FXAAPassFragment的结果。

1
2
3
4
5
6
7
8
9
10
float GetSubpixelBlendFactor (LumaNeighborhood luma) {
    float filter = luma.n + luma.e + luma.s + luma.w;
    filter *= 1.0 / 4;
    return filter;
}

float4 FXAAPassFragment (Varyings input) : SV_TARGET {
    ...
    return GetSubpixelBlendFactor(luma);
}
低通滤波器
低通滤波器

结果是应用于未跳过像素周围luma的低通滤波器。下一步是通过取邻居平均值与中间值之间的绝对差将其转换为高通滤波器。

1
2
filter = abs(filter - luma.m);
return filter;
高通滤波器
高通滤波器

之后,我通过将滤波器除以luma范围来归一化它。

1
2
filter = filter / luma.range;
return filter;
归一化滤波器
归一化滤波器

此时,结果太强,无法用作混合因子。FXAA通过对其应用平方的smoothstep函数来修改滤波器。

线性和平方smoothstep
线性和平方smoothstep
1
2
filter = smoothstep(0, 1, filter);
return filter * filter;
平滑滤波器
平滑滤波器

滤波器的质量可以通过将对角邻居也纳入其中来提高,所以将它们的luma值添加到邻域中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct LumaNeighborhood {
    float m, n, e, s, w, ne, se, sw, nw;
    float highest, lowest, range;
};

LumaNeighborhood GetLumaNeighborhood (float2 uv) {
    LumaNeighborhood luma;
    luma.m = GetLuma(uv);
    luma.n = GetLuma(uv, 0.0, 1.0);
    luma.e = GetLuma(uv, 1.0, 0.0);
    luma.s = GetLuma(uv, 0.0, -1.0);
    luma.w = GetLuma(uv, -1.0, 0.0);
    luma.ne = GetLuma(uv, 1.0, 1.0);
    luma.se = GetLuma(uv, 1.0, -1.0);
    luma.sw = GetLuma(uv, -1.0, -1.0);
    luma.nw = GetLuma(uv, -1.0, 1.0);
    ...
}

因为对角邻居在空间上离中间更远,所以它们应该比直接邻居更不重要。我通过将直接邻居的权重加倍来将这一点纳入平均值。这就像一个3×3帐篷滤波器,没有中间。

邻居权重
邻居权重

我现在还必须饱和归一化滤波器,因为我存储的最高值没有考虑对角样本,因此在除法后我仍然可能有一个超过1的值。

1
2
3
4
5
6
7
8
9
float GetSubpixelBlendFactor (LumaNeighborhood luma) {
    float filter = 2.0 * (luma.n + luma.e + luma.s + luma.w);
    filter += luma.ne + luma.nw + luma.se + luma.sw;
    filter *= 1.0 / 12.0;
    filter = abs(filter - luma.m);
    filter = saturate(filter / luma.range);
    filter = smoothstep(0, 1, filter);
    return filter * filter;
}
扩展滤波器和两个滤波器之间的绝对差
扩展滤波器和两个滤波器之间的绝对差
扩展滤波器和两个滤波器之间的绝对差

混合方向

在确定混合因子之后,下一步是决定混合哪两个像素。FXAA将中间像素与其直接邻居之一混合,所以是北、东、南或西邻居。选择这四个像素中的哪一个取决于对比度梯度的方向。在最简单的情况下,中间像素接触两个对比区域之间的水平或垂直边缘。在水平边缘的情况下,它应该是北或南邻居,具体取决于中间是在边缘下方还是上方。否则,它应该是东或西邻居,具体取决于中间是在边缘的左侧还是右侧。

四个可能的混合方向
四个可能的混合方向

边缘通常不是完全水平或垂直的,但我通过比较邻域中的水平和垂直对比度来选择最佳近似值。如果有水平边缘,那么中间上方或下方将有强烈的垂直对比度。我通过将北和南相加,减去中间的两倍,并取其绝对值来衡量这一点。相同的逻辑适用于垂直边缘,但使用东和西。如果水平结果大于垂直结果,那么我将其声明为水平边缘。创建一个函数来指示这一点,给定一个邻域。

1
2
3
4
5
bool IsHorizontalEdge (LumaNeighborhood luma) {
    float horizontal = abs(luma.n + luma.s - 2.0 * luma.m);
    float vertical = abs(luma.e + luma.w - 2.0 * luma.m);
    return horizontal >= vertical;
}

我可以通过包含对角邻居来提高边缘方向检测的质量。对于水平边缘,我对向东一步的三个像素和向西一步的三个像素执行相同的计算,对结果求和。同样,这些附加值离中间更远,所以我通过将中间对比度的权重加倍来将它们的相对重要性减半。相同的逻辑适用于垂直边缘对比度,但使用北和南偏移。

1
2
3
4
5
6
7
8
9
10
11
bool IsHorizontalEdge (LumaNeighborhood luma) {
    float horizontal =
        2.0 * abs(luma.n + luma.s - 2.0 * luma.m) +
        abs(luma.ne + luma.se - 2.0 * luma.e) +
        abs(luma.nw + luma.sw - 2.0 * luma.w);
    float vertical =
        2.0 * abs(luma.e + luma.w - 2.0 * luma.m) +
        abs(luma.ne + luma.nw - 2.0 * luma.n) +
        abs(luma.se + luma.sw - 2.0 * luma.s);
    return horizontal >= vertical;
}

现在引入一个FXAAEdge结构来包含有关检测到的边缘的信息。此时只是它是否为水平的。创建一个GetFXAAEdge方法,给定一个邻域返回该信息。

1
2
3
4
5
6
7
8
9
struct FXAAEdge {
    bool isHorizontal;
};

FXAAEdge GetFXAAEdge (LumaNeighborhood luma) {
    FXAAEdge edge;
    edge.isHorizontal = IsHorizontalEdge(luma);
    return edge;
}

FXAAPassFragment中获取边缘数据,然后使用它来可视化边缘的方向,例如使水平边缘为红色,垂直边缘为白色。此时我不关心混合因子。

1
2
3
4
5
6
7
8
9
float4 FXAAPassFragment (Varyings input) : SV_TARGET {
    LumaNeighborhood luma = GetLumaNeighborhood(input.screenUV);
    if (CanSkipFXAA(luma)) {
        return 0.0;
    }

    FXAAEdge edge = GetFXAAEdge(luma);
    return edge.isHorizontal ? float4(1.0, 0.0, 0.0, 0.0) : 1.0;
}
水平边缘为红色,垂直边缘为白色
水平边缘为红色,垂直边缘为白色

知道边缘方向告诉我在哪个维度中必须混合。如果是水平的,那么我将垂直跨越边缘混合,否则它是垂直的,我将水平跨越边缘混合。在UV空间中到下一个像素的距离取决于像素大小,这取决于混合方向。所以让我们向FXAAEdge添加像素步长的大小,并在GetFXAAEdge中初始化它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct FXAAEdge {
    bool isHorizontal;
    float pixelStep;
};

FXAAEdge GetFXAAEdge (LumaNeighborhood luma) {
    FXAAEdge edge;
    edge.isHorizontal = IsHorizontalEdge(luma);
    if (edge.isHorizontal) {
        edge.pixelStep = GetSourceTexelSize().y;
    }
    else {
        edge.pixelStep = GetSourceTexelSize().x;
    }
    return edge;
}

接下来,我必须确定是否应该在正方向或负方向混合。我通过比较中间适当方向的每一侧的对比度——luma梯度——来做到这一点。如果我有水平边缘,那么北是正邻居,南是负邻居。如果我有垂直边缘,那么东是正邻居,西是负邻居。

如果正梯度小于负梯度,那么中间在边缘的右侧,我必须在负方向混合,我通过否定步长来做到这一点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
float lumaP, lumaN;
if (edge.isHorizontal) {
    edge.pixelStep = GetSourceTexelSize().y;
    lumaP = luma.n;
    lumaN = luma.s;
}
else {
    edge.pixelStep = GetSourceTexelSize().x;
    lumaP = luma.e;
    lumaN = luma.w;
}
float gradientP = abs(lumaP - luma.m);
float gradientN = abs(lumaN - luma.m);
if (gradientP < gradientN) {
    edge.pixelStep = -edge.pixelStep;
}

现在我可以可视化混合方向,例如使正边缘为红色,负边缘为白色。

1
return edge.pixelStep > 0.0 ? float4(1.0, 0.0, 0.0, 0.0) : 1.0;
正边缘(左侧和底侧)为红色,负边缘为白色
正边缘(左侧和底侧)为红色,负边缘为白色

最终混合

此时我可以获得混合因子并知道向哪个方向混合。最终结果是通过使用混合因子在中间像素与其适当方向的邻居之间线性插值获得的。我可以通过简单地使用等于像素步长乘以混合因子的偏移来采样图像来做到这一点。此外,如果我跳过它,我必须返回原始像素。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
float4 FXAAPassFragment (Varyings input) : SV_TARGET {
    LumaNeighborhood luma = GetLumaNeighborhood(input.screenUV);
    if (CanSkipFXAA(luma)) {
        return GetSource(input.screenUV);
    }

    FXAAEdge edge = GetFXAAEdge(luma);
    float blendFactor = GetSubpixelBlendFactor(luma);
    float2 blendUV = input.screenUV;
    if (edge.isHorizontal) {
        blendUV.y += blendFactor * edge.pixelStep;
    }
    else {
        blendUV.x += blendFactor * edge.pixelStep;
    }
    return GetSource(blendUV);
}
有无混合对比
有无混合对比
有无混合对比

混合强度

FXAA不仅影响明显的高对比度边缘,它混合任何具有足够高对比度的东西,包括孤立像素。虽然这有助于减轻萤火虫,但它也会激进地模糊小细节,这通常是对FXAA的最大抱怨。

具有小细节的电路材料,有无子像素混合对比
具有小细节的电路材料,有无子像素混合对比
具有小细节的电路材料,有无子像素混合对比

FXAA可以通过简单地缩小其混合因子来控制子像素混合的强度。以下是其原始文档:

1
2
3
4
5
6
7
// 选择子像素锯齿消除的量。
// 这会影响锐度。
//   1.00 - 上限(更柔和)
//   0.75 - 默认过滤量
//   0.50 - 下限(更锐利,更少的子像素锯齿消除)
//   0.25 - 几乎关闭
//   0.00 - 完全关闭

我也使其可配置,通过向CameraSettings.FXAA添加一个0-1滑块用于子像素混合。

1
2
[Range(0f, 1f)]
public float subpixelBlending;

CustomRenderPipelineAsset中给它与原始相同的75%强度默认值。

1
2
3
4
5
fxaa = new CameraBufferSettings.FXAA {
    fixedThreshold = 0.0833f,
    relativeThreshold = 0.166f,
    subpixelBlending = 0.75f
}

然后在PostFXStack.DoFinal中将其添加到FXAA配置向量。

1
2
3
buffer.SetGlobalVector(fxaaConfigId, new Vector4(
    fxaa.fixedThreshold, fxaa.relativeThreshold, fxaa.subpixelBlending
));

并在GetSubpixelBlendFactor的末尾将其应用于混合因子。

1
2
3
4
float GetSubpixelBlendFactor (LumaNeighborhood luma) {
    ...
    return filter * filter * _FXAAConfig.z;
}
Image
子像素混合降低到75%
子像素混合降低到75%

沿边缘混合

因为像素混合因子是在3×3块内确定的,所以它只能平滑该尺度的特征。但边缘可以比这更长。像素可能最终位于倾斜边缘阶梯的长阶梯上的某个位置。虽然局部边缘是水平或垂直的,但真正的边缘通常处于另一个角度。如果我知道这个真实边缘,那么我可以更好地匹配相邻像素的混合因子,在整个长度上平滑边缘。

针垫几何体,全强度下有无子像素混合对比
针垫几何体,全强度下有无子像素混合对比
针垫几何体,全强度下有无子像素混合对比

相比之下,在渲染缩放2时,我获得了更好的边缘,因为更高的分辨率可以在整个长度上稍微平滑阶梯。也可以在增加的渲染缩放之上应用FXAA以获得更平滑的结果,但目前这对边缘质量没有太大影响。

渲染缩放2,不使用FXAA
渲染缩放2,不使用FXAA

边缘亮度

要弄清楚我正在处理什么类型的边缘,我必须跟踪更多信息。我知道3×3块的中间像素位于边缘的一侧,而至少一个其他像素位于相对侧。为了进一步识别边缘,我需要知道它的luma梯度。我已经在GetFXAAEdge中计算过了。我现在需要跟踪这个梯度和边缘另一侧的luma,所以将它们添加到边缘数据中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct FXAAEdge {
    bool isHorizontal;
    float pixelStep;
    float lumaGradient, otherLuma;
};

FXAAEdge GetFXAAEdge (LumaNeighborhood luma) {
    ...
    if (gradientP < gradientN) {
        edge.pixelStep = -edge.pixelStep;
        edge.lumaGradient = gradientN;
        edge.otherLuma = lumaN;
    }
    else {
        edge.lumaGradient = gradientP;
        edge.otherLuma = lumaP;
    }
    return edge;
}

引入一个GetEdgeBlendFactor函数,它返回边缘的单独混合因子。它需要luma邻域、边缘数据和像素UV坐标来做到这一点,所以为这些添加参数。我将从返回边缘的luma梯度开始。调整FXAAPassFragment,使其仅可视化新的边缘混合因子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
float GetEdgeBlendFactor (LumaNeighborhood luma, FXAAEdge edge, float2 uv) {
    return edge.lumaGradient;
}

float4 FXAAPassFragment (Varyings input) : SV_TARGET {
    LumaNeighborhood luma = GetLumaNeighborhood(input.screenUV);
    if (CanSkipFXAA(luma)) {
        return 0.0;
    }

    FXAAEdge edge = GetFXAAEdge(luma);
    float blendFactor = GetEdgeBlendFactor (luma, edge, input.screenUV);
    return blendFactor;
    float2 blendUV = input.screenUV;
    ...
}
边缘梯度
边缘梯度

追踪边缘

我必须弄清楚像素沿水平或垂直边缘段的相对位置。唯一的方法是沿边缘在两个方向上走,直到我找到端点。这可以通过沿边缘采样像素对并检查它们是否仍然类似于我最初检测到的边缘来完成。

搜索端点
搜索端点

但我不需要每一步采样两个像素。我可以在它们之间采样一个样本,这给我它们luma的平均值。这将足以确定边缘的结束。

搜索(黄色)和邻域(黑色)样本
搜索(黄色)和邻域(黑色)样本

要执行此搜索,我在GetEdgeBlendFactor中必须做的第一件事是确定在边缘上采样的UV坐标。我必须将原始UV坐标向边缘偏移半个像素步长。

1
2
3
4
5
6
7
8
9
10
float GetEdgeBlendFactor (LumaNeighborhood luma, FXAAEdge edge, float2 uv) {
    float2 edgeUV = uv;
    if (edge.isHorizontal) {
        edgeUV.y += 0.5 * edge.pixelStep;
    }
    else {
        edgeUV.x += 0.5 * edge.pixelStep;
    }
    return edge.lumaGradient;
}

之后,沿边缘单步的UV偏移取决于其方向。它要么是水平的,要么是垂直的。

1
2
3
4
5
6
7
8
9
10
float2 edgeUV = uv;
float2 uvStep = 0.0;
if (edge.isHorizontal) {
    edgeUV.y += 0.5 * edge.pixelStep;
    uvStep.x = GetSourceTexelSize().x;
}
else {
    edgeUV.x += 0.5 * edge.pixelStep;
    uvStep.y = GetSourceTexelSize().y;
}

我要做的是确定采样的luma值与最初检测到的边缘上的luma平均值之间的对比度。如果这种对比度变得太大,那么我已经离开了边缘。FXAA使用边缘的luma梯度的四分之一作为此检查的阈值。所以我必须跟踪这个阈值和初始边缘luma平均值。

1
2
3
float edgeLuma = 0.5 * (luma.m + edge.otherLuma);
float gradientThreshold = 0.25 * edge.lumaGradient;
return edge.lumaGradient;

我从在正方向上走一步开始。确定正向偏移UV坐标,计算该偏移与原始边缘之间的luma梯度,并检查它是否等于或超过阈值。这告诉我是否在正方向的边缘末端。如果我直接可视化这个检查,那么我将只看到直接位于边缘端点旁边的那些像素。

1
2
3
4
5
6
7
8
float edgeLuma = 0.5 * (luma.m + edge.otherLuma);
float gradientThreshold = 0.25 * edge.lumaGradient;

float2 uvP = edgeUV + uvStep;
float lumaGradientP = abs(GetLuma(uvP) - edgeLuma);
bool atEndP = lumaGradientP >= gradientThreshold;

return atEndP;
正方向上到端点的一步
正方向上到端点的一步

要走完整条边缘,我必须在循环中重复此过程,只要我还没有到达末端。我还必须在某个点终止此过程,这样它就不会永远继续下去,比如在最多100步后,所以循环应该允许再进行99步。

1
2
3
4
5
6
7
8
9
float2 uvP = edgeUV + uvStep;
float lumaGradientP = abs(GetLuma(uvP) - edgeLuma);
bool atEndP = lumaGradientP >= gradientThreshold;

for (int i = 0; i < 99 && !atEndP; i++) {
    uvP += uvStep;
    lumaGradientP = abs(GetLuma(uvP) - edgeLuma);
    atEndP = lumaGradientP >= gradientThreshold;
}
正方向上最多100步
正方向上最多100步

一旦我完成搜索,我可以通过从最终偏移分量中减去适当的原始UV坐标分量来找到UV空间中到正端的距离。然后我可以可视化距离,将其放大以使其更容易看到。

1
2
3
4
5
6
7
8
float distanceToEndP;
if (edge.isHorizontal) {
    distanceToEndP = uvP.x - uv.x;
}
else {
    distanceToEndP = uvP.y - uv.y;
}
return 10.0 * distanceToEndP;
UV空间中到正端的距离,×10
UV空间中到正端的距离,×10

负方向

我还必须在负方向上做同样的事情,所以复制相关代码并适当调整它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
float2 uvP = edgeUV + uvStep;
float lumaGradientP = abs(GetLuma(uvP) - edgeLuma);
bool atEndP = lumaGradientP >= gradientThreshold;
int i;
for (i = 0; i < 99 && !atEndP; i++) {
    uvP += uvStep;
    lumaGradientP = abs(GetLuma(uvP) - edgeLuma);
    atEndP = lumaGradientP >= gradientThreshold;
}

float2 uvN = edgeUV - uvStep;
float lumaGradientN = abs(GetLuma(uvN) - edgeLuma);
bool atEndN = lumaGradientN >= gradientThreshold;
for (i = 0; i < 99 && !atEndN; i++) {
    uvN -= uvStep;
    lumaGradientN = abs(GetLuma(uvN) - edgeLuma);
    atEndN = lumaGradientN >= gradientThreshold;
}

然后确定到负端的距离,它的工作方式与到正端的相同,但取反。

1
2
3
4
5
6
7
8
9
float distanceToEndP, distanceToEndN;
if (edge.isHorizontal) {
    distanceToEndP = uvP.x - uv.x;
    distanceToEndN = uv.x - uvN.x;
}
else {
    distanceToEndP = uvP.y - uv.y;
    distanceToEndN = uv.y - uvN.y;
}

我现在可以找到到边缘最近端的距离并可视化它。

1
2
3
4
5
6
7
8
float distanceToNearestEnd;
if (distanceToEndP <= distanceToEndN) {
    distanceToNearestEnd = distanceToEndP;
}
else {
    distanceToNearestEnd = distanceToEndN;
}
return 10.0 * distanceToNearestEnd;
到最近端的距离
到最近端的距离

请注意,找到的距离在大多数情况下似乎是有意义的,但并非总是如此。因为FXAA是一个近似值,所以它有时会错误地猜测或错过边缘的末端。

单侧混合

此时我知道到边缘最近端点的距离,我可以用它来确定混合因子。但我只会在边缘向包含中间像素的区域倾斜的方向上这样做。这确保我只在边缘的一侧混合像素。

选择混合哪一侧
选择混合哪一侧

要确定方向,我需要知道搜索时获得的最后一个梯度的方向。为了使这成为可能,我将更改代码以跟踪luma增量而不是绝对梯度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
float2 uvP = edgeUV + uvStep;
float lumaDeltaP = GetLuma(uvP) - edgeLuma;
bool atEndP = abs(lumaDeltaP) >= gradientThreshold;
int i;
for (i = 0; i < 99 && !atEndP; i++) {
    uvP += uvStep;
    lumaDeltaP = GetLuma(uvP) - edgeLuma;
    atEndP = abs(lumaDeltaP) >= gradientThreshold;
}

float2 uvN = edgeUV - uvStep;
float lumaDeltaN = GetLuma(uvN) - edgeLuma;
bool atEndN = abs(lumaDeltaN) >= gradientThreshold;
for (i = 0; i < 99 && !atEndN; i++) {
    uvN -= uvStep;
    lumaDeltaN = GetLuma(uvN) - edgeLuma;
    atEndN = abs(lumaDeltaN) >= gradientThreshold;
}

现在我可以确定最终增量的符号。我可以通过检查它是否大于或等于零来做到这一点。

1
2
3
4
5
6
7
8
9
10
float distanceToNearestEnd;
bool deltaSign;
if (distanceToEndP <= distanceToEndN) {
    distanceToNearestEnd = distanceToEndP;
    deltaSign = lumaDeltaP >= 0;
}
else {
    distanceToNearestEnd = distanceToEndN;
    deltaSign = lumaDeltaN >= 0;
}

如果最终符号与原始边缘的符号匹配,那么我正在远离边缘,应该跳过混合,返回零。

1
2
3
4
5
6
if (deltaSign == (luma.m - edgeLuma >= 0)) {
    return 0.0;
}
else {
    return 10.0 * distanceToNearestEnd;
}
仅单侧的距离
仅单侧的距离

最终混合因子

如果我在边缘的正确一侧,那么我以0.5减去到边缘最近端点的相对距离的因子混合。这意味着我越接近端点混合越多,在边缘中间根本不会混合。

1
2
3
4
5
6
if (deltaSign == (luma.m - edgeLuma >= 0)) {
    return 0.0;
}
else {
    return 0.5 - distanceToNearestEnd / (distanceToEndP + distanceToEndN);
}
边缘混合因子
边缘混合因子

现在调整FXAAPassFragment,这样我可以看到边缘混合的结果。

1
2
3
4
5
6
7
8
if (CanSkipFXAA(luma)) {
    return GetSource(input.screenUV);
}

FXAAEdge edge = GetFXAAEdge(luma);
float blendFactor = GetEdgeBlendFactor (luma, edge, input.screenUV);
//return blendFactor;
float2 blendUV = input.screenUV;
仅边缘混合和仅子像素混合,全强度
仅边缘混合和仅子像素混合,全强度
仅边缘混合和仅子像素混合,全强度

要应用边缘和子像素混合,我使用两者中最大的混合因子。

1
2
3
float blendFactor = max(
    GetSubpixelBlendFactor(luma), GetEdgeBlendFactor (luma, edge, input.screenUV)
);
边缘混合与0.75的子像素混合结合
边缘混合与0.75的子像素混合结合

有限边缘搜索

如果边缘几乎是水平或垂直的,搜索边缘的端点可能需要很长时间。在任一方向上最多100个样本太多,无法保证可接受的性能。所以我必须更早地终止搜索,但这将使FXAA无法检测更长的边缘。为了清楚地说明这一点,将GetEdgeBlendFactor中的搜索减少到任一方向上最多四个像素,所以在三步后终止循环。

1
2
3
for (i = 0; i < 3 && !atEndP; i++) { ... }
...
for (i = 0; i < 3 && !atEndN; i++) { ... }
最多100步和仅最多四步
最多100步和仅最多四步
最多100步和仅最多四步

结果是所有距离超过四个像素的端点都被视为距离四个像素,这降低了FXAA的质量。如果循环在找到边缘之前终止,那么我低估了距离,因为末端至少还有一步的距离。因此,如果我没有找到它,我可以通过添加另一步来猜测真实距离来稍微改善结果。如果我在四步后没有找到它,那么我猜测真实距离是五。

1
2
3
4
5
6
7
8
9
for (i = 0; i < 3 && !atEndP; i++) { ... }
if (!atEndP) {
    uvP += uvStep;
}
...
for (i = 0; i < 3 && !atEndN; i++) { ... }
if (!atEndN) {
    uvN -= uvStep;
}
最多四步带额外猜测
最多四步带额外猜测

边缘质量

我允许边缘搜索走多远限制了结果的质量和所需时间。所以这是质量和性能之间的权衡,这意味着没有单一的最佳选择。为了使我的方法可配置,我将为额外边缘步数引入定义语句、额外步数的偏移列表,以及当我必须停止搜索时使用的最后边缘步数猜测的偏移。使用这些来创建边缘步长大小的静态常量数组,然后在GetEdgeBlendFactor中使用它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#define EXTRA_EDGE_STEPS 3
#define EDGE_STEP_SIZES 1.0, 1.0, 1.0
#define LAST_EDGE_STEP_GUESS 1.0

static const float edgeStepSizes[EXTRA_EDGE_STEPS] = { EDGE_STEP_SIZES };

float GetEdgeBlendFactor (LumaNeighborhood luma, FXAAEdge edge, float2 uv) {
    ...
    for (i = 0; i < EXTRA_EDGE_STEPS && !atEndP; i++) {
        uvP += uvStep * edgeStepSizes[i];
        lumaDeltaP = GetLuma(uvP) - edgeLuma;
        atEndP = abs(lumaDeltaP) >= gradientThreshold;
    }
    if (!atEndP) {
        uvP += uvStep * LAST_EDGE_STEP_GUESS;
    }
    ...
    for (i = 0; i < EXTRA_EDGE_STEPS && !atEndN; i++) {
        uvN -= uvStep * edgeStepSizes[i];
        lumaDeltaN = GetLuma(uvN) - edgeLuma;
        atEndN = abs(lumaDeltaN) >= gradientThreshold;
    }
    if (!atEndN) {
        uvN -= uvStep * LAST_EDGE_STEP_GUESS;
    }
    ...
}

我明确地为步长大小创建一个数组,这样我就可以改变它们。例如,原始FXAA算法包含多个质量预设,它们在步数和步长大小上都有所不同。质量预设22是一个快速低质量预设,有三个额外步骤。第一个额外步骤——在单个像素的初始偏移之后——偏移为1.5。这个额外的半像素偏移意味着我最终沿边缘采样四个像素的正方形的平均值,而不是单对。之后的两个步骤大小为2,每个再次采样四个像素的正方形而不是对。因此,它仅用四个样本覆盖最多七个像素的距离。如果它未能检测到末端,它猜测它至少还有八步远。

1
2
3
#define EXTRA_EDGE_STEPS 3
#define EDGE_STEP_SIZES 1.5, 2.0, 2.0
#define LAST_EDGE_STEP_GUESS 8.0
低质量FXAA
低质量FXAA

使用这些设置,我获得低质量结果,但它们比我将额外步长大小固定为1时更能处理更长的边缘。缺点是边缘可能看起来有点抖动。这是由产生不太准确结果的更大步长引起的。

让我们使用当前配置用于低质量FXAA,仅在定义了FXAA_QUALITY_LOW时使用它,目前还没有定义。否则,我将使用对应于质量预设26的设置。它使用与预设22相同的方法,但有八个额外样本,除了最后一个步长为4以跳过一步以向前看得更远外,其他都是步长2。

1
2
3
4
5
6
7
8
9
#if defined(FXAA_QUALITY_LOW)
    #define EXTRA_EDGE_STEPS 3
    #define EDGE_STEP_SIZES 1.5, 2.0, 2.0
    #define LAST_EDGE_STEP_GUESS 8.0
#else
    #define EXTRA_EDGE_STEPS 8
    #define EDGE_STEP_SIZES 1.5, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 4.0
    #define LAST_EDGE_STEP_GUESS 8.0
#endif
中等质量FXAA
中等质量FXAA

让我们将此配置用于中等质量——当定义了FXAA_QUALITY_MEDIUM时——并添加与预设39匹配的最终默认配置。这是一个高质量配置,有十个额外步骤,仅在四个额外对采样后才切换到正方形块采样,再次为最后一步跳过并猜测为8。

1
2
3
4
5
6
7
8
9
10
11
12
13
#if defined(FXAA_QUALITY_LOW)
    #define EXTRA_EDGE_STEPS 3
    #define EDGE_STEP_SIZES 1.5, 2.0, 2.0
    #define LAST_EDGE_STEP_GUESS 8.0
#elif defined(FXAA_QUALITY_MEDIUM)
    #define EXTRA_EDGE_STEPS 8
    #define EDGE_STEP_SIZES 1.5, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 4.0
    #define LAST_EDGE_STEP_GUESS 8.0
#else
    #define EXTRA_EDGE_STEPS 10
    #define EDGE_STEP_SIZES 1.0, 1.0, 1.0, 1.0, 1.5, 2.0, 2.0, 2.0, 2.0, 4.0
    #define LAST_EDGE_STEP_GUESS 8.0
#endif
高质量FXAA
高质量FXAA

要允许选择质量级别,向PostFXStack着色器的两个FXAA pass添加multi-compile指令。我只需要低质量和中等质量版本的关键字,对高质量版本使用没有关键字的默认值。

1
2
#pragma fragment FXAAPassFragment
#pragma multi_compile _ FXAA_QUALITY_MEDIUM FXAA_QUALITY_LOW

CameraBufferSettings.FXAA添加相应的质量配置选项。

1
2
3
public enum Quality { Low, Medium, High }

public Quality quality;

然后在PostFXStack中启用或禁用适当的关键字。在新的ConfigureFXAA方法中执行此操作,并将设置配置向量的代码也移到那里。然后在DoFinal中的适当时刻调用它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
const string
    fxaaQualityLowKeyword = "FXAA_QUALITY_LOW",
    fxaaQualityMediumKeyword = "FXAA_QUALITY_MEDIUM";
...

void ConfigureFXAA () {
    if (fxaa.quality == CameraBufferSettings.FXAA.Quality.Low) {
        buffer.EnableShaderKeyword(fxaaQualityLowKeyword);
        buffer.DisableShaderKeyword(fxaaQualityMediumKeyword);
    }
    else if (fxaa.quality == CameraBufferSettings.FXAA.Quality.Medium) {
        buffer.DisableShaderKeyword(fxaaQualityLowKeyword);
        buffer.EnableShaderKeyword(fxaaQualityMediumKeyword);
    }
    else {
        buffer.DisableShaderKeyword(fxaaQualityLowKeyword);
        buffer.DisableShaderKeyword(fxaaQualityMediumKeyword);
    }
    buffer.SetGlobalVector(fxaaConfigId, new Vector4(
        fxaa.fixedThreshold, fxaa.relativeThreshold, fxaa.subpixelBlending
    ));
}

void DoFinal (int sourceId) {
    ...
    if (fxaa.enabled) {
        ConfigureFXAA();
        buffer.GetTemporaryRT(
            colorGradingResultId, bufferSize.x, bufferSize.y, 0,
            FilterMode.Bilinear, RenderTextureFormat.Default
        );
        Draw(
            sourceId, colorGradingResultId,
            keepAlpha ? Pass.ApplyColorGrading : Pass.ApplyColorGradingWithLuma
        );
    }
    ...
}
FXAA设置为高质量
FXAA设置为高质量

这些质量预设只是示例,你可以根据自己的喜好配置它们。也可以将FXAA与渲染缩放加倍结合以获得更好的结果。请记住,FXAA在调整后的渲染缩放下运行,所以这相当昂贵。

渲染缩放2,有无高质量FXAA对比
渲染缩放2,有无高质量FXAA对比
渲染缩放2,有无高质量FXAA对比

你不需要一路加倍。例如,你可以将FXAA与渲染缩放4/3结合。这将使像素数量增加1.78倍而不是四倍。这是Timothy Lottes在他的SIGGRAPH2011演讲《实时抗锯齿的过滤方法》中建议的。

双线性渲染缩放1.333333,有无高质量FXAA对比
双线性渲染缩放1.333333,有无高质量FXAA对比
双线性渲染缩放1.333333,有无高质量FXAA对比

这可以通过使用双三次重新缩放进一步平滑。

双三次渲染缩放1.333333,有无高质量FXAA对比
双三次渲染缩放1.333333,有无高质量FXAA对比
双三次渲染缩放1.333333,有无高质量FXAA对比

也可以使用FXAA来改善降低渲染缩放的结果。

双三次渲染缩放0.5,有无高质量FXAA对比
双三次渲染缩放0.5,有无高质量FXAA对比
双三次渲染缩放0.5,有无高质量FXAA对比

展开循环

因为我的循环有保证的最大迭代次数,所以可以展开它们,这意味着我用一系列条件代码块替换它们。我不必显式这样做,我可以让着色器编译器通过在循环之前放置UNITY_UNROLL来做到这一点。这为它们添加了展开属性。

1
2
3
4
5
6
int i;
UNITY_UNROLL
for (i = 0; i < EXTRA_EDGE_STEPS && !atEndP; i++) { ... }
...
UNITY_UNROLL
for (i = 0; i < EXTRA_EDGE_STEPS && !atEndN; i++) { ... }

结果证明这始终稍微提高了性能,预计不会超过1 FPS的增益。虽然这不多,但它是免费的。

原始FXAA算法还组合了两个循环,在锁步中在两个方向上搜索。每次迭代,只有尚未完成的方向才会前进并再次采样。这在某些情况下可能更快,但在我的情况下,两个单独的循环比单个循环表现稍好。一如既往,如果你想要绝对最佳性能,请自己测试,每个项目、每个目标平台。

This post is licensed under CC BY 4.0 by the author.