案例 23.1级联可视化调试 Shader入门
目标:调级联划分时,最直观的办法是直接看到每一级覆盖哪里。写一个调试 Shader,把 ComputeCascadeIndex 的结果涂成颜色,叠在简单光照上。
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 节观察屏幕空间阴影时还会用到。


级联的代价主要在 CPU 和顶点端:每一级都要单独剔除、单独提交一遍投射物。一个在 4 个级联里都可见的物体会被画 4 次。所以移动端常见的配置是 1~2 级联、阴影距离 30~60 米。Split 比例的经验是:让第一级刚好覆盖“玩家最关注的那一圈”(第三人称游戏里大约是角色周围几米到十几米),后面的级联逐级放大。Inspector 里把 Working Unit 切到 Metric,可以直接按米填写。
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 计算:
方向光的 frustumSize 取 2 / projection.m00,就是 23.1.1 节说的 2r。这说明 Inspector 上的 Bias 不是“米”,而是“纹素”:分辨率降低一半、阴影距离加倍,同样的 Bias 数值在世界里的偏移量会自动变大一倍,所以一般不需要随分辨率反复调。两个 Bias 的几何意义如下图:
你可能会预期把 Depth Bias 和 Normal Bias 都调到 0 就能看到满屏痤疮,实际在 URP 14 里多半看不到。原因在 ShadowUtils.RenderShadowSlice 的第一行:cmd.SetGlobalDepthBias(1.0f, 2.5f)。URP 渲染每一块阴影图时都打开了硬件的常量偏移和斜率偏移(注释里说数值与 HDRP 默认一致),斜面越陡,硬件偏移越大,已经替你挡掉了大部分痤疮。所以 Asset 上的 Bias 更多是在处理硬件偏移管不到的情况:低分辨率、远处级联、开了软阴影之后 PCF 采样到邻近纹素等。


开启软阴影后,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_LOW | 4 个固定偏移点取平均 | 4 |
| Medium | _SHADOWS_SOFT_MEDIUM | 5×5 帐篷滤波(Tent),SampleShadow_ComputeSamples_Tent_5x5 | 9 |
| High | _SHADOWS_SOFT_HIGH | 7×7 帐篷滤波,SampleShadow_ComputeSamples_Tent_7x7 | 16 |
帐篷滤波利用了硬件比较采样器自带的双线性:每次比较采样其实已经覆盖 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 要把这五个都声明上,否则软阴影会退化成硬阴影。

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