GPU Gems 3 · Chapter 10. Parallel-Split Shadow Maps on Programmable GPUs

从 shadow-map aliasing 出发,解释如何用平行深度分层、practical split、crop matrix 与可编程 GPU 路径,为大场景分配更合适的阴影采样密度。

学习目标

  • 能解释为什么相机近处与远处需要不同的 shadow-map sampling density,以及平行深度分层如何减少 perspective aliasing
  • 能修改 PSSM Design Lab 的 split 数量、practical split 权重、light projection 与硬件路径,观察画质和 rendering passes 的变化
  • 能回答:什么时候选择 multipass、DX9 单 pass 合成或 DX10 texture array,并说明 split、crop matrix 与 caster 范围如何共同决定结果

先让影子纹理匹配观察距离

想象一张地图覆盖整片城市:如果地图的每一格在市中心代表一米,在郊外也仍代表一米,市中心的细节会被挤成模糊的色块,郊外却浪费很多空白。相机观察阴影时也有同样的问题:近处表面在屏幕上占很多像素,远处表面只占很少像素,但一张统一的深度纹理很难同时照顾两者。

本章解决的问题是:怎样把固定数量的 shadow-map texels 分给一条很长的视线范围,让近处的锯齿减少,又不让每个深度层的额外渲染成本失控。如果只把整条范围塞进一张图,阴影边缘会随着距离出现明显的采样失配;如果盲目复制很多张图,画质可能变好,帧时间却先坏掉。

1. 一张 shadow map 为什么会把分辨率用错

标准 shadow mapping 的困难不是深度比较本身,而是屏幕像素与光空间 texel 的覆盖面积不匹配。近处的一个 texel 可能投到很长的屏幕表面,远处则可能有很多 texel 竞争很少的像素;这类 perspective aliasing 会让阴影边缘闪烁、锯齿或忽软忽硬。表面法线和光线方向还会带来 projection aliasing,但它需要场景几何信息,不能只靠分层消失。

parallel-split shadow map 用平行于 view plane 的 clip planes 把 view frustum 分成 V₀ ... Vₘ₋₁。每个 split 都计算自己的 light view-projection matrix,并以相同或可调的分辨率生成 Tᵢ。因为每张图只覆盖一段深度,view-space 的采样需求可以更接近 texture-space 的采样频率。

把一张失配的 shadow map,拆成多张对焦的地图近处像素需要更密采样,远处可以用更宽的视空间覆盖V0shadow map 0local densityV1shadow map 1local densityV2shadow map 2local densityV3shadow map 3local densitynearfarview-space zPSSM(4, res)T₀T₁T₂T₃每个 split 计算自己的 light view-projection,再只为该深度层采样对应的 shadow map
误差来自两把尺:视空间的像素与光空间的 texel 没对齐single shadow mapnear: one texel covers too much screenperspective aliasing rises as z gets smallparallel splitsnear split · densemiddle split · balancedfar split · wider更好匹配 view-space / texture-space frequency减少 aliasing,不需要映射奇点PSSM 主要缓解 perspective aliasing;投影 aliasing 仍与表面法线和光线方向有关

这里的关键不是“层数越多越好”。层数增加会减少每层覆盖范围,但也会增加 shadow-map 生成、绑定或 shader 选择的管理成本;PSSM 的设计目标是把误差和 pass 数放进同一个预算,而不是追求一个孤立的低 aliasing 指标。

2. practical split scheme:在近处与远处之间取平衡

uniform split 沿相机的近远距离等分,简单但会继续让近处欠采样。logarithmic split 更接近让 dz / (z ds) 保持稳定的理论目标,却可能把最靠近相机的层压得过短,导致里面几乎没有物体、分辨率反而被浪费。practical split 用权重 λ 混合两组位置:Cᵢ = λCᵢˡᵒᵍ + (1−λ)Cᵢᵘⁿⁱ,让应用可以在两种偏差之间移动。

在切分前先收紧 camera near/far plane 也很重要。空的深度范围会让每个 split 都把 shadow-map texel 花在不可见空间;收紧后再调 λ,才是在有效可见物体上分配分辨率。λ 不是一个跨镜头永远正确的常数:远近物体分布、相机速度和阴影质量目标都会改变它的合适位置。

uniform 与 logarithmic 都偏科,practical 取中间解near nfar fsplit positionuniformlogarithmicpractical λCᵢ = λCᵢˡᵒᵍ + (1−λ)Cᵢᵘⁿⁱλ≈0.5 常作为兼顾近处与远处的起点先收紧 near/far 平面,再按应用场景调 λ;不要把一套 split 位置当成所有相机的常数

3. 每个 split 都需要自己的 light projection

分好 view frustum 以后,还要为每个 Vᵢ 建立光源的子视锥 Wᵢ。最简单的 scene-independent 方法只在 light space 中包住 split frustum,逻辑稳定,却可能包含大量空白。scene-dependent 方法还找出可能投影到该 split 的 shadow casters 和 receivers,再把 light frustum 收紧。

常见的组合顺序可以写成 lightViewMatrix · lightProjMatrix · cropMatrixᵢ。crop matrix 是一个 off-center orthographic projection:它不改变光源的观察方向,只把当前 split 的有效边界缩放、平移到 shadow map 的可用区域。scene-dependent projection 提升了 texel 利用率,但也要求越界采样使用合适的 border color,并且不能把可能挡光的 caster 裁掉。

light frustum 也要对焦:包得松,texel 就被空白浪费scene-independentsimple · robust空白多,分辨率利用率低scene-dependent + cropcasters + receivers 收紧 Wᵢcrop matrix 放大有效区域越紧的投影越依赖 casters/receivers 的边界与 sampler border color 处理

4. 生成与合成:fragment 先决定自己属于哪一层

PSSM 的四个主步骤可以保持不变:切分 view frustum;为每个 split 计算 light transform;生成各自的 shadow map;在场景中合成阴影。实现差异主要出现在第三和第四步:shadow map 是逐层重复生成,还是由 GPU 一次提交复制到多个目标;场景阴影是每层单独绘制,还是由 shader 按 fragment 的 view-space z 选择地图。

在 DX9-level 的单 pass 合成里,fragment 不应该用 light-space depth 来决定 split,因为 split planes 是在 view space 中定义的。顶点阶段传出 view-space z 和每张地图的 texture coordinate,pixel shader 用 z 与 split end 比较,找到唯一的 i 后只采样 Tᵢ。这让场景 shadow synthesis 从每层一次变成一张场景 pass,但同时要付出多组 sampler 与 texture coordinate 的资源限制。

float zView = fragment.viewSpaceZ;
int i = firstSplitContaining(zView, splitEnds);
float shadowDepth = sample(T[i], fragment.shadowUv[i]);
float lit = depthTest(fragment.lightDepth[i], shadowDepth);
return applyShadow(lit);

这段伪代码只描述合成阶段的选择契约。multipass 仍然是最容易验证的基线;单 pass 路径减少了合成重复,却不能自动减少 shadow-map 生成的成本。

5. texture array 与可编程 GPU 的复制位置

DX10 的 render-target array 配合 geometry shader 可以让一个 primitive 被送往不同的 layer;shadow maps 可以放在一个 texture array 中,生成阶段只提交一次几何。DX9 则通常把每张地图作为独立纹理同时绑定,由 pixel shader 根据 view-space z 选择 sampler。两者不是同一硬件能力的简单换名:资源绑定、sampler 数量和可写 render-target layer 的限制不同。

DX10 路径还可以用 instancing 让 instance index 表示 split index,把 split-specific crop matrix 放到 vertex shader,再让 geometry shader 主要设置 render-target index。为了避免把每个 caster 无条件复制到所有层,可以为它记录 firstSplitlastSplit;连续包围体只会覆盖连续的 split 范围。

多张地图的代价,取决于 GPU 把“复制”放在哪里multipass无硬件加速render T₀ → shaderender T₁ → shaderender T₂ → shadepasses ∝ splitssingle-pass shadeDX9 programmablemaps stored separatelyview z → choose isample Tᵢ + depth testsamplers limit splitsarray + cloneDX10 programmabletexture2DArrayGS clone or instancerender-target arrayshadow maps once三种方案共享 split 与 crop 计算;差异在 shadow-map 生成和场景合成的 pass 如何被搬走
caster 不必复制到所有层:只记住它覆盖的连续范围split layersT0T1T2T3T4castersA: first=0, last=2B: first=1, last=4C: first=3, last=4连续包围体只会覆盖连续 split;firstSplit / lastSplit 足够驱动 geometry shader 的 layer 输出

6. 把画质、pass 和资源限制放进同一张账

PSSM 的方案选择不是一个固定排行榜。multipass 的优点是路径直观、容易调试,代价是生成和合成都按 split 重复;DX9 single-pass shade 可以把合成 pass 合并,但 sampler 数量和更复杂的 shader 会限制层数;DX10 texture array 加 geometry shader cloning 或 instancing 可以减少地图生成的重复,却需要新的资源视图、layer 输出和硬件能力。

同一套 split 也不一定要同一张分辨率。近层往往更值得保留高密度 shadow map,远层可以降低分辨率;point light 可以沿同样的思路使用,只是 light projection 与覆盖面需要重新核对。最终应同时记录近远 aliasing、shadow-map 内存、caster 绘制次数、fragment 采样成本和设备功能,而不是只比较一张截图。

三步验收:从分层到可编程阴影

分步1 / 3

第一步:把 view frustum 切成可解释的深度层

先收紧 near/far,再用 practical split scheme 在 uniform 和 logarithmic 分布之间选位置。每个 split 都应有清晰的深度边界和自己的 shadow-map 预算。

把一张失配的 shadow map,拆成多张对焦的地图近处像素需要更密采样,远处可以用更宽的视空间覆盖V0shadow map 0local densityV1shadow map 1local densityV2shadow map 2local densityV3shadow map 3local densitynearfarview-space zPSSM(4, res)T₀T₁T₂T₃每个 split 计算自己的 light view-projection,再只为该深度层采样对应的 shadow map
uniform 与 logarithmic 都偏科,practical 取中间解near nfar fsplit positionuniformlogarithmicpractical λCᵢ = λCᵢˡᵒᵍ + (1−λ)Cᵢᵘⁿⁱλ≈0.5 常作为兼顾近处与远处的起点先收紧 near/far 平面,再按应用场景调 λ;不要把一套 split 位置当成所有相机的常数

PSSM Design Lab

把 split 分配、light frustum 对焦和硬件路径分开调整,观察近远采样密度与 rendering passes 的取舍。

PSSM(3) · DX10 · independentV0T0V1T1V2T2near ← view-space z → far读数near density99far density74passes2coverage88%map reads / fragment 1 · split weight λ 50%趋势示意:真实帧率仍取决于 casters、分辨率与 GPU 功能

shader 选择 split 后可减少合成 pass;继续检查 sampler 或 texture-array 资源限制。

本章小结

  • PSSM 按 view-space depth 分层,让近远区域获得更匹配的 shadow-map sampling density。
  • practical split scheme 混合 uniform 与 logarithmic,并应先收紧 near/far plane。
  • 每个 split 都需要自己的 light transform;crop matrix 可提高有效 texel 利用率,但边界更敏感。
  • multipass、DX9 single-pass shade 与 DX10 array + clone 把重复成本放在不同管线阶段。
  • 评估方案时同时记录 aliasing、passes、sampler/array 资源、casters 与目标 GPU 能力。

练习

问题 1|分层直觉。 为什么 near/far 等距的 split 不一定是最好的?请说明 logarithmic split 的优势、实际缺点,以及 practical split 如何介于两者之间。

问题 2|修改 Demo 代码。 给 PSSM Design Lab 增加一个 splitEnds 预览:实现一个函数,输入 near、far、splitCount 和 λ,返回 uniform/logarithmic/practical 三组边界,并把 practical 边界画成竖线。你会怎样测试它没有把 near/far 空白也当成有效预算?

问题 3|方案选型。 一个旧 GPU 只能稳定执行多 pass,一个 DX9-level GPU 有足够 sampler 但没有 texture-array render target,一个 DX10 GPU 支持 geometry shader 和 render-target array。分别从 multipass、DX9 single-pass shade、DX10 array + clone 中选择,并指出每种路径仍需测什么。

名词解释

本章出现的专业名词,用大白话再讲一遍。

parallel-split shadow map
practical split scheme
crop matrix
texture array
geometry shader cloning

讨论

评论区加载中…