PCSS 软阴影渲染器

现实世界中,阴影从来不是非黑即白的——靠近物体的地方阴影清晰,远离的地方阴影边缘模糊渐变。这种效果叫做半影(Penumbra),是面光源(有面积的光,区别于理想点光源)的自然产物。

今天实现了三种阴影算法,从最简单的硬阴影,逐步演进到 UE5/Unity HDRP 都在用的 PCSS(Percentage Closer Soft Shadows)——一种能根据遮挡距离自适应调整半影大小的软阴影算法。


三种算法对比

三种阴影方法对比

从左到右:Hard Shadow(硬阴影) | PCF(固定软化) | PCSS(自适应软阴影)

注意 PCSS 的阴影:球体与地面接触处的阴影清晰,悬空球体的阴影边缘模糊扩散——这才是物理正确的半影效果。


第一章:为什么会有半影?

用一张图来理解:

1
2
3
4
5
6
7
8
      ○○○○○        ← 面光源(有面积)
|||
A──B──C ← 遮挡物(球体)
/|\
/ | \
/ | \
阴影 半影 阴影
(全暗)(渐变)(全亮)
  • 本影(Umbra):被遮挡物完全挡住光源的区域,完全黑暗
  • 半影(Penumbra):只被遮挡物部分遮住光源的区域,亮度渐变

关键规律:遮挡物离接收面越远,半影越大。这就是 PCSS 的核心物理依据。

想象用手在阳光下投影:手贴着桌面时,影子边缘清晰;手离桌面越高,影子越模糊、越大。这就是半影的几何原因。


第二章:Shadow Map——阴影算法的基础

所有现代实时阴影算法都基于 Shadow Map(阴影贴图)

原理

  1. Pass 1(光源视角):把相机放到光源位置,渲染场景,只记录每个像素的深度值(到光源的距离),存储为一张深度图(Shadow Map)
  2. Pass 2(相机视角):正常渲染场景,对每个像素,计算它在光源视角下的投影位置,然后查询 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
// Shadow Map 生成:正交投影,从光源方向看向场景
void generateShadowMap(const Scene& scene, const AreaLight& light,
ShadowMap& sm) {
// 定义光源的正交视锥
float halfSize = 8.0f;
// 从光源位置向场景中心方向,计算正交投影矩阵
Vec3 lightDir = (sceneCenter - light.pos).norm();

for (int y = 0; y < sm.height; y++) {
for (int x = 0; x < sm.width; x++) {
// 计算这个 texel 对应的世界空间射线
Ray ray = lightRay(x, y, sm, light);

// 找最近的遮挡物
HitInfo hit = scene.intersect(ray);
if (hit.hit)
sm.depth[y * sm.width + x] = hit.t; // 存储深度
else
sm.depth[y * sm.width + x] = FLT_MAX;
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
// 着色时查询 Shadow Map:判断当前点是否在阴影中
float isInShadow(Vec3 worldPos, const ShadowMap& sm, const AreaLight& light) {
// 将世界坐标转换到光源的 Shadow Map 坐标
Vec2 uv = worldToShadowUV(worldPos, sm, light);
float shadowDepth = sm.sample(uv.x, uv.y); // Shadow Map 中存储的深度
float receiverDepth = distanceToLight(worldPos, light); // 当前点到光源的距离

// bias 避免数值精度问题导致自身遮挡("Shadow Acne")
float bias = 0.005f;
return (receiverDepth - bias > shadowDepth) ? 0.0f : 1.0f;
// 返回 0 = 在阴影中,返回 1 = 在光照中
}

Shadow Acne(阴影痤疮):不加 bias 时,由于浮点精度误差,物体会”遮挡自己”,表面出现斑点状的错误阴影。加一个小的 bias 偏移接收深度,就能避免这个问题。


第三章:硬阴影——最简单的 Shadow Map 查询

硬阴影只做一次 Shadow Map 查询,结果非 0 即 1:

1
2
3
4
5
6
7
8
9
10
float hardShadow(Vec3 worldPos, const ShadowMap& sm, const AreaLight& light) {
Vec2 uv = worldToShadowUV(worldPos, sm, light);
if (uv.x < 0 || uv.x > 1 || uv.y < 0 || uv.y > 1)
return 1.0f; // 超出 Shadow Map 范围,视为无阴影

float shadowDepth = sm.sample(uv.x, uv.y);
float receiverDepth = shadowSpaceDepth(worldPos, sm, light);

return (receiverDepth - 0.005f > shadowDepth) ? 0.0f : 1.0f;
}

效果:锐利的硬边阴影,渲染快(每像素只查询 Shadow Map 一次),但不真实——现实中除了激光这样的点光源,几乎不存在完全锐利的阴影。

硬阴影


第四章:PCF——用采样平均实现软化

PCF(Percentage Closer Filtering) 是硬阴影的直接改进:在 Shadow Map 上的查询点周围采样多次,取平均值,得到一个 0~1 之间的软化结果。

为什么不能直接对 Shadow Map 做模糊?

Shadow Map 存储的是深度值,不是颜色。如果先对深度图做模糊,然后查询一次,得到的会是”平均深度”——这在物理上没有意义(平均深度既不是最近的遮挡物,也不能正确表示软阴影)。

PCF 的做法:先做多次深度比较,再对比较结果(0/1)取平均。这样得到的是”有多少比例的采样点不在阴影里”,即该点的可见度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
float pcfShadow(Vec3 worldPos, const ShadowMap& sm, const AreaLight& light,
float filterRadius, int numSamples) {
Vec2 centerUV = worldToShadowUV(worldPos, sm, light);
float receiverDepth = shadowSpaceDepth(worldPos, sm, light);

float sum = 0.0f;
// Poisson Disk 采样分布(相比规则网格,避免规则噪声)
for (int i = 0; i < numSamples; i++) {
// Poisson Disk 点:在单位圆内均匀分布,无聚簇
Vec2 offset = poissonDisk[i] * filterRadius;
float depth = sm.sample(centerUV.x + offset.x,
centerUV.y + offset.y);
// 0 = 在阴影,1 = 在光照
sum += (receiverDepth - 0.005f > depth) ? 0.0f : 1.0f;
}
return sum / numSamples; // 可见度:0.0(全阴影)~ 1.0(全光照)
}

Poisson Disk 采样:在 Shadow Map 上不均匀分布的规则网格采样会产生条纹状伪影。Poisson Disk 保证采样点在圆盘内均匀分布(任意两点距离超过最小间距),视觉效果更随机、更自然。

1
2
3
4
5
6
// 预计算的 Poisson Disk 采样点(16个,在单位圆内均匀分布)
static const Vec2 poissonDisk[16] = {
{-0.94201624f, -0.39906216f}, { 0.94558609f, -0.76890725f},
{-0.09418410f, -0.92938870f}, { 0.34495938f, 0.29387760f},
// ...(共16个)
};

PCF 的问题:filterRadius 是固定的,所有地方的软化程度一样——无论遮挡物贴地还是悬空,半影大小不变。这不符合物理规律。

PCF软阴影


第五章:PCSS——自适应软阴影的核心

PCSS 解决了 PCF 的问题:根据遮挡物到接收面的距离,动态调整 PCF 的滤波半径

物理依据:

$$w_{penumbra} = \frac{(d_{receiver} - d_{blocker}) \times w_{light}}{d_{blocker}}$$

其中:

  • $d_{receiver}$:接收阴影的点到光源的距离
  • $d_{blocker}$:遮挡物到光源的距离(平均值)
  • $w_{light}$:光源的物理大小
  • $w_{penumbra}$:半影大小

直觉:相似三角形。遮挡物越靠近接收面($d_{receiver} - d_{blocker}$ 越小),半影越小;遮挡物越远,半影越大。

PCSS 分三步:

Step 1:Blocker Search(遮挡物搜索)

在接收点周围的一个搜索范围内,查询 Shadow Map,找出所有”遮挡了该点”的遮挡物的平均深度:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
float findAvgBlockerDepth(Vec2 uv, float receiverDepth,
float searchRadius, const ShadowMap& sm) {
float blockerSum = 0.0f;
int numBlockers = 0;

for (int i = 0; i < BLOCKER_SEARCH_SAMPLES; i++) {
Vec2 offset = poissonDisk[i] * searchRadius;
float depth = sm.sample(uv.x + offset.x, uv.y + offset.y);

// 只统计"比接收点更近"的遮挡物
if (depth < receiverDepth - 0.005f) {
blockerSum += depth;
numBlockers++;
}
}

if (numBlockers == 0) return -1.0f; // 无遮挡,无阴影
return blockerSum / numBlockers; // 平均遮挡深度
}

搜索半径与光源大小相关:searchRadius = lightSizeUV * receiverDepth,这样远处的点会搜索更大的范围(因为面光源在远处的”张角”更大)。

Step 2:Penumbra Estimation(半影估算)

用平均遮挡深度代入公式,计算半影大小:

1
2
3
4
5
6
float penumbraWidth = (receiverDepth - avgBlockerDepth)
* lightSizeWorld
/ avgBlockerDepth;

// 转换为 Shadow Map UV 空间的半径
float filterRadius = penumbraWidth / shadowMapCoverage;

Step 3:Adaptive PCF(用动态半径做 PCF)

用第 2 步算出的 filterRadius 代替固定值,执行 PCF:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
float pcss(Vec3 worldPos, const ShadowMap& sm, const AreaLight& light) {
Vec2 uv = worldToShadowUV(worldPos, sm, light);
float depth = shadowSpaceDepth(worldPos, sm, light);

// Step 1: 搜索遮挡物
float searchR = lightSizeUV * depth;
float avgBlocker = findAvgBlockerDepth(uv, depth, searchR, sm);

if (avgBlocker < 0.0f) return 1.0f; // 无遮挡

// Step 2: 估算半影大小
float penumbra = (depth - avgBlocker) * lightSizeWorld / avgBlocker;
float filterRadius = penumbra / shadowCoverage;
filterRadius = std::min(filterRadius, maxFilterRadius); // 防止过大

// Step 3: 用动态半径做 PCF
return pcfShadow(worldPos, sm, light, filterRadius, PCF_SAMPLES);
}

最终效果

PCSS输出

球体贴地处(遮挡距离小)阴影清晰,悬空球体(遮挡距离大)阴影模糊扩散。


第六章:场景设置与光线追踪

本项目使用简单的光线追踪来渲染基础场景(用于生成 Shadow Map 和最终图像),不使用光栅化。

场景构成

  • 3 个球体:红球(大,r=1.2)、绿球(中,r=0.75)、蓝球(小,r=0.5)
  • 棋盘格地板(方便观察阴影边缘细节)
  • 矩形面光源:3×3 单位,位于 (4, 8, 4)
  • Shadow Map 分辨率:512×512,正交投影

Phong 着色 + 阴影整合

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Vec3 shade(const HitInfo& hit, const Scene& scene,
const AreaLight& light, const ShadowMap& sm,
ShadowMode mode) {
// 基础 Phong 光照
Vec3 N = hit.normal;
Vec3 L = light.getDirection(hit.pos);
float diff = std::max(0.0f, N.dot(L));

// 根据选择的阴影模式计算可见度
float visibility;
if (mode == HARD) visibility = hardShadow(hit.pos, sm, light);
else if (mode == PCF) visibility = pcfShadow(hit.pos, sm, light, 0.02f, 16);
else visibility = pcss(hit.pos, sm, light);

Vec3 ambient = hit.color * 0.15f;
Vec3 diffuse = hit.color * diff * visibility;
return ambient + diffuse;
}

第七章:文件大小揭示的信息量差异

一个有趣的验证角度——三张图的文件大小:

1
2
3
hard_shadow.png:  93KB
pcf_shadow.png: 384KB(是硬阴影的 4.1 倍!)
pcss_shadow.png: 135KB

为什么 PCF 文件最大? PNG 压缩依赖像素间的相关性。硬阴影的边缘是锐利的二值跳变,压缩率极高;PCF 的软化边缘产生了大量细腻的渐变细节,PNG 难以高效压缩,文件因此更大。

这反过来说明了 PCF 确实产生了更丰富的边缘信息——这正是软阴影的视觉价值所在。


量化验证

1
2
3
4
5
6
7
8
9
10
11
全图亮度统计(mean/max):
Hard Shadow: mean=139.4, max=214 ✅
PCF Shadow: mean=138.6, max=214 ✅
PCSS Shadow: mean=139.6, max=214 ✅

三种方法亮度均值几乎相同——阴影算法只改变阴影边缘,不影响光照总量。

文件大小对比:
Hard: 93KB
PCF: 384KB(边缘细节最丰富)
PCSS: 135KB(自适应半影,介于中间)

第八章:进阶与现代实现

PCSS 的代价

PCSS 比硬阴影贵很多:

  • Blocker Search:N 次 Shadow Map 采样(通常 16~32 次)
  • Adaptive PCF:M 次 Shadow Map 采样(通常 16~64 次)

每像素总采样:3296 次,比硬阴影多 3296 倍。在实时渲染中,这需要 GPU 并行和各种优化来保持帧率。

现代游戏引擎的软阴影

  • UE5:PCSS 作为基础,加上时域去噪(Temporal Denoising)降低采样数
  • Unity HDRP:提供 PCSS 选项,结合 TAA 降噪
  • 实时光追:RTX 的 Ray-Traced Shadows 从根本上解决了这个问题,直接发射阴影光线到面光源的随机采样点,结合降噪网络(DLSS 3/4)

延伸阅读

  • 原始论文:Randima Fernando, Percentage-Closer Soft Shadows, GDC 2005
  • 扩展:VSSM(Variance Soft Shadow Mapping),用方差近似快速估算遮挡比例
  • 最新:Ray-Traced Shadows + Denoiser(Nvidia NRD/DLSS)

小结

今天实现的三种阴影算法,代表了实时阴影技术的演进路径:

  1. Hard Shadow:一次 Shadow Map 查询,快但不真实
  2. PCF:多次查询取平均,得到固定软化的软阴影
  3. PCSS:先搜索遮挡物深度,再用物理公式估算半影大小,最后做自适应 PCF——真正的”遮挡物越远阴影越软”

核心公式:$w_{penumbra} = (d_{receiver} - d_{blocker}) \times w_{light} / d_{blocker}$

这个公式来自简单的相似三角形几何关系,物理意义明确,是 PCSS 整个算法的灵魂。


完成时间: 2026-03-10 05:33
迭代次数: 1 次(一次编译运行成功)
运行时间: 0.86秒(三张图共)
编译器: g++ -O2 -std=c++17

代码仓库: https://github.com/chiuhoukazusa/daily-coding-practice/tree/main/2026/03/03-10-pcss-soft-shadows