《Unity Shader 入门精要》学习总结04


透明渲染

需要在实时渲染中实现透明效果,通常会在渲染模型时控制透明通道,并开启透明混合,使其与之前的渲染结果进行混合,而不是直接覆盖。

在 Unity 中实现透明效果,第一种是透明度测试(Alpha Test),还有一种是透明度混合(Alpha Blending)。

  • 透明度测试:判断片元透明度是否满足条件,若是不满足,直接舍弃;满足则使用不透明物体的处理方式,不需要关闭深度写入,是一种开销小的方式。抖动透明就是基于透明度测试实现的。
  • 透明度混合:真正的半透明效果,开启后会使用当前片元的透明度作为混合因子,与颜色缓冲中的颜色进行混合,得到新颜色。在使用透明度混合时,我们需要关闭深度写入,也就是说,对于透明度混合渲染来说,深度数据是只读的。此外我们还需要小心处理渲染顺序。

渲染顺序

渲染顺序在透明度混合渲染中是一个大难点。因为透明度混合渲染需要关闭深度写入,还需要正确的顺序进行混合。如果我们没有关闭深度写入,会导致特定渲染顺序下,后面的片元被剔除。但是,在关闭深度写入后,我们需要格外注意渲染顺序。可以看看下面的例子。

渲染顺序示例图

前置设定
左图 右图
A(近) 透明 α 透明 α
B(远) 不透明 透明 β
背景 G 已渲染(颜色 g) 已渲染(颜色 g)
深度测试 始终开启 始终开启
深度写入 A 关闭,B(不透明)开启 两个均关闭 或 开启后做对比
混合算子 α·src + (1-α)·dst 同左
左图情况 1:先 B 后 A
  1. B(不透明)先渲染:颜色缓冲 = b,深度缓冲 = B 的深度。
  2. A(透明)后渲染:深度测试通过(A 更近),关闭深度写入。
    混合: Cfinal=αa+(1α)b 得到 A 叠加在 B 上的正确半透明效果。
左图情况 2:先 A 后 B
  1. 背景 G:颜色缓冲 = g,深度缓冲 = ∞。
  2. A(透明)先渲染:深度测试通过,关闭深度写入(深度缓冲仍为 ∞)。
    混合: Cbuf=αa+(1α)g
  3. B(不透明)后渲染:深度测试通过(B 的深度 < ∞),B 直接覆盖 Cfinal=b A 完全消失。
右图情况 1:先 B 后 A
  1. 背景 G:C_buf = g
  2. B(透明)渲染: Cbuf=βb+(1β)g
  3. A(透明)渲染: Cfinal=αa+(1α)[βb+(1β)g] 正确:A 的 α 叠加在"B+背景"之上。
右图情况 2:先 A 后 B
  1. 背景 G:C_buf = g
  2. A(透明)先渲染: Cbuf=αa+(1α)g
  3. B(透明)后渲染:深度测试通过(深度写入关闭,或者深度缓冲区没有 A 的记录),混合: Cfinal=βb+(1β)[αa+(1α)g] 错误:B 的 β 现在作用在"A+背景"上,视觉上 B 浮到了 A 前面。

若深度写入开启:A 先写深度 → B 深度测试失败 → B 被丢弃,只剩 A+背景,也是错误。

透明混合的 over 算子中 α 只属于"源"片元。交换渲染顺序就交换了"谁是源",于是 α 和 β 作用的对象完全改变。关闭深度写入防止了错误排序时片元被完全丢弃,但正确的从远到近排序仍是获得正确半透明画面的唯一途径。

从上面的例子看出,不管是半透明和不透明混合,还是两个半透明混合,渲染顺序都非常重要。

所以,Unity 在渲染物体时,会对物体进行排序,再按照顺序进行渲染,其常规逻辑是:

  1. 先渲染所有不透明物体
  2. 再以从后往前的顺序渲染所有的透明物体

但是纵使按照这样的渲染逻辑来渲染,依旧会产生错误的混合渲染结果。这个错误便是源于我们无法精确地给出半透明物体的渲染排序。
在 Unity 中,可能两个物体不是完全的一前一后,有可能是相互交织,或是存在有复杂的遮挡关系。这时,这套基于粗粒度(以物体为排序单位)的排序逻辑就彻底失效了。

物体相互交织导致排序困难

遇到这种情况,我们只能尝试对模型进行拆分,多使用凸面体,或是使用不会受到排序影响的混合算法。

Unity Shader 中的渲染顺序

前面说到,Unity 默认管线会按照一个队列排序渲染的物体。Unity 使用了一个叫做 渲染队列(render queue)的解决方案。我们可以在 SubShaderQueue 标签中设置模型的渲染队列和顺序(当然在材质面板中也有这个设置项,会覆盖 SubShader 中的标签设置)。

渲染队列设置面板

透明度测试在 Unity Shader 中的实现

我们会在 shader 代码块中使用 clip 函数进行透明度测试。clip 是一个 CG 函数。

函数声明: void clip(float4 x) / void clip(float3 x) / void clip(float2 x) / void clip(float x)

参数: 裁剪时使用的标量或矢量条件。

描述: 如果给定参数的任何一个分量是负数,舍弃当前像素的输出颜色。

float 是 HLSL 中的标量类型,在着色器代码中统一使用 float 即可,无需刻意写作 float1

代码如下:

Shader "Unity入门精要/Chapter8/AlphaTest"
{
    Properties
    {
        _MainTex ("颜色贴图", 2D) = "white" {}
        _Color ("基础色", Color) = (0.0, 0.0 ,0.0, 0.0)
        _Clip ("裁切阈值", Range(0, 1)) = 0.0
    }
    SubShader
    {
        Tags { "RenderType"="TransparentCutout" "Queue" = "AlphaTest" "IgnoreProjector" = "True" }
        Pass
        {
            Tags { "LightMode" = "ForwardBase" }
            Cull Off

            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "UnityCG.cginc"
            #include "Lighting.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
                float3 normal : NORMAL;
            };

            struct v2f
            {
                float2 uv : TEXCOORD0;
                float4 vertex : SV_POSITION;
                float4 vertex_WS : TEXCOORD1;
                float3 normal_WS : TEXCOORD2;
            };

            sampler2D _MainTex;
            float4 _MainTex_ST;
            float4 _Color;
            fixed _Clip;

            v2f vert (appdata input)
            {
                v2f output;
                output.vertex = UnityObjectToClipPos(input.vertex);
                output.uv = input.uv;
                output.vertex_WS = mul(unity_ObjectToWorld, input.vertex);
                output.normal_WS = UnityObjectToWorldNormal(input.normal);
                return output;
            }

            fixed4 frag (v2f input) : SV_Target
            {
                fixed3 normal_WS = normalize(input.normal_WS);
                fixed3 lightDir_WS = normalize(UnityWorldSpaceLightDir(input.vertex_WS));
                fixed NdotL = dot(normal_WS , lightDir_WS);
                
                fixed4 mainTex = tex2D(_MainTex, input.uv * _MainTex_ST.xy + _MainTex_ST.zw);

                fixed3 albedo = _Color * mainTex.xyz;
                
                fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;
                fixed3 diffuse = _LightColor0 * albedo * max(0, NdotL);
                
                clip(mainTex.a - _Clip); // 使用 clip 函数进行透明度检测裁切
                
                return fixed4(diffuse + ambient, 1.0);
            }
            ENDCG
        }
    }
    Fallback "Transparent/Cutout/VertexLit"
}

透明度混合在 Unity Shader 中的实现

为了进行透明度混合,我们需要使用 Unity 提供的混合命令——Blend。只有开启混合时,片元的 Alpha 通道才有意义。

Blend命令说明

下面会使用第二种语义进行混合。第二种语义会以下面方式进行颜色混合:

DstColornew=SrcAlpha×SrcColor+(1SrcAlpha)×DstColorold

代码如下。

单面透明渲染:

Shader "Unity入门精要/Chapter8/AlphaBlend"
{
    Properties
    {
        _MainTex ("颜色贴图", 2D) = "white" {}
        _Color ("基础色", Color) = (0.0, 0.0 ,0.0, 0.0)
        _AlphaScale ("Alpha偏移", Range(0, 1)) = 1.0
    }
    SubShader
    {
        Tags { "RenderType"="Transparent" "Queue" = "Transparent" "IgnoreProjector" = "True" }

        Pass
        {
            Tags { "LightMode" = "ForwardBase" }
            ZWrite Off
            Blend SrcAlpha OneMinusSrcAlpha
            // Blend SrcAlpha Zero

            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"
            #include "Lighting.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
                float3 normal : NORMAL;
            };

            struct v2f
            {
                float2 uv : TEXCOORD0;
                float4 vertex : SV_POSITION;
                float4 vertex_WS : TEXCOORD1;
                float3 normal_WS : TEXCOORD2;
            };

            sampler2D _MainTex;
            float4 _MainTex_ST;
            float4 _Color;
            fixed _AlphaScale;

            v2f vert (appdata input)
            {
                v2f output;
                output.vertex = UnityObjectToClipPos(input.vertex);
                output.uv = input.uv;
                output.vertex_WS = mul(unity_ObjectToWorld, input.vertex);
                output.normal_WS = UnityObjectToWorldNormal(input.normal);
                return output;
            }

            fixed4 frag (v2f input) : SV_Target
            {
                fixed3 normal_WS = normalize(input.normal_WS);
                fixed3 lightDir_WS = normalize(UnityWorldSpaceLightDir(input.vertex_WS));
                fixed NdotL = dot(normal_WS , lightDir_WS);
                
                fixed4 mainTex = tex2D(_MainTex, input.uv * _MainTex_ST.xy + _MainTex_ST.zw);

                fixed3 albedo = _Color * mainTex.xyz;
                
                fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;
                fixed3 diffuse = _LightColor0 * albedo * max(0, NdotL);
                fixed alpha = mainTex.a * _AlphaScale;
                
                return fixed4(diffuse + ambient, alpha);
            }
            ENDCG
        }
    }
    Fallback "Transparent/VertexLit"
}

双面透明渲染:

使用 Cull 指令,两个 Pass 分别渲染背面和正面。

Shader "Unity入门精要/Chapter8/AlphaBlendBS"
{
    Properties
    {
        _MainTex ("颜色贴图", 2D) = "white" {}
        _Color ("基础色", Color) = (0.0, 0.0 ,0.0, 0.0)
        _AlphaScale ("Alpha偏移", Range(0, 1)) = 1.0
    }
    SubShader
    {
        Tags { "RenderType"="Transparent" "Queue" = "Transparent" "IgnoreProjector" = "True" }

        // Pass 1:渲染背面
        Pass   
        {
            Tags { "LightMode" = "ForwardBase" }
            ZWrite Off
            Cull Front
            Blend SrcAlpha OneMinusSrcAlpha

            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"
            #include "Lighting.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
                float3 normal : NORMAL;
            };

            struct v2f
            {
                float2 uv : TEXCOORD0;
                float4 vertex : SV_POSITION;
                float4 vertex_WS : TEXCOORD1;
                float3 normal_WS : TEXCOORD2;
            };

            sampler2D _MainTex;
            float4 _MainTex_ST;
            float4 _Color;
            fixed _AlphaScale;

            v2f vert (appdata input)
            {
                v2f output;
                output.vertex = UnityObjectToClipPos(input.vertex);
                output.uv = input.uv;
                output.vertex_WS = mul(unity_ObjectToWorld, input.vertex);
                output.normal_WS = UnityObjectToWorldNormal(input.normal);
                return output;
            }

            fixed4 frag (v2f input) : SV_Target
            {
                fixed3 normal_WS = normalize(input.normal_WS);
                fixed3 lightDir_WS = normalize(UnityWorldSpaceLightDir(input.vertex_WS));
                fixed NdotL = dot(normal_WS , lightDir_WS);
                
                fixed4 mainTex = tex2D(_MainTex, input.uv * _MainTex_ST.xy + _MainTex_ST.zw);

                fixed3 albedo = _Color * mainTex.xyz;
                
                fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;
                fixed3 diffuse = _LightColor0 * albedo * max(0, NdotL);
                fixed alpha = mainTex.a * _AlphaScale;
             
                return fixed4(diffuse + ambient, alpha);
            }
            ENDCG
        }
        
        // Pass 2:渲染正面
        Pass   
        {
            Tags { "LightMode" = "ForwardBase" }
            ZWrite Off
            Cull Back
            Blend SrcAlpha OneMinusSrcAlpha

            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"
            #include "Lighting.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
                float3 normal : NORMAL;
            };

            struct v2f
            {
                float2 uv : TEXCOORD0;
                float4 vertex : SV_POSITION;
                float4 vertex_WS : TEXCOORD1;
                float3 normal_WS : TEXCOORD2;
            };

            sampler2D _MainTex;
            float4 _MainTex_ST;
            float4 _Color;
            fixed _AlphaScale;

            v2f vert (appdata input)
            {
                v2f output;
                output.vertex = UnityObjectToClipPos(input.vertex);
                output.uv = input.uv;
                output.vertex_WS = mul(unity_ObjectToWorld, input.vertex);
                output.normal_WS = UnityObjectToWorldNormal(input.normal);
                return output;
            }

            fixed4 frag (v2f input) : SV_Target
            {
                fixed3 normal_WS = normalize(input.normal_WS);
                fixed3 lightDir_WS = normalize(UnityWorldSpaceLightDir(input.vertex_WS));
                fixed NdotL = dot(normal_WS , lightDir_WS);
                
                fixed4 mainTex = tex2D(_MainTex, input.uv * _MainTex_ST.xy + _MainTex_ST.zw);

                fixed3 albedo = _Color * mainTex.xyz;
                
                fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;
                fixed3 diffuse = _LightColor0 * albedo * max(0, NdotL);
                fixed alpha = mainTex.a * _AlphaScale;
                
                return fixed4(diffuse + ambient, alpha);
            }
            ENDCG
        }
    }
    Fallback "Transparent/VertexLit"
}

深度写入的半透明效果:

使用该方法来进行准确的像素级深度排序进行透明渲染,使复杂的模型能够渲染出正确的透明渲染效果,并剔除掉被遮挡部分。

此方法需要注意渲染顺序,否则会产生剔除掉后面物体的现象。

在你的透明混合 Pass 前加上一个这样的 Pass:

Pass 
{
    ZWrite On
    ColorMask 0 // 使用 ColorMask 来关闭色彩缓冲写入
}

这个 Pass 会进行深度预写入,但是不会写入色彩缓冲。

ShaderLab 混合命令

混合和两个操作数有关:

  • 源颜色(source color):片元着色器产生的颜色
  • 目标颜色(destination color):颜色缓冲中得到的颜色

我们使用混合等式进行混合运算,在混合中使用两个等式,一个用于混合颜色,一个用于混合 Alpha。

混合命令语义说明

例如上表中下面那一行的命令,就是分开计算了 Alpha 混合:

Orgb=SrcFactor×Srgb+DstFactor×Drgb Oa=SrcFactorA×Sa+DstFactorA×Da

下面是一些 ShaderLab 支持的混合因子:

混合因子表

下面是一些常用的混合操作:

混合操作表1
混合操作表2

常见的混合类型:

常见混合类型