GameTech/URP 渲染实战

案例 23.1级联可视化调试 Shader入门

目标:调级联划分时,最直观的办法是直接看到每一级覆盖哪里。写一个调试 Shader,把 ComputeCascadeIndex 的结果涂成颜色,叠在简单光照上。

Assets/Cookbook/Ch23/ShadowDebug.shader(节选:frag)
half4 frag(Varyings i) : SV_Target
{
    float4 shadowCoord = TransformWorldToShadowCoord(i.positionWS);
    half shadow = MainLightRealtimeShadow(shadowCoord);
    if (_Mode > 0.5) return half4(shadow.xxx, 1);

    half3 n = normalize(i.normalWS);
    Light l = GetMainLight();
    half3 shade = (saturate(dot(n, l.direction)) * shadow * 0.8 + 0.25).xxx;
    #if defined(_MAIN_LIGHT_SHADOWS_CASCADE)
        half cascade = ComputeCascadeIndex(i.positionWS);   // 0..3,超出所有级联球为 4
    #else
        half cascade = 0;
    #endif
    static const half3 kColors[5] =
    {
        half3(1.0, 0.35, 0.3), half3(0.35, 0.9, 0.4), half3(0.35, 0.55, 1.0),
        half3(1.0, 0.85, 0.3), half3(0.6, 0.6, 0.6)
    };
    half3 tint = lerp(half3(1, 1, 1), kColors[(int)cascade], _Tint);
    return half4(shade * tint, 1);
}
vec4 frag()
{
    vec4 shadowCoord = TransformWorldToShadowCoord(positionWS);
    float shadow = MainLightRealtimeShadow(shadowCoord);
    if (_Mode > 0.5) return vec4(vec3(shadow), 1);

    vec3 n = normalize(normalWS);
    Light l = GetMainLight();
    vec3 shade = vec3(saturate(dot(n, l.direction)) * shadow * 0.8 + 0.25);
    // _MAIN_LIGHT_SHADOWS_CASCADE 关键字在网页版是个 uniform(级联数 > 1 时为 1)
    float cascade = _MainLightShadowsCascade > 0.5 ? ComputeCascadeIndex(positionWS) : 0.0;   // 0..3,超出所有级联球为 4
    vec3 kColors[5] = vec3[5](
        vec3(1.0, 0.35, 0.3), vec3(0.35, 0.9, 0.4), vec3(0.35, 0.55, 1.0),
        vec3(1.0, 0.85, 0.3), vec3(0.6, 0.6, 0.6)
    );
    vec3 tint = mix(vec3(1, 1, 1), kColors[int(cascade)], _Tint);
    return vec4(shade * tint, 1);
}

有两点要注意。第一,ComputeCascadeIndex 只在 _MAIN_LIGHT_SHADOWS_CASCADE 开启时才有意义:URP 在级联数为 1 时开的是 _MAIN_LIGHT_SHADOWS,此时 _CascadeShadowSplitSpheres* 不会被正确填写,所以要用 #if 包起来。第二,这个 Shader 同时提供了 _Mode = 1 的“只看阴影衰减”模式,23.7 节观察屏幕空间阴影时还会用到。

图 23-4级联可视化(阴影距离 60 米,默认 Split 比例)。左:1 级联,全部是级联 0(红);中:2 级联,近处红、远处绿;右:4 级联,红/绿/蓝/黄依次向远处排开。注意 4 级联时第一级(红)只覆盖相机脚下很小一块
图 23-5同样 2048 分辨率、60 米阴影距离,近处栅栏的阴影质量。左:1 级联,栅栏影子糊成灰带;中:2 级联,能看出条纹;右:4 级联,条纹清晰。代价是阴影 Pass 要渲染 4 次投射物

级联的代价主要在 CPU 和顶点端:每一级都要单独剔除、单独提交一遍投射物。一个在 4 个级联里都可见的物体会被画 4 次。所以移动端常见的配置是 1~2 级联、阴影距离 30~60 米。Split 比例的经验是:让第一级刚好覆盖“玩家最关注的那一圈”(第三人称游戏里大约是角色周围几米到十几米),后面的级联逐级放大。Inspector 里把 Working Unit 切到 Metric,可以直接按米填写。

提示级联之间默认是硬切换的,在级联交界处可能看到阴影清晰度突变。URP 14 没有提供级联间混合,只有最后一级到“无阴影”的 Last Border 淡出。如果交界线在你的场景里很显眼,可以适当提高分辨率让两级精度差小一些,或者把交界放在玩家不常注视的距离。

23.1.4 Depth Bias 与 Normal Bias

阴影映射的本质误差来自“一个纹素只存一个深度,却代表一整块斜面”。接收端把像素深度和纹素深度比较时,同一个表面的不同部分会时而在前、时而在后,形成条纹状的自阴影,这就是阴影痤疮(Shadow Acne)。解决办法是给比较加一个容差,URP 把它放在投射端,在 ShadowCaster 的顶点着色器里移动顶点:

// 摘自 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 由 C# 端 ShadowUtils.GetShadowBias 计算:

bdepth=−DepthBias×texelbnormal=−NormalBias×texeltexel=frustumSizeN

方向光的 frustumSize 取 2 / projection.m00,就是 23.1.1 节说的 2r。这说明 Inspector 上的 Bias 不是“米”,而是“纹素”:分辨率降低一半、阴影距离加倍,同样的 Bias 数值在世界里的偏移量会自动变大一倍,所以一般不需要随分辨率反复调。两个 Bias 的几何意义如下图:

顶点 P Depth Bias:沿光线方向(−L)推离光源 N Normal Bias:沿 −N 收缩(×(1−N·L)) 光线方向 Bias 太小 → 阴影痤疮(条纹自阴影) Depth Bias 太大 → 彼得潘(影子脱离   接触点,物体像飘起来) Normal Bias 太大 → 影子变瘦,   细杆的影子断开、漏光
图 23-6两种 Bias 的几何意义。Depth Bias 把投射体整体推离光源;Normal Bias 把投射体沿法线往里缩,缩的量随表面与光线的夹角增大

你可能会预期把 Depth Bias 和 Normal Bias 都调到 0 就能看到满屏痤疮,实际在 URP 14 里多半看不到。原因在 ShadowUtils.RenderShadowSlice 的第一行:cmd.SetGlobalDepthBias(1.0f, 2.5f)。URP 渲染每一块阴影图时都打开了硬件的常量偏移和斜率偏移(注释里说数值与 HDRP 默认一致),斜面越陡,硬件偏移越大,已经替你挡掉了大部分痤疮。所以 Asset 上的 Bias 更多是在处理硬件偏移管不到的情况:低分辨率、远处级联、开了软阴影之后 PCF 采样到邻近纹素等。

图 23-7Depth Bias 对比(512 分辨率、硬阴影、Normal Bias = 0、低角度逆光)。左:0;中:1;右:10(最大值)。0 时并没有明显痤疮(硬件斜率偏移在起作用);10 时影子整体向远离光源方向错开,方块和球的影子与接触点之间出现缝隙,这就是“彼得潘”现象
图 23-8Normal Bias 对比(Depth Bias = 1)。左:0;中:1;右:4。Normal Bias 增大后,球的影子明显缩小,细栅栏的影子变细甚至断续。细小物体(栏杆、树枝、头发片)对 Normal Bias 最敏感

开启软阴影后,GetShadowBias 还会把两个 Bias 再乘以 PCF 核的半径(Medium 为 2.5、High 为 3.5,代码注释说明这是“采样不止一个纹素远”的粗略补偿)。所以从硬阴影切到软阴影,你会看到影子稍微“瘦”一点、离接触点稍远一点,这是预期行为。另外点光源的 Normal Bias 在 URP 里被固定为 0,与内置管线的约定相同。

调 Bias 的顺序建议:先用默认的 1/1;出现痤疮先加 Depth Bias,每次 0.5;物体脚下影子脱开就回退 Depth Bias、改加一点 Normal Bias;细物体影子断开就降低 Normal Bias。某盏灯需要特殊处理时,在 Light 组件上把 Bias 切到 Custom,不要为了一盏灯改全局。

23.1.5 软阴影与质量档位

硬阴影对阴影图只采样一次(硬件比较采样器自带 2×2 双线性比较,边缘有一个纹素宽的过渡)。软阴影则在周围多采样几次再加权平均,这就是 PCF(Percentage Closer Filtering)。URP 14 提供三档:

档位静态分支关键字采样方式比较采样次数
Low_SHADOWS_SOFT_LOW4 个固定偏移点取平均4
Medium_SHADOWS_SOFT_MEDIUM5×5 帐篷滤波(Tent),SampleShadow_ComputeSamples_Tent_5x59
High_SHADOWS_SOFT_HIGH7×7 帐篷滤波,SampleShadow_ComputeSamples_Tent_7x716

帐篷滤波利用了硬件比较采样器自带的双线性:每次比较采样其实已经覆盖 2×2 个纹素,再配合精心计算的采样位置和权重,9 次就能得到 5×5 帐篷核的结果。关键字方面,URP 14 的 Lit.shader 声明的是 multi_compile_fragment _ _SHADOWS_SOFT _SHADOWS_SOFT_LOW _SHADOWS_SOFT_MEDIUM _SHADOWS_SOFT_HIGH。至于 URP 实际打开哪一个,要看 ShadowUtils.SetSoftShadowQualityShaderKeywords:在绝大多数平台上 SupportsPerLightSoftShadowQuality() 返回 true,URP 只打开 _SHADOWS_SOFT,在 SampleShadowmapFiltered 里按每盏灯的档位(_MainLightShadowParams.y)做动态分支,所以每盏灯可以有不同档位;只有移动 VR 平台(源码注释提到 Quest 2 与 HoloLens)出于性能考虑改用 _SHADOWS_SOFT_LOW/MEDIUM/HIGH 静态分支,按 Asset 的档位统一处理。自己写的 Shader 要把这五个都声明上,否则软阴影会退化成硬阴影。

图 23-9软阴影质量(1024 分辨率特写)。从左到右:硬阴影、Low、Medium、High。硬阴影能看到台阶状锯齿;Low 只是把锯齿稍微抹开;Medium 和 High 的边缘平滑,High 的半影更宽

注意 URP 的软阴影是固定宽度的滤波,半影宽度不随遮挡物与接收面的距离变化,物理上并不正确(真实阴影离接触点越远越虚)。它的价值是“抗锯齿”。如果需要随距离变化的软阴影,要用 PCSS 一类的方法,URP 14 没有内置。移动端建议用 Low 或 Medium,并把它和分辨率一起权衡:1024 + Medium 往往比 2048 + 硬阴影观感更好、也更省。