UnityShader入门精要C8-透明效果

本文记录《UnityShader入门精要》第8章的读书笔记。

很多物体是有半透明效果的,例如玻璃、塑料,这时就需要使用和不透明物体Opaque不同的渲染方法。

第8章主要介绍了两种实现透明的方法,透明度测试(Alpha Test)透明度混合(Alpha Blending)。首先介绍了渲染顺序对结果的影响,然后分别实现了透明度测试和透明度混合。但这两种方法渲染结果离真实相差不小,作为改进,作者继续介绍了开启深度写入的半透明效果,和双面渲染。最后还介绍了ShaderLab的混合命令,混合除了用于透明度混合,还可以实现PhotoShop中的很多效果,例如正片叠底Multiply。

8. 透明效果

要实现透明效果,通常会在渲染模型时控制它的透明通道(Alpha Channel)。在Unity中,有两种方法来实现透明效果:

  • 透明度测试(Alpha Test)
  • 透明度混合(Alpha Blending)

之前我们没有强调渲染顺序的问题,因为对于不透明(opaque)物体,不考虑它们的渲染顺序也能得到正确的排序结果,这是由于强大的深度缓冲的存在(depth buffer,也被称为z-buffer)。如果要渲染的片元,深度值和深度缓冲的比较,距离摄像机更远,就不应该渲染在屏幕上(有物体遮挡住了)。

透明度测试和透明度混合的基本原理:

  • 透明度测试: “霸道极端”,只要一个片元的透明度不满足条件(通常是小于某个阈值),就舍弃。不需要关闭深度写入(ZWrite),要么完全透明看不见,要么完全不透明像不透明物体。
  • 透明度混合: 可以得到真正的半透明效果,使用当前片元的透明度作为混合因子,与已经存储在颜色缓冲中的颜色值进行混合,得到新的颜色值。需要关闭深度写入,不用关闭深度测试,需要非常小心物体的渲染顺序。(没有写入深度,如何进行深度测试,判断这个透明物体是在不透明物体前面还是后面?

8.1 为什么渲染顺序很重要

为什么透明度混合要关闭深度写入呢?如果不关闭,会导致半透明物体表面后面的物体被剔除。关闭深度写入导致渲染顺序变得非常重要。

不同渲染顺序的结果,A遮挡B时:(如何控制渲染顺序呢?

  • A是半透明物体,B是不透明物体:
    • 先渲染B再A:B先写入颜色缓冲和深度缓冲,再用A的透明度和颜色缓冲中的B颜色进行混合,得到正确的半透明效果
    • 先渲染A再B:A写入颜色缓冲,但因为关闭了深度写入,不会修改深度缓冲。B通过了深度测试,直接覆盖A的颜色,视觉上B出现在A前面,错误结果
  • A和B都是半透明物体:
    • 先B再A:正确结果
    • 先A再B:A先写入,B和颜色缓冲中的A混合,看起来B在A前面,错误结果

渲染引擎一般都会对物体进行排序,再渲染:

  1. 先渲染所有不透明物体,并开启它们的深度测试和深度写入
  2. 把半透明物体按它们距离摄像机的远近进行排序,从后往前渲染;并开启它们的深度测试,关闭深度写入

循环重叠的半透明物体,永远无法得到正确的结果(2021年了有办法了吗?)。因为深度缓冲中的值是像素级别的,我们现在对单个物体级别进行排序,会造成要么物体A在B前面渲染,要么B在A前,无法一部分在前一部分在后。

有时无论选取网格上哪点的深度值进行排序,都会得到错误的结果。解决方法通常是分割网格。

8.2 UnityShader的渲染顺序

为解决渲染顺序使用了渲染队列(render queue),可以使用SubShader的Queue标签来决定模型归于哪个渲染队列。Unity内部使用整数索引来表示每个渲染队列,索引越小越早渲染。

使用透明度测试的代码类似这样:

SubShader{
    Tags{"Queue"="AlphaTest"}
    Pass{
        ...
    }
}

使用透明度混合的代码类似这样:

SubShader{
    Tags{"Queue"="Transparent"}
    Pass{
        ZWrite Off
        ...
    }
}

8.3 透明度测试

在片元着色器中使用clip函数进行透明度测试,原理:

void clip(float4 x){
    if(any(x<0)) discard;
}
Shader "ShaderLearning/Shader8.3_AlphaTest"{
    Properties{
        _Color("Main Tint",Color)=(1,1,1,1)
        _MainTex("Main Tex",2D)="white"{}
        _Cutoff("Alpha Cutoff",Range(0,1))=0.5 // 调用clip进行透明度测试的判断条件
    }

    SubShader{
        // 使用AlphaTest队列
        // 不受投影器(Projectors)影响
        // 归入TransparentCutout组,表示这是使用了透明度测试的Shader
        // 使用透明度测试的Shader都应在SubShader中设置这3个标签
        Tags{"Queue"="AlphaTest" "IgnoreProjector"="True" "RenderType"="TranspparentCutout"}

        Pass{
            Tags{"LightMode"="ForwardBase"}

            CGPROGRAM

            #pragma vertex vert
            #pragma fragment frag
            #include "Lighting.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;
                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=UnityObjectToWorldDir(v.vertex);
                o.uv=TRANSFORM_TEX(v.texcoord,_MainTex);

                return o;
            }

            // 在片元着色器进行透明度测试
            fixed4 frag(v2f i):SV_Target{
                fixed3 worldNormal=normalize(i.worldNormal);
                fixed3 worldLight=normalize(UnityWorldSpaceLightDir(i.worldPos));

                fixed4 texColor=tex2D(_MainTex,i.uv);

                // Alpha test
                clip(texColor.a-_Cutoff);
                // Equal to
                // if((texColor.a-_Cutoff)<0.0){discard;}

                fixed3 albedo=texColor.rgb*_Color.rgb;
                fixed3 ambient=UNITY_LIGHTMODEL_AMBIENT.xyz*albedo;
                fixed3 diffuse=_LightColor0.rgb*albedo*max(0,dot(worldNormal,worldLight));

                return fixed4(ambient+diffuse,1.0);
            }

            ENDCG
        }
    }

    Fallback "Transparent/Cutout/VertexLit" // 还可以保证使用透明度测试的物体可以正确向其他物体投射阴影
}

使用作者提供的transparent_texture带透明度的贴图进行测试,这张贴图有4个方块分别带有不同透明度。调整材质中的AlphaCutoff,不同值时结果如下,要么透明要么不透明:

然而得到的透明效果边缘往往参差不齐有锯齿,这是因为边界处纹理的透明度变化精度问题,为了更顺滑可以用透明度混合。

另外发现一点,在我的实践中,这个Cube是有阴影的,而作者的图片是没有阴影的。发现注释掉最后一行Fallback后,我的方块也没有了阴影,所以Cube的阴影是Fallback的这个Shader提供的。那么有个问题,Fallback不应该是当SubShader不起作用时才调用的吗?(详见本书第3章3.3.4小节)在这里我自定义的Shader生效了,调整AlphaCutoff可以得到不同的效果,Fallback也同时起作用了。

查阅资料发现,很多人同样有这个疑惑,我得到了这个解释:

这是因为unity在表面着色器上有个机制,就是投影是单独的系统(大概是这样),如果你没有被称为”ShadowCaster” 或者”ShadowCollector”的pass,而且没有fallback那么shader将不产生阴影,所以你需要至少用Fallback进来Diffuse或者VertexLit(Diffuse实际上包含了VertexLit)来保证表面着色器能自动生成投影的pass。所以这里的Fallback并不是备胎,而是必要的保存投影的方式。当然投影的方式你也能自己写

简而言之,当我们自己的Shader中没有阴影的实现时,会从Fallback中实现阴影

这一观点可参考笔记后引用部分的1/2/3条。

8.4 透明度混合

要用到Unity提供的混合命令Blend:

本节使用 Blend SrcFactor DstFactor 来进行混合,使用Blend命令的时候,Unity就打开了混合模式。经过混合后的新颜色是:

Shader "ShaderLearning/Shader8.4_AlphaBlend"{
    Properties{
        _Color("Main Tint",Color)=(1,1,1,1)
        _MainTex("Main Tex",2D)="white"{}
        _AlphaScale("Alpha Scale",Range(0,1))=1 // 用于在透明纹理的基础上控制整体的透明度
    }

    SubShader{
        // 使用Transparent队列
        // 不受投影器(Projectors)影响
        // 归入Transparent组,表示这是使用了透明度混合的Shader
        // 使用透明度混合的Shader都应在SubShader中设置这3个标签
        Tags{"Queue"="Transparent" "IgnoreProjector"="True" "RenderType"="Transparent"}

        Pass{
            Tags{"LightMode"="ForwardBase"}

            ZWrite Off // 关闭深度写入
            Blend SrcAlpha OneMinusSrcAlpha // 开启该Pass的混合模式

            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=UnityObjectToWorldDir(v.vertex);
                o.uv=TRANSFORM_TEX(v.texcoord,_MainTex);

                return o;
            }

            // 在片元着色器进行透明度测试
            fixed4 frag(v2f i):SV_Target{
                fixed3 worldNormal=normalize(i.worldNormal);
                fixed3 worldLight=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,worldLight));

                // 设置了返回值中的透明通道
                return fixed4(ambient+diffuse,texColor.a*_AlphaScale);
            }

            ENDCG
        }
    }

    Fallback "Transparent/VertexLit"
}

透明度混合的关键是,在Pass的开头设置了透明度混合所需要的Tags,并开启了混合模式Blend;并在片元着色器最后的返回值的透明通道中,将整体透明度系数和贴图透明通道值相乘,作为最终颜色的透明度。

不同AlphaScale下的半透明效果如下:

在透明度混合中,即使设置了Fallback,也没有阴影(这个是正常现象,印象中在UE4里,半透明物体也是不投射阴影的)。

如果在Pass的Tags中,不进行正确的设置,会如何呢?例如把Queue设置为AlphaTest,或者把RenderType设置为TransparentCutout,结果也是能正常渲染的,效果和都设置为Transparent一致。猜测这是由于场景比较简单,只有背景、不透明平面和半透明方块,它们的渲染顺序没有变化。如果场景复杂了,可能会产生问题,总之Tags还是需要按要求设置好。

当模型本身有复杂的遮挡关系,或是包含了复杂的非凸网格的时候,会有各种各样因为排序错误产生的错误的透明效果,这是由于无法对模型进行像素级别的深度排序造成的。下图为渲染Knot(环形节)的效果:

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

为了解决由于关闭深度写入而造成的错误排序的情况,可以使用两个Pass来渲染模型:

  • 第一个Pass开启深度写入,但不输出颜色,仅把该模型的深度值写入深度缓冲
  • 第二个Pass进行正常的透明度混合,因为有了深度信息,就可以按照像素级别的深度排序结果进行渲染

损失了一定的性能,实现模型与后面背景的混合,但模型内部没有真正的半透明效果。

开启深度写入的Pass,只需要在原来使用的Pass前面增加一个新的Pass。

 Tags{"Queue"="Transparent" "IgnoreProjector"="True" "RenderType"="Transparent"}

// Extra pass that renders to depth buffer only
Pass{
    ZWrite On
    ColorMask 0 // 该Pass不写入任何颜色通道,即不输出任何颜色
}

Pass{
    // ...透明度混合
}

渲染Knot的结果如下,会被自身片元遮挡的,但看起来还是不够真实…

8.6 ShaderLab的混合命令

混合还有很多其他用处,不仅仅用于透明度混合。

混合的实现:源颜色(source color)目标颜色(destination color)

  • 源颜色S:由片元着色器产生的颜色
  • 目标颜色D:从颜色缓冲中读取到的颜色值
  • 输出颜色O:混合后的颜色,重新写入颜色缓冲

8.6.1 混合等式和参数

混合等式(blend equation):已知S和D,得到O的等式。

混合是一个逐片元的操作,不可编程,但高度可配置。可以设置混合时使用的运算操作、混合因子,需要两个等式分别用于RGB和A。

第一个命令使用同样的因子来混合RGB通道和A通道。使用这些因子进行加法混合时使用的混合等式:

  • Orgb=SrcFactorSrgb+DstFactorDrgb
  • Oa=SrcFactorSa+DstFactorDa

ShaderLab支持的混合因子如下:

使用不同参数混合A通道的命令如下:

Blend SrcAlpha OneMinusSrcAlpha, One Zero

8.6.2 混合操作

以上的混合等式默认是使用加法,使用ShaderLab的混合操作命令BlendOp BlendOperation,可以进行更改例如减法:

8.6.3 常见的混合类型

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

// 正常(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

分析一下以上混合模式的公式,S源颜色、D颜色缓冲中的颜色(认为是后面物体的颜色吧)、O输出:

  • 正常:O=SxSa+Dx(1-Sa)
  • 柔和相加:O=Sx(1-Drgba)+D
  • 正片叠底:O=SxDrgba=(SrxDr,SgxDg,SbxDb,SaxDa)
  • 两倍相乘:O=SxDrgba+DxSrgba=(SrxDr+DrxSr,SgxDg+DgxSg,SbxDb+DbxSb,SaxDa+DaxSa)=2(SrxDr,SgxDg,SbxDb,SaxDa)
  • 变暗:O=Min(S,D)
  • 变亮:O=Max(S,D)
  • 滤色:O=Sx(1-Drgba)+D,同柔和相加?
  • 线性减淡:O=S+D

为了验证混合模式,新建场景放置Cube在(-0.25,1,0),放置Cylinder在(0.25,1,-2),使圆柱遮挡方块方块。

方块使用Shader5.2_SimpleShader,将片元着色器输出的颜色值改为固定的(0.0,1.0,0.0,1.0),即不透明绿色(0,255,0,255),方块在屏幕上的颜色也是(0,255,0,255)。

圆柱使用Shader8.6_BlendOperation,将片元着色器输出的颜色改为fixed4(_Color.rgb,texColor.ax_AlphaScale),不受环境光和漫反射影响。MainTint使用红色(255,0,0,255),AlphaScale设置为0.4,最终圆柱片元着色器输出颜色应该为(255,0,0,102),圆柱在屏幕上的颜色应该是(255,0,0,102)x0.4=(102,0,0,41)。相机位置在(0,1,-10),设置为正交投影,Size1.3,挂上脚本ColorPicker用于直观查看色值。相机背景颜色设置为纯黑(0,0,0,0),方便计算。

那么各混合模式圆柱和重叠部分的在屏幕上的色值应该如下:

  • 正常:(255,0,0,102)x0.4=(102,0,0,41),(255,0,0,102)x0.4+(0,255,0,255)x0.6=(102,153,0,194)
  • 柔和相加:(255,0,0,102)x1=(255,0,0,102),(255x1+0,0+255x1,0,102x0+255x1)=(255,255,0,255)
  • 正片叠底:(255x0,0x0,0x0,102x0)=(0,0,0,0),(255x0,0x1,0x0,102x1)=(0,0,0,102)
  • 两倍相乘:(0,0,0,0)x2=(0,0,0,0),(0,0,0,102)x2=(0,0,0,204)
  • 变暗:(0,0,0,0),(0,0,0,102)
  • 变亮:(255,0,0,102),(255,255,0,255)
  • 滤色:同柔和相加
  • 线性减淡:(255,0,0,102),(255,255,0,102+255)=(255,255,0,255)

结果如下,有很多重复了,不是很有代表性…

8.7 双面渲染的透明效果

之前无论是透明度测试还是透明度混合,都无法观测到正方体内部和背面的形状。这是因为默认下渲染引擎剔除了物体背面(相对于相机的方向),只渲染了正面。可以使用Cull指令来控制需要剔除哪个面,语法如下:

Cull Back|Front|Off

8.7.1 透明度测试的双面渲染

只需要在Pass的渲染设置中使用Cull指令来关闭剔除:

Pass{
    Tags{"LightMode"="ForwardBase"}

    // Turn off culling
    Cull Off

    CGPROGRAM
    ...

8.7.2 透明度混合的双面渲染

因为透明度混合需要关闭深度写入,所以不能直接关闭剔除功能,这样无法保证同一物体的正面和背面图元的渲染顺序。

为此我们把双面渲染的工作分成两个Pass——第一个Pass只渲染背面,第二个Pass只渲染正面。

将透明度混合的Pass复制一个,在两个Pass中分别使用Cull指令剔除不同朝向的渲染图元:

Pass{
    Tags{"LightMode"="ForwardBase"}

    // First pass renders only back faces
    Cull Front
    ...
}

Pass{
    Tags{"LightMode"="ForwardBase"}

    // Second pass renders only front faces
    Cull Back
    ...
}

999. 引用

  1. unity关于shader投影 Fallback实际上并不是备胎:https://www.it610.com/article/5036292.htm
  2. ShaderLab:Fallback:https://docs.unity3d.com/cn/2019.4/Manual/SL-Fallback.html
  3. Fallback Off turns off shadows in surface shader:https://forum.unity.com/threads/fallback-off-turns-off-shadows-in-surface-shader.257430/

转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 cdd@ahucd.cn

×

喜欢就点赞,疼爱就打赏

B站 cdd的庇护之地 github itch