文/洛城@Life of a Pixel专栏
先前对反走样(anti-alias)的相关文章做了一些梳理,所以决定先完成这个比较容易说清楚的话题。当然,按惯例,这篇文章也还是只做一个概述,一些实现的细节可以参考我分享的引用列表。
走样的原因及其分类
说到走样,首先要说的就是采样。这也算是很多图形学专著中提到反走样相关技术时的一个惯例。许多专著中都会提到奈奎斯特采样定理[1]。作为一个数学渣我也不打算去严谨地证明,只说我对采样定理的一个直观理解:傅里叶变换[2]告诉我们,任何一个函数都可以通过不同周期的正余弦函数线性组合而成,一个函数经过傅里叶变换后的函数的定义域表示分解后的正余弦函数的频率范围,值则表示了这个线性组合的系数(当然对于连续的傅里叶变换而言线性组合系数这个解释似乎不准确)。总之,如果一个函数它的傅里叶变换函数只在一个有限的区间内值不为0,那就可以称之为有限带宽函数。假设它的最大频率不超过B,那么我们通过2B的采样频率,就可以无损地恢复这个原始函数。因为最大频率的波可以通过一个周期内的两个点唯一确定。
这个解释可能也不好,毕竟我的数学是在机械班学的。。。回到图形学的话题,所谓渲染,实际上就是对一个连续函数在空间内进行离散的采样(这个函数应该包含的场景的几何覆盖关系,着色参数和着色方程等)。而这个函数不是有限带宽函数,这意味着我们不论以多大的采样频率(反映在图形上,就是图像分辨率)去采样这个函数,都不可能完美地恢复原始信号,也就都会造成走样(aliasing),反应在图像上就是锯齿或者噪点。总结来说,就是在图形渲染中,走样是不可避免的,我们能够做的,仅仅是利用各种技术去减轻这种现象。
为了去减轻走样或者欠采样,我们首先需要搞清楚有哪些因素会导致走样,总体来说,造成走样有两个主要的原因[3]:
(1)对几何覆盖函数的采样不足,也就是我们最常看到的边缘锯齿或者更学术化地称之为几何走样(Geometry Aliasing),一般发生在光栅化阶段。
几何走样,注意立方体边缘
(2)对渲染方程的采样不足,因为渲染方程也是一个连续函数,对某些部分(比如法线,高光等)在空间变化较快(高频部分)采样不足也会造成走样,反映在视觉上一般是图像闪烁或者噪点,这类称之为着色走样(Shading Aliasing),一般发生在着色阶段。
着色走样,注意上图中的高光,给人的感觉非常噪
接下来的技术将会按其解决的走样问题类型进行一个简单分类。相对来说人眼对几何走样的敏感度更高,所以我们一般提到的反走样技术也大多数是几何反走样。但是随着近年游戏引擎对画质要求越来越高,也有非常多算法是用于解决着色走样。这两类算法解决问题的思路也往往差别较大。
几何反走样:基于超采样的方法
SSAA(Supersampling Anti-Aliasing)
SSAA可以说是图形学中最简单粗暴的反走样方法,但同时也最有效,它唯一也是致命的缺点是性能太差。开篇已经说过,任何类型的走样归根结底都是因为欠采样,那么我们只需要增加采样数,就可以减轻走样现象。这就是SSAA,所以SSAA简单的来说可以分三步:
(1)在一个像素内取若干个子采样点
(2)对子像素点进行颜色计算(采样)
(3)根据子像素的颜色和位置,利用一个称之为resolve的合成阶段,计算当前像素的最终颜色输出
基本的SSAA框架
不同SSAA方式在子采样位置的选取和最终resolve使用的滤波器上有所不同,可以使用不同的采样模板(规则采样,旋转采样,随机采样,抖动采样等)或者不同的滤波函数(方波滤波器或者高斯滤波器)。
SSAA同时是几何反走样和着色反走样方法,因为它不但增加了当前几何覆盖函数(Coverage)的采样率,也对渲染方程进行了更高频率的采样(单独计算每个子像素的颜色)。
MSAA(Multisample Anti-Aliasing)
SSAA中每个像素需要多次计算着色,这对实时渲染的开销是巨大的(想想4K和1080P的性能差异),我们开始也说过,实际上人眼对几何走样更敏感,能否解耦几何覆盖函数的采样率和着色方程的采样率呢?答案是肯定的。MSAA的原理很简单,它仍然把一个像素划分为若干个子采样点,但是相较于SSAA,每个子采样点的颜色值完全依赖于对应像素的颜色值进行简单的复制(该子采样点位于当前像素光栅化结果的覆盖范围内),不进行单独计算。此外它的做法和SSAA相同,每个子像素会在光栅化阶段分别计算自身的Z值和模板值,有完整的Z-Test和Stencil-Test并单独保存在Z-Buffer和Stencil-Buffer里,就是我们需要的几何覆盖信息。类似于SSAA,MSAA也需要一个resolve的过程,在早期(DX9/10?)这个过程是显卡的一个固有单元在执行,执行的方式一般也就是简单的Box Filter,随着可编程管线的功能逐渐强大,现在可以通过Pixel Shader来访问相应的MSAA Texture,并且定制resolve的算法[4]。由于MSAA拥有硬件支持,相对开销比较小,又能很好地解决几何走样问题,在游戏中应用非常广泛(我们在游戏画质选项中常看到的4x/8x/16x抗锯齿一般说的就是MSAA的子采样点数量分别为4/8/16个)。
MSAA在光栅化过程中的原理
CSAA(Coverage Sampling Anti-Aliasing)
MSAA相对于SSAA的好处是不用多次计算着色,但对每个子采样点仍然需要单独存储其Z值和stencil值,并且实际上每个子采样点还需要单独存储颜色(只是该颜色不是通过单独计算得来),这仍然会造成非常巨大的存储及读写开销。能否把子像素的Z/Stencil/Color值和Coverage的值进一步解耦开呢?这就是CSAA[5]的思路:在MSAA已有的子像素的存储结构基础上,给每个像素再增加一个Coverage Info,也就是在光栅化阶段进一步提高每个像素的几何覆盖函数的采样率,这个采样结果用一个N比特的数表示(每一个比特表示一个子采样点的覆盖信息),可以这么理解:MSAA是一个像素有M个子像素,每个子像素有一份Z/Color/Stencil,而CSAA则是说,每个像素有M个子像素(每个子像素有一份Z/Color/Stencil),还有一个额外的N Bit的Coverage信息。乍一看似乎CSAA的开销比MSAA更大,但实际上,CSAA可以使用少量的子像素加上更大的Coverage采样率,来实现MSAA需要更多子像素才能达到的同等效果。例如可以通过16x CSAA(4个子像素,每个像素16Bit的Coverage)达到8x或者16x的MSAA的效果。当然缺点是CSAA的resolve过程不可控。但是可以通过在Shader里输出一个Custom Coverage的结果,然后将光栅器算出的Geometry Coverage和Custom Coverage通过位与(AND)操作合成为最终用于resolve的Coverage。
CSAA子像素和覆盖信息解耦的存储结构
另:CSAA是我们厂(N)的叫法,友商(A)那边应该管这个技术叫EQAA(Enhanced Quality Anti-Aliasing)[6]。
几何反走样:基于形态学的方法
前面提到的若干种基于超采样的反走样方法有一个基本特性:需要特定的硬件支持(当然SSAA除外),同时它的存储和性能开销也相当大,尤其是对一些性能瓶颈是带宽的渲染架构(没错,说的就是延迟渲染)来说负担更明显甚至无法支持(当然MSAA可以应用于延迟渲染的架构,感兴趣可见这里[7][8][9],这里不再展开)。现如今不支持延迟渲染你都不好意思说自己自己的引擎是次时代的,自然反走样就需要更适合延迟渲染的方法。我们知道,延迟渲染框架带来的一大便利是丰富的全屏后处理效果。那么如果能在全屏后处理框架下完成反走样无疑是最快最合适的方案。
形态学反走样属于Screen Space AA的一类,它的基本思路是:假设同一物体在某些信息上存在连续性,那么可以通过检测像素在这些信息(颜色,深度,法线)上的不连续找出一些边缘,同时这些边缘根据局部形状不同会形成一些形态模式(pattern),我们通过总结出一些固有模式,然后通过这些模式反推(拟合)出采样前几何边界的解析形式(直线方程),最后通过这些方程再来计算每个像素的覆盖率,利用覆盖率的结果重新混合原始颜色(也就是resolve过程),最终达到反走样的目的。
MLAA(Morphological Antialiasing)→SMAA(Subpixel Morphological Antialiasing)
MLAA和SMAA在算法思路上并无区别,只是MLAA算法最初提出来是基于CPU的算法,而SMAA则结合GPU的特点进行了工程上的优化。看过我之前有关皮肤渲染的文章并且认真去看了相关文献的读者应该对SMAA的作者Jorge Jimenez不太陌生,因为我们在那篇文章中介绍的大量有关角色渲染的方法都是他提出的。实际上他在Siggraph2011中有一个非常棒的course[10]专门阐述了SMAA算法,从原理到工程细节一清二楚,非常推荐仔细阅读,这里有关SMAA的算法概述基本也来自于这个course。
简单来说SMAA(以及MLAA)包含三个步骤:
(1)找到图像中不连续的像素(即边缘像素)
(2)从每个不连续像素出发,找到经过它的直线的两个端点,记录端点的距离及整个线条的形状(这个形状模式最多有16种),并估算当前像素被这条线段切分后的两个部分的面积
(3)每个像素最多被四个不同方向的线条切分,则该像素最多有四个面积权重,根据该权重,取周围像素和当前像素进行颜色混合
整个SMAA的工作流程
在第一个步骤里,我们确定哪些像素可能是边缘,这些区域会被认为是“可能出现走样的像素集合”。这和我们一般意义上的边缘检测区别不大,关键在于你如何定义“不连续”,可选的依据包括颜色(亮度),深度,法线,PrimitiveID等信息,颜色作为连续性的参考依据的优点是易于获得(谁还没个色彩信息),同时能一定程度上实现着色反走样(因为着色走样反映出来就是色彩信息不连续)。但它也有可能造成不必要的模糊。而深度,法线,PrimitiveID这些信息在前向渲染的框架内往往不易获取,但对延迟渲染来说天生就有(G-Buffer),使用它们能够较为准确地找到可能出现几何走样的像素集,缺点当然是边缘检测要更慢一些。此外,相较于由于CPU的版本可以一次完成上述三个步骤,而GPU必须分步执行,因此在第一步执行完毕后,我们可以使用Stencil Buffer把非边缘像素mask掉来进一步提高之后的算法效率。
第二个步骤需要从当前像素出发,向两端查找对应线段的端点,这个搜索过程单步来说很简单:即沿某个方向检查前一个像素和后一个像素是否都不为0(假设0为非边缘,1为边缘)。但是对于GPU来说,为了找到线段终点,每个像素需要执行大量的贴图读取操作。SMAA利用GPU固有的双线性插值特性来将两次贴图读取合并成一次,读取位置位于两个像素的中点,这样,我们只需要判断经过双线性插值得到的值是否为1即可,为1表示当前位置不是线段终点,否则即是。
双线性插值的步进示意图,采样数减少一半
找到终点实际上只是找到了线段的长度,线段是什么形态的还需要确定端点的位置,SMAA进一步用双线性插值加上一个小偏移量的做法,使得shader能够用一次贴图采样即判断出一个端点的位置。
端点形态的确定
此外,由于线段根据端点位置的不同可以有16中不同的形态,如果在shader里一一判断并计算,将造成大量的分支开销(有关图形渲染开销的话题或许会在我今后的专栏文章里详细解释)。为了防止这种情况,SMAA把线段形态的确定以及进一步的面积覆盖率计算全部预计算到了一张贴图上,然后根据找到的端点位置和长度作为索引去查找这张4D贴图。
FXAA(Fast Approximate Anti-Aliasing)
FXAA和也是一种形态学反走样方法,但相比于SMAA,它进一步简化了整个算法步骤,将我们描述的三个步骤整合在了一个后处理的pass里,当然它的基本算法也遵循以上步骤。实际算法的说明里拆解出了更多的步骤,这里还是对照SMAA来简单解释FXAA的步骤:
(1)边缘像素集筛选,为了更好的通用性,FXAA使用sRGB空间的颜色作为输入,并根据局部的亮度对比度来确定一个像素是否是边缘像素。这也就是表示FXAA一般应该发生在Tone Mapping之后,或者也可以把Tone Mapping和FXAA整合成一个pass。
红色像素表示找到的边界像素,黄色表示水平的边界,蓝色的表示垂直的边界
(2)线段的搜索,不同于SMAA,FXAA只查找一个方向的线段,这个线段我们认为是主方向,它要么是横向要么是纵向。在通过局部差分得到主方向后,FXAA沿着主方向搜索两个端点到该点的距离(不同于SMAA,FXAA不需要确定两个端点的位置,只确定距离即可),假设是横向,则找到和。
(3)最后,FXAA根据和确定当前当前像素在找到的直线上的Coverage,进一步得出垂直于主方向的另一个方向上的偏移量,基于该偏移量用双线性插值重采样,得到的最终颜色就相当于根据Coverage进行了线性混合后的最终结果。
从当前像素的两个方向查找长线段的端点
根据两端线段及总长度的比值,得出当前像素的中心沿垂直方向的偏移量
更详细的算法说明可以看NV的文档[11],另外这篇文章[12]对FXAA算法也解释的很清晰。FXAA主要的优势体现在易于整合进现有架构,并且性能开销极低,但比起之前的算法,效果当然也要差一些。
几何反走样:基于时间的方法
近年来游戏引擎中最常用的反走样方法是基于时间的反走样方法,它的假设是:整个场景很少发生大幅度的镜头/物体运动,帧与帧之间具有比较明显的连续性,上一帧某个物体的微小表面在下几帧中仍会出现(只是位置发生了较小移动)。在文章开始时我们曾说过,走样是因为采样不足,前面介绍的方法是把采样点散布在二维空间里,这些可以统称为空间反走样方法(Spatial Anti-Aliasing),而基于时间的反走样则是把采样点散布在帧序列(时间)里,这样单帧渲染的压力就明显减小。理论上基于时间的反走样在场景运动不大的情况下效果和性能都显著好于上述各类方案。
TAA(Temporal Anti-Aliasing)
严格来说TAA并不能算一个具体的算法,而是更像一个统一的算法框架。和SSAA一样,TAA也能够同时减轻几何走样和着色走样的问题。它的基本框架大概是这样[13]:
Temporal AA的算法框架
总体来说TAA也分为采样(sampling)和合成(resolve)两个过程,不同的TAA的具体实现也是围绕这两个部分有所变化。
采样
采样的部分主要涉及两个方面,一个是样本的位置分布,另一个是历史样本的获取。
因为每个像素需要的样本被分摊在了时间轴上,因此实际上每帧我们都只需渲染一个新的样本,然后将它和其他历史样本混合即可。考虑这样的情况:当整个场景完全不动的时候,每次我们获取的子样本位置都一样,那不论经过多少次混合,最终混合后的像素仍然是走样的,为此,我们需要在光栅化G-Buffer的阶段,在Projection Matrix之后再加上一个Jittered Matrix。这个Jitttered Matrix会根据一个样本分布的pattern对当前采样位置进行一个微小的偏移,保证每帧样本分布的位置都略微有所不同。这样经过混合即可产生反走样的效果。通常来说规则的采样点pattern效果不会太好,UE4使用的是Halton Sequence[14][15]。关于抖动采样的一个具体实现,这里有一篇介绍[16]。
为了获取历史样本,我们需要一个称为reproject的过程,这个过程从原理上是比较简单的:首先根据当前像素的uv和depth以及当前相机的View Projection Matrix去反推出当像素的世界坐标,然后根据上一帧的View Projection Matrix计算出当前像素点在上一帧图像上的uv作为采样坐标。但只有这些还不够,原因是这里我们只考虑了相机的移动。如果物体本身也发生了移动呢?这里就需要另一个G-Buffer的辅助信息:Motion Vector Buffer。这里Motion Vector Buffer的不做描述,它是一般是一张RG16F的贴图,通常用于提供运动模糊计算需要的信息。综上所述,对于静态物体,我们使用反投影的方法找到它的历史像素采样坐标,对于动态物体,我们使用反投影结合Motion Vector Buffer来获取历史像素坐标[13][17]。
在实际的计算中,我们往往不会使用当前像素点位置对应的Motion Vector的值,而是在取该位置的领域中运动最剧烈的向量作为实际的Motion Vector(比如3×3的领域里的最大Motion Vector)。这样做的原因是,如果只考虑当前像素自身的运动,那么在运动物体(前景)和不运动物体(背景)交界处的背景像素就会因为没有运动而无法产生较好的反走样效果。
Motion Vector的选取,注意电线杆(前景)和天空(背景)的边缘处发生的变化
合成
合成部分相对来说比较简单,主要解决的问题是样本的合成方法以及历史样本的合法性验证。
最直观的样本合成方法是把所有历史像素和当前像素进行加权平均。
这个方法显著的缺点是需要大量的存储空间(每个历史像素单独存储),并且难于跟踪验证每一个历史样本的有效性。所以一般用我们称之为Exponential Move Average(EMA)的方法,名字看起来很唬人,但是看一眼公式你就会发现非常熟悉:
实际上如果你实现过基于Ping-Pong Buffer的Motion Blur就会发现,两者用的公式是一样,这里α一般设置成0.1。这个合成方法的优点是,不论我们有所少个采样样本,我们都只存一个像素的历史颜色。
之前我们说过,TAA假设场景里某一个微面元在连续的帧里都能找到对应的像素样本,但是有时候这样的假设是错误的,比如某些时候因为镜头或者物体的运动导致一些原本可见的像素变得不可见(或者相反),又或者场景某些物体的光照情况发生了剧烈的变化。这些都会导致我们在时间轴上累计的历史样本失效。如果将这些失效的像素混合进当前颜色里,就会产生所谓的鬼影(Ghosting)。
验证像素有效性的方法都基于一个假设:当前像素样本附近的颜色和它的颜色接近,并且它们的取值范围形成一个凸包,我们认为位于这个凸包内的历史样本的色彩取值都是有效的,可以采用,而这个凸包外的色彩取值则无效,需要通过进一步的处理才能够采用。
首先是凸包的构造,尽管我们能够在某个色彩空间内(比如RGB空间)根据像素领域内的每个像素颜色精确构造一个凸包并判断某个点是否位于凸包内,但这样做计算成本很高,所以一般来说我们使用每个分量的最大/最小值构造出一个AABB,利用这个AABB去做历史样本的验证。一般来说,基于YCoCg色彩空间的AABB的验证效果会优于RGB空间(毕竟亮度的变化是比较敏感的,所以YCoCg构造出的更像是RGB空间里的一个OBB包围盒,它更能精确反应当前区域的色彩分布)[14]。
另一种凸包的构造方案是基于当前区域的统计信息,我们称之为Variance Clipping。顾名思义,它使用当前像素领域内的所有像素颜色的一阶矩和二阶矩作为AABB的center和extent。
当历史像素不在合法范围内时,有两种处理方法,分别是clamp和clip,clamp就是直接逐分量地把每个颜色值截取到合法范围内,clip则更复杂一些,它将历史样本和当前AABB的中心值连线,并将连线和AABB的交点作为修正后的值来使用。
YCoCg空间下的clamp和clip的区别
可以看出来,TAA从原理以及算法上并不复杂,但由于它将样本分布在时间上这样一个特点,所以它的实现贯穿了整个引擎的渲染流水线(比如样本生成是在G-Buffer的绘制阶段和Lighting阶段,resolve一般发生在后处理阶段。相比之下全屏范走样的方案则往往只发生在后处理阶段)。所以它在引擎上实现的工程难度较大,需要针对具体引擎进行较为深度的架构改造。此外,理想的情况是TAA结合MSAA一起使用,当然这样会造成更大的开销,因此很少真的有引擎这样做。
实际上,这种将样本从空间分布到时间上的策略,对于一些需要随机采样点的图形学算法(比如SSAO和SSR)来说,也能起到很好的效果(大幅增加了可用的采样点)。今年HPG2017上的基于1SPP的光线追踪反走样方法[18]也利用了Temporal AA作为基本的思路。
a将随机光线分摊到时间上的SSR的效果
总结
以上是一些常用反走样方法的描述。在这个主题的下一篇文章里我会继续总结着色反走样以及其他和反走样有关的算法内容。整篇文章断断续续写了一个月有余,原本计划一篇写清楚的内容也因为东拉西扯终于拆成两篇,下一篇不出意外应该是明年的某个时候吧。。。写完的时候恰好是平安夜,当然本来这也算不上什么节日(至少对我而言),不过想想这种时候还在写这种丧心病狂的文章突然觉得有点凄凉。。。好在南方并不太会下雪。
引用
[1]采样定理-维基百科
[2]傅里叶变换-维基百科
[3]Applying Sampling Theory To Real-Time Graphics
[4]Experimenting with Reconstruction Filters for MSAA Resolve
[5]CSAA-Coverage Sampled AA|NVIDIA
[6]EQAA Modes for AMD 6900 Series Graphics Cards
[7]Deferred MSAA
[8]Antialiased Deferred Rendering
[9]CryEngine3 GRAPHICS GEMS
[10]Filtering Approaches for Real-Time Anti-Aliasing
[11]FXAA WhitePaper
[12]Implementing FXAA
[13]Temporal Reprojection Anti-Aliasing in INSIDE
[14]High Quality Temporal Supersampling
[15]Halton sequence-Wikipedia
[16]Temporal Anti-Aliasing-Mali GPU and Vulkan
[17]Anti Aliasing Methods in CryENGINE-3
[18]Spatiotemporal Variance-Guided Filter,向实时光线追踪迈进
|