计算机图形学五反走样的方法(详解)

计算机图形学五反走样的方法(详解)

前言:

Anti-Aliasing与Jaggles的关系

锯齿产生的原因:一句话回答的话,就是采样率不足。
展开回答,因为物体在三维世界中是连续的,而光栅化是将连续的物体用离散的像素表示出来,对于每个像素都是“是”和“不是”的一种选择。而像素本身又有一定的大小,因此呈现阶梯形状,也就是锯齿。锯齿的大小和像素本身的大小有关。

AA的思路只有两种:一种是用图像处理的方法使得边缘柔化,属于图像层面的后处理,相应的有SMAA,FXAA 等。另一种是增加采样率,如SSAA,MSAA,TAA。

一.SSAA(超采样抗锯齿)

在数学上最完美的抗锯齿方法。
走样就是因为屏幕的像素不够导致采样频率不够。我们不必做到完全无走样的采样,只要采样频率高到能骗过眼睛就行了。那么好,我在1个像素里面取4个、8个甚至16个采样点,每个采样点都给它来一次着色计算,最后把渲染出来的颜色经过filter的重构后,再重新采样(Resample)混色。这种粗暴的方法就是所谓的Supersampling Anti-Aliasing(简称SSAA)。SSAA的确能渲染出非常保真的画质,但是这就意味着每一帧都要先渲染出原来一帧图像的4倍、8倍甚至16倍分辨率的图像!以普通玩家的硬件水平根本支撑不了这么大的开销。
每一个像素根据采样点数量,执行n次fragment shader。
性能开销极大,因此虽然数学上是最完美的,但是开销吃不住。
在这里插入图片描述

二.MSAA(多重采样抗锯齿)

SSAA的性能开销太大,但是原理很完美。因此我们想办法取其精华,去其糟粕。
其实没有必要每个采样点都去渲染一次
MSAA在光栅化的时候检测经过深度测试后的三角形是否覆盖了采样点,并在采样点存储通过插值中心点得到的颜色来进行着色计算,最后按其覆盖权重来进行混色。这样,一个像素只需要进行一次shader,而不是每个采样点进行一次shader
但是,color buffer依然是n倍。因为要存储n个采样点的颜色。只不过我们不用每个采样点执行一次fragment shader了。而是通过覆盖率计算后,每个像素点执行一次fragment shader。

从上图可以看出来,color buffer 的开销也是4倍的
SSAA和MSAA基本用在前向渲染管线里面,并不太合适延迟渲染管线的,毕竟光栅化信息已经存储在GBuffer里面了。

MSAA主要通过硬件来实现。

文刀秋二大神的描述:
MSAA(Multi-Sampling AA)则很聪明的只是在光栅化阶段,判断一个三角形是否被像素覆盖的时候会计算多个覆盖样本(Coverage sample),但是在pixel shader着色阶段计算像素颜色的时候每个像素还是只计算一次。例如下图是4xMSAA,三角形只覆盖了4个coverage sample中的2个。所以这个三角形需要生成一个fragment在pixel shader里着色,只不过生成的fragment还是在像素中央(位置,法线等信息插值到像素中央)然后只运行一次pixel shader,最后得到的结果在resolve阶段会乘以0.5,因为这个三角形只cover了一半的sample。现代所有GPU都在硬件上实现了这个算法,而且在shading的运算量远大于光栅化的今天,这个方法远比SSAA快很多。顺便提一下之前NV的CSAA,它就是更进一步的把coverage sample和depth,stencil test分开了。

MSAA优点:

使用起来简单方便,抗锯齿效果非常好。
应用于一些特定物体的渲染,比如渲染头发,细绳子等物体时,因为太细了,常常得到的图像宽度只有一个像素,这种时候只能用MSAA或者TAA了,后处理方法失效。

MSAA缺点:

会额外消耗大量内存和带宽,特别是对于延迟渲染来说,GBuffer 本身就已经很大了,如果再使用 MSAA,额外的带宽消耗极大。
因此延迟渲染一般不会使用 MSAA来作为实现抗锯齿手段。而目前大部分 PC 端游戏都是基于延迟渲染管线的,包括Unity 的 HDRP ,所以 PC 游戏一般不会使用 MSAA。

从对硬件的利用率上来说,MSAA 对硬件的利用率其实很低,因为很多时候我们想要抗锯齿的部分,都只是在物体边缘或者高光变化的高频部分。其他颜色不怎么变化,比较低频的地方,其实是不需要抗锯齿效果的。使用 MSAA 进行大量物体的渲染时,很多带宽是被浪费的。因此即使在手机上,目前使用 MSAA 的情况也比较少。可以说,MSAA 是一种比较经典,但是目前显得略有些过时的抗锯齿方式。

MSAA优化:

对贴图格式进行优化。对于 MSAA,每个像素上的次像素点,都会单独存储颜色值。

一种优化的方案是使用 NVIDIA 的 CSAA(coverage sampling antialiasing)或者 AMD 的 EQAA(enhanced quality antialiasing)。

如上图右边所示,这种方式下每个次像素点不会记录颜色,而是记录颜色列表的索引,这样可以减少内存的消耗

参考资料:

MSAA大神文章
https://therealmjp.github.io/posts/msaa-overview/

三.FXAA(快速近似抗锯齿)

对于硬件性能十分有限的地低端机器来说,很难使用增加采样率的AA算法。
前言我们提到了,除了增加采样率之外,还可以通过后处理的方法来达到柔和锯齿边缘的效果
这些算法一般都是屏幕空间,思路很简单,在得到锯齿图后,通过边缘检测的方式得到锯齿边缘,之后再对其进行后处理优化。
Fast approximate Anti-Aliasing(FXAA)就是其中比较常见的后处理AA算法

FXAA比较重视性能,只需要一次PASS就能得到结果。

FXAA3.11有两个版本,quality版本注重抗锯齿质量,console版本注重抗锯齿速度。

FXAA实现步骤

下面讲下quality版本的实现过程。

分为以下几个步骤:

1.对比度的计算

首先找到所有物体的边缘,一般通过对比像素颜色的亮度值(Lumen值),公式为L = 0.299 * R + 0.587 * G + 0.114 * B。
如果一个像素与周围四个像素的亮度差异大于一定的阈值,就可以认为这个像素是边缘的一部分即边缘像素,需要进行抗锯齿处理。求亮度值是可以直接用上面的亮度公式,也可以直接使用G分量的颜色值来作为亮度值,因为绿色对整体亮度的贡献是最大的
亮度值也可以在上一个pass中处理时,写入alpha通道中,这样可以省去计算每个采样点亮度的过程,还可以直接使用gather4函数加速采样。
在这里插入图片描述
采样位置如上图,分别得到中间点M和周围的四个点N,E,W,S。

float MaxLuma = max(N,E,W,S,M);
float MinLuma = max(N,E,W,S,M);

//周围五个像素点,最大亮度和最下亮度的差,作为对比度
float Contrast = MaxLuma - MinLuma;
if(Contrast >= max(_MinThreshold,MaxLuma*_Threhold)....

其中,_MinThreshold和 _Threshold都是可进行配置的阈值。
如果对比度较大,会进行抗锯齿处理,否则会跳过后续计算。

2.基于亮度的混合系数计算

接下来就是确定当前像素点进行混合时的系数,为了使结果更加精确,我们还需要对对角线上的四个点进行采样并计算亮度值,这样就需要再额外进行采样并得到斜对角上四个点的亮度值:
在这里插入图片描述
上图可知,当计算FXAA的基于亮度的混合系数时,我们一共需要得到9个采样点的位置。
通过计算目标像素和周围像素点的平均亮度的差值,我们来确定将来进行颜色混合时的权重,因为对焦像素距离中心像素比较远,所以计算平均亮度值时的权重会略微低一点。
在这里插入图片描述
上图为计算FXAA的基于亮度的混合系数时,九个采样点位置的权重。

计算出周围像素点平均亮度和中间亮度的差,再用亮度范围进行归一化,为了使混合权重更加平滑,我们对得到的混合权重再用smoothstep处理一下,再将结果进行平方处理。

//按照相应的权重,将周围像素点亮度相加
float Filter = 2*(N+E+S+W)+NE+NW+SE+SW;
Filter = Filter / 12;
//计算出基于亮度的混合系数
Filter = abs(Filter - M);
Filter = saturate(Filter / Contrast);
//使输出结果更加平滑
float PixelBlend = smoothstep(0,1,Filter);
PixelBlend = PixelBlend* PixelBLend; 
3.计算混合方向

接下来就是确定进行混合计算的方向,锯齿边界通常不会是刚好水平或者是垂直的,但是这里我们要寻找一个最接近的方向。
在这里插入图片描述
锯齿可分为上下左右四种方向。

通过下面的计算方式,我们来确定通过锯齿边界的方向,如果水平方向的亮度变化较大,锯齿边界就是垂直的,沿水平方向进行混合,如果垂直方向的亮度变化较大,锯齿边界是水平的,按垂直方向进行混合。

//计算水平方向上的亮度变化幅度
float Vertical = abs(N+S-2*M)*2+abs(NS+SE-2*E)+abs(NW+SW-2*W);
//计算垂直方向亮度变化幅度
float Horizontal = abs(E+W-2*M)*2+abs(NE+NW-2*N)+abs(SE+SW-2*S);
//判断边界方向
bool IsHorizontal = Vertial>Horizontal;
//根据边界方向,先计算出后面搜索时的步长
float2 PixelStep = IsHorizontal ? float2(0, _MainTex_TexelSize.y) : float2(_MainTex_TexelSize.x, 0);

计算得到混合方向后,我们接着来确定具体混合时沿着正负方向的哪个方向进行混合。我们来取变化值最大的那个方向。当在垂直的时候,我们约定向上为正,向下为负。在水平方向时,约定向右为正,向左为负

float Positive = abs((IsHorizontal ? N : E) - M);
float Negative = abs((IsHorizontal ? S : W) - M);
if(Positive < Negative) PixelStep = -PixelStep;
4.混合

然后就是根据得到的混合方向和混合权重进行混合即可,采样的方式是将当前像素点的uv,沿着偏移方向,按照偏移权重偏移。

float4 Result = tex2D(_MainTex, UV + PixelStep * PixelBlend);

接着我们就可以得到初步的抗锯齿效果。

5.边界混合系数

观察抗锯齿之后的结果,发现,斜向的抗锯齿,AA效果有点差。
这是因为我们其实只是根据目标像素点周围3x3的像素点进行采样分析,并且假设锯齿边界是完全垂直或者水平的,但是很多时候,我们的锯齿边界是带有角度的。这样们要得到正确的混合系数,就需要将采样的范围扩展到3x3像素块之外,求出锯齿的倾斜角度。
比如下图中的理想抗锯齿和效果,就需要进行额外的抗锯齿。
理想情况下的抗锯齿效果
首先我们需要算出当前像素点和待混合像素点之间的亮度差值,作为锯齿边界两端亮度变化的梯度值,这里的值其实在前面是已经求得的:

float Positive = abs((IsHorizonal?N:E)-M);
float Negative = abs((IsHorizonal?S:W)-M);
float Gradient,OppositeLuminance;
//算出当前像素点,锯齿两侧的亮度差,作为后序搜索边界时判断锯齿边界的标准
if(Positive>Negative)
{
	Gradient = Positive;
	OppositeLuminance = IsHorizontal?N:E;
}
else
{
	PixelStep = -PixelStep;
    Gradient = Negative;
    OppositeLuminance = IsHorizontal ? S : W;
}

接下来就是沿着边界两侧的方向,进行搜索,知道找到锯齿边界为止。判断边界的方式是计算两侧的亮度值的差,是否和当前的亮度变化梯度值符合。
在这里插入图片描述
从中间的目标像素点开始,沿着锯齿边界,向两侧搜索,找到锯齿边界的结束位置

这里我们没必要在每次判断时,分别在边界两侧进行两次采样,利用双线性过滤,在边界处采样,即可得到两侧像素的平均亮度值。比如下图中,在黄色的点处采样,就可以得到上下两个像素点的平均亮度值。
在这里插入图片描述
利用双线性采样的性质,在黄色点处采样,就能得到当前采样点上下两个像素点的平均亮度值。

根据边界的方向,我们在这里确定搜索的方向,并进行搜索:

float2 UVEdge = UV;
UVEdge += PixelStep * 0.5f;
float2 EdgeStep = IsHorizontal ? float2(_MainTex_TexelSize.x, 0) : float2(0, _MainTex_TexelSize.y);

先算出当前像素点和待混合像素点的平均亮度值,然后乘以0.25作为梯度阈值,沿着两侧进行搜索时,判断和当前平均亮度值的差,如果超过阈值,则认为是达到了锯齿的边界。

下面是沿两侧搜索的代码,_SearchSteps 定义了搜索时的步长, _Guess 是在未搜索到边界时,预测的边界位置。搜索时的步长越大,得到的结果也越准确,性能消耗也越大。在这里我们设 _SearchSteps = 10、 _Guess = 8,表示会沿着左右两侧搜索 10 个像素的距离,当10个像素内仍然没有找到边界,就假设边界在 8 个像素距离的位置。

float EdgeLuminance = (M + OppositeLuminance) * 0.5f;
float GradientThreshold = EdgeLuminance * 0.25f;
float PLuminanceDelta, NLuminanceDelta, PDistance, NDistance;
int i;
// 沿着锯齿方向搜索
for(i = 1; i <= _SearchSteps; ++i) {
    PLuminanceDelta = Luminance(tex2D(_MainTex, UVEdge + i * EdgeStep)) - EdgeLuminance;
    if(abs(PLuminanceDelta) > GradientThreshold) {
        PDistance = i * (IsHorizontal ? EdgeStep.x : EdgeStep.y);
        break;
    }
}
if(i == _SearchSteps + 1) {
    PDistance = EdgeStep * _GUESS;
}

// 沿着另一侧锯齿方向搜索
for(i = 1; i <= _SearchSteps; ++i) {
    NLuminanceDelta = Luminance(tex2D(_MainTex, UVEdge - i * EdgeStep)) - EdgeLuminance;
    if(abs(NLuminanceDelta) > GradientThreshold) {
        NDistance = i * (IsHorizontal ? EdgeStep.x : EdgeStep.y);
        break;
    }
}
if(i == _SearchSteps + 1) {
    NDistance = -EdgeStep * _GUESS;
}

接下来就是计算混合时的系数了,不过在计算之前,我们还需要判断一下混合的方向是否和当前需要计算的一致,这里是通过比较相对亮度的正负值来实现的。

比如下图中的锯齿边界,在水平的锯齿边界上进行计算时,两个黄色的点是现在我们要计算混合系数的目标像素点,两个红色标记的点是我们期望进行混合的点。因为左边的混合方向其实应该是向上的,因此这种情况下就不需要进行计算,而是在处理上面红色像素时进行计算。而右边的黄色标记机目标像素点,是需要在当前处理的。
在这里插入图片描述
在处理两个黄色的像素点时,左边的黄点处像素,因为锯齿边界方向的错误,不会进行基于边界的抗锯齿计算。

如果判断出边界方向不符合时,就直接设混合系数为0,边界方向符合时,按照相对的距离,来估算混合系数:

float EdgeBlend;
if(PDistance < NDistance){
    if(sign(PLuminanceDelta) == sign(M - EdgeLuminance)){
        EdgeBlend = 0;
    } else{
        EdgeBlend = 0.5f - PDistance / (PDistance + NDistance);
    }
} 
else {
    if(sign(NLuminanceDelta) == sign(M - EdgeLuminance)){
        EdgeBlend = 0;
    }
    else{
        EdgeBlend = 0.5f - NDistance / (PDistance + NDistance);
    }
}

最后,我们使用求得的两种方式算出的混合系数中的最大值,作为最终的混合系数,可以看大斜向的锯齿,也有了不错的AA效果:

// 取两种方式算出的混合系数中的最大值
float FinalBlend = max(PixelBlend, EdgeBlend);
// 进行混合
float4 Result = tex2D(_MainTex, UV + PixelStep * FinalBlend);

FXAA的console版本主要是在pc机上,是对quality的一个简化,不做叙述。

FXAA优点:

集成比较方便,只需要一个pass就能实现抗锯齿,同时提供了两个版本,quality和console版本,console主要面向ps3.可以根据情况灵活选取,是手机上最常用的抗锯齿方式,现在主要是quality。

FXAA缺点:

画面会略微有些模糊。而且由于FXAA是基于后处理判断边界来实现的,因此没有次像素特性,在光照高频(颜色变化很快)的地方会很不稳定。单独看静态的场景没有什么问题,但是移动相机会变得闪烁。

FXAA优化:

上述算法主要是基于像素边缘和原始边界的对应关系,对于长边很适合。但是对于某些高频的细节,原始边界可能就覆盖了屏幕空间中的一两个像素,这种算法无能为力,因此可以结合额外的低通滤波,补充对高频细节的抗锯齿。

参考资料:

http://wingerzeng.com/2021/10/14/%E5%AE%9E%E6%97%B6%E6%B8%B2%E6%9F%93%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0%E2%80%94FXAA/

四.SMAA(次像素形态抗锯齿)

使用后处理去柔化的AA算法除了FXAA以外,还有效果更好的Morphological antialiasing(MLAA)和Subpixel Morphological Antialiasing(SMAA)。

这两个算法其实是同一种思路,只不过SMAA是使用GPU加速版的MLAA。比起FXAA只是找两个方向边缘的思路,它们更加详细地把所有的可能出现的边缘形状进行分类——总共有16种模式——然后对这些边缘像素矢量化(其实就是把阶梯转化成斜线),根据像素本应该的覆盖大小作为权重对边缘像素进行混色。

1.MLAA原理简介

SMAA在下图的文章中被提出。
在这里插入图片描述

SMAA的核心原理来源于MLAA。

2.MLAA基本思路:

检测每帧图像上的边缘(通常可对亮度、颜色、深度或者法线进行边缘检测),然后对这些边缘进行模式识别,归类出Z、U、L三种形状,根据形状对边缘进行重新矢量化(re-vectorization),并对边缘上的像素根据覆盖面积计算混合权重,将其与周围的颜色进行混合,从而达到平滑锯齿的目的。

在这里插入图片描述
以下图为例详细说明。
用绿色标记的线条为检测到的Z形边缘,从这些边缘我们可以推断出原始边缘的形状,即图中蓝色线条。这个过程叫重新矢量化。此时我们能够得知边缘附近每个像素被蓝色线条截断了百分之多少。根据此信息将当前像素与邻近像素的颜色混合,便能得到平滑的边缘。
在这里插入图片描述
像素被蓝线截断面积的百分比(小于50%的部分)叫做权重因子。
对于任一个边缘像素,要计算其权重因子,只需要知道d_left、d_right,以及两端交叉边的朝向(即边缘的形状,此处为Z形)即可。其中d_left和d_right为当前像素距离边缘两端的距离。得到权重因子a后,通过如下计算实现边缘平滑:

为了节省计算资源,Jimenez为每种形状模式预计算了一张查找表(称作areaTex)以便快速获取权重因子,如下图所示:
在这里插入图片描述
在这里插入图片描述
为了节省计算资源,作者为每种形状模式与计算了一张查找表,以便快速获得权重因子。
在这里插入图片描述
注意贴图中的每个像素保存了两个数值(分别储存在R和G通道里),这两个数值分别代表当前像素及共享同一边缘的邻接像素的权重因子。

以上是仅针对水平方向锯齿的情况,对于垂直方向的锯齿,处理方式类似。

3.MLAA->SMAA

MLAA把所有的工作都交给CPU去做了,而SMAA把几个重要的工作分配给了GPU进行了加速
首先是搜寻边缘像素的末端,SMAA利用GPU双线性插值的特性,在贴图采样的时候偏移了0.5个像素,加快了搜寻速度。然后在确定端点形状的时候,利用端点形状只有4种可能的特性,只需要0.25像素的偏移,仅需一次贴图采样就能判断出端点形状。

最后是判断边缘形状。我们上面讲过总共有16种模式,如果通过分支去判断会产生很大的开销。

SMAA将所有的模式预计算到了一张4D贴图里,只需要两端端点的形状和长度信息就能索引到相应的覆盖权重。

Jimenez版的MLAA处理过程如下:
在这里插入图片描述

分为三个步骤:1.边缘检测。2.计算权重因子。3.混合周围像素。

SMAA在(b)、(c)的基础上加入了针对尖锐几何特征的处理,并加入了对角线模式识别;在(d)中加入了对局部对比度的考虑;在(e)改善了距离搜索算法。

3.1 SMAA边缘检测

SMAA的第一步是图像边缘的检测。SMAA在常规边缘检测的基础上加入了对局部对比度的考量。注意下方左图中圆圈内的像素,常规做法中如阈值设置不当,容易检测出多余的边(下方右图红色线段)。
在这里插入图片描述
SMAA的改良版检测方法如下图所示,灰点为当前像素,以检测左边缘为例,C_l必须同时满足:①大于一定阈值;②大于C_l 、C_r 、C_t 、C_b 、C_2l强度最大值的一半这两个条件时,才会被判定为边缘。这种考虑了局部对比度的检测可以有效避免误检。

3.2 SMAA的形状检测及权重计算

边缘形状的检测及混合权重的计算是在上一步中得到的edgesTex上进行的。

通常锯齿只有一个像素的高度,因此可以分成水平方向的锯齿及垂直方向的锯齿两种情况分别处理。
在这里插入图片描述
这里先以水平方向的情况为例进行说明。

3.3 距离搜索

首先我们要检测出当前像素到所在边缘左右两端的距离,以及该边缘的形状,才能得到此像素及其邻接像素的权重因子。

Jimenez版MLAA中运用了硬件的线性插值机制来加速距离的搜索。下图为向左搜索的例子,其中★为当前像素,◆为采样位置。通过将采样位置偏移0.5个像素,可以使搜索时一次行进两个单位。当采样到的g值小于1时(等于0.5或者0),我们便知道已到达了左边界。
在这里插入图片描述
但这样搜索存在的一个比较大的问题是会错过途中出现的交叉边。
在这里插入图片描述
SMAA对此进行改进,利用硬件的双线性插值机制,通过一次采样得知4条边的情况。在水平向左搜索的例子中,SMAA先对当前像素偏移(-0.25, -0.125)个单位,再向左以每步2个单位的速度搜索。当采样到的g值小于0.8,或者r值大于0时,即可知已抵达或越过边界。如果已越过边界,便需要将采样位置往回退,以得到准确的边界点坐标。回退距离是与前面采样到的rg值的组合一一对应的。因此SMAA提供了一张叫做searchTex的查找表(如下图),以快速获取回退距离。
在这里插入图片描述
对于向右、上、下方向搜索的情况,则需要先分别偏移(1.25, -0.125)、(-0.125, -0.25)、(-0.125, 1.25)个单位,再向各自方向行进。

这样,便得到了端点的坐标以及到两端的准确距离d_left和d_right。

3.4 形状检测

得知端点坐标之后,需要判断交叉边的朝向,以确定边缘形状。利用硬件线性插值机制,只需一次采样即可获取交叉边的信息。以右端点为例,如下图所示:
在这里插入图片描述
采样点向上偏移了0.25个单位,采样得到的r值存在4种可能(0, 0.25, 0.75, 1.0),分别代表了4种形状。我们将左右端采样到的r值分别记作e_1和e_2。

3.5权重因子的获取

在这里插入图片描述
整张贴图被分成了5x5共25个小格,除去中间的一行和一列,每一格对应一种形状。第一行对应(e_1, e_2)的值分别为(0, 0),(0.25, 0),(0.5, 0),(0.75, 0),(1, 0),第一列则对应(0, 0),(0, 0.25),(0, 0.5)……
贴图中每个像素存储了两个数值(r和g),分别是当前像素及另一边像素(共享边缘的邻接像素)的权重因子,记作a_t和a_b。
MLAA直接将此权重因子用于后续的颜色混合,然而目前的形状检测并不能区分锯齿与真正的转角,
这就容易导致尖锐形状被模糊,如下图所示:
在这里插入图片描述
圆圈中存在真正的转角,但从重矢量化的结果得知该转角将被模糊掉。
在这里插入图片描述
SMAA在此加入了对真正转角的检测处理。其检测原理基于一个很基本的发现:锯齿通常只有一个像素的高度。因此在检测交叉边的时候,将检测范围扩展到两个像素的宽度,并对权重因子作如下更新即可:
在这里插入图片描述

在这里插入图片描述
其中r为圆滑因子,若要完全保留转角,则令r=0,若要把转角当锯齿处理,则令r=1。上图中左上方的转角,由于e_3不为零,因此转角处像素的权重因子将会乘以r,这就实现了尖锐转角的保留。

将获取到的权重因子保存到一张叫blendTex的纹理中,其中rg通道存储水平方向锯齿的权重因子,ba通道存储垂直方向锯齿的权重因子。
对角模式的检测与权重计算
此前,大部分后处理抗锯齿技术只考虑水平方向及垂直方向的锯齿,而忽视了对角方向的处理。对于接近45°的斜线,容易出现下图中矢量化错误的问题:
在这里插入图片描述
SMAA引入了针对对角模式的处理,使矢量化更加准确:
在这里插入图片描述绿色线段代表检测出来的对角线,蓝色线段代表修正后的矢量化结果。基本处理思路与水平垂直方向的情况类似,分为以下三个步骤:

  1. 查找当前像素到两端的距离d_l和d_r。
  2. 提取交叉边信息e_1和e_2。
  3. 综合(d_l, d_r, e_1, e_2)信息从预计算的贴图中获取权重信息,如下图所示:
    在这里插入图片描述
    对角线搜索不能像水平垂直搜索那样一步两个单位地行进,只能在对角方向上一步一个单位地行进。另外,45°方向及-45°方向的搜索是需要分别处理的,因为45°方向的边缘像素上r和g同时=1(像素呈黄色),而-45°方向的边缘像素则是左绿右红的状态。如下图所示。
    在这里插入图片描述
    45°的情况,采样点可直接设在像素中心,而-45°的情况则需要将采样点往x方向偏移+0.25个单位,以一次获取两个像素的情况。
    另外,在具体实现时,对角模式的检测是执行在水平与垂直检测之前的,若对角检测获取到了大于0的权重因子,则跳过后续的水平与垂直的检测。获取到的权重保存在blendTex的rg通道里。前面流程图中的(e)图就是一张blendTex。
    至此便完成了第二个pass的渲染。由于整个pass是仅对边缘像素起作用的,因此可以用stencil buffer将边缘像素标记出来,以减少不必要的计算。

颜色的混合
第二个pass所得到的blendTex作为该pass的输入。blendTex记录了每一个像素的权重因子,根据权重信息将画面的每个像素与其相邻像素混合,便能达到抗锯齿的目的。
首先需要获取当前像素与上下左右4个邻接像素的混合权重因子。(此处需要注意blendTex对权重的记录方式:blendTex中每个边缘像素记录的不仅有当前像素的权重,还有对向像素(共享边缘的邻接像素)的权重)。混合时,要么在水平方向进行混合,要么在垂直方向混合。假设float4 a的xyzw四个分量分别记录了当前像素与右、上、左、下4个方向的混合权重因子,则判断max(a.x, a.z)与max(a.y, a.w)的大小关系来决定混合的方向。以水平方向为例,分别将向左及向右混合得到的颜色记作C1和C2,那么该像素最终的颜色=a.zC1+a.xC2。
像素颜色的混合也可以利用硬件线性插值机制实现,如下图所示:
在这里插入图片描述
至此,我们便成功地把图像上的锯齿平滑掉了。

亚像素渲染
基础的SMAA算法是以像素为单位进行处理的,因此不能捕捉到亚像素级别的细节。
原作者剔除将基础的SMAA算法与其他AA算法进行结合,以达到更好的亚像素级渲染效果。并总结出以下的几种模式。
SMAA 1x:包含了上述章节所述特性的抗锯齿算法。
SMAA S2x:SMAA 1x与空间多重采样(MSAA 2x)相结合。
SMAA T2x:SMAA 1x与temporal AA相结合。
SMAA 4x:SMAA 1x与空间多重采样以及temporal AA同时结合。
这里有点奇怪,原文作者明确的说了,SMAA 4x是将
在这里插入图片描述
但是nvidia官网上介绍古墓丽影渲染性能时,说了只有MSAA和SMAA结合,不知道是不是TAA被阉割了还是介绍错了。。。
在这里插入图片描述
其中SMAA T2x的基本思路是:结合速度场信息,将当前帧(SMAA 1x处理后)的像素与上一帧的对应像素进行混合:
s_t=αx_t+(1-α)s_(t-1)
其中混合的权重α是取决于像素在帧间的对应程度的,与当前帧及上一帧的速度相关。具体计算在此暂不详述。
在渲染不同帧时,需要对摄像机在视平面上作不同的少量偏移,以捕捉亚像素级别的细节。SMAA T2x的做法是,对单数帧偏移(0.25, -0.25)个像素单位,对双数帧偏移(-0.25, 0.25)个单位。对于不同的偏移值,像素的权重因子显然也是不一样的,如下图所示:
在这里插入图片描述
对于橙色采样点,权重因子比原来更大,而对于紫色采样点则相反。SMAA为所有用到的偏移值分别预计算了一份权重查找表,并全部整合到areaTex里:
在这里插入图片描述
SMAA S2x的做法也类似,只不过不是在时间上混合,而是在空间上混合。SMAA S2x为同一个像素计算不同偏移值((0.25, -0.25)和(-0.25, 0.25))下的权重因子,将由两个权重因子计算得到的两个颜色的均值作为最终的颜色。

SMAA 4x则是在SMAA S2x的基础上加上temporal AA,其中空间及时间上的偏移值更改如下:
在这里插入图片描述

4.SMAA优点

可以看出,SMAA 对锯齿的处理非常精细,得到的效果也非常好,可以说是基于后处理方法,处理抗锯齿的极限。如果要得到更好的抗锯齿效果,还可以和其他的抗锯齿方式进行结合。

5.SMAA缺点

和 FXAA 一样,SMAA 也没有次像素的特征,在高频区域移动摄影机时可能会出现闪烁。
SMAA的需要三次 Pass。
相对于 FXAA 性能消耗略大,对于现在的PC来可以说是非常轻松。而在手机上运行时,因为手机上切换 RenderTarget 会有比较大的开销,因此手机上还是使用 FXAA 的比较多,虽然画面会模糊一些,但是属于可以接受的范围。

之后会贴上实现代码的

五.TAA(时域抗锯齿)

之前讲过,抗锯齿的方法无外乎两种,通过增加采样率抗锯齿,和通过后处理的方式在图像层面抗锯齿。

MSAA是在图像空间上增加次像素点,来实现抗锯齿效果,带来了额外的内存开销。

TAA的原理和SSAA大致上是相通的,不同点在于,TAA是综合历史帧数据来实现的抗锯齿,这样会使得每个像素点的多次采样均匀平摊到多个帧当中,相对开销会小很多。

1.静态场景

首先来看处理静态场景的情况。在前面讲到 MSAA 时我们知道,实现抗锯齿要在一个像素中的多个位置进行采样。在 MSAA 中,我们在一帧中,在每个像素中放置了多个次像素采样点。

在 TAA 中,我们实现的方式,就是在每帧采样时,将采样的点进行偏移,实现抖动 (jitter)。

采样点抖动的偏移,是和 MSAA 的次像素采样点放置是相同的,都需要使用低差异的采样序列,来实现更好的抗锯齿效果。TAA 中都会直接使用 Halton 序列,采样的点位置如下所示:
Halton采样序列

上图是Halton采样序列

要对采样点进行偏移,我们只需要稍微修改下我们的透视投影矩阵。这部分比较简单,只需要将偏移的XY分量分别写入到投影矩阵的[2, 0] 和 [2, 1]即可。这样,当左边的向量值乘以新的投影矩阵时,最终得到裁剪空间的坐标也会相应偏移。稍微需要注意下的就是需要将偏移的值转化到裁剪空间中。
在这里插入图片描述
上图是抖动对投影矩阵的影响。

体现在代码中,这一步大致是这样的。

/ 先将Halton序列的值转化为 -0.5~0.5范围的偏移;再除以屏幕长度,得到UV下的偏移值;最后乘以2,是转化到裁剪空间中的偏移值
ProjectionMatrix.m02 += (HaltonSequence[Index].x - 0.5f) / ScreenWidth * 2;
ProjectionMatrix.m12 += (HaltonSequence[Index].y - 0.5f) / ScreenHeight * 2;

接下来就是对历史的帧进行混合了,首先我们要确定 TAA 在图形管线中的位置。
从结果上来看,如果使用 HDR 颜色作为输入,得到的抗锯齿效果不佳。所以需要把 TAA 放到 ToneMapping 之后。但是这样又会影响后续需要 HDR 的 Bloom 等特效的计算。因此我们需要先进行一次 ToneMapping,进行 TAA 后再将 ToneMapping 还原,然后处理需要 HDR 颜色输入的后处理,最终再进行一次 ToneMapping 计算。第一次的 ToneMapping 使用比较简单的 Reinhard ToneMapping 算法即可。
这里的处理方式和前面我们讲过的 HDR 下 MSAA 的处理方式是一样的。
在这里插入图片描述
为了计算方便,我们不会将很多个历史帧保留下来做混合,而是直接使用当前帧的结果和上一帧得到的历史结果做混合。混合的方式,就是简单地使用百分比混合,即将历史帧数据,和当前帧数据进行 lerp。

float3 currColor = currBuffer.Load(pos);
float3 historyColor = historyBuffer.Load(pos);
return lerp(historyColor, currColor, 0.05f);

这里的取混合系数为0.05,意味着最新的一帧渲染结果,只对最终结果产生了5%的贡献。

将累计的过程展开来看的话,可以看出当前帧 TAA 后的结果,是包含了所有的历史帧结果的,说明这种方式是合理的:在这里插入图片描述
下图显示了 TAA 历史帧周期和混合系数,相对应的每像素超采样个数。例如当取混合系数 alpha = 0.1,混合周期 N = 5 时,相当于每个像素点进行了2.2个超采样, 混合周期 N = 10时,相当于每个像素点进行了 5.1 次超采样。
在这里插入图片描述
到这里我们已经实现了静态场景和静态镜头的 TAA,下面就要开始考虑动态的部分了。

2.重投影

首先要考虑的是镜头的移动,镜头移动后,原来投射到某个像素上的物体,现在很可能不在原来的位置上了。假设物体是不动的,我们就可以使用当前帧的深度信息,反算出世界坐标,使用上一帧的投影矩阵,在混合计算时做一次重投影 Reprojection/重投影
在这里插入图片描述
重投影

不过重投影只能适用于静态的物体,如果物体是移动的,我们就无法精确还原物体上一帧的投影位置了。

3.动态物体

物体本身的移动比较复杂,包含了平移旋转缩放。再加上摄影机本身的移动,直接在混合时进行计算的话,计算起来非常困难。

要对历史数据进行混合,就要能够还原出当前物体在屏幕中投影的位置。为了能够精确地记录物体在屏幕空间中的移动,我们使用 Motion Vector 贴图来记录物体在屏幕空间中的变化距离,表示当前帧和上一帧中,物体在屏幕空间投影坐标的变化值。因为 Motion Vector 的精度要求比较高,因此用RG16格式来存储。
Motion Vector 可以作为延迟渲染的 GBuffer 的一部分,除了用了实现 TAA,还可以实现运动模糊/Motion Blur 等效果。

在渲染物体时,我们需要用到上一帧的投影矩阵和上一帧该物体的位置信息,这样可以得到当前帧和上一帧的位置差,并写入到 Motion Vector。对于带蒙皮动画的物体,我们同时需要上一帧的骨骼的位置,来计算处上一帧中投影到的位置。计算上一帧位置和当前帧位置的方法是一样的,都是从 VS 中输出裁剪空间的齐次坐标,在 PS 中读取,然后就可以做差求得 Motion 值。为了使 Motion 的值比较精确,我们在计算 Motion 时,不会添加抖动

另外一个需要考虑的地方是一些基于UV 变化的动画效果,需要将偏移值转化为屏幕空间中的偏移。

还有就是平面反射的效果,需要小心翼翼地推导出反射时使用的矩阵和抖动,反射的位置信息等,这里的原理并不复杂,但是计算起来会非常麻烦。
在这里插入图片描述
上图反映了平面反射的计算需要考虑TAA的影响。

尽管从理论上来说,所有的物体都应该有Motion Vector信息,但是有些物体却无法做到,比如:

带有复杂贴图动画的物体,粒子烟雾,水流等;
半透明物体,因为Motion Vector只有一层,因此无法写入。

不过因为这些物体汪汪都是很薄的一层,且都是很快小时的,因此抖动产生的误差比较不容易注意到,所以也就不需要去特殊处理。

4.使用 Motion Vector

接下来就是使用 Motion Vector 进行混合计算了,我们需要使用 Motion Vector 算出上一帧物体在屏幕空间中投射的坐标
在计算之前,我们先要移除当前像素采样的抖动偏移值,然后减去采样 Motion Vector 得到的 Motion 值,就可以算出上一帧中投影坐标的位置。然后就可以根据位置对历史数据进行采样了,因为我们得到的坐标往往不是正好在像素中心位置,因此这里使用双线性模式进行采样。

// 减去抖动坐标值,得到当前实际的像素中心UV值
uv -= _Jitter;
// 减去Motion值,算出上帧的投影坐标
float2 uvLast = uv - motionVectorBuffer.Sample(point, uv);
//使用双线性模式采样
float3 historyColor = historyBuffer.Sample(linear, uvLast);

当镜头的移动时,可能会导致物体的遮挡关系发生变化,比如一个远处的物体原来被前面的物体遮挡住,现在因为镜头移动而忽然出现,这时采样 Motion 偏移得到的位置,上帧中其实是没有渲染的数据的。
因此为了得到更加平滑的数据,可以在当前像素点周围判断深度,取距离镜头最近的点位置,来采样 Motion Vector 的值,这样可以减弱遮挡错误的影响。

这一步的计算过程大致如下:

float2 GetClosestFragment(float2 uv)
{
    float2 k = _CameraDepthTexture_TexelSize.xy;
    //在上下左右四个点
    const float4 neighborhood = float4(
        SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, sampler_PointClamp, clamp(uv - k)),
        SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, sampler_PointClamp, clamp(uv + float2(k.x, -k.y))),
        SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, sampler_PointClamp, clamp(uv + float2(-k.x, k.y))),
        SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, sampler_PointClamp, clamp(uv + k))
    );
    // 获取离相机最近的点
    #if defined(UNITY_REVERSED_Z)
        #define COMPARE_DEPTH(a, b) step(b, a)
    #else
        #define COMPARE_DEPTH(a, b) step(a, b)
    #endif
    // 获取离相机最近的点,这里使用 lerp 是避免在shader中写分支判断
    float3 result = float3(0.0, 0.0, SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, sampler_PointClamp, uv));
    result = lerp(result, float3(-1.0, -1.0, neighborhood.x), COMPARE_DEPTH(neighborhood.x, result.z));
    result = lerp(result, float3( 1.0, -1.0, neighborhood.y), COMPARE_DEPTH(neighborhood.y, result.z));
    result = lerp(result, float3(-1.0,  1.0, neighborhood.z), COMPARE_DEPTH(neighborhood.z, result.z));
    result = lerp(result, float3( 1.0,  1.0, neighborhood.w), COMPARE_DEPTH(neighborhood.w, result.z));
    return (uv + result.xy * k);
}

//在周围像素中,寻找离相机最近的点
float2 closest = GetClosestFragment(uv);
//使用周围最近点,得到Velocity值,来计算上帧投影位置
float2 uvLast = uv - motionVectorBuffer.Sample(point, closest);
//...

这里的计算,是寻找周围 5 个像素点的最近点。如果想要更好的效果,可以将 周围 5 个点改成 9 个点。

因为使用了双线性采样,所以得到的值会混合周围像素的颜色,造成结果略微模糊。如果想要使得到的历史结果质量更好,也可以使用一些特殊的过滤方式进行处理。比如UE4中使用 Catmull–Rom 的方式进行锐化过滤。Catmull-Rom方式的采样,会在目标点周围进行 5 次采样,然后根据相应权重进行过滤混合,额外的开销也非常大。
在这里插入图片描述

5.对历史结果的处理

由于像素抖动,模型变化,渲染光照变化导致渲染结果发生变化时,会导致历史帧得到的像素值失效,就会产生鬼影/ghosting 和 闪烁 /flicking 问题。
在这里插入图片描述
为了缓解鬼影和闪烁的问题,我们还要对采样的历史帧和当前帧数据进行对比,将历史帧数据 clamp/截断 在合理的范围内。

要确定当前帧目标像素的亮度范围,就需要读取当前帧数据目标像素周围 5 个或者 9 个像素点的颜色范围:
在这里插入图片描述
比如现在要使用周围 9 个点像素作为 clamp 范围,AABBMin和 AABBMax形成了一个 AABB 的范围区域,为了使计算的结果更加准确,我们这里把色彩先转换到YCgCo 色彩空间内。

简单的做法就是直接进行 clamp:

float3 AABBMin, AABBMax;
AABBMax = AABBMin = RGBToYCoCg(Color);
// 取得YCoCg色彩空间下,Clip的范围
for(int k = 0; k < 9; k++)
{
    float3 C = RGBToYCoCg(_MainTex.Sample(sampler_PointClamp, uv, kOffsets3x3[k]));
    AABBMin = min(AABBMin, C);
    AABBMax = max(AABBMax, C);
}
// 需要 Clip处理的历史数据
float3 HistoryYCoCg = RGBToYCoCg(HistoryColor);

// 简单地进行Clmap
float3 ResultYCoCg =  clmap(History, AABBMin, AABBMax);
//还原到RGB色彩空间,得到最终结果
HistoryColor.rgb = YCoCgToRGB(ResultYCoCg));

另外一种做法是进行 clip,clip的效果会更好,计算量也会相对较大二者的差别可从下图看出:
在这里插入图片描述
上图是Clip和Clamp的区别

float3 AABBMin, AABBMax;
AABBMax = AABBMin = RGBToYCoCg(Color);
// 取得YCoCg色彩空间下,Clip的范围
for(int k = 0; k < 9; k++)
{
    float3 C = RGBToYCoCg(_MainTex.Sample(sampler_PointClamp, uv, kOffsets3x3[k]));
    AABBMin = min(AABBMin, C);
    AABBMax = max(AABBMax, C);
}
// 需要 Clip处理的历史数据
float3 HistoryYCoCg = RGBToYCoCg(HistoryColor);

// 下面是clip计算的过程
float3 Filtered = (AABBMin + AABBMax) * 0.5f;
float3 RayOrigin = History;
float3 RayDir = Filtered - History;
RayDir = abs( RayDir ) < (1.0/65536.0) ? (1.0/65536.0) : RayDir;
float3 InvRayDir = rcp( RayDir );

// 获取和Box相交的位置
float3 MinIntersect = (AABBMin - RayOrigin) * InvRayDir;
float3 MaxIntersect = (AABBMax - RayOrigin) * InvRayDir;
float3 EnterIntersect = min( MinIntersect, MaxIntersect );
float ClipBlend = max( EnterIntersect.x, max(EnterIntersect.y, EnterIntersect.z ));
ClipBlend = saturate(ClipBlend);

// 取得和 ClipBox 的相交点
float3 ResultYCoCg =  lerp(History, Filtered, ClipBlend);
//还原到RGB色彩空间,得到最终结果
HistoryColor.rgb = YCoCgToRGB(ResultYCoCg));

6.混合的得到的结果

然后就可以混合历史颜色和当前的颜色了,不同于在静态场景中直接使用 0.05 作为混合系数,我们在这里使用一个可变化的混合系数值来平衡抖动和模糊的效果,当物体的 Motion Vector 值比较大时,就增大 blendFactor 的值,反之则减小:

// 与上帧相比移动距离越远,就越倾向于使用当前的像素的值
blendFactor = saturate(0.05 + length(motion) * 100);
return lerp(historyColor, currColor, blendFactor);

因为我们用到了很多双线性采样,会使得得到的结果有些模糊,因此我们根据情况选择是否对结果进行一次简单的锐化。

到这里为止,我们已经实现了 TAA 的整个流程,得到了不错的抗锯齿效果。

支线:仅使用重投影的TAA

在某些情况下,出于性能或其他方面的考虑,我们不能使用 Motion Buffer 来保存两帧之间的投影坐标差。这种方式没有使用 Motion Vector 来保存投影坐标,相对会比较节省性能。如果物体是快速运动的,就会产生错误的效果。
这种方式虽然结果不精确,但是性能开销小。非常适用于一些需要进行降噪处理的场合。比如对于 SSAO、SSR、Volumetric Fog、Raymarching Cloud 等这些开销很大的渲染效果,重投影的 TAA 能以非常低的开销实现降噪。而产生的渲染瑕疵,往往是可以接受的。
在这里插入图片描述
仅重投影的TAA对体积雾的平滑效果

TAA小结

因为 TAA 本质上是和 SSAA 类似的原理,所以完全不会有后处理抗锯齿的次像素问题,AA效果可以和 MSAA 相当。
由于 MSAA 在延迟渲染管线中无法使用,且使用 Motion Vector 也和延迟渲染比较契合,因此在 Unreal 引擎的带领下,TAA 目前也逐渐成为现代 3A游戏的标配。

当然其缺点也比较明显,虽然 TAA 实现的原理并不复杂,但是和渲染管线关系密切,需要改动的地方较多。可以说是牵一发而动全身,对于其他的每一项渲染功能,都需要考虑 TAA 的影响。

除此之外 TAA 中很容易出现一些闪烁,鬼影等问题,需要不同的应用场景进行处理。因为 TAA 和渲染管线是紧密相关的,在不同游戏引擎中,TAA的很多细节处理方式都不太一样。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值