GameTech/URP 渲染实战

案例 23.7用 Renderer Feature 实现接触阴影高阶

目标:仿照 URP 自带的 Screen Space Shadows 的结构,写一个 Renderer Feature,在深度预渲染之后生成一张全屏的接触阴影纹理 _ContactShadowTexture;接收物体的 Shader 用屏幕 UV 读它,和阴影贴图的结果取最小值。Renderer Feature 的完整生命周期在第 25 章详细讲,这里关注阴影本身。

Assets/Cookbook/Ch23/ContactShadowFeature.cs(节选:pass)
class ContactShadowPass : ScriptableRenderPass
{
    static readonly int s_Params = Shader.PropertyToID("_ContactParams");
    static readonly int s_LightDir = Shader.PropertyToID("_ContactLightDir");
    readonly ProfilingSampler m_Sampler = new ProfilingSampler("ContactShadows");

    RTHandle m_Target;
    Material m_Material;
    Settings m_Settings;
    Vector3 m_LightDir;

    public void Setup(Settings s, Material material, Vector3 lightDir)
    {
        m_Settings = s; m_Material = material; m_LightDir = lightDir;
        // 声明需要深度纹理:URP 会因此安排 DepthPrepass,保证此时 _CameraDepthTexture 已就绪
        ConfigureInput(ScriptableRenderPassInput.Depth);
    }

    public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData)
    {
        var desc = renderingData.cameraData.cameraTargetDescriptor;
        desc.depthBufferBits = 0;
        desc.msaaSamples = 1;
        desc.graphicsFormat = RenderingUtils.SupportsGraphicsFormat(GraphicsFormat.R8_UNorm, FormatUsage.Linear | FormatUsage.Render)
            ? GraphicsFormat.R8_UNorm : GraphicsFormat.B8G8R8A8_UNorm;
        RenderingUtils.ReAllocateIfNeeded(ref m_Target, desc, FilterMode.Bilinear, TextureWrapMode.Clamp, name: "_ContactShadowTexture");
        cmd.SetGlobalTexture(m_Target.name, m_Target.nameID);
        ConfigureTarget(m_Target);
        ConfigureClear(ClearFlag.None, Color.white);
    }

    public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
    {
        // URP 14 里 renderingData.commandBuffer 是 internal,自己的 Feature 从池里借一个
        var cmd = CommandBufferPool.Get();
        using (new ProfilingScope(cmd, m_Sampler))
        {
            m_Material.SetVector(s_Params, new Vector4(m_Settings.length, m_Settings.steps, m_Settings.thickness, m_Settings.bias));
            m_Material.SetVector(s_LightDir, new Vector4(m_LightDir.x, m_LightDir.y, m_LightDir.z, m_Settings.intensity));
            // 源和目标是同一张图:Shader 根本不读 _BlitTexture,只借 Blitter 画一个全屏三角形
            Blitter.BlitCameraTexture(cmd, m_Target, m_Target, m_Material, 0);
        }
        context.ExecuteCommandBuffer(cmd);
        CommandBufferPool.Release(cmd);
    }

    public void Dispose() => m_Target?.Release();
}
// 案例 23.9:接触阴影(Contact Shadows)的网页版(对应 Ch23/ContactShadowFeature.cs)。
// 在深度预渲染之后,用一个全屏 Pass 从每个像素出发、沿主光方向在屏幕空间里步进几步,
// 检查有没有被深度缓冲里更靠前的表面挡住,结果写进一张 R8 的全屏纹理 _ContactShadowTexture。
// 接收物体的 Shader 再用屏幕 UV 读它,与阴影贴图的结果取最小值。
//
// 网页引擎没有 URP 那样“声明 ConfigureInput(Depth) 就自动安排 DepthPrepass”的机制,
// 所以这里自己先画一遍 DepthOnly,得到此时的 _CameraDepthTexture。
import { Material } from '../engine/shader.js';
import { RenderPassEvent } from '../engine/features.js';

export default class ContactShadowFeature {
  constructor(engine, data, scene) {
    this.engine = engine; this.scene = scene;
    const s = (data && data.settings) || {};
    this.settings = { length: s.length ?? 0.35, steps: s.steps ?? 16, thickness: s.thickness ?? 0.15, bias: s.bias ?? 0.01, intensity: s.intensity ?? 1 };
  }

  async init() {
    this.material = new Material(await this.engine.shaders.get('Cookbook/Ch23/ContactShadowPass'));
  }

  addPasses(frame, enqueue) {
    const L = frame.mainLight;
    if (!L) return;
    // 方向光的 forward 是光线前进方向,取反得到“指向光源”的 L
    const dir = [-L.forward[0], -L.forward[1], -L.forward[2]];
    // 与 URP 的屏幕空间阴影相同的时机:深度预渲染之后、不透明物体之前
    enqueue(RenderPassEvent.AfterRenderingPrePasses + 1, (fr) => this.execute(fr, dir));
  }

  execute(fr, L) {
    const gl = fr.gl, s = this.settings, W = fr.width, H = fr.height;
    // 1) 深度预渲染(URP 的 DepthPrepass)
    const depthRT = fr.getTemporaryRT('ContactDepthPrepass', W, H, { color: 'R8', depth: 'DEPTH24' });
    this.engine.bindTarget(depthRT);
    gl.depthMask(true); gl.colorMask(true, true, true, true);
    gl.clearColor(1, 1, 1, 1); gl.clearDepth(1);
    gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
    fr.drawRenderers({ queue: 'opaque', lightModes: ['DepthOnly'] });
    fr.setGlobal('_CameraDepthTexture', depthRT.depthTexture);

    // 2) 全屏步进,写 _ContactShadowTexture
    const target = fr.getTemporaryRT('_ContactShadowTexture', W, H, { color: 'R8' });
    this.material.setVector('_ContactParams', [s.length, s.steps, s.thickness, s.bias]);
    this.material.setVector('_ContactLightDir', [L[0], L[1], L[2], s.intensity]);
    fr.blit(null, target, this.material, 0);
    fr.setGlobal('_ContactShadowTexture', target.texture);
    fr.bindCamera();
  }
}

几个要点:ConfigureInput(ScriptableRenderPassInput.Depth) 声明这个 Pass 需要深度纹理,并且事件早于不透明物体,URP 会因此安排一次深度预渲染,保证 Pass 执行时 _CameraDepthTexture 已经是本帧的深度。OnCameraSetup 里用 RenderingUtils.ReAllocateIfNeeded 按相机尺寸分配 R8 纹理并设为全局纹理。URP 14 里 RenderingData.commandBuffer 是 internal 的,自己的 Feature 要从 CommandBufferPool 借命令缓冲。主光方向从 visibleLights[mainLightIndex].localToWorldMatrix 的第三列取:方向光的 forward 是光线前进方向,取反就是“指向光源”的 L。

Assets/Cookbook/Ch23/ContactShadowPass.shader(节选:march)
half Frag(Varyings input) : SV_Target
{
    float2 uv = input.texcoord;
    float deviceDepth = SampleSceneDepth(uv);
#if UNITY_REVERSED_Z
    if (deviceDepth <= 0.0) return 1;              // 天空:不处理
#else
    if (deviceDepth >= 1.0) return 1;
    deviceDepth = deviceDepth * 2.0 - 1.0;
#endif
    // 1) 由深度重建这个像素的世界坐标
    float3 positionWS = ComputeWorldSpacePosition(uv, deviceDepth, UNITY_MATRIX_I_VP);

    int   steps     = (int)_ContactParams.y;
    float stepLen   = _ContactParams.x / _ContactParams.y;
    float thickness = _ContactParams.z;
    float3 L = _ContactLightDir.xyz;

    // 2) 用交错梯度噪声抖动起点,把条带状走样变成细噪点(软阴影 / TAA 会再把它抹平)
    float jitter = InterleavedGradientNoise(uv * _ScreenSize.xy, 0);
    float3 p = positionWS + L * (_ContactParams.w + stepLen * jitter);

    half occlusion = 0;
    [loop] for (int i = 0; i < steps; i++)
    {
        p += L * stepLen;
        // 3) 把采样点投回屏幕,读那里的场景深度
        float4 positionCS = TransformWorldToHClip(p);
        float2 sampleUV = ComputeNormalizedDeviceCoordinates(p, UNITY_MATRIX_VP);
        if (any(sampleUV < 0) || any(sampleUV > 1)) break;

        float sceneEyeDepth  = LinearEyeDepth(SampleSceneDepth(sampleUV), _ZBufferParams);
        float sampleEyeDepth = positionCS.w;           // 透视投影下 w 就是观察空间深度
        float delta = sampleEyeDepth - sceneEyeDepth;  // > 0:采样点在可见表面之后

        // 4) 在表面之后、但又没“穿透”太深 → 光线被这块表面挡住了
        if (delta > 0.002 && delta < thickness)
        {
            // 离起点越远的遮挡越弱,避免接触阴影末端出现硬边
            occlusion = 1.0 - (float)i / steps;
            break;
        }
    }
    return 1.0 - occlusion * _ContactLightDir.w;
}
float Frag()
{
    vec2 uv = texcoord;
    float deviceDepth = SampleSceneDepth(uv);
    // WebGL 深度不反转:远平面是 1
    if (deviceDepth >= 1.0) return 1.0;            // 天空:不处理
    // 1) 由深度重建这个像素的世界坐标(GLSL 版 ComputeWorldSpacePosition 内部已把 0~1 深度换成 -1~1)
    vec3 positionWS = ComputeWorldSpacePosition(uv, deviceDepth, UNITY_MATRIX_I_VP);

    int   steps     = int(_ContactParams.y);
    float stepLen   = _ContactParams.x / _ContactParams.y;
    float thickness = _ContactParams.z;
    vec3 L = _ContactLightDir.xyz;

    // 2) 用交错梯度噪声抖动起点,把条带状走样变成细噪点(软阴影 / TAA 会再把它抹平)
    float jitter = InterleavedGradientNoise(uv * _ScreenParams.xy, 0);
    vec3 p = positionWS + L * (_ContactParams.w + stepLen * jitter);

    float occlusion = 0.0;
    for (int i = 0; i < steps; i++)
    {
        p += L * stepLen;
        // 3) 把采样点投回屏幕,读那里的场景深度
        vec4 positionCS = TransformWorldToHClip(p);
        vec2 sampleUV = ComputeNormalizedDeviceCoordinates(p, UNITY_MATRIX_VP);
        if (any(lessThan(sampleUV, vec2(0.0))) || any(greaterThan(sampleUV, vec2(1.0)))) break;

        float sceneEyeDepth  = LinearEyeDepth(SampleSceneDepth(sampleUV), _ZBufferParams);
        float sampleEyeDepth = positionCS.w;           // 透视投影下 w 就是观察空间深度
        float delta = sampleEyeDepth - sceneEyeDepth;  // > 0:采样点在可见表面之后

        // 4) 在表面之后、但又没“穿透”太深 → 光线被这块表面挡住了
        if (delta > 0.002 && delta < thickness)
        {
            // 离起点越远的遮挡越弱,避免接触阴影末端出现硬边
            occlusion = 1.0 - float(i) / float(steps);
            break;
        }
    }
    return 1.0 - occlusion * _ContactLightDir.w;
}

步进的每一步把世界坐标投回屏幕:ComputeNormalizedDeviceCoordinates 内部处理了 UNITY_UV_STARTS_AT_TOP 的翻转,与重建世界坐标用的 ComputeWorldSpacePosition 互为逆运算。比较深度用的是观察空间线性深度:采样点的深度直接取裁剪坐标的 w(透视投影下 w 就是观察空间深度),场景深度用 LinearEyeDepth 从深度缓冲换算。“厚度”参数是对深度缓冲局限的妥协:深度缓冲只知道最前面那层表面,不知道它有多厚,采样点在表面后方太远时多半是从物体背后穿过,不应算遮挡。起点用交错梯度噪声(Interleaved Gradient Noise)抖动,把步进造成的条带变成细噪点;遮挡强度随步数衰减,避免接触阴影末端出现一道硬边。

Assets/Cookbook/Ch23/ContactShadowLit.shader(节选:frag)
half4 frag(Varyings i) : SV_Target
{
    half3 albedo = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, i.uv).rgb * _BaseColor.rgb;
    half3 n = normalize(i.normalWS);
    Light l = GetMainLight(TransformWorldToShadowCoord(i.positionWS), i.positionWS, half4(1, 1, 1, 1));

    half contact = 1;
    if (_UseContact > 0.5)
    {
        float2 screenUV = GetNormalizedScreenSpaceUV(i.positionCS);
        contact = SAMPLE_TEXTURE2D(_ContactShadowTexture, sampler_LinearClamp, screenUV).r;
    }
    if (_ShowContact > 0.5) return half4(contact.xxx, 1);

    half shadow = min(l.shadowAttenuation, contact);
    half3 c = albedo * (l.color * saturate(dot(n, l.direction)) * shadow + SampleSH(n));
    return half4(c, 1);
}
vec4 frag()
{
    vec3 albedo = texture(_BaseMap, uv).rgb * _BaseColor.rgb;
    vec3 n = normalize(normalWS);
    Light l = GetMainLight(TransformWorldToShadowCoord(positionWS), positionWS, vec4(1, 1, 1, 1));

    float contact = 1.0;
    if (_UseContact > 0.5)
    {
        vec2 screenUV = GetNormalizedScreenSpaceUV(gl_FragCoord);
        contact = texture(_ContactShadowTexture, screenUV).r;
    }
    if (_ShowContact > 0.5) return vec4(vec3(contact), 1);

    float shadow = min(l.shadowAttenuation, contact);
    vec3 c = albedo * (l.color * saturate(dot(n, l.direction)) * shadow + SampleSH(n));
    return vec4(c, 1);
}
图 23-19接触阴影(512 分辨率、60 米阴影距离,模拟“阴影贴图照顾不到小物体”)。左:只有阴影贴图,小石子的影子是一团模糊的灰,几乎看不出;中:加上接触阴影,每个小物体脚下都有清晰的影子,物体“落地”了;右:接触阴影纹理本身

局限与调参:接触阴影只能看到屏幕上可见的表面,屏幕边缘外和被遮住的物体不会投影;物体背后(深度缓冲看不到的那一面)用厚度猜测,厚度太大会出现“影子拖尾”,太小会让薄物体漏光。追踪长度一般控制在几十厘米以内,只用来补阴影贴图的近距离细节。性能上,它是每像素 N 次深度采样的全屏 Pass,步数 16~24 在桌面端开销不大;移动端可以降到 8 步,或者用半分辨率渲染再双线性放大。HDRP 内置了更完整的 Contact Shadows(还支持附加光),URP 14 没有内置,本案例提供的是一个可以直接用的起点。