GameTech/URP 渲染实战

案例 16.1混合模式大比拼入门

目标:用一个 Shader 切出六种常见混合模式,在同一个背景(上半黑→白渐变、下半色相条)上叠三张红绿蓝圆盘,直观对比。

完整代码:

Assets/Cookbook/Ch16/BlendModes.shader
// 混合模式实验台:源/目标因子和混合运算都做成材质属性,同一个 Shader 切出所有常见模式。
// _Output 决定片元输出的形式:直通(rgb, a)/ 预乘(贴图已预乘)/ 乘法就绪(lerp(1, rgb, a))。
Shader "Cookbook/Ch16/BlendModes"
{
    Properties
    {
        [MainTexture] _BaseMap ("Base Map (alpha = shape)", 2D) = "white" {}
        [MainColor] _BaseColor ("Color", Color) = (1, 0.3, 0.2, 0.75)
        [Enum(UnityEngine.Rendering.BlendMode)] _SrcBlend ("Src Blend", Float) = 5   // SrcAlpha
        [Enum(UnityEngine.Rendering.BlendMode)] _DstBlend ("Dst Blend", Float) = 10  // OneMinusSrcAlpha
        [Enum(UnityEngine.Rendering.BlendOp)] _BlendOp ("Blend Op", Float) = 0       // Add
        [KeywordEnum(Straight, Premultiply, MultiplyReady)] _Output ("Fragment Output", Float) = 0
    }
    SubShader
    {
        Tags { "RenderType"="Transparent" "RenderPipeline"="UniversalPipeline" "Queue"="Transparent" "IgnoreProjector"="True" }

        Pass
        {
            Name "Blend"
            Tags { "LightMode"="UniversalForward" }
            BlendOp [_BlendOp]
            Blend [_SrcBlend] [_DstBlend]
            ZWrite Off
            Cull Off

            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #pragma shader_feature_local_fragment _OUTPUT_STRAIGHT _OUTPUT_PREMULTIPLY _OUTPUT_MULTIPLYREADY
            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"

            CBUFFER_START(UnityPerMaterial)
                float4 _BaseMap_ST;
                half4 _BaseColor;
                float _SrcBlend, _DstBlend, _BlendOp;
            CBUFFER_END
            TEXTURE2D(_BaseMap); SAMPLER(sampler_BaseMap);

            struct Attributes { float4 positionOS : POSITION; float2 uv : TEXCOORD0; };
            struct Varyings { float4 positionCS : SV_POSITION; float2 uv : TEXCOORD0; };

            Varyings vert(Attributes v)
            {
                Varyings o;
                o.positionCS = TransformObjectToHClip(v.positionOS.xyz);
                o.uv = TRANSFORM_TEX(v.uv, _BaseMap);
                return o;
            }

            half4 frag(Varyings i) : SV_Target
            {
                half4 tex = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, i.uv);
                half3 rgb = tex.rgb * _BaseColor.rgb;
                half  a   = tex.a * _BaseColor.a;
            #if defined(_OUTPUT_PREMULTIPLY)
                // 预乘:贴图的 rgb 已经乘过 alpha(导出时完成),这里只再乘上材质的不透明度。
                // 于是 rgb 可以“大于” alpha——alpha = 0 而 rgb > 0 的像素就是纯叠加的辉光
                return half4(tex.rgb * _BaseColor.rgb * _BaseColor.a, a);
            #elif defined(_OUTPUT_MULTIPLYREADY)
                return half4(lerp(1.0h, rgb, a), a);  // 乘法类:透明处输出白色(乘 1 不变)
            #else
                return half4(rgb, a);                 // 直通
            #endif
            }
            ENDHLSL
        }
    }
}
// 混合模式实验台:源/目标因子和混合运算都做成材质属性,同一个 Shader 切出所有常见模式。(GLSL 版)
// _Output 决定片元输出的形式:直通(rgb, a)/ 预乘(贴图已预乘)/ 乘法就绪(lerp(1, rgb, a))。
Shader "Cookbook/Ch16/BlendModes"
{
    SubShader
    {
        Tags { "RenderType"="Transparent" "RenderPipeline"="UniversalPipeline" "Queue"="Transparent" "IgnoreProjector"="True" }

        Pass
        {
            Name "Blend"
            Tags { "LightMode"="UniversalForward" }
            BlendOp [_BlendOp]
            Blend [_SrcBlend] [_DstBlend]
            ZWrite Off
            Cull Off

            GLSLPROGRAM
            #pragma shader_feature_local_fragment _OUTPUT_STRAIGHT _OUTPUT_PREMULTIPLY _OUTPUT_MULTIPLYREADY

            uniform vec4 _BaseMap_ST;
            uniform vec4 _BaseColor;
            uniform sampler2D _BaseMap;

            VARYING vec2 uv;

            #ifdef VERTEX
            void main()
            {
                gl_Position = TransformObjectToHClip(positionOS.xyz);
                uv = TransformTex(texcoord0.xy, _BaseMap_ST);
            }
            #endif

            #ifdef FRAGMENT
            vec4 frag()
            {
                vec4 tex = texture(_BaseMap, uv);
                vec3 rgb = tex.rgb * _BaseColor.rgb;
                float a  = tex.a * _BaseColor.a;
            #if defined(_OUTPUT_PREMULTIPLY)
                // 预乘:贴图的 rgb 已经乘过 alpha(导出时完成),这里只再乘上材质的不透明度。
                // 于是 rgb 可以“大于” alpha——alpha = 0 而 rgb > 0 的像素就是纯叠加的辉光
                return vec4(tex.rgb * _BaseColor.rgb * _BaseColor.a, a);
            #elif defined(_OUTPUT_MULTIPLYREADY)
                return vec4(mix(vec3(1.0), rgb, a), a);  // 乘法类:透明处输出白色(乘 1 不变)
            #else
                return vec4(rgb, a);                     // 直通
            #endif
            }
            void main() { fragColor = frag(); }
            #endif
            ENDGLSL
        }
    }
}

渲染状态全部引用材质属性:

Assets/Cookbook/Ch16/BlendModes.shader(节选:state)
BlendOp [_BlendOp]
Blend [_SrcBlend] [_DstBlend]
ZWrite Off
Cull Off
BlendOp [_BlendOp]
Blend [_SrcBlend] [_DstBlend]
ZWrite Off
Cull Off

片元输出有三种形式,对应不同混合模式对“源颜色”的约定:

Assets/Cookbook/Ch16/BlendModes.shader(节选:frag)
half4 frag(Varyings i) : SV_Target
{
    half4 tex = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, i.uv);
    half3 rgb = tex.rgb * _BaseColor.rgb;
    half  a   = tex.a * _BaseColor.a;
#if defined(_OUTPUT_PREMULTIPLY)
    // 预乘:贴图的 rgb 已经乘过 alpha(导出时完成),这里只再乘上材质的不透明度。
    // 于是 rgb 可以“大于” alpha——alpha = 0 而 rgb > 0 的像素就是纯叠加的辉光
    return half4(tex.rgb * _BaseColor.rgb * _BaseColor.a, a);
#elif defined(_OUTPUT_MULTIPLYREADY)
    return half4(lerp(1.0h, rgb, a), a);  // 乘法类:透明处输出白色(乘 1 不变)
#else
    return half4(rgb, a);                 // 直通
#endif
}
vec4 frag()
{
    vec4 tex = texture(_BaseMap, uv);
    vec3 rgb = tex.rgb * _BaseColor.rgb;
    float a  = tex.a * _BaseColor.a;
#if defined(_OUTPUT_PREMULTIPLY)
    // 预乘:贴图的 rgb 已经乘过 alpha(导出时完成),这里只再乘上材质的不透明度。
    // 于是 rgb 可以“大于” alpha——alpha = 0 而 rgb > 0 的像素就是纯叠加的辉光
    return vec4(tex.rgb * _BaseColor.rgb * _BaseColor.a, a);
#elif defined(_OUTPUT_MULTIPLYREADY)
    return vec4(mix(vec3(1.0), rgb, a), a);  // 乘法类:透明处输出白色(乘 1 不变)
#else
    return vec4(rgb, a);                     // 直通
#endif
}
模式Blend 设置结果典型用途
Alpha(传统透明)SrcAlpha OneMinusSrcAlphaS·a + D·(1−a)玻璃、半透明 UI、淡出
预乘 AlphaOne OneMinusSrcAlphaS′ + D·(1−a),S′ = S·a同时表达“遮挡”和“发光”、正确的过滤与合成
Additive(叠加)SrcAlpha One 或 One OneS·a + D火焰、光束、能量特效
Soft Additive(滤色)OneMinusDstColor OneS + D − S·D = 1 − (1−S)(1−D)柔和的光晕,不容易过曝
Multiply(正片叠底)DstColor ZeroS·D阴影贴片、染色、污渍
2× MultiplyDstColor SrcColor2·S·D既能变亮也能变暗(S > 0.5 变亮)的细节叠加
Max(变亮)BlendOp Maxmax(S, D)多层光斑取最亮,不累加
图 16-2左:Alpha 混合,重叠处后画的盘遮住先画的,与背景明暗无关;中:预乘 Alpha,实心部分与左图一致,外圈是 alpha = 0 但 rgb > 0 的辉光,纯叠加;右:Additive,重叠处颜色相加趋向白色,在黑色背景上最明显、在白色背景上几乎看不见
图 16-3左:Soft Additive(滤色),重叠处变亮但不会过曝;中:Multiply,只会让背景变暗,在黑色区域完全不可见;右:BlendOp Max,逐分量取最大值,重叠处不累加

预乘 Alpha 为什么重要

预乘(Premultiplied Alpha)把“颜色乘以覆盖率”这一步提前到贴图导出时完成,Shader 输出的 rgb 已经是 S·a。这带来三个好处:

  1. 遮挡和发光可以共存:rgb 与 alpha 是独立的。alpha 决定“挡住多少背景”,rgb 决定“加上多少光”。alpha = 0、rgb > 0 的像素就是纯发光——图 16-2 中图的辉光就是这样做出来的,一个材质同时完成了 Alpha 混合和 Additive 两种工作,特效可以少一个 Draw Call。
  2. 过滤正确:直通 Alpha 的贴图在透明区域的 rgb 往往是无意义的值(常见是黑色),双线性过滤、mip 生成时会把这些黑色混进边缘,出现一圈暗边。预乘后透明区域的 rgb 一定是 0,过滤结果天然正确。
  3. 合成满足结合律:多层预乘图像可以先两两合成再整体合成,结果不变——这对把 UI 或特效先渲染到 RT 再合成的流程非常重要。
常见坑① URP 默认使用 HDR 颜色缓冲,Additive 可以让颜色超过 1,配合 Bloom 很漂亮;但 Soft Additive 的因子 OneMinusDstColor 在目标值大于 1 时变成负数,会把颜色“减”掉,HDR 下请慎用。② Multiply 类模式要求透明区域输出白色(乘 1 不变),所以片元输出要用 lerp(1, rgb, a),直接输出 rgb 会把透明区域染黑。③ 在线性颜色空间工程里,混合发生在线性值上(sRGB 目标会在读写时自动转换),半透明边缘的观感和 Photoshop 默认的 Gamma 空间混合不同,美术对比参考图时需要知道这一点。④ Min/Max 运算会忽略混合因子,直接逐分量取最小/最大值。

对照:URP Lit 的 Blending Mode 是怎么设置的

URP Lit 材质的 Surface Type 选 Transparent 后,Blending Mode 有四个选项。它们最终都落到材质的 _SrcBlend/_DstBlend/_SrcBlendAlpha/_DstBlendAlpha 四个属性上,由编辑器脚本 BaseShaderGUI.cs 设置(摘要如下,以 URP 14 源码为准):

Blending ModeRGB 因子Alpha 因子Shader 里的配合
AlphaSrcAlpha, OneMinusSrcAlphaOne, OneMinusSrcAlpha—
PremultiplyOne, OneMinusSrcAlphaOne, OneMinusSrcAlpha关键字 _ALPHAPREMULTIPLY_ON
AdditiveSrcAlpha, OneOne, One—
MultiplyDstColor, ZeroZero, One(保留目标 Alpha)关键字 _ALPHAMODULATE_ON,Shader 输出 lerp(1, rgb, a)

注意 Alpha 模式下 Alpha 通道用的是 One, OneMinusSrcAlpha 而不是和 RGB 一样的因子——正是上面说的“覆盖率累积”。另外还有一个 Preserve Specular Lighting 选项(Alpha 与 Additive 模式可用):勾选后 RGB 的源因子被改成 One,由 Shader 自己只把漫反射部分乘上 alpha,高光部分不乘。物理上这很合理——一块几乎全透明的玻璃,表面反射的高光并不会因为玻璃透明而变弱。用前面的说法,这就是“预乘 Alpha,但只预乘漫反射”。如果你自己写的透明 Shader 里玻璃的高光总是灰蒙蒙的,原因多半就在这里。