GameTech/URP 渲染实战

案例 16.5抖动透明(Dither)入门

目标:让物体“半透明”却不进透明队列——依然写深度、依然投影、完全不用排序。

原理:抖动透明也叫“纱窗透明”(Screen-door Transparency):不是把颜色混合进去,而是按一个固定的阈值图案,丢弃一部分像素。不透明度 0.5 就丢掉一半的像素,远看起来就是半透明的。阈值图案用 4×4 的 Bayer 矩阵,它的 16 个阈值均匀分布在 0~1,相邻像素的阈值尽量拉开,所以任何不透明度下丢弃的像素都分布得很均匀:

Assets/Cookbook/Ch16/DitherFade.shader(节选:bayer)
// 4×4 Bayer 矩阵:16 个阈值均匀分布在 0..1,且相邻像素的阈值尽量“错开”
float Bayer4x4(uint2 p)
{
    const float m[16] = {
         0.0,  8.0,  2.0, 10.0,
        12.0,  4.0, 14.0,  6.0,
         3.0, 11.0,  1.0,  9.0,
        15.0,  7.0, 13.0,  5.0 };
    p %= 4;
    return (m[p.y * 4 + p.x] + 0.5) / 16.0;
}

// opacity 为 0.5 时,正好有一半像素的阈值低于它而被保留
void DitherClip(float2 pixelPos, half opacity)
{
    clip(opacity - Bayer4x4((uint2)pixelPos));
}
// 4×4 Bayer 矩阵:16 个阈值均匀分布在 0..1,且相邻像素的阈值尽量“错开”
float Bayer4x4(uvec2 p)
{
    const float m[16] = float[16](
         0.0,  8.0,  2.0, 10.0,
        12.0,  4.0, 14.0,  6.0,
         3.0, 11.0,  1.0,  9.0,
        15.0,  7.0, 13.0,  5.0);
    p %= 4u;
    return (m[p.y * 4u + p.x] + 0.5) / 16.0;
}

#ifdef FRAGMENT
// opacity 为 0.5 时,正好有一半像素的阈值低于它而被保留
void DitherClip(vec2 pixelPos, float opacity)
{
    if (opacity - Bayer4x4(uvec2(pixelPos)) < 0.0) discard;
}
#endif
Assets/Cookbook/Ch16/DitherFade.shader
// 抖动透明(Dither / Screen-door Transparency):不混合,而是按屏幕位置的阈值图案丢弃一部分像素。
// 物体仍然是不透明队列、写深度、不需要排序;代价是近看能看到网点。阴影 Pass 用同样的抖动。
Shader "Cookbook/Ch16/DitherFade"
{
    Properties
    {
        _BaseColor ("Color", Color) = (0.9, 0.5, 0.25, 1)
        _Fade ("Opacity", Range(0, 1)) = 0.5
        [Toggle] _DitherShadow ("Dither In Shadow Pass", Float) = 1
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="AlphaTest" }

        HLSLINCLUDE
        #include "Assets/Cookbook/Ch16/Ch16Common.hlsl"
        CBUFFER_START(UnityPerMaterial)
            half4 _BaseColor;
            half  _Fade;
            float _DitherShadow;
        CBUFFER_END

        // 4×4 Bayer 矩阵:16 个阈值均匀分布在 0..1,且相邻像素的阈值尽量“错开”
        float Bayer4x4(uint2 p)
        {
            const float m[16] = {
                 0.0,  8.0,  2.0, 10.0,
                12.0,  4.0, 14.0,  6.0,
                 3.0, 11.0,  1.0,  9.0,
                15.0,  7.0, 13.0,  5.0 };
            p %= 4;
            return (m[p.y * 4 + p.x] + 0.5) / 16.0;
        }

        // opacity 为 0.5 时,正好有一半像素的阈值低于它而被保留
        void DitherClip(float2 pixelPos, half opacity)
        {
            clip(opacity - Bayer4x4((uint2)pixelPos));
        }
        ENDHLSL

        Pass
        {
            Name "ForwardLit"
            Tags { "LightMode"="UniversalForwardOnly" }
            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) : SV_Target
            {
                DitherClip(i.positionCS.xy, _Fade);     // SV_POSITION.xy 在片元里就是像素坐标
                return half4(SimpleLighting(_BaseColor.rgb, normalize(i.normalWS), i.positionWS, 0.5h), 1);
            }
            ENDHLSL
        }

        Pass
        {
            Name "ShadowCaster"
            Tags { "LightMode"="ShadowCaster" }
            ZWrite On ZTest LEqual ColorMask 0
            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            float4 vert(Attributes v) : SV_POSITION { return Ch16ShadowPositionCS(v.positionOS.xyz, v.normalOS); }
            half4 frag(float4 positionCS : SV_POSITION) : SV_Target
            {
                // 阴影贴图里的“像素”是阴影贴图的纹素,抖动后经过 PCF 软阴影过滤,会得到浅一些的阴影
                if (_DitherShadow > 0.5) DitherClip(positionCS.xy, _Fade);
                return 0;
            }
            ENDHLSL
        }

        Pass
        {
            Name "DepthOnly"
            Tags { "LightMode"="DepthOnly" }
            ZWrite On ColorMask R
            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            float4 vert(Attributes v) : SV_POSITION { return TransformObjectToHClip(v.positionOS.xyz); }
            half4 frag(float4 positionCS : SV_POSITION) : SV_Target { DitherClip(positionCS.xy, _Fade); return 0; }
            ENDHLSL
        }
    }
}
// 抖动透明(Dither / Screen-door Transparency):不混合,而是按屏幕位置的阈值图案丢弃一部分像素。(GLSL 版)
// 物体仍然是不透明队列、写深度、不需要排序;代价是近看能看到网点。阴影 Pass 用同样的抖动。
Shader "Cookbook/Ch16/DitherFade"
{
    SubShader
    {
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="AlphaTest" }

        GLSLINCLUDE
        #include "Ch16/Ch16Common.glsl"
        uniform vec4 _BaseColor;
        uniform float _Fade;
        uniform float _DitherShadow;

        // 4×4 Bayer 矩阵:16 个阈值均匀分布在 0..1,且相邻像素的阈值尽量“错开”
        float Bayer4x4(uvec2 p)
        {
            const float m[16] = float[16](
                 0.0,  8.0,  2.0, 10.0,
                12.0,  4.0, 14.0,  6.0,
                 3.0, 11.0,  1.0,  9.0,
                15.0,  7.0, 13.0,  5.0);
            p %= 4u;
            return (m[p.y * 4u + p.x] + 0.5) / 16.0;
        }

        #ifdef FRAGMENT
        // opacity 为 0.5 时,正好有一半像素的阈值低于它而被保留
        void DitherClip(vec2 pixelPos, float opacity)
        {
            if (opacity - Bayer4x4(uvec2(pixelPos)) < 0.0) discard;
        }
        #endif
        ENDGLSL

        Pass
        {
            Name "ForwardLit"
            Tags { "LightMode"="UniversalForwardOnly" }
            GLSLPROGRAM
            #ifdef VERTEX
            void main() { Ch16Vert(); }
            #endif
            #ifdef FRAGMENT
            void main()
            {
                DitherClip(gl_FragCoord.xy, _Fade);     // gl_FragCoord.xy 在片元里就是像素坐标(对应 HLSL 的 SV_POSITION.xy)
                fragColor = vec4(SimpleLighting(_BaseColor.rgb, normalize(normalWS), positionWS, 0.5), 1.0);
            }
            #endif
            ENDGLSL
        }

        Pass
        {
            Name "ShadowCaster"
            Tags { "LightMode"="ShadowCaster" }
            ZWrite On ZTest LEqual ColorMask 0
            GLSLPROGRAM
            #ifdef VERTEX
            void main() { gl_Position = Ch16ShadowPositionCS(positionOS.xyz, normalOS); }
            #endif
            #ifdef FRAGMENT
            void main()
            {
                // 阴影贴图里的“像素”是阴影贴图的纹素,抖动后经过 PCF 软阴影过滤,会得到浅一些的阴影
                if (_DitherShadow > 0.5) DitherClip(gl_FragCoord.xy, _Fade);
                fragColor = vec4(0.0);
            }
            #endif
            ENDGLSL
        }

        Pass
        {
            Name "DepthOnly"
            Tags { "LightMode"="DepthOnly" }
            ZWrite On ColorMask R
            GLSLPROGRAM
            #ifdef VERTEX
            void main() { gl_Position = TransformObjectToHClip(positionOS.xyz); }
            #endif
            #ifdef FRAGMENT
            void main() { DitherClip(gl_FragCoord.xy, _Fade); fragColor = vec4(gl_FragCoord.z); }
            #endif
            ENDGLSL
        }
    }
}

URP 自己的 LOD 交叉淡入淡出(LOD Cross-Fade)就是抖动透明:LODCrossFade.hlsl 里用屏幕坐标采样一张抖动纹理 _DitheringTexture,两个 LOD 级别用互补的阈值丢弃像素,过渡期间两者拼起来恰好覆盖每一个像素。

图 16-9不透明度 1、0.75、0.5、0.25 的抖动透明胶囊。它们仍在不透明队列、写深度;阴影 Pass 使用同样的抖动,投影随不透明度变淡

阴影的抖动有个细节:ShadowCaster Pass 里的 SV_POSITION.xy 是阴影贴图的像素坐标,所以抖动图案是“印”在阴影贴图上的。软阴影的 PCF 过滤会把这些网点平均,于是地面上得到的是一个浅一些的、平滑的阴影,而不是网点状的阴影。这一点在第 23 章“半透明物体的阴影”里会进一步展开。

提示抖动透明的网点在静止画面上很明显,但如果每帧偏移抖动图案(例如按帧号平移 Bayer 坐标),再交给时间抗锯齿(TAA)在多帧之间累积,网点几乎看不出来,这是很多现代游戏处理“角色挡住镜头时淡出”的标准做法。URP 14 的相机 Anti-aliasing 选项里提供了 TAA,但它依赖历史帧,并不是每个项目都会开启;在没有时间累积的情况下,可以用更大的抖动矩阵(8×8)或蓝噪声纹理让网点更不规则、更不显眼。