每日编程实践: Cascaded Shadow Maps CSM 级联阴影
Cascaded Shadow Maps — CSM 级联阴影
昨天实现了 PCSS 软阴影,解决了”阴影软化”的问题。今天面对另一个经典难题:大场景下的阴影精度。
问题是这样的:用一张 Shadow Map 覆盖整个场景,近处的阴影分辨率会严重不足——一个 Shadow Map 的纹素可能对应几十平方米的地面,阴影边缘会出现巨大的锯齿块;但如果把 Shadow Map 只覆盖近处,远处又会完全没有阴影。
CSM(Cascaded Shadow Maps,级联阴影映射) 的解法是:把视锥体按深度切成若干段,每段用一张独立的 Shadow Map,近处精细、远处粗略——跟 Mipmap 的思路如出一辙。
今天完整实现了 4 级 CSM,包含对数线性级联分割、每级独立正交投影和 PCF 软阴影。
效果展示
主渲染(含 CSM 阴影)

近景红球、中景紫球橙球、远景山丘都投射出软化阴影,地面阴影由近到远精度自然过渡。
级联区域可视化

颜色叠层:红色 = Cascade 0(最近) | 绿色 = Cascade 1 | 蓝色 = Cascade 2 | 黄色 = Cascade 3(最远)。可以清楚看到级联分界线和每段的覆盖范围。
Shadow Maps 调试图(4 级联 2×2 拼合)

从左上到右下分别是 Cascade 0~3 的深度图。亮色=近,暗色=远。近处级联(左上)覆盖范围小但包含完整球体细节,远处级联(右下)覆盖范围大但精度相对低。
第一章:为什么 Shadow Map 精度不够?
回顾 Shadow Map 的工作原理:把相机放到光源位置,渲染一张深度图(Shadow Map),正式渲染时查询深度图来判断遮挡。
精度问题的根源:
Shadow Map 的纹素分辨率是固定的(比如 512×512)。当 Shadow Map 要覆盖一个很大的场景(比如 200m × 200m),每个纹素就对应 200/512 ≈ 0.39m 的地面面积——这会导致阴影边缘出现明显的”像素块”状锯齿。
如果把 Shadow Map 缩小到只覆盖近处(比如 20m × 20m),近处阴影精度很好(每纹素 0.039m),但远处完全没有 Shadow Map 覆盖,全部无阴影。
透视投影加剧了问题:在透视相机下,屏幕上同样大小的一个像素,在近处对应很小的世界空间区域,在远处对应很大的区域——也就是说,近处像素需要高精度阴影,远处像素能接受低精度。
CSM 正是顺应了这个特点。
第二章:CSM 的核心思想——视锥体分割
CSM 把相机视锥体按深度分成 N 个子锥体(称为”级联”),每个级联独立生成一张 Shadow Map:
1 | 相机 ←─────────────────────────────── 近 → 远 |
- Cascade 0:最近的子锥体,Shadow Map 只覆盖近处一小片区域,精度极高
- Cascade 3:最远的子锥体,Shadow Map 覆盖整个远处,精度低但远处像素本身也小
渲染时,根据片元的深度选择对应的级联,采样该级联的 Shadow Map。
关键参数:如何分割视锥体?
第三章:级联分割——对数线性混合
视锥体分割有两种极端方案:
均匀分割:把 [near, far] 等距分成 N 段。问题是近处范围太大,精度浪费;远处积压太多。
对数分割:按对数比例分割,近处密(精度高)远处稀疏。适合透视相机的非线性深度分布。
实践中常用对数-线性混合,由参数 λ(0~1)控制比例:
$$z_i = \lambda \cdot z_{near} \cdot \left(\frac{z_{far}}{z_{near}}\right)^{i/N} + (1-\lambda) \cdot \left(z_{near} + \frac{i}{N}(z_{far}-z_{near})\right)$$
其中第一项是对数分割,第二项是线性分割。
1 | // 计算 N+1 个分割平面的深度值 |
对于本项目(near=0.5,far=100,λ=0.72,4级联),分割点是:
1 | 0.50 | 8.46 | 19.16 | 40.18 | 100.00 |
近处 8.46 单位,远处 60 单位——近处级联覆盖范围远小于远处。
第四章:每个级联的光照矩阵构建
知道了每个级联的深度范围,下一步是为每个级联构建光照相机(正交投影)。
步骤:
1. 提取子视锥体的 8 个角点
每个级联对应一个截锥台(frustum)——把完整视锥体在 [nearZ_i, farZ_i] 处截断。把截锥台的 8 个角点变换到世界空间:
1 | // 用子视锥体的透视矩阵(从 nearZ_i 到 farZ_i)生成 VP 矩阵 |
2. 在光照空间计算 AABB
把 8 个世界空间角点,用光源的 LookAt 矩阵变换到光照空间,找到包围盒:
1 | void buildLightMatrix(Cascade& casc, std::array<Vec3,8> corners, Vec3 lightDir) { |
⚠️ 最大的坑:Z 范围计算
今天调试最耗时的 bug 就在这里。原始代码用固定的 orthoNear=0.1, orthoFar=500,导致:
- 光源后退了 200 单位,场景物体的光照空间 Z ≈ 190~210
- 正交投影把 [0.1, 500] 映射到 NDC [-1,1],场景物体只占了 [0.37, 0.43] 这个窄区间
- Shadow Map 深度精度浪费 95%,PCF 比较时 bias 根本调不准
- 结果:几乎所有像素都判断为”不在阴影”
修复方法:从实际角点的光照空间 Z 计算正交投影范围,让场景占满整个深度范围。
修复前后对比:
1 | Cascade[0] 深度范围: |
第五章:Shadow Map 生成——正交投影光栅化
有了光照矩阵,就可以把场景三角形光栅化到 Shadow Map:
1 | void rasterizeShadowTri(const Tri& tri, Cascade& casc) { |
第六章:阴影采样——选择级联 + PCF
在主渲染时,对每个片元:
- 计算其视图空间深度
viewZ - 比较
splits[]数组找到对应的级联索引 - 把片元世界坐标投影到该级联的光照空间,采样 Shadow Map
- 用 PCF 做软阴影
1 | // 确定所在级联(基于视图空间 Z) |
bias 的两层细节:
0.0005的最小值防止在接近水平面(cosA≈1,法线垂直光线)时 bias 为 00.003 * (1 - cosA)让掠射角(光线几乎与面平行,cosA≈0)时 bias 更大——这些区域因光线很斜,投影的 Z 偏差最大,需要更大 bias 才能避免 Shadow Acne
第七章:重心坐标——光栅化核心公式
重心坐标是光栅化中判断”像素是否在三角形内”并做属性插值的核心。
对屏幕坐标为 $(x_0,y_0), (x_1,y_1), (x_2,y_2)$ 的三角形,像素点 $(px, py)$ 的重心坐标:
$$w_0 = \frac{(y_1-y_2)(px-x_2)+(x_2-x_1)(py-y_2)}{(y_1-y_2)(x_0-x_2)+(x_2-x_1)(y_0-y_2)}$$
$$w_1 = \frac{(y_2-y_0)(px-x_0)+(x_0-x_2)(py-y_0)}{(y_1-y_2)(x_0-x_2)+(x_2-x_1)(y_0-y_2)}$$
$$w_2 = 1 - w_0 - w_1$$
三个权重都 ≥ 0 时,点在三角形内。深度、颜色、法线等属性都用 $w_0 A + w_1 B + w_2 C$ 插值。
今天踩的坑:barycentric 公式里分子的 $(y_1-y_2)$ 和 $(x_2-x_1)$ 的符号顺序写错了——变成 $(y_2-y_1)$ 和 $(x_1-x_2)$,导致 w0 和 w1 的分子符号都反了,但分母符号不变,所有权重变成负值,所有像素被跳过,Shadow Map 完全空白。
这种 bug 编译不会报错,运行结果只是”啥都没渲染”,必须通过打印中间变量才能定位。
第八章:优化空间与工业实践
本实现的局限
无 Cascade 间过渡:级联切换处会出现精度跳变的硬边界。工业实现用混合区(在两个级联重叠范围内做线性插值)来平滑过渡。
Shadow Map 稳定性:相机移动时,Shadow Map 的像素对齐位置会抖动(texel snapping),导致阴影闪烁。修复方法:把光照矩阵对齐到 Shadow Map 纹素大小的整数倍。
固定级联数 4:实际游戏引擎通常 3~5 级联,配合动态调整分割参数。
无 CSM 边界模糊:本实现用固定 3×3 PCF,更好的做法是 PCSS + CSM 结合——PCF 半径根据光源大小和遮挡距离自适应调整。
工业级 CSM(以 UE4/UE5 为例)
- Dynamic Shadows:UE 默认用 CSM,4 个级联,分割参数可调
- Distance Field Shadows:超远处(数百米外)切换为 SDF 阴影,避免 Shadow Map 精度崩溃
- Cascaded PCF / PCSS:每个级联的软化程度不同(近处精细、远处粗糙)
- Temporal Shadow:用 TAA 的历史帧复用来降低 Shadow Map 采样数
Shadow Map 系列回顾
| 方法 | 优点 | 缺点 |
|---|---|---|
| Basic Shadow Map | 简单、快 | 近处精度差、aliasing |
| PCF | 软边缘 | 固定半径,性能代价高 |
| PCSS | 物理正确软阴影 | 更慢,采样数多 |
| CSM | 解决精度问题 | 级联切换抖动,需调参 |
| VSSM | 近似快速软阴影 | 漏光问题 |
| Ray-Traced Shadows | 最准确 | 需要 RT 硬件 |
量化验证
1 | Shadow Map 覆盖率(有效纹素比例): |
小结
CSM 的核心流程:
- 级联分割:对数-线性混合公式切割视锥体深度范围
- 子视锥体角点:用逆 VP 矩阵把 NDC 立方体反投影到世界空间
- 光照矩阵:为每个级联建独立正交投影,XY 紧包角点 AABB,Z 从实际角点深度计算
- Shadow Map 生成:用光照 VP 矩阵光栅化三角形,写入最小深度
- 采样:根据视图 Z 选级联,投影到光照空间,PCF 软化
最重要的教训:Z 范围一定要从实际场景 AABB 计算,不能用固定大值。一个 500 单位的正交远平面,把 20 单位深度范围的场景压缩到 4% 的深度精度,任何 bias 都无法正常工作。
完成时间: 2026-03-12 06:04
迭代次数: 8 次
编译器: g++ -std=c++17 -O2(0 错误 0 警告)











