作者:王子饼干 ——镇江虹视游戏科技有限公司联合创始人、《多洛可小镇》制作人
原文链接:UnityShader入门精要笔记10:透明效果
原文发布时间:2020年3月13日
文章类型: 授权转载
企者不立,跨者不行,自见者不明,自是者不彰,自伐者无功,自矜者不长。
——《道德经·第二十四》
笔记当前使用的Unity版本:“2019.3.3”
笔记当前Unity最新的版本:“2020.1.0.Alpha 25”
透明效果是游戏中很常见的效果,虽然要实现透明会和很多别的效果冲突,但是它是怎么冲突的,必须自己知道,这样才可以更加细致的控制各种效果。透明效果其实并不是第八章的主题,第八章核心的主题其实是“混合”与“渲染顺序”
之前说过,如果把两种颜色数据乘起来,它就相当于对两种颜色作正片叠底,(由于乘法是满足交换律的,所以谁叠谁,最终效果都是一样的)但其实呢,正片叠底只是混合的一种方式。还有很多其他的混合方式,不同的混合方式有不同的计算公式,正片叠底是最简单的一种。如果大家想要快速的了解各种各样的混合,这里有一个非常棒的视频可以告诉你。
渲染顺序非常的重要,简单来说,渲染管线提供了一种叫做深度写入的东西。假设人眼就是摄像机,你肯定看不到一堵墙后面的东西,因为墙离你的眼睛更近,把后面的东西都挡住了。是的,如果这一个被渲染出来的场景,深度写入就会把墙后面的东西剔除掉。
(深度渲染其实并非剔除了后面的物体,而是用墙的颜色覆盖了原来深度缓冲的数据,如果墙的前面还有东西,那么墙的颜色也会被覆盖)。
深度写入是如此的好用~
一切都看起来非常好,直到你想要做出透明效果,简单来说,如果你做了一个半透明的效果,但是没有关闭深度写入,深度写入仍然会剔除墙后面的东西,即使你的墙是透明的。所以为了透明效果,我们必须关闭深度写入。
当关闭深度写入~
在关闭深度写入之后,我们先来看一下它可能会出现什么错误。假设我们要渲染两个物体,一个是半透明的物体A,一个是不透明的物体B。我们假设A在B的前面(A离摄像机更近)
这是不透明的物体和半透明物体之间的联系。那么两个物体都是半透明物体呢?假设我们有两个物体A和B,A在B的前面(离摄像机更近),并且两者都是半透明物体。
所以我们先渲染不透明的物体,再渲染半透明的物体。在半透明物体中,我们先渲染离相机更远的物体,再渲染离相机更近的物体。当渲染半透明物体的时候,使用半透明混合和不透明物体的颜色进行混合。
所以这样就解决问题了吗?其实并没有,我们考虑这样一种情况,如果A和B都是半透明的物体,并且它们交错在一起。
事实上,半透明效果总归会在一些奇奇怪怪的地方漏出马脚,比如一个扭曲交错的玻璃瓶。我们尽量保证半透明物体和周围环境的交互,让它不要出现这种问题。
既然我们已经知道了得到透明效果需要控制渲染顺序,我们的问题就成了,怎么让Unity3D先渲染半透明物体呢?
Unity3D提出了一个叫做Render Queue的东西,(翻译过来就是渲染队列),这个数字决定了物体的渲染顺序。数字小的会被先渲染,数字大的后渲染。
如果我们希望用RenderQueue来控制物体的渲染顺序,我们需要在着色器代码中指出。
SubShader{
Tags{"Queue"="Transparent"}
Pass{
ZWrite Off
//...other code
}
}
当然,除了Tags,我们还要使用ZWrite Off指令来关闭了深度写入。
Unity有两种实现透明度的方式,一种是透明度测试,一种是透明度混合。
透明度测试一种比较极端的透明方法,它实际上是无法实现半透明效果的,它的透明度要么为1,要么为0,也就是要么完全透明(一点都看不到),要么完全不透明。
当然啦,这是因为它的实现方式导致的,它的实现方式是把依据透明度把指定的像素块剔除掉。Unity为我们提供了一个非常便利的函数clip来实现这个效果。如果参数小于0,那么它就会该像素块剔除。类似于下面的伪代码。
void clip(float4 x){
if(any(x) < 0){
discard; //Cull
}
}
接下来我们来测试一下透明度测试。那么在测试之前呢,我们先准备一张素材,这里的话,透明度测试非常适合用在像素游戏当中,所以这边我使用像素素材来作为案例。
我们新建一个四边面,然后直接把这个图赋值给这个四边面,(它会创建一个默认材质)
不过最终结果就是这样,大部分情况下,我们希望透明的像素被扣掉。接下来我们编写一个新的着色器来尝试把它的透明度不满足一定条件的部分扣掉。
Shader "UShaderMagicBook/AlphaTest"{
Properties{
_MainTex("Main Texture",2D) = "white"{}
_BaseColor("Base Color",Color) = (1.0,1.0,1.0,1.0)
_Alpha("Alpha Threshold",Range(0,1)) = 0
}
SubShader{
Tags{"Queue"="AlphaTest" "IgnoreProjector"="True" "RenderType"="TransparentCutout"}
//AlphaTest规定了使用了该着色器的物体的渲染顺序,并且它现在忽略投影器的影响(IgnoreProjector)
//最后它会被归纳到Unity提前定义好的组当中,TransparentCutout指的就是使用了透明度测试的着色器
pass{
Tags{"LightMode"="ForwardBase"}
//不变
CGPROGRAM
#pragma vertex Vertex
#pragma fragment Pixel
#include "Lighting.cginc"
struct vertexInput{
float4 vertex : POSITION;
float3 normal : NORMAL;
float4 texcoord : TEXCOORD0;
};
struct vertexOutput{
float4 pos : SV_POSITION;
float3 worldNormal : TEXCOORD0;
float3 worldPos : TEXCOORD1;
float2 uv : TEXCOORD2;
};
sampler2D _MainTex;
fixed4 _BaseColor;
fixed _Alpha;
vertexOutput Vertex(vertexInput v){
vertexOutput o;
o.pos = UnityObjectToClipPos(v.vertex);
o.worldNormal = UnityObjectToWorldNormal(v.normal);
o.worldPos = mul(unity_ObjectToWorld,v.vertex);
o.uv = v.texcoord;
return o;
}
fixed4 Pixel(vertexOutput i):SV_TARGET{
fixed3 worldNormal = normalize(i.worldNormal);
fixed3 lightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
fixed4 texColor = tex2D(_MainTex,i.uv);
clip(texColor.a - _Alpha);
//把透明度低于_Alpha的像素全部都剔除掉;
fixed3 albedo = texColor.xyz * _BaseColor.xyz;
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;
fixed3 diffuse = _LightColor0.xyz * albedo * saturate(dot(worldNormal,lightDir));
return fixed4(diffuse + ambient,1.0);
}
ENDCG
}
}
}
然后创建一个新的材质,观察一下效果。
不知道是不是我的游戏开发经验太少了,目前这是我能找到的唯一一个,透明度测试可以用得上的地方。(#/ ~ \#)
ps:其实透明度测试可以不用设置太多东西,直接当成不透明物体来处理就可以了。
ok,到了实现真正的半透明的时候了。
我们之前也了解过混合,当时我们所了解的混合是针对图片的,也就是把主纹理混合,或者把主纹理和一种颜色混合。而我们现在需要做完全不相同的事情,把两个物体的颜色混合。也就是一个是A物体,一个是B物体,我们需要制作出,透过A可以看见B的那种效果。
那么就需要混合A和B的颜色。
那么问题来了,B的颜色在哪里呢?
这个问题非常难回答,因为混合操作我们是没有办法手动控制的,只能配置一些命令,来控制混合的方式,因而我们没有办法形象的体会到Unity的内部究竟是怎么做的。
事实上,Unity为我们提供了一个叫做Blend的命令,该命令可以开启混合,按照我们指定的方式来与颜色缓冲中的颜色进行混合。(当然,B肯定也在里面)
Blend命令有一些参数可选,如下图所示。
我们一般会选择Blend SrcFactor DstFactor来进行混合操作,这里的SrcFactor并不是具体的命令,而是一个参数项,具体的命令还要继续设定。
这就是计算公式,如果我们这里的混合因子SrcFactor,DstFactor都设为1,那就是线性叠加。(线性叠加在光照计算中是常用的混合方式)
我们给出了一种由于关闭了深度写入而造成的错误排序的情况。一种解决方法是使用两个Pass来渲染模型:第一个Pass开启深度写入,但是不输出颜色,它的目的仅仅是为了把该模型的深度值写入到深度缓存中;第二个pass进行正常的透明度混合,由于上一个Pass已经得到了逐像素的正确的深度信息,该Pass就可以按照像素级别的正确的深度排序结果进行透明渲染。但这种方法的缺点在于,多使用一个Pass会对性能造成一定的影响。
不需要增加新的渲染代码,只需要增加一个pass并且开启深度写入即可。
Shader "ShaderLearning/transparent/transparentBlendZWrite"{
Properties{
_SurfaceColor("Surface Color",Color) = (1.0,1.0,1.0,1.0)
_MainTex("Main Tex",2D) = "white"{}
_AlphaScale("Alpha Scale",Range(0,1)) = 1
}
SubShader{
Tags{
"Queue" = "Transparent"
"IgnoreProjector" = "True"
"RenderType" = "Transparent"
}
pass{
ZWrite on // 启动深度写入
ColorMask 0 // 该Pass不输出任何的颜色
}
pass{
//同之前的代码
}
}
}
由于性能上的问题,所以我们要做一定的抉择,在效果和效率之间进行权衡。下面给出开启了深度写入的透明度混合的完整的代码,和效果演示。
Shader "UShaderMagicBook/AlphaBlend_ZWrite"{
Properties{
_MainTex("Main Texture",2D) = "white"{}
_BaseColor("Base Color",Color) = (1.0,1.0,1.0,1.0)
_AlphaScale("Alpha Threshold",Range(0,1)) = 1
}
SubShader{
Tags{"Queue"="Transparent" "IgnoreProjector"="True" "RenderType"="Transparent"}
//记住Transparent就完事了
pass{
ZWrite On
ColorMask 0 // 控制该Pass不输出任何颜色
}
pass{
ZWrite Off
Blend SrcAlpha OneMinusSrcAlpha
//关闭深度写入,同时开启混合。这里我们的混合因子选择了SrcAlpha 和OneMinueSrcAlpha
//这两个计算因子最终可以得到半透明混合的效果。
Tags{"LightMode"="ForwardBase"}
//不变
CGPROGRAM
#pragma vertex Vertex
#pragma fragment Pixel
#include "Lighting.cginc"
struct vertexInput{
float4 vertex : POSITION;
float3 normal : NORMAL;
float4 texcoord : TEXCOORD0;
};
struct vertexOutput{
float4 pos : SV_POSITION;
float3 worldNormal : TEXCOORD0;
float3 worldPos : TEXCOORD1;
float2 uv : TEXCOORD2;
};
sampler2D _MainTex;
fixed4 _BaseColor;
fixed _AlphaScale;
vertexOutput Vertex(vertexInput v){
vertexOutput o;
o.pos = UnityObjectToClipPos(v.vertex);
o.worldNormal = UnityObjectToWorldNormal(v.normal);
o.worldPos = mul(unity_ObjectToWorld,v.vertex);
o.uv = v.texcoord;
return o;
}
fixed4 Pixel(vertexOutput i):SV_TARGET{
fixed3 worldNormal = normalize(i.worldNormal);
fixed3 lightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
fixed3 albedo = tex2D(_MainTex,i.uv).xyz * _BaseColor.xyz;
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;
fixed3 diffuse = _LightColor0.xyz * albedo * saturate(dot(worldNormal,lightDir));
return fixed4(diffuse + ambient,_AlphaScale);
}
ENDCG
}
}
}
最终效果如下图所示
看起来还不错~
仔细观察上面的透明效果,我们发现,正方体的内部是看不见的,这是由于图形引擎为了性能问题,一般会把背面剔除掉。Unity为我们提供了一个命令,Cull
Cull命令三个参数可选,Off | Front | Back,分别是关闭剔除,剔除正面和剔除背面。在透明度测试当中,我们可以放心的使用关闭剔除,来看到正方体的内部,但是在透明度混合当中,直接关闭剔除可能会导致一些问题。此时我们可以准备两个Pass,一个Pass用于渲染正面,一个Pass用于渲染背面。这样就可以保证渲染顺序的正确性。
Pass{
Tags{"LightMode"="ForwardBase"}
Cull Front
//和之前一样的代码
}
Pass{
Tags{"LightMode"="ForwardBase}
Cull Back
//和之前一样的代码
}
完整的代码由于和之前的代码重复性过高,这里就不再放出来了,可以去我的github 下载。
然后要提一点就是,当我们要进行双面透明度渲染的时候,是不能开启深度写入的。不然双面渲染就会失效。
我们在计算透明度混合的时候使用了SrcFactor和DstFactor两个因子,分别对应了SrcAlpha和OneMinusSrcAlpha,当然,Unity提供了更多的混合因子,我们来看一些其他的混合因子,以及他们可以产生的效果。
说起来比较难理解和高大尚的东西,其实只是两三行代码的事,关于混合操作,在开头已经提供了一个非常不错的视频,大家可以再去回顾一下。到此为止,我们的透明度这一章节就算结束了。
所有的代码都已经上传到了我的github,大家可以在下面的链接中找到,如果这篇文章对你有帮助的话,不要忘记点个关注,或者给我的github贡献一颗⭐,谢谢啦。