GameTech/URP 渲染实战

案例 6.4手写 ShadowCaster Pass进阶

目标:完整实现一个可复用的 ShadowCaster Pass,正确处理方向光与点光/聚光两种投射、深度与法线偏移、近平面压扁,以及 Alpha 裁剪材质的镂空阴影。

完整代码

Assets/Cookbook/Ch06/ShadowCasterPass.hlsl
// 案例 6.4 手写 ShadowCaster Pass:把物体画进光源的阴影图(只写深度)。
// 用法:在 Pass 里先声明好材质 CBUFFER(需要 Alpha 裁剪时要有 _BaseMap/_BaseColor/_Cutoff),再 include 本文件。
#ifndef COOKBOOK_CH06_SHADOW_CASTER_PASS_INCLUDED
#define COOKBOOK_CH06_SHADOW_CASTER_PASS_INCLUDED

#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
// Shadows.hlsl 里的 SampleShadowmap 用到 LerpWhiteTo(),它在 CommonMaterial.hlsl 里,必须先 include
#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/CommonMaterial.hlsl"
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Shadows.hlsl"

// 由 URP 的 MainLightShadowCasterPass / AdditionalLightsShadowCasterPass 在画每个阴影切片前设置
// (见 Runtime/ShadowUtils.cs 的 SetupShadowCasterConstantBuffer)
float3 _LightDirection;   // 方向光:指向光源的方向
float3 _LightPosition;    // 点光 / 聚光:光源的世界坐标

struct ShadowCasterAttributes
{
    float4 positionOS : POSITION;
    float3 normalOS   : NORMAL;
    float2 texcoord   : TEXCOORD0;
    UNITY_VERTEX_INPUT_INSTANCE_ID
};

struct ShadowCasterVaryings
{
#if defined(_ALPHATEST_ON)
    float2 uv         : TEXCOORD0;
#endif
    float4 positionCS : SV_POSITION;
    UNITY_VERTEX_INPUT_INSTANCE_ID
};

float4 GetShadowCasterPositionCS(float3 positionWS, float3 normalWS)
{
    // 1) 求“指向光源”的方向:方向光处处相同;点光/聚光每个顶点不同
#if defined(_CASTING_PUNCTUAL_LIGHT_SHADOW)
    float3 lightDirectionWS = normalize(_LightPosition - positionWS);
#else
    float3 lightDirectionWS = _LightDirection;
#endif

    // 2) 在世界空间里做深度偏移 + 法线偏移,再变换到光源的裁剪空间
    //    (此刻 UNITY_MATRIX_VP 已被 URP 设成了“光源相机”的 VP 矩阵)
    float4 positionCS = TransformWorldToHClip(ApplyShadowBias(positionWS, normalWS, lightDirectionWS));

    // 3) 把跑到近平面之外的顶点“压扁”到近平面上(Shadow Pancaking),
    //    否则光源近平面之前的投射体会被裁掉,阴影凭空缺一块
#if UNITY_REVERSED_Z
    positionCS.z = min(positionCS.z, UNITY_NEAR_CLIP_VALUE);
#else
    positionCS.z = max(positionCS.z, UNITY_NEAR_CLIP_VALUE);
#endif
    return positionCS;
}

ShadowCasterVaryings ShadowCasterVertex(ShadowCasterAttributes input)
{
    ShadowCasterVaryings output = (ShadowCasterVaryings)0;
    UNITY_SETUP_INSTANCE_ID(input);
    UNITY_TRANSFER_INSTANCE_ID(input, output);

    float3 positionWS = TransformObjectToWorld(input.positionOS.xyz);
    float3 normalWS = TransformObjectToWorldNormal(input.normalOS);
    output.positionCS = GetShadowCasterPositionCS(positionWS, normalWS);
#if defined(_ALPHATEST_ON)
    output.uv = TRANSFORM_TEX(input.texcoord, _BaseMap);
#endif
    return output;
}

half4 ShadowCasterFragment(ShadowCasterVaryings input) : SV_Target
{
    UNITY_SETUP_INSTANCE_ID(input);
#if defined(_ALPHATEST_ON)
    // 镂空材质的影子也要镂空:和颜色 Pass 用同样的 clip
    half alpha = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv).a * _BaseColor.a;
    clip(alpha - _Cutoff);
#endif
    return 0;
}

#endif
// 案例 6.4 手写 ShadowCaster Pass:把物体画进光源的阴影图(只写深度)。(GLSL 版,对应 Ch06/ShadowCasterPass.hlsl)
// 用法:在 Pass 里先声明好材质 uniform(需要 Alpha 裁剪时要有 _BaseMap/_BaseMap_ST/_BaseColor/_Cutoff),再 include 本文件。
// ApplyShadowBias 在自动包含的 Lighting.glsl 里(对应 URP 的 Shadows.hlsl)。

// 由 URP 的 MainLightShadowCasterPass / AdditionalLightsShadowCasterPass 在画每个阴影切片前设置
// (见 Runtime/ShadowUtils.cs 的 SetupShadowCasterConstantBuffer)。
// GLSL 版里 _LightDirection / _LightPosition 已在 Lighting.glsl 里声明:
//   uniform vec3 _LightDirection;   // 方向光:指向光源的方向
//   uniform vec3 _LightPosition;    // 点光 / 聚光:光源的世界坐标

#if defined(_ALPHATEST_ON)
VARYING vec2 uv;
#endif

vec4 GetShadowCasterPositionCS(vec3 positionWS, vec3 normalWS)
{
    // 1) 求“指向光源”的方向:方向光处处相同;点光/聚光每个顶点不同
    //    (HLSL 里的 _CASTING_PUNCTUAL_LIGHT_SHADOW 关键字,在 GLSL 版里是一个 uniform)
    vec3 lightDirectionWS = _CastingPunctualLightShadow > 0.5 ? normalize(_LightPosition - positionWS) : _LightDirection;

    // 2) 在世界空间里做深度偏移 + 法线偏移,再变换到光源的裁剪空间
    //    (此刻 UNITY_MATRIX_VP 已被 URP 设成了“光源相机”的 VP 矩阵)
    vec4 positionCS = TransformWorldToHClip(ApplyShadowBias(positionWS, normalWS, lightDirectionWS));

    // 3) 把跑到近平面之外的顶点“压扁”到近平面上(Shadow Pancaking),
    //    否则光源近平面之前的投射体会被裁掉,阴影凭空缺一块
#if UNITY_REVERSED_Z
    positionCS.z = min(positionCS.z, UNITY_NEAR_CLIP_VALUE);
#else
    positionCS.z = max(positionCS.z, UNITY_NEAR_CLIP_VALUE);
#endif
    return positionCS;
}

#ifdef VERTEX
void main()
{
    vec3 positionWS = TransformObjectToWorld(positionOS.xyz);
    vec3 normalWS = TransformObjectToWorldNormal(normalOS);
    gl_Position = GetShadowCasterPositionCS(positionWS, normalWS);
#if defined(_ALPHATEST_ON)
    uv = TransformTex(texcoord0.xy, _BaseMap_ST);
#endif
}
#endif

#ifdef FRAGMENT
void main()
{
#if defined(_ALPHATEST_ON)
    // 镂空材质的影子也要镂空:和颜色 Pass 用同样的 clip
    float alpha = texture(_BaseMap, uv).a * _BaseColor.a;
    if (alpha - _Cutoff < 0.0) discard;
#endif
    fragColor = vec4(0.0);
}
#endif

关键代码讲解

Assets/Cookbook/Ch06/ShadowCasterPass.hlsl(节选:shadowpos)
float4 GetShadowCasterPositionCS(float3 positionWS, float3 normalWS)
{
    // 1) 求“指向光源”的方向:方向光处处相同;点光/聚光每个顶点不同
#if defined(_CASTING_PUNCTUAL_LIGHT_SHADOW)
    float3 lightDirectionWS = normalize(_LightPosition - positionWS);
#else
    float3 lightDirectionWS = _LightDirection;
#endif

    // 2) 在世界空间里做深度偏移 + 法线偏移,再变换到光源的裁剪空间
    //    (此刻 UNITY_MATRIX_VP 已被 URP 设成了“光源相机”的 VP 矩阵)
    float4 positionCS = TransformWorldToHClip(ApplyShadowBias(positionWS, normalWS, lightDirectionWS));

    // 3) 把跑到近平面之外的顶点“压扁”到近平面上(Shadow Pancaking),
    //    否则光源近平面之前的投射体会被裁掉,阴影凭空缺一块
#if UNITY_REVERSED_Z
    positionCS.z = min(positionCS.z, UNITY_NEAR_CLIP_VALUE);
#else
    positionCS.z = max(positionCS.z, UNITY_NEAR_CLIP_VALUE);
#endif
    return positionCS;
}
vec4 GetShadowCasterPositionCS(vec3 positionWS, vec3 normalWS)
{
    // 1) 求“指向光源”的方向:方向光处处相同;点光/聚光每个顶点不同
    //    (HLSL 里的 _CASTING_PUNCTUAL_LIGHT_SHADOW 关键字,在 GLSL 版里是一个 uniform)
    vec3 lightDirectionWS = _CastingPunctualLightShadow > 0.5 ? normalize(_LightPosition - positionWS) : _LightDirection;

    // 2) 在世界空间里做深度偏移 + 法线偏移,再变换到光源的裁剪空间
    //    (此刻 UNITY_MATRIX_VP 已被 URP 设成了“光源相机”的 VP 矩阵)
    vec4 positionCS = TransformWorldToHClip(ApplyShadowBias(positionWS, normalWS, lightDirectionWS));

    // 3) 把跑到近平面之外的顶点“压扁”到近平面上(Shadow Pancaking),
    //    否则光源近平面之前的投射体会被裁掉,阴影凭空缺一块
#if UNITY_REVERSED_Z
    positionCS.z = min(positionCS.z, UNITY_NEAR_CLIP_VALUE);
#else
    positionCS.z = max(positionCS.z, UNITY_NEAR_CLIP_VALUE);
#endif
    return positionCS;
}

第一步,求光的方向。方向光的方向处处相同,URP 在绘制阴影前把它写进 _LightDirection;点光和聚光的方向随顶点而变,URP 写入的是光源位置 _LightPosition,要在顶点里自己求 normalize(_LightPosition − positionWS)。两种情况用 _CASTING_PUNCTUAL_LIGHT_SHADOW 关键字区分:URP 的 MainLightShadowCasterPass 在绘制前用 CoreUtils.SetKeyword 把它关掉,AdditionalLightsShadowCasterPass 把它打开。它只影响顶点着色器,所以用 multi_compile_vertex 声明,避免片元变体翻倍。

第二步,偏移并变换。ApplyShadowBias 已经在 6.5 节分析过。之后调用的 TransformWorldToHClip 和平时一样使用 UNITY_MATRIX_VP——在阴影 Pass 里,URP 已经把它设成了光源的视图投影矩阵,所以同一个函数自动就“从光源看过去”。

第三步,近平面压扁(Shadow Pancaking)。方向光的阴影相机的近平面被尽量贴近接收阴影的区域,以提高深度精度。可是投射阴影的物体可能在近平面之前(例如高处的树冠),如果被近平面裁掉,它的影子就凭空少一块。把 z 夹到近平面上,这些顶点就被“压扁”在近平面上,仍然写入了最近的深度、仍然能挡光。UNITY_NEAR_CLIP_VALUE 在 Reversed-Z 平台(D3D、Metal、Vulkan)上是 1,在 OpenGL 上是 −1,所以比较方向用 UNITY_REVERSED_Z 区分。

片元着色器只有在 _ALPHATEST_ON 时才做事:镂空材质(树叶、铁丝网)的影子也要镂空,必须用与颜色 Pass 相同的贴图、颜色和阈值做 clip。不需要裁剪时它什么也不输出,配合 Pass 上的 ColorMask 0,GPU 只写深度。

在 Shader 中使用它:

Assets/Cookbook/Ch06/ClassicLit.shader(节选:shadowpass)
Pass
{
    Name "ShadowCaster"
    Tags { "LightMode"="ShadowCaster" }
    ZWrite On
    ZTest LEqual
    ColorMask 0
    Cull [_Cull]

    HLSLPROGRAM
    #pragma target 2.0
    #pragma vertex ShadowCasterVertex
    #pragma fragment ShadowCasterFragment
    #pragma shader_feature_local_fragment _ALPHATEST_ON
    #pragma multi_compile_instancing
    // 区分方向光与点光/聚光:两者计算“指向光源方向”的方式不同
    #pragma multi_compile_vertex _ _CASTING_PUNCTUAL_LIGHT_SHADOW
    #include "Assets/Cookbook/Ch06/ShadowCasterPass.hlsl"
    ENDHLSL
}
Pass
{
    Name "ShadowCaster"
    Tags { "LightMode"="ShadowCaster" }
    ZWrite On
    ZTest LEqual
    ColorMask 0
    Cull [_Cull]

    GLSLPROGRAM
    #pragma shader_feature_local_fragment _ALPHATEST_ON
    // 区分方向光与点光/聚光:两者计算“指向光源方向”的方式不同(GLSL 版里是 uniform _CastingPunctualLightShadow)
    #include "Ch06/ShadowCasterPass.glsl"
    ENDGLSL
}

Pass 的渲染状态与 Lit 保持一致:ZWrite On、ZTest LEqual、ColorMask 0,Cull 跟随材质。材质 CBUFFER 写在 SubShader 的 HLSLINCLUDE 块里,被所有 Pass 共享——这是 SRP Batcher 的要求:同一个 Shader 的所有 Pass 必须使用布局完全相同的 UnityPerMaterial。

图 6-15左:用 Material.SetShaderPassEnabled("ShadowCaster", false) 关闭了这个 Pass,物体不再出现在阴影图里,也就不投射阴影;右:恢复后正常投影。Renderer 上的 Cast Shadows 选项关掉时效果相同
坑① 只 include Shadows.hlsl 而没有 include CommonMaterial.hlsl,会报 undeclared identifier 'LerpWhiteTo'(第 5 章已提过)。② 顶点动画的 Shader(第 17 章)必须在 ShadowCaster 里做完全相同的顶点偏移,否则影子还停在原地。③ 用 UsePass "Universal Render Pipeline/Lit/ShadowCaster" 偷懒虽然能投影,但 Lit 的 Pass 使用 Lit 的 CBUFFER,与你的 Shader 不一致,整个 Shader 会失去 SRP Batcher 兼容性。