透明效果(一)
本文最后更新于:2025年4月17日 下午
透明效果
在实时渲染中要实现透明效果,通常会在渲染模型时控制它的透明通道(Alpha Channel)。当开启透明混合后,当一个物体被渲染到屏幕上时,每个片元除了颜色值和深度值之外,它还有另一个属性——透明度。当透明度为 1 时,表示该像素是完全不透明的,而当其为 0 时,则表示该像素完全不会显示。
在 Unity 中,我们通常使用两种方法来实现透明效果:第一种是使用透明度测试(Alpha Test),这种方法其实无法得到真正的半透明效果;另一种是透明度混合(Alpha Blending)。
对于不透明(opaque)物体,不考虑它们的渲染顺序也能得到正确的排序效果,这是由于强大的深度缓冲(depth buffer,也被称为 z-buffer)的存在。在实时渲染中,深度缓冲是用于解决可见性(visibility)问题的,它可以决定哪个物体的哪些部分会被渲染在前面,而哪些部分会被其他物体遮挡。它的基本思想是:根据深度缓存中的值来判断该片元距离摄像机的距离,当渲染一个片元时,需要把它的深度值和已经存在于深度缓冲中的值进行比较(如果开启了深度测试),如果它的值距离摄像机更远,那么说明这个片元不应该被渲染到屏幕上(有物体挡住了它);否则,这个片元应该覆盖掉此时颜色缓冲中的像素值,并把它的深度值更新到深度缓冲中(如果开启了深度写入)。
使用深度缓冲,可以让我们不用关心不透明物体的渲染顺序,例如 A 挡住 B,即便我们先渲染 A 再渲染 B,也不用担心 B 会遮盖掉 A,因为在进行深度测试时会判断出 B 距离摄像机更远,也就不会写入到颜色缓冲中。但如果想要实现透明效果,事情就不那么简单了,这是因为,当使用透明度混合时,我们关闭了深度写入(ZWrite)。
简单来说,透明度测试和透明度混合的基本原理如下。
-
透明度测试:它采用一种“霸道极端”的机制,只要一个片元的透明度不满足条件(通常是小于某个阈值),那么它对应的片元就会被舍弃。被舍弃的片元将不会再进行任何处理,也不会对颜色缓冲产生任何影响;否则,就会按照普通的不透明物体的处理方式来处理它,即进行深度测试、深度写入等。也就是说,透明度测试是不需要关闭深度写入的,它和其他不透明物体最大的不同就是它会根据透明度来舍弃一些片元。虽然简单,但是它产生的效果也很极端,要么完全透明,即看不到,要么完全不透明,就像不透明物体那样。
-
透明度混合:这种方法可以得到真正的半透明效果。它会使用当前片元的透明度作为混合因子,与已经存储在颜色缓冲中的颜色值进行混合,得到新的颜色。但是,透明度混合需要关闭深度写入,这使得我们要非常小心物体的渲染顺序。需要注意的是,透明度混合只关闭了深度写入,但没有关闭深度测试。这意味着,当使用透明度混合渲染一个片元时,还是会比较它的深度值与当前深度缓冲中的深度值,如果它的深度值距离摄像机更远,那么就不会再进行混合操作。这一点决定了,当一个不透明物体出现在一个透明物体的前面,而我们先渲染了不透明物体,它仍然可以正常地遮挡住透明物体。也就是说,对于透明度混合来说,深度缓冲是只读的。
为什么渲染顺序很重要
使用透明度混合技术时,为什么要关闭深度写入呢?如果不关闭深度写入,一个半透明表面背后的表面本来是可以透过它被我们看到的,但由于深度测试时判断结果是该半透明表面距离摄像机更近,导致后面的表面将会被剔除,我们也就无法透过半透明表面看到后面的物体了。但是,我们由此就破坏了深度缓冲的工作机制,而这是一个非常非常非常糟糕的事情,尽管我们不得不这样做。关闭深度写入导致渲染顺序将变得非常重要。
我们来考虑最简单的情况。假设场景里有两个物体 A 和 B,如下图所示,其中 A 是半透明物体,而 B 是不透明物体。
我们来考虑不同的渲染顺序会有什么结果。
-
第一种情况,我们先渲染 B,再渲染 A。那么由于不透明物体开启了深度测试和深度检验,而此时深度缓冲中没有任何有效数据,因此 B 首先会写入颜色缓冲和深度缓冲。随后我们渲染 A,透明物体仍然会进行深度测试,因此我们发现和 B 相比,A 距离摄像机更近,因此,我们会使用 A 的透明度来和颜色缓冲中的 B 颜色进行混合,得到正确的半透明效果。
-
第二种情况,我们先渲染 A,再渲染 B。渲染 A 时,深度缓冲区中没有任何有效数据,因此 A 直接写入颜色缓冲,但由于对半透明物体关闭了深度写入,因此 A 不会修改深度缓冲。等到渲染 B 时,B 会进行深度测试,它发现深度缓存中还没有数据,那么它就写入颜色缓冲了,结果就是 B 会直接覆盖 A 的颜色。从视觉上来看,B 就出现在了 A 的前面,而这是错误的。
从这个例子可以看出,当关闭了深度写入后,渲染顺序多么重要。由此我们知道,我们应该在不透明物体渲染完之后再渲染半透明物体。如果都是半透明物体,渲染顺序依然重要。还是假设场景里有两个物体 A 和 B,如下图所示,其中 A 和 B 都是半透明物体。
我们还是考虑不同的渲染顺序有什么不同结果。
-
第一种情况,我们先渲染 B,再渲染 A。那么 B 会正常写入颜色缓冲,然后 A 会和颜色缓冲中的 B 颜色进行混合,得到正确的半透明效果。
-
第二种情况,我们先渲染 A,再渲染 B。那么 A 会先写入颜色缓冲,随后 B 会和颜色缓冲中的 A 进行混合,这样混合结果会完全反过来,看起来就好像 B 在 A 的前面,得到的就是错误的半透明结构。
从这个例子可以看出,半透明物体之间也是要符合一定的渲染顺序的。基于这两点,渲染引擎一般都会先对物体进行排序,再渲染。常用的方法是。
-
先渲染所有不透明物体,并开启它们的深度测试和深度写入。
-
把半透明物体按他们距离摄像机的远近进行排序,然后按照从后往前的顺序渲染这些半透明物体,并开启它们的深度测试,但关闭深度写入。
但现在问题仍然没有完全解决,在一些情况下,半透明物体的渲染还是会出现问题。这是因为第二步中的渲染顺序仍然含糊不清,若距离摄像机的远近是由距离摄像机的深度值决定的,但深度缓冲中的值都是像素级别的,每个像素都有一个深度值,如果我们现在对单个物体级别进行排序,那么这意味着排序结果是:要么物体 A 全部在 B 前面渲染,要么 A 全部在 B 后面渲染。但如果存在循环重叠的情况,那么使用这种方法就永远无法得到正确的结果。下图给出了 3 个物体循环重叠的情况。
在上图中,由于 3 个物体互相重叠,我们不能得到一个正确的排序顺序。这种时候,我们可以选择把物体拆分为两个部分,然后在进行正确的排序。但即便我们通过分割的方法解决了循环覆盖的问题,还是会有其他情况发生,比如下图中的情况。
这里的问题是:如何进行排序?我们知道,一个物体的网格结构往往占据了空间中的某一块区域,也就是说,这个网格上每一个点的深度值可能都是不一样的。我们无论选择网格中点、最远的点还是最近的点的深度值来作为整个物体的深度值和其他物体进行排序,都会得到错误的结果。我们的排序结果总是 A 在 B 的前面,但实际上 A 有一部分被 B 遮挡了。这也意味着,一旦选定了一种判断方式后,在某些情况下半透明物体之间一定会出现错误的遮挡问题。这种问题的解决方法通常也是分割网格。
尽管这样做总会出现一些意外情况,但由于上述方法足够有效且容易实现,因此大多数游戏引擎都使用了这种方法。为了减少错误排序的情况,我们可以尽可能让模型是凸面体,并且考虑将复杂模型拆分成可以独立排序的多个子模型等。其实就算排序错误结果有时也不会非常糟糕,如果不想分割网格,可以试着让透明通道更加柔和,使穿插看起来不是那么明显。我们也可以使用开启了深度写入的半透明效果来近似模拟物体的半透明。
下面我们来看一下 Unity 如何解决排序问题。
Unity Shader 的渲染顺序
Unity 为了解决渲染顺序的问题提供了**渲染队列(render queue)**这一解决方案。我们可以使用 SubShader 的 Queue 标签来决定我们的模型将归于哪个渲染队列。Unity 在内部使用一系列整数索引来表示每个渲染队列,且索引号越小表示越早被渲染。
名称 | 队列索引号 | 描述 |
---|---|---|
Background | 1000 | 这个渲染队列会在任何其他队列之前被渲染,我们通常使用该队列来渲染那些需要绘制在背景上的物体 |
Geometry | 2000 | 默认的渲染队列,大多数物体都使用这个队列。不透明物体使用这个队列 |
AlphaTest | 2450 | 需要透明度测试的物体使用这个队列。在 Unity5 中从 Geometry 队列中被单独分离出来,这是因为在所有不透明物体渲染之后再渲染它们会更加高效 |
Transparent | 3000 | 这个队列中的物体会在所有 Geometry 和 AlphaTest 物体渲染后,再按从后往前的顺序进行渲染。任何使用了透明度混合(例如关闭了深度写入的 Shader)的物体都应该使用该队列 |
Overlay | 4000 | 该队列用于实现一些叠加效果。任何需要在最后渲染的物体都应该使用该队列 |
因此,如果我们想要通过透明度测试实现透明效果,代码中应该包含类似下面的代码:
1 |
|
如果我们想要通过透明度混合来实现透明效果,代码中应该包含类似下面的代码:
1 |
|
其中,ZWrite Off用于关闭深度写入,在这里我们选择把它写在 Pass 中。我们也可以把它写在 SubShader 中,这意味着该 SubShader 下所有 Pass 都会关闭深度写入。
透明度测试
透明度测试:只要一个片元的透明度不满足条件(通常是小于某个阈值),那么它对应的片元就会被舍弃。被舍弃的片元将不会再进行任何处理,也不会对颜色缓冲产生任何影响;否则,就会按照普通的不透明的物体的处理方式来处理它。
通常我们会在片元着色器中使用 clip 函数来进行透明度测试。clip 是 CG 中的一个函数,它的定义如下。
函数:void clip(float4 x); void clip(float3 x); void clip(float2 x); void clip(float1 x); void clip(float x);
参数:裁剪时使用的标量或矢量条件。
描述:如果给定参数的任何一个分量是负数,就会舍弃当前像素的输出颜色。它等同于下面的代码:
1 |
|
我们将用下面这张图来实现透明度测试。
1 |
|
效果如下图所示:
Unity 中透明度测试使用的渲染队列是名为 AlphaTest 的队列,因此我们需要把 Queue 标签设置成 AlphaTest。而 RenderType 标签可以让 Unity 把这个 Shader 归入到提前定义的组(这里就是 TransparentCutout 组)中,以指明该 Shader 是一个使用了透明度测试的 Shader。RenderType 标签通常被用于着色器替换功能。我们还把 IgnoreProjector 设置为 True,这意味着这个 Shader 不会受到投影器(Projectors)的影响。通常,使用了透明度测试的 Shader 都应该在 SubShader 中设置这三个标签。
前面我们已经提到过 clip 函数的定义,它会判断它的参数,即 texColor.a - _Cutoff 是否为负数,如果是就会舍弃该片元的输出。也就是说,当 texColor.a 小于材质参数 _Cutoff 时,该片元就会产生完全透明的效果。使用 clip 函数等同于先判断参数是否小于零,如果是就使用 discard 指令来显示剔除该片元。材质面板中的 Alpha cutoff 参数用于调整透明度测试时使用的阈值,当纹理像素的透明度小于该值时,对应的片元就会被舍弃。当我们逐渐调大该值时,立方体上的网格会逐渐消失。如下图所示。
和之前使用的 Diffuse 和 Specular 不同,这次我们使用内置的 Transparent/Cutout/VertexLit 来作为回调 Shader。这不仅能够保证在我们编写的 SubShader 无法在当前显卡上工作时可以有合适的代替 Shader,还可以保证使用透明度测试的物体可以正确地向其他物体投射阴影。
从上边的图可以看出,透明度测试得到的透明效果很“极端”——要么完全透明,要么完全不透明,它的效果往往像在一个不透明物体上挖了一个空洞。而且,得到的透明效果往往在边缘处参差不齐,有锯齿,这是因为在边界处纹理的透明度的变化精度问题。为了得到更加柔滑的透明效果,就可以使用透明度混合。
透明度混合
透明度混合:这种方法可以得到真正的半透明效果。它会使用当前片元的透明度作为混合因子,与已经存储在颜色缓冲中的颜色值进行混合,得到新的颜色。但是,透明度混合需要关闭深度写入,这使得我们要非常小心物体的渲染顺序。
为了进行混合,我们需要使用 Unity 提供的混合命令——Blend。Blend 是 Unity 提供的设置混合模式的命令。想要实现半透明的效果就需要把当前自身的颜色和已经存在于颜色缓冲中的颜色值进行混合,混合时使用的函数就是由该指令决定的。下表给出了 Blend 命令的语义。
语义 | 描述 |
---|---|
Blend Off | 关闭混合 |
Blend SrcFactor DstFactor | 开启混合,并设置混合因子。源颜色(该片元产生的颜色)会乘以 SrcFactor,而目标颜色(已经存在于颜色缓存的颜色)会乘以 DstFactor,然后把两者相加后在存入颜色缓冲中 |
Blend SrcFactor DstFactor, SrcFactorA DstFactorA | 和上面几乎一样,只是使用不同的因子来混合透明通道 |
BlendOp BlendOperation | 并非是把源颜色和目标颜色简单相加后混合,而是使用 BlendOperation 对它们进行其他操作 |
我们下面会使用 Blend SrcFactor DstFactor 来进行混合,这个命令在设置混合因子的同时也开启了混合模式。这是因为开启了混合之后,设置片元的透明通道才有意义,而 Unity 在我们使用 Blend 命令的时候就自动帮我们打开了。我们会把源颜色的混合因子 SrcFactor 设为 SrcAlpha,而目标颜色的混合因子 DstFactor 设为 OneMinusSrcAlpha。这意味着,经过混合后新的颜色是:
$$
DstColor_{new} = SrcAlpha \times SrcColor + (1 - SrcAlpha) \times DstColor_{old}
$$
我们仍然使用透明度测试用的纹理图。
1 |
|
效果如下图所示,我们可以调节材质面板上的 Alpha Scale 参数,以控制整体透明度。
我们在前面解释了由于关闭深度写入带来的各种问题。当模型本身有复杂的遮挡关系或是包含了复杂的非凸网格的时候,就会有各种各样因为排序错误而产生的错误的透明效果。下图给出了使用上面的 Unity Shader 渲染的有复杂遮挡关系模型的效果。
这都是由于我们关闭了深度写入造成的,因为这样我们无法对模型进行像素级别的深度排序。前面我们提到了一种解决方法是分割网格,从而得到一个“质量优等”的网格。但是很多情况下这往往是不切实际的。这时,我们可以想办法重新利用深度写入,让模型可以像半透明物体一样进行淡入淡出。这就是开启深度写入的半透明效果。
参考
《Unity Shader入门精要》