Cascaded Shadow Maps — CSM 级联阴影

昨天实现了 PCSS 软阴影,解决了”阴影软化”的问题。今天面对另一个经典难题:大场景下的阴影精度

问题是这样的:用一张 Shadow Map 覆盖整个场景,近处的阴影分辨率会严重不足——一个 Shadow Map 的纹素可能对应几十平方米的地面,阴影边缘会出现巨大的锯齿块;但如果把 Shadow Map 只覆盖近处,远处又会完全没有阴影。

CSM(Cascaded Shadow Maps,级联阴影映射) 的解法是:把视锥体按深度切成若干段,每段用一张独立的 Shadow Map,近处精细、远处粗略——跟 Mipmap 的思路如出一辙。

今天完整实现了 4 级 CSM,包含对数线性级联分割、每级独立正交投影和 PCF 软阴影。


效果展示

主渲染(含 CSM 阴影)

CSM 主渲染

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

级联区域可视化

级联可视化

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

Shadow Maps 调试图(4 级联 2×2 拼合)

Shadow Maps

从左上到右下分别是 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
2
3
4
相机  ←─────────────────────────────── 近 → 远
│ Cascade0 │ Cascade1 │ Cascade2 │ Cascade3 │
近平面 ────────────────────────────────────────────────── 远平面
精度高,范围小 精度低,范围大
  • 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 计算 N+1 个分割平面的深度值
std::array<float, NUM_CASCADES+1> cascadeSplits(
float near, float far, float lambda = 0.75f)
{
std::array<float, NUM_CASCADES+1> splits;
splits[0] = near;
splits[NUM_CASCADES] = far;

float ratio = far / near;
for (int i = 1; i < NUM_CASCADES; i++) {
float p = (float)i / NUM_CASCADES;

// 对数分割:指数增长,密集覆盖近处
float logSplit = near * powf(ratio, p);
// 线性分割:均匀分布
float uniSplit = near + (far - near) * p;

// 混合:lambda=0.75 表示偏向对数分割
splits[i] = lambda * logSplit + (1 - lambda) * uniSplit;
}
return splits;
}

对于本项目(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
2
3
4
5
6
7
8
9
// 用子视锥体的透视矩阵(从 nearZ_i 到 farZ_i)生成 VP 矩阵
Mat4 subProj = perspective(55.0f, aspect, nearZ_i, farZ_i);
Mat4 subVP = subProj * camView;
Mat4 invSubVP = inverse(subVP); // 求逆,把 NDC 角点变回世界空间

// NDC 立方体的 8 个角点 (±1, ±1, ±1)
for each ndc_corner in {(-1,-1,-1), (1,-1,-1), ..., (1,1,1)} {
worldCorner = invSubVP.transformPoint(ndc_corner); // 反投影到世界
}

2. 在光照空间计算 AABB

把 8 个世界空间角点,用光源的 LookAt 矩阵变换到光照空间,找到包围盒:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
void buildLightMatrix(Cascade& casc, std::array<Vec3,8> corners, Vec3 lightDir) {
// 视锥体中心点(作为光源 LookAt 的目标)
Vec3 center = {0,0,0};
for (auto& c : corners) center += c;
center = center * (1.0f / 8);

// 光源位置:从中心沿光线方向后退 200 单位
Vec3 lightPos = center - lightDir * 200.0f;
casc.lightView = lookAt(lightPos, center, {0,1,0});

// 把 8 个角点变换到光照空间,计算 XY 包围盒
float minX=1e9f, maxX=-1e9f, minY=1e9f, maxY=-1e9f;
float minZ=1e9f, maxZ=-1e9f;
for (auto& c : corners) {
Vec3 lp = casc.lightView.transformPoint(c);
minX = min(minX, lp.x); maxX = max(maxX, lp.x);
minY = min(minY, lp.y); maxY = max(maxY, lp.y);
minZ = min(minZ, lp.z); maxZ = max(maxZ, lp.z);
}

// 用实际 Z 范围构造正交投影(右手系:Z 为负,需取反)
// 向后延伸 80 单位,捕获视锥体外的阴影投射体(如高山)
float orthoNear = -maxZ - 80.0f;
float orthoFar = -minZ + 10.0f;

casc.lightProj = orthoProj(minX, maxX, minY, maxY, orthoNear, orthoFar);
casc.lightVP = casc.lightProj * casc.lightView;
}

⚠️ 最大的坑: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
2
3
Cascade[0] 深度范围:
修复前:[0.3774, 0.3935](范围 0.016,极度压缩)
修复后:[0.7483, 0.8268](范围 0.079,5倍改善)

第五章:Shadow Map 生成——正交投影光栅化

有了光照矩阵,就可以把场景三角形光栅化到 Shadow Map:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
void rasterizeShadowTri(const Tri& tri, Cascade& casc) {
// 1. 把三角形三个顶点变换到光照裁剪空间
float cx[3], cy[3], cz[3], cw[3];
for (int i = 0; i < 3; i++)
casc.lightVP.transform4(tri.v[i], cx[i], cy[i], cz[i], cw[i]);

// 2. 正交投影 w=1,直接除法得 NDC
float nx[3], ny[3], nz[3];
for (int i = 0; i < 3; i++) {
float iw = 1 / cw[i];
nx[i] = cx[i] * iw; // NDC x ∈ [-1, 1]
ny[i] = cy[i] * iw; // NDC y ∈ [-1, 1]
nz[i] = cz[i] * iw; // NDC z ∈ [-1, 1],转 [0,1] 后存深度
}

// 3. NDC → Shadow Map 像素坐标
float sx[3], sy[3], sd[3];
for (int i = 0; i < 3; i++) {
sx[i] = (nx[i] * 0.5f + 0.5f) * S; // [0, S]
sy[i] = (ny[i] * 0.5f + 0.5f) * S; // [0, S](不翻转,光照空间Y向上)
sd[i] = nz[i] * 0.5f + 0.5f; // [0, 1],小=近
}

// 4. 重心坐标光栅化,每个像素写入最小深度
for each pixel (px, py) in bounding_box {
float w0, w1, w2 = barycentric_weights(px, py, sx, sy);
if (any_weight < 0) continue; // 在三角形外
float depth = w0*sd[0] + w1*sd[1] + w2*sd[2];
sm.set(px, py, depth); // 保留最小深度(最近物体)
}
}

第六章:阴影采样——选择级联 + PCF

在主渲染时,对每个片元:

  1. 计算其视图空间深度 viewZ
  2. 比较 splits[] 数组找到对应的级联索引
  3. 把片元世界坐标投影到该级联的光照空间,采样 Shadow Map
  4. 用 PCF 做软阴影
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 确定所在级联(基于视图空间 Z)
Vec3 vPos = camView.transformPoint(wPos);
float viewZ = -vPos.z; // 右手系,前向 = -Z,取反变正

int cascIdx = NUM_CASCADES - 1;
for (int c = 0; c < NUM_CASCADES; c++) {
if (viewZ >= splits[c] && viewZ < splits[c+1]) {
cascIdx = c;
break;
}
}

// 采样对应级联的 Shadow Map
auto& casc = cascades[cascIdx];
float lx, ly, lz, lw;
casc.lightVP.transform4(wPos, lx, ly, lz, lw);

if (lw > 0.001f) {
float u = lx/lw * 0.5f + 0.5f; // [0, 1]
float v = ly/lw * 0.5f + 0.5f; // [0, 1]
float d = lz/lw * 0.5f + 0.5f; // [0, 1],当前深度

if (u,v,d 都在 [0,1] 范围内) {
// 自适应 bias:法线越接近垂直于光线,bias 越大
float cosA = |wNorm · lightDir|;
float bias = max(0.0005f, 0.003f * (1 - cosA));

// 3×3 PCF
shadow = shadowPCF(casc.sm, u, v, d, bias);
}
}

bias 的两层细节

  • 0.0005 的最小值防止在接近水平面(cosA≈1,法线垂直光线)时 bias 为 0
  • 0.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)$,导致 w0w1 的分子符号都反了,但分母符号不变,所有权重变成负值,所有像素被跳过,Shadow Map 完全空白。

这种 bug 编译不会报错,运行结果只是”啥都没渲染”,必须通过打印中间变量才能定位。


第八章:优化空间与工业实践

本实现的局限

  1. 无 Cascade 间过渡:级联切换处会出现精度跳变的硬边界。工业实现用混合区(在两个级联重叠范围内做线性插值)来平滑过渡。

  2. Shadow Map 稳定性:相机移动时,Shadow Map 的像素对齐位置会抖动(texel snapping),导致阴影闪烁。修复方法:把光照矩阵对齐到 Shadow Map 纹素大小的整数倍。

  3. 固定级联数 4:实际游戏引擎通常 3~5 级联,配合动态调整分割参数。

  4. 无 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Shadow Map 覆盖率(有效纹素比例):
Cascade[0] (0.5~8.5m): 42.0% ✅
Cascade[1] (8.5~19m): 79.7% ✅
Cascade[2] (19~40m): 84.6% ✅
Cascade[3] (40~100m): 20.3% ✅(远处覆盖地面边缘稀疏)

深度范围分布(修复后):
Cascade[0]: [0.748, 0.827](范围 0.079,比修复前扩大 5×)
Cascade[1]: [0.690, 0.850](范围 0.160)
Cascade[2]: [0.612, 0.870](范围 0.258)
Cascade[3]: [0.639, 0.850](范围 0.211)

场景规模:14,276 个三角形(球体 + 地面 + 山丘锥体)
运行时间:< 1 秒
迭代次数:8 次(主要在 ortho Z 范围和 barycentric 符号上)

小结

CSM 的核心流程:

  1. 级联分割:对数-线性混合公式切割视锥体深度范围
  2. 子视锥体角点:用逆 VP 矩阵把 NDC 立方体反投影到世界空间
  3. 光照矩阵:为每个级联建独立正交投影,XY 紧包角点 AABB,Z 从实际角点深度计算
  4. Shadow Map 生成:用光照 VP 矩阵光栅化三角形,写入最小深度
  5. 采样:根据视图 Z 选级联,投影到光照空间,PCF 软化

最重要的教训:Z 范围一定要从实际场景 AABB 计算,不能用固定大值。一个 500 单位的正交远平面,把 20 单位深度范围的场景压缩到 4% 的深度精度,任何 bias 都无法正常工作。


完成时间: 2026-03-12 06:04
迭代次数: 8 次
编译器: g++ -std=c++17 -O2(0 错误 0 警告)

代码仓库: https://github.com/chiuhoukazusa/daily-coding-practice/tree/main/2026/03/03-12-csm-cascaded-shadow-maps