GameTech/URP 渲染实战
CHAPTER 06第 6 章

多光源与阴影

上一章的 Shader 只认识一盏方向光,而且光线可以穿过任何物体。真实的场景里有台灯、路灯、火把和手电筒,物体会互相遮挡。本章先让 Shader 在 Forward 和 Forward+ 两条路径下都能遍历所有光源,再把阴影映射从原理到源码彻底拆开,最后组装出一个完整的 Blinn-Phong + 多光源 + 阴影 Shader——ClassicLit,后续章节都会在它的基础上扩展。

本章你会学到
URP 如何组织主光源与附加光,Forward 与 Forward+ 的光源剔除差异
LIGHT_LOOP_BEGIN/END 与 _FORWARD_PLUS:一套代码兼容两条路径
点光、聚光的衰减公式,以及 URP 为什么要加“平滑窗口”
阴影映射原理:深度比较、粉刺、Peter-Panning、Bias、PCF、级联
手写 ShadowCaster:ApplyShadowBias 与 _CASTING_PUNCTUAL_LIGHT_SHADOW
DepthOnly / DepthNormals Pass 的作用,以及完整的 ClassicLit Shader

6.1 URP 如何组织多个光源

URP 把场景里的实时光源分成两类:主光源(Main Light)只有一盏,必须是方向光,挑选规则在 5.2 节讲过;其余所有可见的方向光、点光和聚光统称附加光(Additional Lights)。主光源的数据放在 _MainLightPosition、_MainLightColor 等独立的全局变量里,并享有专门的级联阴影;附加光的数据则打包成数组:

摘自 URP 14 ShaderLibrary/Input.hlsl
float4 _AdditionalLightsPosition[MAX_VISIBLE_LIGHTS];
// In Forward+, .a stores whether the light is using subtractive mixed mode.
half4 _AdditionalLightsColor[MAX_VISIBLE_LIGHTS];
half4 _AdditionalLightsAttenuation[MAX_VISIBLE_LIGHTS];
half4 _AdditionalLightsSpotDir[MAX_VISIBLE_LIGHTS];
half4 _AdditionalLightsOcclusionProbes[MAX_VISIBLE_LIGHTS];
float _AdditionalLightsLayerMasks[MAX_VISIBLE_LIGHTS]; // we want uint[] but Unity api does not support it.

MAX_VISIBLE_LIGHTS 是每个相机最多能看到的附加光数量:桌面平台 256,移动平台 32,OpenGL ES 3.0 级别的低端设备 16(定义在 URP 配置包的 ShaderConfig.cs.hlsl 里)。问题是:一个像素不可能真的把 256 盏灯全算一遍。怎样只算“照得到它的那几盏”,就是渲染路径要解决的问题。

Forward:每个物体一张光源列表 Forward+:屏幕分块 × 深度分段 1234 物体 A CPU 为物体 A 排序挑出:[2, 1, 4, 3] 数量受 Per Object Limit 限制(≤ 8) 写入 unity_LightData.y 与 unity_LightIndices tile 每个 tile 存一张“哪些灯与我相交”的位掩码; 每个深度段(Z-Bin)也存一张,像素取两者交集 没有每物体上限;方向光不入分簇,单独循环
图 6-1两种附加光剔除方式。Forward 在 CPU 上为每个物体挑选最重要的几盏灯;Forward+ 在 GPU 端按屏幕 tile 和深度分段建立光源位掩码,每个像素只遍历与自己所在 tile、深度段都相交的灯

Forward 路径以物体为单位剔除:渲染每个物体前,CPU 根据灯光的范围、强度与物体包围盒,为它挑出最重要的若干盏灯,下标写进 unity_LightIndices,数量写进 unity_LightData.y。数量受 URP Asset 中 Lighting › Additional Lights › Per Object Limit 限制,最大为 8。它的问题很明显:一个大物体(比如地面)身上可能落着十几盏灯,却最多只能算 8 盏,多出来的灯会突然“消失”;为了绕开限制,美术不得不把地面切成小块。

Forward+(URP 14 新增)以像素为单位剔除:把屏幕分成若干 tile,把视锥沿深度分成若干段(Z-Bin),在 GPU 上为每个 tile 和每个深度段各算一张“与哪些灯相交”的位掩码。着色时,像素根据自己的屏幕坐标和深度取出两张掩码求交,只遍历结果里的灯。它没有每物体上限,大地面上铺满灯也没问题;代价是需要额外的计算和缓冲。方向光照亮所有像素,没有必要放进分块结构,所以 Forward+ 把附加的方向光单独放在光源数组最前面,数量记在 URP_FP_DIRECTIONAL_LIGHTS_COUNT 里。

Deferred 路径把材质属性先写进 G-Buffer,再在屏幕空间逐光源累加,不在本章讨论范围(参见第 2 章)。需要注意的是:Deferred 下透明物体和“只能前向渲染”的材质仍然走前向光照,所以前向的多光源写法在任何路径下都用得上。

6.2 附加光循环:两条路径,一套写法

URP 用宏把两种剔除方式藏在同一套接口后面。核心是 RealtimeLights.hlsl 里的这两个宏:

摘自 URP 14 ShaderLibrary/RealtimeLights.hlsl
#if USE_FORWARD_PLUS
    #define LIGHT_LOOP_BEGIN(lightCount) { \
    uint lightIndex; \
    ClusterIterator _urp_internal_clusterIterator = ClusterInit(inputData.normalizedScreenSpaceUV, inputData.positionWS, 0); \
    [loop] while (ClusterNext(_urp_internal_clusterIterator, lightIndex)) { \
        lightIndex += URP_FP_DIRECTIONAL_LIGHTS_COUNT; \
        FORWARD_PLUS_SUBTRACTIVE_LIGHT_CHECK
    #define LIGHT_LOOP_END } }
#elif !_USE_WEBGL1_LIGHTS
    #define LIGHT_LOOP_BEGIN(lightCount) \
    for (uint lightIndex = 0u; lightIndex < lightCount; ++lightIndex) {

    #define LIGHT_LOOP_END }
#else
    ...
#endif

读这段宏要注意四件事:

  1. USE_FORWARD_PLUS 从哪来。Core.hlsl 开头写着:如果定义了 _FORWARD_PLUS,就定义 USE_FORWARD_PLUS 1,同时强制定义 _ADDITIONAL_LIGHTS 并取消 _ADDITIONAL_LIGHTS_VERTEX。而 _FORWARD_PLUS 是 URP 在 Forward+ 路径下打开的全局关键字。所以 Shader 必须声明 #pragma multi_compile _ _FORWARD_PLUS,否则在 Forward+ 下拿到的是 Forward 版本的代码,而 Forward+ 根本不填每物体的光源列表——附加光会全部失效。
  2. Forward+ 版本偷偷引用了变量 inputData。宏展开后直接读 inputData.normalizedScreenSpaceUV 和 inputData.positionWS,所以调用它的函数里必须有一个名叫 inputData、类型为 InputData 的变量,并且这两个字段已经填好。名字写成别的会编译失败。
  3. Forward 版本的 lightCount 来自 GetAdditionalLightsCount()。在 Forward+ 下这个函数恒返回 0,宏也不使用它,循环靠分簇迭代器 ClusterNext 驱动。
  4. Forward+ 不包含附加方向光。迭代器得到的下标要加上 URP_FP_DIRECTIONAL_LIGHTS_COUNT,前面那几个方向光必须由我们自己再写一个 for 循环处理。URP 自己的 UniversalFragmentPBR 和 UniversalFragmentBlinnPhong 都是这么写的。

取到下标以后,用 GetAdditionalLight(lightIndex, positionWS) 得到一个 Light 结构体——和主光源完全一样的结构,于是“一盏灯怎么照亮表面”的代码可以原样复用。这个函数在 Forward 下会先通过 GetPerObjectLightIndex 把“本物体的第几盏灯”换算成全局下标,在 Forward+ 下则直接使用全局下标。它还有两个重载:多一个 shadowMask 参数的版本会顺带计算附加光阴影和光照 Cookie;再多一个 AmbientOcclusionFactor 的版本还会乘上 SSAO 的直接光遮蔽。

关键字谁打开它 / 含义
_ADDITIONAL_LIGHTSForward 路径、Asset 中附加光设为 Per Pixel 时由 URP 打开:逐像素循环附加光
_ADDITIONAL_LIGHTS_VERTEX附加光设为 Per Vertex 时打开:在顶点里用 VertexLighting() 累加 Lambert
_FORWARD_PLUSRenderer 的 Rendering Path 为 Forward+ 时打开;Core.hlsl 据此定义 USE_FORWARD_PLUS 与 _ADDITIONAL_LIGHTS
_ADDITIONAL_LIGHT_SHADOWSAsset 开启附加光阴影且有灯投影时打开(见案例 6.7)
_LIGHT_LAYERSAsset 开启 Light Layers 时打开(见 6.4 节)
_LIGHT_COOKIES场景中有灯使用 Cookie 贴图时打开

案例 6.1多光源 Lambert:同时支持 Forward 与 Forward+入门单独打开 ↗

目标:在第 5 章 Lambert 的基础上加入附加光循环,写出一个在 Forward(含逐顶点模式)和 Forward+ 下都能正确工作的最小多光源 Shader。

完整代码

Assets/Cookbook/Ch06/MultiLightLambert.shader
// 案例 6.1 多光源 Lambert:主光 + 附加光循环,同时支持 Forward 与 Forward+
Shader "Cookbook/Ch06/MultiLightLambert"
{
    Properties
    {
        [MainTexture] _BaseMap("Base Map", 2D) = "white" {}
        [MainColor] _BaseColor("Base Color", Color) = (0.8, 0.8, 0.8, 1)
        _AmbientStrength("Ambient Strength", Range(0, 1)) = 0.3
    }

    SubShader
    {
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" }

        HLSLINCLUDE
        #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"

        CBUFFER_START(UnityPerMaterial)
            float4 _BaseMap_ST;
            half4  _BaseColor;
            half   _AmbientStrength;
        CBUFFER_END
        ENDHLSL

        Pass
        {
            Name "ForwardLit"
            Tags { "LightMode"="UniversalForward" }

            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            // Forward 路径:URP 按 Asset 的“Additional Lights”设置打开 _ADDITIONAL_LIGHTS(逐像素)
            // 或 _ADDITIONAL_LIGHTS_VERTEX(逐顶点);Forward+ 路径打开 _FORWARD_PLUS,
            // Core.hlsl 会据此自动定义 _ADDITIONAL_LIGHTS 和 USE_FORWARD_PLUS
            #pragma multi_compile _ _ADDITIONAL_LIGHTS_VERTEX _ADDITIONAL_LIGHTS
            #pragma multi_compile _ _FORWARD_PLUS

            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"

            TEXTURE2D(_BaseMap);
            SAMPLER(sampler_BaseMap);

            struct Attributes
            {
                float4 positionOS : POSITION;
                float3 normalOS   : NORMAL;
                float2 uv         : TEXCOORD0;
            };

            struct Varyings
            {
                float4 positionCS : SV_POSITION;
                float2 uv         : TEXCOORD0;
                float3 positionWS : TEXCOORD1;
                float3 normalWS   : TEXCOORD2;
            #if defined(_ADDITIONAL_LIGHTS_VERTEX)
                half3  vertexLight : TEXCOORD3;
            #endif
            };

            Varyings vert(Attributes input)
            {
                Varyings output;
                VertexPositionInputs pos = GetVertexPositionInputs(input.positionOS.xyz);
                output.positionCS = pos.positionCS;
                output.positionWS = pos.positionWS;
                output.normalWS = TransformObjectToWorldNormal(input.normalOS);
                output.uv = TRANSFORM_TEX(input.uv, _BaseMap);
            #if defined(_ADDITIONAL_LIGHTS_VERTEX)
                // 逐顶点模式:URP 的 VertexLighting() 在顶点里把所有附加光的 Lambert 累加好
                output.vertexLight = VertexLighting(pos.positionWS, output.normalWS);
            #endif
                return output;
            }

            half3 LambertLight(Light light, half3 normalWS)
            {
                half3 radiance = light.color * (light.distanceAttenuation * light.shadowAttenuation);
                return radiance * saturate(dot(normalWS, light.direction));
            }

            half4 frag(Varyings input) : SV_Target
            {
                half3 albedo = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv).rgb * _BaseColor.rgb;
                half3 normalWS = normalize(input.normalWS);

                // LIGHT_LOOP_BEGIN 在 Forward+ 下会直接读取名为 inputData 的变量
                // (inputData.positionWS 与 inputData.normalizedScreenSpaceUV),所以名字不能改
                InputData inputData = (InputData)0;
                inputData.positionWS = input.positionWS;
                inputData.normalWS = normalWS;
                inputData.normalizedScreenSpaceUV = GetNormalizedScreenSpaceUV(input.positionCS);

                // 主光源
                half3 lighting = LambertLight(GetMainLight(), normalWS);

            #if defined(_ADDITIONAL_LIGHTS)
                uint lightCount = GetAdditionalLightsCount();   // Forward+ 下恒为 0,循环靠 cluster 迭代器

                #if USE_FORWARD_PLUS
                // Forward+:附加的“方向光”不进入分簇结构,排在光源数组最前面,要单独循环
                for (uint lightIndex = 0; lightIndex < min(URP_FP_DIRECTIONAL_LIGHTS_COUNT, MAX_VISIBLE_LIGHTS); lightIndex++)
                {
                    lighting += LambertLight(GetAdditionalLight(lightIndex, inputData.positionWS), normalWS);
                }
                #endif

                // Forward:for (lightIndex = 0; lightIndex < lightCount; ...)
                // Forward+:遍历当前像素所在 tile 与 z-bin 的光源位掩码
                LIGHT_LOOP_BEGIN(lightCount)
                    Light light = GetAdditionalLight(lightIndex, inputData.positionWS);
                    lighting += LambertLight(light, normalWS);
                LIGHT_LOOP_END
            #endif

            #if defined(_ADDITIONAL_LIGHTS_VERTEX)
                lighting += input.vertexLight;
            #endif

                half3 color = albedo * lighting + albedo * SampleSH(normalWS) * _AmbientStrength;
                return half4(color, 1);
            }
            ENDHLSL
        }

        // ---- 投影与深度:见案例 6.4 与 6.8 ----
        Pass
        {
            Name "ShadowCaster"
            Tags { "LightMode"="ShadowCaster" }
            ZWrite On
            ZTest LEqual
            ColorMask 0

            HLSLPROGRAM
            #pragma vertex ShadowCasterVertex
            #pragma fragment ShadowCasterFragment
            #pragma multi_compile_vertex _ _CASTING_PUNCTUAL_LIGHT_SHADOW
            #include "Assets/Cookbook/Ch06/ShadowCasterPass.hlsl"
            ENDHLSL
        }

        Pass
        {
            Name "DepthOnly"
            Tags { "LightMode"="DepthOnly" }
            ZWrite On
            ColorMask R

            HLSLPROGRAM
            #pragma vertex DepthOnlyVertex
            #pragma fragment DepthOnlyFragment
            #include "Assets/Cookbook/Ch06/DepthOnlyPass.hlsl"
            ENDHLSL
        }
    }
}
// 案例 6.1 多光源 Lambert:主光 + 附加光循环,同时支持 Forward 与 Forward+(GLSL 版,对应 Ch06/MultiLightLambert.shader)
Shader "Cookbook/Ch06/MultiLightLambert"
{
    SubShader
    {
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" }

        GLSLINCLUDE
        uniform vec4  _BaseMap_ST;
        uniform vec4  _BaseColor;
        uniform float _AmbientStrength;
        ENDGLSL

        Pass
        {
            Name "ForwardLit"
            Tags { "LightMode"="UniversalForward" }

            GLSLPROGRAM
            // Forward 路径:URP 按 Asset 的“Additional Lights”设置打开 _ADDITIONAL_LIGHTS(逐像素)
            // 或 _ADDITIONAL_LIGHTS_VERTEX(逐顶点);Forward+ 路径打开 _FORWARD_PLUS。
            // 网页版引擎始终定义 _ADDITIONAL_LIGHTS(逐像素),Forward / Forward+ 的区别由引擎在
            // GetAdditionalLightsCount() / GetAdditionalLight() 内部处理(见下面的循环)
            #pragma multi_compile _ _ADDITIONAL_LIGHTS_VERTEX _ADDITIONAL_LIGHTS

            uniform sampler2D _BaseMap;

            VARYING vec2 uv;
            VARYING vec3 positionWS;
            VARYING vec3 normalWS;
        #if defined(_ADDITIONAL_LIGHTS_VERTEX)
            VARYING vec3 vertexLight;
        #endif

            #ifdef VERTEX
            void main()
            {
                VertexPositionInputs pos = GetVertexPositionInputs(positionOS.xyz);
                gl_Position = pos.positionCS;
                positionWS = pos.positionWS;
                normalWS = TransformObjectToWorldNormal(normalOS);
                uv = TransformTex(texcoord0.xy, _BaseMap_ST);
            #if defined(_ADDITIONAL_LIGHTS_VERTEX)
                // 逐顶点模式:URP 的 VertexLighting() 在顶点里把所有附加光的 Lambert 累加好
                vertexLight = vec3(0.0);
                for (int i = 0; i < GetAdditionalLightsCount(); i++)
                {
                    Light l = GetAdditionalPerObjectLight(i, pos.positionWS);
                    vertexLight += LightingLambert(l.color * l.distanceAttenuation, l.direction, normalWS);
                }
            #endif
            }
            #endif

            #ifdef FRAGMENT
            vec3 LambertLight(Light light, vec3 normalWS)
            {
                vec3 radiance = light.color * (light.distanceAttenuation * light.shadowAttenuation);
                return radiance * saturate(dot(normalWS, light.direction));
            }

            void main()
            {
                vec3 albedo = texture(_BaseMap, uv).rgb * _BaseColor.rgb;
                vec3 N = normalize(normalWS);

                // HLSL 版里 LIGHT_LOOP_BEGIN 在 Forward+ 下会直接读取名为 inputData 的变量,所以要先填好它;
                // GLSL 版的循环是普通 for,这里保留同样的写法便于对照
                InputData inputData = InitInputData();
                inputData.positionWS = positionWS;
                inputData.normalWS = N;
                inputData.normalizedScreenSpaceUV = GetNormalizedScreenSpaceUV(gl_FragCoord);

                // 主光源
                vec3 lighting = LambertLight(GetMainLight(), N);

            #if defined(_ADDITIONAL_LIGHTS)
                // Forward:lightCount = 本物体受影响的灯数(Per Object Limit 以内),循环变量是“本物体的第几盏灯”;
                // Forward+:HLSL 版先单独循环附加的方向光,再用 LIGHT_LOOP_BEGIN 遍历当前像素所在 tile / z-bin 的灯。
                // 网页版没有分簇结构,Forward+ 下 GetAdditionalLightsCount() 直接返回全部可见附加光(方向光排在最前面),
                // 所以两种路径都写成同一个普通 for 循环
                int lightCount = GetAdditionalLightsCount();
                for (int lightIndex = 0; lightIndex < lightCount; lightIndex++)
                {
                    Light light = GetAdditionalLight(lightIndex, inputData.positionWS);
                    lighting += LambertLight(light, N);
                }
            #endif

            #if defined(_ADDITIONAL_LIGHTS_VERTEX)
                lighting += vertexLight;
            #endif

                vec3 color = albedo * lighting + albedo * SampleSH(N) * _AmbientStrength;
                fragColor = vec4(color, 1.0);
            }
            #endif
            ENDGLSL
        }

        // ---- 投影与深度:见案例 6.4 与 6.8 ----
        Pass
        {
            Name "ShadowCaster"
            Tags { "LightMode"="ShadowCaster" }
            ZWrite On
            ZTest LEqual
            ColorMask 0

            GLSLPROGRAM
            #include "Ch06/ShadowCasterPass.glsl"
            ENDGLSL
        }

        Pass
        {
            Name "DepthOnly"
            Tags { "LightMode"="DepthOnly" }
            ZWrite On
            ColorMask R

            GLSLPROGRAM
            #include "Ch06/DepthOnlyPass.glsl"
            ENDGLSL
        }
    }
}

关键代码讲解

Assets/Cookbook/Ch06/MultiLightLambert.shader(节选:keywords)
// Forward 路径:URP 按 Asset 的“Additional Lights”设置打开 _ADDITIONAL_LIGHTS(逐像素)
// 或 _ADDITIONAL_LIGHTS_VERTEX(逐顶点);Forward+ 路径打开 _FORWARD_PLUS,
// Core.hlsl 会据此自动定义 _ADDITIONAL_LIGHTS 和 USE_FORWARD_PLUS
#pragma multi_compile _ _ADDITIONAL_LIGHTS_VERTEX _ADDITIONAL_LIGHTS
#pragma multi_compile _ _FORWARD_PLUS
// Forward 路径:URP 按 Asset 的“Additional Lights”设置打开 _ADDITIONAL_LIGHTS(逐像素)
// 或 _ADDITIONAL_LIGHTS_VERTEX(逐顶点);Forward+ 路径打开 _FORWARD_PLUS。
// 网页版引擎始终定义 _ADDITIONAL_LIGHTS(逐像素),Forward / Forward+ 的区别由引擎在
// GetAdditionalLightsCount() / GetAdditionalLight() 内部处理(见下面的循环)
#pragma multi_compile _ _ADDITIONAL_LIGHTS_VERTEX _ADDITIONAL_LIGHTS

两行 multi_compile 组合出 3 × 2 = 6 个变体。注意 _ADDITIONAL_LIGHTS_VERTEX 与 _FORWARD_PLUS 同时开启的组合实际上不会出现(Core.hlsl 会在 Forward+ 下取消前者),URP 的构建期变体剥离也会删掉它们。

Assets/Cookbook/Ch06/MultiLightLambert.shader(节选:frag)
half4 frag(Varyings input) : SV_Target
{
    half3 albedo = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv).rgb * _BaseColor.rgb;
    half3 normalWS = normalize(input.normalWS);

    // LIGHT_LOOP_BEGIN 在 Forward+ 下会直接读取名为 inputData 的变量
    // (inputData.positionWS 与 inputData.normalizedScreenSpaceUV),所以名字不能改
    InputData inputData = (InputData)0;
    inputData.positionWS = input.positionWS;
    inputData.normalWS = normalWS;
    inputData.normalizedScreenSpaceUV = GetNormalizedScreenSpaceUV(input.positionCS);

    // 主光源
    half3 lighting = LambertLight(GetMainLight(), normalWS);

#if defined(_ADDITIONAL_LIGHTS)
    uint lightCount = GetAdditionalLightsCount();   // Forward+ 下恒为 0,循环靠 cluster 迭代器

    #if USE_FORWARD_PLUS
    // Forward+:附加的“方向光”不进入分簇结构,排在光源数组最前面,要单独循环
    for (uint lightIndex = 0; lightIndex < min(URP_FP_DIRECTIONAL_LIGHTS_COUNT, MAX_VISIBLE_LIGHTS); lightIndex++)
    {
        lighting += LambertLight(GetAdditionalLight(lightIndex, inputData.positionWS), normalWS);
    }
    #endif

    // Forward:for (lightIndex = 0; lightIndex < lightCount; ...)
    // Forward+:遍历当前像素所在 tile 与 z-bin 的光源位掩码
    LIGHT_LOOP_BEGIN(lightCount)
        Light light = GetAdditionalLight(lightIndex, inputData.positionWS);
        lighting += LambertLight(light, normalWS);
    LIGHT_LOOP_END
#endif

#if defined(_ADDITIONAL_LIGHTS_VERTEX)
    lighting += input.vertexLight;
#endif

    half3 color = albedo * lighting + albedo * SampleSH(normalWS) * _AmbientStrength;
    return half4(color, 1);
}
void main()
{
    vec3 albedo = texture(_BaseMap, uv).rgb * _BaseColor.rgb;
    vec3 N = normalize(normalWS);

    // HLSL 版里 LIGHT_LOOP_BEGIN 在 Forward+ 下会直接读取名为 inputData 的变量,所以要先填好它;
    // GLSL 版的循环是普通 for,这里保留同样的写法便于对照
    InputData inputData = InitInputData();
    inputData.positionWS = positionWS;
    inputData.normalWS = N;
    inputData.normalizedScreenSpaceUV = GetNormalizedScreenSpaceUV(gl_FragCoord);

    // 主光源
    vec3 lighting = LambertLight(GetMainLight(), N);

#if defined(_ADDITIONAL_LIGHTS)
    // Forward:lightCount = 本物体受影响的灯数(Per Object Limit 以内),循环变量是“本物体的第几盏灯”;
    // Forward+:HLSL 版先单独循环附加的方向光,再用 LIGHT_LOOP_BEGIN 遍历当前像素所在 tile / z-bin 的灯。
    // 网页版没有分簇结构,Forward+ 下 GetAdditionalLightsCount() 直接返回全部可见附加光(方向光排在最前面),
    // 所以两种路径都写成同一个普通 for 循环
    int lightCount = GetAdditionalLightsCount();
    for (int lightIndex = 0; lightIndex < lightCount; lightIndex++)
    {
        Light light = GetAdditionalLight(lightIndex, inputData.positionWS);
        lighting += LambertLight(light, N);
    }
#endif

#if defined(_ADDITIONAL_LIGHTS_VERTEX)
    lighting += vertexLight;
#endif

    vec3 color = albedo * lighting + albedo * SampleSH(N) * _AmbientStrength;
    fragColor = vec4(color, 1.0);
}
  1. 构造 inputData。只填循环需要的两个字段:世界坐标,以及用 GetNormalizedScreenSpaceUV(input.positionCS) 算出的屏幕 UV。片元着色器里 SV_POSITION 的 xy 是像素坐标,这个函数除以屏幕尺寸并处理了平台的 UV 翻转。
  2. 一盏灯的贡献写成函数 LambertLight(Light, N)。主光源、附加方向光、分簇里的点光/聚光都调用它。distanceAttenuation × shadowAttenuation 一起乘进去,即使现在还没有阴影,将来换成带阴影的 GetAdditionalLight 重载也不用改这里。
  3. 两个循环。#if USE_FORWARD_PLUS 里的 for 循环只处理附加方向光;LIGHT_LOOP_BEGIN/END 之间处理其余光源,宏内部提供了循环变量 lightIndex。
  4. 逐顶点模式。Varyings 里多一个 vertexLight,在顶点着色器调用 URP 的 VertexLighting() 累加所有附加光的 Lambert,片元里加回去即可。
图 6-28 盏彩色点光源围成一圈,地面和三个球都使用本案例的 Shader。左:Forward,Per Object Limit = 4,地面是一个物体,只分到 4 盏灯,另外 4 盏在地面上“消失”了;中:Forward,Per Object Limit = 8;右:Forward+,没有每物体上限,结果与中图一致
坑如果你的自定义 Shader 在切换到 Forward+ 后附加光全没了,先检查三件事:是否声明了 _FORWARD_PLUS;inputData.normalizedScreenSpaceUV 和 positionWS 是否赋值(漏赋值时编译能过,但所有像素都落在同一个 tile 上,光照会错乱成块状);是否漏掉了附加方向光的那个单独循环。

6.3 点光与聚光的衰减

物理的衰减:平方反比

点光源向四面八方均匀发光,距离为 d 处的球面面积是 4πd²,同样的光通量摊在越来越大的球面上,辐照度就按 1/d² 下降。这是唯一“物理正确”的距离衰减。它有两个工程上的麻烦:一是 d → 0 时趋于无穷;二是它永远不会真正变成 0,而渲染器必须为每盏灯设一个有限的影响范围(Range),否则每盏灯都要影响所有像素,剔除就无从谈起。

URP 的做法:平方反比 × 平滑窗口

摘自 URP 14 ShaderLibrary/RealtimeLights.hlsl
float DistanceAttenuation(float distanceSqr, half2 distanceAttenuation)
{
    float lightAtten = rcp(distanceSqr);
    float2 distanceAttenuationFloat = float2(distanceAttenuation);

    // Use the smoothing factor also used in the Unity lightmapper.
    half factor = half(distanceSqr * distanceAttenuationFloat.x);
    half smoothFactor = saturate(half(1.0) - factor * factor);
    smoothFactor = smoothFactor * smoothFactor;

    return lightAtten * smoothFactor;
}

其中 distanceAttenuation.x 由 C# 端设为 1/Range²(见 UniversalRenderPipelineCore.cs 的 GetPunctualLightDistanceAttenuation)。写成公式:

A(d)=1d2⋅(1−(d2r2)2)2(d≤r)

第一项是物理的平方反比;第二项是一个“窗口函数”:d 远小于 r 时接近 1,几乎不影响平方反比;d 接近 r 时平滑地降到 0,并且在 d = r 处函数值和导数都为 0,不会出现可见的边界。注释里说这个窗口与 Unity 光照烘焙器(Lightmapper)一致,这保证了同一盏灯的实时光照与烘焙光照在衰减上匹配。

d = Range 012 00.511.2 距离 d / Range(Range = 1 时) 1/d²(物理) 平滑窗口 (1 − (d²/r²)²)² URP = 两者相乘 线性 1 − d/r (不推荐)
图 6-3距离衰减曲线。URP 的衰减在近处与平方反比几乎重合,在 Range 附近平滑归零;线性衰减在近处太暗、远处太亮,观感“发灰”

聚光灯的角度衰减

聚光灯在距离衰减之外,还要乘一个角度衰减。设 S 为聚光灯照射方向的反方向(和 L 一样,从表面这一侧指回光源),θ 为 L 与 S 的夹角,外角 θo 和内角 θi 分别是 Spot Angle 和 Inner Spot Angle 的一半:

Aspot=saturate(cosθ−cosθocosθi−cosθo)2

URP 在 C# 端预先算好 1/(cosθi − cosθo) 和 −cosθo/(cosθi − cosθo),存进 _AdditionalLightsAttenuation.zw,Shader 里只需要一次乘加:saturate(SdotL * z + w),再平方。点光源的 zw 被设成让结果恒为 1 的值,所以点光和聚光可以走同一段代码。

θo θi 内锥(深色)满亮度,内外锥之间平滑过渡 θi = 15° θo = 30° 0°40° L 与聚光朝向的夹角 θ
图 6-4聚光灯的内外锥与角度衰减。平方让过渡的两端更柔和

案例 6.2自己计算衰减:为什么一定要有平滑窗口进阶单独打开 ↗

目标:不调用 GetAdditionalLight(),直接读取 URP 上传的光源数组,自己计算距离和角度衰减;对比平方反比、线性和 URP 三种衰减,看清“窗口”在剔除中的作用。

Assets/Cookbook/Ch06/FalloffCompare.shader(节选:punctual)
// 不用 GetAdditionalLight(),直接读 URP 上传的光源数组,自己算衰减。
// URP 14 在所有平台都走常量数组(USE_STRUCTURED_BUFFER_FOR_LIGHT_DATA 恒为 0)
half3 PunctualLight(uint loopIndex, float3 positionWS, half3 normalWS)
{
#if USE_FORWARD_PLUS
    int i = int(loopIndex);                       // Forward+:循环变量就是全局光源下标
#else
    int i = GetPerObjectLightIndex(loopIndex);    // Forward:先查“本物体的第几盏灯”对应的全局下标
#endif
    float4 lightPos   = _AdditionalLightsPosition[i];     // w = 0 表示方向光
    half3  color      = _AdditionalLightsColor[i].rgb;
    half4  attenParam = _AdditionalLightsAttenuation[i];  // x = 1/range²,zw = 聚光角度参数
    half4  spotDir    = _AdditionalLightsSpotDir[i];

    float3 lightVector = lightPos.xyz - positionWS * lightPos.w;
    float  d2 = max(dot(lightVector, lightVector), HALF_MIN);
    half3  L = half3(lightVector * rsqrt(d2));

#if defined(_FALLOFF_INVERSESQUARE)
    float atten = rcp(d2);                                         // 物理上的平方反比,没有窗口
#elif defined(_FALLOFF_LINEAR)
    float atten = saturate(1.0 - sqrt(d2 * attenParam.x));         // 1 - d/range
#else
    float atten = DistanceAttenuation(d2, attenParam.xy);          // URP:平方反比 × 平滑窗口
#endif
    atten *= AngleAttenuation(spotDir.xyz, L, attenParam.zw);      // 聚光锥;点光的参数让它恒为 1

    return color * atten * saturate(dot(normalWS, L));
}
// 不用 GetAdditionalLight(),直接读 URP 上传的光源数组,自己算衰减。
// (网页版的光源数组与 URP 14 同名:_AdditionalLightsPosition / Color / Attenuation / SpotDir)
vec3 PunctualLight(int loopIndex, vec3 positionWS, vec3 normalWS)
{
    // Forward+:循环变量就是全局光源下标;Forward:先查“本物体的第几盏灯”对应的全局下标。
    // HLSL 版用 #if USE_FORWARD_PLUS 区分;网页版的 GetPerObjectLightIndex 在 Forward+ 下原样返回
    int i = GetPerObjectLightIndex(loopIndex);
    vec4 lightPos   = _AdditionalLightsPosition[i];     // w = 0 表示方向光
    vec3 color      = _AdditionalLightsColor[i].rgb;
    vec4 attenParam = _AdditionalLightsAttenuation[i];  // x = 1/range²,zw = 聚光角度参数
    vec4 spotDir    = _AdditionalLightsSpotDir[i];

    vec3  lightVector = lightPos.xyz - positionWS * lightPos.w;
    float d2 = max(dot(lightVector, lightVector), HALF_MIN);
    vec3  L = lightVector * inversesqrt(d2);

#if defined(_FALLOFF_INVERSESQUARE)
    float atten = 1.0 / d2;                                        // 物理上的平方反比,没有窗口
#elif defined(_FALLOFF_LINEAR)
    float atten = saturate(1.0 - sqrt(d2 * attenParam.x));         // 1 - d/range
#else
    float atten = DistanceAttenuation(d2, attenParam.xy);          // URP:平方反比 × 平滑窗口
#endif
    atten *= AngleAttenuation(spotDir.xyz, L, attenParam.zw);      // 聚光锥;点光的参数让它恒为 1

    return color * atten * saturate(dot(normalWS, L));
}
  1. 下标换算。和 GetAdditionalLight 内部一样:Forward 下循环变量是“本物体的第几盏灯”,要用 GetPerObjectLightIndex 查出全局下标;Forward+ 下循环变量本身就是全局下标。
  2. 方向光与点光统一处理。_AdditionalLightsPosition 的 w 分量对方向光为 0、对点光为 1,所以 lightPos.xyz − positionWS × lightPos.w 对方向光得到方向本身,对点光得到“从表面指向光源”的向量。这是 URP 的一个小技巧,省掉了分支。
  3. Range 不需要单独上传。attenParam.x 就是 1/Range²,线性衰减需要的 d/Range 用 sqrt(d2 * attenParam.x) 即可。
图 6-5同一盏点光(Range = 2.5),俯视。从左到右:① 纯平方反比 + Forward+:光照在 tile 边界处被齐刷刷切断,出现锯齿状的方块边缘;② 纯平方反比 + Forward:地面这个物体整个都分到了这盏灯,光一直延伸到画面外,永不结束;③ 线性衰减:中心太暗、边缘太亮,光斑像一块发灰的圆片;④ URP 的平滑衰减:近处与平方反比一致,在 Range 处自然消失

图 6-5 的前两格揭示了“窗口”的真正意义。剔除——无论是 Forward 的每物体列表,还是 Forward+ 的 tile/Z-Bin 掩码——都是保守的、粗粒度的:它只保证“Range 范围内的像素一定会算这盏灯”,却不保证“Range 范围外的像素一定不算”。如果衰减在 Range 处不为 0,那么剔除粒度就会直接暴露在画面上:Forward+ 下是 tile 形状的硬边,Forward 下则是物体一进一出范围时整块的亮度跳变。只有让衰减在 Range 处平滑归零,剔除才是“隐形”的。

图 6-6聚光灯,Spot Angle = 70°,Inner Spot Angle 从左到右为 5°、45°、68°。内角越接近外角,光斑边缘越硬
调参建议灯光的 Range 不要设得“刚好够”,而要让平方反比在 Range 附近已经足够暗,这样窗口只做最后的收尾,衰减看起来最自然;Range 也不要无谓地大,它直接决定了这盏灯参与多少像素的计算。Intensity 与 Range 是两个独立旋钮:前者决定亮度,后者决定影响范围和性能。

6.4 Light Layers 简介

Culling Mask 用 GameObject 的 Layer 决定灯照哪些物体,但 Layer 同时还管着物理、相机可见性,很快就不够用。URP 14 提供了独立的渲染层(Rendering Layers):每个 Renderer 有一个 32 位的 renderingLayerMask,每盏灯在 Universal Additional Light Data 里也有一个 renderingLayers,两者按位与不为 0 时灯才照亮物体。这个检查发生在 Shader 里:

uint meshRenderingLayers = GetMeshRenderingLayer();          // 来自 unity_RenderingLayer
...
#if defined(_LIGHT_LAYERS)
    if (IsMatchingLightLayer(light.layerMask, meshRenderingLayers))
#endif
        color += ShadeOneLight(light, ...);

开启方法:URP Asset 的 Lighting › Use Rendering Layers,URP 随即打开全局关键字 _LIGHT_LAYERS。自定义 Shader 需要声明 #pragma multi_compile _ _LIGHT_LAYERS 并在每盏灯的贡献外加上判断,案例 6.9 的 ClassicLit 已经包含了这段代码。

图 6-7两个球、地面和一盏红色点光,均使用 ClassicLit。红灯只属于渲染层 1,右球同时属于 Default 与层 1,左球与地面只属于 Default。左:未开启 Light Layers,红灯照亮一切;右:开启后,红灯只照亮右球
坑渲染层必须先在 Project Settings › Graphics › URP Global Settings › Rendering Layers 里命名才有效。URP 会用“已命名的层”过滤灯光的层掩码(RenderingLayerUtils.ToValidRenderingLayers),新工程默认只有一个 Default 层,此时把灯设到其它层,它会变得什么都照不亮。

6.5 阴影映射原理

光照模型回答的是“光到了这个点以后反射多少”,阴影回答的是更早的问题:光到底能不能到这个点。实时渲染几乎都用阴影映射(Shadow Mapping)来回答它,思路出奇地简单。

两趟渲染

光源 ① 光源视角只渲染深度(虚线:光源的近平面) 遮挡物 A B 相机 ② 相机渲染时,把像素变换到光源空间 A:深度 ≈ 阴影图里记录的深度 → 受光 B:比记录的深度(遮挡物)更远 → 阴影 灰色区域:遮挡物背后的阴影体
图 6-8阴影映射的两趟渲染。第一趟从光源出发记录“每个方向上最近的表面有多远”;第二趟对每个像素问:我比那个最近的表面更远吗?
  1. 阴影投射(Shadow Caster)阶段:把相机放到光源的位置,只渲染深度,得到一张阴影图(Shadow Map)。方向光用正交投影,聚光用透视投影,点光要朝 6 个方向各渲染一张。在 URP 里,这一步由 MainLightShadowCasterPass 和 AdditionalLightsShadowCasterPass 完成,它们调用每个物体 Shader 里 LightMode = ShadowCaster 的 Pass。
  2. 阴影接收(Shadow Receiver)阶段:正常渲染时,把像素的世界坐标乘以“世界 → 阴影图”矩阵,得到它在阴影图上的 UV 和它在光源视角下的深度 z,再与阴影图中该 UV 处存储的深度比较:z 更远,说明光被别的东西挡住了。

URP 中,世界坐标到阴影图坐标的矩阵存放在 _MainLightWorldToShadow[](每个级联一个),由 TransformWorldToShadowCoord() 完成变换。这个矩阵已经包含了光源的 VP 矩阵、从 [−1,1] 到 [0,1] 的缩放偏移、平台的 Reversed-Z 处理,以及级联在阴影图集里的图块位置。比较操作则交给硬件:阴影图被声明为 TEXTURE2D_SHADOW,配合比较采样器 sampler_LinearClampCompare,SAMPLE_TEXTURE2D_SHADOW 直接返回比较结果(0 或 1),在线性过滤模式下硬件还会对相邻 2×2 个 texel 的比较结果做双线性插值。

走样与“阴影粉刺”

阴影图的分辨率有限,一个 texel 覆盖场景中一小块面积,只记录一个深度。当受光面相对光源倾斜时,同一个 texel 覆盖的表面上,各点的真实深度并不相同,而 texel 里只存了中心那一点的深度。于是表面上有一半的点“比记录的深度更远”,被判定为自己遮挡了自己,形成一条条明暗相间的条纹——这就是阴影粉刺(Shadow Acne),也叫自阴影走样。

光源视角:横轴是阴影图的 texel,纵轴是到光源的深度(向下更远) 不加偏移:粉刺 加上深度偏移以后 蓝:texel 记录的深度(只采中心一点) 橙:比记录更远 → 被误判为阴影 记录的深度整体推远,表面都在台阶之前 → 不再自遮挡
图 6-9阴影粉刺的成因。倾斜表面上,每个 texel 只记录中心的深度(蓝色台阶),台阶下方的那半段表面被误判为“在阴影里”(橙色),形成条纹。把记录的深度往远处推一点就能消除

深度偏移与 Peter-Panning

最直接的修正是给比较留一点余量:要么在接收端把 z 往光源方向拉近,要么在投射端把写入阴影图的深度推远。这就是深度偏移(Depth Bias)。它的副作用是:偏移量大于物体与地面接触处的深度差时,阴影会从物体脚下“脱开”,物体看起来像飘在空中——这被形象地称为 Peter-Panning(彼得·潘的影子会离开身体)。表面越倾斜,消除粉刺需要的偏移越大,所以单一的常数偏移很难两全。

法线偏移

另一种思路是把投射体沿法线向内收缩一点再写入阴影图,即法线偏移(Normal Bias)。它对倾斜表面特别有效:表面越是侧对光源,沿法线收缩在深度方向上产生的偏移越大,正好是粉刺最严重的地方。它的副作用是物体“变瘦”:阴影整体缩小,薄的物体(树叶、栏杆、薄板)的阴影可能出现漏洞甚至消失。

深度偏移过大:影子与物体脱离 法线偏移过大:物体“变瘦” ← 缝隙 影子 虚线:写入阴影图的“替身”沿光线方向后移了 薄板收缩后几乎没有体积 → 影子出现漏洞或消失 收缩量 ∝ (1 − N·L):越侧对光源,收缩越多
图 6-10两种偏移的副作用。深度偏移过大导致影子与物体脱离(Peter-Panning);法线偏移过大让物体“变瘦”,薄物体的影子破洞

URP 两种偏移都在投射端的顶点着色器里完成,函数是 Shadows.hlsl 中的 ApplyShadowBias:

摘自 URP 14 ShaderLibrary/Shadows.hlsl
float3 ApplyShadowBias(float3 positionWS, float3 normalWS, float3 lightDirection)
{
    float invNdotL = 1.0 - saturate(dot(lightDirection, normalWS));
    float scale = invNdotL * _ShadowBias.y;

    // normal bias is negative since we want to apply an inset normal offset
    positionWS = lightDirection * _ShadowBias.xxx + positionWS;
    positionWS = normalWS * scale.xxx + positionWS;
    return positionWS;
}

_ShadowBias.x 和 .y 都是负数(C# 端取了负号),所以第一行把顶点沿“指向光源的方向”的反方向推远,第二行沿法线反方向向内收缩,收缩量再乘以 (1 − N·L)。这两个值的单位很讲究,C# 端的 ShadowUtils.GetShadowBias() 是这样算的:

摘自 URP 14 Runtime/ShadowUtils.cs(节选)
// depth and normal bias scale is in shadowmap texel size in world space
float texelSize = frustumSize / shadowResolution;
float depthBias = -shadowData.bias[shadowLightIndex].x * texelSize;
float normalBias = -shadowData.bias[shadowLightIndex].y * texelSize;
...
if (shadowLight.lightType == LightType.Point)
    normalBias = 0.0f;
...
// For now we scale it by the PCF kernel size of non-mobile platforms (5x5)
depthBias *= kernelRadius;   // Low 1.5 / Medium 2.5 / High 3.5(仅软阴影)
normalBias *= kernelRadius;

也就是说:Asset 或灯光上填的 Depth Bias = 1,意思是“偏移 1 个阴影图 texel 在世界空间中的大小”。阴影图分辨率越低、阴影距离越远(texel 越大),实际偏移就越大,偏移会自动适应分辨率。开启软阴影时,偏移还要乘以滤波核的半径,因为 PCF 会采样到更远的 texel。点光源的法线偏移被强制设为 0(与 Built-in 管线的约定相同)。此外,URP 在渲染阴影图时还会通过 cmd.SetGlobalDepthBias(1.0f, 2.5f) 打开一个固定的硬件斜率偏移,所以即使把两个 Bias 都设为 0,粉刺也比教科书示意图里轻得多。

PCF:软化锯齿

阴影图的 texel 被放大到屏幕上时,阴影边缘会出现明显的锯齿。百分比渐近过滤(Percentage-Closer Filtering,PCF)的做法是:在阴影图上采样周围多个位置,分别做深度比较,再把 0/1 结果加权平均。注意顺序:是先比较、再平均,而不是先平均深度再比较——后者没有意义,因为深度的平均值不对应任何真实表面。

1 1 0 1 0 0 1 1 0 3×3 次比较:5 个受光 → 硬阴影:只看中心 → 0(全黑) PCF:5 / 9 ≈ 0.56(半影) URP:Low = 4 次硬件比较采样;Medium = 5×5 帐篷滤波 (9 次采样);High = 7×7 帐篷滤波(16 次采样)
图 6-11PCF 先对邻域内每个 texel 做深度比较,再对 0/1 结果加权平均,得到 0~1 之间的软边

URP 14 把软阴影质量分为 Low、Medium、High 三档,对应 Shadows.hlsl 中的 SampleShadowmapFilteredLowQuality / MediumQuality / HighQuality:Low 是 4 次带硬件双线性比较的采样;Medium 和 High 使用 Core RP 的帐篷滤波(SampleShadow_ComputeSamples_Tent_5x5 / 7x7),利用每次硬件比较采样本身是 2×2 双线性的特点,用 9 次和 16 次采样分别覆盖 5×5 与 7×7 的区域。每盏灯可以单独选择质量(Light 组件上的 Soft Shadows Quality),默认使用 Asset 的设置。

级联阴影:近处清晰,远处够用

方向光的阴影图要覆盖从相机近处一直到阴影距离(Shadow Distance)的整个区域。如果只用一张图,近处一个 texel 可能对应几厘米甚至几十厘米,阴影糊成一片;而远处的物体在屏幕上很小,根本不需要那么高的精度。级联阴影(Cascaded Shadow Maps,CSM)把视锥沿深度切成几段,每段单独渲染一张阴影图:近处的段范围小、精度高,远处的段范围大、精度低,让阴影图的 texel 大小与屏幕像素大小大致匹配。

相机 俯视:视锥按 Cascade Split 切成 4 段,每段用一个包围球选择 级联 0 级联 1 级联 2 级联 3 阴影图集:4 级联时 每块是总分辨率的 1/2
图 6-12级联阴影。每一段覆盖的范围依次变大,但都使用同样大小的图块,所以近处的阴影精度远高于远处

URP 的实现细节有几点值得知道:

  • 级联数可选 1~4,分割比例由 Asset 中的 Cascade Split 决定(默认 4 级联为 6.7%、20%、46.7%)。所有级联共享一张阴影图集:2 个级联时图集高度减半、分成左右两块;3、4 个级联时分成 2×2 四块,每块边长是图集的一半。
  • 每个级联用一个包围球而不是视锥切片来描述,球心和半径存在 _CascadeShadowSplitSpheres0~3 与 _CascadeShadowSplitSphereRadii 里。用球的好处是:相机旋转时包围体不变,阴影图不会因为视锥朝向变化而“游动”闪烁。
  • 着色时,ComputeCascadeIndex() 判断像素落在哪个球里,选第一个命中的级联;都没命中返回 4,对应一个“空”矩阵,结果视为受光。
  • 阴影距离的最后一段(Asset 中的 Last Border,默认 20%)会用 GetMainLightShadowFade() 让阴影平滑淡出,而不是在阴影距离处一刀切掉。

案例 6.3接收主光源阴影入门单独打开 ↗

目标:让 Lambert Shader 接收主光源的实时阴影,支持单张阴影图、级联、屏幕空间阴影和三档软阴影,并支持材质级的“不接收阴影”开关。

关键字

Assets/Cookbook/Ch06/ShadowReceive.shader(节选:keywords)
// 主光阴影:无 / 单张阴影图 / 级联 / 屏幕空间(由 URP Asset 与 Renderer 设置决定,运行时自动开关)
#pragma multi_compile _ _MAIN_LIGHT_SHADOWS _MAIN_LIGHT_SHADOWS_CASCADE _MAIN_LIGHT_SHADOWS_SCREEN
// 软阴影:_SHADOWS_SOFT 在运行时按每盏灯的质量分支;LOW/MEDIUM/HIGH 是给“必须静态分支”的平台准备的
#pragma multi_compile_fragment _ _SHADOWS_SOFT _SHADOWS_SOFT_LOW _SHADOWS_SOFT_MEDIUM _SHADOWS_SOFT_HIGH
// 材质级开关:不接收阴影
#pragma shader_feature_local _RECEIVE_SHADOWS_OFF
// 主光阴影:无 / 单张阴影图 / 级联 / 屏幕空间(由 URP Asset 与 Renderer 设置决定,运行时自动开关)。
// 软阴影:_SHADOWS_SOFT 在运行时按每盏灯的质量分支。
// 这两组是 URP 全局关键字,网页版引擎始终定义 _MAIN_LIGHT_SHADOWS 与 _SHADOWS_SOFT,不用声明。
// 材质级开关:不接收阴影
#pragma shader_feature_local _RECEIVE_SHADOWS_OFF

这三行与 URP 14 Lit.shader 的写法一致:

  • _MAIN_LIGHT_SHADOWS:只有一个级联;_MAIN_LIGHT_SHADOWS_CASCADE:多级联;_MAIN_LIGHT_SHADOWS_SCREEN:Renderer 上加了 Screen Space Shadows 特性,阴影已经在一个屏幕空间 Pass 里算好,这里只需按屏幕 UV 采样(第 23 章)。三者都不开时,表示没有主光阴影。
  • 软阴影的四个关键字中,_SHADOWS_SOFT 在运行时读取每盏灯的质量设置做动态分支;_SOFT_LOW/MEDIUM/HIGH 是给不擅长动态分支的平台准备的静态版本,URP 根据平台和设置自动选择。
  • _RECEIVE_SHADOWS_OFF 是材质关键字:Shadows.hlsl 开头检查它,定义了它就不会定义 MAIN_LIGHT_CALCULATE_SHADOWS,阴影函数直接返回 1。

片元代码

Assets/Cookbook/Ch06/ShadowReceive.shader(节选:frag)
half4 frag(Varyings input) : SV_Target
{
    half3 albedo = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv).rgb * _BaseColor.rgb;
    half3 normalWS = normalize(input.normalWS);

    // 世界坐标 -> 阴影图坐标(xy = 阴影图 UV,z = 在光源空间里的深度)。
    // 开了级联时,这里会先按距离选出级联号,再用对应的矩阵变换
    float4 shadowCoord = TransformWorldToShadowCoord(input.positionWS);

    // 采样阴影图并与 z 比较:1 = 受光,0 = 在阴影里(软阴影时是 0~1 的过渡)
    half shadow = MainLightRealtimeShadow(shadowCoord);

    // 超出“阴影距离”的地方,阴影要平滑淡出,而不是突然消失
    shadow = lerp(shadow, 1.0, GetMainLightShadowFade(input.positionWS));

    Light mainLight = GetMainLight();
    half3 radiance = mainLight.color * mainLight.distanceAttenuation * shadow;
    half3 color = albedo * radiance * saturate(dot(normalWS, mainLight.direction));
    color += albedo * SampleSH(normalWS) * _AmbientStrength;
    return half4(color, 1);
}
void main()
{
    vec3 albedo = texture(_BaseMap, uv).rgb * _BaseColor.rgb;
    vec3 N = normalize(normalWS);

    // 世界坐标 -> 阴影图坐标(xy = 阴影图 UV,z = 在光源空间里的深度)。
    // 开了级联时,这里会先按距离选出级联号,再用对应的矩阵变换
    vec4 shadowCoord = TransformWorldToShadowCoord(positionWS);

    // 采样阴影图并与 z 比较:1 = 受光,0 = 在阴影里(软阴影时是 0~1 的过渡)。
    // URP 的 MainLightRealtimeShadow 在 _RECEIVE_SHADOWS_OFF 时直接返回 1(宏 MAIN_LIGHT_CALCULATE_SHADOWS 未定义)
#if defined(_RECEIVE_SHADOWS_OFF)
    float shadow = 1.0;
#else
    float shadow = MainLightRealtimeShadow(shadowCoord);
#endif

    // 超出“阴影距离”的地方,阴影要平滑淡出,而不是突然消失
    shadow = mix(shadow, 1.0, GetMainLightShadowFade(positionWS));

    Light mainLight = GetMainLight();
    vec3 radiance = mainLight.color * mainLight.distanceAttenuation * shadow;
    vec3 color = albedo * radiance * saturate(dot(N, mainLight.direction));
    color += albedo * SampleSH(N) * _AmbientStrength;
    fragColor = vec4(color, 1.0);
}
  1. TransformWorldToShadowCoord:开了级联时先调用 ComputeCascadeIndex,再用对应的 _MainLightWorldToShadow[i] 变换;开了屏幕空间阴影时则返回屏幕坐标。
  2. MainLightRealtimeShadow:按关键字选择采样方式(屏幕空间纹理 / 硬阴影 / 某档 PCF),乘上灯光的 Shadow Strength,并对超出阴影图深度范围的坐标返回 1。
  3. GetMainLightShadowFade:返回 0~1 的淡出系数,用它把阴影插值回 1。URP 自己的 MainLightShadow() 函数把实时阴影、烘焙阴影(Shadowmask)和淡出组合在一起,接入光照贴图时应该用它,这里为了看清每一步拆开来写。
深入:阴影坐标在哪个阶段算Shadows.hlsl 在只有一个级联(或屏幕空间阴影)时会定义 REQUIRES_VERTEX_SHADOW_COORD_INTERPOLATOR,提示你可以在顶点着色器里算好阴影坐标再插值——单个矩阵是线性变换,逐顶点算再插值与逐像素算结果相同,还省了一次矩阵乘法。多级联时必须逐像素计算,因为同一个三角形可能横跨两个级联。案例 6.9 的 ClassicLit 两种情况都处理了。
图 6-13地面与三个物体都使用本案例的 Shader。左:材质打开 _RECEIVE_SHADOWS_OFF,物体仍然投射阴影(写进了阴影图),但谁也不接收;中:主光源为硬阴影;右:软阴影(Medium)
图 6-14阴影边缘特写(阴影图 1024)。从左到右:Hard、Soft Low、Soft Medium、Soft High。硬阴影的方块锯齿清晰可见;Low 只是稍作柔化;Medium 与 High 的帐篷滤波让边缘越来越宽、越来越平滑,细杆的远端阴影也随之变淡

案例 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 兼容性。

案例 6.5阴影 Bias 调参对比进阶单独打开 ↗

目标:亲眼看到粉刺、Peter-Panning 和法线偏移过度三种问题,掌握 Bias 的调参方法。

测试场景:一个立方体放在地面上,一块悬空的薄板,一个球,主光源为高角度很低的侧光(20°),阴影图分辨率 512,软阴影 High——低分辨率和大滤波核都会放大偏移带来的问题。Depth Bias 与 Normal Bias 通过 URP Asset 修改(灯光上的 Bias 选项默认“Use settings from Render Pipeline Asset”):

图 6-16从左到右(Depth Bias, Normal Bias)=(0, 0)、(1, 1)、(8, 0)、(0, 3)。① 无偏移:地面布满细密的条纹(粉刺);② 默认值:干净;③ 深度偏移过大:立方体的影子与底部脱开,整体后移(Peter-Panning),球的影子也变小;④ 法线偏移过大:物体在阴影图里“变瘦”,立方体的影子收缩、与底部分离,球影明显缩小

调参的一般步骤:

  1. 先把两个 Bias 都设为 0,确认问题确实来自偏移不足(出现粉刺),而不是别的原因(比如法线反了、模型没有厚度)。
  2. 先加 Normal Bias,到粉刺基本消失为止。它对倾斜表面最有效,而且对接触处的影响比深度偏移小。
  3. 再用少量 Depth Bias 处理剩下的问题,同时观察物体与地面的接触处,一旦出现缝隙就往回调。
  4. 检查最薄的物体(树叶、栏杆、薄板)的影子,Normal Bias 过大时它们会先出问题。
  5. 修改阴影分辨率、阴影距离、级联数或软阴影质量后,重新检查——因为 Bias 的单位是“texel”,这些设置都会改变它的世界空间大小。
提示某一盏灯需要特殊的偏移时,在 Light 组件的 Shadows 部分把 Bias 从 “Use settings from Render Pipeline Asset” 改为 Custom(对应 UniversalAdditionalLightData.usePipelineSettings = false),URP 就会改用 light.shadowBias 和 light.shadowNormalBias。

为什么不在接收端做偏移?接收端偏移(比如根据屏幕空间导数估计表面斜率)可以更精确,HDRP 就有这样的选项;但它要在每个接收阴影的像素上额外计算,而投射端偏移只在阴影 Pass 的顶点上计算一次。URP 选择了更便宜的方案,ShadowUtils.cs 的注释里也坦率地说明了这是对透视阴影(聚光/点光)的“粗略近似”。

案例 6.6级联阴影可视化进阶单独打开 ↗

目标:写一个调试 Shader,按像素所在的级联染色,直观检查级联划分;再对比 1 个和 4 个级联的阴影精度。

Assets/Cookbook/Ch06/CascadeDebug.shader(节选:frag)
half4 frag(Varyings input) : SV_Target
{
    half3 albedo = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv).rgb * _BaseColor.rgb;
    half3 normalWS = normalize(input.normalWS);

    Light mainLight = GetMainLight(TransformWorldToShadowCoord(input.positionWS));
    half3 color = albedo * mainLight.color * mainLight.shadowAttenuation * saturate(dot(normalWS, mainLight.direction));
    color += albedo * SampleSH(normalWS) * 0.4;

    // ComputeCascadeIndex:依次判断像素是否落在 4 个“级联包围球”里,返回第一个命中的序号;
    // 4 表示超出了所有级联(阴影距离之外)
#if defined(_MAIN_LIGHT_SHADOWS_CASCADE)
    half cascade = ComputeCascadeIndex(input.positionWS);
#else
    half cascade = 0;   // 只有一个级联时 URP 不会更新包围球数据,直接当作第 0 级
#endif
    const half3 kCascadeColors[5] =
    {
        half3(1.0, 0.25, 0.2), half3(0.25, 0.85, 0.3), half3(0.25, 0.45, 1.0), half3(1.0, 0.85, 0.2), half3(1, 1, 1)
    };
    half3 tint = kCascadeColors[(uint)cascade];
    color = lerp(color, color * tint * 1.6, _TintStrength);
    return half4(color, 1);
}
void main()
{
    vec3 albedo = texture(_BaseMap, uv).rgb * _BaseColor.rgb;
    vec3 N = normalize(normalWS);

    Light mainLight = GetMainLight(TransformWorldToShadowCoord(positionWS));
    vec3 color = albedo * mainLight.color * mainLight.shadowAttenuation * saturate(dot(N, mainLight.direction));
    color += albedo * SampleSH(N) * 0.4;

    // ComputeCascadeIndex:依次判断像素是否落在 4 个“级联包围球”里,返回第一个命中的序号;
    // 4 表示超出了所有级联(阴影距离之外)。
    // HLSL 版用 _MAIN_LIGHT_SHADOWS_CASCADE 关键字区分;GLSL 版里它是 uniform:_MainLightShadowsCascade > 0.5
    float cascade = 0.0;   // 只有一个级联时 URP 不会更新包围球数据,直接当作第 0 级
    if (_MainLightShadowsCascade > 0.5) cascade = ComputeCascadeIndex(positionWS);

    const vec3 kCascadeColors[5] = vec3[5](
        vec3(1.0, 0.25, 0.2), vec3(0.25, 0.85, 0.3), vec3(0.25, 0.45, 1.0), vec3(1.0, 0.85, 0.2), vec3(1, 1, 1)
    );
    vec3 tint = kCascadeColors[int(cascade)];
    color = mix(color, color * tint * 1.6, _TintStrength);
    fragColor = vec4(color, 1.0);
}

ComputeCascadeIndex 是 Shadows.hlsl 中的公开函数。只有一个级联时,URP 不会更新包围球数据,这时读出来的可能是残留值,所以要用 _MAIN_LIGHT_SHADOWS_CASCADE 关键字保护。返回值 4 表示超出了所有级联,用白色表示。

图 6-17阴影距离 60 米,分别使用 1、2、4 个级联(4 级联的分割为 10%、25%、50%)。红、绿、蓝、黄依次为级联 0~3。可以看到级联边界是以相机为中心的圆弧,这正是“包围球”的形状
图 6-18阴影距离 80 米、阴影图 1024、硬阴影,镜头靠近前排立方体。左:1 个级联,一张 1024 的图要覆盖 80 米,近处的影子边缘模糊、呈块状;右:4 个级联,第一级只覆盖前几米,虽然每块只有 512,近处阴影依然清晰
调参建议级联划分的原则是让每一级在屏幕上的“texel 密度”接近。透视投影下远处缩小得很快,所以分割点应该向近处集中(默认的 6.7%、20%、46.7% 就是这个思路)。把 Shadow Distance 设得刚好覆盖需要阴影的范围,往往比加级联更有效:阴影距离减半,所有级联的精度都翻倍。移动端常用 1~2 个级联,配合较短的阴影距离。

案例 6.7附加光阴影进阶单独打开 ↗

目标:理解点光、聚光阴影的存储方式,让多光源 Shader 正确接收它们。

附加光的阴影全部放在一张阴影图集 _AdditionalLightsShadowmapTexture 里:聚光灯占 1 个图块(透视投影),点光源占 6 个图块(立方体的 6 个面,每面 90° 多一点的透视投影,多出的角度用来给软阴影的滤波核留边)。每个图块的分辨率由灯光的 Shadow Resolution 档位(Low / Medium / High,像素数在 Asset 里设置)决定,图集放不下所有图块时 URP 会自动缩小它们(具体策略以实际版本为准)。每盏灯的阴影参数打包在 _AdditionalShadowParams[lightIndex] 里:x 是阴影强度,y 是软阴影质量,z 表示是否点光,w 是它的第一个图块的下标(−1 表示不投影)。

采样逻辑在 AdditionalLightRealtimeShadow 里:对点光源,先用 CubeMapFaceID(−lightDirection) 求出像素位于立方体的哪个面,把图块下标加上面号,再用对应的矩阵变换和采样。好消息是,这些都封装在 GetAdditionalLight(i, positionWS, shadowMask) 这个重载里——只要 Shader 声明了 _ADDITIONAL_LIGHT_SHADOWS,调用它返回的 light.shadowAttenuation 就已经是阴影结果。

#pragma multi_compile_fragment _ _ADDITIONAL_LIGHT_SHADOWS

LIGHT_LOOP_BEGIN(pixelLightCount)
    // 带 shadowMask 参数的重载:内部调用 AdditionalLightShadow(),还会乘上 Cookie
    Light light = GetAdditionalLight(lightIndex, inputData.positionWS, shadowMask);
    color += Shade(light, ...);   // Shade 里已经乘了 light.shadowAttenuation
LIGHT_LOOP_END
图 6-19夜景:中央一盏暖色点光、左前方一盏冷色聚光,场景使用案例 6.9 的 ClassicLit。左:两盏灯都关闭阴影,光穿过柱子;右:开启软阴影,点光在六个方向投下放射状的影子,聚光的影子拉得很长
注意附加光阴影要同时满足:URP Asset 勾选 Lighting › Additional Lights › Cast Shadows;灯光组件的 Shadow Type 不是 No Shadows;灯光是逐像素光(逐顶点附加光不支持阴影);Shader 声明了 _ADDITIONAL_LIGHT_SHADOWS。点光源要渲染 6 次阴影,开销是聚光灯的 6 倍,移动端要非常克制。

案例 6.8DepthOnly 与 DepthNormals Pass入门单独打开 ↗

目标:弄清这两个 Pass 什么时候被调用、缺了会怎样,并写出可复用的实现。

除了颜色 Pass 和阴影 Pass,URP 还会在某些情况下单独渲染一遍场景的深度或深度+法线:

  • DepthOnly:深度预渲染(Depth Prepass)。当某个功能需要相机深度纹理 _CameraDepthTexture、而又无法直接拷贝深度缓冲时(与平台、MSAA、渲染设置有关),URP 会先用所有物体的 DepthOnly Pass 画一遍深度。缺少这个 Pass 的物体不会出现在深度纹理里,软粒子、水的深浅、深度雾等效果会“看不见”它。
  • DepthNormals:当有功能通过 ConfigureInput(ScriptableRenderPassInput.Normal) 请求法线纹理 _CameraNormalsTexture 时触发,典型的就是 SSAO(Source 为 Depth Normals 时)、部分贴花模式和自定义的屏幕空间描边。这个 Pass 同时写深度和世界空间法线。
Assets/Cookbook/Ch06/DepthOnlyPass.hlsl
// DepthOnly Pass:只写深度。URP 做深度预渲染(Depth Prepass)、生成 _CameraDepthTexture 时会用到它。
#ifndef COOKBOOK_CH06_DEPTH_ONLY_PASS_INCLUDED
#define COOKBOOK_CH06_DEPTH_ONLY_PASS_INCLUDED

#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"

struct DepthOnlyAttributes
{
    float4 positionOS : POSITION;
    float2 texcoord   : TEXCOORD0;
    UNITY_VERTEX_INPUT_INSTANCE_ID
};

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

DepthOnlyVaryings DepthOnlyVertex(DepthOnlyAttributes input)
{
    DepthOnlyVaryings output = (DepthOnlyVaryings)0;
    UNITY_SETUP_INSTANCE_ID(input);
    UNITY_TRANSFER_INSTANCE_ID(input, output);
    UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(output);
    output.positionCS = TransformObjectToHClip(input.positionOS.xyz);
#if defined(_ALPHATEST_ON)
    output.uv = TRANSFORM_TEX(input.texcoord, _BaseMap);
#endif
    return output;
}

half DepthOnlyFragment(DepthOnlyVaryings input) : SV_Target
{
    UNITY_SETUP_INSTANCE_ID(input);
    UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input);
#if defined(_ALPHATEST_ON)
    clip(SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv).a * _BaseColor.a - _Cutoff);
#endif
    // 深度由硬件写入;某些路径会把深度作为颜色写进 R 通道(Pass 里 ColorMask R),所以照 URP 的做法返回 z
    return input.positionCS.z;
}

#endif
// DepthOnly Pass:只写深度。URP 做深度预渲染(Depth Prepass)、生成 _CameraDepthTexture 时会用到它。(GLSL 版,对应 Ch06/DepthOnlyPass.hlsl)

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

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

#ifdef FRAGMENT
void main()
{
#if defined(_ALPHATEST_ON)
    if (texture(_BaseMap, uv).a * _BaseColor.a - _Cutoff < 0.0) discard;
#endif
    // 深度由硬件写入;某些路径会把深度作为颜色写进 R 通道(Pass 里 ColorMask R),所以照 URP 的做法返回 z
    fragColor = vec4(gl_FragCoord.z);
}
#endif
Assets/Cookbook/Ch06/DepthNormalsPass.hlsl
// DepthNormals Pass:同时写深度和世界空间法线到 _CameraNormalsTexture。
// SSAO(Depth Normals 模式)、贴花、部分自定义屏幕空间效果都依赖它。
#ifndef COOKBOOK_CH06_DEPTH_NORMALS_PASS_INCLUDED
#define COOKBOOK_CH06_DEPTH_NORMALS_PASS_INCLUDED

#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"

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

struct DepthNormalsVaryings
{
    float4 positionCS : SV_POSITION;
    float2 uv         : TEXCOORD0;
    float3 normalWS   : TEXCOORD1;
    UNITY_VERTEX_INPUT_INSTANCE_ID
    UNITY_VERTEX_OUTPUT_STEREO
};

DepthNormalsVaryings DepthNormalsVertex(DepthNormalsAttributes input)
{
    DepthNormalsVaryings output = (DepthNormalsVaryings)0;
    UNITY_SETUP_INSTANCE_ID(input);
    UNITY_TRANSFER_INSTANCE_ID(input, output);
    UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(output);
    output.positionCS = TransformObjectToHClip(input.positionOS.xyz);
    output.normalWS = TransformObjectToWorldNormal(input.normalOS);
    output.uv = input.texcoord;
#if defined(_ALPHATEST_ON)
    output.uv = TRANSFORM_TEX(input.texcoord, _BaseMap);
#endif
    return output;
}

void DepthNormalsFragment(DepthNormalsVaryings input
    , out half4 outNormalWS : SV_Target0
#ifdef _WRITE_RENDERING_LAYERS
    , out float4 outRenderingLayers : SV_Target1
#endif
)
{
    UNITY_SETUP_INSTANCE_ID(input);
    UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input);
#if defined(_ALPHATEST_ON)
    clip(SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv).a * _BaseColor.a - _Cutoff);
#endif
    // URP 14 的 _CameraNormalsTexture 直接存归一化的世界空间法线(不做 0.5 偏移)
    outNormalWS = half4(NormalizeNormalPerPixel(input.normalWS), 0.0);
#ifdef _WRITE_RENDERING_LAYERS
    // 开启“渲染层”且有贴花等需求时,URP 会让这个 Pass 顺便写出网格的渲染层
    outRenderingLayers = float4(EncodeMeshRenderingLayer(GetMeshRenderingLayer()), 0, 0, 0);
#endif
}

#endif
// DepthNormals Pass:同时写深度和世界空间法线到 _CameraNormalsTexture。(GLSL 版,对应 Ch06/DepthNormalsPass.hlsl)
// SSAO(Depth Normals 模式)、贴花、部分自定义屏幕空间效果都依赖它。

VARYING vec2 uv;
VARYING vec3 normalWS;

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

#ifdef FRAGMENT
void main()
{
#if defined(_ALPHATEST_ON)
    if (texture(_BaseMap, uv).a * _BaseColor.a - _Cutoff < 0.0) discard;
#endif
    // URP 14 的 _CameraNormalsTexture 直接存归一化的世界空间法线(不做 0.5 偏移)
    // (网页版不支持“渲染层”贴图,_WRITE_RENDERING_LAYERS 那一路省略)
    fragColor = vec4(NormalizeNormalPerPixel(normalWS), 0.0);
}
#endif

两点说明:其一,DepthOnly 的片元返回 positionCS.z 并在 Pass 上声明 ColorMask R,与 URP 的 DepthOnlyPass.hlsl 一致——某些路径下深度会被写进一张颜色格式的纹理,这时 R 通道的值才是深度。其二,URP 14 的法线纹理直接存归一化的世界空间法线;只有 Deferred 开启 Accurate G-buffer normals 时才会用八面体编码(_GBUFFER_NORMALS_OCT),而 Lit 的 DepthNormals Pass 并不声明这个关键字。_WRITE_RENDERING_LAYERS 来自 RenderingLayers.hlsl(用 #include_with_pragmas 引入),开启渲染层并且贴花需要它时,这个 Pass 会顺便写出网格的渲染层。

图 6-20Renderer 上加了 SSAO(Depth Normals 模式)。左:关闭物体材质上的深度法线 Pass,物体没有进入 _CameraNormalsTexture,SSAO 看不到它,物体与地面接触处没有任何遮蔽;右:恢复后,立方体底部、纽结与地面之间出现自然的接触阴影

UniversalForward 还是 UniversalForwardOnly?

URP 的 Deferred 路径会把有 UniversalGBuffer Pass 的材质写进 G-Buffer,再统一计算光照;没有 G-Buffer Pass 的材质,只有标记为 UniversalForwardOnly(或无标签的旧式 Pass)时,才会在 Deferred 的“Forward Only”通道里前向绘制。只标记 UniversalForward 又没有 G-Buffer Pass 的材质,在 Deferred 下根本不会被绘制。所以光照模型无法写进 G-Buffer 的自定义 Shader(Blinn-Phong、卡通、皮肤……)应该用 UniversalForwardOnly,它在 Forward / Forward+ 下与 UniversalForward 的行为完全相同。配套地,深度法线 Pass 要标 DepthNormalsOnly:Deferred 的深度法线预渲染只收集这个标签;Forward 下 DepthNormals 和 DepthNormalsOnly 都会被绘制,所以两者只能选一个。URP 自带的 Complex Lit 就是这样组织的。

案例 6.9ClassicLit:完整的 Blinn-Phong + 多光源 + 阴影 Shader高阶单独打开 ↗

目标:把本章所有内容组装成一个可以直接用于项目的 Shader:归一化 Blinn-Phong、球谐环境光、主光源级联软阴影、Forward / Forward+ 附加光循环、附加光阴影、逐顶点附加光、Light Layers、光照 Cookie、SSAO、雾效、Alpha 裁剪、GPU Instancing,以及完整的 ShadowCaster / DepthOnly / DepthNormals Pass,兼容 SRP Batcher,并能在 Forward、Forward+、Deferred 三种路径下工作。

文件组织仿照 URP 自己的 Lit:

文件内容
ClassicLit.shader属性、渲染状态、各 Pass 的关键字声明
ClassicLitInput.hlsl材质 CBUFFER 与贴图声明,被所有 Pass 共享
ClassicLitForwardPass.hlsl前向光照的顶点/片元函数
ShadowCasterPass.hlsl 等案例 6.4、6.8 写好的通用 Pass

Shader 主文件

Assets/Cookbook/Ch06/ClassicLit.shader
// 案例 6.9 ClassicLit:完整的 Blinn-Phong + 多光源 + 阴影 Shader(后续章节会以它为基础扩展)
Shader "Cookbook/Ch06/ClassicLit"
{
    Properties
    {
        [MainTexture] _BaseMap("Base Map", 2D) = "white" {}
        [MainColor] _BaseColor("Base Color", Color) = (1, 1, 1, 1)
        _SpecColor("Specular Color", Color) = (0.2, 0.2, 0.2, 1)
        _Smoothness("Smoothness", Range(0, 1)) = 0.5
        [HDR] _EmissionColor("Emission", Color) = (0, 0, 0, 1)

        [Toggle(_ALPHATEST_ON)] _AlphaClip("Alpha Clip", Float) = 0
        _Cutoff("Alpha Cutoff", Range(0, 1)) = 0.5
        [Toggle(_RECEIVE_SHADOWS_OFF)] _ReceiveShadowsOff("Receive Shadows Off", Float) = 0
        [Enum(UnityEngine.Rendering.CullMode)] _Cull("Cull", Float) = 2
    }

    SubShader
    {
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" }

        HLSLINCLUDE
        #include "Assets/Cookbook/Ch06/ClassicLitInput.hlsl"
        ENDHLSL

        Pass
        {
            Name "ForwardLit"
            // 没有 GBuffer Pass 的自定义光照 Shader 要标成 UniversalForwardOnly:
            // Forward / Forward+ 下它和 UniversalForward 一样被绘制;Deferred 下它会进入“Forward Only”通道,
            // 而只标 UniversalForward 的物体在 Deferred 里会直接消失
            Tags { "LightMode"="UniversalForwardOnly" }
            Cull [_Cull]

            HLSLPROGRAM
            #pragma target 2.0
            #pragma vertex ClassicLitVertex
            #pragma fragment ClassicLitFragment

            // 材质关键字
            #pragma shader_feature_local _RECEIVE_SHADOWS_OFF
            #pragma shader_feature_local_fragment _ALPHATEST_ON

            // URP 关键字(与 URP 14 Lit.shader 的 ForwardLit 保持一致的子集)
            #pragma multi_compile _ _MAIN_LIGHT_SHADOWS _MAIN_LIGHT_SHADOWS_CASCADE _MAIN_LIGHT_SHADOWS_SCREEN
            #pragma multi_compile _ _ADDITIONAL_LIGHTS_VERTEX _ADDITIONAL_LIGHTS
            #pragma multi_compile_fragment _ _ADDITIONAL_LIGHT_SHADOWS
            #pragma multi_compile_fragment _ _SHADOWS_SOFT _SHADOWS_SOFT_LOW _SHADOWS_SOFT_MEDIUM _SHADOWS_SOFT_HIGH
            #pragma multi_compile_fragment _ _SCREEN_SPACE_OCCLUSION
            #pragma multi_compile_fragment _ _LIGHT_COOKIES
            #pragma multi_compile _ _LIGHT_LAYERS
            #pragma multi_compile _ _FORWARD_PLUS
            #pragma multi_compile_fog
            #pragma multi_compile_instancing

            #include "Assets/Cookbook/Ch06/ClassicLitForwardPass.hlsl"
            ENDHLSL
        }

        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 "DepthOnly"
            Tags { "LightMode"="DepthOnly" }
            ZWrite On
            ColorMask R
            Cull [_Cull]

            HLSLPROGRAM
            #pragma target 2.0
            #pragma vertex DepthOnlyVertex
            #pragma fragment DepthOnlyFragment
            #pragma shader_feature_local_fragment _ALPHATEST_ON
            #pragma multi_compile_instancing
            #include "Assets/Cookbook/Ch06/DepthOnlyPass.hlsl"
            ENDHLSL
        }

        Pass
        {
            Name "DepthNormalsOnly"
            // 与 UniversalForwardOnly 配套:Deferred 的深度法线预渲染只收 DepthNormalsOnly;
            // Forward 下 DepthNormals 与 DepthNormalsOnly 都会被绘制,所以两者只能二选一
            Tags { "LightMode"="DepthNormalsOnly" }
            ZWrite On
            Cull [_Cull]

            HLSLPROGRAM
            #pragma target 2.0
            #pragma vertex DepthNormalsVertex
            #pragma fragment DepthNormalsFragment
            #pragma shader_feature_local_fragment _ALPHATEST_ON
            #pragma multi_compile_instancing
            #include_with_pragmas "Packages/com.unity.render-pipelines.universal/ShaderLibrary/RenderingLayers.hlsl"
            #include "Assets/Cookbook/Ch06/DepthNormalsPass.hlsl"
            ENDHLSL
        }
    }
}
// 案例 6.9 ClassicLit:完整的 Blinn-Phong + 多光源 + 阴影 Shader(后续章节会以它为基础扩展)(GLSL 版,对应 Ch06/ClassicLit.shader)
Shader "Cookbook/Ch06/ClassicLit"
{
    SubShader
    {
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" }

        GLSLINCLUDE
        #include "Ch06/ClassicLitInput.glsl"
        ENDGLSL

        Pass
        {
            Name "ForwardLit"
            // 没有 GBuffer Pass 的自定义光照 Shader 要标成 UniversalForwardOnly:
            // Forward / Forward+ 下它和 UniversalForward 一样被绘制;Deferred 下它会进入“Forward Only”通道,
            // 而只标 UniversalForward 的物体在 Deferred 里会直接消失
            Tags { "LightMode"="UniversalForwardOnly" }
            Cull [_Cull]

            GLSLPROGRAM
            // 材质关键字
            #pragma shader_feature_local _RECEIVE_SHADOWS_OFF
            #pragma shader_feature_local_fragment _ALPHATEST_ON

            // URP 关键字:主光阴影、附加光(逐像素)、附加光阴影、软阴影由网页版引擎始终定义;
            // _FORWARD_PLUS、_LIGHT_LAYERS 在网页版里分别由引擎内部 / uniform _LightLayers 处理;
            // _SCREEN_SPACE_OCCLUSION 由 _AmbientOcclusionParam.x 判断;雾由 unity_FogMode 判断
            #pragma multi_compile _ _ADDITIONAL_LIGHTS_VERTEX _ADDITIONAL_LIGHTS

            #include "Ch06/ClassicLitForwardPass.glsl"
            ENDGLSL
        }

        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
        {
            Name "DepthOnly"
            Tags { "LightMode"="DepthOnly" }
            ZWrite On
            ColorMask R
            Cull [_Cull]

            GLSLPROGRAM
            #pragma shader_feature_local_fragment _ALPHATEST_ON
            #include "Ch06/DepthOnlyPass.glsl"
            ENDGLSL
        }

        Pass
        {
            Name "DepthNormalsOnly"
            // 与 UniversalForwardOnly 配套:Deferred 的深度法线预渲染只收 DepthNormalsOnly;
            // Forward 下 DepthNormals 与 DepthNormalsOnly 都会被绘制,所以两者只能二选一
            Tags { "LightMode"="DepthNormalsOnly" }
            ZWrite On
            Cull [_Cull]

            GLSLPROGRAM
            #pragma shader_feature_local_fragment _ALPHATEST_ON
            #include "Ch06/DepthNormalsPass.glsl"
            ENDGLSL
        }
    }
}

前向 Pass 的关键字是 URP 14 Lit.shader 中 ForwardLit 关键字的一个子集:去掉了光照贴图、反射探针混合、贴花缓冲、调试显示等第 8 章以后才涉及的部分。如果你的项目用到这些功能,回到 Lit.shader 按需补上。

材质输入

Assets/Cookbook/Ch06/ClassicLitInput.hlsl
// 案例 6.9 ClassicLit 的材质输入:所有 Pass 共享同一个 UnityPerMaterial,保证 SRP Batcher 兼容
#ifndef COOKBOOK_CH06_CLASSIC_LIT_INPUT_INCLUDED
#define COOKBOOK_CH06_CLASSIC_LIT_INPUT_INCLUDED

#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"

CBUFFER_START(UnityPerMaterial)
    float4 _BaseMap_ST;
    half4  _BaseColor;
    half4  _SpecColor;
    half   _Smoothness;
    half4  _EmissionColor;
    half   _Cutoff;
CBUFFER_END

TEXTURE2D(_BaseMap);
SAMPLER(sampler_BaseMap);

#endif
// 案例 6.9 ClassicLit 的材质输入:所有 Pass 共享同一组材质参数(GLSL 版,对应 Ch06/ClassicLitInput.hlsl)
// HLSL 版把它们放进 CBUFFER_START(UnityPerMaterial) 以兼容 SRP Batcher;GLSL 里就是普通 uniform。

uniform vec4  _BaseMap_ST;
uniform vec4  _BaseColor;
uniform vec4  _SpecColor;
uniform float _Smoothness;
uniform vec4  _EmissionColor;
uniform float _Cutoff;

uniform sampler2D _BaseMap;

CBUFFER 被放在单独的文件里,通过 SubShader 级别的 HLSLINCLUDE 引入,四个 Pass 拿到的是同一份声明。_Cutoff 只有在开启 Alpha 裁剪时才被用到,但它必须始终存在于 CBUFFER 中:SRP Batcher 要求 CBUFFER 的布局不能随关键字变化。

顶点着色器

Assets/Cookbook/Ch06/ClassicLitForwardPass.hlsl(节选:vert)
Varyings ClassicLitVertex(Attributes input)
{
    Varyings output = (Varyings)0;
    UNITY_SETUP_INSTANCE_ID(input);
    UNITY_TRANSFER_INSTANCE_ID(input, output);
    UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(output);

    VertexPositionInputs vertexInput = GetVertexPositionInputs(input.positionOS.xyz);
    half3 normalWS = TransformObjectToWorldNormal(input.normalOS);

    output.positionCS = vertexInput.positionCS;
    output.positionWS = vertexInput.positionWS;
    output.normalWS = normalWS;
    output.uv = TRANSFORM_TEX(input.texcoord, _BaseMap);

    half fogFactor = ComputeFogFactor(vertexInput.positionCS.z);
    half3 vertexLight = VertexLighting(vertexInput.positionWS, normalWS);   // 只有 _ADDITIONAL_LIGHTS_VERTEX 时非零
    output.fogFactorAndVertexLight = half4(fogFactor, vertexLight);

#if defined(REQUIRES_VERTEX_SHADOW_COORD_INTERPOLATOR)
    output.shadowCoord = GetShadowCoord(vertexInput);
#endif
    return output;
}
#ifdef VERTEX
// URP 的 VertexLighting():只有 _ADDITIONAL_LIGHTS_VERTEX(逐顶点附加光)时才非零
vec3 VertexLighting(vec3 positionWS, vec3 normalWS)
{
    vec3 vertexLightColor = vec3(0.0);
#if defined(_ADDITIONAL_LIGHTS_VERTEX)
    for (int i = 0; i < GetAdditionalLightsCount(); i++)
    {
        Light light = GetAdditionalPerObjectLight(i, positionWS);
        vertexLightColor += LightingLambert(light.color * light.distanceAttenuation, light.direction, normalWS);
    }
#endif
    return vertexLightColor;
}

void main()
{
    VertexPositionInputs vertexInput = GetVertexPositionInputs(positionOS.xyz);
    vec3 nWS = TransformObjectToWorldNormal(normalOS);

    gl_Position = vertexInput.positionCS;
    positionWS = vertexInput.positionWS;
    normalWS = nWS;
    uv = TransformTex(texcoord0.xy, _BaseMap_ST);

    float fogFactor = ComputeFogFactor(vertexInput.positionCS.z);
    vec3 vertexLight = VertexLighting(vertexInput.positionWS, nWS);   // 只有 _ADDITIONAL_LIGHTS_VERTEX 时非零
    fogFactorAndVertexLight = vec4(fogFactor, vertexLight);
}
#endif

顶点阶段做三件“能提前算就提前算”的事:雾因子(URP 14 实际在片元里重新计算,见下)、逐顶点附加光 VertexLighting()(只有 _ADDITIONAL_LIGHTS_VERTEX 时才有内容,其余情况返回 0 并被编译器优化掉),以及在单级联时提前算好阴影坐标。

组装 InputData

Assets/Cookbook/Ch06/ClassicLitForwardPass.hlsl(节选:inputdata)
InputData BuildInputData(Varyings input, half3 normalWS)
{
    InputData inputData = (InputData)0;
    inputData.positionWS = input.positionWS;
    inputData.positionCS = input.positionCS;
    inputData.normalWS = normalWS;
    inputData.viewDirectionWS = GetWorldSpaceNormalizeViewDir(input.positionWS);
#if defined(REQUIRES_VERTEX_SHADOW_COORD_INTERPOLATOR)
    inputData.shadowCoord = input.shadowCoord;
#elif defined(MAIN_LIGHT_CALCULATE_SHADOWS)
    inputData.shadowCoord = TransformWorldToShadowCoord(input.positionWS);   // 级联:逐像素选级联
#else
    inputData.shadowCoord = float4(0, 0, 0, 0);
#endif
    // URP 14 强制逐像素算雾(ShaderVariablesFunctions.hlsl 里 #define _FOG_FRAGMENT 1),顶点雾因子只作后备
    inputData.fogCoord = InitializeInputDataFog(float4(input.positionWS, 1.0), input.fogFactorAndVertexLight.x);
    inputData.vertexLighting = input.fogFactorAndVertexLight.yzw;
    inputData.bakedGI = SampleSH(normalWS);                     // 本章只用球谐环境光,光照贴图见第 8 章
    inputData.normalizedScreenSpaceUV = GetNormalizedScreenSpaceUV(input.positionCS);
    inputData.shadowMask = half4(1, 1, 1, 1);                   // 没有烘焙阴影时为 1
    return inputData;
}
InputData BuildInputData(vec3 normalWS)
{
    InputData inputData = InitInputData();
    inputData.positionWS = positionWS;
    inputData.positionCS = gl_FragCoord;
    inputData.normalWS = normalWS;
    inputData.viewDirectionWS = GetWorldSpaceNormalizeViewDir(positionWS);
#if defined(_RECEIVE_SHADOWS_OFF)
    inputData.shadowCoord = vec4(0, 0, 0, 0);                               // 不接收阴影:URP 不定义 MAIN_LIGHT_CALCULATE_SHADOWS
#else
    inputData.shadowCoord = TransformWorldToShadowCoord(positionWS);        // 级联:逐像素选级联
#endif
    // URP 14 强制逐像素算雾(_FOG_FRAGMENT),这里按像素位置重新求雾因子,顶点雾因子只作后备
    inputData.fogCoord = ComputeFogFactor(TransformWorldToHClip(positionWS).z);
    inputData.vertexLighting = fogFactorAndVertexLight.yzw;
    inputData.bakedGI = SampleSH(normalWS);                     // 本章只用球谐环境光,光照贴图见第 8 章
    inputData.normalizedScreenSpaceUV = GetNormalizedScreenSpaceUV(gl_FragCoord);
    inputData.shadowMask = vec4(1, 1, 1, 1);                    // 没有烘焙阴影时为 1
    return inputData;
}

这个函数和 URP 的 InitializeInputData(LitForwardPass.hlsl)结构相同。之所以要把各种数据装进 InputData,除了 LIGHT_LOOP_BEGIN 的硬性要求,还因为 URP 的很多函数——GetMainLight(inputData, ...)、GetAdditionalLight(i, inputData, ...)、CalculateShadowMask——都以它为参数。注意雾:URP 14 在 ShaderVariablesFunctions.hlsl 里写死了 #define _FOG_FRAGMENT 1,雾一律按像素深度计算,所以要调用 InitializeInputDataFog 而不是直接使用顶点里的雾因子。

一盏灯的光照

Assets/Cookbook/Ch06/ClassicLitForwardPass.hlsl(节选:blinnphong)
// 一盏灯的贡献:归一化 Blinn-Phong(URP 约定,漫反射不除 π,高光系数为 (n+8)/8)
half3 ClassicBlinnPhong(Light light, half3 normalWS, half3 viewDirWS, half3 albedo, half3 specColor, float shininess)
{
    half NdotL = saturate(dot(normalWS, light.direction));
    half3 radiance = light.color * (light.distanceAttenuation * light.shadowAttenuation * NdotL);

    float3 H = SafeNormalize(float3(light.direction) + float3(viewDirWS));
    float NdotH = saturate(dot(float3(normalWS), H));
    float spec = (shininess + 8.0) * 0.125 * pow(NdotH, shininess);

    return (albedo + specColor * spec) * radiance;
}
// 一盏灯的贡献:归一化 Blinn-Phong(URP 约定,漫反射不除 π,高光系数为 (n+8)/8)
vec3 ClassicBlinnPhong(Light light, vec3 normalWS, vec3 viewDirWS, vec3 albedo, vec3 specColor, float shininess)
{
    float NdotL = saturate(dot(normalWS, light.direction));
    vec3 radiance = light.color * (light.distanceAttenuation * light.shadowAttenuation * NdotL);

    vec3 H = SafeNormalize(light.direction + viewDirWS);
    float NdotH = saturate(dot(normalWS, H));
    float spec = (shininess + 8.0) * 0.125 * pow(NdotH, shininess);

    return (albedo + specColor * spec) * radiance;
}

这就是第 5 章案例 5.6 的归一化 Blinn-Phong,只是把光的颜色、距离衰减、阴影衰减和 N·L 合成了一个 radiance。所有光源——主光源、附加方向光、点光、聚光——都调用它。

片元着色器

Assets/Cookbook/Ch06/ClassicLitForwardPass.hlsl(节选:frag)
half4 ClassicLitFragment(Varyings input) : SV_Target
{
    UNITY_SETUP_INSTANCE_ID(input);
    UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input);

    half4 baseSample = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv) * _BaseColor;
#if defined(_ALPHATEST_ON)
    clip(baseSample.a - _Cutoff);
#endif
    half3 albedo = baseSample.rgb;
    half3 specColor = _SpecColor.rgb;
    float shininess = exp2(10.0 * _Smoothness + 1.0);        // 与 URP SimpleLit 相同的映射

    half3 normalWS = NormalizeNormalPerPixel(input.normalWS);
    InputData inputData = BuildInputData(input, normalWS);    // 名字必须叫 inputData,LIGHT_LOOP_BEGIN 会用到

    half4 shadowMask = CalculateShadowMask(inputData);
    AmbientOcclusionFactor aoFactor = CreateAmbientOcclusionFactor(inputData.normalizedScreenSpaceUV, 1.0);
    uint meshRenderingLayers = GetMeshRenderingLayer();

    // ---- 环境光(SSAO 只压暗间接光,直接光按 Direct Lighting Strength 衰减)----
    half3 color = inputData.bakedGI * albedo * aoFactor.indirectAmbientOcclusion;

    // ---- 主光源:带阴影、阴影淡出和 Cookie ----
    Light mainLight = GetMainLight(inputData, shadowMask, aoFactor);
#if defined(_LIGHT_LAYERS)
    if (IsMatchingLightLayer(mainLight.layerMask, meshRenderingLayers))
#endif
        color += ClassicBlinnPhong(mainLight, normalWS, inputData.viewDirectionWS, albedo, specColor, shininess);

    // ---- 附加光源 ----
#if defined(_ADDITIONAL_LIGHTS)
    uint pixelLightCount = GetAdditionalLightsCount();

    #if USE_FORWARD_PLUS
    for (uint lightIndex = 0; lightIndex < min(URP_FP_DIRECTIONAL_LIGHTS_COUNT, MAX_VISIBLE_LIGHTS); lightIndex++)
    {
        FORWARD_PLUS_SUBTRACTIVE_LIGHT_CHECK
        Light light = GetAdditionalLight(lightIndex, inputData, shadowMask, aoFactor);
        #if defined(_LIGHT_LAYERS)
        if (IsMatchingLightLayer(light.layerMask, meshRenderingLayers))
        #endif
            color += ClassicBlinnPhong(light, normalWS, inputData.viewDirectionWS, albedo, specColor, shininess);
    }
    #endif

    LIGHT_LOOP_BEGIN(pixelLightCount)
        // 这个重载会顺带算出附加光阴影(_ADDITIONAL_LIGHT_SHADOWS)与 Cookie
        Light light = GetAdditionalLight(lightIndex, inputData, shadowMask, aoFactor);
        #if defined(_LIGHT_LAYERS)
        if (IsMatchingLightLayer(light.layerMask, meshRenderingLayers))
        #endif
            color += ClassicBlinnPhong(light, normalWS, inputData.viewDirectionWS, albedo, specColor, shininess);
    LIGHT_LOOP_END
#endif

#if defined(_ADDITIONAL_LIGHTS_VERTEX)
    color += inputData.vertexLighting * albedo;               // 逐顶点附加光:只有漫反射
#endif

    color += _EmissionColor.rgb;
    color = MixFog(color, inputData.fogCoord);
    return half4(color, baseSample.a);
}
void main()
{
    vec4 baseSample = texture(_BaseMap, uv) * _BaseColor;
#if defined(_ALPHATEST_ON)
    if (baseSample.a - _Cutoff < 0.0) discard;
#endif
    vec3 albedo = baseSample.rgb;
    vec3 specColor = _SpecColor.rgb;
    float shininess = exp2(10.0 * _Smoothness + 1.0);        // 与 URP SimpleLit 相同的映射

    vec3 N = NormalizeNormalPerPixel(normalWS);
    InputData inputData = BuildInputData(N);

    vec4 shadowMask = inputData.shadowMask;                   // URP:CalculateShadowMask(inputData)
    // URP:CreateAmbientOcclusionFactor(uv, 1.0)。没开 SSAO 时两项都是 1
    AmbientOcclusionFactor aoFactor = GetScreenSpaceAmbientOcclusion(inputData.normalizedScreenSpaceUV);
    uint meshRenderingLayers = GetMeshRenderingLayer();

    // ---- 环境光(SSAO 只压暗间接光,直接光按 Direct Lighting Strength 衰减)----
    vec3 color = inputData.bakedGI * albedo * aoFactor.indirectAmbientOcclusion;

    // ---- 主光源:带阴影、阴影淡出(URP 的 GetMainLight(inputData, shadowMask, aoFactor))----
    Light mainLight = GetMainLight(inputData.shadowCoord, inputData.positionWS, shadowMask);
    mainLight.color *= aoFactor.directAmbientOcclusion;
    // HLSL 版的 #if defined(_LIGHT_LAYERS):GLSL 版里是 uniform _LightLayers(URP Asset 打开 Light Layers 时为 1)
    if (_LightLayers < 0.5 || IsMatchingLightLayer(GetMainLightLayerMask(), meshRenderingLayers))
        color += ClassicBlinnPhong(mainLight, N, inputData.viewDirectionWS, albedo, specColor, shininess);

    // ---- 附加光源 ----
#if defined(_ADDITIONAL_LIGHTS)
    // HLSL 版:Forward+ 先单独循环附加方向光,再用 LIGHT_LOOP_BEGIN 遍历分簇结构;
    // 网页版统一成普通 for 循环(Forward 下是本物体的灯,Forward+ 下是全部可见附加光,方向光在最前面)
    int pixelLightCount = GetAdditionalLightsCount();
    for (int lightIndex = 0; lightIndex < pixelLightCount; lightIndex++)
    {
        // 这个重载会顺带算出附加光阴影(_ADDITIONAL_LIGHT_SHADOWS)
        Light light = GetAdditionalLight(lightIndex, inputData.positionWS, shadowMask);
    #if defined(_RECEIVE_SHADOWS_OFF)
        light.shadowAttenuation = 1.0;
    #endif
        light.color *= aoFactor.directAmbientOcclusion;
        if (_LightLayers < 0.5 || IsMatchingLightLayer(GetAdditionalLightLayerMask(lightIndex), meshRenderingLayers))
            color += ClassicBlinnPhong(light, N, inputData.viewDirectionWS, albedo, specColor, shininess);
    }
#endif

#if defined(_ADDITIONAL_LIGHTS_VERTEX)
    color += inputData.vertexLighting * albedo;               // 逐顶点附加光:只有漫反射
#endif

    color += _EmissionColor.rgb;
    color = MixFog(color, inputData.fogCoord);
    fragColor = vec4(color, baseSample.a);
}
  1. 阴影遮罩与 AO。CalculateShadowMask 在没有光照贴图时返回 unity_ProbesOcclusion;CreateAmbientOcclusionFactor 在开启 SSAO 时采样 _ScreenSpaceOcclusionTexture,得到间接光遮蔽和直接光遮蔽两个值,没开启时都是 1。
  2. 主光源。GetMainLight(inputData, shadowMask, aoFactor) 一次完成:阴影采样、阴影淡出、烘焙阴影混合、Cookie、SSAO 对直接光的衰减。
  3. 附加光。结构与案例 6.1 相同,只是换成了带阴影和 AO 的 GetAdditionalLight 重载,并加上 Light Layers 判断。宏 FORWARD_PLUS_SUBTRACTIVE_LIGHT_CHECK 只在“Forward+ + 光照贴图 + Subtractive 模式”时展开为一个 continue,用来跳过已经烘焙进光照贴图的灯,保留它是为了和 URP 的行为一致。
  4. 自发光与雾。自发光在雾之前加入,所以远处的灯泡会被雾吞没;如果希望自发光穿透雾,可以把它移到 MixFog 之后。

效果

图 6-21场景中所有物体(包括地面)都使用 ClassicLit:红色纽结与蓝球是高光滑度材质,砖块立方体使用公共贴图 bricks_albedo,地上的“灯泡”是自发光材质,里面藏着一盏投射软阴影的暖色点光;右侧还有一盏冷色点光和一盏投影的聚光灯,主光源为微弱的逆光
图 6-22同一场景在三种渲染路径下的结果。左:Forward;中:Forward+;右:Deferred(ClassicLit 作为 Forward Only 材质绘制)。三者一致,说明附加光循环与 Pass 标签都写对了
图 6-23光滑度从左到右为 0.1、0.3、0.5、0.7、0.9,高光颜色同为 0.2。上排:URP Simple Lit;下排:ClassicLit。两者的指数映射相同,但 Simple Lit 的高光没有归一化,越光滑越暗;ClassicLit 越光滑越小、越亮

与 Simple Lit 对比,除了归一化,还有两处差异值得注意:Simple Lit 的高光项没有乘 N·L(见 CalculateBlinnPhong 与 LightingSpecular),光从侧后方掠射时,高光可能越过交界线出现在背光面上;Simple Lit 的光滑度在 _SPECULAR_COLOR 模式下实际取自 _SpecColor.a(材质面板会把 Smoothness 滑块写进这里),用脚本修改 _Smoothness 是无效的。

如何扩展

ClassicLit 是后续章节的“底座”:第 7 章会给它加上法线贴图(在 Varyings 里加切线、构建 TBN);第 8 章会接入光照贴图、反射探针和 Meta Pass;第 14 章的卡通渲染会把 ClassicBlinnPhong 换成色阶化的光照函数;第 17 章的顶点动画需要在 Forward、ShadowCaster、DepthOnly、DepthNormals 四个 Pass 里做相同的顶点偏移。由于光照函数、InputData 构建和各个 Pass 已经拆开,这些扩展都只需要改动很小的范围。

变体数量前向 Pass 的关键字组合在理论上有数万种。URP 在构建时会根据工程里所有 URP Asset 实际启用的功能(是否有附加光阴影、是否用 Forward+、是否开 SSAO 等)剥离不可能用到的变体,实际进包的数量要少得多。如果构建时间或包体过大,第 29 章会介绍如何进一步剥离。

6.6 性能与移动端

多光源与阴影是渲染中最容易失控的开销,这里把本章涉及的性能要点集中列一下:

  • 附加光的开销与“受影响的像素数 × 灯数”成正比。Range 越大,照到的像素越多;Forward+ 去掉了每物体上限,也就去掉了一道“天然的保险”,大量大范围的灯会让每个像素都循环很多次。
  • 阴影的开销主要在投射端:每个级联、每盏投影的聚光、每个点光的 6 个面,都要把范围内的投射物体重新画一遍(只写深度,但 Draw Call 和顶点处理一样多)。关掉小物体的 Cast Shadows、减少级联、缩短阴影距离,通常比降低分辨率更有效。
  • 接收端的开销取决于软阴影质量:High 每个像素 16 次比较采样,Low 只有 4 次。移动端建议 Low 或硬阴影。
  • 移动端的附加光:低端设备可以把附加光设为 Per Vertex(Shader 要支持 _ADDITIONAL_LIGHTS_VERTEX),并且不开附加光阴影。
  • 精度:衰减计算中的距离平方必须用 float,Light.distanceAttenuation 也声明为 float,half 在光源附近会溢出、在远处会损失精度。

本章小结

  • URP 的光源分为主光源和附加光;Forward 按物体剔除(Per Object Limit ≤ 8),Forward+ 按屏幕 tile 和深度分段剔除,没有每物体上限。
  • 附加光循环写法:声明 _ADDITIONAL_LIGHTS(_VERTEX) 与 _FORWARD_PLUS;准备名为 inputData 的变量;Forward+ 下单独循环附加方向光;其余用 LIGHT_LOOP_BEGIN/END。
  • URP 的距离衰减 = 平方反比 × (1 − (d²/r²)²)²,窗口让衰减在 Range 处平滑归零,从而隐藏剔除的粒度;聚光角度衰减是 saturate 后的平方。
  • 阴影映射:先从光源渲染深度,再把像素变换到光源空间比较深度;粉刺用深度偏移和法线偏移消除,过度则产生 Peter-Panning 和“变瘦”;PCF 先比较后平均;级联让近处的阴影更清晰。
  • URP 的 Bias 以阴影图 texel 为单位,在投射端由 ApplyShadowBias 施加,软阴影时还会乘滤波核半径;渲染阴影图时另有固定的硬件斜率偏移。
  • ShadowCaster 需要处理 _CASTING_PUNCTUAL_LIGHT_SHADOW、近平面压扁和 Alpha 裁剪,并与颜色 Pass 共享 CBUFFER。
  • 没有 G-Buffer Pass 的自定义光照 Shader 应使用 UniversalForwardOnly 与 DepthNormalsOnly 标签,才能在 Deferred 下正常显示。

练习

  1. 在案例 6.1 中,把附加方向光的单独循环删掉,然后在 Forward+ 下给场景加一盏非主光源的方向光,观察并解释现象。
  2. 修改 FalloffCompare,实现一个“距离衰减可调”的版本:在平方反比和 URP 衰减之间用一个参数插值,并思考它在什么样的风格化项目里有用。
  3. 给 ClassicLit 增加一个“阴影颜色”属性:让阴影区域不是变黑,而是向某个颜色过渡(提示:只修改主光源的 radiance 计算)。
  4. 用 CascadeDebug 观察:把相机快速旋转时,级联边界是否移动?把 Asset 中的 Shadow Distance 从 60 改为 30,各级联的覆盖范围怎样变化?
  5. 给 ClassicLit 的 ShadowCaster 加入顶点沿法线外扩的参数,模拟“更粗”的投影体,观察它对 Peter-Panning 的影响,并与 Normal Bias 的方向做对比。