阴影:shadow map、级联阴影、acne 与 bias

Unity 知识 · Shader 区域 · 光照与材质 · 第 5 篇 更新于 2026-10-15

实时阴影的本质是一次「从光源视角的深度比较」。理解了这一点,阴影拉丝、悬浮、远处消失这些现象全都能解释,也知道该调哪个参数。这篇从原理到 Unity 的级联实现,再到采样代码与调参。

shadow map:光源视角的深度图

两步完成:

  1. 深度预通道:从光源视角渲染场景,只写深度(这就是每个投影物体要跑 ShadowCaster Pass 的原因——它输出的 SV_POSITION 是光空间的裁剪位置,见 /notes/unity/principles-2/)。
  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 的物体不被投影,缺的两样东西是什么?