CSM级联阴影
需要预先学习:
1. CSM 级联阴影
ShadowMap与PCF、PCSS 中实现了单张阴影贴图:一张正交投影的深度图罩住整个场景。它有一个根本性的问题——分辨率分配与视觉需求不匹配。CSM(Cascaded Shadow Maps,级联阴影贴图)把视锥按深度切成多段,每段单独渲染一张阴影贴图,让近处获得高分辨率、远处用低分辨率,是实时渲染中大范围场景阴影的标准方案。
1.1 分辨率困境
透视投影下,屏幕空间里"近大远小":近处的物体占满屏幕的大片像素,远处的物体挤在屏幕的一角。而阴影贴图是均匀的——正交投影把整个场景等分进固定数量的纹素。于是:
- 近处:屏幕上一大片像素对应的场景区域,在阴影贴图里只占几个纹素,阴影边缘糊成一片块状锯齿;
- 远处:阴影贴图的大半纹素被远方的场景占用,但它们在屏幕上只占几行像素,分辨率被浪费。
提高贴图分辨率只能缓解,不能解决:要让近处阴影清晰,需要的分辨率高到远处完全用不完;照顾远处,近处又回到锯齿。正确思路是按需分配——把阴影贴图拆成多张,近处的贴图只覆盖视锥近端的一小段,纹素密度自然就上来了。
1.2 基本思路
CSM 把摄像机的视锥(frustum,见 视图(摄像机)与投影)沿深度方向切成 段,每段称为一个级联(cascade),渲染 张阴影贴图:
- 对每一级,取视锥在该深度区间内的部分,求一个罩住它的光源正交投影;
- 用这个投影从光源渲染深度贴图——近处的级联覆盖范围小,同分辨率下纹素密度高;
- 摄像机视角渲染时,按片元在摄像机空间的深度选择它属于哪一级,采样对应的阴影贴图。

| 级联 | 覆盖范围 | 贴图分辨率 | 纹素密度 |
|---|---|---|---|
| 第 0 级 | 视锥近端一小段(离摄像机近) | 与远处相同 | 高 |
| 第 1 级 | 中间一段 | 与远处相同 | 中 |
| 第 2 级 | 视锥远端一大段 | 与远处相同 | 低 |
每级的分辨率相同,但覆盖范围逐级增大——近处的阴影用了最多的纹素,远处的阴影纹素稀疏但本来就不显眼,视觉上整体清晰。
1.3 视锥分割
分割位置决定各级的覆盖范围,直接影响画质。设近平面距离为 、远平面距离为 ,共 级。
均匀分割(uniform split):每级占相同的深度长度:
实现最简单,但近端级联仍然太"长":透视下近端深度变化在屏幕上极其敏感,第一级覆盖了 这么长一段,近处的纹素密度依然不足。
对数分割(logarithmic split):每级占相同的深度比例,深度随级数按等比数列增长:
等比数列恰好匹配透视投影的深度压缩特性——屏幕上均匀的像素带对应指数增长的深度,所以对数分割下每级在屏幕上占的"像素高度"大致相等,纹素利用率最均匀。
混合分割(practical split):对数分割在极端近远比下会让近端级联短得失去意义,实践中用对数与均匀的加权混合:
通常取 0.5 左右: 退化为均匀分割, 退化为对数分割。
// C++:计算各级联的分割深度(视图空间,正值)
float lambda = 0.5f; // 混合系数,可调
float splits[CASCADE_COUNT];
for (int i = 0; i < CASCADE_COUNT; ++i)
{
float ratio = float(i + 1) / CASCADE_COUNT;
float logSplit = nearZ * pow(farZ / nearZ, ratio);
float uniformSplit = nearZ + (farZ - nearZ) * ratio;
splits[i] = lambda * logSplit + (1.0f - lambda) * uniformSplit;
}
1.4 每级联的深度贴图
分割深度只是摄像机空间的数值,渲染深度贴图需要把它换算成世界空间的光源正交投影。
第一步:求该级视锥子段的 8 个角点。 由摄像机投影矩阵的逆矩阵,把 NDC 立方体的 8 个角变换回世界空间:
// 视图空间深度 d(正数)映射到 NDC 的 z(近平面 -1,远平面 +1)
// 透视投影下视图空间 z 为负,所以取 -d 代入
float viewDepthToNDC(float d, float nearZ, float farZ)
{
return (farZ + nearZ) / (farZ - nearZ)
+ 2.0f * farZ * nearZ / ((farZ - nearZ) * (-d));
}
// 输入:摄像机 MVP、近远平面、该级的近端与远端分割深度
// 输出:该级视锥子段的 8 个角点(世界空间)
void getCascadeCorners(const glm::mat4& viewProj,
float nearZ, float farZ,
float nearD, float farD,
glm::vec3 corners[8])
{
glm::mat4 invVP = glm::inverse(viewProj);
// 近平面 4 个角用 nearD 的 NDC z,远平面 4 个角用 farD 的 NDC z
float zn = viewDepthToNDC(nearD, nearZ, farZ);
float zf = viewDepthToNDC(farD, nearZ, farZ);
glm::vec4 ndc[8] = {
{-1, -1, zn, 1}, { 1, -1, zn, 1}, {-1, 1, zn, 1}, { 1, 1, zn, 1}, // 近端
{-1, -1, zf, 1}, { 1, -1, zf, 1}, {-1, 1, zf, 1}, { 1, 1, zf, 1} // 远端
};
// 逆变换回世界空间,除以 w 完成从裁剪坐标到 NDC 的归一化
for (int i = 0; i < 8; ++i)
{
glm::vec4 world = invVP * ndc[i];
corners[i] = glm::vec3(world) / world.w;
}
}
第二步:求包围盒并构造光源正交投影。 8 个角点的轴对齐包围盒就是该级场景的范围,以包围盒中心为原点构造光源视图:
// 由 8 个角点求轴对齐包围盒
glm::vec3 minC = ...; glm::vec3 maxC = ...;
glm::vec3 center = (minC + maxC) * 0.5f;
float extent = glm::length(maxC - minC) * 0.5f; // 半边长(取最长的轴)
// 平行光:正交投影,从光源方向看向包围盒中心
glm::mat4 lightView = glm::lookAt(center + lightDir * extent, // 光源位置
center, // 看向中心
glm::vec3(0, 1, 0));
glm::mat4 lightProj = glm::ortho(-extent, extent, -extent, extent,
0.0f, 2.0f * extent);
lightViewProj[i] = lightProj * lightView;
注意:每帧摄像机移动时,包围盒中心随之变化,正交投影在纹素网格上"滑动",阴影会出现抖动(shadow shimmering)。工程上会把投影矩阵对齐到纹素(texel snapping):把包围盒中心取整到纹素大小的整数倍,保证阴影贴图的采样位置逐帧稳定。代价是阴影略微滞后于摄像机移动。
多级深度贴图用一张 纹理数组(texture array)装下,一次 FBO 绑定即可逐层渲染:
// 纹理数组:CASCADE_COUNT 层深度纹理
glGenTextures(1, &shadowMaps);
glBindTexture(GL_TEXTURE_2D_ARRAY, shadowMaps);
glTexImage3D(GL_TEXTURE_2D_ARRAY, 0, GL_DEPTH_COMPONENT,
SHADOW_WIDTH, SHADOW_HEIGHT, CASCADE_COUNT,
0, GL_DEPTH_COMPONENT, GL_FLOAT, nullptr);
glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
// 渲染第 i 级:把深度附件挂到纹理数组的第 i 层
glFramebufferTextureLayer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT,
shadowMaps, 0, i);
渲染流程与单张 ShadowMap 相同(见 ShadowMap与PCF、PCSS 的 Pass 1),只是对每级换一个 lightViewProj,把场景画进对应层。
1.5 片元级联选择
Pass 2 中,每个片元先按摄像机空间的深度决定它属于哪一级,再采样该级的阴影贴图:
// 伪代码:选择级联
int select_cascade(float view_depth, float splits[], int n)
{
for (int i = 0; i < n; ++i)
if (view_depth < splits[i]) return i; // 第一个比片元深的边界
return n - 1; // 超过最后一级,用最远一级
}
由于每级覆盖范围不同,级联交界处两张贴图的分辨率突变,阴影边缘会有一条接缝(cascade seam)。缓解手段:
- 边界混合(cascade blending):在分割深度附近的一段过渡带内,同时采样相邻两级,按距离加权混合,接缝变成平滑过渡;
- 保证各级贴图的边界重叠:渲染时把每级范围向相邻级扩展一点,让交界处两侧都能采到有效数据。
注:级联数量不是越多越好。常用 3~4 级:每多一级就多一遍场景的深度渲染,开销线性增长,而超过 4 级后画质收益递减。每级分辨率常用 1024 或 2048。
1.6 GLSL 实现
顶点着色器
片元着色器需要片元的世界坐标(做光源空间变换)与视图空间深度(选级联),顶点着色器把两者都传下去:
#version 330 core
layout (location = 0) in vec3 aPos;
uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;
out vec3 FragPos; // 世界空间位置
out float ViewDepth; // 视图空间深度(正值)
void main()
{
vec4 worldPos = model * vec4(aPos, 1.0);
vec4 viewPos = view * worldPos;
FragPos = worldPos.xyz;
ViewDepth = -viewPos.z; // OpenGL 视图空间 z 为负,取反得正值
gl_Position = projection * viewPos;
}
片段着色器
#version 330 core
in vec3 FragPos; // 世界空间位置
in float ViewDepth; // 视图空间深度(正值)
uniform sampler2DArray shadowMaps; // 每级一张深度图
uniform mat4 lightViewProj[4]; // 每级的光源 MVP
uniform float cascadeSplits[4]; // 每级的分割深度(视图空间)
uniform float farPlane; // 摄像机远平面(超出最后一级时钳制)
out vec4 FragColor;
/*
* 选择片元所属的级联
*
* 参数:
* viewDepth 片元的视图空间深度(正值)
*
* 返回:
* 级联索引 0 ~ 3
*/
int selectCascade(float viewDepth)
{
for (int i = 0; i < 4; ++i)
{
if (viewDepth < cascadeSplits[i])
return i;
}
return 3; // 超出最后一级,用最远一级兜底
}
/*
* 在指定级联的阴影贴图中判断阴影
*
* 参数:
* cascadeIndex 级联索引
* fragPos 片元位置(世界空间)
*
* 返回:
* 1.0 表示在阴影中,0.0 表示被照亮
*/
float calcShadowCSM(int cascadeIndex, vec3 fragPos)
{
// 片元变换到该级联的光源空间
vec4 lightSpacePos = lightViewProj[cascadeIndex] * vec4(fragPos, 1.0);
vec3 projCoords = lightSpacePos.xyz / lightSpacePos.w;
projCoords = projCoords * 0.5 + 0.5; // NDC 映射到贴图坐标
// 超出贴图范围不判阴影
if (projCoords.x < 0.0 || projCoords.x > 1.0 ||
projCoords.y < 0.0 || projCoords.y > 1.0)
return 0.0;
// 采样纹理数组的第 cascadeIndex 层
float closestDepth =
texture(shadowMaps, vec3(projCoords.xy, float(cascadeIndex))).r;
float currentDepth = projCoords.z;
float bias = 0.005;
return currentDepth - bias > closestDepth ? 1.0 : 0.0;
}
void main()
{
// 选择级联并判断阴影
int cascade = selectCascade(min(ViewDepth, farPlane));
float shadow = calcShadowCSM(cascade, FragPos);
// 光照部分与 Phong模型与Blinn-Phong模型 的片段着色器相同,
// 只需要把阴影系数乘到直接光照上:
// color = ambient + (1.0 - shadow) * (diffuse + specular);
// 这里省略光照代码,先把阴影系数本身输出为灰度,便于调试观察
FragColor = vec4(vec3(1.0 - shadow), 1.0);
}
注:把
calcShadowCSM中的单次采样换成 ShadowMap与PCF、PCSS 的 PCF 邻域采样,就是"CSM + PCF"的组合,这也是实时引擎的常见配置:近处级联的 PCF 核小、阴影锐利,远处级联的 PCF 核大、阴影柔和——PCSS 的"软硬随距离变化"在 CSM 里天然成立。
C++ 侧每帧更新
// 每帧计算:视锥分割深度 + 各级联光源 MVP
float splits[CASCADE_COUNT];
computeSplits(splits); // 混合分割公式
for (int i = 0; i < CASCADE_COUNT; ++i)
{
// 该级视锥子段:近端取上一级分割(第 0 级取 nearZ)
float nearD = (i == 0) ? nearZ : splits[i - 1];
float farD = splits[i];
// 求该级 8 个角点,再构造光源正交投影(见“每级联的深度贴图”一节)
glm::vec3 corners[8];
getCascadeCorners(viewProj, nearZ, farZ, nearD, farD, corners);
lightViewProj[i] = buildLightMatrix(corners, lightDir);
}
// 逐级渲染深度图到纹理数组,再传 uniform
glUseProgram(shadowShader);
for (int i = 0; i < CASCADE_COUNT; ++i)
{
glFramebufferTextureLayer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, shadowMaps, 0, i);
glClear(GL_DEPTH_BUFFER_BIT);
glUniformMatrix4fv(glGetUniformLocation(shadowShader, "lightViewProj"),
1, GL_FALSE, &lightViewProj[i][0][0]);
drawScene();
}
glUseProgram(sceneShader);
glUniform1fv(glGetUniformLocation(sceneShader, "cascadeSplits"),
CASCADE_COUNT, splits);
for (int i = 0; i < CASCADE_COUNT; ++i)
{
std::string name = "lightViewProj[" + std::to_string(i) + "]";
glUniformMatrix4fv(glGetUniformLocation(sceneShader, name.c_str()),
1, GL_FALSE, &lightViewProj[i][0][0]);
}
1.7 参数与工程要点
| 参数 | 常用取值 | 说明 |
|---|---|---|
| 级联数量 | 3 ~ 4 | 更多级收益递减,深度渲染开销线性增长 |
| 每级分辨率 | 1024 ~ 2048 | 近级联也可以单独用更高分辨率 |
| 混合系数 | 0.5 左右 | 分割策略在对数与均匀之间权衡 |
| 分割近远比 | 视场景而定 | 比值越大,对数分割的优势越明显 |
工程上还有两个常见补充:级联边界混合消除接缝(上文已提),以及把 CSM 与 ShadowMap与PCF、PCSS 中的 bias 优化(法线偏移)结合——每级的光源投影范围不同,bias 应该随级联变化,否则近处级联容易漏光、远处级联容易 acne。



