[UnityShader学习笔记] UnityShader中透明效果及相关原理(深度缓存、深度测试、深度写入和面片剔除等)

前言

  • 此文章为笔者学习《UnityShader入门精要》书籍的学习笔记,因此大部分内容均出自此书;
  • 笔者尽量进行总结,并会补充相关的知识例如片元、深度缓存、帧缓存的概念;
  • 在一些地方如光照模型的计算不会过多涉及,因此需要有一定的光照和图形学基础;
  • 此文章目前没有提供源码。如果你需要源码,请在评论区或者私信我,我会准备源码后放在github上!

渲染顺序

  • 注意:
  • 这部分内容属于理论部分,如果你只想知道如何在UnityShader中实现透明效果,此部分可以省略;
  • 但是为了更好的理解透明效果的原理,笔者认为这部分理论知识是需要掌握的;

如果你曾经使用过绘图软件进行绘画,同一个图层下,如果使用不透明的画笔进行绘画,那么下一笔绘制的肯定会覆盖掉前一笔绘制过的东西。由此可见,绘制顺序在绘制中起到的作用。

图形学中,也需要考虑这个绘制顺序,不然即使A物体在B物体的后面,如果按照先绘制B后绘制A的话,仍然会出现A遮挡住B的情况出现。如下图所示,AB两个物体,会有两种渲染顺序,且它们的渲染结果是截然不同的:
渲染AB两个物体,有两种渲染顺序
然而在渲染管线中,一般是不需要刻意注意绘制顺序的,这是因为GPU中有一个称为深度缓存(z-buffer) 的东西。

深度缓存、深度测试和深度写入

o 深度缓存

所谓深度缓存,和帧缓存(frame-buffer) 一样,是一个二维纹素矩阵,只是上面存储的不是RGBA颜色值,而只存储了一个深度值(z)。

这个深度缓存是为了深度测试(depth-test)服务的,深度测试是片元测试中的其中一部分,发生在渲染管线中片元着色器后面(感兴趣可以了解渲染管线,了解顶点渲染到输出的过程会对图形学的学习有很大帮助)。

o 深度测试

所谓深度测试,简单来说就是将当前需要绘制的内容,和已经绘制的内容的深度进行比较——如果需要绘制的离得更远,就丢弃,否则不丢弃。
假设z越小表示离得越近,将当前要渲染的片元的深度值 Zsrc,与深度缓存中相同位置的深度值 Zdst进行比较,如果:

  • Zsrc > Zdst,说明当前片元更远,被遮挡,即片元被丢弃
  • Zsrc <= Zdst,说明当前偏远更近,可以被渲染

(至于什么是片元,会在后面进行补充)

o 深度写入

最后就是深度写入,顾名思义,就是将一个片元的深度值写入深度缓存当中。
至于写入的时机,一般就是这个片元通过深度测试的时候,也就是说当此片元离得更近时,会将此片元的深度值更新到深度缓存当中。

片元(补充部分)

渲染管线中,当顶点经过图元装配、光栅化以后,得到的内容称为片元(fragment)。片元最后会成为像素,但不是全部的片元都会成为像素,换一句话说,片元有可能会被丢弃。
至于为什么会被丢弃,前面已经说过其中一个原因了,那就是没有通过深度测试。当然深度测试只是片元测试中的一个部分,其他的还有透明度测试等。如下图所示:
片元测试示意图

透明物体对渲染顺序的影响

虽然前面说过,因为有神奇的深度缓存,因此一般来说不用考虑渲染顺序的问题,因为片元会根据自己的深度和已有的深度进行比较后自动丢弃,但这是针对不透明物体而言的。一旦场景中有不透明物体,那么深度缓存、深度测试就“不灵”了。

为什么这么说呢?我们可以进行场景假设,模拟一下实际的渲染。

不透明物体+半透明物体

假设场景中,有物体A和B,且假定A在B的前面,其中A是半透明物体B是不透明物体,如下图所示(其中灰色条纹是背景图,方便效果显式):

在这里插入图片描述


  • 如果我们先渲染B,后渲染A,那么流程是这样的(假定深度缓存为空):
  1. 渲染不透明物体B,进行深度测试;
  2. 由于此时深度缓存为空,所有的片元都通过了深度测试,可以进行渲染、同时将B的深度写入深度缓存中;
  3. 渲染半透明物体A,进行深度测试,从深度缓存中没有发现比自己更靠近的物体,因此A可以进行渲染;

那么渲染结果如下图所示,这样绘制的结果(先绘制不透明物体,后绘制透明物体)是正确的:
在这里插入图片描述


  • 如果我们先渲染A,后渲染B,那么流程是这样的(假定深度缓存为空):
  1. 渲染半透明物体A,进行深度测试;
  2. 由于此使深度缓存为空,所有的片元都通过了深度测试,可以进行渲染,同时将A的深度写入深度缓存中;
  3. 渲染不透明物体B,进行深度测试,从深度缓存中发现已经有比自己更近的物体(即A),因此丢弃物体A;

(*注: 这里的“物体A”丢弃并不完全准确,因为深度测试是以片元为单位的,但是读者可以认为这里所有A的片元都被遮挡住,被丢弃了)

那么渲染结果如下图所示,从渲染效果上看,仿佛就不存在物体A一样:
在这里插入图片描述

但我们希望的渲染效果是能透过半透明物体A看到后面的物体B。出现这样的原因,是因为错误的深度测试所造成的,也就是说因为只依赖于深度缓存而不管渲染顺序,就会出现这样的情况。

对于渲染顺序,除非引擎内部进行了排序(如 cocos2d-x 3.0以后,会按照局部z和全局z坐标进行一个排序后,再进行渲染),ff否则一般就会按照物体的访问顺序进行渲染,因此上面先渲染A后渲染B是完全有可能的。

从这个场景的两种渲染顺序,我们可以得到以下两个,可以正确渲染场景的结论:

  • 透明物体的渲染,要在不透明物体的渲染以后,也就是说所先渲染不透明物体后,再渲染透明物体;
  • 对于透明物体的渲染,需要关闭深度写入,否则会出现渲染错误(但是仍然开启深度测试)

半透明物体+半透明物体

你可能仍然会怀疑上面的第二条结论,即透明物体只要在不透明物体以后进行渲染,好像都可以得到正确的结果?
但是一旦涉及到透明效果,问题就会变得非常复杂。我们来考虑另外一个场景,这个场景只有透明物体。

假设场景中有物体A和B,物体A、B都是透明物体,且物体A在物体B的前面,开启深度写入互不重叠(至于为什么强调不重叠,后面会进行说明)。

  • 如果我们先渲染B,后渲染A,那么渲染流程和前面是一样的,而且渲染结果正确,因此略过;
  • 如果我们先渲染A,后渲染B,那么渲染流程仍然和前面是一样的,并且不出意外的话,透明物体A遮挡住了透明物体B;

真糟糕,我们应该能透过A看到B的。从这个结果你可能会说,如果我们按照透明物体从远到近的顺序进行绘制(也就是先A后B),不也可以不用关闭深度写入,从而有正确的渲染结果吗?

然而当透明物体出现重叠时,问题又又又又出现了,如下图所示,透明物体A和透明物体B部分部分重叠,导致物体A有部分被B遮挡住了(方便起见,我们将这部分命名为A1):
在这里插入图片描述

如果按照前面所说的,我们先渲染远物体再渲染近物体,假定远的物体是B,近的物体是A(这里有一些问题,后面会解释),同样开启深度写入的话,那么渲染流程如下所示:

  1. 渲染远的透明物体B,可以正确渲染,同时将B的深度信息写入深度缓存中;
  2. 渲染近的透明物体A,对于没有被B遮挡的片元部分,可以正常进行绘制;而对于A1,由于深度缓存当中有比自己离得更近的深度信息,丢弃;

这导致的渲染结果就是,A1被丢弃了,明显不是我们想要的渲染结果,A1应该可以透过B渲染得到:
在这里插入图片描述
因此渲染透明物体,我们不得不关闭强大的深度写入。但是即使关闭了深度写入,针对刚才的这个重叠问题,由于我们是先绘制B后绘制A,这样的渲染结果会造成另外的问题是——视觉效果上A1在B前面。如下图所示:
在这里插入图片描述

出现这样的渲染结果,究其原因是因为前面提到的,我们不能准确定义不透明物体的远近关系。如刚才的问题,下图中重叠的物体A和B,从不同的参考角度,可以得到不同的远近关系:
在这里插入图片描述

换一句说,渲染顺序是以图元(Primitive)为单位的,但是深度测试和深度写入却是以片元为单位

总而言之,对于透明物体的渲染,我们不能给出一个完美的、可以兼顾所有情况的解决方案。《UnityShader入门精要》中提到了渲染引擎中,一般采取的方法是:

(1)先渲染所有不透明物体,并且开启深度写入和深度测试;
(2)把透明物体按它们距离摄像机的远近进行排序,然后按照从远到近的顺序渲染这些透明物体,开启深度测试,但是关闭深度写入

渲染顺序总结

从渲染管线的角度上看,渲染透明物体是一件非常复杂的事情,由于涉及的情况较多,因此并不能有完美的解决方案。上面所说的场景情况可能比较难理解,需要理清图元、片元和深度缓存之间的关系

总而言之,对于渲染不透明物体和透明物体,笔者有以下的总结:

  • 不透明物体的渲染,尽管使用深度写入和深度测试,都可以保证有正确的渲染结果;
  • 透明物体的渲染,必须关闭深度写入,但是保持使用深度测试(这是因为第一步渲染的不透明物体可以遮挡透明物体);
  • 透明物体的渲染还要按照从远到近的顺序进行渲染;
  • 但是即使如此,仍然会有不可避免的渲染问题出现;

(*注:笔者在学习这方面的知识的时候也困惑了比较长的时间。当然上面所说的不一定就是正确的,如果有不正确的,或者迷糊不清的地方,请通过评论或者私信的方式告诉我,也欢迎大家一起讨论!)


UnityShader中的透明效果

接下来这部分是关于如何在Unity中实现透明效果。
注意:这里假定读者有一定的UnityShader(或者OpenGL、D3D等图形接口的Shader)的编写经验,所以只会给出关键部分的Shader代码

渲染队列RenderQueue

前面提到过渲染顺序是较为重要的事情,一般引擎会采用以下方法处理透明、不透明物体的渲染:

(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();						// 丢弃此片元
// 透明度测试通过,可以进行其他操作

clip函数

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,给定比例0.3,这个比例意思是——0占三成,1占七成(即1-0.3的结果),用数学公式来说如下:
  • 0 * 0.3 + 1 * ( 1 - 0.3 ) = 0.7——0 和1 在比例 0.3下线性插值得到了中间数值0.7

这不难理解,可是线性插值和混合有什么关系呢?如果把0看作是黑色,把1看作是白色的话,现在要在一张白色背景上,贴上一张黑色透明贴纸,并且黑色贴纸不透明度是30%(等价于透明度70%)。那么被黑色贴纸覆盖的区域的颜色,可以用以下公式进行计算得到:

  • 0(黑色) * 0.3 + 1(白色) * 0.7 = 0.7(灰色),渲染结果如下图所示:
    在这里插入图片描述

通过上面的例子,你大概也能猜到混合的实质了。从图形学角度解释,是指将如何将自身的颜色像素作用到已经存在于帧缓存对应像素上的这么一个过程。(帧缓存和深度缓存一样,同样是二维矩阵,矩阵中每一个元素表示一个像素。如果读者不熟悉帧缓存的概念,建议查阅相关资源,这会让你更好理解混合)。

用数学公式进行描述的话,如下所示:
在这里插入图片描述

而 SrcFactor和 FBFactor即刚才所说的线性插值的比例,表示旧帧缓存、自身颜色像素占目标帧缓存的各自的比例。一般来说这会使用透明度作为这个混合的比例。

Shader中的Blend关键字

在UnityShader中,使用 Blend关键字指定混合比例:Blend <A> <B>,前者表示 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
  • 读者会留意到上面的变暗/变量使用了新的关键字 BlendOp,这个关键字将会指定混合使用的算术运算符,默认是加法。支持的算术符有:
混合算术符 描述
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中支持
  • 除了Blend <A> <B> 的形式,还有一种是Blend <A> <B> <C> <D>,其中 A和 B描述颜色的RGB通道的混合因子,C和 D描述颜色Alpha通道的混合因子,如 Blend SrcAlpha OneMinusSrcAlpha One Zero表示:
  • FB.rgb = Src.rgb * Src.a + FB.rgb * ( 1 - Src.a )
  • FB.a = Src.a * 1 + FB.a * 0

在Shader中利用Blend实现混合

用了上面的前置准备知识以后,接下来可以开始写Shader的代码了,总体上和透明度测试的代码类似,不过这里使用了简单的bling-phong光照模型进行光照的计算。
唯一不一样的是,在片元着色器的返回值里,Alpha通道使用的是贴图采样的结果:
在这里插入图片描述
当然我们不要忘记设置渲染队列以及开启混合、关闭深度写入了,这里使用的混合因子是 Blend SrcAlpha OneMinusSrcAlpha:
在这里插入图片描述
在这里插入图片描述
这样以后,运行结果如下图所示,可以发现此时的透明效果还是挺棒棒的:
在这里插入图片描述


使用面片剔除修正显示效果

虽然上面的图中得到了正确的透明效果,但是可以发现没有透过透明的正方体看到背面,这是因为面片剔除导致的。

什么是面片剔除

所谓面片剔除,指的是那些背对摄像机的面片,将不会进行渲染,通过减少渲染的定点数以提高渲染效率。
如何定义一个面片是否背对摄像机?如果你看过我前面一篇文章 从图形学认识Unity中的Mesh,面片是有方向的。这个方向由构成面片的索引顶点的顺序决定,例如下图中的三角形的三个顶点,不同的标准可能会有不同的方向:
在这里插入图片描述

  • 需要注意的是,Unity中使用的是左手螺旋(可以自己通过定义一个简单的三角形Mesh进行测试,这里就不作介绍了)。

而所谓背对摄像机,值这个方向和摄像机的朝向一致、而面对摄像则是指和摄像机朝的向相反,如下图所示:
在这里插入图片描述

修改面片剔除修正显示效果

UnityShader中,可以通过 Cull关键字和对应参数来控制面片剔除,如:

Cull参数 描述
Off 不开启面片剔除
Front 剔除面向摄像机的面片
Back 剔除背对摄像机的面片

为了修正显示效果,我们的思路是——先渲染背面的面片(Cull Front)后,再渲染正面的面片(Cull Back)。我们可以使用两个Pass来实现这个效果,这两个Pass的代码是一样的,只是一个剔除正面,一个剔除背面。
在这里插入图片描述
在这里插入图片描述
除此以外,其他的代码是一摸一样的,效果如下图所示:
在这里插入图片描述

你可能会问为什么不直接关闭面片剔除(Cull Off),这样可以不用两个Pass了。但是这里有一个问题是,我们渲染透明效果,是关闭了深度写入的,如果直接关闭面片剔除,那么很有可能得到错误的渲染结果,因为渲染顺序完全按照面片的先后渲染顺序,如下图所示:
在这里插入图片描述

发布了4 篇原创文章 · 获赞 0 · 访问量 467

猜你喜欢

转载自blog.csdn.net/Arkish/article/details/99675324