透明效果(二)

本文最后更新于:2025年4月17日 下午

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

在上一篇中,我们给出了一种由于关闭深度写入而造成排序错误的情况。一种解决办法是使用两个 Pass 来渲染模型:第一个 Pass 开启深度写入,但不输出颜色,它的目的仅仅是为了把该模型的深度值写入到深度缓冲中;第二个 Pass 进行正常的透明度混合,由于上一个 Pass 已经得到了逐像素的正确的深度信息,该 Pass 就可以按照像素级别的深度排序结果进行透明渲染。但这个方法的缺点在于,多使用一个 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
Shader "Custom/Chapter8/Chapter8-AlphaBlendZWrite"
{
Properties
{
_Color ("Main Tint", Color) = (1,1,1,1)
_MainTex ("Main Tex", 2D) = "white" {}
_AlphaScale ("Alpha Scale", Range(0, 1)) = 1
}
SubShader
{
Tags { "Queue"="Transparent" "IgnoreProhector"="True" "RenderType"="Transparent" }

Pass {
ZWrite On
ColorMask 0
}

Pass {
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;
float2 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 "Transparent/VertexLit"
}

这个新添加的 Pass 的目的仅仅是为了把模型的深度信息写入深度缓冲中,从而剔除模型中被自身遮挡的片元。因此,Pass 的第一行开启了深度写入。在第二行,我们使用了一个新的渲染命令——ColorMask。在 ShaderLab 中,ColorMask 用于设置颜色通道的写掩码(write mask)。它的语义如下:

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

当 ColorMask 设为 0 时,意味着该 Pass 不写入任何颜色通道,即不会输出任何颜色。这正是我们需要的——该 Pass 只需写入深度缓存即可。

效果如下图所示:

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

可以看出,使用这种方法,我们仍然可以实现模型与它后面的背景混合的效果,但模型内部之间不会有任何真正的半透明效果。

ShaderLab 的混合命令

混合除了用于透明度混合,还有很多其他用处。下面我们将更加详细地了解混合中的细节问题。

我们首先来看一下混合是如何实现的。当片元着色器产生一个颜色的时候,可以选择与颜色缓存中的颜色进行混合。这样一来,混合就和两个操作数有关:源颜色(source color)目标颜色(destination color)。源颜色,我们用 S 表示,指的是由片元着色器产生的颜色值;目标颜色,我们用 D 表示,指的是从颜色缓冲中读取到的颜色值。对它们进行混合后得到的输出颜色,我们用 O 表示,它会重新写入到颜色缓冲中。需要注意的是,当我们谈及混合中的源颜色、目标颜色和输出颜色时,它们都包含了 RGBA 四个通道的值,而并非仅仅是 RGB 通道。

想要使用混合,我们必须首先开启它。在 Unity 中,当我们使用 Blend(Blend Off 命令除外) 命令时,除了设置混合状态外也开启了混合。但是,在其他图形 API 中我们是需要手动开启的。例如在 OpenGL 中,我们需要使用 glEnable(GL_BLEND) 来开启混合。但在 Unity 中,它已经在背后为我们做了这些工作。

混合等式和参数

混合是一个逐片元的操作,而且它不是可编程的,但却是高度可配置的。也就是说,我们可以设置混合时使用的运算操作、混合因子等来影响混合。那么,这些配置又是如何实现的呢?

现在,我们已知两个操作数:源颜色 S 和目标颜色 D,想要得到输出颜色 O 就必须使用一个等式来计算。我们把这个等式称为混合等式(blend equation)。当进行混合时,我们需要使用两个混合等式:一个用于混合 RGB 通道,一个用于混合 A 通道。当设置混合状态时,我们实际上设置的就是混合等式中的操作因子。在默认情况下,混合等式使用的操作都是加操作(我们也可以使用其他操作),我们只需要再设置一下混合因子即可。由于需要两个等式(分别用于混合 RGB 通道和 A 通道),每个等式有两个因子(一个用于和源颜色相乘,一个用于和目标颜色相乘),因此一共需要 4 个因子。下表给出了 ShaderLab 中设置混合因子的命令。

命令 描述
Blend SrcFactor DstFactor 开启混合,并设置混合因子。源颜色(该片元产生的颜色)会乘以 SrcFactor,而目标颜色(已经存在于颜色缓存的颜色)会乘以 DstFactor,然后把两者相加后在存入颜色缓冲中
Blend SrcFactor DstFactor, SrcFactorA DstFactorA 和上面几乎一样,只是使用不同的因子来混合透明通道

可以发现,第一个命令只提供了两个因子,这意味着将使用同样的混合因子来混合 RGB 通道和 A 通道,即此时 SrcFactorA 将等于 SrcFactor,DstFactorA 将等于 DstFactor。下面就是使用这些因子进行加法混合时使用的混合公式:

$$
O_{rgb} = SrcFactor \times S_{rgb} + DstFactor \times D_{rgb}
$$
$$
O_a = SrcFactorA \times S_a + DstFactorA \times D_a
$$

那么,这些混合因子可以有哪些值呢?下表给出了 ShaderLab 支持的几种混合因子。

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

使用上面的指令进行设置时,RGB 通道的混合因子和 A 通道的混合因子都是一样的,有时我们希望可以使用不同的参数混合 A 通道,这时就可以利用 Blend SrcFactor DstFactor, SrcFactorA DstFactorA 指令。例如,如果我们想要在混合后,输出颜色的透明度值就是源颜色的透明度,可以使用下面的命令:

1
Blend SrcAlpha OneMinusSrcAlpha, One Zero

混合操作

在上面涉及的混合等式中,当把源颜色和目标颜色与它们对应的混合因子相乘后,我们都是把它们的结果加起来作为输出颜色的。那么可不可以选择不用加法,而使用减法呢?答案是肯定的,我们可以使用 ShaderLab 的 BlendOp BlendOperation 命令,即混合操作命令。下表给出了 ShaderLab 中支持的混合操作。

操作 描述
Add 将混合后的源颜色和目的颜色相加。默认的混合操作使用的混合等式是:
$O_{rgb} = SrcFactor \times S_{rgb} + DstFactor \times D_{rgb}$
$O_a = SrcFactorA \times S_a + DstFactorA \times D_a$
Sub 用混合后的源颜色减去混合后的目的颜色。使用的混合等式是:
$O_{rgb} = SrcFactor \times S_{rgb} - DstFactor \times D_{rgb}$
$O_a = SrcFactorA \times S_a - DstFactorA \times D_a$
RevSub 用混合后的目的颜色减去混合后的源颜色。使用的混合等式是:
$O_{rgb} = DstFactor \times D_{rgb} - SrcFactor \times S_{rgb}$
$O_a = DstFactorA \times D_a - SrcFactorA \times S_a$
Min 使用源颜色和目的颜色中较小的值,是逐分量比较的。使用的混合等式是:
$O_{rgba} = (min(S_r, D_r), min(S_g, D_g), min(S_b, D_b), min(S_a, D_a))$
Max 使用源颜色和目的颜色中较大的值,是逐分量比较的。使用的混合等式是:
$O_{rgba} = (max(S_r, D_r), max(S_g, D_g), max(S_b, D_b), max(S_a, D_a))$
其他逻辑操作 仅在 DirectX 11.1 中支持

混合操作命令通常是与混合因子命令一起工作的。但需要注意的是,当使用 MinMax 混合操作时,混合因子实际上是不起任何作用的,它们仅会判断原始的源颜色和目的颜色之间的比较结果。

常见的混合类型

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

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
//正常(Normal),即透明度混合
Blend SrcAlpha OneMinusSrcAlpha

//柔和相加(Soft Additive)
Blend OneMinusDstColor One

//正片叠底(Multiply),即相乘
Blend DstColor Zero

//两倍相乘(2x Multiply)
Blend DstColor SrcColor

//变暗(Darken)
BlendOp Min
Blend One One

//变亮(Lighten)
BlendOp Max
Blend One One

//滤色(Screen)
Blend OneMinusDstColor One
//等同于
Blend One OneMinusSrcColor

//线性减淡(Linear Dodge)
Blend One One

效果如下:

常见的混合类型

需要注意的是,虽然上面使用 Min 和 Max 混合操作时仍然设置了混合因子,但实际上它们并不会对结果有任何影响,因为 Min 和 Max 混合操作会忽略混合因子。另一点是,虽然上面有些混合模式并没有设置混合操作的种类,但是他们默认就是使用加法操作,相当于设置了 BlendOp Add。

双面渲染的透明效果

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

1
Cull Back | Front | Off

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

透明度测试的双面渲染

实现透明度测试物体的双面渲染效果非常简单,只需要在 Pass 的渲染设置中使用 Cull 指令来关闭剔除即可。

1
2
3
4
5
6
Pass {
Tags {"LightMode" = "ForwardBase"}

//Turn off culling
Cull 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
Shader "Custom/Chapter8/Chapter8-AlphaBlendBothSided"
{
Properties
{
_Color ("Main Tint", Color) = (1,1,1,1)
_MainTex ("Main Tex", 2D) = "white" {}
_AlphaScale ("Alpha Scale", Range(0, 1)) = 1
}
SubShader
{
Tags { "Queue"="Transparent" "IgnoreProhector"="True" "RenderType"="Transparent" }

Pass {
Tags { "LightMode" = "ForwardBase" }
Cull Front
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;
float2 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
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;
float2 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 "Transparent/VertexLit"
}

通过上面的代码,我们可以得到下图中的效果:

透明度混合的双面渲染

参考

《Unity Shader入门精要》


透明效果(二)
http://example.com/posts/透明效果(二)/
作者
祭零小白
发布于
2022年1月27日
许可协议