需要预先学习:


1. ShadowMap

阴影 中讲过 ShadowMapping 的原理:把深度测试搬到光源位置再做一遍,用"光源视角记深度 + 摄像机视角比深度"的两遍框架判断片元是否被遮挡。这篇笔记把它落地成 OpenGL 代码,并在这个基础上实现 PCF 与 PCSS。


1.1 基本原理回顾

ShadowMapping 分两遍渲染:

Pass 1:从光源渲染场景,只记深度。 平行光配正交投影,把场景画进一张深度贴图(depth map)——只写深度缓冲,不写颜色。深度贴图记录了从光源看过去每个方向上最近的表面距离。

Pass 2:从摄像机正常渲染场景。 着色时把片元变换到光源的观察空间,投影得到它对应的贴图坐标与深度,与贴图中记录的最近深度比较:片元更远,说明它前面有别的表面挡光,在阴影中。

// 伪代码:Pass 2 中的阴影判断
float d_frag = to_light_view(fragment.pos).z;   // 片元在光源视角下的深度
float d_map  = shadow_map.sample(uv);           // 贴图记录的最近深度
bool in_shadow = d_frag > d_map;                // 更远 -> 被遮挡

两个数值问题也一并回顾:比较时加偏移(bias)缓解自遮挡(shadow acne),bias 过大会导致阴影整体后退(peter panning)。


1.2 Pass 1:生成深度贴图

深度贴图只存深度、不存颜色,用帧缓冲对象(FBO,见 帧缓冲)离屏渲染。绑定一个只挂深度纹理、不挂颜色纹理的 FBO:

// 深度贴图纹理
GLuint depthMap;
glGenTextures(1, &depthMap);
glBindTexture(GL_TEXTURE_2D, depthMap);
glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT,
             SHADOW_WIDTH, SHADOW_HEIGHT, 0,      // 常用 1024 ~ 4096
             GL_DEPTH_COMPONENT, GL_FLOAT, nullptr);
// 深度贴图不需要过滤与环绕,阴影采样是逐纹素比较
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_BORDER);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_BORDER);
GLfloat borderColor[] = { 1.0f, 1.0f, 1.0f, 1.0f };   // 边界外算"不受阴影"
glTexParameterfv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, borderColor);

// 帧缓冲:只挂深度,颜色附件留空
GLuint depthFBO;
glGenFramebuffers(1, &depthFBO);
glBindFramebuffer(GL_FRAMEBUFFER, depthFBO);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT,
                       GL_TEXTURE_2D, depthMap, 0);
// 不绑定颜色附件,显式声明不绘制颜色
glDrawBuffer(GL_NONE);
glReadBuffer(GL_NONE);
glBindFramebuffer(GL_FRAMEBUFFER, 0);

注:深度贴图用 GL_CLAMP_TO_BORDER 且边界色为白色(深度 1.0):片元投影到贴图范围之外时采样到 1.0,比较时"贴图深度"最大,片元不会误判为阴影。OpenGL 深度默认范围是 [0,1][0, 1],1.0 表示"无穷远"。

Pass 1 的顶点着色器只做一件事:把顶点变换到光源空间。平行光用正交投影,光源的位置与朝向决定 view 矩阵:

#version 330 core

layout (location = 0) in vec3 aPos;

uniform mat4 lightViewProj;   // lightProj * lightView(光源视角的 MVP)

void main()
{
    gl_Position = lightViewProj * vec4(aPos, 1.0);
}

C++ 侧设置投影与视图矩阵,渲染场景:

// 平行光:正交投影,范围要恰好罩住场景(或视锥,见 CSM 笔记)
glm::mat4 lightProj = glm::ortho(-10.0f, 10.0f, -10.0f, 10.0f, 0.1f, 30.0f);
glm::mat4 lightView = glm::lookAt(lightPos, glm::vec3(0.0f), glm::vec3(0.0f, 1.0f, 0.0f));
glm::mat4 lightViewProj = lightProj * lightView;

glUseProgram(shadowShader);                 // Pass 1 专用着色器
glUniformMatrix4fv(glGetUniformLocation(shadowShader, "lightViewProj"),
                   1, GL_FALSE, &lightViewProj[0][0]);

glViewport(0, 0, SHADOW_WIDTH, SHADOW_HEIGHT);
glBindFramebuffer(GL_FRAMEBUFFER, depthFBO);
glClear(GL_DEPTH_BUFFER_BIT);
drawScene();                                // 绘制全部物体
glBindFramebuffer(GL_FRAMEBUFFER, 0);

注:Pass 1 不需要片段着色器的颜色输出,给一个空实现即可(void main() {})。如果绘制时开启了背面剔除,注意剔除方向要与摄像机视角一致,否则阴影会"漏"。


1.3 Pass 2:阴影判断

Pass 2 用正常的光照着色器,把 lightViewProj 与深度贴图传进去。片元着色器中做阴影判断:

/*
 *  判断片元是否在阴影中
 *
 *  参数:
 *      shadowMap       深度贴图
 *      lightViewProj   光源空间的 MVP 矩阵
 *      fragPos         片元位置(世界空间)
 *
 *  返回:
 *      1.0 表示在阴影中,0.0 表示被照亮
 */
float calcShadow(sampler2D shadowMap, mat4 lightViewProj, vec3 fragPos)
{
    // 1. 片元变换到光源的裁剪空间,再归一化到 NDC
    vec4 lightSpacePos = lightViewProj * vec4(fragPos, 1.0);
    vec3 projCoords = lightSpacePos.xyz / lightSpacePos.w;

    // 2. NDC 的 [-1, 1] 映射到贴图坐标的 [0, 1]
    projCoords = projCoords * 0.5 + 0.5;

    // 3. 超出贴图范围(光源视野之外)不判阴影
    if (projCoords.x < 0.0 || projCoords.x > 1.0 ||
        projCoords.y < 0.0 || projCoords.y > 1.0)
        return 0.0;

    // 4. 比较:贴图记录的最近深度 vs 片元深度
    float closestDepth = texture(shadowMap, projCoords.xy).r;
    float currentDepth = projCoords.z;

    // 5. 加偏移缓解 shadow acne(沿法线偏移更稳,见注)
    float bias = 0.005;

    // 片元比记录的最近表面更远 -> 被遮挡
    return currentDepth - bias > closestDepth ? 1.0 : 0.0;
}

注:固定 bias 对斜射平面不够用——掠射角下深度量化误差更大。更稳的做法是法线偏移(normal offset bias):bias = max(0.05 * (1.0 - dot(normal, lightDir)), 0.005),让偏移量随光线与表面的夹角自动增大。

在光照函数里用掉阴影系数:阴影中只保留环境光,漫反射与高光归零:

// 伪代码:把阴影系数乘到直接光照上
vec3 ambient  = ambientStrength * lightColor;
vec3 diffuse  = calcDiffuse(normal, lightDir, albedo, lightColor);
vec3 specular = calcSpecularBlinnPhong(normal, lightDir, viewDir,
                                       specColor, lightColor, shininess);

float shadow = calcShadow(shadowMap, lightViewProj, FragPos);
vec3 color = ambient + (1.0 - shadow) * (diffuse + specular);

2. PCF

ShadowMap 直接比较的结果只有"是 / 否"两个值,阴影边缘是硬边界,还叠加了低分辨率贴图的块状锯齿(见 阴影 中的走样问题)。PCF(Percentage Closer Filtering,百分比邻近过滤)把二值比较改成多次采样平均:在贴图坐标的邻域内采样多个深度值,各自与片元深度比较,把多个 0/1 结果取平均,得到一个 0 到 1 之间的阴影系数。


2.1 基本原理

普通的阴影采样查一次深度贴图,得到一个 0/1 结果;PCF 查邻域内的 N×NN \times N 个纹素,得到 N2N^2 个 0/1 结果,取平均:

shadow=1N2∑i=1N∑j=1N[ dfrag−bias>dmap(uv+(i,j)⋅texelSize) ]shadow = \frac{1}{N^2}\sum_{i=1}^{N}\sum_{j=1}^{N} \Big[\, d_{frag} - bias > d_{map}(uv + (i,j) \cdot texelSize) \,\Big]

  • 邻域内全部被遮挡:shadow = 1.0,全阴影;
  • 邻域内全部可见:shadow = 0.0,全亮;
  • 阴影边缘附近:部分被遮挡,shadow 落在 0 到 1 之间,边缘出现渐变过渡。

注:PCF 的"过滤"发生在比较之后——它平均的是比较结果,不是深度值。如果把深度值先平均再比较(类似普通纹理过滤),边缘会糊成错误的结果,这就是"percentage closer"这个名字的由来。

滤波核大小决定阴影边缘的软硬:3×3 核的过渡很窄,主要作用是消除锯齿;更大的核(如 5×5、7×7)能做出更宽的渐变。缺点是滤波半径是固定的,整个画面阴影软硬一致,不区分"贴着地面的锐利阴影"与"悬空物体的柔和阴影"——这是 PCSS 要解决的问题。


2.2 GLSL 实现

/*
 *  PCF 阴影判断:邻域多次采样取平均
 *
 *  参数:
 *      shadowMap       深度贴图
 *      lightViewProj   光源空间的 MVP 矩阵
 *      fragPos         片元位置(世界空间)
 *      texelSize       一个纹素的大小(1.0 / 贴图分辨率),用于扩大采样邻域
 *
 *  返回:
 *      0.0 ~ 1.0 的阴影系数,边缘处呈现渐变
 */
float calcShadowPCF(sampler2D shadowMap, mat4 lightViewProj,
                    vec3 fragPos, vec2 texelSize)
{
    // 与 calcShadow 相同的坐标变换
    vec4 lightSpacePos = lightViewProj * vec4(fragPos, 1.0);
    vec3 projCoords = lightSpacePos.xyz / lightSpacePos.w;
    projCoords = projCoords * 0.5 + 0.5;

    if (projCoords.x < 0.0 || projCoords.x > 1.0 ||
        projCoords.y < 0.0 || projCoords.y > 1.0)
        return 0.0;

    float currentDepth = projCoords.z;
    float bias = 0.005;
    float shadow = 0.0;

    // 3x3 邻域采样:每个采样点独立比较,再取平均
    for (int x = -1; x <= 1; ++x)
    {
        for (int y = -1; y <= 1; ++y)
        {
            vec2 sampleUV = projCoords.xy + vec2(x, y) * texelSize;
            float closestDepth = texture(shadowMap, sampleUV).r;
            shadow += currentDepth - bias > closestDepth ? 1.0 : 0.0;
        }
    }

    return shadow / 9.0;    // 平均:9 个采样点的 0/1 结果
}

调用时传入纹素大小:

vec2 texelSize = 1.0 / textureSize(shadowMap, 0);   // 贴图分辨率的倒数
float shadow = calcShadowPCF(shadowMap, lightViewProj, FragPos, texelSize);

注:规则网格采样在边缘附近可能出现规律性条纹,实践中常用泊松盘(Poisson disk)分布的可旋转采样点或抖动偏移打破规律性,代价是阴影系数带有轻微噪声。固定核的 PCF 依然是实时渲染中性价比最高的方案。


3. PCSS

PCF 的滤波半径是常数,画面里所有阴影边缘软硬一致。但真实阴影不是这样:阴影 中讲过,面光源产生的阴影有本影(umbra)与半影(penumbra),半影的宽度取决于遮挡物到接收面的距离——遮挡物贴地时阴影锐利,悬得越高边缘越柔和。PCSS(Percentage Closer Soft Shadows,百分比邻近软阴影)让滤波半径随几何关系动态变化,模拟出这种软硬变化。


3.1 基本原理

先看半影宽度的几何关系。面光源(宽度 wlightw_{light})发出的光被遮挡物挡住一部分,投影面上出现半影:

本影半影示意图

遮挡物在光源与接收面之间,用相似三角形可以推出半影宽度:

wpenumbra=dreceiver−dblockerdblocker⋅wlightw_{penumbra} = \frac{d_{receiver} - d_{blocker}}{d_{blocker}} \cdot w_{light}

  • 遮挡物贴着接收面(dreceiver≈dblockerd_{receiver} \approx d_{blocker}):半影趋近于 0,阴影锐利;
  • 遮挡物靠近光源(dblockerd_{blocker} 小):半影宽,阴影柔和。

PCSS 把半影宽度换算成滤波半径,分三步计算:

  1. 遮挡物搜索(blocker search):在片元对应的贴图坐标周围一个固定半径内采样深度,凡是比片元浅(更靠近光源)的采样点,就是挡在片元前面的遮挡物,取它们的深度平均值 dblockerd_{blocker};
  2. 半影估计(penumbra estimation):代入上面的公式,用 dreceiverd_{receiver}(片元自身在光源视角下的深度)、dblockerd_{blocker} 与光源尺寸算出半影宽度;
  3. PCF 过滤:以半影宽度为滤波半径做 PCF——近处的阴影用窄核(锐利),远处的阴影用宽核(柔和)。

注:PCF 是硬阴影的反走样手段,PCSS 才是真正模拟软阴影的算法——它在 PCF 之上多做了两步,把固定的滤波半径换成由几何推导出的动态半径。


3.2 GLSL 实现

PCSS 的三个函数依次实现。第一步,在固定搜索半径内统计遮挡物平均深度:

/*
 *  遮挡物搜索:统计比片元更靠近光源的表面的平均深度
 *
 *  参数:
 *      shadowMap       深度贴图
 *      uv              片元对应的贴图坐标
 *      currentDepth    片元在光源视角下的深度
 *      searchRadius    搜索半径(纹素个数)
 *      texelSize       一个纹素的大小
 *
 *  返回:
 *      遮挡物的平均深度;没有遮挡物时返回 -1.0(表示无遮挡)
 */
float findBlockerDepth(sampler2D shadowMap, vec2 uv, float currentDepth,
                       float searchRadius, vec2 texelSize)
{
    float blockerDepthSum = 0.0;
    float blockerCount = 0.0;

    // 在方形邻域内采样(实践中常用环形或泊松分布采样减少采样数)
    for (int x = -int(searchRadius); x <= int(searchRadius); ++x)
    {
        for (int y = -int(searchRadius); y <= int(searchRadius); ++y)
        {
            float closestDepth =
                texture(shadowMap, uv + vec2(x, y) * texelSize).r;

            // 比片元浅:它是挡在片元前面的遮挡物,计入平均
            if (closestDepth < currentDepth)
            {
                blockerDepthSum += closestDepth;
                blockerCount += 1.0;
            }
        }
    }

    return blockerCount > 0.0 ? blockerDepthSum / blockerCount : -1.0;
}

第二步与第三步合并:算出半影宽度后,以它为半径做 PCF。

/*
 *  PCSS 阴影判断:遮挡物搜索 + 半影估计 + 动态半径 PCF
 *
 *  参数:
 *      shadowMap       深度贴图
 *      lightViewProj   光源空间的 MVP 矩阵
 *      fragPos         片元位置(世界空间)
 *      lightSize       光源尺寸 w_light(模拟面光源的宽度,调参用)
 *      texelSize       一个纹素的大小
 *
 *  返回:
 *      0.0 ~ 1.0 的阴影系数,半影处呈渐变
 */
float calcShadowPCSS(sampler2D shadowMap, mat4 lightViewProj,
                     vec3 fragPos, float lightSize, vec2 texelSize)
{
    vec4 lightSpacePos = lightViewProj * vec4(fragPos, 1.0);
    vec3 projCoords = lightSpacePos.xyz / lightSpacePos.w;
    projCoords = projCoords * 0.5 + 0.5;

    if (projCoords.x < 0.0 || projCoords.x > 1.0 ||
        projCoords.y < 0.0 || projCoords.y > 1.0)
        return 0.0;

    float currentDepth = projCoords.z;
    float bias = 0.005;

    // 第一步:遮挡物搜索(固定搜索半径,如 20 个纹素)
    float searchRadius = 20.0;
    float blockerDepth =
        findBlockerDepth(shadowMap, projCoords.xy, currentDepth,
                         searchRadius, texelSize);

    // 没有遮挡物:片元完全被照亮
    if (blockerDepth < 0.0) return 0.0;

    // 第二步:半影宽度,由相似三角形推导
    // d_receiver 即 currentDepth,d_blocker 即 blockerDepth
    float penumbraWidth =
        (currentDepth - blockerDepth) / blockerDepth * lightSize;

    // 第三步:以半影宽度为半径做 PCF(采样数与半径成正比)
    float shadow = 0.0;
    int sampleCount = int(penumbraWidth);
    for (int x = -sampleCount; x <= sampleCount; ++x)
    {
        for (int y = -sampleCount; y <= sampleCount; ++y)
        {
            float closestDepth =
                texture(shadowMap, projCoords.xy + vec2(x, y) * texelSize).r;
            shadow += currentDepth - bias > closestDepth ? 1.0 : 0.0;
        }
    }

    float total = float((2 * sampleCount + 1) * (2 * sampleCount + 1));
    return shadow / total;
}

注:PCSS 的开销随半影宽度增长——阴影越软,采样次数越多。工程上常用固定数量的采样点配合随机旋转(如 16 个泊松盘采样点)来控制成本,并配合降噪(见 纹理 中的滤波思路)抹平噪声。

三个算法逐级递进,可以统一归纳:

算法 采样方式 滤波半径 效果
ShadowMap 1 次采样,二值比较 无 硬阴影,边缘锯齿
PCF 邻域 N×NN \times N 次采样取平均 固定 边缘渐变,消除锯齿
PCSS 遮挡物搜索 + 邻域采样 随遮挡关系动态变化 软硬随距离变化的真实软阴影