[引擎搭建记录] 时间性抗锯齿(TAA)

知乎地址:https://zhuanlan.zhihu.com/p/64993622
我的知乎专栏:https://zhuanlan.zhihu.com/c_1099268510815010816

最近我做好了简单的场景编辑和序列化,打算回来继续折腾渲染部分了,首先想要实现的就是TAA(temporal anti-aliasing),为什么阴影和AO之类的啥都没有却要先写TAA呢,因为TAA对整个管线的结构影响非常大,而且后面会有许多地方用得着temporal的方法来增加采样率,比如AO、阴影、SSR等等,所以就把抗锯齿先写了。git地址:

MrySwk/GravityEngine​

这一套做法一开始基本上是从虚幻里抄出来的,写的期间我受到了许多大佬的教育,所以最后还是决定(跟风)换掉虚幻里的许多做法,最后出来的效果还不完美但是已经勉强能看了,,,然后这将是一篇比较萌新向的文章(因为我就是萌新),会尽量写清楚点,要是有什么错误的话请评论区啪啪啪打我脸_(:з」∠)_

抗锯齿前后对比图如下:

截图的时候没注意把第二张图分辨率截大了点,懒得再回去截了(╯ ̄Д ̄)╯╘═╛

可以看到上面那张图中主要有两种锯齿,一种是几何的锯齿,比如右边那俩默认材质的柱子和方块的边上就有这种几何锯齿,还有一种是着色的锯齿,球上面的细细的闪光点就是这类。

之前的很多抗锯齿算法是把边缘找出来处理,这种做法只能解决geometry锯齿,对shading aliasing无能为力,要解决shading的aliasing,可以通过增加采样次数的方法,然而直接做多次采样的开销是非常大的,而TAA的做法是,把多次采样的过程分布到每一帧中去,也就是每一帧都利用前面几帧保存下来的数据,也就是所谓的“temporal”所指的意思了,如图。

[引擎搭建记录] 时间性抗锯齿(TAA)_第1张图片

当然了,这么做的前提是时间里的每一帧在不同的局部位置采样,也就是说每一帧我们采样的位置就不能像以前一样都在正中心采样了,而是要加一个小的偏移量,一般来讲,这个步骤在写GBuffer的时候完成,而且是通过改投影矩阵的方法:

[引擎搭建记录] 时间性抗锯齿(TAA)_第2张图片

要验证为什么要这么改就用xyz1去乘投影矩阵然后做一下齐次除法看下结果就知道了,然后这里修改的偏移量一般选的是低差异序列,具体用什么可以尝试,只要效果好就行,这里我抄的虚幻的,虚幻用的是Halton(2,3),如下

[引擎搭建记录] 时间性抗锯齿(TAA)_第3张图片

现在我们知道采样位置,比较trivial的想法就是存前7帧的结果和当前第八帧的渲染结果相加,然后除以8,问题是这样就要很大的空间来存(小几百兆),开销挺大的,一般的做法是存之前历史帧的累加结果,即 P n = α ⋅ c n + ( 1 − α ) ⋅ P n − 1 P_n=\alpha\cdot c_n+(1-\alpha)\cdot P_{n-1} Pn=αcn+(1α)Pn1,也就是用前n-1帧的采样结果加上这一帧的结果混合起来保存为新的历史,可以先认为这里的 α \alpha α 是取一个比较小的值比如0.05,后面会 提到这个值是根据画面动静变化的。

现在我们知道了一个静态的场景怎么去利用过去帧的数据做supersampling,然而,这只是针对静态情形来说的,实际的情形总是是动态的,我们的镜头可能移动,场景内的物体也可能移动,那我们要怎么样利用历史的帧数据来帮助当前这一帧抗锯齿呢?

首先考虑镜头的移动,下面这幅图可以说明这种情况

[引擎搭建记录] 时间性抗锯齿(TAA)_第4张图片

要知道这个点上一帧在什么位置,我们可以用这一帧的坐标,乘ViewProj的逆,再乘上一帧的ViewProj矩阵,变换到上一帧的裁剪空间里,就知道该采样哪个点了。也就是说,我们要保存摄像机上一帧的VP矩阵。

接下来看物体的移动,我们可以保存物体上一帧的位置(world矩阵),然后同一个局部坐标的点,分别乘这一帧和上一帧的world矩阵,再分别乘这一帧和上一帧的VP,就得到裁剪空间里的位置,我们保存这一帧减上一帧作为这一帧的速度,如果有骨骼,还要考虑骨骼的变换矩阵(不过我这里实现的暂时还没有考虑骨骼动画)。

注意我们只要x和y两个维度,也就是说是在屏幕上的速度,我们要采样历史,就用当前uv减去这一帧的uv速度去采样就行了,这样,我们就能够在动态的场景下利用过去帧的数据了。

结合上面的信息我们可以把整个场景的速度写到一个速度buffer里,作为TAA pass的输入,算出来的速度buffer大概是长这样(r和g分别对应x和y):

[引擎搭建记录] 时间性抗锯齿(TAA)_第5张图片

这里用的是R16G16的格式,精度过低的话会影响效果。

然后根据inside的制作组(就是那个ign10分的游戏,同地狱边境制作组)提到的,我们采样速度的时候,可以深度图上做3x3采样,找出一个范围内深度最小的位置的速度作为速度矢量,来保证运动的边缘有更好的抗锯齿效果,如图(注意电线杆的边缘):

[引擎搭建记录] 时间性抗锯齿(TAA)_第6张图片

然后值得一提的是TAA在管线中的位置,虚幻的TAA是放在其它的后处理之前的,这么可以防止其它后处理出现的闪烁,但是因为高光很容易闪,我们又希望能在低动态范围处理,所以这里虚幻选择的是先tonemap,再算超采样,最后逆tonemap输出,去做其他的后处理。

现在假如我们有了速度buffer、深度buffer和历史、当前帧渲染结果,就可以进入TAA pass了,taa pass的第一步,把uv坐标unjitter,因为之前我们算过了jitter,这里要去掉,在原位置输出结果。我们可以尝试把历史和当前帧直接混合,看一下结果:

这就是ghosting现象,因为我们不加任何判断直接混合历史和当前帧,但是之前存在的位置在这一帧中可能就被挡住了,或者上一帧中被遮挡的东西这一帧突然出现了,就像下面这样:

[引擎搭建记录] 时间性抗锯齿(TAA)_第7张图片

这时候还直接用历史采样来混合就会导致错误的结果,这个问题也就是temporal超采样的弊端,我们没办法保证我们需要的信息都能从历史中取到,总会有新的点在画面中出现,所以这里我们需要采取一些措施来防止这种错误,也就是说要限制历史采样的颜色范围,不能和当前的颜色差异太大,差异太大的话很可能就是出现了错误的结果。

一般来讲,避免这个问题的方法就是把历史帧采样的结果给clamp到一个范围,以虚幻为例,是在当前像素周围3x3范围(或者上下左右中五个像素)内统计颜色的最大最小值,然后把历史颜色约束在这个范围内,理想的情况下,最好的做法是构造一个凸包(convex hull),但是这种方法代价太大了,比较快的方法就是统计颜色的最大值和最小值,这样的话得到的就是一个AABB。

这里虚幻4建议是在YCoCg范围内做clamp/clip,也就是相当于让AABB朝向亮度方向,这么做的原因是相比起色度,亮度的局部变化要高很多,所以相当于做了下图中的操作:

[引擎搭建记录] 时间性抗锯齿(TAA)_第8张图片

上图用的方法是clamp,找到最近的点,然后实际上用clip代替clamp效果更好,这样的话色彩不会在盒子的角落聚集:

[引擎搭建记录] 时间性抗锯齿(TAA)_第9张图片
其中clip的朝向是统计出来颜色的均值,实际上inside的制作组提到过clip到box中心的效率更高,因此我用的是clip到box中心。

float3 ClipAABB(float3 aabbMin, float3 aabbMax, float3 prevSample, float3 avg)
{
#ifdef CLIP_TO_CENTER
	// note: only clips towards aabb center (but fast!)
	float3 p_clip = 0.5 * (aabbMax + aabbMin);
	float3 e_clip = 0.5 * (aabbMax - aabbMin);

	float3 v_clip = prevSample - p_clip;
	float3 v_unit = v_clip.xyz / e_clip;
	float3 a_unit = abs(v_unit);
	float ma_unit = max(a_unit.x, max(a_unit.y, a_unit.z));

	if (ma_unit > 1.0)
		return p_clip + v_clip / ma_unit;
	else
		return prevSample;// point inside aabb
#else
	float3 r = prevSample - avg;
	float3 rmax = aabbMax - avg.xyz;
	float3 rmin = aabbMin - avg.xyz;

	const float eps = 0.000001f;

	if (r.x > rmax.x + eps)
		r *= (rmax.x / r.x);
	if (r.y > rmax.y + eps)
		r *= (rmax.y / r.y);
	if (r.z > rmax.z + eps)
		r *= (rmax.z / r.z);

	if (r.x < rmin.x - eps)
		r *= (rmin.x / r.x);
	if (r.y < rmin.y - eps)
		r *= (rmin.y / r.y);
	if (r.z < rmin.z - eps)
		r *= (rmin.z / r.z);

	return avg + r;
#endif
}

然后统计box边界的时候,我用的是Variance Clip,这一点英伟达在gdc上面提过,因为在用AABB统计的时候,约束后的结果可能依然离当前结果非常远,如下图左边

[引擎搭建记录] 时间性抗锯齿(TAA)_第10张图片

可以看到左图中点被约束到了AABB内,但是依然离我们想要的结果非常远,而右图就是variance clip的结果,做法如下:

[引擎搭建记录] 时间性抗锯齿(TAA)_第11张图片

	// Variance clip.
	float3 mu = m1 / N;
	float3 sigma = sqrt(abs(m2 / N - mu * mu));
	float3 minc = mu - VarianceClipGamma * sigma;
	float3 maxc = mu + VarianceClipGamma * sigma;

	prevColor = ClipAABB(minc, maxc, prevColor, mu);

这里的gamma我按原文取的是1.0。

Clip过之后,就应该可以看到残影消失了,但是,新的问题会出现,也就是TAA最蛋疼的一个问题,镜头不动的时候,场景里会有高光的flickering。

截gif图质量不高可能看不太清楚,放大仔细看的话好几个球上面都有小的闪烁,如果这一步出现了闪烁得非常厉害,很可能是unjitter那一步做错了(我之前就是),虽然后面加很多处理也能压住,但是很影响效果,如果出现了这种细细的小闪烁的话,是正常的,接下来我们可能需要花很大功夫来处理这个问题。

首先值得一提的是在采样当前帧结果的时候,做一次滤波可以很大程度上解决闪烁的问题,但是带来的坏处也是非常明显的,整个画面会变得比较糊,虚幻这里做了3x3或者五个像素的filter,因此我在一开始也试了一下做filter,但是出来的结果不是太理想,如果不滤波的话,画面会清晰很多,下面我把我最终得到的结果和虚幻的对比一下:


[引擎搭建记录] 时间性抗锯齿(TAA)_第12张图片

上面是这篇文章的最终结果,下面是虚幻,可以看到即使是虚幻也还是有一点闪烁,而且这里需要强调的是,滤波虽然可以把高频部分抹掉很多,但是导致的结果就是高光的细节也被抹掉了,仔细看的话可以发现虚幻的高光部分会很糊,虽然上下对比的时候光照环境相差比较大,但是高光部分的细节应该还是可以看清楚的,虚幻的高光看起来柔和很多,但是某种程度上来说也会比较糊,不是那么锐利,虽然很可能不仅仅是这一步滤波导致的结果,但是这一步造成了比较大的影响是肯定的。

所以这里如果不使用滤波,我们要尽可能多地想办法解决flickering问题,出现闪烁的原因,主要就是频率太高,而且主要是亮度的频率,在这个场景里我贴的全是2k和4k贴图,可以想象在屏幕上有限的像素点内做到把画面亮度跳动都表现出来是很难的,也就是说我们还是不得不把当中的细节给抹平一些,但是又不能抹得太平导致画面糊掉。

不得不说要把TAA效果做好是一件挺不容易的事,连虚幻在siggraph上的报告也提到了anti-flickering是extremely difficult,要得出一个让人能接受的结果大概需要反复的调整,这里我用了各种各样的办法,最后算是把flickering基本上压住了,但是还是会稍微有一点,恐怕是很难完全避免了,下面会列出我在调的过程中感觉比较重要的一些点(至少在我的环境下是这样的):

1.最好在YCoCg空间里做clip,但是感觉影响不是特别大。

2.一定要做ToneMap,这个影响非常非常非常大,我们是想要抹掉频谱上的尖刺,因此在低动态范围来做混合是非常有效的,最好选一个好的tonemap曲线,我目前用的是Karis在siggraph的演讲里提到的tonemap,这个链接里的下面那个
tone-mapping
[引擎搭建记录] 时间性抗锯齿(TAA)_第13张图片

此外,还可以考虑filmic tonemapping,这个是麦老师推荐的,是神海2利用的tonemap方法。
Filmic Tonemapping

float A = 0.15;
float B = 0.50;
float C = 0.10;
float D = 0.20;
float E = 0.02;
float F = 0.30;
float W = 11.2;

float3 Uncharted2Tonemap(float3 x)
{
   return ((x*(A*x+C*B)+D*E)/(x*(A*x+B)+D*F))-E/F;
}

float4 ps_main( float2 texCoord  : TEXCOORD0 ) : COLOR
{
   float3 texColor = tex2D(Texture0, texCoord );
   texColor *= 16;  // Hardcoded Exposure Adjustment

   float ExposureBias = 2.0f;
   float3 curr = Uncharted2Tonemap(ExposureBias*texColor);

   float3 whiteScale = 1.0f/Uncharted2Tonemap(W);
   float3 color = curr*whiteScale;
      
   float3 retColor = pow(color,1/2.2);
   return float4(retColor,1);
}

3.虚幻的3x3采样是带了权的,分了spatial weight和hdr weight,可以发现spatial weight会偏向于原位置,离原位置较远的点权重会小很多,权重分布用的是CatmullRom或者sigma等于0.47的正态分布,具体可以去看ue的源代码。这告诉我们,采样的时候可以稍微往原位置偏一点,哪怕是单次的采样,也可以加个位置偏移量,亲测有奇效。

4.最后输出的时候混合参数(就是刚刚说alpha取0.05的那个)可以根据画面动静变化,也就是说根据速度矢量的大小进行插值,还可以考虑把历史和当前帧的差距考虑进去(虚幻有这一条),我目前设置的是静态0.03,动态0.12。

5.麦老师和cgbull大佬的实现里面,最后做了一次锐化,这个可以防止画面糊掉,不过目前我这个里面暂时没有很糊所以没有做这一步,如果在前面做了滤波的话倒是可以考虑锐化,做法是找个高频滤波的采样核算一次,然后乘一个锐化程度的系数加在原来的结果上,比如下面这几个就是常见的高频滤波:
[引擎搭建记录] 时间性抗锯齿(TAA)_第14张图片

因为TAA比较特殊,会受很多东西的影响,也会影响很多东西,所以在不同环境下调出来的结果、需要的参数肯定也是不完全一样的,所以我这里提到的不一定对所有情况有用,只能作个参考。具体的情况肯定要做具体的调整,而且调的过程是需要作出各种取舍的,比如给的限制比较严格,那可能就效果有限,如果限制过于宽松,可能就会ghost,要出好的效果其实挺不容易的。

下面是最终调出来的结果,闪烁基本上没有了,有的时候还会有一点点,不过还算能接受:

然后是100倍tile的测试,极限高频的情况下依然会有闪烁,但应该不算太瞎眼

[引擎搭建记录] 时间性抗锯齿(TAA)_第15张图片

具体的代码可以去看我的工程,有点多就不全贴出来了,现在管线里也基本上啥都没有,还在起步阶段,之后要争取慢慢完善(๑•̀ㅂ•́)و✧

Reference
[1] http://advances.realtimerendering.com/s2014/
[2] http://graphicrants.blogspot.com/2013/12/tone-mapping.html
[3] http://twvideo01.ubm-us.net/o1/vault/gdc2016/Presentations/Pedersen_LasseJonFuglsang_TemporalReprojectionAntiAliasing.pdf
[4] https://developer.download.nvidia.cn/gameworks/events/GDC2016/msalvi_temporal_supersampling.pdf
[5] http://filmicworlds.com/blog/filmic-tonemapping-operators/
[6] https://community.arm.com/developer/tools-software/graphics/b/blog/posts/temporal-anti-aliasing
[7] http://iryoku.com/aacourse/downloads/13-Anti-Aliasing-Methods-in-CryENGINE-3.pdf
[8] http://eeweb.poly.edu/~yao/EE3414/image_filtering.pdf

你可能感兴趣的:(自制引擎)