阴影:shadow map、级联阴影、acne 与 bias
实时阴影的本质是一次「从光源视角的深度比较」。理解了这一点,阴影拉丝、悬浮、远处消失这些现象全都能解释,也知道该调哪个参数。这篇从原理到 Unity 的级联实现,再到采样代码与调参。
shadow map:光源视角的深度图
两步完成:
- 深度预通道:从光源视角渲染场景,只写深度(这就是每个投影物体要跑
ShadowCasterPass 的原因——它输出的SV_POSITION是光空间的裁剪位置,见 /notes/unity/principles-2/)。 - 光照通道:片元里把世界坐标变换到光空间,取 shadow map 里该纹素的深度
z_map,和自己的深度z_me比较:z_me > z_map + bias→ 在阴影里。
// 概念代码:手动阴影采样
float4 shadowCoord = TransformWorldToShadowCoord(positionWS);
float zMap = SAMPLE_TEXTURE2D(_MainLightShadowmapTexture, sampler_LinearClamp, shadowCoord.xy).r;
float zMe = shadowCoord.z;
float inShadow = step(zMe - bias, zMap); // 0 = 阴影
实际项目用引擎函数(见下文),但排查问题时要能画出这张图。
PCF:软阴影的第一层
shadow map 是离散的,硬比较会有锯齿。**PCF(Percentage Closer Filtering)**在周围取多个纹素分别比较,取「被遮挡比例」当阴影强度——硬件的线性过滤对深度值无效(深度不能插值比较),必须多次比较。软阴影质量 = 采样次数与半径,次数直接进性能账,见 /notes/unity/performance-1/。
级联(cascade):远处的精度从哪来
一张 shadow map 覆盖整个视锥,远处纹素对应世界尺寸巨大,阴影糊成一片。Unity 的方向光把视锥按距离切成 2/4 段(cascade),每段各渲染一张 shadow map,近处 map 覆盖小范围所以精度高。shader 侧引擎自动选 cascade(UnityGlobalShadowParams 里带 cascade 计数与切换距离),手写全自定义光照时才需要自己处理。
调参直觉:
- Shadow Distance(URP asset / Lighting 窗口):超过这个距离不画实时阴影(转烘焙或无阴影)。调小省性能,远处角色阴影消失。
- Cascade Count:4 → 2 省渲染开销,但 cascade 切换边界可能出现可见的清晰度跳变。
- Cascade splits:各段距离配比,近段占比越大近处越锐。
acne 与 peter-panning:bias 的两端
Shadow acne(拉丝条纹):深度比较在自遮挡曲面上因精度/纹素错位产生摩尔条纹——理论上物体遮挡自己。解法是 bias:比较时给 z_me 加一个小偏移。
Peter-panning(悬浮):bias 过大,阴影整体脱离物体,角色像浮在空中。
现代引擎的三件套:
- 固定 depth bias:小值,压掉大部分 acne。
- Normal offset(法线偏移):采样点沿表面法线挪一点再取深度——对斜面特别有效,比纯 depth bias 少悬浮。URP 灯光的「Bias / Normal Bias」就是这两个。
- Slope-scaled bias:按表面斜率放大 bias(斜面需要更大偏移)。
调参顺序:Normal Bias 先给(如 0.5–2),Depth Bias 尽量小(0.02–0.1 量级),出现悬浮先减 Depth Bias 而不是加。
URP 里的正确写法
// 片元里(URP,Lighting.hlsl)
Light mainLight = GetMainLight();
float4 shadowCoord = TransformWorldToShadowCoord(positionWS);
half shadowAtt = MainLightRealtimeShadow(shadowCoord); // 已含 PCF 与级联选择
float3 diffuse = lambert * mainLight.color * shadowAtt; // 衰减乘进光照
自定义 shader 要同时提供 ShadowCaster Pass(带 Tags { "LightMode"="ShadowCaster" })并加 #pragma multi_compile _ _MAIN_LIGHT_SHADOWS _MAIN_LIGHT_SHADOWS_CASCADE,否则引擎不会投影它 / 采样不到阴影——Pass 标签与 keyword 见 /notes/unity/engineering-1/、/notes/unity/engineering-3/。
阴影的其他代价与形态
- 附加灯阴影:每盏带阴影的实时灯多一遍深度渲染 + 片元多一次采样,移动端通常只给主光实时阴影。
- 接触阴影:脚下假阴影(blob shadow / 贴花)便宜且稳,角色游戏常用。
- 烘焙阴影:静态物体的阴影烘进 lightmap/shadowmask,零运行时成本,见 /notes/unity/lighting-4/。
自检三问
- 为什么线性过滤 shadow map 不能直接做软阴影,必须 PCF?
- 角色阴影和脚之间有明显缝隙,先调哪个参数?
- 自定义 shader 的物体不被投影,缺的两样东西是什么?