本系列为作者学习UnityShader入门精要而作的笔记,内容将包括:
- 书本中句子照抄 + 个人批注
- 项目源码
- 一堆新手会犯的错误
- 潜在的太监断更,有始无终
总之适用于同样开始学习Shader的同学们进行有取舍的参考。
文章目录
透明效果
想要在实时渲染中实现透明效果,我们通常需要在渲染模型时控制它的透明通道(Alpha Channel) 。当开启透明混合之后,物体在渲染时除了颜色值和深度值之外,还有另一个属性——透明度。当透明度为1的时候,表示该像素是完全不透明的,而当其为0代表完全透明。
在Unity中,我们通常使用两种方法来实现透明度效果:
- 透明度测试(Alpha Test)
- 透明度混合(Alpha Blending)
在之前的案例中,我们在渲染时是不考虑渲染顺序的,因为对于不透明物体即使不考虑渲染顺序也可以得到正确的渲染结果。这是由于Z-buffer深度缓冲 的存在,它会根据片元和相机的距离自动处理渲染顺序。
Z-buffer的基本思想是:当渲染一个片元时,需要将它的深度值和已经存在于深度缓冲中的值进行比较(如果开启了深度测试),如果它的值距离摄像机更远,则这个片元不应该被渲染,否则如果它的值距离摄像机更近,则应当覆盖掉颜色缓冲中的像素值,并把它的深度值更新到深度缓冲中(如果开启了深度写入)
因此使用深度缓冲(也就是开启深度写入Z-buffer)。我们就不必考虑物体渲染顺序的问题,片元会在深度测试中根据摄像机的距离自动渲染,而想要实现透明效果,就要开启透明度混合,此时会关闭深度写入。
透明度测试和透明度混合的原理如下:
- 透明度测试: 若一个片元的透明度不满足条件(通常是小于某个阈值),那么它对应的片元就会舍弃。否则保留,因此透明度测试要么全透明,要么不透明。因此透明度测试是不需要关闭深度写入的。
- 透明度混合: 透明度混合可以实现真正的半透明效果,它会使用当前片元的透明度作为混合因子,与已经存储在颜色缓冲中的颜色值进行混合得到新的颜色。但是透明度测试会关闭深度写入,这使得我们必须非常小心物体的渲染顺序。但是透明度混合并不会关闭深度测试,对透明度混合来说,深度缓冲是只读的,它会透明度混合渲染片元时会比较深度值与当前深度缓冲中的深度值,如果它的深度值距离摄像机更远,那就不会再进行混合操作。
为什么渲染顺序很重要
假设我们需要渲染一个半透明物体A,一个不透明物体B,且A距离相机比B距离相机更近。如果这个A先被渲染,半透明混合时对深度测试是只读的,不会写入。因此当渲染不透明物体B时,B通过深度测试发现深度值中并没有写入(透明度混合不写入深度),因此B就会直接覆盖掉A的颜色值,就导致了渲染错误。
因此在透明度混合下,我们需要确定渲染顺序,避免渲染顺序错误导致的渲染问题。
此外,若物体AB都是半透明物体。若先渲染A再渲染B,B会在A的前面,反之则A在B的前面。
因此,在透明度混合的情况下,无论物体是否透明,我们都需要注意渲染顺序。
因此引擎的渲染顺序往往是:
(1)先对不透明物体进行渲染,并开启他们的深度测试和深度写入。
(2)将半透明物体按它们距离摄像机的远近进行排序,然后按照从后往前的顺序渲染这些半透明物体,并开启它们的深度测试,但关闭深度写入。
即使这样,半透明物体的渲染依然存在问题——由于深度值是像素级的(每个像素都有深度值),而对于物体的深度判断是由单个物体进行排序的,那么如果出现上图8.3的情况,这三个循环重叠的半透明物体的渲染顺序究竟哪个先哪个后呢?
因此,为了解决这种情况,我们通常会使用分割的方法解决,我们可以将一个物体分割为两个不同的部分
还有一种情况,如上图8.4所示,即使我们要排序,那么我们应该根据哪个点的深度值来进行排序,在该图的情况中,无论使用网格中点,最近点,最远点,排序的结果都是A在B的前面,但是A实际上有一部分被B所遮挡了。这种情况的解决方法通常也是分割网格。
为了解决上述问题,我们尽量使得模型是凸面体,并且考虑将复杂的模型拆分成多个独立的子模型。如果我们不想分割网格,也可以试着让透明通道更加柔和,使得渲染的穿插看起来不是那么的明显,也可以使用开启了深度写入的半透明效果来近似模拟物体的半透明。
UnityShader的渲染顺序
Unity为了解决渲染顺序的问题,提供了渲染队列(Render Queue) 这一解决方案。我们可以使用SubShader的Queue标签来决定我们的模型将归于哪个渲染队列。
Unity内部使用了一系列的整数索引来表示每个渲染队列,且索引号越小表示越早被渲染。
我们根据需要将物体置于不同的队列进行渲染
如果我们想要通过透明度测试实现透明效果,则代码中应包含下面的代码:
SubShader{
Tags {"Queue" = "AlphaTest"}
Pass{....}
}
如果想要使用透明度混合来实现透明效果,代码中应包含类似下面的代码:
SubShader{
Tags {"Queue" = "Transparent"}
Pass{
ZWrite Off
...
}
}
其中,ZWrite Off用于关闭深度写入,在透明度混合中深度值是只读的。我们可以将其写在Pass块中,也可以写在SubShader块中,代表了该SubShader块下的所有Pass都会关闭深度写入。
透明度测试
让我们在Shader中实现透明度测试的代码。原理很简单:若一个片元的透明度不满足某个条件(通常是小于某个阈值),那么它对应的片元就会被舍弃。否则就按照不透明物体来处理。
我们通常使用clip函数进行透明度测试:
我们用样例给出的纹理进行透明度测试:
Shader "Custom/AlphaTest_Shader_Copy"
{
Properties
{
_Color("Color Tint",Color) = (1,1,1,1)
_MainTex("Main Tex",2D) = "white" {}
_CutOff("Alpha CutOff",Range(0,1)) = 0.5
}
SubShader
{
// 队列使用透明度测试,IgnoreProjector设置为true意味着Shader不受投影器影响
// RenderType可以让Unity把Shader归入到提前定义的组————此处为TransparentCutout组
// 使用了透明度测试的Shader都应该在SubShader中设置这三个标签
Tags{"Queue" = "AlphaTest" "IgnoreProjector" = "True" "RenderType"= "TransparentCutout"}
Pass
{
Tags{"LightMode" = "ForwardBase"}
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Lighting.cginc"
#include "UnityCG.cginc"
fixed4 _Color;
sampler2D _MainTex;
float4 _MainTex_ST;
fixed _CutOff;
struct a2v
{
float4 vertex : POSITION;
float3 normal : NORMAL;
float4 texcoord : TEXCOORD0;
};
struct v2f
{
float4 pos :SV_POSITION;
float2 uv : TEXCOORD0;
float3 worldNormal : TEXCOORD1;
float3 worldPos : TEXCOORD2;
};
v2f vert(a2v v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv.xy = v.texcoord.xy * _MainTex_ST.xy + _MainTex_ST.zw;
//o.worldNormal = mul(unity_ObjectToWorld,v.normal).xyz;
o.worldNormal = UnityObjectToWorldNormal(v.normal);
o.worldPos = mul(unity_ObjectToWorld,v.vertex).xyz;
return o;
}
float4 frag(v2f i):SV_Target
{
fixed3 worldNormalDir = normalize(i.worldNormal);
fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
fixed4 texColor = tex2D(_MainTex,i.uv.xy);
// 舍弃alpha值小于CutOff的片元
clip(texColor.a - _CutOff);
fixed3 albedo = texColor.rgb * _Color.rgb;
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo.xyz;
fixed3 diffuse = _LightColor0.rgb * albedo.rgb * saturate(dot(worldNormalDir,worldLightDir));
return fixed4(diffuse + ambient,1.0);
}
ENDCG
}
}
Fallback "Transprant/Cutout/VertexLit"
}
最终我们只需调整_CutOff
的值就能舍弃掉对应的片元了
透明度测试得到的效果很极端——要么完全透明,要么完全不透明,并且由于边界处纹理的透明度精度问题,使得舍弃的片元边缘存在锯齿。为了得到更加丝滑的透明效果,我们会选择使用透明度混合。
透明度混合
透明度混合可以得到真正的半透明效果,它会使用当前片元的透明度作为混合因子,与已经存储在颜色缓冲中的颜色值进行混合,得到新的颜色,但是,透明度混合需要关闭深度写入,因此我们需要非常小心物体的渲染顺序。
为了进行混合,我们需要使用unity提供的混合命令——Blend
在本节中我们将使用第二种语义:即Blend SrcFactor DstFactor来进行混合。需要注意的是,这个命令在设置混合因子的同时也开启了混合模式,因为只有开启混合之后,设置片元的透明通道才有意义,而Unity在我们使用Blend命令的时候就自动帮我们打开混合模式了。
所以如果模型没有混合透明效果,往往是因为在Pass块中没有使用Blend命令,一方面是没有设置混合因子,甚至可能根本没打开混合模式。
我们会把源颜色的混合因子SrcFactor设置为SrcAlpha,而目标颜色的混合因子DstFactor设为OneMinusScrAlpha。这意味着混合后的新颜色是:
D
s
t
C
o
l
o
r
n
e
w
=
S
r
c
A
l
p
h
a
×
S
c
r
C
o
l
o
r
+
(
1
−
S
r
c
A
l
p
h
a
)
×
D
s
t
C
o
l
o
r
o
l
d
DstColor_{new}=SrcAlpha × ScrColor + (1-SrcAlpha)×DstColor_{old}
DstColornew=SrcAlpha×ScrColor+(1−SrcAlpha)×DstColorold
让我们根据上述公式实现一个混合透明度shader:
Shader "Custom/AlphaBlend_Shader_Copy"
{
Properties
{
_Color("Color Tint",Color) = (1,1,1,1)
_MainTex("Main Tex",2D) = "white" {}
_AlphaScale("AlphaScale",Range(0,1)) = 1
}
SubShader
{
Tags{"Queue" = "Transparent" "IgnoreProject" = "true" "RenderType" = "Transparent"}
Pass
{
Tags{"LightMode" = "ForwardBase"}
// 关闭深度写入,设置混合模式,其中源混合因子SrcFactor为源透明度SrcAlpha
// 目标混合因子DstFactor 为 1-SrcAlpha
ZWrite Off
Blend SrcAlpha OneMinusSrcAlpha
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Lighting.cginc"
#include "UnityCG.cginc"
fixed4 _Color;
sampler2D _MainTex;
float4 _MainTex_ST;
float _AlphaScale;
struct a2v
{
float4 vertex : POSITION;
float3 normal : NORMAL;
float3 texcoord : TEXCOORD0;
};
struct v2f
{
float4 pos:SV_POSITION;
float3 worldPos : TEXCOORD0;
float3 worldNormal : TEXCOORD1;
float2 uv : TEXCOORD2;
};
v2f vert(a2v v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv.xy = v.texcoord.xy * _MainTex_ST.xy + _MainTex_ST.zw;
o.worldNormal = UnityObjectToWorldNormal(v.normal);
o.worldPos = mul(unity_ObjectToWorld,v.vertex).xyz;
return o;
}
fixed4 frag(v2f i):SV_Target
{
fixed3 worldNormalDir = normalize(i.worldNormal);
fixed3 worldLighrDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
fixed4 texColor = tex2D(_MainTex,i.uv);
fixed3 albedo = texColor.xyz * _Color.rgb;
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo.xyz;
fixed3 diffuse = _LightColor0.rgb * albedo.rgb * saturate(dot(worldNormalDir,worldLighrDir));
// 此处与书中不同,我对最终的输出透明度加了个判断,使得透明度scale仅对透明部分像素起作用
fixed finalAlpha = texColor.a;
if(texColor.a < 1)
{
finalAlpha = texColor.a * _AlphaScale;
}
// 输出时注意alpha通道进行计算
return fixed4(ambient + diffuse,finalAlpha);
}
ENDCG
}
}
}
按照上述代码调整scale,保留了网格部分的UV颜色值(alpha=1),对透明部分UV颜色进行了修改
如果用上述的shader赋予结构较为复杂的网格,就会得到错误的半透明效果,究其原因是因为我们关闭了深度写入,所以所有重叠的不透明片元都进行了透明度混合。
开启深度写入的半透明效果
针对上面讲的这种错误情况,一种解决方法是:使用两个Pass来渲染模型:第一个Pass开启深度写入,但不输出颜色,它的目的仅仅是为了将该模型的深度值写入到深度缓冲中;第二个Pass进行正常的透明度混合,由于第一个Pass已经获得了逐像素的正确的深度信息,因此该Pass就可以根据深度信息进行正确的透明渲染,这种方法的缺点在于:多使用一个Pass对性能造成了一定的影响
让我们试试这种方法:
Shader "Custom/AlphaBlend_ZWrite_Shader_Copy"
{
Properties
{
_Color("Color Tint",Color) = (1,1,1,1)
_MainTex("Main Tex",2D) = "white" {}
_AlphaScale("Alpha Scale",Range(0,1)) = 1
}
SubShader
{
Tags{"Queue" = "Transparent" "IgnoreProjector" = "True" "RenderType" ="Transparent"}
// 只需增加一个进行深度写入的Pass即可
Pass {
ZWrite On
// ColorMask用于设置颜色通道的 写掩码(write mask)
// 包括 RGB | A | 0 | 以及其他RGBA的组合 ,当其为对应值时代表对对应通道写入颜色值,当为0则不写入任何颜色值
ColorMask 0
}
Pass
{
Tags{"LightMode" = "ForwardBase"}
ZWrite Off
Blend SrcAlpha OneMinusSrcAlpha
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Lighting.cginc"
#include "UnityCG.cginc"
fixed4 _Color;
sampler2D _MainTex;
float4 _MainTex_ST;
float _AlphaScale;
struct a2v
{
float4 vertex : POSITION;
float4 texcoord : TEXCOORD0;
float3 normal : NORMAL;
};
struct v2f
{
float4 pos : SV_POSITION;
float2 uv : TEXCOORD0;
float3 worldNromal : TEXCOORD1;
float3 worldPos : TEXCOORD2;
};
v2f vert(a2v v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv.xy = _MainTex_ST.xy * v.texcoord.xy + _MainTex_ST.zw;
o.worldNromal = UnityObjectToWorldNormal(v.normal);
o.worldPos = mul(unity_ObjectToWorld,v.vertex).xyz;
return o;
}
fixed4 frag(v2f i):SV_Target
{
fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.pos));
fixed3 worldNormalDir = normalize(i.worldNromal);
fixed4 texColor = tex2D(_MainTex,i.uv);
fixed3 albedo = _Color.rgb * texColor.xyz;
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo.xyz;
fixed3 diffuse = _LightColor0.rgb * albedo.rgb * saturate(dot(worldLightDir,worldNormalDir));
return fixed4(ambient + diffuse , _AlphaScale * texColor.a);
}
ENDCG
}
}
FallBack "Transparent/VertexLit"
}
总之只需要在透明度混合的Pass前添加一个Pass提前写入深度值(不写入颜色值)即可。这样透明度混合时就能对提前写入的深度值进行深度测试了。
ShaderLab中的混合命令
透明度的混合实际上就是颜色的混合。当片元着色器产生一个颜色的时候,可以选择与颜色缓冲中的颜色进行混合。这样混合就和两个操作数相关:源颜色(source color) 喝目标颜色(Destination color) 。
源颜色,我们使用S来表示,指的是由片元着色器产生的颜色值。而目标颜色用D来表示,指的是目标颜色缓冲区内的颜色值。将他们进行混合之后的颜色值我们用O表示,它会重写写入到颜色缓冲中。透明度混合时总是包含了RGBA四个通道的。
在Unity Shader中,我们使用Blend命令来开启混合模式(在其他图形API中是需要我们手动开启的)
混合等式和参数
混合是一个逐片元的操作,因此它并不是可编程的,却是高度可配置的。我们可以在设置混合时使用运算操作和混合因子来影响混合:
已知源颜色S和目标颜色D,获得混合颜色O。其中的计算公式就被我们称为混合等式(blend equation) 。当进行混合时,我们需要使用两个混合等式:一个用于混合RGB通道,一个混合A通道 。
在配置混合状态时,实际上我们就是在设置混合等式中的混合因子和操作。默认情况下混合等式使用的都是加操作,而混合因子SrcFactor和DstFactor则分别用于同源颜色和目标颜色相乘,因此总共可以设置四个因子:
这四个混合因子是可配置的,不过不能自由赋值,我们只能使用预定义语义赋值:
举个例子:假设我们想要实现混合透明度的输出颜色在RGB通道上混合,但透明度按照源颜色透明度输出,就可以根据Blend SrcFactor DstFactor , SrcFactorA DstFactorA 进行下列定义:
Blend SrcAlpha OneMinusSrcAlpha, One Zero
根据之前的公式,则可知混合颜色在RGB通道上按照源颜色的透明度混合,而最后的A通道则完全按照源颜色的透明度值进行输出。
混合操作
那么我们是否可以使用不同的混合操作呢?当然是可以的:
BlendOp BlendOperation
具体语义如下:
需要注意的是,使用Min和Max之后,混合操作只会逐通道地选取二者的最值,混合因子就不起作用了。
需要注意的是,混合因子不影响Max和Min操作。在不设置混合操作的情况下默认使用加法。
双面透明渲染效果
在我们之前渲染的透明立方体中,实质上效果还是很奇怪的。因为我们看不到这个透明物体的内部结构,仅仅是与身后的背景进行了透明度混合,这种情况是因为该立方体面向摄像机的图元都被渲染了,而立方体背面的不会显示在摄像机中的图元则被自动剔除。因此最终的透明度混合效果就是面向摄像机的图元间的混合(正方体的正面和背景的正面)
那么想要看到内部结构,就应当使得内部的片元也进行透明度混合,也就是使得背向摄像机的图元也被渲染之后混合。一般情况下我们看不见背向图元,因此默认是对背向剔除的。现在想要看到背向的颜色值也进行透明度混合,因此我们需要将其渲染出来。这通常会导致片元的数量加倍。因此如果非必要,就不要关闭背向剔除功能。
我们用Cull指令来控制需要剔除哪个面的图元:
Cull Back | Front | Off
剔除正面和背面的立方体
同样进行透明度测试的shader,显然如果剔除了背面是看不见内部的透明结构的。
透明度混合的双面渲染
想要实现内部结构透明度混合的渲染效果,我们需要注意:由于透明度混合时关闭了深度写入,因此往往会造成一些问题。我们无法保证片元的渲染顺序,因此在深度测试时就可能得到错误的结果。
正确的解决思路是从后往前依次进行透明度混合。因此我们需要用两个Pass,一个对背面片元进行透明度混合,一个对正面片元进行透明度混合:
Shader "Custom/AlphaBlendBothSide_Copy"
{
Properties
{
_Color("Color Tint",Color) = (1,1,1,1)
_MainTex("Main Texture",2D) = "white" {}
_AlphaScale ("Alpha Scale" , Range(0,1)) = 1
_CutOff("Cut Off",Range(0,1)) = 0.5
}
SubShader
{
Tags{"Queue"="Transparent" "IgnoreProjector" = "True" "RenderType" = "Transparent"}
Pass
{
Tags{"LightingMode" = "ForwardBase"}
// 背面渲染
Cull Front
Blend SrcAlpha OneMinusSrcAlpha
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Lighting.cginc"
#include "UnityCG.cginc"
fixed4 _Color;
sampler2D _MainTex;
float4 _MainTex_ST;
fixed _AlphaScale;
fixed _CutOff;
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.uv = _MainTex_ST.xy * v.texcoord.xy + _MainTex_ST.zw;
o.worldNormal = UnityObjectToWorldNormal(v.normal);
o.worldPos = mul(unity_ObjectToWorld,v.vertex);
return o;
}
fixed4 frag(v2f i):SV_Target
{
fixed3 worldNormalDir = 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.rgb;
clip(TexColor.a - _CutOff);
fixed3 diffuse = _LightColor0.rgb * albedo.rgb * saturate(dot(worldLightDir,worldNormalDir));
return fixed4(ambient.xyz + diffuse.xyz , _AlphaScale * TexColor.a);
}
ENDCG
}
Pass
{
Tags{"LightingMode" = "ForwardBase"}
// 正面渲染
Cull Back
Blend SrcAlpha OneMinusSrcAlpha
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Lighting.cginc"
#include "UnityCG.cginc"
fixed4 _Color;
sampler2D _MainTex;
float4 _MainTex_ST;
fixed _AlphaScale;
fixed _CutOff;
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.uv = _MainTex_ST.xy * v.texcoord.xy + _MainTex_ST.zw;
o.worldNormal = UnityObjectToWorldNormal(v.normal);
o.worldPos = mul(unity_ObjectToWorld,v.vertex);
return o;
}
fixed4 frag(v2f i):SV_Target
{
fixed3 worldNormalDir = 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.rgb;
clip(TexColor.a - _CutOff);
fixed3 diffuse = _LightColor0.rgb * albedo.rgb * saturate(dot(worldLightDir,worldNormalDir));
return fixed4(ambient.xyz + diffuse.xyz , _AlphaScale * TexColor.a);
}
ENDCG
}
}
}
将透明度混合的Pass重复渲染两次,实现了这个效果