在图形渲染中,锯齿或者说走样是一个不得不提的问题。
本文将对锯齿的产生以及相关的抗锯齿技术进行一个简单的介绍,尤其是时间抗锯齿(Temporal AA)。
图形渲染,实质而言是一种采样(Sampling):对三维场景进行采样,输出 2 D 2D 2D的图像。
根据奈奎斯特采样定理:
更多细节请参考《信号与系统》相关的知识。
这里隐藏着一个完美重建的条件,即原信号的最高频率必须要要是有限的(band-limited)。
但场景的是义在三维空间中是连续函数(包含的场景的几何覆盖关系,着色参数和着色方程等),这个函数并非有限带宽的函数。
因此,不论以多大的采样频率(反应在图形上,即图像分辨率)去采样这个函数,都不可能完美的恢复原始信号。
最终显示的像素则是一个离散的二维数组,判断一个点到底没有被某个像素覆盖的时候单纯是一个“有”或者“没有"问题,丢失了连续性的信息,导致锯齿(走样,Aliasing)。
其实在现实生活中,走样现象还是比较常见的,比如摩尔纹,车轮倒转等。
具体到实时渲染领域中,可以将锯齿(走样)分为以下三种:
锯齿问题是渲染系统中不可绕过的问题,而为了解决这个问题,也涌现了一批优秀的解决方案。
这些降低锯齿感的做法,称作抗锯齿Anti-Aliasing,简称AA。
抗锯齿的方法一般可以分为两类:
空间抗锯齿技术是指:
时域抗锯齿技术是指:
空间抗锯齿的算法有很多,如:SSAA、MSAA、CSAA、DEAAA、MLAA、SRAA、FXAA、SMAA。
本节将对其中的SSAA、MSAA进行简单的介绍。
时域抗锯齿技术将在第四节中进行介绍。
SSAA,基于超采样的方法,是最简单粗暴的抗锯齿技术。
注,FSAA是Full-Screen AA的缩写,虽与SSAA名字不同,但两者指的是同一项AA技术。
拿4xSSAA举例子:
这种做法在数学上是最完美的抗锯齿(同时是几何反走样和着色反走样方法),因为它不但增加了当前几何覆盖函数(Coverage)的采样率,也对渲染方程进行了更高频率的采样(单独计算每个子像素的颜色)。
但是劣势也很明显,光栅化和着色的计算负荷都比原来多了4倍,RenderTarget的大小也涨了4倍,这导致其性能太差。
SSAA,简单的来说可以分三步:
不同SSAA方式在子采样位置的选取和最终resolve使用的滤波器上有所不同。
可以使用不同的采样模板(规则采样,旋转采样,随机采样,抖动采样等)或者不同的滤波函数(方波滤波器或者高斯滤波器)。
摘自深入剖析MSAA和延迟渲染与MSAA的那些事
SSAA,需要更多的显存空间和更多的着色计算(每个子采样点都需要进行光照计算),所以一般不会使用这种技术。
MSAA,基于人眼对几何走样更敏感的原则,将几何覆盖函数的采样率和着色方程的采样率进行了解耦。其将一个像素划分为若干个子采样点,但相较于SSAA,每个子采样点的颜色值完全依赖于对应像素的颜色值进行简单的复制(该子采样点位于当前像素光栅化结果的覆盖范围内),不进行单独计算,即只计算一次着色。
子采样点计算一个覆盖信息(coverage)和遮挡信息(occlusion),即每个子像素会在光栅化阶段分别计算自身的Z值和模板值,有完整的Z-Test和Stencil-Test并单独保存在Z-Buffer和Stencil-Buffer里。
覆盖(Coverage):
如下图,一个三角形的覆盖信息。蓝色的点代表采样点,每一个都在像素的中心位置。红色的点代表三角形覆盖的采样点。
遮挡(Occlusion):
覆盖和遮挡两个一起决定了一个图形的可见性。
由于对于每个子采样点而言,都需要存储额外的深度值,这意味着深度缓冲区是非MSAA情况下的 n n n倍。
并且,虽然只对每个像素进行着色一次,但是这并不意味着我们只需要存储一个颜色值,而是需要为每一个子采样点都存储颜色值,所以我们需要额外的空间来存储每个子采样点的颜色值。所以,颜色缓冲区的大小也为非MSAA下的n倍。
一般情况下是这样,如果没有优化。
因此如果一个三角形覆盖了4倍采样方式的一半,那么一半的子采样点会接收到新的值。或者如果所有的子采样点都被覆盖,那么所有的都会接收到值。
通过使用覆盖掩码来决定子采样点是否需要更新值,最终结果可能是n个三角形部分覆盖子采样点的n个值。
下图展示了4倍MSAA光栅化的过程。
像超采样一样,过采样的信号必须重新采样到指定的分辨率,这样我们才可以显示它。
这个过程叫解析(resolving)。
在它最早的版本里,解析过程是在显卡的固定硬件里完成的。一般使用的采样方法就是一像素宽的box过滤器。这种过滤器对于完全覆盖的像素会产生跟没有使用MSAA一样的效果。
不同的硬件厂商可能会使用不同的算法。
不同的子采样点的个数会带来不同的抗锯齿效果,如下图所示。
随着显卡的不断升级,我们现在可以通过自定义的shader来做MSAA的解析了,比如DX12就支持。
小结如下:
MSAA并不是在光栅化阶段就可以完全的,它在这个阶段只是生成覆盖信息,然后计算像素颜色,根据覆盖信息和深度信息决定是否来写入子采样点。
整个完成后再通过某个过滤器进行降采样得到最终的图像。大体流程如下所示:
延迟渲染到底能不能开MSAA?为什么?
从原理上来说,是完全没有问题的。
延迟渲染分为GBuffer阶段和光照阶段,看其具体步骤:
从MSAA的原理中,MSAA对于光照是没有抗锯齿的功能的(因为并没有计算多余的光照信息),其本身是一种几何抗锯齿算法。
因此,有效的应用阶段其实是GBuffer阶段。
几何锯齿的产生原因是像素有大小,在光栅化时对于三角形边缘的像素采样不足,MSAA就是提高了对光栅化采样率来达到抗锯齿的效果。GBuffer中,记录光栅化覆盖信息的是BaseColor,因此只需要在渲染BaseColor纹理的过程中执行MSAA,即可达到抗锯齿的效果。
为什么会有“延迟渲染没法使用硬件抗锯齿”说法呢?
因为:在十几年前的DX9时代,MRT是不支持MSAA的!!!
后来的DX10.1就支持了带MSAA的MRT。
不过,MRT要求对每个RT使用相同的Sample,所以我们是没法对BaseColor开小灶的,要么多花一个Pass先画BaseColor再画其他,要么对其他RT也进行多倍采样。
对于前者,新增Pass又再一次增大了性能消耗。
而后者会对深度和法线进行插值,显然也不可行,除非自定义最后插值的过程。
这样一通操作下来,还是消耗了数倍的带宽。
这样权衡下来,还不如直接上SSAA算了,至少后者效果更好。
而实践中,TAA等后处理抗锯齿加SSAA的组合成为主流,MSAA的身影反而几乎看不到了。
TAA(Temporal Anti-Aliasing),是一种基于时间的反走样方法,它仍是为了解决几何走样和着色走样,并非为了解决时域走样(如旋转车轮)。
根据前文介绍,走样的出现是由于采样不足, SSAA 和 MSAA 都是将采样点散布在当前帧的二维空间里。
而基于时间的反走样则是将把采样点散布在帧序列(时间)里,从而减轻了单帧渲染的负担。
如下图所示,SSAA在每帧都需要执行多个子像素采样,而TAA则是把这些采样点均摊到多帧。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-jALfFNAI-1626444779199)(images/10-SSAA-TAA.png)]
它基于一个假设:
就理论而言,TAA在场景运动变化不大的情况下,效果和性能都显著优于上述的各类算法。
TAA的基本框架如下图所示:
总体而言,可以分为两个部分:
TAA的核心思想如下图所示,把样本分布到过去的N帧(历史帧)中去,然后每一帧从过去的N帧中取得样本信息然后Filter,达到N倍Super Sampling的效果。
对于一个完全静止的画面(相机不运动,场景中的物体也不发生运动),只需要每帧简单地对采样点的位置进行抖动(Jitter),稍微改变采样点的位置即可生成多个采样点,再采样加权这些样本,即可实现超采样,从而实现抗锯齿的目的。
那么如何生成这些样本点呢?
常用的方法是:对采样点进行偏移。具体的实现方法为对投影矩阵添加小的偏移量。
如UE4中:
ProjMatrix[2][0] +=(SampleX * 2.0f -1.0f)/ViewRect.Width();
ProjMatrix[2][1] +=(SampleY * 2.0f -1.0f)/ViewRect.Height();
当然,随机生成采样偏移的方法是可行的。
但是我们希望采样点在像素矩形内尽可能均匀且分散,而纯随机的采样点可能会导致一定的聚集。
对于TAA而言,除了在空间上均匀且分散以外,我们还希望样本点能够在时间上也是均匀分散的。
Low-discrepancy(低差异序列)则可以为我们提供非常好的均匀散布的特性。
使用低差异序列作为采样点的一个好处是,无论需要多少个采样点,都可以通过取序列中的前 N 个值,得到一个均匀分布的 pattern。
不过,采样点的数量会影响TAA的收敛速度,如果配合不恰当的历史帧混合还会导致画面抖动的现象。
UE4使用的是Halton Sequence。在二维空间下的 Halton Sequence 通常采用 2 和 3 作为 XY 坐标序列的基数,更大的基数带来非常规整的分布。
UE4默认选择策略是halton(2, 3)的前8次采样,同时还提供了多种配置。
下面为UE4不同采样数的抖动策略:
// [-0.5, 0.5]
if( CVarTemporalAASamplesValue == 2 )
{
// 2xMSAA
// Pattern docs: http://msdn.microsoft.com/en-us/library/windows/desktop/ff476218(v=vs.85).aspx
// N.
// .S
float SamplesX[] = { -4.0f/16.0f, 4.0/16.0f };
float SamplesY[] = { -4.0f/16.0f, 4.0/16.0f };
check(TemporalAASamples == UE_ARRAY_COUNT(SamplesX));
SampleX = SamplesX[ TemporalSampleIndex ];
SampleY = SamplesY[ TemporalSampleIndex ];
}
else if( CVarTemporalAASamplesValue == 3 )
{
// 3xMSAA
// A..
// ..B
// .C.
// Rolling circle pattern (A,B,C).
float SamplesX[] = { -2.0f/3.0f, 2.0/3.0f, 0.0/3.0f };
float SamplesY[] = { -2.0f/3.0f, 0.0/3.0f, 2.0/3.0f };
check(TemporalAASamples == UE_ARRAY_COUNT(SamplesX));
SampleX = SamplesX[ TemporalSampleIndex ];
SampleY = SamplesY[ TemporalSampleIndex ];
}
else if( CVarTemporalAASamplesValue == 4 )
{
// 4xMSAA
// Pattern docs: http://msdn.microsoft.com/en-us/library/windows/desktop/ff476218(v=vs.85).aspx
// .N..
// ...E
// W...
// ..S.
// Rolling circle pattern (N,E,S,W).
float SamplesX[] = { -2.0f/16.0f, 6.0/16.0f, 2.0/16.0f, -6.0/16.0f };
float SamplesY[] = { -6.0f/16.0f, -2.0/16.0f, 6.0/16.0f, 2.0/16.0f };
check(TemporalAASamples == UE_ARRAY_COUNT(SamplesX));
SampleX = SamplesX[ TemporalSampleIndex ];
SampleY = SamplesY[ TemporalSampleIndex ];
}
else if( CVarTemporalAASamplesValue == 5 )
{
// Compressed 4 sample pattern on same vertical and horizontal line (less temporal flicker).
// Compressed 1/2 works better than correct 2/3 (reduced temporal flicker).
// . N .
// W . E
// . S .
// Rolling circle pattern (N,E,S,W).
float SamplesX[] = { 0.0f/2.0f, 1.0/2.0f, 0.0/2.0f, -1.0/2.0f };
float SamplesY[] = { -1.0f/2.0f, 0.0/2.0f, 1.0/2.0f, 0.0/2.0f };
check(TemporalAASamples == UE_ARRAY_COUNT(SamplesX));
SampleX = SamplesX[ TemporalSampleIndex ];
SampleY = SamplesY[ TemporalSampleIndex ];
}
else
{
float u1 = Halton( TemporalSampleIndex + 1, 2 );
float u2 = Halton( TemporalSampleIndex + 1, 3 );
// Generates samples in normal distribution
// exp( x^2 / Sigma^2 )
static auto CVar = IConsoleManager::Get().FindConsoleVariable(TEXT("r.TemporalAAFilterSize"));
float FilterSize = CVar->GetFloat();
// Scale distribution to set non-unit variance
// Variance = Sigma^2
float Sigma = 0.47f * FilterSize;
// Window to [-0.5, 0.5] output
// Without windowing we could generate samples far away on the infinite tails.
float OutWindow = 0.5f;
float InWindow = FMath::Exp( -0.5 * FMath::Square( OutWindow / Sigma ) );
// Box-Muller transform
float Theta = 2.0f * PI * u2;
float r = Sigma * FMath::Sqrt( -2.0f * FMath::Loge( (1.0f - u1) * InWindow + u1 ) );
SampleX = r * FMath::Cos( Theta );
SampleY = r * FMath::Sin( Theta );
}
对于完全静止的画面,直接将同一屏幕位置的像素加权混合即可。
但如果场景中摄像机发生了移动或者物体发生了运动,场景中同一点在屏幕上的位置可能发生改变,那么直接加权混合,就会出现残影,甚至是错误。
为了获取正确的历史样本,我们需要知道当前帧屏幕上的位置相对于历史帧位置的偏移量。
因此,我们需要一张二维的Motion Vector Buffer,这张速度缓冲中记录这屏幕上每个位置的运动信息。通过这个信息即可获得相对应的历史帧的屏幕空间位置。
那么如何渲染得到这张Motion Vector Buffer呢?
这里需要考虑两种情况:
对于情况1,可使用Reprojection(重投影)技术,生成速度缓冲。
基本的思路:
该过程的示例图如下所示:
对于情况2,则需要对Mesh进行两次空间变换,分别投影,生成速度缓冲。
在一次渲染的时候,需要额外得到该物体在当前帧和上一帧的localToWorldMatrix
,使用这两个矩阵,和这两帧的摄像机投影矩阵,就可以变换得到一个物体从前一帧到当前帧投影到屏幕空间的 motion vector。
对于应用了骨骼蒙皮的 mesh,我们还需要得到前一帧的骨骼状态,在渲染其 motion vector 时,则需要做两次蒙皮变换,这对于具有大量骨骼动画的场景或许是一个不小的性能开销。
示例代码如下:
float4x4 PreviousViewProjection;
float4x4 PreviousModelMatrix;
float2 motionVector(flaot4 vertexPos)
{
float4 skinnedVertex = skinning(vertexPos);
float4 worldPos = mul(PreviousModelMatrix, skinnedVertex);
float4 p = mul(PreviousViewProjection, worldPos);
p /= p.w;
float2 previousScreenPos = p * .5 + .5;
float2 currentScreenPos = // ...
// ... Unjitter
// 二者相减得到motion vector
return currentScreenPos - previousScreenPos;
}
合成是将不同采样点渲染的画面混合。
尽管通过分摊超采样的方法,将计算量分摊到了不同帧,但是保存多帧是不切实际的,这会带来很大的性能开销。
这里采用了一种 Exponential History 的方法来解决。
理论上来说,我们所需要求解的值是过去N帧的均值,即:
s t = 1 N ∑ k = 0 N − 1 x t − k s_t = \frac{1}{N}\sum_{k=0}^{N-1}x_{t-k} st=N1k=0∑N−1xt−k
这个均值可以通过只保存一帧不断积累的历史帧来近似,变成:
s t = α x t + ( 1 − α ) x t − 1 s_t =\alpha x_{t} + (1-\alpha)x_{t-1} st=αxt+(1−α)xt−1
当 α \alpha α无限小的时候,这个近似值也就无限地接近于理论值。
α \alpha α为被称为指数平滑系数。
实际使用中,虚幻引擎4中的Temporal AA实现会选8或者16个时间样本,然后 α \alpha α取值0.04左右,再根据其他信息例如离上一帧像素间的距离的大小微调。
验证数据(Rejection and Rectification)
上述的融合公式要建立在历史样本是有效的情况。
对于实际情况:在进行Reprojection可能无法找到正确的历史像素,比如物体移动的时候,导致上一帧被遮挡的内容露出,匹配到上一帧的遮挡物上面了。
而遮挡物则匹配到上一帧的其他内容。又或者场景某些物体的光照情况发生了剧烈的变化。
以上的这些情况都会导致图像出现了鬼影(Ghosting)的效果。
鬼影问题来自于历史帧像素的无效。因此,我们需要对历史帧像素的有效性进行验证,并对无效的像素进行修正,从而解决这个问题。
一个最容易想到的方法就是:深度比较(Depth Compare)。即当前像素的深度与上一帧历史像素位置对应的深度对比,当差距过大的时候,就认为历史帧无效。
但这个方法并不有效,因为不是所有的有效像素都满足depth变化缓慢的条件,这可能导致正确的匹配被deny的风险。
UE4采用了一个方法Neighborhood Clamping,即根据当前像素周围像素的颜色数据来剔除无效的历史像素。
这种方法基于一个假设:当前像素样本附近的颜色和它的颜色接近,并且它们的取值范围形成一个凸包,我们认为位于这个凸包内的历史样本的色彩取值都是有效的,可以采用,而这个凸包外的色彩取值则无效,需要通过进一步的处理才能够采用。
目前并没有完全健壮的方法可以完美的检测无效样本,以下有一些参考的方法:
以下提供了一些参考的代码。
首先,求AABB盒的最大最小值:可以选择邻近的5个像素或者9个像素算出最小值和最大值。
float4 NeighborMin, NeighborMax;
NeighborMin = min3( Neighbors[1], Neighbors[3], Neighbors[4] );
NeighborMin = min3( NeighborMin, Neighbors[5], Neighbors[7] );
NeighborMax = max3( Neighbors[1], Neighbors[3], Neighbors[4] );
NeighborMax = max3( NeighborMax, Neighbors[5], Neighbors[7] );
NeighborMin和NeighborMax就形成一个AABB,可以通过截断(Clamp)对历史帧进行处理。
这就是UE4最早采用的邻近截取(Neibour Clamping)方案,截断的算法实现也非常简单:
History = clamp(History, NeighborMin, NeighborMax);
这种Clamp方法会导致一个问题,即处于颜色范围某条边外侧的所有像素都将被clamp至此边上,即颜色集聚。
如下图所示,出现了很多artifacts,最明显的是每个边缘都有红色的重影,图像看起来像是低分辨率最近过滤的。
当然更进一步就是采用上述(b)方法中的裁剪(Clip),需要计算线段与AABB的交点。
UE4的实现代码如下:
float HistoryClip(float3 History, float3 Filtered, float3 NeighborMin, float3 NeighborMax)
{
float3 BoxMin = NeighborMin;
float3 BoxMax = NeighborMax;
//float3 BoxMin = min( Filtered, NeighborMin );
//float3 BoxMax = max( Filtered, NeighborMax );
float3 RayOrigin = History;
float3 RayDir = Filtered - History;
RayDir = abs( RayDir ) < (1.0/65536.0) ? (1.0/65536.0) : RayDir;
float3 InvRayDir = rcp( RayDir );
float3 MinIntersect = (BoxMin - RayOrigin) * InvRayDir;
float3 MaxIntersect = (BoxMax - RayOrigin) * InvRayDir;
float3 EnterIntersect = min( MinIntersect, MaxIntersect );
return max3( EnterIntersect.x, EnterIntersect.y, EnterIntersect.z );
}
// Clamp history.
float4 ClampHistory(inout FTAAIntermediaryResult IntermediaryResult, float4 History, float4 NeighborMin, float4 NeighborMax)
{
#if !AA_CLAMP
return History;
#elif AA_CLIP
// Clip history, this uses color AABB intersection for tighter fit.
//float4 TargetColor = 0.5 * ( NeighborMin + NeighborMax );
float4 TargetColor = IntermediaryResult.FilteredColor;
float ClipBlend = HistoryClip( HistoryColor.rgb, TargetColor.rgb, NeighborMin.rgb, NeighborMax.rgb );
//float DistToClamp = saturate(-ClipBlend) / ( saturate(-ClipBlend) + 1 );
//float DistToClamp = abs( ClipBlend ) / ( 1 - ClipBlend );
ClipBlend = saturate( ClipBlend );
HistoryColor = lerp( HistoryColor, TargetColor, ClipBlend );
#if AA_FORCE_ALPHA_CLAMP
HistoryColor.a = clamp( HistoryColor.a, NeighborMin.a, NeighborMax.a );
#endif
return HistoryColor;
#else //!AA_CLIP
History = clamp(History, NeighborMin, NeighborMax);
return History;
#endif
}
不过,在一个较小的空间范围内(如3X3的块),颜色(即色度数据对比度)可以是多种多样的,但各个像素的亮度对比度走向基本上是稳定的。
基于上述这个理论,(c)将RGB的AABB转换到YCoCg颜色空间中进行Clip,从而实现了更好的效果。
下面展示了对比效果,可以看出YCoCg的效果明显要好的多。
RGB和YCoCg颜色空间转换的参考代码如下:
float3 RGBToYCoCg(float3 RGB)
{
float Y = dot(RGB, float3(1, 2, 1));
float Co = dot(RGB, float3(2, 0, -2));
float Cg = dot(RGB, float3(-1, 2, -1));
float3 YCoCg = float3(Y, Co, Cg);
return YCoCg;
}
float3 YCoCgToRGB(float3 YCoCg)
{
float Y = YCoCg.x * 0.25;
float Co = YCoCg.y * 0.25;
float Cg = YCoCg.z * 0.25;
float R = Y + Co - Cg;
float G = Y + Cg;
float B = Y - Co - Cg;
float3 RGB = float3(R, G, B);
return RGB;
}
采用AABB算出来的包围盒不够紧密,(d)方法使用了统计均值和方差来优化AABB的生成,再去裁剪历史帧像素。通过这种方式生成的AABB结合了像素数据分布的特点,能得到更好更稳定的结果。
UE4也提供了这方法的算法:
// Compute the neighborhood bounding box used to reject history.
void ComputeNeighborhoodBoundingbox(
in FTAAInputParameters InputParams,
in FTAAIntermediaryResult IntermediaryResult,
out float4 OutNeighborMin,
out float4 OutNeighborMax)
{
// TODO: clean this up.
float4 Neighbors[kNeighborsCount];
UNROLL
for (uint i = 0; i < kNeighborsCount; i++)
{
Neighbors[i] = SampleCachedSceneColorTexture(InputParams, kOffsets3x3[i]).Color;
}
float4 NeighborMin;
float4 NeighborMax;
#if AA_HISTORY_CLAMPING_BOX == HISTORY_CLAMPING_BOX_VARIANCE
{
#if AA_SAMPLES == 9
const uint SampleIndexes[9] = kSquareIndexes3x3;
#elif AA_SAMPLES == 5
const uint SampleIndexes[5] = kPlusIndexes3x3;
#else
#error Unknown number of samples.
#endif
float4 m1 = 0;
float4 m2 = 0;
for( uint i = 0; i < AA_SAMPLES; i++ )
{
float4 SampleColor = Neighbors[ SampleIndexes[i] ];
m1 += SampleColor;
m2 += Pow2( SampleColor );
}
m1 *= (1.0 / AA_SAMPLES);
m2 *= (1.0 / AA_SAMPLES);
float4 StdDev = sqrt( abs(m2 - m1 * m1) );
NeighborMin = m1 - 1.25 * StdDev;
NeighborMax = m1 + 1.25 * StdDev;
NeighborMin = min( NeighborMin, IntermediaryResult.FilteredColor );
NeighborMax = max( NeighborMax, IntermediaryResult.FilteredColor );
}
#elif AA_HISTORY_CLAMPING_BOX == HISTORY_CLAMPING_BOX_MIN_MAX
{
NeighborMin = min3( Neighbors[1], Neighbors[3], Neighbors[4] );
NeighborMin = min3( NeighborMin, Neighbors[5], Neighbors[7] );
NeighborMax = max3( Neighbors[1], Neighbors[3], Neighbors[4] );
NeighborMax = max3( NeighborMax, Neighbors[5], Neighbors[7] );
}
#else
#error Unknown history clamping box.
#endif
OutNeighborMin = NeighborMin;
OutNeighborMax = NeighborMax;
}
下图展示了上述四种方法的对比。
可以看出,几种方法都对包围盒外的历史数据进行了修正,但也只能是“修正”,也不可能做到对鬼影的完全消除,特别是在摄像机及场景剧烈运动时,不得不为抵消鬼影,使抗锯齿效果大打折扣。
在合成这个阶段还要考虑以下问题:
对于问题1:
TAA应该在LDR空间中进行!
因为在高动态线性HDR下进行混合会产生大量的高频抖动。在HDR高亮度的边缘下会被拉得很长很长,在这个情况下进行混合得到的情况肯定不平滑。
对于问题2:
UE4建议放在后处理之前!
因为,后处理的Bloom、Lens Flare等算法,可能会放大场景中能量较高的噪点,表现上就是频繁地闪烁。一个常见的例子,就是Bloom的闪烁问题,Bloom算法会放大能量高的噪点导致的。
一个常用操作是:先进行色调映射,进行 TAA 后,在逆向映射回去进行后处理。
如下图所示。
而在色调映射时,常用的操作是进行 Reinhard 操作 x 1 + x \frac{x}{1+x} 1+xx,但这有可能会让颜色不够饱和,UE4加入了颜色的亮度(Luma)因素:
T ( c o l o r ) = c o l o r 1 + l u m a T − 1 ( c o l o r ) = c o l o r 1 − l u m a \begin{aligned} T(color) & = \frac{color}{1+luma} \\ T^{-1}(color) & = \frac{color}{1-luma} \end{aligned} T(color)T−1(color)=1+lumacolor=1−lumacolor
同时,UE4中,混合历史帧像素和当前帧像素,引入了Luma的影响系数。公式如下:
B l e n d = h i s t o r y ⋅ ( 1 − w 1 w 0 + w 1 ) + c u r r e n t ⋅ ( w 1 w 0 + w 1 ) w 0 = ( 1 − α ) ⋅ l u m a ( h i s t o r y ) w 1 = ( α ) ⋅ l u m a ( c u r r e n t ) \begin{aligned} Blend & = history \cdot (1 - \frac{w_1}{w_0+w_1}) + current \cdot (\frac{w_1}{w_0+w_1}) \\ w_0 & = (1-\alpha) \cdot luma(history) \\ w_1 & = (\alpha) \cdot luma(current) \\ \end{aligned} Blendw0w1=history⋅(1−w0+w1w1)+current⋅(w0+w1w1)=(1−α)⋅luma(history)=(α)⋅luma(current)
其中, α \alpha α为前面提到的指数平滑系数。
UE4默认情况下,指数平滑系数的默认值是0.04。该系数受到场景运动矢量的影响,如果运动速度越大,那么当前帧的权重需要增大。
代码如下:
BlendFinal = lerp(BlendFinal, 0.2, saturate(Velocity / 40));
抗锯齿Anti-Aliasing技术综述
深入剖析MSAA
延迟渲染与MSAA的那些事
图形学基础 - 着色 - 空间抗锯齿技术
图形学基础 - 着色 - TAA抗锯齿
反走样技术(一):几何反走样
TAA-Links
Temporal Anti-Aliasing
深入浅出Temporal Antialising
在 Unity SRP 实现 Temporal Anti-aliasing
DX12渲染管线(2) - 时间性抗锯齿(TAA)
SakuraRender(一)—PBR & TAA
High Quality Temporal Supersampling