Alpha To Coverage(A2C)是一种经由流水线完成的“Alpha Test”。在使用了多重采样(Multi-sample)的场合下,经由检测当前需要绘制的fragment的alpha值来决定该fragment在对应像素上的sample覆盖率。应该说,这也算是很有历史感的显卡应用技术了,而本文将重在谈及此技术之前将在流水线level老生重弹一下MultiSample。——ZwqXin.com
[乱弹纪录I:Geometry Shader]
本文来源于 ZwqXin (http://www.zwqxin.com/), 转载请注明
原文地址:http://www.zwqxin.com/archives/opengl/talk-about-alpha-to-coverage.html
好多时候我们都会碰到这样一个问题:渲染Billboard集(譬如草簇、云簇、头发之类)的时候,因为billboard纹理中需要绘制出来只有其中一部分(见下图),图片中的“背景”部分就要在渲染时抠掉。传统的做法是ALpha-Test(曾经在那OpenGL传统渲染管道下,Alpha-Test跟Depth-Test、Stencil-Test、Scissor-Test一样是状态机管理系统中的“耀眼明星”,而在可编程渲染管道下,只要在fragment shader里discard掉不需要的fragment就可以了——Alpha-Test处理从Fragment Shader后的阶段往前提送,降格成普通的逐像素处理法),但是正如大家写代码所验证的一样,这种“非0即1”的强硬手段,让billboard的边缘突显了出来,形成了强烈的非真实感。所以说,这种基于某个alpha阈值的剔除法,只对那些本身具有强边缘的物件有好的视觉效果(譬如二维标签),对于希望“把三维的东西用二维的billboard去表达”的物件(譬如上述的草、云之类例子)来说,应该要有一种弱化边缘,使边缘逐渐虚化的渲染机制。
(Alpha-Test 硬边缘)
Alpha-Blend。根据物件纹理本身的渐变透明度(或颜色本身),使该物件与背景进行一定的混合(GL_SRC_ALPHA之类的因子决定混合参数),回答了这种机制。在Fragment-Shader之后的流水线阶段,在绘制上当前的渲染目标缓冲区(包括屏幕)时,每个fragment都被进行一次这样的混合处理,这样如果纹理本身带有渐变的透明度,就会在最终的渲染结果上显现出来。注意的是这种机制不是对每个物件并行处理的,对当前DrawCall产生作用时必然会考虑的是当前“画板上已经画了些什么”——所谓的“背景”。这样最后的结果就与深度信息无关了,是一种很纯粹的“画家算法”——混合的结果跟绘制的顺序有关(考虑画草簇,画第二株草体时,即使它位于第一株草体后面,也会与当前画面上的第一株草体混合,所以一画即错)。所以说,对于Alpha-Blend的物件,必须让绘制满足(对于视点)从后往前的顺序执行。
(Alpha-Blend)
(Alpha-Blend 上图是视觉转到Billboard渲染顺序非从后到前时造成的错乱;下图是同样场景下视觉转到Billboards按视线的后到前排序时)
可以料想到,这样的话就得对草簇的位置在视图空间下排序。如果要旋转漫游,那就必须实时地进行再排序。对于大量的物体,譬如成万的草或云的billboard,这样的排序非常损害FPS。正因此,OIT(order independent transparent)这个概念也就衍生了。为了实现OIT,很多方法逐渐被提出,其中一个就是Alpha To Coverage(A2C)。相比于当时代显卡硬件引入的Per-Pixel Linked Lists技术,Alpha To Coverage自NVidia 8Series时代已经出来了(对于OpenGL来说,这是很早就有的一个功能),也算适合于当前主流显卡。
MultiSample
Alpha To Coverage的实现的一个前提就是场景的渲染缓冲区使用了multi-sample(多重采样,见[全屏反锯齿 - 多重采样Ⅰ] [全屏反锯齿 - 多重采样Ⅱ] )。这里的缓冲区包括屏幕,也包括FBO(见[多重采样(MultiSample)下的FBO反锯齿] )。所以在引入这种机制之前,重温一下流水线中Multi-Sample是如何实现的吧。
多重采样,顾名思义,就是对一个像素产生多个采样值并把它们合成最终输出缓冲区的单一值。这样就包括了两个过程:1.在栅格化(Rasterization)出像素(fragment)前,提高采样率;2.在把所有像素输出到渲染缓冲区前执行Resolve以生成单一像素值。
举4XMSAA为例。对于第一个过程,你可以认为栅格化时同一个Fragment对图元(Primative)位置点采样了4次,每一次的位置(sample point location)都稍微不同(我一般臆想成在该fragment对应的像素点中心的微偏上下左右四个采样点,类似“regular grid”的采样pattern,当然实际的采样模式我不想断定哦,毕竟OpenGL把它交给显卡制造商了)。注意,Fragment Shader不是执行4次哦,因为它是针对fragment而不是fragment-sample的,它所有的输入varying变量都是针对其像素中心点(或者应该说fragment-center更好)而言的,所以计算的output color结果始终是针对该栅格化出的像素中心点而言的。
那么,MultiSample表现在什么地方呢?
depth和stencil等——它们都是针对fragment-sample的,但是又能对MSAA本质的抗锯齿属性有啥贡献呢?事实上,例如,虽然每个sample都会存储它自身的深度值(一般在fragment shader后写入),但resolve的时候该fragment的输出深度值一般只会取最靠近中心的那个sample的depth值。那何苦每个sample都去取深度值呢?那是因为之后需要每个sample都执行一下depth-test,以确定整个fragment是否要流向(通往缓冲区输出的)流水线下一阶段——只有当全部fragment-sample的Depth-Test都Fail掉的时候,才决定抛弃掉这个fragment(蒙版值stencil也是这样的,每个sample都得进行Stencil-Test,如果有alpha-blend那也同样了)。
似乎上述跟MSAA的抗锯齿(anti-alias)没太大关系哦。那么,MultiSample还表现在什么地方呢?
coverage——抱歉本文章的主题在这里才出现(引入),但我觉得上述背景介绍好了,才方便介绍这个概念。coverage(覆盖率)是MultiSample下每个fragment都带有的新属性(当然了,新增的存储变量还包括新增的depth和stencil),它是一个mask(如果还取4XMSAA为例,这个mask的表达形式就是:xxxx,其中x等于0或1),嘛,就是一个二进制的bit mask嘛。显然它的每一位(每一个x)代表的就是一个sample,其值为1代表该sample被(栅格化过程中的图元Primative)覆盖,其值为0代表没被覆盖——事实上,对于没有覆盖的sample上述depth和stencil等都没必要进行测试了。在multisample的第二个过程(resolve)中,只有该coverage mask中指定被覆盖的sample才参与最终的color-output。
Coverage的mask值是栅格化时就决定了的,它直接影响"像素后处理"阶段(上述的各种test)。那么,我们就有可能在这两个处理阶段之间的可编程阶段——Fragment Shader中去改变它。注意,在当代非最前端的显卡中(至少SM4.0),Fragment Shader没有直接改写coverage值的能力,但是它的确有能力去改变它,或者说,影响它——Alpha To Coverage。
终于憋到这里了啊。Alpha To Coverage,顾名思义,就是这样一个转换:Fragment's Alpha -> Fragment's Coverage mask for multisamples。当然了,即使Fragment Shader不去改变Alpha,这个转换还是会进行的,但是在我的例子中,我们需要一种更具有弹性的方法代替Alpha Test去控制billboard上各个像素的透明度,让它达到类似启用alpha-blend时的(依据物件图像边缘透明度而透出背景场景)效果,而这种方式就是Alpha To Coverage,所以需要指定每个fragment的透明度——简单地sample一张纹理足矣,这样billboard每个像素就具有其依据图片(假设此图片是认真制作的^^)的切实的透明度,非物件部分的alpha为0;.物件主体部分alpha为1;主体的边缘部分则是0~1的渐变alpha值了。
关键部分是Fragment Shader执行之后——Alpha To Coverage就在此时进行转换。一个fragment的Alpha值在0~1间,它对应着一个dither mask。还是以4XMSAA为例,这个dither mask也是xxxx的形式,Alpha为0对应了0000,alpha为1对应了1111,至于中间的值的对应关系,OpenGL是交由显卡制造商决定的——其实一般就是类似[0~0.249 -> 0000, 0.25~0.499 -> 0001, 0.5~0.749 -> 0011, 0.75~0.99-> 0111]这样(在D3D11中,就可以自定义这个dither mask)。恩,dither mask就是用来决定该fragment的samples中,用于最终组成output color的那些samples。具体就是把它与coverage mask作一次逻辑与操作获得新的coverage mask。在本例中,根据上面叙述就理解了,其实除了billboard矩形边缘外,栅格化后其余像素的coverage mask都是1111。billboard中非物件部分(透明度为0)的默认dither mask是0000,逻辑与之后新的coverage mask就是0000了,也就是最终会把该fragment所有sample都丢弃;billboard中物件主体部分dither mask是1111,逻辑与后新的coverage mask还是1111,也就是说最后还是取全部sample去计算输出;billboard中主体边缘部分也直接就是coverage mask = dither mask了,所以最终会根据该新的coverage对应的渐变alpha去选择摒弃掉一些sample了。注意,所有sample的颜色贡献都是那根据fragment-center计算的fragment shader输出值,都是一样的,所以计算output color忽略的具体是哪些sample这毫无所谓。
那billboard矩形边缘呢?如果是边缘部分本身alpha为0,呵呵那新的Coverage mask不也是0000的结末嘛。如果是上面草体billboard图片的下边缘部位呢?栅格化出来的coverage mask不一定是0000,假设是1101吧,再假设alpha为0.8,dither mask取0111吧,逻辑与后的结果是0101,取两个sample去参与最终颜色的输出计算,比前面两个mask都要少。所以说这时它既兼顾了alpha也兼顾了边缘属性(见下述)咯?
再提一句,coverage的作用还包括在Depth-Test之类的“后像素处理”中,因为只有coverage mask为1的sample才会参与这些处理。
也该是时候谈到一直说的“计算output color”是怎么一回事了。MultiSample的Resolve阶段,如果是屏幕输出的话这个阶段会发生在设备的屏幕输出直前;如果是FBO输出,则是发生在把这个Multisample-FBO映射到非multisample的FBO(或屏幕)的时候(见[多重采样(MultiSample)下的FBO反锯齿] )。Resolve,说白了就是把MultiSample的存储区域的数据,根据一定法则映射到可以用于显示的Buffer上了(这里的输出缓冲区包括了Color、Depth或还有Stencil这几个)。Depth和Stencil前面已经提及了法则了,Color方面其实也简单,一般的显卡的默认处理就是把sample的color取平均了。注意,因为depth-test等Test以及Coverage mask的影响下,有些sample是不参与计算的(被摒弃),例如4XMSAA下上面的0101,就只有两个sample,又已知各sample都对应的只是同一个颜色值,所以output color = 2 * fragment color / 4 = 0.5 * fragment color。也就是是说该fragemnt最终显示到屏幕(或Non-MS-FBO)上是fragment shader计算出的color值的一半——这不仅是颜色亮度减半还包括真·透明度值的减半。
单纯针对Coverage mask的影响而言,现在可以再来看看怎样理解“兼顾了alpha也兼顾了边缘属性”。这个coverage mask包括了栅格化出的coverage mask(姑且叫last coverage mask)和dither mask两部分。前者反映着该Fragment的边缘属性,如果没有启用Alpha to Coverage(也就是说不用考虑dither mask),这就造成output color = 0.75 * fragment color,该Fragment因其25%的部分位于“边缘外”(假设采样完全正确)而导致25%的虚化——这就是MSAA的真意,边缘虚化实现抗锯齿。那么现在考虑上Alpha To Coverage,该像素本身0.8的alpha值导致其进一步的虚化。对于上述“除了billboard矩形边缘外其余像素”的讨论更可知道:Alpha To Coverage依托于MSAA,但它也能作用于场景中的非边缘部分!
于是,Alpha To Coverage的实现理论就暂告一段落了。说到使用,代码上看真很简单——只要enable了GL_SAMPLE_ALPHA_TO_COVERAGE就OK了。当然了,在此之前必须确认当前的渲染缓冲区启用了MultiSample( [全屏反锯齿 - 多重采样Ⅱ] / [多重采样(MultiSample)下的FBO反锯齿] )。另外,似乎一般情况下MultiSample的环境下会自动启用GL_MULTISAMPLE(如果没有就手动启用吧:glEnable(GL_MULTISAMPLE)),它确保MultiSample在流水线中执行,如果被Disable了的话MultiSample Buffer中的各Fragment的Coverage总会是“全覆盖”(1111...)、各sample也只会写入相同的值——相当于给了那么大的多重采样存储区却不执行多重采样。
(Alpha-To-Coverage 4xMSAA)
虽然相比Alpha-Test好很多了,但显然比不上Alpha-Blend的效果。从图中可以看到很明显的颗粒感,首先很容易想到是精度不足所致的。因为我们现在(4xMSAA)是把Alpha值为0~0.249范围内(仅为举例)的Fragment统一当做是0 Coverage(完全不覆盖),把0.25~0.499内的统一当作只覆盖一个sample……这样就相当于把整个渐变的Alpha值范围硬性地划分成4个区域了(更准确来说,A2C本质是针对每个sample做Alpha-Test),从而精度很低。那么解决办法就是提高多重采样的sample数——譬如使用16xMSAA,渲染结果如下图,明显好很多了吧?(但是谁会单纯为了这个而去承受16xMSAA带来的巨大FPS损耗哦~)
(Alpha-To-Coverage 16xMSAA)
其实仔细看上图还是觉得有线粒感,这是因为还有一个更重要因素——Dither mask太统一而导致的。譬如50%Alpha时只有0011的mask而没有0101、1001之类的mask,这样每个50%Alpha的Fragment舍弃的sample可能都是相同的相对位置上的sample。虽说采样的方式是交由显卡制造商决定的,且某些显卡下Fragment在不同位置的采样pattern或许都会变化,但邻近的Fragment肯定更倾向于相同的pattern,譬如第一个sample总是fragment-center的偏上位置,第二个sample总是在偏右位置……这样的结果是,0011的coverage mask总只保留这两个位置的sample而摒弃掉偏左和偏下位置的sample——线粒感于是形成了。
在D3D11中有办法在Shader中为每一个Fragment指定sample mask,这样各种随机各种分布的机制就可以引入了。但OpenGL4.x中虽然也有sampleMask输出(也就是说输出Coverage mask),但对于输入(栅格化出来的coverage mask)暂时无法获得(根据spec,可能晚点会加入)?所以说暂无法自定义这个dither pattern。其实OpenGL也有其他方法去更改这个Coverage mask(当然还是要注意首先确定开启了multisample):
这里开启了GL_SAMPLE_MASK,并通过glSampleMaski去定义这个mask(其实就是Coverage,4xMSAA下就是0000~1111,16xMSAA下就是0000000000000000~1111111111111111了,按上述16进制方式传入参数即可)。其实应该glSampleCoverage也可以的(第一个参数范围0.0~1.0,像A2C一样映射到Coverage),但我没成功让它起作用的说~~
(SampleMask: 0x0E 4xMSAA)
(SampleMask: 0xAFFF 16xMSAA)
这种方式的结果如上图。嘛,就是对于渲染的东西作为一个全局影响的Coverage了,结果简直就是类似于全局调整Alpha值嘛,坑爹啊?!
最后的比较:
恩本文到此结束。随着图形学技术的发展,OIT的重要性越来越突显出来了,从最初的Alpha-Tset,到Alpha To Coverage,到其他领域的depth-peeling、ABuffer、Per-Pixel-Linked-List ……图形学的世界是越来越宽广了。
本文来源于 ZwqXin ( http://www.zwqxin.com/), 转载请注明.