如果你曾经使用过绘图软件进行绘画,同一个图层下,如果使用不透明的画笔进行绘画,那么下一笔绘制的肯定会覆盖掉前一笔绘制过的东西。由此可见,绘制顺序在绘制中起到的作用。
图形学中,也需要考虑这个绘制顺序,不然即使A物体在B物体的后面,如果按照先绘制B后绘制A的话,仍然会出现A遮挡住B的情况出现。如下图所示,AB两个物体,会有两种渲染顺序,且它们的渲染结果是截然不同的:
然而在渲染管线中,一般是不需要刻意注意绘制顺序的,这是因为GPU中有一个称为深度缓存(z-buffer) 的东西。
所谓深度缓存,和帧缓存(frame-buffer) 一样,是一个二维纹素矩阵,只是上面存储的不是RGBA颜色值,而只存储了一个深度值(z)。
这个深度缓存是为了深度测试(depth-test)服务的,深度测试是片元测试中的其中一部分,发生在渲染管线中片元着色器后面(感兴趣可以了解渲染管线,了解顶点渲染到输出的过程会对图形学的学习有很大帮助)。
所谓深度测试,简单来说就是将当前需要绘制的内容,和已经绘制的内容的深度进行比较——如果需要绘制的离得更远,就丢弃,否则不丢弃。
假设z越小表示离得越近,将当前要渲染的片元的深度值 Zsrc,与深度缓存中相同位置的深度值 Zdst进行比较,如果:
(至于什么是片元,会在后面进行补充)
最后就是深度写入,顾名思义,就是将一个片元的深度值写入深度缓存当中。
至于写入的时机,一般就是这个片元通过深度测试的时候,也就是说当此片元离得更近时,会将此片元的深度值更新到深度缓存当中。
渲染管线中,当顶点经过图元装配、光栅化以后,得到的内容称为片元(fragment)。片元最后会成为像素,但不是全部的片元都会成为像素,换一句话说,片元有可能会被丢弃。
至于为什么会被丢弃,前面已经说过其中一个原因了,那就是没有通过深度测试。当然深度测试只是片元测试中的一个部分,其他的还有透明度测试等。如下图所示:
虽然前面说过,因为有神奇的深度缓存,因此一般来说不用考虑渲染顺序的问题,因为片元会根据自己的深度和已有的深度进行比较后自动丢弃,但这是针对不透明物体而言的。一旦场景中有不透明物体,那么深度缓存、深度测试就“不灵”了。
为什么这么说呢?我们可以进行场景假设,模拟一下实际的渲染。
假设场景中,有物体A和B,且假定A在B的前面,其中A是半透明物体,B是不透明物体,如下图所示(其中灰色条纹是背景图,方便效果显式):
那么渲染结果如下图所示,这样绘制的结果(先绘制不透明物体,后绘制透明物体)是正确的:
(*注: 这里的“物体B”丢弃并不完全准确,准确来说是丢弃被A遮挡的B的部分)
那么渲染结果如下图所示。渲染结果是错误的,因为被A遮挡的B的部分完全消失了:
但我们希望的渲染效果是能透过半透明物体A看到后面的物体B。出现这样的原因,是因为错误的深度测试所造成的,也就是说若只依赖于深度缓存而不管渲染顺序,就会出现这样的情况。
对于渲染顺序,除非引擎内部进行了排序(如 cocos2d-x 3.0以后,会按照局部z和全局z坐标进行一个排序后,再进行渲染),ff否则一般就会按照物体的访问顺序进行渲染,因此上面先渲染A后渲染B是完全有可能的。
从这个场景的两种渲染顺序,我们可以得到以下两个,可以正确渲染场景的结论:
你可能仍然会怀疑上面的第二条结论,即透明物体只要在不透明物体以后进行渲染,好像都可以得到正确的结果?
但是一旦涉及到透明效果,问题就会变得非常复杂。我们来考虑另外一个场景,这个场景只有透明物体。
假设场景中有物体A和B,物体A、B都是透明物体,且物体A在物体B的前面,开启深度写入,互不重叠(至于为什么强调不重叠,后面会进行说明)。
真糟糕,我们应该能透过A看到B的。从这个结果你可能会说,如果我们按照透明物体从远到近的顺序进行绘制(也就是先A后B),不也可以不用关闭深度写入,从而有正确的渲染结果吗?
然而当透明物体出现重叠时,问题又又又又出现了,如下图所示,透明物体A和透明物体B部分部分重叠,导致物体A有部分被B遮挡住了(方便起见,我们将这部分命名为A1):
如果按照前面所说的,我们先渲染远物体再渲染近物体,假定远的物体是B,近的物体是A(这里有一些问题,后面会解释),同样开启深度写入的话,那么渲染流程如下所示:
这导致的渲染结果就是,A1被丢弃了,明显不是我们想要的渲染结果,A1应该可以透过B渲染得到:
因此渲染透明物体,我们不得不关闭强大的深度写入。但是即使关闭了深度写入,针对刚才的这个重叠问题,由于我们是先绘制B后绘制A,这样的渲染结果会造成另外的问题是——视觉效果上A1在B前面。如下图所示:
出现这样的渲染结果,究其原因是因为前面提到的,我们不能准确定义不透明物体的远近关系。如刚才的问题,下图中重叠的物体A和B,从不同的参考角度,可以得到不同的远近关系:
换一句说,渲染顺序是以图元(Primitive)为单位的,但是深度测试和深度写入却是以片元为单位。
总而言之,对于透明物体的渲染,我们不能给出一个完美的、可以兼顾所有情况的解决方案。《UnityShader入门精要》中提到了渲染引擎中,一般采取的方法是:
(1)先渲染所有不透明物体,并且开启深度写入和深度测试;
(2)把透明物体按它们距离摄像机的远近进行排序,然后按照从远到近的顺序渲染这些透明物体,开启深度测试,但是关闭深度写入
从渲染管线的角度上看,渲染透明物体是一件非常复杂的事情,由于涉及的情况较多,因此并不能有完美的解决方案。上面所说的场景情况可能比较难理解,需要理清图元、片元和深度缓存之间的关系。
总而言之,对于渲染不透明物体和透明物体,笔者有以下的总结:
(*注:笔者在学习这方面的知识的时候也困惑了比较长的时间。当然上面所说的不一定就是正确的,如果有不正确的,或者迷糊不清的地方,请通过评论或者私信的方式告诉我,也欢迎大家一起讨论!)
接下来这部分是关于如何在Unity中实现透明效果。
注意:这里假定读者有一定的UnityShader(或者OpenGL、D3D等图形接口的Shader)的编写经验,所以只会给出关键部分的Shader代码。
前面提到过渲染顺序是较为重要的事情,一般引擎会采用以下方法处理透明、不透明物体的渲染:
(1)先渲染所有不透明物体,并且开启深度写入和深度测试;
(2)把透明物体按它们距离摄像机的远近进行排序,然后按照从远到近的顺序渲染这些透明物体,开启深度测试,但是关闭深度写入
在UnityShader中,使用了渲染队列来处理渲染顺序的问题。简单来说,索引号越小的渲染队列越先被渲染:
Unity中预先定义了三个渲染队列,它们分别是Geomerty、AlphaTest和Transparent,区别和一般用途如下图所示:
名称 | 索引号 | 描述 |
---|---|---|
Geometry | 2000 | 默认的渲染队列,大多数物体都使用这个队列,包括不透明物体 |
AlphaTest | 2450 | 需要使用透明度测试来实现透明效果的物体使用这个渲染队列 |
Transparent | 3000 | 需要使用透明度混合来实现透明效果的物体使用这个渲染队列 |
UnityShader中有两种方法实现透明效果,也就是上面所说的透明度测试和透明度混合,它们的实现方式不同,实现的渲染效果也不相同。总结来说:
(至于什么是帧缓存,将会在后面进行解释)
接下来将会详细讲解如何在UnityShader中使用透明度测试和透明度混合,以及简单描述为什么要这么做。
透明度测试在片元着色器中进行,它基于一个判断标准,如果一个片元的某个数值低于既定的阈值,那么就将其丢弃。
这个判断标准当然可以是颜色的Alpha,例如 color是 Texture上采样的某个颜色值,那么透明度测试可以用以下的伪代码描述:
float4 color = tex2D( _MainTex, uv );
if( color.a < thrashold)
discard(); // 丢弃此片元
// 透明度测试通过,可以进行其他操作
Cg中提供了一个 clip函数,它的思想和上面一样——如果函数的参数数值小于0,那么这个片元就会被丢弃。
需要注意的是,clip的参数可以是float2、float3、float4,因此实际上是如果参数中任一分量小于0,那么这个片元就会被丢弃。如下所示,它们都会被被丢弃:
float temp0 = -1;
float2 temp1 = ( -1, 1 );
float3 temp2 = ( -1, 1, 1 );
float4 temp3 = ( -1, 1, 1, 1 );
clip( temp0 ); // 丢弃
clip( temp1 ); // 丢弃
clip( temp2 ); // 丢弃
clip( temp3 ); // 丢弃
接下来将会使用如下图进行测试,每个大色块的Alpha不尽相同:
在Shader中,我们对这张图进行采样后,取其Alpha通道的数值减去一个阈值,并作为参数传给clip函数:
代码中的_AlphaTestFactor就是阈值,它通过Proeprty的形式传入Shader中:
同时我们不能忘记要修改渲染顺序。利用Tag的形式,在Shader中将RenderQueue修改为AlphaTest:
(一般来说,透明度测试都需要包含这三个Tag)
这样以后的运行结果是,如果alpha通道小于_AlphaTestFactor,那么这个片元就会被丢弃掉。如下图所示,是将_AlphaTestFactor设置为不同的数值后的渲染结果示意图(由于alpha通道最高只有80%,因此当_AlphaTestFactor为0.9时,所有片元都被丢弃了):
实际上透明度测试的效果并不会太好,除了它要么完全渲染要么完全丢弃,还有一个问题是边缘上会由于精度的问题,而导致产生锯齿(上图中0.3、0.5、0.7如果仔细看,可以发现边缘会有部分黄色颜色残留)。
而透明度混合真正实现了透明的效果,而且效果比较平滑。
// @TODO: 这里补张运行效果图
在开始讲透明度混合前,我们先来考虑一个比较简单,也比较类似的数学问题,那就是线性插值。如果你用过Unity中的Mathf.Lerp函数,那会很容易理解。
所谓线性插值,最简单的情形就是给定一个比例(0~1)和两个数值,我们可以得到介于这两个数值的一个中间数值,如:
这不难理解,可是线性插值和混合有什么关系呢?如果把0看作是黑色,把1看作是白色的话,现在要在一张白色背景上,贴上一张黑色透明贴纸,并且黑色贴纸不透明度是30%(等价于透明度70%)。那么被黑色贴纸覆盖的区域的颜色,可以用以下公式进行计算得到:
通过上面的例子,你大概也能猜到混合的实质了。从图形学角度解释,是指将如何将自身的颜色像素作用到已经存在于帧缓存对应像素上的这么一个过程。(帧缓存和深度缓存一样,同样是二维矩阵,矩阵中每一个元素表示一个像素。如果读者不熟悉帧缓存的概念,建议查阅相关资源,这会让你更好理解混合)。
而 SrcFactor和 FBFactor即刚才所说的线性插值的比例,表示旧帧缓存、自身颜色像素占目标帧缓存的各自的比例。一般来说这会使用透明度作为这个混合的比例。
在UnityShader中,使用 Blend关键字指定混合比例:Blend ,前者表示 SrcFactor,后者表示 FBFactor。
例如 Blend One One,表示 SrcFactor和 FBFactor都是 1。
UnityShader,预先定义了以下混合比例:
混合比例关键字 | 描述 |
---|---|
Zero | 表示0 |
One | 表示 |
SrcColor | 表示每个通道的计算,使用自身像素的RGB通道作为因子 |
SrcAlpha | 表示使用自身像素的Alpha通道作为因子 |
DstColor | 表示每个通道的计算,使用旧帧缓存像素的RGB通道作为因子 |
DstAlpha | 表示使用旧帧缓存的Alpha通道作为因子 |
OneMinusSrcColor | 表示每个通道的计算,使用自身像素的 (1.0-RGB) 作为因子 |
OneMinusSrcAlpha | 表示使用自身像素的 ( 1 - Alpha ) 作为因子 |
OneMinusDstColor | 表示每个通道的计算,使用旧缓存像素的 (1.0-RGB) 作为因子 |
OneMinusDstAlpha | 表示使用旧缓存像素的 ( 1 - Alpha ) 作为因子 |
需要注意的是,如果没有显式的开启混合,UnityShader默认是关闭混合的,即等价于Blend Off,它的效果是直接覆盖掉帧缓存上的像素。
一般来说,使用 Blend SrcAlpha OneMinusSrcAlpha 就可以得到普通的透明效果了。顺带一提,通过不同的因子组合,可以得到类似于PhotoShop中不同的图层模式(正常、正片叠底、变暗、变量等)的效果(来源《UnityShade入门精要》):
- 正常: Blend SrcAlpha OneMinusSrcAlpha
- 柔和相加: Blend OneMinusDstColor One
- 正片叠底: Blend DstColor Zero
- 两倍相乘: Blend DstColor SrcColor
- 变暗: BlendOp Min Blend One One
- 变亮: BlendOp Max Blend One One
- 滤色: Blend OneMinusDstColor One/ Blend One OneMinusSrcColor
- 线性减淡: Blend One One
混合算术符 | 描述 |
---|---|
Add | 表示混合时使用加法,即 FB = Src * SrcFactor + FB * FBFactor |
Sub | 表示混合时使用减法,即 FB = Src * SrcFactor - FB * FBFactor |
RevSub | 表示混合时使用减法,但是反过来,即 FB = FB * FBFactor - Src * SrcFactor |
Min | 表示取较小的数值,即 FB = Min( Src * SrcFactor , FB * FBFactor ) |
Max | 表示取较大的数值,即 FB = Max( Src * SrcFactor , FB * FBFactor |
逻辑运算符 | 只在DirectX 11.1中支持 |
用了上面的前置准备知识以后,接下来可以开始写Shader的代码了,总体上和透明度测试的代码类似,不过这里使用了简单的bling-phong光照模型进行光照的计算。
唯一不一样的是,在片元着色器的返回值里,Alpha通道使用的是贴图采样的结果:
当然我们不要忘记设置渲染队列以及开启混合、关闭深度写入了,这里使用的混合因子是 Blend SrcAlpha OneMinusSrcAlpha:
这样以后,运行结果如下图所示,可以发现此时的透明效果还是挺棒棒的:
虽然上面的图中得到了正确的透明效果,但是可以发现没有透过透明的正方体看到背面,这是因为面片剔除导致的。
所谓面片剔除,指的是那些背对摄像机的面片,将不会进行渲染,通过减少渲染的定点数以提高渲染效率。
如何定义一个面片是否背对摄像机?如果你看过我前面一篇文章 从图形学认识Unity中的Mesh,面片是有方向的。这个方向由构成面片的索引顶点的顺序决定,例如下图中的三角形的三个顶点,不同的标准可能会有不同的方向:
而所谓背对摄像机,值这个方向和摄像机的朝向一致、而面对摄像则是指和摄像机朝的向相反,如下图所示:
UnityShader中,可以通过 Cull关键字和对应参数来控制面片剔除,如:
Cull参数 | 描述 |
---|---|
Off | 不开启面片剔除 |
Front | 剔除面向摄像机的面片 |
Back | 剔除背对摄像机的面片 |
为了修正显示效果,我们的思路是——先渲染背面的面片(Cull Front)后,再渲染正面的面片(Cull Back)。我们可以使用两个Pass来实现这个效果,这两个Pass的代码是一样的,只是一个剔除正面,一个剔除背面。
除此以外,其他的代码是一摸一样的,效果如下图所示:
你可能会问为什么不直接关闭面片剔除(Cull Off),这样可以不用两个Pass了。但是这里有一个问题是,我们渲染透明效果,是关闭了深度写入的,如果直接关闭面片剔除,那么很有可能得到错误的渲染结果,因为渲染顺序完全按照面片的先后渲染顺序,如下图所示: