0.本文示例代码地址
1. 透明物体处理的问题
对于透明物体的处理,是游戏引擎的一个重要能力,通常通过模型的透明通道 alpha来控制物体的透明情况,alpha取值范围为 [0,1],0表示完全透明,看不到该物体,1表示完全不透明。
1.1 不透明物体的渲染顺序
对于完全不透明的物体,渲染的顺序并不会影响渲染的结果,这是因为有深度缓存的存在,深度是如何工作的,可以参考下面这段伪代码。
// 遍历需要渲染的物体
for (i = 0; i < model_list.length; i ++) {
model = model_list[i];
// 针对每个顶点执行顶点着色器
for (j = 0; j < model.vertex.length; j ++) {
v2f[j] = vert_shader(model.vertex[i]);
}
// 针对模型覆盖到的每个像素执行片段着色器,片段着色器的输入是顶点着色器输出插值得到
for (k = 0; k = model.cover_pixels_lsit.length; k ++) {
pixel = model.cover_pixels_lsit[k];
// 像素屏幕坐标
x = pixel.x;
y = pixel.y;
// 像素深度
z = pixel.z;
// 执行片元着色器,计算这个片元的颜色
color = frag_shader(v2f[k]);
// 取出深度缓冲对应坐标的深度值
currentZ = zBuffer[x][y];
// 当前像素的深度更小时,采用当前像素覆盖颜色缓冲区和深度缓冲区
if (z < currentZ) {
colorBuffer[x][y] = color;
if (zwrite == true) {
zBuffer[x][y] = z;
}
}
}
}
这段代码只是为了直观描述我所理解的深度缓存工作原理,实际上的深度测试和写入是渲染管线的一个固定阶段,由GPU执行。
1.2 透明物体的渲染顺序
不透明物体的渲染不依赖于渲染顺序,因为每个像素只会采用一个物体的颜色,我们采用在这个像素上离摄像机最近的物体对应颜色就可以了。这个方法应用于透明物体会存在问题:假设透明物体A位于摄像机和不透明物体B之间,那么摄像机应该能够看到B,然而如果使用深度测试和写入的方法,在深度测试时因为A的深度更小,直接采用了A的颜色,根本看不到B的颜色。一个解决方法是对物体进行先排序,再渲染
- 先渲染所有的不透明物体,开启它们的深度测试和深度写入
- 半透明物体按照距离摄像机的远近排序,从远到近进行渲染,进行透明度测试或混合。
1.3 UnityShader 的渲染顺序
Unity 为了解决渲染顺序的问题,提出了渲染队列 的解决方案,使用 SubShader 的 Queue 标签来确定当前模型归属于那个渲染队列。不同的渲染队列有各自的渲染顺序值,值越小表示越早渲染。这个值可以在模型的材质面板进行调整设置:
Unity 预定义了一批渲染队列如下表:
在材质面板可以通过 RenderQueue 下拉选择这些预定义的渲染顺序,也可以在输入框直接输入你想要的渲染顺序值。
2. 透明度测试 Alpha Test
2.1 透明度测试原理
不同引擎透明测试实现方法可能不一样,透明测试通常是渲染管线的一个固定阶段,在片元着色器之后执行,例如 OpenGL 提供了是否开启透明测试的开关以及测试方法和参数的设置:
glEnable(GL_ALPHA_TEST); // 启用Alpha测试
glDisable(GL_ALPHA_TEST); // 禁用Alpha测试
glAlphaFunc(GL_GREATER, 0.5f); // alpha值大于 0.5 时通过测试
glAlphaFunc(GL_NEVER); // 始终不通过
// ...
而在 Unity 中,透明度测试的方法是在片元着色器中增加代码,针对已计算出的片元颜色的 .a 分量进行测试如果未能通过测试,抛弃当前片元,否则进行后续逐片元操作步骤。
容易得出结论:使用 Alpha Test,像素要么完全透明,要么完全不透明。
2.2 代码分析
先上整体 Shader 代码:
Shader "Shader_Examples/05_AlphaTest"
{
Properties
{
_MainTex ("Texture", 2D) = "white" {}
_AlphaCutout ("AlphaCutout", Range(0,1)) = 1.0
}
SubShader
{
Tags { "Queue"="AlphaTest"}
LOD 100
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};
struct v2f
{
float2 uv : TEXCOORD0;
float4 vertex : SV_POSITION;
};
sampler2D _MainTex;
float4 _MainTex_ST;
fixed _AlphaCutout;
v2f vert (appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
return o;
}
fixed4 frag (v2f i) : SV_Target
{
fixed4 col = tex2D(_MainTex, i.uv);
clip(col.a - _AlphaCutout);
return col;
}
ENDCG
}
}
FallBack "Transparent/Cutout/VertexLit"
}
- 在SubShader中指定物体的渲染队列为 "AlphaTest"
SubShader
{
Tags { "Queue"="AlphaTest"}
- 变量 alphaCutout 用来控制透明度测试的阈值
_AlphaCutout ("AlphaCutout", Range(0,1)) = 1.0
- 片元着色器中透明度测试的逻辑,
clip
函数执行如下操作:若参数小于0,舍弃该片元,否则保留该片元,进行后续处理,可以看出,当纹理中对应位置的 a 分量小于 _AlphaCutout 时,该像素将不会显示。
fixed4 frag (v2f i) : SV_Target
{
fixed4 col = tex2D(_MainTex, i.uv);
clip(col.a - _AlphaCutout);
return col;
}
使用Unity Shader 入门精要中提供的素材:
场景中创建4个quad,使用上面的 05_AlphaTest shader 来创建材质,设置不同的 _AlphaCutout,可以看到不通的效果如下:
从图中可以看出,AlphaTest的透明效果比较极端,只有两种:完全透明或不透明。因为在边界处纹理透明度精度问题,在边界上的效果参差不齐。引入透明度混合,可以得到更好的透明效果。
另外,这里会不会有这样的问题呢?
某个片元经过 alpha 测试后不通过,所以不写颜色缓冲区,但是写了深度缓冲区,会导致在它后面的物体在渲染时因为深度不足而没有被渲染,这种情况会出现吗?
这个问题不存在,因为深度测试和写入的步骤在 alpha 测试之后。在alpha 测试阶段被 discard 的fragment,将不会再执行深度测试和写入。
3. 透明度混合 Alpha Blend
3.1 透明度混合原理
透明度混合(Alpha Blend)是渲染管线的一个固定阶段,我们并不能直接在片元着色器中写代码来实现,而是通过设置对应的操作开关来进行透明度混合相关的设置。透明度混合的原理大概可以用下面的伪代码来表示:
void render(model_list) {
// 遍历需要渲染的物体
for (i = 0; i < model_list.length; i ++) {
model = model_list[i];
// 针对每个顶点执行顶点着色器
for (j = 0; j < model.vertex.length; j ++) {
v2f[j] = vert_shader(model.vertex[i]);
}
// 针对模型覆盖到的每个像素执行片段着色器,片段着色器的输入是顶点着色器输出插值得到
for (k = 0; k = model.cover_pixels_lsit.length; k ++) {
pixel = model.cover_pixels_lsit[k];
// 像素屏幕坐标
x = pixel.x;
y = pixel.y;
// 像素深度
z = pixel.z;
// 执行片元着色器,计算这个片元的颜色
color = frag_shader(v2f[k]);
// 包括 scissor 测试/stencil 测试以及深度测试等等,先忽略
other_test();
if (BlendSetting.on == true){// 开启了混合
srcColor = color;
dstColor_old = colorBuffer[x][y];
// 计算混合颜色
dstColor_new.rgb = srcColor.rgb * srcColor.a + dstColor_old.rgb * (1 - srcColor.a);
dstColor_new.a = srcColor.a * srcColor.a + dstColor_old.a * (1 - srcColor.a);
// 写到颜色缓冲区
colorBuffer[x][y] = dstColor_new;
// 深度写入
if (ZWrite == true) {
zBuffer[x][y] = z;
}
}
}
}
}
这段伪代码中对于 Alpha 混合原理核心的代码是这两行:
// 计算混合颜色
dstColor_new.rgb = srcColor.rgb * srcColor.a + dstColor_old.rgb * (1 - srcColor.a);
dstColor_new.a = srcColor.a * srcColor.a + dstColor_old.a * (1 - srcColor.a);
其实 Alpha Blend 的过程就是根据当前像素计算出的颜色和颜色缓冲区中对应像素的颜色,以一定的计算方式得到最终的颜色,写入到颜色缓冲区,这段伪代码里的计算假定了混合因子的设置选项为如下所示:
Blend SrcAlpha OneMinusSrcAlpha
也就是:使用当前片段的 a 分量作为 RGB和A的贡献值,(1-a)为颜色缓冲区中对应像素的 RGB和A的贡献值。对于混合因子可以有很多不同的设置,将在文章的后面详细列出。
3.2 代码分析
先把完整的包含 Alpha Blend 的 shader 代码贴出来:
Shader "Shader_Examples/05_AlphaBlend"
{
Properties
{
_MainTex ("Texture", 2D) = "white" {}
_AlphaScale ("_AlphaScale", Range(0,1)) = 1.0
}
SubShader
{
Tags { "Queue"="Transparent"}
LOD 100
Pass
{
ZWrite Off
Blend SrcAlpha OneMinusSrcAlpha
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};
struct v2f
{
float2 uv : TEXCOORD0;
float4 vertex : SV_POSITION;
};
sampler2D _MainTex;
float4 _MainTex_ST;
fixed _AlphaScale;
v2f vert (appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
return o;
}
fixed4 frag (v2f i) : SV_Target
{
fixed4 col = tex2D(_MainTex, i.uv);
return fixed4(col.rgb, col.a * _AlphaScale);
}
ENDCG
}
}
FallBack "Transparent/Cutout/VertexLit"
}
3.2.1 变量 alphaScale
代码很简单,就是针对张贴图采样并显示,这里增加了一个变量 _alphaScale 来控制透明的程度,方便看不同透明程度下的混合效果
3.2.2 关闭深度写入
其实 Alpha Blend 未涉及到关于顶点着色器或片元着色器代码的修改,只是在 SubShader 中进行了两个设置
ZWrite Off
如果不关闭深度写入,会渲染半透明物体时写入深度,在渲染一个被它挡住的不透明物体时,该物体会因为深度测试没有通过而没有被绘制出来,这是不对的,解决的办法可以先进行排序,先渲染非透明物体。
但如果还有半透明物体被其他半透明物体挡住呢,深度写入依然可能导致有半透明物体没有被渲染。所以需要针对半透明物体的渲染关闭深度写入,确保被挡住的物体能够被渲染。当然,关闭深度写入也会引入新的问题,我们后面再说。
3.2.3 混合方式设置
为 Pass 设置 Alpha Blend 的混合方式:
Blend SrcAlpha OneMinusSrcAlpha
混合的本质就把当前片元着色器计算出的颜色(源颜色,SrcColor)和颜色缓冲区中已存在的颜色(目标颜色,DstColor)根据一定的规则进行计算,将得到的颜色当做最终的目标颜色写入颜色缓冲区。Unity 可以通过上述 Blend 命令设置计算的规则,如图所示:
Unity 支持的混合因子有:
3.2.4 混合结果
使用上述的 05_AlphaBlend ,设置不同的 _AlphaScale 参数,得到如下的透明度混合效果:
3.2.5 关闭深度写入的问题
在 AlphaBlend 时需要关闭深度写入的原因是,要确保半透明物体背后的物体(透明或半透明)能够被渲染,然而当物体本身有一部分被自己遮挡时,关闭深度写入就会有问题,下面这个示意图是一个 knot 的物体,使用本节的 Alpha Blend shader 渲染得到的结果:
很明显在模型遮挡自己的部分渲染结果不对。
4. 开启深度写入
解决这个问题的办法是,在进行上述 Alpha Blend 渲染前,增加一个 Pass,不处理颜色,只处理深度测试和写入。而原本的 Pass 不变,由于第一个 Pass 已经处理了深度,第二个 Pass 在深度测试时能够得到正确的像素级的排序。
ZWrite Off 只是关闭了深度的写入,深度测试步骤依然会被进行
那么进行深度处理的 Pass 代码改怎么写呢?比较简单,在上述的 05_AlphaBlend.shader 中增加如下一个 Pass 就可以了
Pass
{
ZWrite On
ColorMask 0
}
-
ZWrite On
表示这个 Pass 的渲染需要进行深度写入 -
ColorMask
用来设置颜色通道的 写掩码,通常的语义如下
ColorMask RGB | A | 0 | R 、G 、B 、A 任意组合
当 ColorMask 设置为 0 时,表示该 Pass 不会写颜色缓冲区,这正是我们第一个 Pass 需要的。使用一个 Pass 单独处理深度得到的渲染结果是:
5. 双面渲染的透明效果
大部分时候(尤其是非透明物体)我们只需要渲染物体面向摄像机的一面(正面)就可以了,有些时候我们需要渲染物体的背面时,只要使用 Cull 命令就可以了
默认情况,即Pass中不写 Cull 时,为Cull Back
,即不渲染物体的背面。当设置为 Cull Front
时,只渲染背面而不渲染正面,当设置为 Cull Off
时,不进行剔除,正面和背面都会渲染。
5.1 AlphaTest 关闭背面剔除
在上述的 AlphaTest shader 的 Pass 中,增加如下的代码,渲染时会关闭背面剔除。
Pass {
Cull Off
...
}
如下图所示:左边是Cull Off
,右边是默认也就是Cull Back
的效果:
5.2 AlphaBlend 关闭背面剔除
在进行透明度混合时,由于需要关闭深度写入,将无法保证同一个物体正面和背面的渲染顺序,可能会得到错误的半透明效果,所以需要使用两个 Pass 来进行处理,第一个 Pass 渲染背面,第二个 Pass 渲染正面,以确保正确的渲染顺序。
Pass {
Cull Front
ZWrite Off
...
}
Pass {
Cull Back
ZWrite Off
...
}
得到的效果如下图:左边是 Cull Back
设置下的 AlphaBlend,右边是先后使用两个 Pass 分别 Cull Front
和 Cull Back
参考文献