《shader入门精要》笔记-第8章-透明效果

Unity中,有两种方式实现透明效果: 一种是透明度测试(Alpha Test),这种方法无法得到真正的半透明效果;另一种方法是透明度混合(Alpha Blending)

对于不透明物体,不考虑渲染顺序也能得到正确的排序结果,这是由于强大的深度缓冲(depth-buffer,也称z-buffer)的存在.
在实时渲染中,深度缓冲是用于解决可见性问题的,它会解决哪些物体的哪些部分会被渲染在前面,哪些部分会被遮挡.

如果想要实现透明度效果,需要关闭深度写入.

透明度测试和透明度混合的原理如下:

  • 透明度测试
    采用一种"极其霸道"的机制,只要一个片元的透明度不满足条件(通常是小于某个阈值),那么它对应的片元就会被舍弃.被舍弃的片元将不会再做任何处理,也不会对颜色缓冲区造成影响.透明度测试是不需要关闭深度写入的,它和其他不透明物体最大的不同在于它会根据透明度舍弃一些片元.虽然简单,但其产生的效果会很极端:要么完全透明,要么不透明.
  • 透明度混合
    这种方法可以得到真正的半透明的效果.他会使用当前片元的透明度作为混合因子,与已经存储到颜色缓冲中的颜色进行混合,得到新的颜色.但是,透明度混合需要关闭深度写入,但没有关闭深度测试,这意味着如果使用透明度混合去渲染一个片元的话时,还是会比较它和当前缓冲区中的深度值.也就是说,对于透明度混合来说,深度缓冲是只读的.

渲染顺序

关闭深度写入很重要

使用透明度混合时,如果不关闭深度写入:
如果一个半透明表面背后的表面本是可以透过前面的表面被我们看到的,但由于深度测试时判断结果是前面的半透明表面距离摄像机更近,导致后面的表面将会被剔除,我们无法透过半透明表面看到后面的物体了.
但是,我们破坏了深度缓冲的工作机制,而这是一个非常非常糟糕的事情,尽管我们不得不这样做: 关闭深度写入导致渲染顺序变得非常重要

不同的渲染顺序会得到的结果

假设场景里有两个物体:A和B,其中A是半透明物体,B是不透明物体,如图所示
20190323084257.png

  • 第一种情况:先渲染B,再渲染A
    由于不透明物体开启了深度测试和深度写入,而因此我们的深度缓存中没有任何有效数据,因此B首先会写入颜色缓冲和深度缓冲.随后我们渲染透明物体A,仍会进行深度测试,而我们发现A比B距离摄像头更近,所以我们会使用A的带透明度颜色和B的颜色进行混合,得到正确的半透明效果
  • 第二种情况:先渲染A,再渲染B
    渲染A时,深度缓冲中没有任何有效数据,因此A直接写入颜色缓冲.但不会写入深度缓冲.等渲染B时,进行深度测试的时候,深度缓冲中没有任何有效数据,会直接覆盖A的颜色,从视觉上来看,B出现在了A的前面,而这是错误的.

因此,渲染引擎一般都会对物体进行排序,再渲染.常见方法是:

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

但是,这种方法仍然存在问题:
第二步中的渲染顺序,是依靠距离摄像机的深度判断的,而深度缓冲中的值其实是像素级别的,但是我们需要对物体级别进行排序.如图,会得到3个物体循环的情况.
20190323093519.png
尽管总有一些情况打乱我们的阵脚,但由于上述方法足够有效且容易实现,因此大多数游戏引擎都选用的这种方法.

Unity Shader的渲染顺序

Unity为解决渲染问题提供了渲染队列(render queue)这一方案.我们可以使用Subshader的Queen标签来决定我们的模型将归于哪个渲染队列.
Unity在内部使用了一系列整数索引来表示每个渲染队列,索引号越小就越先被渲染.在Unity5中,Unity提前定义了5个渲染队列.

名称 队列索引号 描述
Background 1000 背景
Geometry 2000 默认渲染队列,大多数物体使用这个队列,不透明物体使用这个队列.
AlphaTest 2450 需要透明度测试的物体使用这个队列.在Unity5中它从Geometry队列中被单独分出来,这是因为在所有不透明物体轩然之后再渲染它们更高效.
Transparent 3000 这个队列中的物体会在所有Geometry和AlphaTest物体渲染后,再按从后往前的顺序进行渲染.任何使用了透明度混合(例如关闭了深度写入的Shader)的物体都应该使用该队列(???)
Overlay 4000 该队列用于实现一些叠加效果.任何需要在最后渲染的物体都应该使用该队列

因此,我们想要通过透明度测试实现透明效果,代码中应该包含类似下面的代码:

1
2
3
4
5
6
SubShader {
Tags{"Queue" = "AlphaTest"}
Pass{
// ......
}
}

如果我们想通过透明度混合来实现透明效果,代码中应该包含类似下面的代码:

1
2
3
4
5
6
7
SubShader {
Tags{"Queue" = "Transparent"}
Pass{
ZWrite Off
// ......
}
}

其中,ZWrite Off用于关闭深度写入.我们可以把它写在Pass或SubShader中.

透明度测试

目测用的不多,先不看

透明度混合

会使用当前片元的透明度作为混合因子,与已经存储在颜色缓冲中的颜色进行混合,得到新的颜色.

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

语义 描述
Blend Off 关闭混合
Blend SrcFactor DstFactor 开启混合,并设置混合因子.片元颜色会乘以SrcFactor,而已存在于颜色缓冲区的颜色会乘以DstFactor,然后把两者相加存入缓存.
Blend SrcFactor DstFactor, SrcFactorA DstFactorA 和上面几乎一样,只是使用不同因子来混合透明通道
BlendOp BlendOption 并非是把源颜色和目标颜色简单相加后混合,而是使用BlendOption对它们进行其他操作

在本节中我们使用第二种语义来混合,需要注意的是,这个命令在设置混合因子的同时也开启了混合模式.

实践: 透明度混合

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
77
78
79
80
// Upgrade NOTE: replaced '_Object2World' with 'unity_ObjectToWorld'

Shader "Custom/8.4"
{
Properties
{
_Color ("Color", Color) = (1,1,1,1)
_MainTex ("Albedo (RGB)", 2D) = "white" {}

// AlphaScale用来控制整体透明度
_AlphaScale("Alpha Scale",Range(0,1)) = 1
}
SubShader
{

// Queue标签指定队列
// RenderType标签设置为Transparent,用来指明该Shader是一个使用了透明度混合的Shader.RenderType标签通常被用于着色器替换功能.
// IgnoreProject标签设为True,意味着整个Subshader不受投影器(Projectors)影响.
Tags { "Queue"="TransParent" "IgnoreProject"="True" "RenderType"="Transparent" }

Pass{

// 把LightMode标签设为ForwardBase,是为了让Unity能够按前向渲染路径的方式为我们正确提供各个光照变量.
Tags {"LightMode"="ForwardBase"}

// 关闭深度写入
ZWrite Off

// 设置混合模式
Blend SrcAlpha OneMinusSrcAlpha

CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Lighting.cginc"

fixed4 _Color;
sampler2D _MainTex;
float4 _MainTex_ST;
fixed _AlphaScale;

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

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

v2f vert(a2v 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 = _LightColor0.rgb * albedo * max(0, dot(worldNormal, worldLightDir));

// alpha的值
return fixed4(ambient + diffuse,texColor.a * _AlphaScale);
}

ENDCG
}
}
FallBack "Diffuse"
}

当模型自身有复杂的遮挡关系或包含了复杂的非凸网格的时候,就会有各种各样因为排序错误而产生的错误的排序效果.
20190323121138.png

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

上面的问题都是由于关闭了深度写入造成的,因为这样我们无法对模型进行像素级别的深度排序.
这是,我们可以想办法重新利用深度写入,让模型可以想半透明物体一样进行淡入淡出(淡入淡出什么鬼???).这就是下面的内容.

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

代码只需要在上面的那份代码中新加一个Pass即可,加在原Pass的上面

1
2
3
4
Pass{
ZWrite On
ColorMask 0
}

ColorMask用于设置颜色通道的写掩码(write mask),它的语义如下:

ColorMask RGB | A | 0 | 其他任何R, G, B, A的组合

当ColorMask设为0时,意味着该Pass不写入任何颜色通道,即不会输出任何颜色.

ShaderLab的混合命令

上面已经用了Blend命令进行混合,实际上混合还有其他很多用处,不仅仅是透明度混合.

混合的原理

当片元着色器产生一个颜色的时候,可以选择与颜色缓存中的颜色进行混合,这样一来,混合就和两个操作数有关:源颜色(source color)和目标颜色(destination color).

源颜色,用S表示,指的是片元着色器产生的颜色值;
目标颜色,用D表示,指的是从颜色缓冲中读取到的颜色值.
对它们进行混合后得到输出颜色,用O表示,它会重新写入到颜色缓存中.

需要注意的是,当我们谈及混合中的源颜色, 目标颜色和输出颜色时,他们都包含了RGBA四个通道,而并非仅仅的RGB通道.

开启混合

在Unity中,当我们使用Blend(Blend Off除外)命令时,除了设置混合状态外也开启的混合.

混合的等式和参数

混合是一个逐片元的操作,而且它是不可编程的,但是是高度可配置的.
我们可以设置混合时使用的运算操作,混合因子等来影响混合.

从源颜色S和目标颜色D得到输出颜色O必须适用一个等式来计算,这个等式叫做混合等式(Blending equation).
当进行混合时,我们需要使用两个混合等式,一个用于混合RGB通道,一个用于混合A通道.
当设置混合状态时,我们实际上设置的是混合等式中的操作因子
在默认情况下,混合等式使用的操作都是加操作(我们也可以使用其他操作),我们只需要再设置一下混合因子即可.
由于需要两个等式,每个等式有两个因子(一个用于和源颜色相乘,一个用于和目标颜色相乘),因此一共需要4个因子.
下面是ShaderLab中设置混合因子的命令

命令 描述
Blend SrcFactor DstFactor 开启混合,并设置混合因子.源颜色会乘以SrcFactor,而目标颜色会乘以DstFactor,然后将两者相加后存入颜色缓冲中
Blend SrcFactor DstFactor,SrcFactorA DstFactor A 和上面几乎一样,只是使用不同因子来混合透明通道.

第一个命令只提供了两个因子,这意味着使用同样混合因子来混合RGB通道和A通道,即此时SrcFactorA等于SrcFactor, DstFactorA等于DstFactor.

这些混合因子的值可以取以下:

参数 描述
One 因子为1
Zero 因子为0
SrcColor 因子为源颜色值.当用于RGB通道的混合等式时,使用SrcColor的RGB分量作为混合因子;当用于A通道混合等式时,使用SrcColor的A分量作为混合因子.
SrcAlpha 因子源颜色的透明度值(A通道)
DstColor 因子为目标颜色值.当用于RGB通道的混合等式时,使用DstColor的RGB分量作为混合因子;当用于A通道混合等式时,使用DstColor的A分量作为混合因子.
DstAlpha 尹子维目标颜色值的透明度值(A通道)
OneMinusSrcColor 1 - SrcColor
OneMinusSrcAlpha 1 - SrcAlpha
OneMinusDstColor 1 - DstColor
OneMinusDstAlpha 1 - DstAlpha

混合操作

可以使用ShaderLab的BlendOp BlendOption命令,来选择不同的混合操作

操作 描述
Add 将混合后的源颜色和目的颜色相加.是默认的混合操作.使用的混合等式是:
O(rgb) = SrcFactor * S(rgb) + DstFactor * D(rgb)
O(a) = SrcFactor * S(a) + DstFactor * D(a)
Sub 用混合后的源颜色减去混合后的目标颜色.使用的混合等式是:
O(rgb) = SrcFactor * S(rgb) - DstFactor * D(rgb)
O(a) = SrcFactor * S(a) - DstFactor * D(a)
RevSub 用混合后的目标颜色减去混合后的源颜色.使用的混合等式是:
O(rgb) = DstFactor * D(rgb) - SrcFactor * S(rgb)
O(a) = DstFactor * D(a) - SrcFactor * S(a)
Min 使用源颜色和目标颜色中的较小值,是逐分量比较的.使用的混合等式是
O(rgba) = (min(S®, D®, min(S(g), D(g), min(S(b), D(b), min(S(a), D(a)))
Max 使用源颜色和目标颜色中的较大值,是逐分量比较的.使用的混合等式是
O(rgba) = (max(S®, D®, max(S(g), D(g), max(S(b), D(b), max(S(a), D(a)))

混合因子命令通常是与混合因子命令一起工作的.但使用Min或Max混合操作时,混合因子实际上是不起任何作用的

常见的混合类型

通过混合操作和混合因子命令的组合,可以得到一些类似PhotoShop混合模式中的混合效果.

操作 效果
Blend SrcAlpha OneMinusSrcAlpha 正常(Normal), 即透明度混合
Blend OneMinusDstColor One 柔和相加(Soft Additive)
Blend DstColor Zero 正片叠底(Multiply),即相乘
Blend DstColor SrcColor 两倍相乘(2x Multiply)
BlendOp Min
Blend One One
变暗(Darking)
BlendOp Max
Blend One One
变亮(Lighting)
Blend OneMinusDstColor One 滤色(Screen)
Blend One OneMinusSrcColor 滤色,等同于上面的
Blend One One 线性减淡(Linear Dodge)

20190323134011.png

双面渲染的透明效果

现实生活中,如果一个物体是透明的,意味着我们不仅可以透过它看到其它物体的样子,也可以看到它的内部结构.
但在前面实现的透明效果中,我们无法观察到正方体的内部及其背面的形状.
这是因为,默认情况下,渲染引擎剔除了物体背面的渲染图元,而只渲染了物体的正面.
可以使用Cull指令来控制需要剔除哪个面的图元
在Unity中,Cull指令的语法如下:

Cull Back | Front | Off

如果设置为Back,则背对摄像机的渲染图元不会被渲染,也就是默认情况下的剔除状态;
如果设为Front,则朝向摄像机的渲染图元不会被渲染;
如果设置为Off,就会关闭剔除功能,所有图元都会被渲染,但由于这时需要渲染的图元数目会成倍增加,因此除非是用于特殊效果(例如这里的透明效果),通常情况下是不会关闭剔除功能的.

实践: 透明度混合的双面渲染

我们想要得到正确的透明效果,渲染顺序是十分重要的,我们要保证图元要从前往后渲染.
对于透明度测试来说,由于我们没有关闭深度写入,所以可以利用深度缓冲按逐像素的细粒度进行深度排序,从而保证渲染的正确性.
然而透明度混合要关闭深度写入…我们就需要小心控制渲染顺序来得到正确的深度关系.
如果我们直接关闭剔除功能,我们就无法保证同一个物体的正面和背面渲染图元的渲染顺序,就有可能得到错误的半透明效果.

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

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
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
Shader "Custom/8.4"
{
Properties
{
_Color ("Color", Color) = (1,1,1,1)
_MainTex ("Albedo (RGB)", 2D) = "white" {}
_AlphaScale("Alpha Scale",Range(0,1)) = 1
}
SubShader
{

Tags { "Queue"="TransParent" "IgnoreProject"="True" "RenderType"="Transparent" }

Pass{
Tags {"LightMode"="ForwardBase"}

// 剔除前面
Cull Front

// 全是原Pass
ZWrite Off
Blend SrcAlpha OneMinusSrcAlpha

CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Lighting.cginc"

fixed4 _Color;
sampler2D _MainTex;
float4 _MainTex_ST;
fixed _AlphaScale;

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

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

v2f vert(a2v 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 = _LightColor0.rgb * albedo * max(0, dot(worldNormal, worldLightDir));
return fixed4(ambient + diffuse,texColor.a * _AlphaScale);
}

ENDCG
}

Pass{
Tags {"LightMode"="ForwardBase"}

// 剔除后面
Cull Back

// 原Pass
ZWrite Off
Blend SrcAlpha OneMinusSrcAlpha

CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Lighting.cginc"

fixed4 _Color;
sampler2D _MainTex;
float4 _MainTex_ST;
fixed _AlphaScale;

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

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

v2f vert(a2v 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 = _LightColor0.rgb * albedo * max(0, dot(worldNormal, worldLightDir));
return fixed4(ambient + diffuse,texColor.a * _AlphaScale);
}

ENDCG
}
}
FallBack "Diffuse"
}