写在前面
关于alpha的问题一直是个比较容易摸不清头脑的事情,尤其是涉及到半透明问题的时候,总是不知道为什么A就遮挡了B,而B明明在A前面。这篇文章就总结一下我现在的认识~
Alpha Test和Alpha Blending是两种处理透明的方法。
Alpha Test采用一种很霸道极端的机制,只要一个像素的alpha不满足条件,那么它就会被fragment shader舍弃,“我才不要你类!”。被舍弃的fragments不会对后面的各种Tests产生影响;否则,就会按正常方式写入到缓存中,并进行正常的深度检验等等,也就是说,Alpha Test是不需要关闭ZWrite的。Alpha Test产生的效果也很极端,要么完全透明,即看不到,要么完全不透明。
而Alpha Blending则是一种中庸的方式,它使用当前fragment的alpha作为混合因子,来混合之前写入到缓存中颜色值。但Alpha Blending麻烦的一点就是它需要关闭ZWrite,并且要十分小心物体的渲染顺序。如果不关闭ZWrite,那么在进行深度检测的时候,它背后的物体本来是可以透过它被我们看到的,但由于深度检测时大于它的深度就被剔除了,从而我们就看不到它后面的物体了。因此,我们需要保证物体的渲染顺序是从后往前,并且关闭该半透明对象的ZWrite。
注意:Alpha Blending只是关闭ZWrite,人家可没有关闭ZTest哦!这意味着,在输出一个Alpha Blending的fragment时,它还是会判断和当前Color Buffer中的fragment的深度关闭,如果它比当前的fragment深度更远,那么它就不会再做后续的混合操作;否则,它就会和当前的fragment进行混合,但是不会把自己的深度信息写入Depth Buffer中。这是非常重要的,这一点决定了,即便一个不透明物体出现在一个透明物体的前面,不透明物体仍可以正常的遮挡住透明物体!也就说说,对于Alpha Blending来说,Depth Buffer是只读的。
Surface Shader
在Unity的Surface Shader里实现上述两种技术是非常简单的,可以参见之前的文章——Alpha Test和Alpha Blending。简单总结一下就是,只要在#pragma里设置alphatest:_Cutoff或alpha指令即可。
但是,很多童鞋说在使用Alpha Blending的时候得不到正确的结果,那么很大可能就是Tags没有设对,更具体一点,就是渲染队列没有设置正确。一般透明对象应该起码设置为Tags { "Queue" = "Transparent" }。为什么要正确设置渲染队列呢?如果没有正确设置,那么很有可能透明物体后面的物体会出现在透明物体的前面。
但是,它们背后的原理是什么呢?这就要从它们生成的Vertex & Fragment Shader说起了。那么,请看下一节~
Vertex & Fragment Shader
我们先来说比较简单的Alpha Test。
在Vertex & Fragment Shader里,要实现它非常简单。
一种方法是自己在shader中编写代码,只要使用类似下面的语句就可以了:
// alpha test
clip (o.Alpha - _Cutoff);
clip函数非常简单,就是检查它的参数是否小于0。如果是,就调用discard舍弃该fragment;否则就放过它。
另一种方法是使用固定管线的Alphatest指令。具体可见官方文档。使用Alphatest指令的方法选择更多,我们不仅仅是判断它小于_Cutoff时舍弃该fragment,还可以是判断它是否大于、是否大于等于,等等。但原理是和第一种方法一样,归根到底都是要靠discard函数来舍弃那么不符合条件的fragments。
Alpha Blending略微复杂一点,因为它涉及到了ZWrite的一些问题。
首先,需要正确设置渲染队列:
Tags { "Queue"="Transparent" }
其次,需要关闭ZWrite(但其实指定了下面的混合函数后,背后就会关闭深度缓存):
ZWrite Off
然后,我们可以指定混合函数,类似下面这样:
Blend SrcAlpha OneMinusSrcAlpha
上述是最常见的混合函数因子,其他可以参见官方文档。
在使用Alpha Blending时,一定要格外小心由于它关闭了深度缓存而造成的种种问题。从Unity的这张图可以看出:
深度检验是在Vertex Shader后面就进行的,因此在Fragment Shader阶段,由于它关闭了深度缓存,所以像素的覆盖与否完全取决于渲染的先后顺序。
注意:评论里有童鞋说OpenGL Wiki中给出的顺序图明明是Culling和Depth Test在Fragment Shader的后面啊!怎么这里又跑到前面去了呢!有图为证(来源OpenGL Wiki):
上图中“Fragment Tests”就是做Depth Test的地方。这位童鞋看得很仔细啊。没错,从理论上来说,的确是要等到Fragment Shader完成后,再对所有fragments进行各种检验。但现代的GPU为了性能考虑,往往会做一个类似于“Early-Z”的东西。这个东西可以理解为在Fragment Shader之前就进行一个深度检验,从而剔除那些不可能被渲染到的像素,这些像素就不会再调用Fragment Shader进行处理,从而可以提高性能。而正常的FS后面的Depth Test一般情况下仍然是会做的,之前的这个“Early-Z”可以理解为是一个粗略地剔除。在一些情况下,GPU为了提高性能会做两遍“Depth Test”(对于不同的GPU,第一次“Depth Test”的做法实现可能不一样),但在某些情况下(比如进行了Alpha Test),那么FS之前的Depth Test就需要关闭,这个在后面会讲到。
因此,Unity给出的图,它里面的Depth Test应该是指Early-Z的结果,但在这张图的后面还是会有正常的Depth Test。
以上内容不保证完全正确哦。
当然,有时我们可以混合使用这两种技术,例如第一个pass里使用Alpha Test渲染实体部分,第二pass里对上一个pass里被剔除的fragment使用Alpha Blending进行柔和渲染。
为什么渲染队列和渲染顺序这么重要
当然,这里说的要正确设置渲染队列是指Alpha Blending时的策略。之前说过,如果不关闭ZWrite,那么在进行深度检测的时候,Alpha Blending背后的物体本来是可以透过它被我们看到的,但由于深度检测时大于它的深度就被剔除了,从而我们就看不到它后面的物体了。因此,我们需要关闭该半透明对象的ZWrite。那么,和渲染队列有什么关系呢?如果你的场景里有且只有这么一个物体,那么渲染队列是不重要的。但一旦场景里有了其他不透明物体,问题就麻烦了。正如OpenGL Wiki里说的,“First - the bad news. REALLY bad news.”。。。关闭了深度缓存带来了很多麻烦。“麻烦大了你!”
有两篇文章我觉得大家可以看看:一篇是OpenGL Wiki,一篇是MSDN上的一篇博客。我这里来简单说一下为什么关闭深度缓存会出现这么多麻烦事。
我们首先来理解,为什么一个物体会看起来是“半透明”的。在OpenGL中,这是通过Blending技术实现的。我们都知道一个叫“Color Buffer”的东西,这个可以理解成我们会在屏幕上看到的各种颜色。对于不透明物体来说,经过Fragment Shader处理后的fragment会和当前在Color Buffer中的fragment进行深度比较,结果要么覆盖它要么就被舍弃。但对于半透明物体,由于它关闭了深度缓存,因此不会进行深度比较,而是通过混合系数和当前Color Buffer中的颜色进行混合,使物体看起来好像透过它看到了其他物体。
我们来考虑下面这种情况(来源MSDN上的一篇博客):
其中,A物体是半透明的,而B是一个不透明物体。如果我们先渲染B,再渲染A,那么B首先会写入Color Buffer和Depth Buffer。渲染A的时候,A首先和Depth Buffer中的B进行比较,“诶我在你前面呢~”,然后就会和Color Buffer中B的颜色进行正确的混合。但是,如果我们先渲染A再渲染B,A首先写入Color Buffer,但不会写入Depth Buffer。注意此时的Color Buffer中没有任何颜色,因此A没有进行颜色混合就写入了Color Buffer。等到渲染B的时候,B会做正常的深度检验,它发现“咦,深度缓存中还没有人诶,那我就放心地写入Color Buffer啦~”,结果也就是B会覆盖A的颜色。从视觉上,看起来就是B出现在了A的前面(虽然A未被B覆盖的部分看起来的确是透明了)。
这个例子说明,在A关闭了深度缓存的时候,渲染的顺序是多么重要!一种最简单的方法,也是Unity采用的方法,就是保证所有的不透明物体都会在半透明物体之前被渲染。而这就是通过Tags { "Queue" = "Transparent" }来保证的。Unity的Queue标签决定了这个对象的渲染队列。对于不透明物体来说,它的"Queue"="Geometry",而对于半透明物体,它的 "Queue"="Transparent"。而Geometry队列中的对象总是会在Transparent之前被渲染,这就保证了所有的不透明物体都会在半透明物体之前被渲染。因此,如果你没有正确设置这个值,那么很有可能就会出现上面例子中的情况:后面的B反而在A的前面。
上面这种方法即简单又有效,但对于一些复杂情况下却还是会出现问题。例如,我们需要渲染前后两个半透明物体。还是上面的图,这次A和B都是半透明物体。因为A和B都不会写入深度缓存,因此结果完全取决于它们的绘制顺序。如果我们先渲染B再渲染A,那么B正常写入Color Buffer,而A会和Color Buffer中的B进行混合,结果正确。但是,如果我们先渲染A再渲染B,那么A写入Color Buffer,随后B会和Color Buffer中的A进行混合,这样看起来就好像B在A的前面,结果错误。
一个方法就是保证从后往前渲染所有的半透明物体。这也是Unity的做法,Unity文档中是这样说的:
Geometry render queue optimizes the drawing order of the objects for best performance. All other render queues sort objects by distance, starting rendering from the furthest ones and ending with the closest ones.
也就是说,对于Geometry队列渲染顺序是Unity内部进行优化我们无法得知。但所有其他的队列(包括了Transparent)都是对物体的距离进行排序,然后按从远到近的顺序进行渲染。
那么你会说,好了吧,这样总没事了吧。But but,还是有问题哦。如果你仔细想想的话,“对物体的距离进行排序”,什么是物体的距离呢?你会说,就是距离摄像机的Z值远近嘛,真烦人!但是,由于我们的排序是基于整个物体的,而不是像Depth Test那样是逐像素排序的。这意味着,排序结果是,要么物体A全部在B前面渲染,要么A全部在B后面渲染,但很多时候真实的物体是互相交叉的。我们可以考虑如下情况(来源OpenGL Wiki):
你觉得它们之前的相对排序结果是什么呢?答案是按照之前的整个物体进行排序是永远不会得到正确的结果的,除非我们把每个物体按遮挡分为两个部分。
看到这里你会说,分成两个部分总好了吧!没问题了吧!But but。。。没错,就是有这么多But。即便我们保证不会有这样循环遮挡的物体,还是会有问题。我们再考虑下面的情况(来源OpenGL Wiki):
这里的问题是,“如何排序?”我们知道,一个物体的网格结构往往是占据了空间中的某一块区域,也就是说,这个网格上每一个点的深度值可能都是不一样的,我们使用哪个深度值来作为整个物体的深度来和其他物体进行排序呢?网格中点?最远的点?最近的点?不幸的是,哪个都不对。例如上面这个图,如果使用网格中点的深度值进行排序,那么B在C的前面,但实际上B有一部分被C遮挡了。同理,用最远点和最近点也无法保证结果正确。Unity中的方法是使用网格的中心点来进行半透明物体的排序。这就意味着,在某些情况下半透明物体之前一定会出现错误的遮挡关系。比如这位仁兄就遇到了这样的问题。如果出现这种问题,那么解决方法也是分割网格。其实,任何这种A有一部分在B上面,而B有一部分在A上面的问题,如果又关闭了ZWrite的话,大概就只有分割网格的方法了。
你会说,分割网格好麻烦的,而且万一动了动模型,就又要重新分割,就没有其他方法了吗?当然有,其他方法也有些缺点,这意味着我们要做权衡。上述所有问题都是由于关闭了ZWrite的后果(你现在知道它有多可怕了吧)。那么,我们开启它不就好啦~之前提到的Alpha Test就没有关闭ZWrite,因此,我们可以使用Alpha Test来替代,但缺点是不会得到半透明那种平滑的边界(而且在移动平台上还有性能下降的后果)。还有一种方法就是开启ZWrite。Unity文档中给出了这样一个例子,就是先使用一个Pass来渲染得到深度信息,再使用Alpha Blending进行透明渲染,由于Alpha Blending不写入Depth Buffer,它会根据上一个Pass的结果来决定是否进行混合。但这种方法的缺点是,仅仅是看起来像透明物体,但它不会透出后面物体的颜色。
由此,我们来总结下半透明物体渲染的注意事项:
- 对于不透明物体和半透明物体之间的渲染关系,Unity通过Queue来保证渲染顺序的正确性,因此,我们必须正确设置半透明物体的 "Queue"="Transparent"。
- 对于半透明物体和半透明物体之间的渲染关系,Unity通过使用网格中心点进行排序+从后往前渲染,来尽可能保证渲染顺序的正确性。但对于部分遮挡的物体,还是会产生不正确的遮挡效果。因此我们要么分割网格,要么使用Alpha Test或者开启ZWrite来替代。
性能
Unity的官方文档中,有两个地方提到了它们的性能问题——一个是编写Shaders时的性能提示,一个是优化图像性能。微微地感觉这两个页面有很多重复,未来某一天可能会合成一个页面。。。
它们是这么写的:
Fixed function AlphaTest or it’s programmable equivalent, clip()
, has different performance characteristics on different platforms:
- Generally it’s a small advantage to use it to cull out totally transparent pixels on most platforms.
- However, on PowerVR GPUs found in iOS and some Android devices, alpha testing is expensive. Do not try to use it as “performance optimization” there, it will be slower.
以及
Keep in mind that alpha test (discard) operation will make your fragments slower.
总结一下,就是使用Alpha Test看似更简单,但其实在大多数平台上,相比与Alpha Blending,只有一点小小的性能提升。但是!!!在iOS和某些Android设备上,由于它们使用了PowerVR GPUs,因此Alpha Test的性能消耗反而会更大。因此,一个忠告就是,尽可能使用Alpha Blending,而不要使用Alpha Test。
我们会觉得很奇怪,没有关闭深度缓存,不需要计算混合颜色,仅仅调用来discard舍弃fragment不是非常简单的事吗?为什么在移动平台上反而效率更低呢?有句话叫,“简单粗暴”,可以用在这里。性能下降的原因就是它太粗暴了!(我乱说的你不要当真。。。)
好啦,言归正传~原因呢,就是之前提到的两遍检验。由于我经验有限,只能依靠强大的谷歌来找答案。我找到了这里、这里。总结一下,就是PowerVR GPUs使用了一种叫做“Deferred Tile-Based-Rendering”的技术。这种技术里有一个优化阶段,就是为了减少overdraw它会在调用fragment shader前判断哪些Tile是会被真正渲染的。也就说我们之前说的在FS之前做的“Depth Test”。但是,由于Alpha Test在fragment shader里使用了clip函数改变了fragment是否被渲染的结果,因此,GPUs就无法使用上述的优化策略了。也就是说,只要在完成了所有的fragment shader处理后,GPUs才知道哪些fragments会被真正渲染到屏幕上,这样,原先那些可以减少overdraw的优化就都无效了。