GameTech/URP 渲染实战

案例 3.7投射阴影与写入深度:ShadowCaster 与 DepthOnly进阶

目标:让物体能投射阴影(包括镂空形状的阴影),并能出现在深度图 _CameraDepthTexture 里。

到上一步为止,我们的 Shader 只有一个画颜色的 Pass。URP 在一帧里还会做别的绘制:渲染阴影图时,会以光源为“相机”把场景画一遍,只找带 ShadowCaster 标签的 Pass;需要深度预渲染(Depth Prepass)时,会再画一遍深度,只找 DepthOnly 标签的 Pass。一个 Shader 没有这些 Pass,物体就既不投影,也不会出现在预渲染的深度图里——不会报错,只是“悄悄缺席”。

ShadowCaster Pass

Assets/Cookbook/Ch03/C03_07_ShadowDepth.shader(节选:shadow)
// ---------------- 2. 阴影投射 Pass:把物体画进光源的 Shadow Map ----------------
Pass
{
    Name "ShadowCaster"
    Tags { "LightMode"="ShadowCaster" }
    ZWrite On
    ZTest LEqual
    ColorMask 0          // 只要深度,不写颜色
    Cull [_Cull]

    HLSLPROGRAM
    #pragma vertex ShadowVert
    #pragma fragment ShadowFrag
    // 点光/聚光的阴影要用 _LightPosition 逐顶点求光方向,方向光用 _LightDirection
    #pragma multi_compile_vertex _ _CASTING_PUNCTUAL_LIGHT_SHADOW
    // Shadows.hlsl 用到了 CommonMaterial.hlsl 里的 LerpWhiteTo,单独引用时要先包含它
    #include "Packages/com.unity.render-pipelines.core/ShaderLibrary/CommonMaterial.hlsl"
    #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Shadows.hlsl"

    // 由 URP 在渲染阴影前设置(ShadowUtils.SetupShadowCasterConstantBuffer)
    float3 _LightDirection;
    float3 _LightPosition;

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

    Varyings ShadowVert (Attributes input)
    {
        Varyings output;
        float3 positionWS = TransformObjectToWorld(input.positionOS.xyz);
        float3 normalWS   = TransformObjectToWorldNormal(input.normalOS);
    #if defined(_CASTING_PUNCTUAL_LIGHT_SHADOW)
        float3 lightDirectionWS = normalize(_LightPosition - positionWS);
    #else
        float3 lightDirectionWS = _LightDirection;
    #endif
        // 沿光方向和法线方向各推一点,减轻自阴影条纹(Shadow Acne),细节见第 6 章
        positionWS = ApplyShadowBias(positionWS, normalWS, lightDirectionWS);
        output.positionCS = TransformWorldToHClip(positionWS);
        // 把超出近平面的投射体压回近平面,避免被裁掉导致阴影缺块
    #if UNITY_REVERSED_Z
        output.positionCS.z = min(output.positionCS.z, UNITY_NEAR_CLIP_VALUE);
    #else
        output.positionCS.z = max(output.positionCS.z, UNITY_NEAR_CLIP_VALUE);
    #endif
        output.uv = TRANSFORM_TEX(input.uv, _BaseMap);
        return output;
    }

    half4 ShadowFrag (Varyings input) : SV_Target
    {
        ClipByAlpha(input.uv);   // 镂空的地方不投影
        return 0;
    }
    ENDHLSL
}
// ---------------- 2. 阴影投射 Pass:把物体画进光源的 Shadow Map ----------------
Pass
{
    Name "ShadowCaster"
    Tags { "LightMode"="ShadowCaster" }
    ZWrite On
    ZTest LEqual
    ColorMask 0          // 只要深度,不写颜色
    Cull [_Cull]

    GLSLPROGRAM
    // 点光/聚光的阴影要用 _LightPosition 逐顶点求光方向,方向光用 _LightDirection。
    // 网页版里 _CASTING_PUNCTUAL_LIGHT_SHADOW 不是关键字,而是 uniform _CastingPunctualLightShadow;
    // _LightDirection / _LightPosition / ApplyShadowBias 都已由 Lighting.glsl 提供(对应 Shadows.hlsl)

    VARYING vec2 uv;

    #ifdef VERTEX
    void main()
    {
        vec3 positionWS = TransformObjectToWorld(positionOS.xyz);
        vec3 normalWS   = TransformObjectToWorldNormal(normalOS);
        vec3 lightDirectionWS = _CastingPunctualLightShadow > 0.5
            ? normalize(_LightPosition - positionWS)
            : _LightDirection;
        // 沿光方向和法线方向各推一点,减轻自阴影条纹(Shadow Acne),细节见第 6 章
        positionWS = ApplyShadowBias(positionWS, normalWS, lightDirectionWS);
        gl_Position = TransformWorldToHClip(positionWS);
        // 把超出近平面的投射体压回近平面,避免被裁掉导致阴影缺块
    #if UNITY_REVERSED_Z
        gl_Position.z = min(gl_Position.z, UNITY_NEAR_CLIP_VALUE);
    #else
        gl_Position.z = max(gl_Position.z, UNITY_NEAR_CLIP_VALUE);
    #endif
        uv = TransformTex(texcoord0.xy, _BaseMap_ST);
    }
    #endif

    #ifdef FRAGMENT
    void main()
    {
        ClipByAlpha(uv);   // 镂空的地方不投影
        fragColor = vec4(0.0);
    }
    #endif
    ENDGLSL
}

逐段解释:

// 摘自 URP 14.0.12 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 来自 URP Asset 和 Light 组件上的 Depth Bias / Normal Bias 设置。法线偏移乘了 1 - N·L:表面越是斜对光源,越容易出现 Acne,偏移就越大。偏移过大则会让阴影和物体“脱开”(Peter-Panning),这些调参问题留到第 6 章用对比图细讲。

一个真实的坑:LerpWhiteTo第一次写这个 Pass 时,我们只 include 了 Shadows.hlsl,结果编译报错:undeclared identifier 'LerpWhiteTo',位置在 Shadows.hlsl(298)。原因是 URP 14 的 Shadows.hlsl 用到了 core 包 CommonMaterial.hlsl 里的 LerpWhiteTo,却没有自己 include 它——URP 自带 Shader 不会遇到这个问题,因为它们的 LitInput.hlsl → SurfaceInput.hlsl 已经先引入了 CommonMaterial.hlsl。解决办法就是在 Shadows.hlsl 之前补一行 include,正如上面代码里那样。
图 3-12左:案例 3.6 的 Shader(没有 ShadowCaster),栅栏和球都不投影;右:案例 3.7,两者都有阴影,栅栏的阴影保留了镂空形状。两边的物体本身都不接收阴影——接收阴影需要在颜色 Pass 里采样阴影图,那是第 6 章的内容

DepthOnly Pass

Assets/Cookbook/Ch03/C03_07_ShadowDepth.shader(节选:depth)
// ---------------- 3. 深度 Pass:深度预渲染(Depth Prepass)时把物体写进 _CameraDepthTexture ----------------
Pass
{
    Name "DepthOnly"
    Tags { "LightMode"="DepthOnly" }
    ZWrite On
    ColorMask R          // URP 自带 Shader 的写法;深度写进深度缓冲,颜色通道基本用不到
    Cull [_Cull]

    HLSLPROGRAM
    #pragma vertex DepthVert
    #pragma fragment DepthFrag

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

    Varyings DepthVert (Attributes input)
    {
        Varyings output;
        output.positionCS = TransformObjectToHClip(input.positionOS.xyz);
        output.uv = TRANSFORM_TEX(input.uv, _BaseMap);
        return output;
    }

    half DepthFrag (Varyings input) : SV_Target
    {
        ClipByAlpha(input.uv);
        return input.positionCS.z;
    }
    ENDHLSL
}
// ---------------- 3. 深度 Pass:深度预渲染(Depth Prepass)时把物体写进 _CameraDepthTexture ----------------
Pass
{
    Name "DepthOnly"
    Tags { "LightMode"="DepthOnly" }
    ZWrite On
    ColorMask R          // URP 自带 Shader 的写法;深度写进深度缓冲,颜色通道基本用不到
    Cull [_Cull]

    GLSLPROGRAM
    VARYING vec2 uv;

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

    #ifdef FRAGMENT
    void main()
    {
        ClipByAlpha(uv);
        fragColor = vec4(gl_FragCoord.z);
    }
    #endif
    ENDGLSL
}

这个 Pass 只是把物体按相机的视角再画一遍、写进深度缓冲。它什么时候会被用到?URP 获取 _CameraDepthTexture 有两种方式:一是在画完不透明物体后,直接复制相机的深度缓冲(CopyDepth);二是在画不透明物体之前,用所有物体的 DepthOnly Pass 预先画一遍深度(Depth Prepass)。哪种情况走哪条路由 URP 根据平台能力、MSAA、Renderer Feature 的需求等决定。举例来说,屏幕空间环境光遮蔽(SSAO)需要在不透明物体之前就拿到深度,就会触发预渲染。

走复制路线时,没有 DepthOnly Pass 的物体照样在深度图里(因为它的颜色 Pass 写了深度缓冲);走预渲染路线时,它就消失了。为了把这一点拍下来,我们写了一个什么都不画、只“声明需要在不透明物体之前使用深度”的 Renderer Feature 来强制触发预渲染:

Assets/Cookbook/Ch03/DepthPrepassFeature.cs
// 演示用 Renderer Feature:声明“我在不透明物体之前就要用深度图”,从而让 URP 走深度预渲染(Depth Prepass)。
// 真实项目里 SSAO(Depth 源)、某些自定义效果都会这样触发 Depth Prepass。
// 这时 _CameraDepthTexture 由各物体的 DepthOnly Pass 画出来——没有 DepthOnly Pass 的物体就不在深度图里。
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;

namespace Cookbook.Ch03
{
    public class DepthPrepassFeature : ScriptableRendererFeature
    {
        class RequestDepthPass : ScriptableRenderPass
        {
            public RequestDepthPass()
            {
                renderPassEvent = RenderPassEvent.BeforeRenderingOpaques;
                ConfigureInput(ScriptableRenderPassInput.Depth);
            }
            // 什么都不画,只负责“提需求”
            public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) { }
        }

        RequestDepthPass m_Pass;

        public override void Create() { m_Pass = new RequestDepthPass(); }

        public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
        {
            renderer.EnqueuePass(m_Pass);
        }
    }
}
// 演示用 Renderer Feature(JS 版,对应 Ch03/DepthPrepassFeature.cs):
// 声明“我在不透明物体之前就要用深度图”,从而让 URP 走深度预渲染(Depth Prepass)。
// 这时 _CameraDepthTexture 由各物体的 DepthOnly Pass 画出来——没有 DepthOnly Pass 的物体就不在深度图里。
// C# 版的 Pass 本身什么都不画,只用 ConfigureInput(Depth) 提需求;网页引擎没有“按需求自动加 Prepass”的机制,
// 所以这里把 URP 因此插入的 DepthPrepass 一起做了:在 BeforeRenderingPrePasses 时把不透明物体的 DepthOnly Pass 画进一张深度图。
import { RenderPassEvent } from '../engine/features.js';

export default class DepthPrepassFeature {
  constructor(engine) { this.engine = engine; }

  addPasses(frame, enqueue) {
    const E = this.engine, gl = E.gl;
    enqueue(RenderPassEvent.BeforeRenderingPrePasses, (fr) => {
      const rt = E.rt('cookbook:depthPrepass', fr.width, fr.height, { color: null, depth: 'DEPTH24' });
      E.bindTarget(rt);
      gl.depthMask(true); gl.clearDepth(1); gl.clear(gl.DEPTH_BUFFER_BIT);
      fr.drawRenderers({ queue: 'opaque', lightModes: ['DepthOnly'] });
      E.setGlobal('_CameraDepthTexture', rt.depthTexture);
      // 有了 Prepass,URP 就不再在不透明物体之后 CopyDepth
      fr.requireDepth = false;
      fr.bindCamera();
    }, 'DepthPrepass');
  }
}

它利用了 URP 14 UniversalRenderer 里的规则:如果某个 Pass 通过 ConfigureInput(ScriptableRenderPassInput.Depth) 声明需要深度,并且它的事件早于不透明物体的绘制,就必须做深度预渲染。Renderer Feature 的写法第 25 章会系统讲。

图 3-13开启深度预渲染时的对比。左:正常画面,橙球用案例 3.6 的 Shader(无 DepthOnly),蓝球用案例 3.7;右:在相机前放一张面片,用调试 Shader Cookbook/Ch03/Debug/DepthView 把 _CameraDepthTexture 显示为灰度(越近越亮)——橙球在深度图里不存在

深度图里缺了物体,后果是所有依赖深度的效果都会对它“视而不见”:SSAO 在它周围不产生遮蔽,软粒子、水面的深度交界泡沫、第 18 章护盾的交接高亮都会穿帮。所以只要是不透明物体,就给它配上 DepthOnly Pass。另一个相关的 Pass 是 DepthNormals,它同时输出法线,供 SSAO 等效果使用(URP 的 SSAO 可选择从深度重建法线,也可以要求 DepthNormals),它需要顶点法线,放到第 6 章与光照一起写。

能不能直接复用 URP 的 Pass?有两种“偷懒”写法:一是 UsePass "Universal Render Pipeline/Lit/SHADOWCASTER"(Pass 名要大写),二是在自己的 Pass 里 #include ".../Shaders/ShadowCasterPass.hlsl"。两者都能工作,但都依赖 URP 的约定:属性必须叫 _BaseMap、_BaseColor、_Cutoff,镂空必须用 _ALPHATEST_ON 关键字,第二种还要求你按 SurfaceInput.hlsl 的方式声明贴图(它会自己声明 _BaseMap,你再声明一次就会报 redefinition of '_BaseMap')。你的 Shader 一旦加了顶点动画、换了 UV 来源,借来的 Pass 不会跟着变,阴影就和外形对不上了。本书统一手写,代码量也不过二十几行。