需要预先学习:


1. CSM 级联阴影

ShadowMap与PCF、PCSS 中实现了单张阴影贴图:一张正交投影的深度图罩住整个场景。它有一个根本性的问题——分辨率分配与视觉需求不匹配。CSM(Cascaded Shadow Maps,级联阴影贴图)把视锥按深度切成多段,每段单独渲染一张阴影贴图,让近处获得高分辨率、远处用低分辨率,是实时渲染中大范围场景阴影的标准方案。


1.1 分辨率困境

透视投影下,屏幕空间里"近大远小":近处的物体占满屏幕的大片像素,远处的物体挤在屏幕的一角。而阴影贴图是均匀的——正交投影把整个场景等分进固定数量的纹素。于是:

  • 近处:屏幕上一大片像素对应的场景区域,在阴影贴图里只占几个纹素,阴影边缘糊成一片块状锯齿;
  • 远处:阴影贴图的大半纹素被远方的场景占用,但它们在屏幕上只占几行像素,分辨率被浪费。

提高贴图分辨率只能缓解,不能解决:要让近处阴影清晰,需要的分辨率高到远处完全用不完;照顾远处,近处又回到锯齿。正确思路是按需分配——把阴影贴图拆成多张,近处的贴图只覆盖视锥近端的一小段,纹素密度自然就上来了。


1.2 基本思路

CSM 把摄像机的视锥(frustum,见 视图(摄像机)与投影)沿深度方向切成 NN 段,每段称为一个级联(cascade),渲染 NN 张阴影贴图:

  1. 对每一级,取视锥在该深度区间内的部分,求一个罩住它的光源正交投影;
  2. 用这个投影从光源渲染深度贴图——近处的级联覆盖范围小,同分辨率下纹素密度高;
  3. 摄像机视角渲染时,按片元在摄像机空间的深度选择它属于哪一级,采样对应的阴影贴图。

CSM视锥切割示意图

级联 覆盖范围 贴图分辨率 纹素密度
第 0 级 视锥近端一小段(离摄像机近) 与远处相同 高
第 1 级 中间一段 与远处相同 中
第 2 级 视锥远端一大段 与远处相同 低

每级的分辨率相同,但覆盖范围逐级增大——近处的阴影用了最多的纹素,远处的阴影纹素稀疏但本来就不显眼,视觉上整体清晰。


1.3 视锥分割

分割位置决定各级的覆盖范围,直接影响画质。设近平面距离为 nn、远平面距离为 ff,共 NN 级。

均匀分割(uniform split):每级占相同的深度长度:

Ci=n+(f−n)⋅iNC_i = n + (f - n) \cdot \frac{i}{N}

实现最简单,但近端级联仍然太"长":透视下近端深度变化在屏幕上极其敏感,第一级覆盖了 n∼n+f−nNn \sim n + \frac{f-n}{N} 这么长一段,近处的纹素密度依然不足。

对数分割(logarithmic split):每级占相同的深度比例,深度随级数按等比数列增长:

Ci=n⋅(fn)iNC_i = n \cdot \left( \frac{f}{n} \right)^{\frac{i}{N}}

等比数列恰好匹配透视投影的深度压缩特性——屏幕上均匀的像素带对应指数增长的深度,所以对数分割下每级在屏幕上占的"像素高度"大致相等,纹素利用率最均匀。

混合分割(practical split):对数分割在极端近远比下会让近端级联短得失去意义,实践中用对数与均匀的加权混合:

Ci=λ⋅n(fn)iN+(1−λ)⋅[n+(f−n)iN]C_i = \lambda \cdot n \left( \frac{f}{n} \right)^{\frac{i}{N}} + (1 - \lambda) \cdot \left[ n + (f - n) \frac{i}{N} \right]

λ\lambda 通常取 0.5 左右:λ=0\lambda = 0 退化为均匀分割,λ=1\lambda = 1 退化为对数分割。

// 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 近级联也可以单独用更高分辨率
混合系数 λ\lambda 0.5 左右 分割策略在对数与均匀之间权衡
分割近远比 f/nf/n 视场景而定 比值越大,对数分割的优势越明显

工程上还有两个常见补充:级联边界混合消除接缝(上文已提),以及把 CSM 与 ShadowMap与PCF、PCSS 中的 bias 优化(法线偏移)结合——每级的光源投影范围不同,bias 应该随级联变化,否则近处级联容易漏光、远处级联容易 acne。