← ALL ARTICLES

Shadow Map

全库目录221
本文目录18

一块表面是否受光,取决于光源与它之间有没有遮挡。Shadow Map 把这个问题转成两份深度的比较:先从光源看场景,记录最近表面,再检查当前接收点是否位于它后面。比较得到可见性,材质与光源随后决定这部分光有多亮。

Shadow Map 流程

生成

从光源视角渲染场景,把最近深度写入 Shadow Map。方向光使用正交投影,Spot Light 常使用透视投影,Point Light 可以使用 Cubemap 或其他多面投影。

查询

把当前表面点变换到 Light Clip/Shadow Texture Space,得到:

  • Shadow UV;
  • 当前接收点深度 zrz_r。

读取 Shadow Map 的遮挡深度 zsz_s。在近处深度较小的常规 Z 约定下,如果 zrz_r 明显大于 zsz_s,说明光源和接收点之间有更近表面,当前点在阴影中。

过滤离散可见性

深度比较与过滤顺序

假设一个采样落在遮挡物边缘,两侧深度分别属于近处遮挡物和远处背景。直接插值得到的是一个现实中不存在的中间深度。拿它与接收深度比较,会产生错误可见性。

PCF 的做法是:

  1. 对邻域每个深度分别做比较;
  2. 得到 0/1 或比较结果;
  3. 过滤这些可见性结果。
V=1N∑i=1N[zr−bias≤zi]V=\frac{1}{N}\sum_{i=1}^{N}[z_r-bias\le z_i]

硬件 Comparison Sampler 可以对相邻 Texel 的比较结果做过滤,而不是先过滤原始深度。

PCF 的含义

Percentage-Closer Filtering 产生的是过滤后的可见比例。固定采样半径会得到近似固定宽度的柔边,但它主要是在抑制 Shadow Map 的离散锯齿,不是真正根据面积光源几何得到半影。

采样核越大:

  • 边缘越软;
  • 采样成本越高;
  • 阴影细节更容易丢失;
  • Bias 和漏光问题更明显。

Poisson Disk、旋转采样核和时域抖动可以减少规则图案,但会引入噪声或时间稳定性问题。

硬件比较采样器可以把深度比较和过滤组合起来。下面的 HLSL 代码执行一个 3x3 PCF 核,shadowCoord.xy 已在 [0,1][0,1],shadowCoord.z 与 Shadow Map 使用同一深度约定:

hlsl
float SampleShadowPCF(float3 shadowCoord, float bias)
{
    uint width, height;
    ShadowMap.GetDimensions(width, height);
    float2 texel = 1.0 / float2(width, height);
    float visibility = 0.0;

    for (int y = -1; y <= 1; ++y)
        for (int x = -1; x <= 1; ++x)
            visibility += ShadowMap.SampleCmpLevelZero(
                ShadowSampler,
                shadowCoord.xy + float2(x, y) * texel,
                shadowCoord.z - bias);

    return visibility / 9.0;
}

本段为HLSL机制函数,资源声明为 Texture2D<float> ShadowMap 与 SamplerComparisonState ShadowSampler。约定常规Z、清除深度1、比较 LESS_EQUAL,正bias从接收深度减去;反向Z需同步改变比较方向与偏置符号。线性比较采样的一次查询可比较相邻4个纹素,因此这里是9次查询,覆盖足迹会重叠,并非简单的9个独立点深度。光锥外、UV外和级联外的回退由调用端处理;边框颜色也要与受光约定一致。

PCSS

Percentage-Closer Soft Shadows 用三个步骤近似面积光半影。

1. Blocker Search

在接收点投影附近搜索深度小于 zrz_r 的样本。它们是可能挡住光源的 Blocker。

计算平均 Blocker 深度 zbz_b。没有找到 Blocker 时,接收点通常视为完全受光。

2. 估计 Penumbra

平行平面近似下,半影大小取决于接收点与遮挡物的间距:

wp∝wlzr−zbzbw_p\propto w_l\frac{z_r-z_b}{z_b}

wlw_l 是光源尺寸。接收面离 Blocker 越远,半影越宽。

具体公式需要根据光源投影和深度空间转换。不能直接把非线性 Shadow Depth 塞进比例式。

相似三角形推导。

它来自相似三角形,理解推导才能判断它何时失效。

把光源看作一个宽度为 wlw_l 的面光源,遮挡物是一块平行于光源的不透明平板,接收面也与之平行。沿光源两端各引一条经过遮挡物边缘的直线,它们在接收面上圈出的区域就是半影。

设光源到遮挡物的距离为 dbd_b、光源到接收面的距离为 drd_r,由相似三角形:

wpwl=dr−dbdb\frac{w_p}{w_l}=\frac{d_r-d_b}{d_b}

即:

wp=wl⋅dr−dbdbw_p=w_l\cdot\frac{d_r-d_b}{d_b}

如果 zrz_r、zbz_b 已还原成同一光源空间中的正线性距离,便得到前面的形式。方向光的角半径模型还需要单独推导,不能直接套用有限距离面光源的分母。分母是光源到遮挡物的距离,不是遮挡物到接收面的距离——这是容易记反的地方。

两个直接推论:遮挡物贴近接收面时 dr≈dbd_r\approx d_b,半影趋近于零,这就是接触硬化;遮挡物贴近光源时 dbd_b 很小,半影急剧放大。

适用前提。

推导用到了三个假设,实际场景中都可能不成立:

平行平面。遮挡物与接收面都被当作平行于光源的平面。倾斜的接收面上,半影宽度沿表面变化,单一半径无法准确表达。

单一遮挡深度。Blocker Search 得到的是平均深度。当一个像素同时被远近两个物体遮挡时,平均值可能落在两者之间,对应的半影对谁都不正确。

深度线性可比。比例式中的 zz 必须是线性距离。Shadow Map 存的通常是非线性深度,反向 Z 下方向还相反。必须先转换到线性空间,否则半影宽度会出现随距离系统性偏大或偏小的错误。

采样半径换算。

wpw_p 是世界空间的宽度,PCF 需要的是 Shadow Map 上的 UV 半径。转换依赖光源的投影方式:

正交投影(方向光)下,整张 Shadow Map 覆盖固定的世界范围 WW,转换是常数比例:

ruv=wp2Wr_{uv}=\frac{w_p}{2W}

这里的 wl,wpw_l,w_p 都是完整宽度,采样半径取半宽;若实现把 lightSize 定义为半径,则必须整套改用半径约定。有限距离的透视光源在接收深度 drd_r 处横向覆盖宽度为 W(dr)=2drtan⁡(θx/2)W(d_r)=2d_r\tan(\theta_x/2),所以 ruv=wp/[4drtan⁡(θx/2)]r_{uv}=w_p/[4d_r\tan(\theta_x/2)]。方向光应先由角半径与遮挡间距估计世界半影,再使用正交覆盖换算。

级联阴影中每一级的 WW 不同,同一个 wpw_p 在各级对应的 UV 半径也不同。这是级联过渡处软硬程度突变的常见原因——必须逐级换算,不能全局共用一个系数。

采样半径还需要设上限。半径过大时,固定的采样数会导致采样点过于稀疏,产生条带状噪声;此时应当同步增加采样数,或接受半影宽度被截断。

3. Variable-radius PCF

把估算半影转换为 Shadow Map UV 中的过滤半径,再执行 PCF。

PCSS 是 Contact-hardening Shadow 的近似。它仍会受到 Blocker Search 样本数、深度不连续、接收面角度和复杂遮挡关系影响。

Shadow Acne

由于深度精度、采样和表面斜率,接收面可能错误地与自己的 Shadow Depth 比较失败,出现条纹。

常见 Bias:

  • Constant Depth Bias;
  • Slope-scaled Bias;
  • Normal Bias;
  • Receiver Plane Depth Bias。

Bias 太小会 Acne,太大会 Peter Panning,让阴影与物体脚底分离。Normal Bias 还可能让薄物体漏光或改变阴影形状。

偏置改变的输入

普通Z下,真实接收深度0.5002与贴图0.5000比较会被判阴影;接收端减0.0003后成为0.4999,比较通过。若这份0.0002差值来自同一斜面的采样误差,偏置修复自阴影;若它来自真正贴近表面的遮挡物,相同偏置又会抹去接触阴影。

常量偏置给全部深度相同数值变化,投影深度的非线性使其世界距离效果随位置变化。斜率偏置根据深度相对屏幕的变化量增大偏移,照顾斜面跨纹素的误差。Normal Bias先沿法线移动世界接收点或投射几何,再投影;它也会移动UV,所以薄片轮廓和阴影形状可能改变。Receiver Plane方法利用接收面深度梯度,为每个PCF邻居估计该平面应有的深度,再比较残差,避免仅用中心深度解释整个核。实际API的深度偏置量化和符号需按后端核对。

阴影资源与投影方案

CSM

级联阴影贴图(Cascaded Shadow Maps,CSM)为方向光准备多张不同覆盖范围的 Shadow Map。近处 Cascade 覆盖小、世界空间 Texel 密度高;远处 Cascade 覆盖大,用较低精度维持可见距离。

单张方向光 Shadow Map 必须同时容纳相机附近和远处视锥。透视相机中,近处物体占屏幕面积大,却只得到和远处相同的光空间 Texel 密度,因此近景最容易出现锯齿和游动。

1. 分割相机视锥

设相机近远平面为 n,fn,f,Cascade 数量为 mm,第 ii 个分割位置可以使用均匀分割:

diuniform=n+(f−n)imd_i^{uniform}=n+(f-n)\frac{i}{m}

对数分割会把更多范围留给近处:

dilog=n(fn)i/md_i^{log}=n\left(\frac{f}{n}\right)^{i/m}

实际常用 Practical Split 在两者之间插值:

di=(1−λ)diuniform+λdilogd_i=(1-\lambda)d_i^{uniform}+\lambda d_i^{log}

λ\lambda 越大,近处 Cascade 越密。分割距离通常按正的观察空间深度保存。右手观察空间常用 d=−zviewd=-z_{view},不能直接拿非线性的 Depth Buffer 值比较。

2. 计算每层光空间矩阵

每个 [di,di+1][d_i,d_{i+1}] 都形成一个截断视锥。计算步骤是:

  1. 用该层的近远距离建立相机投影矩阵。
  2. 反投影 NDC 的八个角点到世界空间。
  3. 计算角点中心,建立方向光 View Matrix。
  4. 把八个角点变换到光空间。
  5. 对光空间角点求包围范围,建立正交投影。
  6. 沿光照 Z 方向扩展范围,容纳可能投影进层内的 Caster。

NDC 深度范围取决于 API。OpenGL 默认使用 [−1,1][-1,1],Direct3D 和常见 Vulkan 配置使用 [0,1][0,1]。反投影时必须与实际投影矩阵约定一致。

下面的伪代码表达矩阵构造的数据流:

cpp
Matrix BuildCascadeMatrix(float splitNear, float splitFar)
{
    Matrix sliceProj = Perspective(fov, aspect, splitNear, splitFar);
    Vector4 corners[8] = InverseProjectNdcCorners(sliceProj * view);

    Vector3 center = Average(corners);
    Matrix lightView = LookAt(center - lightDir * casterRange,
                              center, stableUp);
    Bounds bounds = FitLightSpaceBounds(corners, lightView);
    bounds.ExpandDepth(casterRange);

    Matrix lightProj = Orthographic(bounds.min, bounds.max);
    return lightProj * lightView;
}

casterRange 不能只按接收者视锥取值。视锥外的树或建筑仍可能把阴影投进视锥,过小会丢失阴影,过大则浪费深度精度。

3. 保存每层深度

各层可以使用独立纹理,也可以使用同尺寸、格式和 Mip 规则一致的 Texture Array。Array Layer 让 Shader 通过层索引采样,资源绑定更集中。

深度生成可以逐层提交 Draw,也可以使用 Layered Rendering。LearnOpenGL 的 CSM 示例用 Geometry Shader 的 gl_Layer 把图元写入不同 Array Layer。现代引擎也会通过多视图、实例化或逐层 Pass 完成,具体方式取决于 API、硬件和场景提交成本。

光空间矩阵数组适合放在 UBO 或 Constant Buffer。CPU 结构、Shader 布局和矩阵主次序必须一致。

4. 选择 Cascade

像素先计算正的观察空间深度,再找到包含它的分层区间。随后使用对应光空间矩阵和 Texture Array Layer:

glsl
int SelectCascade(vec3 positionWS)
{
    float viewDepth = -(view * vec4(positionWS, 1.0)).z;
    int layer = cascadeCount - 1;

    for (int i = 0; i < cascadeCount - 1; ++i) {
        if (viewDepth < cascadeSplits[i]) {
            layer = i;
            break;
        }
    }
    return layer;
}

这段代码假定右手观察空间中相机前方 Z 为负。引擎使用其他约定时,viewDepth 的符号需要同步调整。

5. 处理边界过渡

相邻 Cascade 的投影、Bias 和过滤结果不同。硬切换会在分割平面出现明显跳线。常见做法是在边界附近定义一段重叠区,同时采样相邻两层:

V=lerp(Vi,Vi+1,t)V=lerp(V_i,V_{i+1},t)

tt 由观察空间深度在过渡区中的位置得到。构建两层矩阵前,需要把各自的接收视锥扩展到整个混合带,并重新确定投射物覆盖与过滤边界;只有两份深度图都包含该接收区域,双层查询才有效。过渡越宽,双层采样区域越大;过窄则难以隐藏差异。两层的世界空间过滤半径也应接近,否则混合仍会看到软硬变化。

6. Bias 和过滤尺度

同样的 Shadow UV 偏移在不同 Cascade 中代表不同世界距离。每层需要按正交投影范围和纹理分辨率计算世界空间 Texel 尺寸,再据此调整:

  • Constant/Slope Bias;
  • Normal Bias;
  • PCF 或 PCSS 采样半径;
  • Cascade 过渡区宽度。

直接复用同一数值会让近层 Peter Panning,或让远层 Acne 和锯齿加重。

7. 稳定化和 Texel Snapping

紧致包围盒会随着相机旋转和移动持续改变,导致世界点映射到不同 Shadow Texel,画面出现阴影游动。稳定化通常包含:

  • 使用包围球或固定尺度确定 Cascade 的 XY 范围;
  • 把光空间投影中心吸附到 Shadow Texel 网格;
  • 固定方向光基底,避免 Up Vector 在临界方向翻转;
  • 对分割距离和投影范围保持确定性。

若正交投影宽度为 ww、阴影分辨率为 RR,一个 Texel 对应的光空间长度是:

stexel=wRs_{texel}=\frac{w}{R}

应先在固定方向光基底下表达世界坐标,再将XY投影中心除以 stexels_{texel}、取整后乘回去。若先让光源View跟随中心平移、再对已经接近零的局部中心取整,就失去了固定的吸附基准。吸附还可能把覆盖范围推移半个纹素,包围范围应留出相应余量。包围球通常更稳定,紧致 AABB 的分辨率利用率更高。工程实现需要在稳定性和有效分辨率之间取舍。

8. 成本

CSM 的主要成本来自:

  • 每个 Cascade 重复生成方向光深度;
  • Caster 剔除、命令生成和状态提交;
  • 多层深度纹理的显存和带宽;
  • 边界混合的额外采样;
  • PCF/PCSS 的采样核。

Cascade 数量、分辨率和更新频率应按平台与画面需求设置。远层可以降低更新频率或渐隐到烘焙阴影、距离场阴影等结果。

9. 调试

  • 用不同纯色显示 Cascade Index,检查层选择和过渡。
  • 单独显示 Texture Array 每一层的深度。
  • 绘制每层世界空间视锥和方向光正交包围盒。
  • 缓慢平移和旋转相机,观察边缘是否按整 Texel 移动。
  • 在边界放置斜面、细杆和远处高 Caster,检查 Bias、漏投影和跳变。
  • 记录每层 Draw、Caster 数量、分辨率和更新时间。

平面投影阴影

Shadow Map 能处理多种接收面和自阴影,但也要生成并查询深度资源。地面平整、阴影质量要求不高的场景中,平面投影阴影(Planar Projected Shadows)可以减少深度资源生成与查询,代价转为投影几何的额外绘制和覆盖率。

思路是:忽略自阴影,只求物体每个顶点沿光方向在地面上的投影位置,把网格压扁到平面上再用深色绘制一遍。

设指向光源的单位向量为 L\mathbf{L},投影沿 −L-\mathbf L,顶点位置为 M\mathbf{M},地面高度为 hh。沿光线走到 y=hy=h 需要的参数是:

t=My−hLyt=\frac{M_y-h}{L_y}

代入得到投影点:

Px=Mx−tLx,Pz=Mz−tLz,Py=hP_x=M_x-tL_x,\qquad P_z=M_z-tL_z,\qquad P_y=h

这就是相似三角形的直接结果,也可以写成一个 4×44\times4 投影矩阵在顶点着色器中统一应用。

它需要额外绘制投影几何,省去阴影深度图和相应查询。投影面与地面重合时仍需处理深度冲突,例如采用受控的几何偏移或深度偏置;实际成本由几何数量、覆盖率和模板操作决定。限制同样明确:只能投到一个平面上,地面起伏或有台阶时会穿帮;没有自阴影;多个投影重叠处颜色会叠加加深,需要用模板缓冲限制每像素只写一次。

LyL_y 接近零时 tt 趋于无穷;还需拒绝 t<0t<0 的反向投影。偏置、接收平面范围和模板位由宿主提供,额外绘制的实际成本仍取决于网格复杂度和覆盖面积。

方差阴影与虚拟阴影

方差阴影贴图(Variance Shadow Map,VSM)保存深度的一阶、二阶矩,用切比雪夫不等式估计受光概率。过滤后得到 μ=E[z]\mu=E[z] 与 m2=E[z2]m_2=E[z^2],取 σ2=max⁡(m2−μ2,ϵ)\sigma^2=\max(m_2-\mu^2,\epsilon)。当 zr≤μz_r\le\mu 时返回1,否则受光比例的上界为 σ2/[σ2+(zr−μ)2]\sigma^2/[\sigma^2+(z_r-\mu)^2]。这个上界可以超过真实可见比例,所以即使计算无误也可能漏光;压缩权重会减轻漏光,同时引入偏暗误差。矩数据可以线性过滤和预过滤,适合较软阴影,但数值精度仍需控制。EVSM 等变体通过指数变换增强分离度,同时更依赖精度和参数控制。

虚拟阴影贴图(Virtual Shadow Map,也简称 VSM)把高分辨率阴影空间切成 Page,只分配和更新当前视图需要的区域。它依赖虚拟地址映射、Page Cache、接收者驱动请求和失效规则,目标是让大世界和高密度几何保持细致阴影。

两个 VSM 指代完全不同:Variance 是基于深度矩的可过滤表示,Virtual 是稀疏分页与缓存方案。阅读资料或抓帧时要先确认上下文。

Point Light Shadow

点光源向所有方向发光,不能用单个透视投影覆盖。常见方案是以光源为中心建立六个 90 度视锥,把径向距离写入深度 Cubemap。

每个面使用相同近远平面,View Matrix 分别朝向正负 X、Y、Z。各面的上方向需符合所用 Cubemap 的面朝向约定,不能把同一个上方向原样用于所有面;尤其正负 Y 面必须选取与视线不平行的上方向。六面可以逐次渲染,也可以使用 Layered Rendering 写入 Cubemap Layer。

查询时用从光源到接收点的向量同时完成方向寻址和距离比较:

glsl
float PointShadow(vec3 positionWS)
{
    vec3 lightToPoint = positionWS - lightPositionWS;
    float receiverDistance = length(lightToPoint);
    float blockerDistance = textureLod(pointShadowMap, lightToPoint, 0.0).r
                          * shadowFar;
    return receiverDistance - shadowBias > blockerDistance
         ? 1.0 : 0.0;
}

这段GLSL函数返回遮挡比例,1表示阴影,与前面PCF返回受光比例的含义相反。宿主提供 samplerCube pointShadowMap,采用点过滤和固定第0级;源深度线性过滤会重现PCF一节指出的错误。写入端把线性径向距离除以 shadowFar,读取后乘回同一值。光源位置的零方向、超出有效距离及近裁剪范围由调用端处理。若资源保存投影深度,必须先按投影公式还原,不能直接与欧氏距离比较。

软化点光阴影时,可以在 lightToPoint 周围按一组球面方向偏移采样。采样半径应转换到世界空间或随接收距离调整。直接使用固定方向偏移会让近远处软化尺度不一致。

主要工程问题包括:

  • 六个面带来的 Caster 绘制、剔除和带宽;
  • 面边界的过滤接缝;
  • 近距离 Bias 和远距离精度;
  • 动态物体引起的缓存失效;
  • 大采样核的 Cubemap 纹理成本。

项目通常限制阴影点光数量、影响半径、分辨率和更新频率。静止灯光可以缓存静态 Caster,再单独更新动态层。Dual Paraboloid 等两面方案减少面数,同时带来投影畸变和接缝。

常见伪影定位

现象优先检查
表面条纹Depth/Slope Bias、精度、法线
阴影悬空Bias 过大、Normal Bias
边缘锯齿分辨率、投影覆盖、PCF、Cascade
相机移动抖动Cascade Stabilization、Texel Snapping
薄墙漏光Shadow Caster 面、Bias、厚度
PCSS 大块噪声Blocker Search、采样序列、深度转换

验证方法

  • 直接显示 Shadow Map 和接收点 Shadow UV。
  • 分别显示 zrz_r、zsz_s、比较结果和最终过滤值。
  • PCSS 单独显示 Blocker Count、Average Blocker Depth 和 Filter Radius。
  • 用平面、方块和可调面积光建立最小测试场景。
  • 在斜面、薄片、远距离和 Cascade 边界做压力测试。

test_gi.py 检查半影完整宽度到采样半径的换算,并用二点深度分布展示方差上界的漏光。正文 PCF 完成 DXC 编译,级联选择与点光查询函数经 glslangValidator 补齐宿主声明后完成 GLSL 330 编译,入口见 compile_gi.py;级联矩阵宿主、实际深度生成与GPU图像尚未运行。

相关主题

参考资料