Fast approximate Anti-Aliasing 快速近似抗锯齿
-
计算并存储像素亮度,或使用绿色
-
找到并混合高对比度像素
-
发现并平滑长边


FXAA抗锯齿(上)对比
1 FXAA Post-FX
当分辨率不够大时,就会有锯齿,就是那些没有对齐到像素格子的(斜)线。此外,小于一个像素的点在与摄像机相对移动,则会时隐时现,也会导致闪烁。
在 render scale 的教程中,通过将 render scale 设置为 2 进行渲染,最后下采样恢复缩放,得以支持 SSAA(Super Sampling Anti-Aliasing)。这一定程度上平滑了锯齿,同时需要以双倍的分辨率渲染,虽然提升了渲染质量,但是需要4倍的片段渲染量,需要更多的GPU时间,因此极其昂贵,通常不会再运行时使用该方案。而小于2对提升画面质量作用不大。
一个替代方案是在原始图像上应用一个 post FX 来平滑并消除锯齿。现在已经发展出来了各种抗锯齿的方案,都是以AA为后缀,如 FXAA, MLAA, SMAA, TAA,MSAA。我们接下来要实现的是 FXAA,是最简单,也是最快的一种方法。
MLAA:Morphological Antialiasing(形态抗锯齿),一种后处理抗锯齿技术,通过分析渲染图像的像素边缘形态,识别出锯齿状的边缘,并进行模糊,从而减少视觉上的锯齿感。
FXAA:一种受 MLAA 启发的简单方法,是一种快速近似抗锯齿,与MLAA相比,牺牲了图像质量,获得更快的速度。其缺点就是图像过于模糊,效果取决于具体的变体,以及参数的调整。我们要实现的是 FXAA 3.11,一个高质量的版本,可以检测长边。
SMAA:Subpixel Morphological Antialiasing(子像素形态抗锯齿),一种比 MLAA 和 FXAA 更先进的后处理抗锯齿技术,旨在结合前两者的优点,达到更好的效果
TAA:Temporal Anti-Aliasing (时间性抗锯齿或时域抗锯齿),利用时间维度,也就是多帧信息,通过复用前一帧的数据来累积样本,达到更高效的抗锯齿效果。
MSAA:Multi-Sampling Anti-Aliasing(多重采样抗锯齿),同 SSAA 一样,需要2倍分辨率的 frame buffer,每4个对应一个原始像素。在渲染时,每个片段依然只计算一次,根据片段对4个像素的覆盖比例,执行混合并写入颜色。这通常是硬件支持的,我们只需要正确的设置设备状态即可。
1.1 Enabling FXAA
虽然 FXAA 是一个后效,但是跟 render scale 一样会影响这个歌图像的质量,因此在 CameraBufferSettings 增加配置。我们先增加一个开关,后续会再增加更多的选项,因此专门定义结构来将相关参数组织到一起。
cs
public class CameraBufferSettings
{
...
[Serializable]
public struct FXAA
{
public bool enabled;
}
public FXAA fxaa;
}
同时,我们给 CameraSettings 增加一个开关,以支持每个摄像机分别开关。
cs
public bool allowFXAA = false;
FXAA 是一种后效,因此由 post FX 应用,所以只有启用了 post FX,FXAA 才会生效。通过PostFXStack.Setup 方法完成参数传递,并存储下来。
cs
public void Setup(..., CameraBufferSettings.FXAA fxaa)
{
...
this.fxaa = fxaa;
ApplySceneViewState();
}
在 CameraRenderer.Render 中,应用摄像机的开关后,传递参数。由于 cameraBufferSettings 是值传递的参数,因此可以直接修改。
cs
public void Render(ScriptableRenderContext context,
Camera camera,
CameraBufferSettings cameraBufferSettings,...)
{
...
cameraBufferSettings.fxaa.enabled &= cameraSettings.allowFXAA;
postFXStack.Setup(..., cameraBufferSettings.fxaa);
...
1.2 FXAA Pass
首先创建 FXAAPass.hlsl 文件,在其中定义 FXAAPassFragment
cs
#pragma once
float4 FXAAPassFragment(Varyings input) :SV_Target
{
return GetSource(input.screenUV);
}
然后在 PostFXStack.shader 定义新的 pass,同时添加对应的枚举值。
cs
Pass
{
Name "FXAA"
Blend [_FinalSrcBlend] [_FinalDstBlend]
HLSLPROGRAM
#pragma target 3.5
#pragma vertex DefaultPassVertex
#pragma fragment FXAAPassFragment
#include "FXAAPass.hlsl"
ENDHLSL
}
FXAA 要在 color grading 之后应用,之前的 render scale 也是在 color grading 之后,因此 color grading 已经不是真正的 final pass 了,因此将该函数改名为 ApplyColorGrading,同时修改 .shader 和枚举值的定义。
PostFXStack.DoColorGradingAndToneMapping 需要实现更多的逻辑,因此改名为 DoFinal。
如果开启了 fxaa 将 color grading 渲染达到一个中间 LDR render target 上,然后再用 fxaa pass 完成 final draw
cs
// 确保混合模式为 one zero
buffer.SetGlobalFloat(finalSrcBlendId, 1f);
buffer.SetGlobalFloat(finalDstBlendId, 0f);
if (fxaa.enabled)
{
buffer.GetTemporaryRT(colorGradingResultId, bufferSize.x, bufferSize.y, 0, FilterMode.Bilinear, RenderTextureFormat.Default);
Draw(sourceId, colorGradingResultId, Pass.ApplyColorGrading);
}
// 没有缩放,直接 draw final
if (bufferSize.x == camera.pixelWidth)
{
if (fxaa.enabled)
{
DrawFinal(colorGradingResultId, Pass.FXAA);
buffer.ReleaseTemporaryRT(colorGradingResultId);
}
else
{
DrawFinal(sourceId, Pass.ApplyColorGrading);
}
}
// 有缩放,现在缩放过的尺寸上进行 color grading and tone mapping,然后在通过 final rescale pass 复制到 camera target
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);
}
bool isBicubicRescaling = bicubicRescaling == CameraBufferSettings.BicubicRescalingMode.UpAndDown
|| (bicubicRescaling == CameraBufferSettings.BicubicRescalingMode.UpOnly && bufferSize.x < camera.pixelWidth);
buffer.SetGlobalFloat(bicubicRescalingId, isBicubicRescaling ? 1f : 0f);
DrawFinal(finalResultId, Pass.FinalRescale);
buffer.ReleaseTemporaryRT(finalResultId);
}
buffer.ReleaseTemporaryRT(colorGradingLUTId);
虽然渲染结果看起来没什么不同,但是我们已经加入了 fxaa 渲染流程。
1.3 Luma
FXAA 通过选择性的降低图像对比度,来平滑明显的锯齿以及孤立的像素。对比度由像素的感知强度来决定。由于目标是减少我们所感知到的锯齿,因此 FXAA 只关注感知亮度,即经过伽马校正的亮度,也称为亮度值,像素的精确颜色不重要,重要的是其感知亮度,所以 FXAA 分析灰度图,也就意味着颜色的跳变,当其亮度相似时,不会被平滑,只有那些明显可见的变化才会处理。
在 FXAAPass.hlsl 实现 GetLuma 函数,计算指定屏幕UV的亮度。这里先返回线性亮度,然后让 FXAAPassFragment 直接返回,看看效果
cs
float GetLuma(float2 uv)
{
return Luminance(GetSource(uv));
}
float4 FXAAPassFragment(Varyings input) :SV_Target
{
return GetLuma(input.screenUV);
}

因为我们对暗色的变化比对亮色更加敏感,因此需要对亮度应用 gamma 调整来将线性亮度转变为合适的亮度。gamma 值取 2 就足够准确了,因此我们返回线性亮度的平方。
cs
float GetLuma(float2 uv)
{
return sqrt(Luminance(GetSource(uv)));
}

1.4 Green for Luma
FXAA 是通过检测对比度和边缘来工作的,因此每个片段就需要多次采样,这样每次采样都计算亮度代价就太高昂了。因为我们对绿色是最敏感的,因此一个替代方法是用绿色通道的值作为亮度,这降低了质量,但是可以避免开平方根。
cs
float GetLuma(float2 uv)
{
return GetSource(uv).g;
}

1.5 Storing Luma in the Alpha Channel
计算的亮度要比绿色通道效果好,但是又不想每次采样(FXAA每个片段要采样多次)时都计算一遍。一个方案是在应用 color grading 时计算一次,然后存储到 color grading result 的 alpha 通道。但是如果后面需要 alpha 值时,就不行了,比如多层摄像机叠加渲染。
由于 alpha 通道并不经常用到,因此我们依然采用上面的方案,创建一个新的 pass,应用 color grading 时将亮度写入 alpha 通道
cs
// .shader
Pass
{
Name "Apply Color Grading with Luma"
//Blend One OneMinusSrcAlpha
Blend [_FinalSrcBlend] [_FinalDstBlend]
HLSLPROGRAM
#pragma target 3.5
#pragma vertex DefaultPassVertex
#pragma fragment ApplyColorGradingWithLumaPassFragment
ENDHLSL
}
// .hlsl
float4 ApplyColorGradingPassFragment(Varyings input) : SV_TARGET
{
float4 color = GetSource(input.screenUV);
color.rgb = ApplyColorGradingLUT(color.rgb);
return color;
}
对应的,如果 color grading 输出了亮度,那么就不用计算亮度了,因此定义一个使用 a 通道作为亮度的 fxaa 版本。在 hlsl 中我们用宏来区分两种不同的逻辑
cs
// fxaa.hlsl
float GetLuma(float2 uv)
{
#if defined(FXAA_ALPHA_CONTAINS_LUMA)
return GetSource(uv).a;
#else
//return sqrt(Luminance(GetSource(uv)));
// FXAA要执行多彩采样,开方太昂贵
// 人眼对绿色最敏感,因此用绿色通道作为亮度,避免开方
return GetSource(uv).g;
#endif
}
// postFXStack.shader
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
}
不要忘记为新增加的 pass 定义对应的枚举值。
1.6 Keeping Alpha
只用当 a 通道不能改变(有用)时,才使用 g 通道作为亮度,否则我们总是计算亮度。a 通道是否有用,取决于渲染的图像的用法,因此需要每个摄像机都能配置 a 通道是否有用,需要保持,因此给 CameraSettings 定义开关,默认是不保持(可以用来存储 luma)。
cs
public bool allowFXAA = true;
public bool keepAlpha = false;
在 CameraRenderer.Render 方法中,设置 post FX stack 时将该参数传递给 post FX stack。因为该参数和 HDR 一样,关系到纹理数据的性质,因此放到 useHDR 参数前面。并在 postFXStack.cs 中,接受参数并缓存下来。
cs
// postFXStack.cs
public void Setup(ScriptableRenderContext context,
Camera camera, PostFXSettings settings, bool keepAlpha, bool useHDR,
...
)
{
this.context = context;
this.camera = camera;
this.keepAlpha = keepAlpha;
...
}
然后根据该参数,在 DoFinal 函数中的 color grading 和 fxaa 过程中,选择是否是 withLuma 的版本
cs
void DoFinal(int sourceId)
{
...
if (fxaa.enabled)
{
buffer.GetTemporaryRT(colorGradingResultId, bufferSize.x, bufferSize.y, 0, FilterMode.Bilinear, RenderTextureFormat.Default);
Draw(sourceId, colorGradingResultId,
keepAlpha ? Pass.ApplyColorGrading : Pass.ApplyColorGradingWithLuma);
}
// 没有缩放,直接 draw final
if (bufferSize.x == camera.pixelWidth)
{
if (fxaa.enabled)
{
DrawFinal(colorGradingResultId, keepAlpha ? Pass.FXAA : Pass.FXAAWithLuma);
buffer.ReleaseTemporaryRT(colorGradingResultId);
}
else
{
DrawFinal(sourceId, Pass.ApplyColorGrading);
}
}
// 有缩放,现在缩放过的尺寸上进行 color grading and tone mapping,然后在通过 final rescale pass 复制到 camera target
else
{
buffer.GetTemporaryRT(finalResultId, bufferSize.x, bufferSize.y, 0, FilterMode.Bilinear, RenderTextureFormat.Default);
if (fxaa.enabled)
{
Draw(colorGradingResultId, finalResultId,
keepAlpha ? Pass.FXAA : Pass.FXAAWithLuma);
buffer.ReleaseTemporaryRT(colorGradingResultId);
}
else
{
Draw(sourceId, finalResultId, Pass.ApplyColorGrading);
}
...
}
目前 fxaa 直接输出亮度的,因此可以通过切换开关,验证流程的正确定,当勾选 keep alpha,则用 g 通道作为亮度,比计算的亮度要暗一些。
2 Subpixel Blending
FXAA 的工作原理是混合那些高对比度的相邻像素,而不是对整张图进行模糊。首先要计算源像素周围的局部对比度,即亮度亮度范围。然后,如果对比度足够大,满足一定条件,则基于对比度选择一个混合因子。第三步,根据局部对比度的梯度决定混合方向。最后,在源像素和合适的相邻像素之间进行混合。
2.1 Luma Neighborhood
通过采样源像素相邻的像素的亮度来获得。为方便该操作,为 GetLuma 增加uv偏移参数,这样可以沿着UV以像素为单位进行偏移。
cs
float GetLuma(float2 uv, float uOffset = 0.0, float vOffset = 0.0)
{
uv += float2(uOffset, vOffset) * GetSourceTexelSize().xy;
...
}
处理了源像素,我们还需要采样其直接相邻像素,我们用指南针在标识它们的方向。所以最终采样5个像素:中间(源)像素,北,东,南,西面的邻居像素

定义一个 LumaNeighborhood 结构体,来存储这些像素的亮度,并定义方法进行填充。然后从中找出最亮和最暗的值,计算范围,并将这三个值存储到结构体中。
cs
struct LumaNeighborhood
{
float m, n, e, s, w;
float lowest, highest, range;
};
LumaNeighborhood GetLumaNeighborhood(float2 uv)
{
LumaNeighborhood luma;
luma.m = GetLuma(uv);
luma.n = GetLuma(uv, 0, 1);
luma.e = GetLuma(uv, 1, 0);
luma.s = GetLuma(uv, 0, -1);
luma.w = GetLuma(uv, -1, 0);
return luma;
}
float4 FXAAPassFragment(Varyings input) :SV_Target
{
LumaNeighborhood luma = GetLumaNeighborhood(input.screenUV);
luma.lowest = min(luma.m, min(luma.n, min(luma.e, min(luma.s, luma.w))));
luma.highest = max(luma.m, max(luma.n, max(luma.e, max(luma.s, luma.w))));
luma.range = luma.highest - luma.lowest;
return luma.m;
}
如果先让 fxaa fragment 直接返回 range,则可以看到我们识别出来了模型的边缘。放大可以看到边缘线是2个像素宽,这是因为边缘的两侧各有一个像素。边缘的亮度对比度越高,边缘线就越亮。

2.2 Fixed Threshold
只有那些对比度足够高的像素才需要混合,最简单的区分这些像素的方法是引入一个对比度阈值,如果像素的对比度范围没有达到该值,这个像素就不需要混合。这种叫做固定阈值,因为后面我们还会介绍相对阈值。
在 CameraBufferSettings.FXAA 结构体中加入固定阈值滑动条,原始的 FXAA 算法也有该阈值,并作出了解释:
cs
// Trims the algorithm from processing darks.
// 0.0833 - upper limit (default, the start of visible unfiltered edges)
// 0.0625 - high quality (faster)
// 0.0312 - visible limit (slower)
尽管文档提到了暗部剔除,但是它是基于对比度的剔除,而不管是亮还是暗。我们也使用原始 FXAA 文档中提到的值的范围。
cs
public struct FXAA
{
public bool enabled;
[Range(0.0312f, 0.0833f)]
public float fixedThreshold;
}
默认值也按照文档中提到的 0.0833
cs
[SerializeField] CameraBufferSettings cameraBuffer = new CameraBufferSettings() {
allowHDR = true,
renderScale = 1.0f,
fxaa = new CameraBufferSettings.FXAA() { fixedThreshold = 0.0833f}
};
定义 _FXAAConfig shader 常量,如果 fxaa 开启,则将阈值上传到 shader
cs
// postFXStack.cs
CameraBufferSettings.FXAA fxaa;
int fxaaConfigId = Shader.PropertyToID("_FXAAConfig");
...
if (fxaa.enabled)
{
buffer.SetGlobalVector(fxaaConfigId, new Vector4(fxaa.fixedThreshold, 0f));
buffer.GetTemporaryRT(colorGradingResultId, bufferSize.x, bufferSize.y, 0, FilterMode.Bilinear, RenderTextureFormat.Default);
...
shader 中接受配置参数,定义判断是否跳过 FXAA 的函数。
cs
float4 _FXAAConfig;
bool CanSkipFXAA(float luma)
{
return luma < _FXAAConfig.x;
}
float4 FXAAPassFragment(Varyings input) :SV_Target
{
LumaNeighborhood luma = GetLumaNeighborhood(input.screenUV);
luma.lowest = min(luma.m, min(luma.n, min(luma.e, min(luma.s, luma.w))));
luma.highest = max(luma.m, max(luma.n, max(luma.e, max(luma.s, luma.w))));
luma.range = luma.highest - luma.lowest;
if(CanSkipFXAA(luma.range))
return 0.0;
return luma.range;
}

如果跳过,则输出黑色,来验证我们的结果。上图中跳过FXAA的像素是纯黑色,低对比度的区域被剔除了,保留多少取决于阈值。
2.3 Relative Threshold
FXAA 还有一个相对于最亮邻居的阈值,邻居越亮,就需要越高的对比度阈值。原始的 FXAA 文档也作出了说明:
cs
// The minimum amount of local contrast required to apply algorithm.
// 0.333 - too little (faster)
// 0.250 - low quality
// 0.166 - default
// 0.125 - high quality
// 0.063 - overkill (slower)
在 FXAA 中定义该阈值,范围和默认值遵循上面文档中的定义,然后将其存储到 _FXAAConfig 的 y 分量中。
cs
// CameraBufferSettings.cs
public struct FXAA
{
public bool enabled;
[Range(0.0312f, 0.0833f)]
public float fixedThreshold;
[Range(0.063f, 0.333f)]
public float relativeThreshold;
}
// CustomRenderPipelineAsset.cs
[SerializeField] CameraBufferSettings cameraBuffer = new CameraBufferSettings() {
allowHDR = true,
renderScale = 1.0f,
fxaa = new CameraBufferSettings.FXAA() {
fixedThreshold = 0.0833f,
relativeThreshold = 0.166f}
};
// postFXStack.cs
buffer.SetGlobalVector(fxaaConfigId,
new Vector4(fxaa.fixedThreshold, fxaa.relativeThreshold, 0f));
在 shader 中,用像素亮度范围和相对阈值与最亮值的乘积作比较,来判断是否跳过FXAA。
最终,我们从固定阈值和相对阈值中选择较大的一个,进行FXAA过滤。
cs
bool CanSkipFXAA(LumaNeighborhood luma)
{
return luma.range < max(_FXAAConfig.x, _FXAAConfig.y * luma.highest);
}
float4 FXAAPassFragment(Varyings input) :SV_Target
{
LumaNeighborhood luma = GetLumaNeighborhood(input.screenUV);
luma.lowest = min(luma.m, min(luma.n, min(luma.e, min(luma.s, luma.w))));
luma.highest = max(luma.m, max(luma.n, max(luma.e, max(luma.s, luma.w))));
luma.range = luma.highest - luma.lowest;
if(CanSkipFXAA(luma))
return 0.0;
return luma.range;
}

我们将配置最低的阈值,以处理更多的像素。
2.4 Blend Factor
提升图像边缘的质量的唯一正确的办法是提高分辨率。然而FXAA只有原始图像数据,所以最好的办法就是猜测丢失的子像素数据来模拟高分辨率到低分辨率的降采样,这是通过混合中间像素和一个邻居像素来实现的。可以简单的平均这两个像素,但是更精确的混合因子,是基于像素对比度和它的邻居的平均值的过滤结果。我们当前要做的就是把这个混合因子计算并显示出来。
首先定义 GetSubpixelBlendFactor 函数,返回邻居像素的平均值,并输出到屏幕
cs
float GetSubpixelBlendFactor(LumaNeighborhood luma)
{
float filter = luma.e + luma.w + luma.n + luma.s;
filter *= 0.25f;
return filter;
}
float4 FXAAPassFragment(Varyings input) :SV_Target
{
...
return GetSubpixelBlendFactor(luma);
}
结果是一个应用到周围没有被忽略的像素上的低通滤波。

下一步是通过计算邻居像素平均值和中间像素的差的绝对值,来得到高通滤波。
cs
float GetSubpixelBlendFactor(LumaNeighborhood luma)
{
...
filter = abs(filter - luma.m);
return filter;
}

然后除以亮度范围进行归一化
cs
filter /= luma.range;
return filter;

现在的结果,如果拿来作为混合因子还是太强了,FXAA 通过应用 smoothstep 再平方以进行修正。

cs
filter = smoothstep(0, 1, filter);
filter = filter * filter;

可以通过进一步合并对角线上像素的亮度来提升过滤器的效果:
cs
LumaNeighborhood GetLumaNeighborhood(float2 uv)
{
...
luma.ne = GetLuma(uv, 1, 1);
luma.se = GetLuma(uv, 1, -1);
luma.sw = GetLuma(uv, -1, -1);
luma.nw = GetLuma(uv, -1, 1);
return luma;
}
因为对象线上的邻居,距离中间像素要更远,因此影响要比直接邻居弱一些。所以我们给直接邻居一个双倍的权重,这像一个3x3的卷积核滤波器,只是没有中间的权重。

现在还需要对归一化的值执行 saturate 使其在 0 - 1 之间,因为我们在提取最高亮度时,没有考虑对角线上的像素,因此结果可能大于1。
cs
float GetSubpixelBlendFactor(LumaNeighborhood luma)
{
float filter = 2.0 * (luma.e + luma.w + luma.n + luma.s);
filter += luma.ne + luma.se + luma.sw + luma.nw;
filter *= 1.0/12;
filter = abs(filter - luma.m);
filter /= luma.range;
filter = saturate(filter);
filter = smoothstep(0, 1, filter);
filter = filter * filter;
return filter;
}
下图左边是扩展对角线后的过滤器结果,右边是与扩展前的差的绝对值

2.5 Blend Direction
确定了混合因子后,就要决定混合哪两个像素了.FXAA是混合中间像素和它的4个直接邻居像素中的一个.选择邻居像素是基于对比度梯度的方向.最简单的情况是,中间像素靠近不同对比度的两个区域的边缘,可能是水平或垂直方向.如果是水平的边缘,如果中间像素在边缘的下面,就北侧的邻居,如果在下面,就选择南侧的邻居.同样的,如果是垂直的边缘,根据中间像素在边缘的左边或右边,选择右边或左边的邻居.总结一下就是,要与在边缘上的邻居进行混合.

很多时候,边缘不会正好是水平或垂直,这时我们通过比较邻居的水平和垂直对比度,选择最接近的方向.对于中间点上面或下面的水平的边缘,一定有最强的垂直对比度.我们通过将上下邻居亮度相加,减去2倍中间量度,并取绝对值,来进行评估.垂直边缘逻辑相同,不过替换成左右邻居的亮度.如果水平结果大于垂直结果,那么我们就判断边缘时水平的.定义一个函数实现该功能.
进一步考虑对角线上的邻居,对于水平方向,考虑右上和右下,左上和左下,同时我们希望直接邻居具有更高的影响力,因此再乘以2,将三个评估值累加。垂直方向类似。
cs
bool IsHorizentalEdge(LumaNeighborhood luma)
{
float horizental = 2.0 * abs(luma.n + luma.s - 2.0 * luma.m);
horizental += abs(luma.ne + luma.se - 2.0 * luma.e);
horizental += abs(luma.nw + luma.sw - 2.0 * luma.w);
float vertical = 2.0 * abs(luma.e + luma.w - 2.0 * luma.m);
vertical += abs(luma.nw + luma.ne - 2.0 * luma.n);
vertical += abs(luma.sw + luma.se - 2.0 * luma.s);
return horizental >= vertical;
}
引入 FXAAEdge 结构体,用来存储检测到的边缘的信息,这里先定义是否是水平方向。创建 GetFXAAEdge 方法来填充
cs
struct FXAAEdge
{
bool isHorizental;
};
FXAAEdge GetFXAAEdge(LumaNeighborhood luma)
{
FXAAEdge edge;
edge.isHorizental = IsHorizentalEdge(luma);
return edge;
}
在片段着色器中,获取边缘信息,如果是水平,渲染成红色,否则白色
cs
float4 FXAAPassFragment(Varyings input) :SV_Target
{
LumaNeighborhood luma = GetLumaNeighborhood(input.screenUV);
if(CanSkipFXAA(luma))
return 0.0;
FXAAEdge edge = GetFXAAEdge(luma);
return edge.isHorizental ? float4(1.0, 0, 0, 0) : 1.0;
}

知道边缘的方向了,就知道在哪个维度上进行混合了。如果是水平边缘,则在垂直方向上进行混合,垂直边缘则在水平方向上混合。混合的像素距离,根据混合方向,选择UV空间中的图素尺寸。向 FXAAEdge 中增加 pixelStep,并在 GetFXAAEdge 进行初始化
cs
FXAAEdge GetFXAAEdge(LumaNeighborhood luma)
{
FXAAEdge edge;
edge.isHorizental = IsHorizentalEdge(luma);
if(edge.isHorizental)
edge.pixelStep = GetSourceTexelSize().y;
else
edge.pixelStep = GetSourceTexelSize().x;
return edge;
}
下一步,来检查需要在正向还是负向上进行混合。通过在对应的方向上,比较对比度-也即亮度梯度,来确定在中间的哪一边。对于水平的边,上面的是正向,下面的是负向。如果是垂直的边,右边是正向,左边是负向。
如果正向的梯度小于负向的梯度,说明中间像素在边的右边,则混合中间像素和负向(左边)的像素。
然后在片段着色器中,获取混合方向后,如果是正向则输出红色,否则是白色
cs
FXAAEdge GetFXAAEdge(LumaNeighborhood luma)
{
FXAAEdge edge;
edge.isHorizental = IsHorizentalEdge(luma);
float lumaP, luamN;
if(edge.isHorizental)
{
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;
return edge;
}

上图中,正向在左下,是红色;负向在右上,是白色,它们都是指向边缘像素。
2.6 Final Blend
知道了混合因子,也知道了跟哪个像素混合,基于混合因子,在中间像素和邻居像素之间进行插值来得到最终结果。通过给UV加一个偏移,由采样器帮我们完成插值。
cs
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.isHorizental)
blendUV.y += blendFactor * edge.pixelStep;
else
blendUV.x += blendFactor * edge.pixelStep;
return GetSource(blendUV);
}

2.7 Blend Strength
FXAA 不止会影响明显的高对比度的边,它会影响所有高对比度区域,包括孤立的像素点(不是边缘)。这可以帮助消除像素闪烁,但是也会模糊那些很小的(需要的)细节,这也是FXAA最大的不足。
如下图,细节被FXAA模糊掉了。

FXAA 可以通过简单的缩小 blend factor 来控制 subpixel 混合强度。下面是原文中的说明
cs
// Choose the amount of sub-pixel aliasing removal.
// This can effect sharpness.
// 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
我们也依据该描述,增加控制参数,并存储到 _FXAAConfig.z 分量上
cs
// CameraBufferSettings.cs
[Range(0f, 1f)]
public float subpixelBlending;
// CustomRenderPipelineAsset.cs
fxaa = new CameraBufferSettings.FXAA() {
fixedThreshold = 0.0833f,
relativeThreshold = 0.166f,
subpixelBlending = 0.75f
}
// PostFXStack.FonFinal
buffer.SetGlobalVector(fxaaConfigId,
new Vector4(fxaa.fixedThreshold, fxaa.relativeThreshold, fxaa.subpixelBlending, 0f));
// fxaa.hlsl
float GetSubpixelBlendFactor(LumaNeighborhood luma)
{
...
filter = filter * filter * _FXAAConfig.z;
return filter;
}

3 Blending Along Edges
因为像素混合因子是由一个 3x3 的矩阵计算的,因此它只能在这么大范围内平滑.但是锯齿边缘有时要长的多.像素可能在一个有一定斜率的很长的台阶状的锯齿的任何地方.从局部看,边可能是水平或垂直的,但是在更长的尺度上,它实际上是另一个方向.如果能得到真正的边缘,那么就可以更好的匹配相邻像素的混合因子,在更长的尺度上进行平滑.
下面的长条的几何体,左边是在真正的边缘上平滑,右边只是在局部平滑,也就是我们现在实现的方式.

作为对比,当设置 render scale 为 2 时,得益于更高的分辨率,可以在更长的尺度上平滑台阶(下采样过程中硬件线性插值完成),以获得更平滑的边缘.在更高的 render scale 上应用 fxaa 以获得更加平滑的效果也是可以的,但是现在的实现对于边缘效果没有太大的差异.

3.1 Edge Luma
为了识别出我们要处理的边缘的类型,我们需要更多的信息.我们知道3x3矩阵中间像素是在边缘的一侧,那么至少有一个邻居像素是在另一侧.为了进一步识别边缘,我们需要知道其亮度梯度,这已经在 GetFXAAEdge 中识别出来了.现在我们需要跟踪该梯度和边缘对侧的亮度,因此在 edge 定义新的变量
cs
if (gradientP < gradientN)
{
edge.pixelStep = -edge.pixelStep;
edge.lumaGradient = gradientN;
edge.otherLuma = lumN;
}
else
{
edge.lumaGradient = gradientP;
edge.otherLuma = lumP;
}
定义一个专门的函数来获得 edge blend factor.这里先返回上面计算的边缘亮度梯度,以查看可视化数据
cs
float GetEdgeBlendFactor(LumaNeighborhood luam, 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);
return GetEdgeBlendFactor(luma, edge, input.screenUV);
}

3.2 Tracing the Edge
我们需要知道像素在水平或垂直边的线段上的相对位置,方法是沿着边的两个方向向前,知道到达线段的终点.这可以通过沿着方向采样像素对,并检查像素对是否依然构成相同的边来实现.

实际上我们不需要真的采样两个像素,而是采样两个像素之间的点,得到一个插值的结果,该结果是两个像素亮度的平均值,也可以判断是否到达边的终点.

为了完成这种搜索,首先需要确定在边缘上采样的UV,即在中间像素UV的基础上,向边缘方向偏移半个像素.然后确定采样方向的UV步长
cs
float GetEdgeBlendFactor(LumaNeighborhood luam, FXAAEdge edge, float2 uv)
{
float2 edgeUV = uv;
float2 uvStep = 0;
if(edge.isHorizental)
{
edgeUV.y += 0.5f * edge.pixelStep;
uvStep.x = GetSourceTexelSize().x;
}
else
{
edgeUV.x += 0.5f * edge.pixelStep;
uvStep.y = GetSourceTexelSize().y;
}
我们要做的是比较采样的亮度值和中间点的平均亮度,得到一个梯度,如果该梯度太大,就代表离开了边.FXAA使用边缘亮度的1/4作为阈值进行比较,因为我们也用该阈值.
先在正向上执行该逻辑的一个步骤:确定UV坐标的正向偏移,计算原始和偏移像素的亮度梯度,检查是否超过阈值,超过则表示到达边的正向的终点.
要到达边的终点,必须要循环上面的步骤,直到到达终点,这里先简单的循环 100 次.
一旦到达终点,我们就可以通过UV的差,知道在正向上,中间点到终点的像素距离,我们可以将该距离可视化
cs
float GetEdgeBlendFactor(LumaNeighborhood luma, FXAAEdge edge, float2 uv)
{
float2 edgeUV = uv;
float2 uvStep = 0;
if(edge.isHorizental)
{
edgeUV.y += 0.5f * edge.pixelStep;
uvStep.x = GetSourceTexelSize().x;
}
else
{
edgeUV.x += 0.5f * edge.pixelStep;
uvStep.y = GetSourceTexelSize().y;
}
float edgeLuma = 0.5f * (luma.m + edge.otherLuma);
float gradientThreshold = 0.25f * edge.lumaGradient;
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;
}
float distanceToEndP;
if(edge.isHorizental)
distanceToEndP = uvP.x - uv.x;
else
distanceToEndP = uvP.y - uv.y;
return 10.0 * (distanceToEndP);
}

3.3 Negative Direction
类似的,可以计算出到负方向上终点的距离,最后我们选择较短的那个距离
cs
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;
}
float distanceToEndP;
float distanceToEndN;
if(edge.isHorizental)
{
distanceToEndP = uvP.x - uv.x;
distanceToEndN = uv.x - uvN.x;
}
else
{
distanceToEndP = uvP.y - uv.y;
distanceToEndN = uv.y - uvN.y;
}
float nearestDistance;
if(distanceToEndP < distanceToEndN)
nearestDistance = distanceToEndP;
else
nearestDistance = distanceToEndN;
return 10.0 * nearestDistance;

可视化后,会发现,距离边的终点越近,颜色越暗
FXAA是一个近似的算法,在多数情况下,计算出来的距离是对的,但有时也会得到不正确的结果.
3.4 Blending on a Single Side
知道了到边缘终点的最近距离后,就可以用它来计算混合因子了.但是只需要考虑边倾斜的方向的区域包含中间像素的情况,这样也确保了我们只混合边一侧的像素.
下图上面的情况是不需要混合的,下面的情况才需要混合

我们需要知道在搜索边缘最后的亮度差,因此修改代码记录该值,并在最后判断出差值的符号.
如果该符号与中间像素的符号一致,则表明时离开边缘,就不需要混合,返回0
cs
float2 uvP = edgeUV + uvStep;
float lumaDeltaP = GetLuma(uvP) - edgeLuma;
float lumaGradientP = abs(lumaDeltaP);
bool atEndP = lumaGradientP >= gradientThreshold;
int i = 0;
for(i = 0; i < 99 && !atEndP; i++)
{
uvP += uvStep;
lumaDeltaP = GetLuma(uvP) - edgeLuma;
lumaGradientP = abs(lumaDeltaP);
atEndP = lumaGradientP >= gradientThreshold;
}
float2 uvN = edgeUV - uvStep;
float lumaDeltaN = GetLuma(uvN) - edgeLuma;
float lumaGradientN = abs(lumaDeltaN);
bool atEndN = lumaGradientN >= gradientThreshold;
for(i = 0; i < 99 && !atEndN; i++)
{
uvN -= uvStep;
lumaDeltaN = GetLuma(uvN) - edgeLuma;
lumaGradientN = abs(lumaDeltaN);
atEndN = lumaGradientN >= gradientThreshold;
}
float distanceToEndP;
float distanceToEndN;
if(edge.isHorizental)
{
distanceToEndP = uvP.x - uv.x;
distanceToEndN = uv.x - uvN.x;
}
else
{
distanceToEndP = uvP.y - uv.y;
distanceToEndN = uv.y - uvN.y;
}
float nearestDistance;
bool deltaSign;
if(distanceToEndP < distanceToEndN)
{
nearestDistance = distanceToEndP;
deltaSign = lumaDeltaP > 0;
}
else
{
nearestDistance = distanceToEndN;
deltaSign = lumaDeltaN > 0;
}
if(deltaSign == (luma.m - edgeLuma >= 0))
return 0;
return 10.0 * nearestDistance;
}

3.5 Final Bend Factor
我们用0.5,减去最近距离在正个边长度上的比例,来作为混合因子.这样只会在靠近边缘方向上会混合,而不会混合边缘中间的点.
cs
if(deltaSign == (luma.m - edgeLuma >= 0))
return 0;
return 0.5 - nearestDistance/(distanceToEndP + distanceToEndN);

如下图,edge blend factor 对水平或垂直的长边混合效果更好,而其它情况下,subpixel blend factor 更好

因此我们选择较大的那个,作为最终的混合因子.
最后修改片段着色器,看看边缘混合效果
cs
float4 FXAAPassFragment(Varyings input) :SV_Target
{
LumaNeighborhood luma = GetLumaNeighborhood(input.screenUV);
if(CanSkipFXAA(luma))
return GetSource(input.screenUV);
FXAAEdge edge = GetFXAAEdge(luma);
float blendFactor = max(GetSubpixelBlendFactor(luma), GetEdgeBlendFactor(luma, edge, input.screenUV));
float2 blendUV = input.screenUV;
if(edge.isHorizental)
blendUV.y += blendFactor * edge.pixelStep;
else
blendUV.x += blendFactor * edge.pixelStep;
return GetSource(blendUV);
}

3.6 Limited Edge Search
如果水平或垂直的边缘很长,就需要消耗大量时间来找到边的终点,最大 100 次采样对性能影响太大,没法保证效率.因此要尽快的结束终点搜索,但在FXAA里对于那些很长的边又不太可能.下面我们将搜索迭代改为3次,对比看看效果:

结果就是终点超过4个像素的,都被当作3个像素处理了,降低了FXAA的质量.如果4个像素距离还不是真正的终点,那我们可以猜测终点时5个像素的距离.如下图,会有所改善

3.7 Edge Quality
端点搜索到多远,限制了结果的质量,以及消耗的时间,因此这是效果和性能之间的妥协,没有最好的选择.因此我们引入相关的配置:搜索距离,每一步的步长,最后一步进行猜测的距离.将它们定义成静态常量数组
同时可以定义两个宏,用来从3组配置中选择一个,分别定义为LowQuality, MediumQuality,如果两个宏都没定义则使用 HighQuality
cs
#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
static const float edgeStepSizes[EXTRA_EDGE_STEPS] = {EDGE_STEP_SIZES};
float GetEdgeBlendFactor(LumaNeighborhood luma, FXAAEdge edge, float2 uv)
{
...
float2 uvP = edgeUV + uvStep;
float lumaDeltaP = GetLuma(uvP) - edgeLuma;
float lumaGradientP = abs(lumaDeltaP);
bool atEndP = lumaGradientP >= gradientThreshold;
int i = 0;
for(i = 0; i < EXTRA_EDGE_STEPS && !atEndP; i++)
{
uvP += edgeStepSizes[i]*uvStep;
lumaDeltaP = GetLuma(uvP) - edgeLuma;
lumaGradientP = abs(lumaDeltaP);
atEndP = lumaGradientP >= gradientThreshold;
}
if(!atEndP)
{
uvP += LAST_EDGE_STEP_GUESS * uvStep;
}
float2 uvN = edgeUV - uvStep;
float lumaDeltaN = GetLuma(uvN) - edgeLuma;
float lumaGradientN = abs(lumaDeltaN);
bool atEndN = lumaGradientN >= gradientThreshold;
for(i = 0; i < EXTRA_EDGE_STEPS && !atEndN; i++)
{
uvN -= edgeStepSizes[i]*uvStep;
lumaDeltaN = GetLuma(uvN) - edgeLuma;
lumaGradientN = abs(lumaDeltaN);
atEndN = lumaGradientN >= gradientThreshold;
}
if(!atEndP)
{
uvN -= LAST_EDGE_STEP_GUESS * uvStep;
}
...
}
在 postFXStack.shader 的 fxaa pass 定义 multi_compile
cs
Pass
{
Name "FXAA"
...
#pragma multi_compile _ FXAA_QUALITY_MEDIUM FXAA_QUALITY_LOW
#pragma vertex DefaultPassVertex
...
}
Pass
{
Name "FXAA with Luma"
...
#pragma multi_compile _ FXAA_QUALITY_MEDIUM FXAA_QUALITY_LOW
#pragma vertex DefaultPassVertex
...
}
在 CameraBufferSettings.cs 中定义对应的枚举,并在 FXAA 中定义对应的成员。在PostFXStack.cs 中定义 FXAAQualityConfigure 函数,根据枚举值设置 keywords。最后在 DoFinal 中,如果启用 fxaa 则调用配置函数。
cs
const string fxaaQualityLowKeyword = "FXAA_QUALITY_LOW";
const string fxaaQualityMediumKeyword = "FXAA_QUALITY_MEDIUM";
void ConfigureFXAAQuality()
{
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);
}
}
void DoFinal(int sourceId)
{
...
if (fxaa.enabled)
{
ConfigureFXAAQuality();
...
3.8 Unrolling loop
由于我们的迭代次数是固定的,因此可以通过指令,让 shader 在编译时展开循环,这样就没有条件语句了
cs
...
UNITY_UNROLL
for(i = 0; i < EXTRA_EDGE_STEPS && !atEndP; i++)
...
UNITY_UNROLL
for(i = 0; i < EXTRA_EDGE_STEPS && !atEndN; i++)
...
FXAA 原始的算法中,将两个方向的迭代合并到一个循环中,找到重点的方向,就不迭代了。这增加了条件,到那时减少了迭代次数。而我们的方法增加了迭代次数,去掉了条件判断。哪种性能更好,需要在实际应用中的不同情况下进行分别测试。