GameTech/URP 渲染实战

案例 23.2平面投影阴影 + 模板防重叠进阶

目标:给角色 Shader 增加一个 Pass,把网格投影到 _PlaneHeight 高度的地面上,画成半透明阴影色;用模板测试保证同一像素只被阴影覆盖一次。

Assets/Cookbook/Ch23/PlanarShadow.shader
// 案例 23.3:平面投影阴影(Planar Shadow)。
// 第一个 Pass 正常着色;第二个 Pass(SRPDefaultUnlit)把同一个网格沿主光方向“拍扁”到地面上,
// 输出半透明的阴影色,并用模板测试保证同一个像素只被阴影覆盖一次,避免重叠部分变黑。
Shader "Cookbook/Ch23/PlanarShadow"
{
    Properties
    {
        [MainColor] _BaseColor ("Base Color", Color) = (0.9, 0.6, 0.3, 1)
        _ShadowColor ("Shadow Color", Color) = (0.05, 0.06, 0.1, 0.65)
        _PlaneHeight ("Plane Height (世界空间 y)", Float) = 0
        _ShadowFalloff ("Height Falloff", Range(0, 2)) = 0.35
        [Enum(UnityEngine.Rendering.CompareFunction)] _StencilComp ("Stencil Comp", Float) = 6  // 6 = NotEqual
    }
    SubShader
    {
        // 排在普通不透明物体之后,保证地面已经画好,阴影才能混合到地面颜色上
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry+10" }

        HLSLINCLUDE
        #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
        CBUFFER_START(UnityPerMaterial)
            half4 _BaseColor;
            half4 _ShadowColor;
            float _PlaneHeight;
            float _ShadowFalloff;
        CBUFFER_END
        ENDHLSL

        Pass
        {
            Name "ForwardLit"
            Tags { "LightMode"="UniversalForward" }
            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"
            struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; };
            struct Varyings { float4 positionCS : SV_POSITION; half3 normalWS : TEXCOORD0; };
            Varyings vert(Attributes v)
            {
                Varyings o;
                o.positionCS = TransformObjectToHClip(v.positionOS.xyz);
                o.normalWS = TransformObjectToWorldNormal(v.normalOS);
                return o;
            }
            half4 frag(Varyings i) : SV_Target
            {
                half3 n = normalize(i.normalWS);
                Light l = GetMainLight();
                half3 c = _BaseColor.rgb * (l.color * saturate(dot(n, l.direction)) + SampleSH(n));
                return half4(c, 1);
            }
            ENDHLSL
        }

        Pass
        {
            Name "PlanarShadow"
            Tags { "LightMode"="SRPDefaultUnlit" }   // URP 会把它和 UniversalForward 一起画

            Stencil
            {
                Ref 1
                Comp [_StencilComp]   // NotEqual:这个像素还没画过阴影才画
                Pass Replace          // 画过就把模板写成 1,后面的阴影三角形再来就被拒绝
            }
            Blend SrcAlpha OneMinusSrcAlpha
            ZWrite Off
            Cull Back

            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            struct Attributes { float4 positionOS : POSITION; };
            struct Varyings { float4 positionCS : SV_POSITION; half fade : TEXCOORD0; };

            Varyings vert(Attributes v)
            {
                Varyings o;
                float3 positionWS = TransformObjectToWorld(v.positionOS.xyz);
                // _MainLightPosition.xyz 对方向光来说是“指向光源”的单位向量 L
                float3 L = normalize(_MainLightPosition.xyz);

                // 求沿 -L 方向走多远能碰到平面 y = h:  P' = P - L * (P.y - h) / L.y
                float height = positionWS.y - _PlaneHeight;
                float3 projected = positionWS - L * (height / max(L.y, 1e-3));
                projected.y = _PlaneHeight + 0.002;   // 抬高 2 毫米,避免与地面 Z-Fighting

                o.positionCS = TransformWorldToHClip(projected);
                // 离地越高的部位投下的阴影越淡,模拟真实阴影随距离变软变淡
                o.fade = saturate(1.0 - height * _ShadowFalloff);
                return o;
            }

            half4 frag(Varyings i) : SV_Target
            {
                return half4(_ShadowColor.rgb, _ShadowColor.a * i.fade);
            }
            ENDHLSL
        }

        Pass
        {
            Name "DepthOnly"
            Tags { "LightMode"="DepthOnly" }
            ZWrite On ColorMask R
            HLSLPROGRAM
            #pragma vertex Ch23DepthOnlyVert
            #pragma fragment Ch23NullFrag
            #include "Assets/Cookbook/Ch23/Ch23Common.hlsl"
            ENDHLSL
        }
    }
}
// 案例 23.3:平面投影阴影(Planar Shadow)(GLSL 版)。
// 第一个 Pass 正常着色;第二个 Pass(SRPDefaultUnlit)把同一个网格沿主光方向“拍扁”到地面上,
// 输出半透明的阴影色,并用模板测试保证同一个像素只被阴影覆盖一次,避免重叠部分变黑。
Shader "Cookbook/Ch23/PlanarShadow"
{
    SubShader
    {
        // 排在普通不透明物体之后,保证地面已经画好,阴影才能混合到地面颜色上
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry+10" }

        GLSLINCLUDE
        uniform vec4  _BaseColor;
        uniform vec4  _ShadowColor;
        uniform float _PlaneHeight;
        uniform float _ShadowFalloff;
        ENDGLSL

        Pass
        {
            Name "ForwardLit"
            Tags { "LightMode"="UniversalForward" }
            GLSLPROGRAM
            VARYING vec3 normalWS;
            #ifdef VERTEX
            void main()
            {
                gl_Position = TransformObjectToHClip(positionOS.xyz);
                normalWS = TransformObjectToWorldNormal(normalOS);
            }
            #endif
            #ifdef FRAGMENT
            void main()
            {
                vec3 n = normalize(normalWS);
                Light l = GetMainLight();
                vec3 c = _BaseColor.rgb * (l.color * saturate(dot(n, l.direction)) + SampleSH(n));
                fragColor = vec4(c, 1);
            }
            #endif
            ENDGLSL
        }

        Pass
        {
            Name "PlanarShadow"
            Tags { "LightMode"="SRPDefaultUnlit" }   // URP 会把它和 UniversalForward 一起画

            Stencil
            {
                Ref 1
                Comp [_StencilComp]   // NotEqual:这个像素还没画过阴影才画
                Pass Replace          // 画过就把模板写成 1,后面的阴影三角形再来就被拒绝
            }
            Blend SrcAlpha OneMinusSrcAlpha
            ZWrite Off
            Cull Back

            GLSLPROGRAM
            VARYING float fade;

            #ifdef VERTEX
            void main()
            {
                vec3 positionWS = TransformObjectToWorld(positionOS.xyz);
                // _MainLightPosition.xyz 对方向光来说是“指向光源”的单位向量 L
                vec3 L = normalize(_MainLightPosition.xyz);

                // 求沿 -L 方向走多远能碰到平面 y = h:  P' = P - L * (P.y - h) / L.y
                float height = positionWS.y - _PlaneHeight;
                vec3 projected = positionWS - L * (height / max(L.y, 1e-3));
                projected.y = _PlaneHeight + 0.002;   // 抬高 2 毫米,避免与地面 Z-Fighting

                gl_Position = TransformWorldToHClip(projected);
                // 离地越高的部位投下的阴影越淡,模拟真实阴影随距离变软变淡
                fade = saturate(1.0 - height * _ShadowFalloff);
            }
            #endif

            #ifdef FRAGMENT
            void main()
            {
                fragColor = vec4(_ShadowColor.rgb, _ShadowColor.a * fade);
            }
            #endif
            ENDGLSL
        }

        Pass
        {
            Name "DepthOnly"
            Tags { "LightMode"="DepthOnly" }
            ZWrite On ColorMask R
            GLSLPROGRAM
            #define CH23_DEPTH_PASS
            #include "Ch23/Ch23Common.glsl"
            ENDGLSL
        }
    }
}

为什么用 SRPDefaultUnlit。URP 的不透明绘制会同时收集 SRPDefaultUnlit、UniversalForward、UniversalForwardOnly 三种 LightMode 的 Pass。一个 Shader 里同时有 UniversalForward 和 SRPDefaultUnlit 两个 Pass,物体就会被画两遍——这是 URP 里实现“多 Pass 效果”(描边、平面阴影)最简单的方式,代价是这两个 Pass 不能被 SRP Batcher 合并成一次绘制。如果项目里平面阴影物体很多,也可以把阴影 Pass 单独做成一个 LightMode(如 "PlanarShadow"),再用 RenderObjects Renderer Feature 在不透明物体之后统一绘制,第 25 章会讲 RenderObjects 的用法。

Assets/Cookbook/Ch23/PlanarShadow.shader(节选:planar)
Pass
{
    Name "PlanarShadow"
    Tags { "LightMode"="SRPDefaultUnlit" }   // URP 会把它和 UniversalForward 一起画

    Stencil
    {
        Ref 1
        Comp [_StencilComp]   // NotEqual:这个像素还没画过阴影才画
        Pass Replace          // 画过就把模板写成 1,后面的阴影三角形再来就被拒绝
    }
    Blend SrcAlpha OneMinusSrcAlpha
    ZWrite Off
    Cull Back

    HLSLPROGRAM
    #pragma vertex vert
    #pragma fragment frag

    struct Attributes { float4 positionOS : POSITION; };
    struct Varyings { float4 positionCS : SV_POSITION; half fade : TEXCOORD0; };

    Varyings vert(Attributes v)
    {
        Varyings o;
        float3 positionWS = TransformObjectToWorld(v.positionOS.xyz);
        // _MainLightPosition.xyz 对方向光来说是“指向光源”的单位向量 L
        float3 L = normalize(_MainLightPosition.xyz);

        // 求沿 -L 方向走多远能碰到平面 y = h:  P' = P - L * (P.y - h) / L.y
        float height = positionWS.y - _PlaneHeight;
        float3 projected = positionWS - L * (height / max(L.y, 1e-3));
        projected.y = _PlaneHeight + 0.002;   // 抬高 2 毫米,避免与地面 Z-Fighting

        o.positionCS = TransformWorldToHClip(projected);
        // 离地越高的部位投下的阴影越淡,模拟真实阴影随距离变软变淡
        o.fade = saturate(1.0 - height * _ShadowFalloff);
        return o;
    }

    half4 frag(Varyings i) : SV_Target
    {
        return half4(_ShadowColor.rgb, _ShadowColor.a * i.fade);
    }
    ENDHLSL
}
Pass
{
    Name "PlanarShadow"
    Tags { "LightMode"="SRPDefaultUnlit" }   // URP 会把它和 UniversalForward 一起画

    Stencil
    {
        Ref 1
        Comp [_StencilComp]   // NotEqual:这个像素还没画过阴影才画
        Pass Replace          // 画过就把模板写成 1,后面的阴影三角形再来就被拒绝
    }
    Blend SrcAlpha OneMinusSrcAlpha
    ZWrite Off
    Cull Back

    GLSLPROGRAM
    VARYING float fade;

    #ifdef VERTEX
    void main()
    {
        vec3 positionWS = TransformObjectToWorld(positionOS.xyz);
        // _MainLightPosition.xyz 对方向光来说是“指向光源”的单位向量 L
        vec3 L = normalize(_MainLightPosition.xyz);

        // 求沿 -L 方向走多远能碰到平面 y = h:  P' = P - L * (P.y - h) / L.y
        float height = positionWS.y - _PlaneHeight;
        vec3 projected = positionWS - L * (height / max(L.y, 1e-3));
        projected.y = _PlaneHeight + 0.002;   // 抬高 2 毫米,避免与地面 Z-Fighting

        gl_Position = TransformWorldToHClip(projected);
        // 离地越高的部位投下的阴影越淡,模拟真实阴影随距离变软变淡
        fade = saturate(1.0 - height * _ShadowFalloff);
    }
    #endif

    #ifdef FRAGMENT
    void main()
    {
        fragColor = vec4(_ShadowColor.rgb, _ShadowColor.a * fade);
    }
    #endif
    ENDGLSL
}

为什么要模板测试。阴影是半透明混合,同一个像素被两层三角形覆盖就会变成 1−(1−a)² 的浓度。角色的身体和手臂、两个角色之间,投影都会重叠。模板设置 Ref 1 / Comp NotEqual / Pass Replace 的含义是:模板缓冲里这个像素还不是 1 才允许画,画完立刻写成 1,于是后来的阴影三角形全部被拒绝。所有角色共用 Ref 1,角色之间的重叠也一并消除。

为什么 Queue 是 Geometry+10。阴影 Pass 不写深度、要和地面颜色混合,所以必须在地面画完之后再画。不透明队列内部是按距离从前往后排序的,地面这种大物体的排序位置不可预测,干脆把角色材质的队列推后一点,保证地面先画。

高度淡出。fade = saturate(1 − height × _ShadowFalloff) 让离地越高的部位投下的影子越淡,模拟真实阴影随距离变虚。片元透明度与顶点高度相关,同一块阴影内部会有渐变。

图 23-11平面投影阴影(关闭实时阴影)。左:模板比较设为 Always,身体与手臂、头部的投影重叠处颜色更深;中:开启模板(NotEqual),整块阴影浓度均匀;右:冷色阴影 + 更强的高度淡出
图 23-12平面阴影的局限。左:平面阴影只认 y = 0 的平面,影子钻到台阶下面被遮住,看起来像台阶上没有影子;右:同一场景的阴影贴图,影子正确地爬上台阶

常见坑:一是光线接近水平时 L.y 趋于 0,投影被拉到无穷远,代码里用 max(L.y, 1e-3) 防止除零,项目里最好再限制最大投影长度。二是投影出的三角形落在地面高度,必须轻微抬高(这里是 2 毫米)或用 Offset 避免 Z-Fighting。三是不平整的地面(坡道、台阶)不适用,可以只在角色脚下的局部地面取一个平均法线和高度,用通用的 n·p + d = 0 版本矩阵。四是 URP 的 Deferred 路径会使用模板缓冲的高位,用平面阴影时注意不要和它的模板位冲突(可以设置 ReadMask/WriteMask 只用低位)。