本版配套:下载小游戏前三卷完整教学快照 。先解压完整依赖,再按包内说明运行;文末的完整文件可单独用于修订。新补参数为教学假设,正文历史快照的固定输出需与当前配置区分。
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 中心/前向 |
|---|---|---|---|
| 16 | 2.193 / 0.0493 | 27.1038 / 0.1089 | 12684 / 3975 |
| 64 | 7.2086 / 0.0342 | 32.8506 / 0.0772 | 18765 / 12213 |
| 256 | 33.5183 / 0.0765 | 66.6509 / 0.1349 | 19190 / 21380 |
第一列的最大误差里塞着 16 个折痕像素——采样点两侧取胜的原语不是同一个(比如一侧是球、另一侧是盒)。把它们剔掉,剩下的 1979 个像素是这样:
| h(Q16) | 中心差分 max/mean | 前向差分 max/mean | |g|-1 max 中心/前向 |
|---|---|---|---|
| 16 | 2.193 / 0.0489 | 4.5989 / 0.0923 | 2313 / 3975 |
| 64 | 0.7078 / 0.0243 | 1.0965 / 0.0318 | 581 / 1079 |
| 256 | 0.633 / 0.0222 | 0.633 / 0.0223 | 119 / 432 |
按「该像素的取胜原语」分组(中心差分 max/mean,单位度):
| h(Q16) | 球(101 像素) | 盒(130 像素) | 平面(1764 像素) |
|---|---|---|---|
| 16 | 2.193 / 0.958 | 0.8953 / 0.0128 | 0 / 0.0 |
| 64 | 0.7078 / 0.4765 | 7.2086 / 0.1539 | 0 / 0.0 |
| 256 | 0.633 / 0.4357 | 33.5183 / 0.8349 | 0 / 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θ‰ | 平面探针‰ | 受光最小‰ | 受光均值‰ | 半影像素 | 变暗像素 | 漏光像素 |
|---|---|---|---|---|---|---|---|
| 32768 | 288.6658 | 288.6963 | 3.6316 | 274.1582 | 1971 | 1971 | 0 |
| 65536 | 577.3468 | 577.3926 | 7.2632 | 548.322 | 1971 | 254 | 0 |
| 131072 | 1154.6936 | 1000.0 | 14.5264 | 960.5729 | 254 | 51 | 0 |
| 262144 | 2309.3872 | 1000.0 | 29.0527 | 988.4561 | 51 | 21 | 0 |
| 524288 | 4618.7744 | 1000.0 | 58.1055 | 995.3232 | 21 | 10 | 0 |
| 1048576 | 9237.5488 | 1000.0 | 116.2109 | 998.1995 | 10 | 2 | 0 |
- 探针列与解析列逐行吻合。 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 个半影像素上的可见度偏差(‰):
| 轴 | 取值 | 平均偏差‰ | 最大偏差‰ |
|---|---|---|---|
| steps | 4 | 44.7327 | 1000.0 |
| steps | 8 | 41.1226 | 1000.0 |
| steps | 16 | 10.1227 | 410.9497 |
| steps | 32 | 0.0232 | 6.8359 |
| steps | 64 | 0.0 | 0.0 |
| tmin | 16 | 0.0 | 0.0 |
| tmin | 64 | 0.0 | 0.0 |
| tmin | 256 | 0.0498 | 13.7634 |
| tmin | 1024 | 0.6143 | 39.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 噪声与收敛的度量 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。