4.3 软阴影与 PCF
实时 PBR 与阴影贴图 那篇默认阴影边界是硬的——一个像素要么全黑要么全亮。真实光源有面积,边界上存在半影:越靠近遮挡物越硬,越远越软。PCF(percentage-closer filtering,百分比渐进过滤)用最少的额外代码近似这件事,本节把它做成纯整数运算,并量出质量与开销的曲线。
换一个能看清半影的场景
4.1、4.2 用的烛影山谷地形起伏太密,半影会淹没在自遮挡里。本节换一个极简场景:一块 8x8 的地面加一面悬空的墙,共 8 顶点 / 4 三角形。方向光调成让墙的影子朝相机方向落,这样半影是一条干净的带,数得清楚。
PCF 就是「邻域里数一数」
硬阴影只取一个纹素:ld - map[ty, tx] > bias。PCF 取 $N \times N$ 个纹素,每个都做一次同样的比较,累加被遮挡的个数 occ,然后
$$\text{可见度} = \frac{N^2 - \text{occ}}{N^2}$$
全是整数:分子分母都非负,除法两侧同为 floor 语义,结果量化到 Q16.16。$N = 1$ 就退化成硬阴影。$N$ 越大,参与比较的纹素越多,边界上的部分遮挡就越多,半影越宽、越平滑。
半影宽度
在影子中心那一行(y = 173)上量「既非全亮也非全暗」的像素个数,再数整幅图里的半影像素与全暗像素:
| N | 采样数 N² | 整幅半影像素 | 全暗像素 | 影子总像素 |
|---|---|---|---|---|
| 1 | 1 | 0 | 1015 | 1015 |
| 3 | 9 | 1103 | 515 | 1618 |
| 5 | 25 | 2146 | 165 | 2311 |
| 7 | 49 | 3059 | 0 | 3059 |
$N = 1$ 时半影为 0——这正是「硬阴影」的定义。$N = 3$ 立刻出现 1103 个半影像素,$N = 7$ 时半影涨到 3059、全暗像素归零。注意影子总像素从 1015 涨到 3059:PCF 把边界向外扩,因为邻域里有任何一个纹素被遮挡就贡献一部分阴影。
同一条扫描线上的软像素个数(penumbra_width_on_scanline)是 0 / 76 / 88 / 94。它和上表的整幅半影像素是两个量:前者只数那一条线,后者数整幅图。$N = 3$ 到 $N = 7$,扫描线上从 76 涨到 94——说明核变大主要是在把边界抹平,而不是无限加宽。
质量与代价
$N$ 越大越接近真实的软阴影。把 $N = 15$ 当作数值参考,量每档可见度与参考的 MAE,再记整帧墙钟时间:
| N | 采样数 N² | MAE vs N=15 | 每像素 (µs) | 整帧 (ms) |
|---|---|---|---|---|
| 1 | 1 | 0.12926 | 5.024 | 114.1 |
| 3 | 9 | 0.11639 | 7.667 | 174.1 |
| 5 | 25 | 0.10724 | 11.199 | 254.3 |
| 7 | 49 | 0.09882 | 17.755 | 403.1 |
这条曲线是本节最该记住的:从 $N = 1$ 到 $N = 3$,MAE 只从 0.12926 降到 0.11639(降约 10%),代价却从 114.1 ms 涨到 174.1 ms(涨 53%);从 $N = 5$ 到 $N = 7$,MAE 再降 8%,代价再涨 58%。PCF 是收益递减的:采样数按 $N^2$ 增长,质量只按 $1/N$ 量级改善。$N = 3$(9 个采样)是性价比拐点;$N = 7$ 以上通常要靠抖动 + 空间上采样,或者干脆换 PCSS / 方差阴影贴图,而不是继续加大核。
S 层脚本
半影、质量、代价三个量都在一个脚本里,真跑得出:
#!/usr/bin/env python3
# file: lantern/sim/cg_shade_04_3_pcf.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_shade_04_3_pcf.py
"""4.3《软阴影与 PCF》:N×N 邻域比对、半影半径与核大小的权衡、以及 PCF 的开销曲线。
场景换成一块**地面 + 一面悬空的墙**(8 顶点 / 4 三角形):地形那种起伏会把半影
淹没在自遮挡里,看不出 PCF 在做什么。方向光刻意调成让墙的影子朝相机方向落。
PCF 判定仍是纯整数:N² 个纹素各做一次 `ld - map > bias` 比较,`occ` 累加,可见度
= `(N² - occ) / N²`。除法只在**非负**分子上做,两侧同为 floor 语义。
质量用「与 N=15 参考的可见度 MAE」度量(把 N=15 当作半影的数值参考),
开销用整帧墙钟毫秒。
"""
import hashlib
import json
import os
import sys
import time
import numpy as np
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
sys.path.insert(0, HERE)
import cglib # noqa: E402
import cg_shade_04_shadowlib as lib # noqa: E402
OUT = os.path.join(ROOT, "out")
os.makedirs(OUT, exist_ok=True)
scene = cglib.load_spec(os.path.join(ROOT, "config", "cg_shade_04_pcf_scene.json"))
sh = cglib.load_spec(os.path.join(ROOT, "config", "cg_shade_04_shadow.json"))
RES = sh["lightRes"]
BIAS = 2048 # 4.2 的曲线说明:这个斜率下 ~2048 才能让 N≥5 的邻域不误伤
REF_N = 15
def lit_fraction(r, pcf_n):
"""可见度 = (N² - occ) / N²,整数量化到 Q16.16。"""
occ = r["occ_count"].astype(np.int64)
den = pcf_n * pcf_n
return ((den - occ) << 16) // den
def run(pcf_n, bias=BIAS):
t0 = time.perf_counter()
r = lib.render_explicit_scene(scene, sh, RES, bias, pcf_n)
ms = (time.perf_counter() - t0) * 1000.0
return r, ms
ref, _ = run(REF_N)
ref_lit = lit_fraction(ref, REF_N)
cov = ref["covered_mask"]
rows = []
for n in sh["pcfSizes"]:
r, ms = run(n)
lit = lit_fraction(r, n)
occ = r["occ_count"].astype(np.int64)
den = n * n
penumbra = int(((occ > 0) & (occ < den) & cov).sum())
full = int(((occ == den) & cov).sum())
diff = np.abs(lit[cov] - ref_lit[cov])
mae = float(diff.mean()) / 65536.0
rows.append({
"n": int(n),
"samples": int(den),
"penumbra_pixels": penumbra,
"full_shadow_pixels": full,
"shadow_pixels": int(r["shadow_pixels"]),
"mae_vs_ref_q16": int(diff.mean()),
"mae_vs_ref": round(mae, 5),
"ms": round(ms, 1),
"per_pixel_us": round(ms * 1000.0 / max(1, int(cov.sum())), 3),
"sha256": hashlib.sha256(np.clip(r["color"], 0, 255).astype(np.uint8).tobytes()).hexdigest(),
})
# 半影半径:在影子中心那一行上量「从全亮到全暗」跨越多少像素
ys, xs = np.nonzero(cov)
mid_y = int(np.median(ys[ys > 0])) if len(ys) else 0
widths = {}
for n in sh["pcfSizes"]:
r, _ = run(n)
occ = r["occ_count"].astype(np.int64)
den = n * n
row = cov[mid_y]
soft = (occ[mid_y] > 0) & (occ[mid_y] < den) & row
widths[str(n)] = int(soft.sum())
cglib.save_png(os.path.join(OUT, "cg_shade_04_3_pcf1.png"), lib.render_explicit_scene(scene, sh, RES, BIAS, 1)["color"])
cglib.save_png(os.path.join(OUT, "cg_shade_04_3_pcf7.png"), lib.render_explicit_scene(scene, sh, RES, BIAS, 7)["color"])
report = {
"canvas": [scene["width"], scene["height"]],
"lightRes": RES,
"bias": BIAS,
"reference_n": REF_N,
"covered_pixels": int(cov.sum()),
"scanline_y": mid_y,
"penumbra_width_on_scanline": widths,
"pcf_curve": rows,
}
with open(os.path.join(OUT, "cg_shade_04_3_pcf.json"), "w") as f:
json.dump(report, f, indent=2)
for k, v in report.items():
if k == "pcf_curve":
print("pcf_curve:")
for row in v:
print(" ", row)
else:
print(f"{k:28s}: {v}")
canvas : [320, 240]
lightRes : 256
bias : 2048
reference_n : 15
covered_pixels : 22704
scanline_y : 173
penumbra_width_on_scanline : {'1': 0, '3': 76, '5': 88, '7': 94}
这里 bias 用的是 2048 而不是 4.1 的 256:墙的影子落在斜面上,4.2 的曲线表明这个斜率下邻域会误伤——$N \ge 5$ 时邻域跨到墙面自己的深度上,bias 太小就会在墙根拉出一圈假阴影。这正是 4.2 那条权衡曲线在真实参数选择上的用法:偏差不是一章的练习题,它直接决定 PCF 的核能不能开大。
P 层:邻域循环的定点实现
P 层的 shadowLit 与 S 层逐行同构,只是宿主换成了 TypeScript。$N = 1$ 走一条快路径(省掉循环),$N \ge 3$ 走通用邻域;两次右移、越界跳过、DEPTH_MAX 哨兵值的处理完全一致:
// snippet: core/src/lantern/cg_shade_04_shadow.ts(PCF 邻域累加)
// verify: node --test 'core/test/cg_shade_04_shadow.test.ts'
export function shadowLit(
ld: number, lu: number, lv: number,
shadowDepth: Float64Array, res: number, bias: number, pcfN: number,
): [number, number] {
const shift = resShift(res);
if (pcfN <= 1) { // 硬阴影:单个纹素
const tx = (lu >> shift) >> 6;
const ty = (lv >> shift) >> 6;
if (tx >= 0 && tx < res && ty >= 0 && ty < res) {
const md = shadowDepth[ty * res + tx];
if (md !== DEPTH_MAX && ld - md > bias) return [0, 1];
}
return [1, 1];
}
const half = (pcfN - 1) >> 1; // N=3 -> 1, N=5 -> 2, N=7 -> 3
let occ = 0;
let total = 0;
for (let dy = -half; dy <= half; dy += 1) {
const ty = ((lv >> shift) >> 6) + dy;
for (let dx = -half; dx <= half; dx += 1) {
const tx = ((lu >> shift) >> 6) + dx;
total += 1;
if (tx >= 0 && tx < res && ty >= 0 && ty < res) {
const md = shadowDepth[ty * res + tx];
if (md !== DEPTH_MAX && ld - md > bias) occ += 1;
}
}
}
return [total - occ, total]; // 分子分母都非负
}
返回的是 (可见采样数, 采样总数) 一对整数,可见度在调用方按 (lit << 16) / total 量化——除法只发生在非负分子上,两侧同为 floor 语义,这条纪律贯穿整卷。half = (pcfN - 1) >> 1 也要求 $N$ 是奇数:$N$ 为偶数时核不对称,半影会偏向一侧,所以 PCF 的核一律取 $1, 3, 5, 7$。
为什么是箱式核
PCF 用的是均匀权重的箱式核:每个邻域纹素权重相同。换成高斯权重能让半影更平滑,但代价是权重不再是整数、乘法变多,而且在高频边界上箱式核与高斯核的 MAE 差异远小于把 $N$ 从 3 提到 5 的收益。先用整数箱式核把 $N$ 定下来,再考虑权重——这是本节全部数字支持的顺序。
W 层用同一份极简场景在 WebGL2 里跑 $N = 1$ 与 $N = 3$ 两档,片元着色器里的邻域循环与 CPU 完全同构(4.1 已给出完整 GLSL),按声明容差比对:MAE 0.3695 / 0.4886、PSNR 38.82 / 40.45 dB、覆盖归属差异 0.08‰;负例(bias = 0 自遮挡)MAE 15.3889、PSNR 18.57 dB,被同一判据拒绝。同样要说明:本机无头环境的 WebGL2 后端是 ANGLE/SwiftShader 软件实现,不是真实 GPU,这些差异不得归因于真实硬件。
半影、采样数与代价三者在此闭合:PCF 用 $N^2$ 次整数比较换一条更宽的半影,而它的收益按 $1/N$ 递减。整个第四章也在这里收束——从「挡没挡住」的布尔判定,到「偏差该多大」的解析权衡,再到「挡了多少」的部分可见度。下一章换一个方向:不再问这点有没有被某个遮挡物挡住,而是问这点周围有多大比例的天空被挡住。
阅读导航:上一节:4.2 分辨率、偏差与走样 · 下一节:5.1 环境光遮蔽 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。