11.3 SDF 的法线与软阴影

有了求交点,法线靠差分、软阴影靠沿光取样,两者都不是免费午餐:差分步长 h 太大会跨过棱角、太小会被定点量化吃掉,实测中心差分在 h=64 时均值 0.0342 度;软阴影有一个系统性平面偏置 k*sin(theta),k 必须超过 1/sin(theta)≈1.732 才能让开阔地面回到满亮度。

本版配套:下载小游戏前三卷完整教学快照 。先解压完整依赖,再按包内说明运行;文末的完整文件可单独用于修订。新补参数为教学假设,正文历史快照的固定输出需与当前配置区分。

11.3 SDF 的法线与软阴影

站内 /graphics-signed-distance-fields/ 那篇把 SDF 的梯度当造型工具用——它做的是浮雕、挤出、软过渡这类画面效果。本节把同一个梯度当光照量用:法线决定明暗朝向,沿光线的场值序列决定遮蔽,两者都不接受「看起来对」。11.2 已经把求交的误差量成了命中、漏交与穿透,本节量的是求交之后那两个派生量的误差,并把它搬进 WebGL2 着色器。

法线:差分步长与折痕

1995 个命中像素,每个像素的差方法线与解析法线比角度(单位:度)。差分步长 h 取 16 / 64 / 256(Q16.16):

h(Q16)中心差分 max/mean前向差分 max/mean|g|-1 max 中心/前向
162.193 / 0.049327.1038 / 0.108912684 / 3975
647.2086 / 0.034232.8506 / 0.077218765 / 12213
25633.5183 / 0.076566.6509 / 0.134919190 / 21380

第一列的最大误差里塞着 16 个折痕像素——采样点两侧取胜的原语不是同一个(比如一侧是球、另一侧是盒)。把它们剔掉,剩下的 1979 个像素是这样:

h(Q16)中心差分 max/mean前向差分 max/mean|g|-1 max 中心/前向
162.193 / 0.04894.5989 / 0.09232313 / 3975
640.7078 / 0.02431.0965 / 0.0318581 / 1079
2560.633 / 0.02220.633 / 0.0223119 / 432

按「该像素的取胜原语」分组(中心差分 max/mean,单位度):

h(Q16)球(101 像素)盒(130 像素)平面(1764 像素)
162.193 / 0.9580.8953 / 0.01280 / 0.0
640.7078 / 0.47657.2086 / 0.15390 / 0.0
2560.633 / 0.435733.5183 / 0.83490 / 0.0

读这张表有四个点:

  • 中心差分一律优于前向差分。 三档 h 上最大误差是 2.193 / 27.1038、7.2086 / 32.8506、33.5183 / 66.6509,前向差分全部更差,因为它把两个采样点放在同一侧,截断误差是 O(h) 而不是 O(h²)。
  • 平面在任何 h 下都精确为 0 度。 半空间的场是线性的(f(p) = p.y - h),中心差分 (f(p+hŷ) - f(p-hŷ)) / 2h 恒等于 1,梯度范数恰好是 1——这是「差分法线对线性场无误差」的实测,也说明下面所有误差都来自曲率与折痕,不是来自差分格式本身。
  • 球与盒随 h 的走向相反。 球是光滑凸面,h 越大越躲得开量化噪声(2.193 → 0.7078 → 0.633);盒有棱角,h 一大就跨到另一个面上去了(0.8953 → 7.2086 → 33.5183)。同一个 h 不可能同时照顾两者。
  • 折痕像素吃掉全部最大误差。 h=64 时全量最大 7.2086 度,去掉 16 个折痕像素只剩 0.7078 度——误差大了 10 倍,源头是 0.8% 的像素。把 h 调小能降低跨原语的概率(h=16 时全量最大 2.193 度),但代价是量化误差上升。

量化误差有个下限:分子是定点整数,f 的一个 ulp 是 1/65536 世界单位,中心差分除以 2h = 32 ulp 后,单个 ulp 的量化误差被放大成 1/32 ≈ 0.0313。h=16 实测的 |g|-1 最大是 12684 Q16.16 = 0.1936,是这条下限的 6.2 倍,多出来的部分来自 len_q16 开方后的截断,以及差分确实跨到了不同原语。所以 h 不能无限小:h=16 的均值(0.0493)反而比 h=64(0.0342)差。这段场景里 h=64 是折中,与着色器里 uNormH 取同一个值。

软阴影:平面偏置

IQ 式软阴影沿光线取 res = min(res, k·h/t),步长推进 t += clamp(h, tmin, tmax)。在地面上取一点、朝光方向走一步就会发现一件事:地面本身就在场里,所以 h 不只是「离遮挡物的距离」,还包含「离地面自己的距离」。光线与地面夹角 θ 满足 sin θ = 光向量的 y 分量 = 0.577354,于是 h ≈ t·sin θ 恒成立,每一步都贡献 k·sin θ,整条光线的可见度被钉在 min(k·sin θ, 1)。

这不是噪声,是系统性偏置,可以解析预测。k 从 32768 扫到 1048576(Q16.16),steps = 32:

k(Q16)k·sinθ‰平面探针‰受光最小‰受光均值‰半影像素变暗像素漏光像素
32768288.6658288.69633.6316274.1582197119710
65536577.3468577.39267.2632548.32219712540
1310721154.69361000.014.5264960.5729254510
2621442309.38721000.029.0527988.456151210
5242884618.77441000.058.1055995.323221100
10485769237.54881000.0116.2109998.19951020
  • 探针列与解析列逐行吻合。 288.6963 对 288.6658、577.3926 对 577.3468,之后两列都撞到 1000.0 的上限(res 上限是 1)。偏置的机理就是 k·sin θ,没有第二个来源——这一列的存在就是为了让「我知道偏置从哪来」变成可核对的数。
  • k 必须大于 1/sin θ ≈ 1.7320 世界单位,否则完全开阔的地面也到不了满亮度:k = 65536(1.0 世界单位)时地面探针只有 577.3926‰,等于整个地面被平白压暗 42%。这是最容易误判成「光照没调好」的一档。
  • k 越大偏置越被压过,但半影会收缩。 半影像素 1971 → 254 → 51 → 21 → 10,k = 1048576(16 世界单位)时全画面只有 2 个像素被「变暗」。软阴影退化成几乎不存在的硬阴影——用大 k 压偏置,是在拿软度换。
  • 漏光列恒为 0。 场值一旦为负(h < 0)就立即返回 0,完全遮蔽区永远是 0,k 再大也不会把遮挡漏过去。这条是这套近似最重要的性质:宁可偏置,不可漏光。

这段场景取 k = 131072(2.0 世界单位):平面探针已经饱和到 1000.0,半影还剩 254 个像素,漏光 0。

这条偏置有 22 行的最小复现——直接调 S 层同一个 soft_shadow,把解析预测与实测并排打出来(/opt/homebrew/bin/python3 sim/cg_trace_11_sdf_probe.py,输出的第一行 32768 288.6658 288.6963 与上表前两列逐位相同):

#!/usr/bin/env python3
# file: lantern/sim/cg_trace_11_sdf_probe.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_trace_11_sdf_probe.py
"""平面偏置探针:解析 min(k*sin(theta),1) 与实测 soft_shadow 可见度(‰)逐档对照。"""
import os
import sys

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cglib  # noqa: E402
import cg_trace_11_sdf as sdf  # noqa: E402

ONE = cglib.ONE
cfg = sdf.load_sdf_config()
scene = sdf.make_scene(cfg)
LDIR = cglib.v_norm(tuple(cfg["light"]["dirQ16"]))
SO = cglib.v_add((-3 * ONE, 0, -3 * ONE), (0, sdf.EPS, 0))
print("k(Q16)   解析 min(k*sinθ,1)‰   实测探针‰")
for _k in cfg["softShadow"]["kSweepQ16"]:
    _a = min(cglib.fmul(_k, LDIR[1]), ONE)
    _v = sdf.soft_shadow(scene, SO, LDIR, cfg, k=_k)
    print(f"{_k:>7d}   {1000.0 * _a / ONE:>12.4f}   {1000.0 * _v / ONE:>12.4f}")

收敛:步数与起点

以 steps = 512、tmin = 16 为参考解,比较 263 个半影像素上的可见度偏差(‰):

轴取值平均偏差‰最大偏差‰
steps444.73271000.0
steps841.12261000.0
steps1610.1227410.9497
steps320.02326.8359
steps640.00.0
tmin160.00.0
tmin640.00.0
tmin2560.049813.7634
tmin10240.614339.1846
  • steps = 4 与 8 的最大偏差是 1000.0‰。 也就是说有像素被算成完全受光、而参考解是完全遮蔽(或反过来),整条光线只走了四步、错过了最近的遮挡物。
  • steps = 32 起进入 0.0x‰ 量级,steps = 64 与 512 步逐位相同——此时循环已经不是走完步数,而是提前撞到 t > tmax 退出,再加步数不会改变任何像素。
  • tmin 是起点偏移,跳过的是离面最近的那一段,而 k·h/t 恰恰在那一段最大。16 与 64 都是 0.0,256(0.0039 世界单位)起开始退化,1024(0.0156)时最大偏差 39.1846‰。把起点往外推以为能省掉自遮挡,代价就是丢掉最该判遮挡的采样。

整帧(软阴影)命中 1995 像素,与硬阴影相比有 249 个像素不同,sha256 = 476c7337167f6fda…——11.2 的 S 层金标与本节用的是同一份 out/cg_trace_11_3_frame.rgb。

P 层用 TypeScript 重写同一条链路,把上面四张表逐档钉死:core/test/cg_trace_11_3_normal_softshadow.test.ts(6 个断言)覆盖法线三档 h、k 扫描的平面偏置探针、收敛表(参考解 steps=512 / tmin=16)、以及整帧 sha256 与「换一个 k 后 sha256 必须变」的负例。

W 层:搬进 WebGL2 着色器

同一套链路进片元着色器。法线用中心差分、软阴影用同一条递推,量化步骤照抄(floor 的落点与 CPU 侧一致),只有数的表示从 Q16.16 换成浮点:

// snippet: web/build_trace11.mjs(着色器源,此处按 GLSL 呈现,文件里是 JS 字符串数组)
// verify: NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_trace11.mjs
vec3 normalCentral(vec3 p) {
  vec3 g = vec3(
    sdScene(p + vec3(uNormH, 0.0, 0.0)) - sdScene(p - vec3(uNormH, 0.0, 0.0)),
    sdScene(p + vec3(0.0, uNormH, 0.0)) - sdScene(p - vec3(0.0, uNormH, 0.0)),
    sdScene(p + vec3(0.0, 0.0, uNormH)) - sdScene(p - vec3(0.0, 0.0, uNormH)));
  return normalize(g);
}
float softShadow(vec3 ro, vec3 rd) {
  float res = 1.0;
  float t = uMinStep;
  for (int i = 0; i < 512; i++) {
    if (float(i) >= uSteps) break;
    float h = sdScene(ro + rd * t);
    if (h < 0.0) return 0.0;
    float r = floor(uK * floor(h / t * ONE)) / ONE;
    if (r < res) res = r;
    t += min(max(h, uMinStep), uMaxStep);
    if (t > uTMax) break;
  }
  return res;
}

对账脚本把三样东西摆在一起:S 层 CPU 金标、页面内的定点 CPU 路径、WebGL2 浮点着色器。判据只挑对舍入不敏感的统计量,并配一个负例:

// file: lantern/web/verify_trace11.mjs
// tier: W
// verify: NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_trace11.mjs
// 卷三第 11 章《符号距离场》的 W 层验证:WebGL2 片元着色器的 SDF ray marching 与
// S 层 CPU 定点金标对账。
//
// 对账结构(每一对只差一个自变量,这是本章「可归因」的全部含义):
//   gl     vs S 层金标 → 只差「数的表示」:定点 Q16.16 vs 浮点(量化步骤已照抄)
//   glBad  vs S 层金标 → 负例:步长放大到 1.5 倍,`|grad f| > 1` 时步长越界
//
// 判据口径(为什么不能用「逐位相等」):着色器是浮点,CPU 是 Q16.16 定点;两侧在
// **表面判定边界**(`f < eps` 的临界像素)与**软阴影步进边界**上必然有少量像素分歧。
// 故只用对舍入不敏感的统计量(MAE / PSNR),并把「覆盖归属差异」(非背景像素判据)
// 单独设上限。判据必须配负例,否则 PASS 不可信。
// 本机 headless 后端是 ANGLE/SwiftShader **软件**实现,不是真实 GPU;脚本如实打印
// 后端名,且**不得**把任何差异归因于真实硬件。

import { createRequire } from "node:module";
import { createHash } from "node:crypto";
import { mkdirSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";

const require = createRequire(import.meta.url);
const { chromium } = require("playwright");
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
const OUT = join(ROOT, "out");
mkdirSync(join(OUT, "shots"), { recursive: true });

const cfg = JSON.parse(readFileSync(join(ROOT, "config", "cg_trace_11_sdf.json"), "utf8"));
const golden = new Uint8Array(readFileSync(join(OUT, "cg_trace_11_3_frame.rgb")));
const BG = cfg.render.backgroundQ8;
const W = cfg.render.width;
const H = cfg.render.height;
const TOL = { mae: 1.0, psnr: 30, coverPermille: 20 };
const sha = (b) => createHash("sha256").update(Buffer.from(b)).digest("hex");

const covered = (rgb) => {
  const m = new Uint8Array(rgb.length / 3);
  for (let p = 0; p < m.length; p += 1) {
    const o = p * 3;
    if (rgb[o] !== BG[0] || rgb[o + 1] !== BG[1] || rgb[o + 2] !== BG[2]) m[p] = 1;
  }
  return m;
};

function stats(actual, expect, acov, ecov) {
  let diffPx = 0;
  let coverDiff = 0;
  let maxDiff = 0;
  let sumAbs = 0;
  let sumSq = 0;
  const pixels = expect.length / 3;
  for (let i = 0; i < expect.length; i += 3) {
    const p = i / 3;
    let pxDiff = 0;
    for (let c = 0; c < 3; c += 1) {
      const d = Math.abs(actual[i + c] - expect[i + c]);
      if (d > pxDiff) pxDiff = d;
      sumAbs += d;
      sumSq += d * d;
    }
    if (pxDiff > 0) diffPx += 1;
    if (pxDiff > maxDiff) maxDiff = pxDiff;
    if (acov[p] !== ecov[p]) coverDiff += 1;
  }
  const mse = sumSq / expect.length;
  return {
    pixels, diffPx, coverDiff, maxDiff,
    mae: sumAbs / expect.length,
    psnr: mse === 0 ? Infinity : 10 * Math.log10((255 * 255) / mse),
  };
}

const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto(`file://${join(ROOT, "web", "dist", "trace11.html")}`);
await page.waitForFunction(() => globalThis.__lantern && globalThis.__lantern.canvasDrawn, null, { timeout: 60000 });
const data = await page.evaluate(() => globalThis.__lantern);
const shot = join(OUT, "shots", "trace11_gl.png");
await page.screenshot({ path: shot });
await browser.close();

const cpu = Uint8Array.from(data.cpu.rgb);
const gl = Uint8Array.from(data.gl.rgb);
const glBad = Uint8Array.from(data.gl.bad);

let cpuVsS = 0;
for (let i = 0; i < cpu.length; i += 1) if (cpu[i] !== golden[i]) cpuVsS += 1;

const goldCov = covered(golden);
const sMain = stats(gl, golden, covered(gl), goldCov);
const sBad = stats(glBad, golden, covered(glBad), goldCov);

const per = (n, total) => `${n} / ${total} (${((n / total) * 1000).toFixed(2)}‰)`;

console.log(`canvasDrawn     : ${data.canvasDrawn}(非背景 ${data.drawnPixels} 像素,判据是「>= 1」)`);
console.log(`场景            : ${data.meta.width}x${data.meta.height} / 法线步长 h=${data.meta.normH} / k=${data.meta.k} / 软阴影 ${data.meta.steps} 步`);
console.log(`GL 后端         : ${data.gl.unmaskedRenderer}`);
console.log(`GL 版本         : ${data.gl.version}   glError : ${data.gl.glError}`);
console.log(`S 层金标 sha256 : ${sha(golden).slice(0, 16)}…  命中 ${data.cpuHits} 像素`);
console.log(`① 页面内 CPU 定点 vs S 层金标:不同字节 = ${cpuVsS}  ← 必须为 0`);
console.log(`   着色器自报命中 ${data.glHitPixels} 像素 vs 页面内 CPU ${data.cpuHits} 像素(差 ${Math.abs(data.glHitPixels - data.cpuHits)},判据 |差| <= 8)`);
console.log(`② WebGL2(浮点着色器,量化步骤照抄)vs S 层金标:`);
console.log(`   任一通道差>0 : ${per(sMain.diffPx, sMain.pixels)}`);
console.log(`   覆盖归属不同 : ${per(sMain.coverDiff, sMain.pixels)}  ← 主判据`);
console.log(`   最大通道差   : ${sMain.maxDiff}   MAE : ${sMain.mae.toFixed(4)}   PSNR : ${sMain.psnr.toFixed(2)} dB`);
console.log(`③ 负例(步长 x1.5,Lipschitz 被违反):`);
console.log(`   覆盖归属不同 : ${per(sBad.coverDiff, sBad.pixels)}`);
console.log(`   最大通道差   : ${sBad.maxDiff}   MAE : ${sBad.mae.toFixed(4)}   PSNR : ${sBad.psnr.toFixed(2)} dB  ← 必须被同一判据拒绝`);
console.log(`   截图         : ${shot}`);

const fail = [];
if (data.error) fail.push(`页面报错:${data.error}`);
if (!data.gl) fail.push("WebGL2 上下文不可用");
if (cpuVsS !== 0) fail.push(`① 页面内 CPU 与 S 层金标不同(${cpuVsS} 字节)`);
if (Math.abs(data.glHitPixels - data.cpuHits) > 8) fail.push(`① 着色器命中数 ${data.glHitPixels} 与页面内 CPU ${data.cpuHits} 相差超过 8 像素`);
if (sMain.mae > TOL.mae) fail.push(`② MAE ${sMain.mae.toFixed(4)} 超出 ${TOL.mae}`);
if (sMain.psnr < TOL.psnr) fail.push(`② PSNR ${sMain.psnr.toFixed(2)} dB 低于 ${TOL.psnr} dB`);
if ((sMain.coverDiff / sMain.pixels) * 1000 > TOL.coverPermille) fail.push(`② 覆盖归属差异超出 ${TOL.coverPermille}‰`);
if (sBad.mae <= TOL.mae && sBad.psnr >= TOL.psnr) fail.push("③ 负例未被判据拒绝 —— 判据无区分力");
if (fail.length) {
  console.log(`\nFAIL:\n  ${fail.join("\n  ")}`);
  process.exit(1);
}
console.log("\nPASS");

跑一遍(工作区 lantern/,用全局 Playwright 1.59.1):

# snippet: 本机实跑(工作区 lantern/)
NODE_PATH=/opt/homebrew/lib/node_modules /opt/homebrew/bin/node web/verify_trace11.mjs
canvasDrawn     : true(非背景 1997 像素,判据是「>= 1」)
场景            : 64x48 / 法线步长 h=64 / k=131072 / 软阴影 32 步
GL 后端         : ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (LLVM 10.0.0) (0x0000C0DE)), SwiftShader driver)
GL 版本         : WebGL 2.0 (OpenGL ES 3.0 Chromium)   glError : 0
S 层金标 sha256 : 476c7337167f6fda…  命中 1995 像素
① 页面内 CPU 定点 vs S 层金标:不同字节 = 0  ← 必须为 0
   着色器自报命中 1997 像素 vs 页面内 CPU 1995 像素(差 2,判据 |差| <= 8)
② WebGL2(浮点着色器,量化步骤照抄)vs S 层金标:
   任一通道差>0 : 67 / 3072 (21.81‰)
   覆盖归属不同 : 2 / 3072 (0.65‰)  ← 主判据
   最大通道差   : 45   MAE : 0.0778   PSNR : 46.41 dB
③ 负例(步长 x1.5,Lipschitz 被违反):
   覆盖归属不同 : 53 / 3072 (17.25‰)
   最大通道差   : 198   MAE : 5.8344   PSNR : 20.91 dB  ← 必须被同一判据拒绝
   截图         : /Users/plumephp/.cgbook/lantern/out/shots/trace11_gl.png

PASS

三条口径必须写在明处:

  • 后端是软件实现。 输出里如实打印 ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device …), SwiftShader driver)——这是 SwiftShader,不是真实 GPU。所以本章不写「GPU 与 CPU 逐位一致」;真正逐位一致的那一条是页面内定点 CPU 路径 vs S 层金标:不同字节 = 0(同为 Q16.16 整数运算,可以也应该逐位比)。
  • 浮点着色器只用容差判据:MAE ≤ 1.0、PSNR ≥ 30 dB、覆盖归属差 ≤ 20‰。实测 MAE 0.0778、PSNR 46.41 dB、覆盖差 2/3072 = 0.65‰,都留了大余量。着色器自报命中 1997 像素、页面内 CPU 1995 像素,差 2(判据 |差| ≤ 8)——差的正是 f < eps 的临界像素,也就是容差本来就该覆盖的那几个。
  • 判据必须配负例,否则 PASS 不可信。 负例把步长放大到 1.5 倍,故意违反 Lipschitz:同一套判据下 MAE 5.8344、PSNR 20.91 dB、覆盖差 17.25‰,被拒绝。没有这个负例,「MAE 0.0778」只能说明两侧一致,不能说明判据有区分力。

另外一处工程细节:冷启动后第一个 page 读 canvas 像素恒为空白,脚本里先做一次预热绘制再取图,canvasDrawn 的断言也放宽成「至少画出 1 个非背景像素」(实测 1997 个),而不是「不等于 0」。

小结

  • 中心差分优于前向差分:三档 h 的最大误差 2.193/27.1038、7.2086/32.8506、33.5183/66.6509 度,前向差分全部更差。
  • 平面法线在任何 h 下精确为 0 度(线性场,差分无误差);误差全部来自曲率与折痕。16 个折痕像素就吃掉了全部最大误差(h=64:7.2086 → 0.7078 度)。
  • h 的两侧都有代价:h=16 的量化下限是 1/32 ≈ 0.0313,实测 |g|-1 最大 0.1936;h=256 跨棱角,盒的最大误差涨到 33.5183 度。本场景取 h=64。
  • 软阴影有系统性平面偏置 min(k·sin θ, 1),sin θ = 0.577354,实测探针与解析预测逐行吻合;k 必须 > 1/sin θ ≈ 1.7320,否则开阔地面被平白压暗(k=65536 时只剩 577.3926‰)。
  • k 越大偏置越小、半影越窄(1971 → 10 像素),漏光恒为 0——宁可偏置,不可漏光。本场景取 k=131072。
  • 收敛:steps = 32 平均偏差 0.0232‰、steps = 64 与 512 步参考逐位相同;tmin 往外推会丢掉最近的采样(1024 时最大偏差 39.1846‰)。
  • W 层对账:页面内定点 CPU vs S 层金标不同字节 0;WebGL2(SwiftShader 软件后端)vs 金标 MAE 0.0778 / PSNR 46.41 dB / 覆盖差 0.65‰,负例 5.8344 / 20.91 dB / 17.25‰ 被拒绝。

阅读导航:上一节:11.2 ray marching 求交 · 下一节:12.1 噪声与收敛的度量 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

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