UnityShader(六)透明效果
一、如何实现透明效果
在Unity中实现透明效果的方式有两种,其一是透明度测试,其二是透明度混合。
- 透明度测试:这种方式不需要关闭深度写入,且实现机制非常简单粗暴。只要一个片元的透明度不满足条件(比如小于某个值),则该片元会被直接舍弃,否则就按照不透明物体的处理方式来处理。它产生的效果要么是完全不透明,要么是完全透明,并不是真正的半透明效果。
- 透明度混合:这种方式会使用当前片元的透明度作为混合因子,与颜色缓冲中的颜色进行混合。这就需要关闭深度写入。而关闭深度写入意味着我们需要非常小心物体的渲染顺序,否则可能出现渲染问题。
为什么要关注渲染顺序
在之前的Shader中我们并没有关心过物体渲染顺序的问题。这是因为深度缓冲的存在。当渲染一个片元时,需要把它的深度值与已经存在于深度缓冲中的值进行比较,如果它的深度值更大,说明有物体挡住了它,这个片元就不应该被渲染到屏幕上;反之,这个片元就会覆盖掉颜色缓冲中的像素值,它的深度值也会更新到深度缓冲中。也就是说,有深度缓冲在,即便我们先渲染了前面的物体A,再渲染了后面的物体B,也不用担心B会覆盖掉A。因为已经在深度缓冲中判断出B在A的后面。
那么进行透明度混合时为什么要关闭深度写入呢?对于一个半透明物体来讲,其后面的物体应该是可以透过它看到的。但如果开启了深度写入,距离摄像机更近的透明物体的深度就会覆盖掉后面的物体。从而后面物体的表面将会被剔除,我们也就无法通过透明物体观察到后面的物体了。
一旦关闭了深度写入,事情就会变得复杂起来。
比如上面这两个物体,A是半透明物体,B是不透明物体。
- 如果我们先渲染B,再渲染A。B首先将颜色和深度写入缓冲区。然后渲染A时进行深度测试,发现A离相机更近。因此A的透明度会和缓冲区中的B的颜色进行混合,得到正确的半透明效果。
- 如果我们先渲染A,再渲染B。A会将颜色写入颜色缓冲,但由于透明物体关闭了深度写入,因此A不会将深度值写入深度缓冲。等到渲染B时,由于深度缓冲中没有数据,所以B会直接将颜色和深度写入缓冲中。结果就是B会覆盖A的颜色,结果是错误的。
那么如果是两个半透明物体又会怎样呢?下图中A和B都是半透明物体。
- 先渲染B再渲染A。B会正常写入颜色缓冲,然后A会和颜色缓冲中的B的颜色进行混合,得到正确结果。
- 先渲染A再渲染B。A会先写入颜色缓冲,然后B会与颜色缓冲中A的颜色进行混合。这样混合结果会反过来,看起来像B在A的前面。结果错误。
渲染引擎在渲染前一般会先对物体进行排序。先渲染所有不透明物体,并开启它们的深度测试和深度写入。然后再把半透明物体按从后往前的顺序进行渲染,并开启深度测试,关闭深度写入。尽管如此,仍然会有些不理想的情况:
像上面几种无法得到正确排序顺序的情况,就只能将物体进行拆分或是让透明通道更加柔和,让穿插不是这么明显。
Unity的渲染队列
Unity为了解决渲染顺序的问题,提供了渲染队列这一解决方案。我们可以在SubShader的Tags中决定模型使用哪个渲染队列。
二、透明度测试
接下来我们实现透明度测试效果。首先将之前的纹理采样的Shader复制过来。这里不考虑高光,所以将计算高光的部分删掉
Shader "Unlit/AlphaTest"
{
Properties
{
_Diffuse("Diffuse",Color) = (1,1,1,1)
_Gloss("Gloss",Range(1,256)) = 20
_MainTex("MainTex",2D) = "white" {}
}
SubShader
{
Tags
{
"LightMode"="ForwardBase"
}
LOD 100
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
#include "Lighting.cginc"
fixed4 _Diffuse;
float _Gloss;
sampler2D _MainTex;
float4 _MainTex_ST;
struct v2f
{
float4 vertex: SV_POSITION;
fixed3 worldNormal: TEXCOORD0;
float2 uv:TEXCOORD1;
};
v2f vert(appdata_base v)
{
v2f o;
// 将顶点坐标从物体空间转换到裁剪空间
o.vertex = UnityObjectToClipPos(v.vertex);
// 将法线从物体空间转换到世界空间
o.worldNormal = UnityObjectToWorldNormal(v.normal);
// 计算uv坐标
o.uv = TRANSFORM_TEX(v.texcoord,_MainTex);
return o;
}
fixed4 frag(v2f i):SV_Target
{
// 获取归一化的光源方向
const fixed3 worldLight = normalize(_WorldSpaceLightPos0);
const fixed3 albedo = tex2D(_MainTex,i.uv).rgb;
// 环境光
const fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;
// 计算漫反射
const fixed3 diffuse = _LightColor0.rgb * albedo * _Diffuse.rgb * (0.5*dot(i.worldNormal, worldLight)+0.5);
fixed3 color = ambient + diffuse;
return fixed4(color, 1);
}
ENDCG
}
}
}
首先定义一个属性作为透明度测试时的判断条件,并在CG中定义与之匹配的变量
_Cutoff("Alpha Cutoff",Range(0,1)) = 0.5
在Tags中将渲染队列设置为「AlphaTest」,并忽略投影器的影响
Tags
{
"Queue"="AlphaTest"
"IgnoreProjector"="True"
}
最后在片元着色器中根据纹理的alpha通道的值进行剔除
fixed4 frag(v2f i):SV_Target
{
// 获取归一化的光源方向
const fixed3 worldLight = normalize(_WorldSpaceLightPos0);
const fixed4 texColor = tex2D(_MainTex,i.uv);
// 根据条件剔除
if(texColor.a - _Cutoff < 0)
{
discard;
}
// 环境光
const fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;
// 计算漫反射
const fixed3 diffuse = _LightColor0.rgb * texColor.rgb * _Diffuse.rgb * (0.5*dot(i.worldNormal, worldLight)+0.5);
fixed3 color = ambient + diffuse;
return fixed4(color, 1);
}
找一张alpha值不同的图片作为纹理,得到如下效果
三、透明度混合
Unity提供了设置混合模式的命令——Blend。混合时使用的函数就由该指令决定。Unity提供的Blend指令如下
对于「Blend SrcFactor DstFactor」和「Blend SrcFactor DstFactor,SrcFactorA DstFactorA」两个命令,它们实际上表达的是两个混合公式
O
r
g
b
=
S
r
c
F
a
c
t
o
r
×
S
r
g
b
+
D
s
t
F
a
c
t
o
r
×
D
r
g
b
O_{rgb}=SrcFactor×S_{rgb}+DstFactor×D_{rgb}
Orgb=SrcFactor×Srgb+DstFactor×Drgb
O
a
=
S
r
c
F
a
c
t
o
r
A
×
S
a
+
D
s
t
F
a
c
t
o
r
A
×
D
a
O_{a}=SrcFactorA×S_{a}+DstFactorA×D_{a}
Oa=SrcFactorA×Sa+DstFactorA×Da
其中
O
r
g
b
、
O
a
O_{rgb}、O_{a}
Orgb、Oa代表混合后的rgb通道和a通道,
S
r
g
b
、
S
a
S_{rgb}、S_{a}
Srgb、Sa代表源颜色通道,
D
r
g
b
、
D
a
D_{rgb}、D_{a}
Drgb、Da代表目标颜色通道。第一个命令只提供了两个因子,意味着rgb通道和a通道的混合因子都是SrcFactor和DstFactor。
那么这些混合因子可以有哪些值呢?
另外,前面的公式将源颜色和目标颜色与混合因子的乘积相加得出最终结果。我们当然也可以自定义其他的混合操作。「BlendOP BlendOperation」的作用就是如此。Unity提供的混合操作如下
下面列出了常见的混合类型,类似于PS中对应的混合效果
// 正常(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
透明度混合实现
接下来我们尝试实现透明度混合。依然是基于之前的纹理采样的Shader进行修改。
首先定义一个属性用来控制整体的透明度,并在CG中声明对应变量
_AlphaScale("Alpha Scale",Range(0,1)) = 1
在Tags中指定渲染队列,忽略投影器影响,将「RenderType」设置为「Transparent」
Tags
{
"Queue"="Transparent"
"IgnoreProjector"="True"
"RenderType"="Transparent"
}
在Pass通道中,将光照模式设置为前项渲染路径,关闭深度写入,并设置混合模式
Tags{ "LightMode"="ForwardBase" }
ZWrite Off
Blend SrcAlpha OneMinusSrcAlpha
最后设置片元着色器返回值中的透明通道
fixed4 frag(v2f i):SV_Target
{
const fixed3 worldLight = normalize(_WorldSpaceLightPos0);
const fixed4 texColor = tex2D(_MainTex,i.uv);
const fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;
const fixed3 diffuse = _LightColor0.rgb * texColor.rgb * _Diffuse.rgb * (0.5*dot(i.worldNormal, worldLight)+0.5);
fixed3 color = ambient + diffuse;
// 修改返回值中的透明通道
return fixed4(color, texColor.a * _AlphaScale);
}
效果如下
但假如我们换一个模型,就会发现这样做有些许问题
很明显,当模型网格相互交叉时,会得到错误的半透明效果。这是因为我们关闭了深度写入,无法对模型进行像素级别的深度排序。解决方案是增加一个Pass通道,在新的Pass通道中开启深度写入,但不输出颜色。这样就会先把模型的深度值写入缓冲中。当第二个Pass再进行透明度混合时,由于上一个Pass已经获得了逐像素的正确深度信息,因而该Pass就可以按照深度排序结果进行透明渲染。当然,这样做的缺点也很明显:多使用了一个Pass会对性能造成影响。
// 新增一个Pass
Pass
{
ZWrite On
// 不写入任何颜色通道
ColorMask 0
}
效果如下