Unity 渲染原理(十二)透明效果与渲染队列

透明是游戏中经常使用的一种场景,在实时渲染中要实现透明效果,通常会在渲染模型时控制它的透明通道。当开启透明混合后,当一个物体被渲染到屏幕上,每个片元除了颜色值和深度值之外,它还有另一个属性,透明度。当透明度为1时,表示该像素完全不透明,当为0时,表示该像素完全不会显示。

在Unity中,我们通常使用两种方法来实现透明效果:第一种是使用透明度测试(Alpha Test),这种方法其实无法得到真正的半透明效果;另一种是透明度混合(Alpha Blending)。

对于不透明物体来说,我们并没有考虑是先渲染A,再渲染B,最后再渲染C,还是按照其他顺序来渲染。事实上,对于不透明物体,不考虑它们的渲染顺序也能得到正确排序效果,这是由于强大深度缓冲(depth-buffer,也称为z-bufer)的存在。在实时渲染,深度缓冲是用于解决可见性问题,它可以决定哪个物体的那些部分会被渲染在前面,而哪些部分会被遮挡。

它的基本思想是:根据深度缓存中的值来判断该片元距离摄像机的距离,当渲染一个片元时,需要把它的深度值和已经存在于深度缓冲中的值进行比较,如果它的值距离摄像机更远,那么说明这片元不应该被渲染到屏幕上;否则,这个片元应该覆盖掉此时的颜色缓冲中的像素值,并把它的深度值更新到深度缓冲中(如果开启了深度写入)。

但是如果我们想要实现透明效果,事情就比较麻烦了,因为一旦我们开启了透明度混合,我们就关闭了深度写入(ZWrite)。简单来说,透明度测试和透明度混合的基本原理如下:

  • 透明度测试:只要一个片元的透明度不满足条件(通常是小于某个阈值),那么它对应的片元就会被舍弃。被舍弃的片元将不会再进行任何处理,也不会对颜色缓冲产生任何影响;否则,就会按照普通的不透明物体的处理方式来处理它,即进行深度测试,深度写入等。也就是说,透明度测试是不需要关闭深度写入的,它和其他不透明物体最大的不同就是它会根据透明度来舍弃一些片元,虽然简单,但是产生的效果也很极端,要么完全透明,即完全看不到,要么完全不透明,就像不透明物体那样。
  • 透明度混合:这种方法可以得到真正的半透明效果,它会使用当前的片元透明度作为混合因子,与已经存储在颜色缓冲中的颜色值进行混合,得到新的颜色。但是,透明度混合需要关闭深度写入,这使得我们要非常小心物体的渲染顺序。需要注意的是,透明度混合只是关闭了深度写入,但是没有关闭深度测试。这意味着,当使用透明度混合渲染一个片元的时,还是会比较它的深度值与当前深度缓冲中的深度值,如果它的深度值距离摄像机更远,那么就不会再进行混合操作。这一点决定了,当一个不透明物体出现在一个透明物体的前面,而我们先渲染了不透明物体,他仍然可以正常地遮挡住透明物体。也就是说,对于透明度混合来说,深度缓冲是只读的。

为什么渲染顺序很重要

对于透明度混合技术,需要关闭深度写入,此时就需要我们小心处理透明物体的渲染顺序。那么,为什么要关闭深度写入呢?

如果不关闭深度写入,一个半透明物体背后的表面本来是可以透过它被我们看到的,但由于深度测试时判断结果是该半透明表面距离摄像机更近,导致后面的表面将会被剔除,我们也就无法通过半透明表面看到后面的物体了。

我们来考虑最简单的情况,假设场景里有两个物体A和B,其中A时半透明物体,B是不透明物体,A距离摄像机更近。

我们来考虑不同的渲染顺序会有什么结果:

  • 第一种情况,首先先渲染B,再渲染A。那么由于不透明物体开启了深度测试和深度检验,此时深度缓冲中还没有任何的有效数据,因此B会首先写入颜色缓冲和深度缓冲。随后我们在渲染A,透明物体仍然会进行深度测试,因为我们发现A距离摄像机更近,因此我们会使用A的透明度和颜色缓冲中B的颜色进行混合,得到正确的半透明效果。
  • 第二种情况,我们先渲染A,再渲染B。渲染A时,深度缓冲区没有任何有效的数据,因此A直接写入颜色缓冲,但由于对半透明物体关闭了深度写入,因此A不会修改深度缓冲。等渲染B时,B会进行深度测试,然后发现深度缓存中还没有任何值,就会直接吸入颜色缓冲,那么B会直接覆盖A的颜色,从视觉上来看,B就出现在了A的前面,而这是错误的。

从这例子可以看出,当关闭了深度写入后,渲染顺序是有多么重要。由此我们知道,我们应该在不透明物体渲染完之后再渲染半透明物体。那么,如果都是半透明物体,渲染顺序还重要吗?答案是肯定的,我们还是假设场景中有两个物体A和B,都是半透明物体,A距离摄像机更近。

我们再来考虑不同的渲染顺序有什么不同的结果:

  • 第一种情况,渲染B后再渲染A。那么B正常写入颜色缓冲,然后A会和颜色缓冲中的B颜色进行混合,得到正确的半透明效果。
  • 第二种情况,先渲染A再渲染B。那么A会先写入颜色缓冲,随后B会和颜色缓冲中的A进行混合,这样的混合结果就会完全反过来,看起来好像B在A的前面,得到的就是错误的半透明结构。

从这例子可以看出,半透明物体之间也是要符合一定的渲染顺序的。

基于这两点,渲染引擎一般会对物体进行排序,再渲染。常用的方法是:

  1. 先渲染所有的不透明物体,并开启它们的深度测试和深度写入。
  2. 把半透明物体按他们距离摄像机的远近进行排序,然后安好从后往前的顺序渲染这些半透明物体,并开启它们的深度测试,但关闭深度写入。

那么问题解决了吗?不幸的是,仍然没有。在一些情况下,半透明物体还是会出现穿帮的镜头。如果我们仔细想一下,上面第二步中的渲染顺序仍然是含糊不清的——“按照他们距离摄像机远近进行排序”,那么这个远近是怎么决定的呢?大家可能觉得是摄像机的深度值。但是深度缓冲是像素级别的,即每个像素都有一个深度值,但是现在我们对单个物体进行排序,这意味着排序结果是,要么A物体全部在B前面,要么A全部在B后面,如果存在循环重叠的情况,这种方法永远无法得到正确结果

如上图所示,三个物体相互重叠,我们不可能得到一个正确的排序顺序,这种时候,我们可以选择把物体拆分成两个部分,然后再进行正确的排序,这时我们可以选择把物体拆分成两个部分,再进行排序。

但即使我们通过分割方法解决了循环覆盖问题,还是有其他情况,如上图的右图所示。我们知道,一个物体的网格结构往往占据了空间中的某一块区域,也就是说,这个网格上每一个点的深度值都可能是不一样的,我们选择哪个深度值作为整个物体的深度值呢?是选择网格中点,最远的点,还是最近的点?无论是哪一种,在上图这种情况下都是错误的。

这意味着,一旦选择某种判定方式,在某些情况下,半透明物体之间一定会出现错的遮挡关系,这种问题的解决方案通常也是分隔网络。

尽管总是会出现某些情况导致错误的结果,分割网路也能帮助我们解决大部分问题,大部分引擎也是如此实现的。当然为了尽量减少错误排序,我们应该尽可能让模型是凸面体,并考虑将复杂的模型拆分为可以独立排序的多个子模型等。

Unity Shader的渲染顺序

Unity为了解决渲染顺序,提供了渲染队列(Render Queue),我们可以使用SubShader的Queue标签来决定我们的模型归于哪个渲染队列,具体可以参考官方文档:https://docs.unity3d.com/ScriptReference/Rendering.RenderQueue.html

透明度测试

定义:只要有一个片元的透明度不满足条件,那么它对应的片元就会被舍弃,被舍弃的片元将不会再进行任何处理,也不会对颜色缓冲产生影响;否则就会按照普通的不透明物体处理它。

通常,我们会在片元着色器中使用clip函数来进行透明度测试。clip是CG中的一个函数,它的定义如下:

函数:void clip(float4 x);void clip(float3 x);void clip(float2 x);void clip(float1 x);void clip(float4 x)
参数:裁剪时使用的标量或矢量条件
描述:如果给定参数的任何一个分量是负数,就会舍弃当前像素的输出颜色

我们写一个简单的Shader来使用这个clip函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
Shader "Unlit/AlphaTest"
{
Properties
{
_Color("Main Tint", Color) = (1,1,1,1)
_MainTex ("Texture", 2D) = "white" {}
_Cutoff("Alpha Cutoff", Range(0,1)) = 0.5
}
SubShader
{
Tags { "Queue"="AlphaTest" "IgnoreProjector"="True" "RenderType"="TransparentCutout" }
LOD 100

Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
// make fog work
#pragma multi_compile_fog

#include "UnityCG.cginc"

struct appdata
{
float4 vertex : POSITION;
float3 normal : NORMAL;
float4 texcoord : TEXCOORD0;
};

struct v2f
{
float4 pos : SV_POSITION;
float3 worldNormal: TEXCOORD0;
float3 worldPos : TEXCOORD1;
float2 uv : TEXCOORD2;
};

float4 _Color;
sampler2D _MainTex;
float4 _MainTex_ST;
fixed _Cutoff;

v2f vert (appdata v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.worldNormal = UnityObjectToWorldNormal(v.normal);
o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);
return o;
}

fixed4 frag (v2f i) : SV_Target
{
fixed3 worldNormal = normalize(i.worldNormal);

fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));

fixed4 texColor = tex2D(_MainTex, i.uv);

clip(texColor.a - _Cutoff);

fixed3 albedo = texColor.rgb * _Color.rgb;

fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;

fixed3 diffuse = unity_LightColor0.rgb * albedo * max(0, dot(worldNormal, worldLightDir));

return fixed4(ambient + diffuse , 1.0);
}
ENDCG
}
}
Fallback "Transparent/Cutout/VertexLit"
}

上一个部分我们讲渲染队列的时候,我们知道了,Unity中透明度测试使用的是渲染队列名为AlphaTest的队列,因此我们需要把Queue标签设置为AlphaTest。而RenderType标签可以让Unity把这个Shader归入到提前定义的组(就是TransparentCutout组)中,以指明该Shader是一个使用了透明度测试的Shader。RenderType标签通常被用于着色器替换功能。我们还吧IgnoreProjector设置为True,这意味着这个Shader不会受到投影器(Projector)的影响。通常,使用了透明度测试的Shader都应该在SubShader中设置这三个标签。

剩下的Shader代码都是很普通的代码,只是调用了clip函数而已。

透明度混合

定义:这种方法可以得到真正的半透明效果。它会使用当前片元的透明度作为混合因子,与已经存储在颜色缓冲中的颜色值进行混合,得到新的颜色。但是透明度混合需要关闭深度写入,这使得我们要非常小心物体的渲染顺序。

为了进行混合,我们需要使用Unity提供的混合命令——Blend。Blend是Unity提供的设置混合模式的命令。想要实现半透明效果就需要把当前自身的颜色和已经存在颜色缓冲中的颜色值进行混合,混合时使用的函数就是由该指令决定的。

具体的Blend命令的语义可以看官方文档:https://docs.unity3d.com/2020.3/Documentation/Manual/SL-Blend.html

假设我们采用了Blend SrcFactor DstFactor, SrcFactor Alpha DstFactor Alpha语义来进行混合,需要注意的是,这个命令在设置了混合因子的同时也开启了混合模式。这是因为,只有开启了混合之后,设置片元着色器的透明通道才有意义,而Unity在我们使用Blend命令的时候就自动帮我们打开了。

我们把源颜色的混合因子SrcFactor设置为SrcAlpha,而目标颜色的混合因子DstFactor设置为OneMinusSrcAlpha,这意味着混合后的新颜色是:finalColor = SrcAlpha * SrcColor + (1-SrcAlpha)*DstColor

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
Shader "Unlit/AlphaBlend"
{
Properties
{
_Color("Main Tint", Color) = (1,1,1,1)
_MainTex ("Texture", 2D) = "white" {}
_AlphaScale("Alpha Scale", Range(0,1)) = 1
}
SubShader
{
Tags { "Queue"="Transparent" "IgnoreProjector"="True" "RenderType"="Transparent" }
LOD 100

Pass
{
ZWrite Off
Blend SrcAlpha OneMinusSrcAlpha
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
// make fog work
#pragma multi_compile_fog

#include "UnityCG.cginc"

struct appdata
{
float4 vertex : POSITION;
float3 normal : NORMAL;
float4 texcoord : TEXCOORD0;
};

struct v2f
{
float4 pos : SV_POSITION;
float3 worldNormal: TEXCOORD0;
float3 worldPos : TEXCOORD1;
float2 uv : TEXCOORD2;
};

float4 _Color;
sampler2D _MainTex;
float4 _MainTex_ST;
fixed _Cutoff;

v2f vert (appdata v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.worldNormal = UnityObjectToWorldNormal(v.normal);
o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);
return o;
}

fixed4 frag (v2f i) : SV_Target
{
fixed3 worldNormal = normalize(i.worldNormal);

fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));

fixed4 texColor = tex2D(_MainTex, i.uv);

fixed3 albedo = texColor.rgb * _Color.rgb;

fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;

fixed3 diffuse = unity_LightColor0.rgb * albedo * max(0, dot(worldNormal, worldLightDir));

return fixed4(ambient + diffuse , 1.0);
}
ENDCG
}
}
Fallback "Transparent/VertexLit"
}

这个Shader和上面的AlphaTest相差的就是关闭了ZWrite,然后设置了混合模式,去掉了片元着色器中的clip

但是这种关闭深度写入的方式会在模型本身又复杂的遮挡关系的时候,因为排序错误而得到错的透明效果。

那么我们能不能想办法再重新用回深度写入呢?答案是可以的,那就是用两个Pass

开启深度写入的半透明效果

既然使用一个Pass来进行半透明有问题,那么我们可以考虑使用两个。

第一个Pass开启深度写入,但是不输出颜色,它的目的仅仅是为了把该模型的深度值写入深度缓冲中;第二个Pass则进行正常的透明度混合,由于上一个Pass已经得到了逐像素级别的正确的深度信息,该Pass就可以按照逐像素级别的深度排序结果进行透明渲染。但是这种方法的缺点在于,多使用一个Pass会对性能造成一定的影响

这个Shader相对于不开启深度写入来说,就是在一开始多了一个Pass

1
2
3
4
5
Pass 
{
ZWrite On
ColorMask 0
}

这个Pass首先是开启了深度写入,把模型的深度信息写入深度缓冲中,从而剔除模型中被自身遮挡的片元。因此该Pass第一行开启了深度写入。第二行是用了ColorMask命令,用于设置颜色通道的掩码,它的值可以是R,G,B,A的任意组合,如果为0那表示不写入颜色通道

双面渲染的透明效果

在现实生活中,如果一个物体是透明的,意味着我们不仅可以透过它看到其他物体的样子,也可以看到它内部的结构。但在前面实现的透明效果中,无论是透明度测试还是透明度混合,我们都无法观察到正方体内部及其背面的形状,导致物体看起来好像只有半个一样。这是因为,默认情况下渲染引擎剔除了物体背面(相对于摄像机方向)的渲染图元么人只渲染了物体的正面。如果我们想要得到双面渲染的效果,可以使用Cull指令来控制需要剔除哪个面的渲染图元。

在Unity中,Cull指令的语法如下:

Cull Back | Front | Off

如果设置为Back,那么背对摄像机的渲染图元就不会被渲染

透明度测试的双面渲染只需要在Pass中关闭剔除就好

而透明度混合就比较麻烦了。和透明度测试相比,想要让透明度混合实现双面渲染会更复杂,因为透明度混合需要关闭深度写入,而这是“一切混乱的开始”

想要得到正确的透明结果,渲染顺序是非常重要的,我们需要保证图元是从后往前渲染的。对于透明度测试来说,由于我们没有关闭深度写入,所以我们可以利用深度缓冲来按逐像素粒度进行深度排序,从而保证渲染的正确性,然而一旦关闭了深度写入,我们就需要小心的控制渲染顺序来得到正确的深度关系。

如果我们还是简单的关闭剔除功能,那么我们就无法保证同一个物体的正面和背面图元的渲染顺序,就有可能得到错误的半透明结果

为此,我们选择把双面渲染的工作分为两个Pass,第一个Pass只渲染背面,第二个Pass只渲染正面,由于Unity会顺序执行SubShader中的各个Pass,因此我们可以保证背面总是在正面被渲染之前渲染,从而可以保证正确的深度关系

具体做法就是,我们把之前的透明度混合的代码中的Pass复制一份出来,然后第一份加上Cull Front,第二份加上Cull Back就好