ShadowMap与PCF、PCSS
需要预先学习:
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 深度默认范围是 ,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 查邻域内的 个纹素,得到 个 0/1 结果,取平均:
- 邻域内全部被遮挡: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 基本原理
先看半影宽度的几何关系。面光源(宽度 )发出的光被遮挡物挡住一部分,投影面上出现半影:

遮挡物在光源与接收面之间,用相似三角形可以推出半影宽度:
- 遮挡物贴着接收面():半影趋近于 0,阴影锐利;
- 遮挡物靠近光源( 小):半影宽,阴影柔和。
PCSS 把半影宽度换算成滤波半径,分三步计算:
- 遮挡物搜索(blocker search):在片元对应的贴图坐标周围一个固定半径内采样深度,凡是比片元浅(更靠近光源)的采样点,就是挡在片元前面的遮挡物,取它们的深度平均值 ;
- 半影估计(penumbra estimation):代入上面的公式,用 (片元自身在光源视角下的深度)、 与光源尺寸算出半影宽度;
- 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 | 邻域 次采样取平均 | 固定 | 边缘渐变,消除锯齿 |
| PCSS | 遮挡物搜索 + 邻域采样 | 随遮挡关系动态变化 | 软硬随距离变化的真实软阴影 |



