塞下曲
[唐朝][许浑]
许浑
夜战桑乾北,秦兵半不归。
朝来有乡信,犹自寄寒衣。
今天要给各位介绍的是UE4在Siggraph 2014上陈述的Temporal Anti-Aliasing抗锯齿算法,原文在这里可以找到。
首先给出效果对比:
FXAA是MLAA的一种,都是在后处理阶段通过对输出中的边缘进行检测来实现AA的一种技术。相对于MSAA,这种技术时间消耗低,显存占用低,且不会导致镜面模糊与subpixel模糊,不过AA质量相对而言比较低。
在这个对比中,可以看到TAA的效果要更为优秀,而同时其消耗也十分的低廉。
常见的锯齿问题如上图所示,通常可以分成模型锯齿与渲染着色锯齿两种。下面先一起来回顾一下TAA之前常用的AA方法并简要概括其弊端。
FSAA是指用4倍分辨率进行渲染,之后通过下采样的双线性滤波实现AA的一种算法,这种算法以质量优秀著称,不过其消耗通常过于高昂从而让人望而却步,MSAA是使用最为广泛的一种AA技术,通过一遍渲染,将结果根据coverage写入到对应的subpixel中,从而以远低于FSAA的消耗得到接近于FSAA的效果。
这种技术的弊端在于,对于延迟渲染管线而言,其实现方式会撑大GBuffer的尺寸,使得渲染的成本以及从GBuffer中解析出AA结果的成本大大增高,此外,MSAA只能应付几何模型的锯齿,对于shading着色锯齿则无能为力。
这里列举了几种空间滤波方法,从前面的对比可以看出,以FXAA为代表的空间滤波方法在UE给出的场景中的表现令人非常失望,为什么会有这种结果呢,因为空间滤波类方法比较适用于阶梯状的线条锯齿,也就是说,比较适用于pixel级别的锯齿,但是这里给出的实例的锯齿却是subpixel级别的,因此空间类滤波方法表现不佳也就可以理解了,另外,需要注意的是,对于pixel层面的锯齿,空间类滤波技术的表现从时间上来看也是不稳定的。
specular lobe filtering算法通过将一个较窄的specular lobe预处理成一个较宽的specular lobe来平滑表面上过于明显的凹凸感,上图给出的Toksvig等算法都是这类算法的代表。这种算法的特点在于可以通过预处理的方式以较小的成本获得较好的模糊效果,其限制在于,很多时候没有办法进行预处理,比如如果没有一张全局统一的法线贴图跟粗糙度贴图的话,就很难对几何表面上的相关细节进行滤波处理。
UE这边尝试使用一种此前曾在多种方法中验证过的时间域滤波算法来处理AA问题。下面先看下静态场景(相机位置不变)下的算法实现。
时间域滤波的第一步就是选取采样点,以一个像素中的数据为例,这里需要为每一帧指定某个subpixel位置(对于同一帧中的其他像素而言,此相对位置都是相同的)的采样点作为渲染时的实际采样点,之后通过对前后多帧数据进行混合得到滤波结果。在实现上来说,最简单的方式就是对Projection矩阵进行修改,具体一点来说,就是对X与Y轴按照深度值指定一个对应的偏移量(投影矩阵输出的范围为[-1,1],因此此处需要相应做一个[0,1]到[-1,1]的变换),这种方式对于其他渲染过程的影响比较小,大部分情况下都可以视为无感知修正。
采样点分布通常会选择一些随机分布的点(采样点分布规律比较规整的话,得到的结果会产生各种条纹),且这些点在时间域与空间域上都应该比较分散,而不要聚集成簇,以尽可能的覆盖整个像素的各个方位。在这里,虽然Halton的采样点分布算法算不上很完美,但是其效果已经基本够用了。
这里的采样点,给出的是连续多帧中不同偏移的采样点安放在同一个像素格子中的示意图。
有了采样pattern之后,下一步做的就是将多帧的采样结果进行混合。混合有两种方式,一种是simple average,即同时保留多帧数据,每次输出的时候,都将前面多帧的数据叠加起来输出,这种做啊的消耗高,因此通常没法做到较多的采样点数目。另一种方式是exponential average,其基本思想是将当前帧未融合前的结果与上一帧融合后的结果进行混合,作为当前帧融合后的结果输出,通过这种方式可以将多帧的数据按照一种特定的规律保存在上一帧融合后的数据结果中。
实际上,当采样权重较小的时候,exponential average与simple average是等价的,上图给出了这种近似的推导过程,当采样点数目为n时,按照循环方式就会有,此时就有,从而推导成立。
混合时机这个地方也有讲究,正确的方式应该是要在tone mapping之前进行,不过由于此时color buffer中由于存在比较高数值的亮度数据,从而导致混合之后的抗锯齿效果并不是特别令人满意,而如果将混合放在tone mapping之后,则在tone mapping(基本上所有的后处理都是在tone mapping之前)之前的后处理效果就会存在锯齿效果。
为了解决两种混合方案的缺点,UE这边给了两种解决方案,第一种比较直接,在所有后处理开始之前,先进行一遍tone mapping,在此基础上进行TAA混合,之后进行一遍逆向tone mapping,后面按照原流程进行其他各项后处理,之后在输出之前,按照原流程进行一遍tone mapping即可(相当于为TAA增加了一遍tone mapping跟reverse tone mapping操作)。
由于tone mapping会导致高亮度的像素的饱和度受损,因此这里提供的另一种解决方案(这种方案其实也是一种tone mapping,只是其使用的tone mapping算法不一样)相对会更优一点,这种方案是在亮度数据上做文章,根据亮度对color进行tone mapping(亮度越高,权重越小),从而可以保留较好的chroma(色度)数据(这个公式只会影响亮度数据,不会影响到色度数据),这种方式得到的结果非常接近ground truth。不过很好奇的是,为什么这里说不需要存储luma值,的时候的luma值要怎么拿到呢?此外,这里的tone mapping跟inverse tone mapping貌似并不能完全对应上,这是什么原因呢?其实这两个是一个问题,tone mapping用的luma跟reverse tone mapping用的luma其实不是一个数值,luma_reverse = luma/(1+luma),从而reverse tone mapping中的1-luma实际上等于1-luma/(1+luma) = 1/(1+luma),因此reverse tone mapping相当于(1+luma)*color,就能跟tone mapping完全对上了。
这里给出了两种tone mapping的效果对比,可以看到,luma weight方法能够很好的保留原图的对比度,而普通tone mapping相对而言就稍微糊一点了。
得到多帧采样color之后,下一步就是对这些color进行混合操作,最简单混合方法是采用均权混合的box filter,硬件MSAA用的就是这种方法,不过这种方法存在一些弊端:首先,边缘处的采样点实际上对于当前像素的贡献相对会小一些,采用均权混合的话,得到的效果会有比较大的偏差;此外,在相机或者物体移动的时候,由于边缘颜色变化较快,使得混合后的结果会不稳定,从而导致闪烁。
为了解决box filter的问题,就需要一套随着距离平滑降低的权重计算方法,关于这个问题有很多解决方案,PRMan文档给出了最优的filter kernel以及对应的参数,不过由于此处采样点数目过少,且PRMan方法中的权重会有负数,会使得混合结果不太稳定,因此UE最终使用的是Blackman-Harris filter方法。另外,由于各个像素的抖动模式都是完全一样的,因此,这些权重系数可以通过CPU计算之后直接传入GPU。
前面给出的是静态场景的TAA实现,下面看下动态场景(物件变化或者相机变化等)的TAA实现。
动态场景与静态场景的区别在于,相同像素位置对应的可能不是同一个世界坐标位置的数据了,因此直接按照静态场景的TAA来制作的话,会出现移动的痕迹(拖尾,ghost image)。
直观的解决方案是从之前帧的buffer中找到当前像素所对应的像素,这个过程称之为reprojection,即将当前像素重新转换回世界坐标,之后按照上一帧的投影方式转换到屏幕坐标(感觉只能解决相机变化带来的数据变化,如果是物体自身位置发生了变化,那么依然无法追踪吧?),不过需要注意的是,这种方式反投影到history buffer时,可能会超出buffer range,从而得不到对应的上一帧像素结果。
reprojection的计算过程通常是使用存储uv offset的velocity buffer来完成,速度贴图通常还会用在motion blur上面,对于这种多种技术都会用到的资源,其消耗就相当于被均分了,非常有用。
这里需要注意的是,进行reprojection,也就是计算velocity buffer的时候,注意将像素的jitter移除(因为不同帧的jitter offset是不一样的,为了避免reprojection到不同的像素上,最好使用相同的subpixel位置来进行计算),velocity buffer存储实际上是相机变化导致的uv offset,并以及geometry transform导致的uv offset(因此在渲染的时候需要同时传入物体上一帧跟当前帧的MVP(Model-View-Projection)矩阵,蒙皮模型也需要传入上一帧的骨骼矩阵)。
这里给出了速度贴图相关的一些实现要点:所有发生过位置变动的像素都应该记录在速度贴图中,速度贴图数据缺失会导致效果出现瑕疵;速度贴图的精度非常重要,过低会导致条纹效果。
算法的基本框架已经给出了,但是在实施的时候还有一些瑕疵需要进行特别关注。
面片内部的像素通过速度贴图能够较好的匹配到原像素,但是处于边缘的像素(这里侧重外边缘,深究的话,其实并不能算当前面片的像素)通过速度贴图可能匹配不到上一帧对应的边缘的像素,从而导致上一帧边缘的AA效果就没有被当前帧所继承,为了解决这个问题,UE这边给出的实现算法就是从周边的像素进行采集,取速度最大的作为当前像素的速度,周边像素呈“X”状排布,且由于外边缘覆盖范围大概为两个像素,因此周边的范围为5x5的。
另一个问题则是,在进行reprojection的时候会出现无法找到正确的历史像素的问题,比如物体移动的时候,导致上一帧被遮挡的内容露出以后会匹配到上一帧的遮挡物上面,而遮挡物则匹配到上一帧的其他内容上,使得渲染结果呈现如图所示的“鬼影”效果。
为了解决这个问题,就需要准确的检测出哪些像素的历史像素是无效的。
无效像素检测,这里介绍了两种方法,第一种是进行depth比对,对当前帧像素depth与上一帧历史像素位置的depth进行比对,当差距过大时,就认为此历史像素匹配关系是无效的,不过由于不是所有的有效像素都满足depth变化缓慢这一条件,因此也有可能导致正确的匹配被deny的风险。
另一种方法则是之前crytek提出的velocity weighting方法(具体方法实现过程可能还需要进一步查阅资料),这种方法只能处理不透明物件的移动导致的鬼影,而无法处理着色产生的鬼影(比如灯的打开与关闭所导致)以及半透物件(比如粒子)的移动导致的鬼影。
UE这边给出了另一种解决方法,这种方法的基本思想是根据当前帧当前像素周边像素的颜色数据来剔除无效的历史像素。这种方案是基于一个假设做出的,即AA的结果实际上是由周边像素数据混合而成,这种假设对于像素中任意subpixel位置颜色都相同的时候(所谓no subpixel features)是成立的,其他情况则基本上也是合理的(后面会给出假设不满足的情况的处理方式)。
之后在获取历史数据的时候,就会根据当前像素周边像素(UE选取的是3x3范围)的颜色范围进行裁剪(clamp),将之收缩至周边像素颜色范围中,如上图所示的历史像素就按照RGB的范围进行逐分量的clamp后的结果。
按照周边像素的颜色范围进行clamp就会导致一个问题,那就是处于颜色范围某条边外侧的所有像素都将被clamp至此边上,即颜色集聚,比如上图中,使用这种方法处理后,在几何体边缘处出现了红色的鬼影效果,且效果上看起来像是使用了低分辨率的近邻采样算法一样,呈现色块。
这里先来处理色块的问题,之所以会出现色块,是因为局部的min/max clamp输出的效果类似于3x3颜色块(相邻像素都被clamp到差不多的颜色上),看起来非常像是低分辨率图像按照point sampler采样的结果,实际上我们需要的是bilinear sampler的效果,这二者的区别在于采样像素时候是否采到了多个像素数据的混合,因此UE这边给出的解决方案是将周边像素的min/max数据考虑进来,组成一个由多个min/max color数据组成的RGB空间形状,之后应用前面所说的clamp算法。
只考虑当前像素min/max数据的时候,得到的是RGB空间中的一个AABB,而如果将周边像素的min/max数据也纳入考虑的话,得到的应该是RGB空间中的一个凸多边形(相当于将多个min/max组成的AABB取并集?),如果用这样的凸多边形来实现之前的clamp算法的话,代价会比较高,这里给出的是另一种计算方法。
在一个较小的空间范围内,各个像素的亮度对比度走向基本上是稳定的,而颜色即色度数据对比度则可以多种多样,利用这个特性,可以解决上面的问题,即将多个min/max color得到的AABB转换到YCoCg空间中,并将之按照亮度对比度方向进行旋转,这样就可以很好的将多个min/max color的AABB相融合了(因为方向一样,那么并集求取就非常容易了)。
另外,前面提到,clamp容易导致颜色聚集,因此这里使用clip来代替clamp,将结果从点分散到线上。这里有个疑问的是,clip的时候投影的规则是如何制定的,难道用的是uv velocity?
这里给出两种方法实现效果的对比,看得出来新的方法效果相对之前的方法质量上有极大的提升。
对于半透物件而言,某一点的像素颜色可能取决于多个物件,这就给reprojection增加了很大的困难,因此使得TAA在半透像素上的作用效果非常堪忧。
理想状态下,可以先绘制不透明物件,之后对其应用TAA之后,再绘制半透物件,不过问题在于,半透物件绘制的时候需要进行深度测试,而当前帧当前像素可用的深度值是经过抖动处理后的位置的,与半透绘制时所需要的中心位置的深度值很难对上,一个可能的解决方案是绘制的时候对深度贴图的写入采用4xMSAA算法,这样不透明物件抖动后的depth数值与未抖动前的中心位置的depth值就都能取到了。
虚幻这边则是借助Stencil来解决这个问题,在编辑的时候,为半透物件材质设置“Responsive AA”标签(因为这种方案只有细小粒子才合用,而非所有的半透物件都能 取得较好效果,因此需要手动设置),之后在渲染的时候,打开了这个标签的物体会修改stencil的数值(修改规则是?),在进行TAA后处理的时候,会(按照什么规则?)打开stencil test(哪些像素通过test,哪些没有?),下面给出实现效果(为什么只能用在细小粒子等半透物体上?)。(这里还有一些PPT内阐释不清楚的细节,等阅读过源码之后再回来解惑)
这里给出了TAA实现的流程图,需要注意的是,TAA对输出的color与depth数据做了抖动,因此在进行其他计算处理的时候要特别小心,任何在TAA环节之后使用非颜色数据(比如深度)的,其效果都可能会导致抖动以及锯齿;任何在TAA环节之前使用了大于3x3的空间滤波算子的,都会加大锯齿程度,从而导致闪烁。
这里举了DOF作为示例,DOF是一个需要用到depth buffer数据的后处理过程,如果在TAA之后进行DOF的所有处理的话,就会导致使用到了抖动过的depth数据,造成结果的抖动与锯齿,为了解决这个问题,就应该将流程改成在TAA之前,将DOF所需要的深度数据处理过程完成,之后将输出结果经过一次TAA之后,作为最终的DOF结果,修正流程如下图所示。
在TAA实现过程中,有两个问题非常难处理,其中一个就是闪烁问题。
这里的闪烁指的是相机处于静止状态时,部分像素的颜色会在明暗之间来回切换,导致的闪烁。
这个问题出现的原因,就是我们前面在neighbourhood clamping中说到的,场景中存在subpixel feature时假设(假设任意像素上一帧的历史color会落在当前像素周边像素color范围之中)被打破导致的。因为当场景中存在subpixel feature时,其颜色可能并不在周边九个像素(某个位置)的color 范围之中,根据clamping的算法,就会经历裁剪过程,用图形方式表示,就是如上图下方的锯齿状曲线所示(被裁剪的那一帧,是肯定落在clamping range之中的,后面的其他帧就不一定了,因此出现时而被裁剪,时而保持原样,出现闪烁)。
关于这个问题,UE给出了相关的解决思路:将clamping结果朝着clamping之前结果做一个偏移,通过这种方式降低脉冲的幅度;降低TAA中的指数系数,从而使得每一帧恢复的幅度相应减小(减少了历史数据的比重)。因为这种方法会导致输出结果变糊,因此需要慎用。
UE首次尝试解决闪烁问题时,使用的方法是在alpha通道中存储历史color的方差数据,通过这种方式来判定是否发生了clamping操作,同时据此来缩小数值,并逐帧恢复。
这种方法还存在一些问题(无法朝着clamping前结果进行偏移,且输出结果偏模糊),因此UE使用了其他的解决方法。
在clamping之后没多久的时候,缩小混合系数(怎么从GPU通知到CPU?),这种方式不需要额外的存储空间。
这种方法也没有完全解决闪烁问题,且对于同时存在多个闪烁点,且闪烁规律正好相反的情况无能为力。
TAA实现中的另一个问题就是容易导致输出结果显得模糊,这里给出了三种不同的缓解方案:对物件贴图都采用mipmap实现;对于低对比度的像素,缩小滤波尺寸;在结果中增加额外的锐化处理。
模糊的一个主要原因在于reprojection获取到的历史结果不准,这里给出了一些尝试与相应的结果,可以看到,并没有得到较好的结果。
TAA除了用作抗锯齿之外,还被用作噪声滤波器,虽然不是其原本设计的目的,但是在SSR以及SSAO实现中取得了非常不错的效果。
虽然neighbourhood clamping算法只能处理一定程度的噪音数据,但是其在众多其他效果上的优化作用还是让人感叹。
后续进一步的AA效果提升,可以考虑从如下方面着手:
- 将空间AA方法与TAA结合起来
- 半透物件绘制流程与不透物件绘制流程分开进行处理
- 对每个像素应用不同的抖动模式
- 优化速度贴图(增加半透物件的相关数据等)
TAA的效果跟性能都非常令人满意,不过在实现的时候可能需要耗费较多的时间进行参数调制。