GameTech/URP 渲染实战

案例 16.4Alpha Clip 与 Alpha to Coverage进阶

目标:比较铁丝网这类“细镂空”材质的三种做法,理解 Alpha to Coverage 的原理以及 alpha 锐化。

原理:Alpha Clip(Alpha Test)在片元里 clip(alpha - cutoff),低于阈值的像素直接丢弃。它不需要排序、能写深度、能投射正确的阴影,是树叶、草、栅栏的标准做法。缺点有两个:边缘是“要么全有要么全无”,锯齿明显,而且 MSAA 救不了它——MSAA 只对几何边缘多采样,片元着色器每像素只执行一次,clip 的结果对所有子样本都一样;第二个缺点是 mip:远处的 mip 层把细线和透明背景平均成很低的 alpha,全部低于阈值,铁丝网在远处就“断”成了零星的点。

Alpha to Coverage(A2C)利用 MSAA 的子样本:开启后,GPU 把片元输出的 alpha 换算成一个覆盖掩码——4× MSAA 下 alpha = 0.5 大约覆盖 2 个子样本——只把颜色写进被覆盖的子样本。MSAA 解析(Resolve)时求平均,就得到了 4 级的半透明过渡,而且不需要排序、照样写深度。

α = 0α ≈ 0.25α ≈ 0.5α ≈ 0.75α = 1 4× MSAA:alpha 换算成被写入的子样本数,Resolve 后得到 5 级覆盖率
图 16-7Alpha to Coverage 的原理(子样本位置示意,实际分布与硬件有关)

但直接把贴图 alpha 交给 A2C 会发虚:贴图边缘的 alpha 过渡可能跨好几个像素,放大看是一片半透明的雾。解决办法是锐化 alpha:以 cutoff 为中心,用 fwidth 把过渡压缩到约一个像素宽:

α′=saturate(α−cutofffwidth(α)+0.5)

这其实就是案例 15.3 网点抗锯齿的同一个技巧:在阈值处做一个像素宽的 smoothstep。URP 14 自己就是这么做的——Lit 材质在“不透明 + Alpha Clipping”时会自动打开 AlphaToMask,而 AlphaClip() 函数在 MSAA 可用时返回锐化后的 alpha:

// 摘自 URP 14 ShaderLibrary/ShaderVariablesFunctions.hlsl
half SharpenAlphaStrict(half alpha, half alphaClipTreshold)
{
    half dAlpha = fwidth(alpha);
    return saturate(((alpha - alphaClipTreshold - (0.5 * dAlpha)) / max(dAlpha, 0.0001)) + 1.0);
}

“MSAA 可用”由一个全局变量 _AlphaToMaskAvailable 表示,DrawObjectsPass 只在相机目标多于 1 个采样、且正在画不透明物体时把它设为 1。也就是说:没开 MSAA 时,同一个材质自动退化为普通的 Alpha Clip,不会出现“A2C 没有子样本可用、结果全透明”的事故。

完整代码:

Assets/Cookbook/Ch16/Cutout.shader
// 镂空材质的三种做法:
//   Alpha Clip:clip() 硬裁剪,边缘锯齿,远处细线会因 mip 平均而消失;
//   Alpha to Coverage(A2C):把 alpha 转成 MSAA 的覆盖掩码,边缘有 MSAA 级别的过渡(需要开 MSAA);
//   A2C + 锐化:用 fwidth 把 alpha 重新拉成“一个像素宽的过渡”,既保住细线又不发虚。
Shader "Cookbook/Ch16/Cutout"
{
    Properties
    {
        [MainTexture] _BaseMap ("Base Map", 2D) = "white" {}
        [MainColor] _BaseColor ("Color", Color) = (1, 1, 1, 1)
        _Cutoff ("Alpha Cutoff", Range(0, 1)) = 0.5
        [Enum(Off, 0, On, 1)] _AlphaToMask ("Alpha To Coverage", Float) = 0
        [Toggle] _Sharpen ("Sharpen Alpha (for A2C)", Float) = 0
        [Toggle] _MipScale ("Preserve Coverage Over Mips", Float) = 0
    }
    SubShader
    {
        Tags { "RenderType"="TransparentCutout" "RenderPipeline"="UniversalPipeline" "Queue"="AlphaTest" }

        HLSLINCLUDE
        #include "Assets/Cookbook/Ch16/Ch16Common.hlsl"
        CBUFFER_START(UnityPerMaterial)
            float4 _BaseMap_ST;
            half4 _BaseColor;
            half  _Cutoff;
            float _AlphaToMask, _Sharpen, _MipScale;
            float4 _BaseMap_TexelSize;
        CBUFFER_END
        TEXTURE2D(_BaseMap); SAMPLER(sampler_BaseMap);

        half CutoutAlpha(float2 uv)
        {
            half a = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, uv).a * _BaseColor.a;
            if (_MipScale > 0.5)
            {
                // 远处 mip 把细线和透明背景平均成很低的 alpha,裁剪后线条就“断”了。
                // 按 mip 级别把 alpha 放大一些,抵消平均带来的损失(经验做法,系数可调)
                float2 texel = uv * _BaseMap_TexelSize.zw;
                float2 dx = ddx(texel), dy = ddy(texel);
                float mip = max(0.0, 0.5 * log2(max(dot(dx, dx), dot(dy, dy))));
                a *= 1.0 + mip * 0.3;
            }
            return a;
        }
        ENDHLSL

        Pass
        {
            Name "ForwardLit"
            Tags { "LightMode"="UniversalForwardOnly" }
            Cull Off
            AlphaToMask [_AlphaToMask]
            HLSLPROGRAM
            #pragma vertex Ch16Vert
            #pragma fragment frag
            #pragma multi_compile _ _MAIN_LIGHT_SHADOWS _MAIN_LIGHT_SHADOWS_CASCADE _MAIN_LIGHT_SHADOWS_SCREEN
            #pragma multi_compile_fragment _ _SHADOWS_SOFT _SHADOWS_SOFT_LOW _SHADOWS_SOFT_MEDIUM _SHADOWS_SOFT_HIGH

            half4 frag(Varyings i, FRONT_FACE_TYPE face : FRONT_FACE_SEMANTIC) : SV_Target
            {
                float2 uv = TRANSFORM_TEX(i.uv, _BaseMap);
                half4 tex = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, uv);
                half a = CutoutAlpha(uv);

                if (_AlphaToMask > 0.5)
                {
                    if (_Sharpen > 0.5)
                    {
                        // 以 cutoff 为中心,把 alpha 的过渡压缩到约一个像素宽
                        a = (a - _Cutoff) / max(fwidth(a), 1e-4) + 0.5;
                    }
                    a = saturate(a);
                }
                else
                {
                    clip(a - _Cutoff);    // 经典 Alpha Test
                    a = 1;
                }

                float3 N = normalize(i.normalWS) * IS_FRONT_VFACE(face, 1.0, -1.0);
                half3 col = SimpleLighting(tex.rgb * _BaseColor.rgb, N, i.positionWS, 0.2h);
                return half4(col, a);     // 开启 A2C 时,这个 alpha 决定 MSAA 子样本覆盖了几个
            }
            ENDHLSL
        }

        Pass
        {
            Name "ShadowCaster"
            Tags { "LightMode"="ShadowCaster" }
            ZWrite On ZTest LEqual ColorMask 0 Cull Off
            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            struct V2F { float4 positionCS : SV_POSITION; float2 uv : TEXCOORD0; };
            V2F vert(Attributes v)
            {
                V2F o;
                o.positionCS = Ch16ShadowPositionCS(v.positionOS.xyz, v.normalOS);
                o.uv = TRANSFORM_TEX(v.uv, _BaseMap);
                return o;
            }
            // 阴影贴图没有 MSAA,只能用硬裁剪
            half4 frag(V2F i) : SV_Target { clip(CutoutAlpha(i.uv) - _Cutoff); return 0; }
            ENDHLSL
        }

        Pass
        {
            Name "DepthOnly"
            Tags { "LightMode"="DepthOnly" }
            ZWrite On ColorMask R Cull Off
            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            struct V2F { float4 positionCS : SV_POSITION; float2 uv : TEXCOORD0; };
            V2F vert(Attributes v)
            {
                V2F o;
                o.positionCS = TransformObjectToHClip(v.positionOS.xyz);
                o.uv = TRANSFORM_TEX(v.uv, _BaseMap);
                return o;
            }
            half4 frag(V2F i) : SV_Target { clip(CutoutAlpha(i.uv) - _Cutoff); return 0; }
            ENDHLSL
        }
    }
}
// 镂空材质的三种做法(GLSL 版):
//   Alpha Clip:clip() 硬裁剪,边缘锯齿,远处细线会因 mip 平均而消失;
//   Alpha to Coverage(A2C):把 alpha 转成 MSAA 的覆盖掩码,边缘有 MSAA 级别的过渡(需要开 MSAA);
//   A2C + 锐化:用 fwidth 把 alpha 重新拉成“一个像素宽的过渡”,既保住细线又不发虚。
Shader "Cookbook/Ch16/Cutout"
{
    SubShader
    {
        Tags { "RenderType"="TransparentCutout" "RenderPipeline"="UniversalPipeline" "Queue"="AlphaTest" }

        GLSLINCLUDE
        #include "Ch16/Ch16Common.glsl"
        uniform vec4 _BaseMap_ST;
        uniform vec4 _BaseColor;
        uniform float _Cutoff;
        uniform float _AlphaToMask, _Sharpen, _MipScale;
        uniform vec4 _BaseMap_TexelSize;
        uniform sampler2D _BaseMap;

        #ifdef FRAGMENT
        float CutoutAlpha(vec2 uv)
        {
            float a = texture(_BaseMap, uv).a * _BaseColor.a;
            if (_MipScale > 0.5)
            {
                // 远处 mip 把细线和透明背景平均成很低的 alpha,裁剪后线条就“断”了。
                // 按 mip 级别把 alpha 放大一些,抵消平均带来的损失(经验做法,系数可调)
                vec2 texel = uv * _BaseMap_TexelSize.zw;
                vec2 dx = dFdx(texel), dy = dFdy(texel);
                float mip = max(0.0, 0.5 * log2(max(dot(dx, dx), dot(dy, dy))));
                a *= 1.0 + mip * 0.3;
            }
            return a;
        }
        #endif
        ENDGLSL

        Pass
        {
            Name "ForwardLit"
            Tags { "LightMode"="UniversalForwardOnly" }
            Cull Off
            AlphaToMask [_AlphaToMask]
            GLSLPROGRAM
            #ifdef VERTEX
            void main() { Ch16Vert(); }
            #endif

            #ifdef FRAGMENT
            void main()
            {
                vec2 uvT = TransformTex(uv, _BaseMap_ST);
                vec4 tex = texture(_BaseMap, uvT);
                float a = CutoutAlpha(uvT);

                if (_AlphaToMask > 0.5)
                {
                    if (_Sharpen > 0.5)
                    {
                        // 以 cutoff 为中心,把 alpha 的过渡压缩到约一个像素宽
                        a = (a - _Cutoff) / max(fwidth(a), 1e-4) + 0.5;
                    }
                    a = saturate(a);
                }
                else
                {
                    if (a - _Cutoff < 0.0) discard;    // 经典 Alpha Test
                    a = 1.0;
                }

                vec3 N = normalize(normalWS) * (gl_FrontFacing ? 1.0 : -1.0);
                vec3 col = SimpleLighting(tex.rgb * _BaseColor.rgb, N, positionWS, 0.2);
                fragColor = vec4(col, a);     // 开启 A2C 时,这个 alpha 决定 MSAA 子样本覆盖了几个
            }
            #endif
            ENDGLSL
        }

        Pass
        {
            Name "ShadowCaster"
            Tags { "LightMode"="ShadowCaster" }
            ZWrite On ZTest LEqual ColorMask 0 Cull Off
            GLSLPROGRAM
            VARYING vec2 uvS;
            #ifdef VERTEX
            void main()
            {
                gl_Position = Ch16ShadowPositionCS(positionOS.xyz, normalOS);
                uvS = TransformTex(texcoord0.xy, _BaseMap_ST);
            }
            #endif
            #ifdef FRAGMENT
            // 阴影贴图没有 MSAA,只能用硬裁剪
            void main() { if (CutoutAlpha(uvS) - _Cutoff < 0.0) discard; fragColor = vec4(0.0); }
            #endif
            ENDGLSL
        }

        Pass
        {
            Name "DepthOnly"
            Tags { "LightMode"="DepthOnly" }
            ZWrite On ColorMask R Cull Off
            GLSLPROGRAM
            VARYING vec2 uvS;
            #ifdef VERTEX
            void main()
            {
                gl_Position = TransformObjectToHClip(positionOS.xyz);
                uvS = TransformTex(texcoord0.xy, _BaseMap_ST);
            }
            #endif
            #ifdef FRAGMENT
            void main() { if (CutoutAlpha(uvS) - _Cutoff < 0.0) discard; fragColor = vec4(gl_FragCoord.z); }
            #endif
            ENDGLSL
        }
    }
}
Assets/Cookbook/Ch16/Cutout.shader(节选:frag)
half4 frag(Varyings i, FRONT_FACE_TYPE face : FRONT_FACE_SEMANTIC) : SV_Target
{
    float2 uv = TRANSFORM_TEX(i.uv, _BaseMap);
    half4 tex = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, uv);
    half a = CutoutAlpha(uv);

    if (_AlphaToMask > 0.5)
    {
        if (_Sharpen > 0.5)
        {
            // 以 cutoff 为中心,把 alpha 的过渡压缩到约一个像素宽
            a = (a - _Cutoff) / max(fwidth(a), 1e-4) + 0.5;
        }
        a = saturate(a);
    }
    else
    {
        clip(a - _Cutoff);    // 经典 Alpha Test
        a = 1;
    }

    float3 N = normalize(i.normalWS) * IS_FRONT_VFACE(face, 1.0, -1.0);
    half3 col = SimpleLighting(tex.rgb * _BaseColor.rgb, N, i.positionWS, 0.2h);
    return half4(col, a);     // 开启 A2C 时,这个 alpha 决定 MSAA 子样本覆盖了几个
}
void main()
{
    vec2 uvT = TransformTex(uv, _BaseMap_ST);
    vec4 tex = texture(_BaseMap, uvT);
    float a = CutoutAlpha(uvT);

    if (_AlphaToMask > 0.5)
    {
        if (_Sharpen > 0.5)
        {
            // 以 cutoff 为中心,把 alpha 的过渡压缩到约一个像素宽
            a = (a - _Cutoff) / max(fwidth(a), 1e-4) + 0.5;
        }
        a = saturate(a);
    }
    else
    {
        if (a - _Cutoff < 0.0) discard;    // 经典 Alpha Test
        a = 1.0;
    }

    vec3 N = normalize(normalWS) * (gl_FrontFacing ? 1.0 : -1.0);
    vec3 col = SimpleLighting(tex.rgb * _BaseColor.rgb, N, positionWS, 0.2);
    fragColor = vec4(col, a);     // 开启 A2C 时,这个 alpha 决定 MSAA 子样本覆盖了几个
}

远处细线消失的问题,本例用了一个经验做法:根据 UV 导数估算当前 mip 级别,按级别放大 alpha(每级约 30%),抵消平均带来的损失:

Assets/Cookbook/Ch16/Cutout.shader(节选:alpha)
half CutoutAlpha(float2 uv)
{
    half a = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, uv).a * _BaseColor.a;
    if (_MipScale > 0.5)
    {
        // 远处 mip 把细线和透明背景平均成很低的 alpha,裁剪后线条就“断”了。
        // 按 mip 级别把 alpha 放大一些,抵消平均带来的损失(经验做法,系数可调)
        float2 texel = uv * _BaseMap_TexelSize.zw;
        float2 dx = ddx(texel), dy = ddy(texel);
        float mip = max(0.0, 0.5 * log2(max(dot(dx, dx), dot(dy, dy))));
        a *= 1.0 + mip * 0.3;
    }
    return a;
}
float CutoutAlpha(vec2 uv)
{
    float a = texture(_BaseMap, uv).a * _BaseColor.a;
    if (_MipScale > 0.5)
    {
        // 远处 mip 把细线和透明背景平均成很低的 alpha,裁剪后线条就“断”了。
        // 按 mip 级别把 alpha 放大一些,抵消平均带来的损失(经验做法,系数可调)
        vec2 texel = uv * _BaseMap_TexelSize.zw;
        vec2 dx = dFdx(texel), dy = dFdy(texel);
        float mip = max(0.0, 0.5 * log2(max(dot(dx, dx), dot(dy, dy))));
        a *= 1.0 + mip * 0.3;
    }
    return a;
}

更严谨的做法是在导入/生成贴图时,为每一级 mip 单独缩放 alpha,使“alpha > cutoff 的像素比例”与 mip 0 保持一致(Unity 的纹理导入设置里有 Mip Maps Preserve Coverage 选项,做的就是这件事)。运行时估算的好处是不依赖导入设置,缺点是系数要凭经验调。

图 16-84× MSAA、以 1/3 分辨率渲染后放大,便于看清像素。左:Alpha Clip,铁丝边缘是硬锯齿,远处的细线因 mip 平均而断成零星的点;中:Alpha to Coverage(未锐化),边缘柔和但远处一片模糊;右:A2C + alpha 锐化 + mip 补偿,边缘干净、远处的网格依然连续
常见坑① 阴影贴图没有 MSAA,ShadowCaster Pass 只能用硬裁剪;DepthOnly Pass 同理。三个 Pass 的裁剪逻辑(包括 mip 补偿)必须一致,否则深度预通道和颜色对不上,会出现闪烁的洞。② URP 在相机有 targetTexture 时以该 RT 的采样数为准,而本书摄影棚默认的抓图 RT 是单采样的,所以本例配图用一个辅助相机渲染到 4× MSAA 的 RT 上,并以 1/3 分辨率点采样放大——你在自己的项目里只需要在 URP Asset 上打开 MSAA。③ Forward+ 与 Forward 都支持 MSAA;Deferred 渲染路径下 MSAA 不生效,A2C 也就没有意义。