4.3 软阴影与 PCF

在深度图上取 N x N 邻域逐个做整数遮蔽比较,可见度=(N^2-occ)/N^2,把硬边界摊成半影。8 顶点 / 4 三角形的极简场景里,N=1/3/5/7 的半影像素为 0/1103/2146/3059,与 N=15 参考的可见度 MAE 为 0.12926/0.11639/0.10724/0.09882,整帧 114.1 到 403.1 ms,收益按 1/N 递减。

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²整幅半影像素全暗像素影子总像素
11010151015
3911035151618
52521461652311
749305903059

$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)
110.129265.024114.1
390.116397.667174.1
5250.1072411.199254.3
7490.0988217.755403.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 环境光遮蔽 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cg」更多文章

  1. 16.3 交付卷三
  2. 16.2 三种消费的联调
  3. 16.1 着色管线的装配