kirin77 发表于 2022-12-5 12:37

用Unity实现FXAA

FXAA是现代的常用抗锯齿手段之一,这次我们来在Unity中从零开始实现它。
首先我们来看一个测试场景,我们在Game视角下将scale拉到2x:



用Unity实现FXAA1

可以看到画面的锯齿比较严重,下面我们将一步一步地实现FXAA,消除锯齿。首先,FXAA是一种降低整个画面对比度的手段,通过降低对比度来消除掉明显的锯齿和一些孤立的像素。而衡量对比度的一种方式就是计算像素的亮度。那么,我们先新建一个后处理效果,计算整个画面上像素的亮度,Unity内置了API LinearRgbToLuminance来进行计算亮度:
// Convert rgb to luminance
// with rgb in linear space with sRGB primaries and D65 white point
half LinearRgbToLuminance(half3 linearRgb)
{
    return dot(linearRgb, half3(0.2126729f,0.7151522f, 0.0721750f));
}看一下亮度图长啥样:

http://pic4.zhimg.com/v2-7adf706901342361c58d213013818f17_r.jpg

用Unity实现FXAA2

有了亮度信息,接下来就可以计算对比度了。我们取当前像素周围上下左右4个像素的亮度信息,然后分别计算出它们的最大值和最小值,最大值和最小值之差作为当前像素的对比度:
struct LuminanceData {
            float m, n, e, s, w;
            float highest, lowest, contrast;
      };
      LuminanceData SampleLuminanceNeighborhood (float2 uv) {
            LuminanceData l;
            l.m = SampleLuminance(uv);
            l.n = SampleLuminance(uv, 0,1);
            l.e = SampleLuminance(uv, 1,0);
            l.s = SampleLuminance(uv, 0, -1);
            l.w = SampleLuminance(uv,-1,0);
            l.highest = max(max(max(max(l.n, l.e), l.s), l.w), l.m);
            l.lowest = min(min(min(min(l.n, l.e), l.s), l.w), l.m);
            l.contrast = l.highest - l.lowest;
            return l;
      }

      float4 ApplyFXAA (float2 uv) {
            LuminanceData l = SampleLuminanceNeighborhood(uv);
            return l.contrast;
      }



用Unity实现FXAA3

对于对比度比较小的像素,我们应该将其过滤掉。这里可以使用绝对阈值和相对阈值,来过滤值比较小或者相对周围值比较小的对比度:
bool ShouldSkipPixel (LuminanceData l) {
            float threshold =
                max(_ContrastThreshold, _RelativeThreshold * l.highest);
            return l.contrast < threshold;
      }

      float4 ApplyFXAA (float2 uv) {
            LuminanceData l = SampleLuminanceNeighborhood(uv);
            if (ShouldSkipPixel(l)) {
                return 0;
            }
            return l.contrast;
      }

http://pic1.zhimg.com/v2-00fc46c8d3b4401ee4656a60a8a02194_r.jpg

用Unity实现FXAA4

有了对比度信息之后,下一步就是要考虑如何根据对比度对像素进行融合。显然,当前像素周围的像素亮度差异越大,融合的比例越高。为了比较准确地计算周围像素的亮度,这次把对角的像素也考虑进来。当然,对角的像素所占的权重会相对低一些:



用Unity实现FXAA5

float DeterminePixelBlendFactor (LuminanceData l) {
            float filter = 2 * (l.n + l.e + l.s + l.w);
            filter += l.ne + l.nw + l.se + l.sw;
            filter *= 1.0 / 12;
            filter = abs(filter - l.m);
            filter = saturate(filter / l.contrast);
            return filter;
      }

      float4 ApplyFXAA (float2 uv) {
            LuminanceData l = SampleLuminanceNeighborhood(uv);
            if (ShouldSkipPixel(l)) {
                return 0;
            }
            float pixelBlend = DeterminePixelBlendFactor(l);
            return pixelBlend;
      }

http://pic1.zhimg.com/v2-9ca1c06fd66f9ff89383aeba90725738_r.jpg

用Unity实现FXAA6

为了让blend系数平滑一点,也可以加上smoothstep和square:
float DeterminePixelBlendFactor (LuminanceData l) {
            float filter = 2 * (l.n + l.e + l.s + l.w);
            filter += l.ne + l.nw + l.se + l.sw;
            filter *= 1.0 / 12;
            filter = abs(filter - l.m);
            filter = saturate(filter / l.contrast);

            float blendFactor = smoothstep(0, 1, filter);
            return blendFactor * blendFactor;
      }



用Unity实现FXAA8

有了融合系数之后,接下来就要考虑怎么融合,对哪两个像素进行融合。我们的目标是降低整个画面的对比度,也就是说要对亮度差异比较大的像素进行融合。这里可以先简单地假设,不同亮度的区域是由水平方向或者竖直方向区分开的,然后比较水平方向的亮度差异和竖直方向的亮度差异,最终决定融合的方向:

http://pic3.zhimg.com/v2-ce5d4878288b1456d5fa97f2c904c01a_r.jpg

用Unity实现FXAA9

struct EdgeData {
            bool isHorizontal;
      };

      EdgeData DetermineEdge (LuminanceData l) {
            EdgeData e;
            float horizontal =
                abs(l.n + l.s - 2 * l.m) * 2 +
                abs(l.ne + l.se - 2 * l.e) +
                abs(l.nw + l.sw - 2 * l.w);
            float vertical =
                abs(l.e + l.w - 2 * l.m) * 2 +
                abs(l.ne + l.nw - 2 * l.n) +
                abs(l.se + l.sw - 2 * l.s);
            e.isHorizontal = horizontal >= vertical;
            return e;
      }

      float4 ApplyFXAA (float2 uv) {
            LuminanceData l = SampleLuminanceNeighborhood(uv);
            if (ShouldSkipPixel(l)) {
                return 0;
            }
            float pixelBlend = DeterminePixelBlendFactor(l);
            EdgeData e = DetermineEdge(l);
            return e.isHorizontal ? float4(1, 0, 0, 0) : 1;
      }来看一下画面中有哪些像素融合时会选择水平方向:

http://pic1.zhimg.com/v2-47b44f16500c221bdcaa0e7aeef835fc_r.jpg

用Unity实现FXAA10

选择水平方向作为亮度区域的分界线,意味着融合时需要选取竖直方向上的像素。但是竖直方向上也有正负两个选择。类似地,我们比较正负方向的亮度差异,哪个差异更大,就选哪个:
float pLuminance = e.isHorizontal ? l.n : l.e;
            float nLuminance = e.isHorizontal ? l.s : l.w;
            float pGradient = abs(pLuminance - l.m);
            float nGradient = abs(nLuminance - l.m);

            e.pixelStep =
                e.isHorizontal ? _MainTex_TexelSize.y : _MainTex_TexelSize.x;

            if (pGradient < nGradient) {
                e.pixelStep = -e.pixelStep;
            }来看一下画面中有哪些像素融合时会选择负方向:



用Unity实现FXAA11

现在万事俱备,可以真正开始blend了。首先我们需要把tex2D换成tex2Dlod来避免mipmap带来的干扰;其次我们可以借助纹理过滤来帮我们自动blend,即采样点位于两个像素之间,根据融合系数的大小,调整采样点到两个像素的距离:
float4 Sample (float2 uv) {
            return tex2Dlod(_MainTex, float4(uv, 0, 0));
      }      
      float4 ApplyFXAA (float2 uv) {
            LuminanceData l = SampleLuminanceNeighborhood(uv);
            if (ShouldSkipPixel(l)) {
                return Sample(uv);
            }
            float pixelBlend = DeterminePixelBlendFactor(l);
            EdgeData e = DetermineEdge(l);

            if (e.isHorizontal) {
                uv.y += e.pixelStep * pixelBlend;
            }
            else {
                uv.x += e.pixelStep * pixelBlend;
            }
            return float4(Sample(uv).rgb, l.m);
      }

http://pic4.zhimg.com/v2-c27bcb6062af5db1c8861d3b1b9cbe8b_r.jpg

用Unity实现FXAA12

我们还可以再加上一个外部控制融合系数的参数,这样就可以动态看到不同融合强度下的效果:



用Unity实现FXAA13

但实际上分隔线的长度不一定只有3个像素大小,我们可以通过计算当前像素和分隔线另一侧的像素的亮度平均值,作为分隔线的亮度,然后不断地沿着这条线向两端进行采样,当采样得到的亮度和分隔线的亮度有明显差异时,就认为找到了这条线的末端:



用Unity实现FXAA14

我们设定每一端的最大查找次数为10:
float DetermineEdgeBlendFactor (LuminanceData l, EdgeData e, float2 uv) {
            float2 uvEdge = uv;
            float2 edgeStep;
            if (e.isHorizontal) {
                uvEdge.y += e.pixelStep * 0.5;
                edgeStep = float2(_MainTex_TexelSize.x, 0);
            }
            else {
                uvEdge.x += e.pixelStep * 0.5;
                edgeStep = float2(0, _MainTex_TexelSize.y);
            }

            float edgeLuminance = (l.m + e.oppositeLuminance) * 0.5;
            float gradientThreshold = e.gradient * 0.25;

            float2 puv = uvEdge + edgeStep;
            float pLuminanceDelta = SampleLuminance(puv) - edgeLuminance;
            bool pAtEnd = abs(pLuminanceDelta) >= gradientThreshold;

            for (int i = 0; i < 9 && !pAtEnd; i++) {
                puv += edgeStep;
                pLuminanceDelta = SampleLuminance(puv) - edgeLuminance;
                pAtEnd = abs(pLuminanceDelta) >= gradientThreshold;
            }

            float2 nuv = uvEdge - edgeStep;
            float nLuminanceDelta = SampleLuminance(nuv) - edgeLuminance;
            bool nAtEnd = abs(nLuminanceDelta) >= gradientThreshold;

            for (int i = 0; i < 9 && !nAtEnd; i++) {
                nuv -= edgeStep;
                nLuminanceDelta = SampleLuminance(nuv) - edgeLuminance;
                nAtEnd = abs(nLuminanceDelta) >= gradientThreshold;
            }
            return pAtEnd || nAtEnd;
      }看看找到的分隔线:

http://pic3.zhimg.com/v2-c38083d55ad509158166809604eda43a_r.jpg

用Unity实现FXAA15

当然,寻找分隔线端点的步长也不一定是定值,可以灵活设置,并且在超过最大迭代次数时,可以大胆地往前步进一个步长,作为预测结果:
#define EDGE_STEP_COUNT 10
#define EDGE_STEPS 1, 1.5, 2, 2, 2, 2, 2, 2, 2, 4
#define EDGE_GUESS 8

static const float edgeSteps = { EDGE_STEPS };         
    for (int i = 2; i < EDGE_STEP_COUNT && !pAtEnd; i++) {
      puv += edgeStep * edgeSteps;
      pLuminanceDelta = SampleLuminance(puv) - edgeLuminance;
      pAtEnd = abs(pLuminanceDelta) >= gradientThreshold;
    }
if (!pAtEnd) {
    puv += edgeStep * EDGE_GUESS;
}接下来,我们需要确定一下融合的系数。首先,越靠近端点的像素,融合的系数越大;其次,端点像素亮度要和当前像素亮度要在分隔线的同一侧,即都要比分隔线亮度更大或者更小:
float pDistance, nDistance;
            if (e.isHorizontal) {
                pDistance = puv.x - uv.x;
                nDistance = uv.x - nuv.x;
            }
            else {
                pDistance = puv.y - uv.y;
                nDistance = uv.y - nuv.y;
            }

            float shortestDistance;
            bool deltaSign;
            if (pDistance <= nDistance) {
                shortestDistance = pDistance;
                deltaSign = pLuminanceDelta >= 0;
            }
            else {
                shortestDistance = nDistance;
                deltaSign = nLuminanceDelta >= 0;
            }

            if (deltaSign == (l.m - edgeLuminance >= 0)) {
                return 0;
            }
            return 0.5 - shortestDistance / (pDistance + nDistance);最后,我们得到了两种计算方式下的融合系数,简单粗暴点,直接取max作为最终效果:



用Unity实现FXAA16

如果你觉得我的文章有帮助,欢迎关注我的微信公众号:Game_Develop_Forever

Reference

FXAA
页: [1]
查看完整版本: 用Unity实现FXAA