11.2 ray marching 求交
站内 /graphics-signed-distance-fields/ 那篇讲的是 SDF 渲染工程:它把 SDF 当作画面风格的一部分,关心的是形变、浮雕、软过渡。本节把 SDF 当求交工具,问题就换成了三个可量的数字——每一步走多远、什么时候停下来、停下来时离表面有多远。11.1 已经把「场可不可信」量成了 Lipschitz 与下界性,本节量的是这套求交本身的行为:第 9 章的 ray marching 沿光线等距采样,误差由步长决定、跟场景无关;sphere tracing 反过来,步长由场值给出,场景越简单收敛越快,代价是每一步都在赌「场是下界」。赌输的后果有两种,本节把两种都逼出来。
循环本体
# snippet: sim/cg_trace_11_sdf.py(节选:sphere tracing 主循环)
# verify: /opt/homebrew/bin/python3 sim/cg_trace_11_2_ray_marching.py
def march(scene, orig, dirv, cfg, scale=ONE, eps=None, max_steps=None, step_scale=ONE):
"""sphere tracing。返回 `(t, steps, status, min_f)`。
`status` ∈ {"hit", "miss", "maxsteps"};`min_f` 是过程中遇到过的最小场值
(`< 0` 说明某一步落进了表面内部——即**穿透**)。
"""
mc = cfg["marching"]
eps = mc["epsQ16"] if eps is None else eps
max_steps = mc["maxSteps"] if max_steps is None else max_steps
tmax = mc["tMaxQ16"]
t = 0
min_f = 1 << 60
for i in range(max_steps):
p = v_add(orig, tuple(fmul(dirv[k], t) for k in range(3)))
f = scene(p, scale)
if f < min_f:
min_f = f
if f < eps:
return t, i + 1, "hit", min_f
t += fmul(f, step_scale)
if t > tmax:
return None, i + 1, "miss", min_f
return None, max_steps, "maxsteps", min_f
三个出口对应三种结局:f < eps 是命中,t > tmax 是逃逸,循环走完还没命中是耗尽。另外循环里悄悄记了一个 min_f——过程中遇到过的最小场值。min_f < 0 意味着某一步的落点已经跑到表面内部,也就是穿透。穿透不一定立刻表现为错:有无限地面兜底时,越界的光线往往还会撞到别的面上,只是 t 错了;没有地面时它才会直接飞走,变成真漏交。
基线一遍
64×48 = 3072 条主光线,场景是 11.1 的「球 ∪ 盒 ∪ 地面」,maxSteps = 256、eps = 64(Q16.16):
| 量 | 实测 |
|---|---|
| 主光线 | 3072 |
| 解析命中(球/盒/平面的闭式求交取最近) | 2048 |
| marching 命中 | 1995 |
| 真漏交(解析有解而 marching 报 miss) | 0 |
| 步数耗尽 | 53 |
| 基线 max|t - t_analytic| | 2046 Q16.16 |
| 过程最小场值 min_f | 2 |
四件事值得注意:真漏交是 0——原场的下界性成立(11.1 已量),步长永远不会跨过表面,这是 sphere tracing 的核心保证;marching 命中比解析命中少 53 条,少掉的不是漏交而是耗尽,这 53 条在 256 步内没能把 f 压到 eps 以下,它们大多是贴着地面掠过的掠射光线;min_f = 2 是正的,最小场值 2 Q16.16(约 3e-5 世界单位),全程没有任何一步落进表面内部,穿透计数是 0,与 11.1 的下界性实测互相印证;最大命中误差 2046 Q16.16 ≈ 0.031 世界单位,这不是「错」,而是 eps 的定义——光线停在 f < eps 处,而不是恰好停在表面上。
迭代次数分布
| 区间 | 光线数 | 区间 | 光线数 | |
|---|---|---|---|---|
| 1–8 | 151 | 105–112 | 48 | |
| 9–16 | 1245 | 113–120 | 36 | |
| 17–24 | 667 | 121–128 | 29 | |
| 25–32 | 288 | 129–136 | 1 | |
| 33–40 | 160 | 137–144 | 1 | |
| 41–48 | 90 | 145–152 | 21 | |
| 49–56 | 105 | 153–160 | 21 | |
| 57–64 | 31 | 161–168 | 13 | |
| 65–72 | 36 | 169–176 | 8 | |
| 73–80 | 10 | 177–184 | 2 | |
| 81–88 | 32 | 249–256 | 64 | |
| 89–96 | 13 |
上表是 maxSteps = 256 的直方图(每 8 步一箱,只列非零箱)。分布是重尾的:41% 的光线(1245/3072)落在 9–16 步这一箱,90% 在 64 步以内,但尾巴一直拖到 184 步,并且最后一箱 249–256 有 64 条——那正是「耗尽」的 53 条加上少数几步之差的邻居。平均步数没有意义(被尾部拉偏),要看的是「多少比例的光线在多少步内完成」。这也是为什么下面的容差表用的是「平均步数 + 耗尽条数」两个量:前者看整体代价,后者看尾部。
容差:eps 扫描
eps 从 16 扫到 512(Q16.16),同一场景、同一批光线:
| eps(Q16) | 原场漏交 | 原场耗尽 | 原场平均步数 | 放大 1.25 倍漏交 | 放大 1.25 倍穿透 | 放大 1.25 倍平均步数 |
|---|---|---|---|---|---|---|
| 16 | 0.0‰ | 32 | 37.457 | 0.0‰ | 49 | 31.570 |
| 32 | 0.0‰ | 32 | 36.072 | 0.0‰ | 49 | 30.053 |
| 64 | 0.0‰ | 26 | 34.721 | 0.0‰ | 49 | 28.367 |
| 128 | 0.0‰ | 9 | 32.919 | 0.0‰ | 49 | 26.632 |
| 256 | 0.0‰ | 0 | 30.767 | 0.0‰ | 49 | 24.882 |
| 512 | 0.0‰ | 0 | 28.484 | 0.0‰ | 49 | 23.121 |
两列必须分开读:原场这一侧,eps 越大耗尽越少、步数越少,漏交始终是 0——eps = 16 时还有 32 条光线走满 256 步,eps = 256 之后一条都没有了,代价是命中点离表面更远(eps = 256 意味着允许停在 0.0039 世界单位处),所以 eps 不是精度旋钮而是「多快认输」的旋钮,它调的是尾部不是精度上限;放大 1.25 倍这一侧,漏交率仍然是 0,但穿透恒为 49 条——每一步都多走 25%,min_f 变负,可是没有一条光线漏交,因为有无限地面兜底,越界的光线继续往前走、最终撞在别的面上,t 错了但 status 还是 hit,「漏交率」这个指标会漏掉穿透,这就是为什么下面还要单独数穿透条数。注意穿透条数 49 在六个 eps 上完全不变,这不是巧合:穿透发生在早期几步(步长还很大、离表面还远),而 eps 只影响「什么时候停」,不影响「前面几步走多远」。
步长放大:穿透先来,漏交后到
把每一步的步长乘以 step_scale(原场 / 放大 1.25 倍两列并排):
| 步长倍数 | 原场穿透 | 原场漏交 | 原场 max|Δt| | 放大 1.25 穿透 | 放大 1.25 漏交 | 放大 1.25 max|Δt| |
|---|---|---|---|---|---|---|
| 0.5 | 0 | 0.0‰ | — | 0 | 0.0‰ | 810 |
| 0.75 | 0 | 0.0‰ | — | 0 | 0.0‰ | 808 |
| 1.0 | 0 | 0.0‰ | — | 49 | 0.0‰ | 14577 |
| 1.25 | 49 | 0.0‰ | — | 64 | 0.0‰ | 62151 |
| 1.5 | 57 | 0.0‰ | — | 143 | 0.0‰ | 1703079 |
| 2.0 | 178 | 0.0‰ | — | 268 | 0.0‰ | 180504 |
读法有三条:原场的临界点在 1.0 与 1.25 之间——0.5、0.75、1.0 倍都是 0 穿透,1.25 倍出现 49 条,2.0 倍涨到 178 条,1.0 是理论上的安全上界(Lipschitz 恰为 1),实测正好卡在那里;有地面时漏交率永远是 0,但 max|Δt| 会炸——放大 1.25 倍、步长 1.5 倍时最大命中误差 1703079 Q16.16 ≈ 26 个世界单位,光线早已偏离、只是碰巧又撞上了别的面,所以**「有命中」不等于「命中了该命中的东西」**;穿透先于漏交出现——放大 1.25 倍时 step_scale = 1.0 就已经有 49 条穿透,而此时漏交还是 0,穿透是「落点过了表面」、漏交是「整条光线飞走」,前者是后者的必要非充分前兆。
场越界时漏交怎么冒出来
把地面拿掉,场景只剩球与盒(231 条解析命中的光线)。这时越界的光线没有东西兜底,会真的逃逸:
| 场放大倍数 | 解析命中 | 真漏交 | 漏交率 | 穿透 | max|Δt| |
|---|---|---|---|---|---|
| 1.0 | 231 | 0 | 0.0‰ | 0 | 1251 |
| 1.1 | 231 | 0 | 0.0‰ | 212 | 40263 |
| 1.25 | 231 | 70 | 303.0303‰ | 161 | 100811 |
| 1.5 | 231 | 231 | 1000.0‰ | 0 | 0 |
| 2.0 | 231 | 231 | 1000.0‰ | 0 | 0 |
这张表把「Lipschitz 被违反」的后果排成了一条时间线:1.0 倍干净(0 漏交、0 穿透,最大误差 1251 Q16.16);1.1 倍全部穿透但一条都没漏(212 条穿透、0 漏交——越界了,但越界得还不够多,光线钻进物体内部后场值变负,f < eps 立刻成立,于是在物体内部报了一个命中,status 是 hit、t 是错的,这是最阴险的一档:指标全绿,画面全错);1.25 倍漏交开始出现(70 条、303.0303‰,穿透降到 161 条——跨过表面的那一步足够大,光线直接穿到物体另一侧,f 再也没小于 eps,于是飞走);1.5 倍以上全部漏交(231/231 = 1000.0‰,穿透归零,因为已经没有一条光线停在物体内部了,全都一步跨过去了)。
所以「漏交率」与「穿透条数」是一对互补的指标:场轻微越界时穿透涨、漏交不动;越界加剧时穿透转成漏交。只看其中一个都会误判。11.1 量出的高度场 Lipschitz = 1084.1064‰ 恰好落在 1.0 与 1.1 之间——也就是说,拿高度场直接当步长用,最可能的状态是「全部穿透、零漏交」,也就是那张指标全绿、画面全错的表。
P 层把数钉死
P 层用 TypeScript 重写同一条链路,BigInt 定点,断言对象是上面每一张表的每一格。拆成两个文件,cg_trace_11_2_ray_marching.test.ts 管基线与容差扫描:
// file: lantern/core/test/cg_trace_11_2_ray_marching.test.ts
// tier: P
// verify: node --test 'core/test/cg_trace_11_2_ray_marching.test.ts'
// 卷三第 11.2 节《ray marching 求交》的 P 层(上):基线一遍与容差扫描。
//
// 断言对象:
// 1. 基线一遍 —— 主光线数、解析命中、marching 命中、真漏交、步数耗尽、最大命中误差;
// 2. 容差—漏交率表 —— eps 从 16 扫到 512 的漏交率 / 步数 / 穿透条数。
// 场放大导致的真漏交与不变量见 cg_trace_11_2_miss_vs_overestimate.test.ts。
// 金标准由 S 侧产出:
// /opt/homebrew/bin/python3 sim/cg_trace_11_2_ray_marching.py
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
import {
type SdfConfig,
analyticHit,
cameraBasis,
makeScene,
march,
primaryDir,
} from "../src/lantern/cg_trace_11_sdf.ts";
import { ONE, fdiv, fmul } from "../src/lantern/fixed.ts";
import { type Vec3 } from "../src/lantern/vec.ts";
const root = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
const cfg = JSON.parse(readFileSync(join(root, "config", "cg_trace_11_sdf.json"), "utf8")) as SdfConfig;
const golden = JSON.parse(readFileSync(join(root, "out", "cg_trace_11_2_march.json"), "utf8"));
const scene = makeScene(cfg);
type Ray = { px: number; py: number; d: Vec3 };
function pixelRays(): { eye: Vec3; rays: Ray[] } {
const cam = cfg.camera;
const w = cfg.render.width;
const h = cfg.render.height;
const tanHalf = BigInt(cam.tanHalfFovYQ16);
const tanX = fmul(tanHalf, fdiv(BigInt(w), BigInt(h)));
const [right, up, back] = cameraBasis(cfg);
const eye: Vec3 = [BigInt(cam.eyeQ16[0]), BigInt(cam.eyeQ16[1]), BigInt(cam.eyeQ16[2])];
const rays: Ray[] = [];
for (let py = 0; py < h; py += 1) {
for (let px = 0; px < w; px += 1) {
rays.push({ px, py, d: primaryDir(cfg, px, py, right, up, back, tanHalf, tanX) });
}
}
return { eye, rays };
}
const { eye, rays } = pixelRays();
const half = rays.filter((r) => r.px % 2 === 0 && r.py % 2 === 0);
test("11.2 基线:命中数、漏交数、最大命中误差与 S 层一致", () => {
let hit = 0;
let miss = 0;
let exhausted = 0;
let analyticHits = 0;
let tErrMax = 0n;
let minFAll = 1n << 60n;
for (const r of rays) {
const ta = analyticHit(cfg, eye, r.d);
const res = march(scene, eye, r.d, cfg);
if (res.minF < minFAll) minFAll = res.minF;
if (res.status === "hit") hit += 1;
else if (res.status === "maxsteps") exhausted += 1;
if (ta !== null) {
analyticHits += 1;
if (res.status === "miss") miss += 1;
else if (res.t !== null) {
const e = res.t > ta ? res.t - ta : ta - res.t;
if (e > tErrMax) tErrMax = e;
}
}
}
assert.equal(rays.length, golden.primaryRays);
assert.equal(analyticHits, golden.baseline.analyticHits);
assert.equal(hit, golden.baseline.hit);
assert.equal(miss, golden.baseline.trueMiss);
assert.equal(exhausted, golden.baseline.exhausted);
assert.equal(tErrMax, BigInt(golden.baseline.tErrMaxQ16));
assert.equal(minFAll, BigInt(golden.baseline.minFQ16));
// 原场是精确距离场:真漏交必须是 0
assert.equal(miss, 0);
});
test("11.2 容差—漏交率:eps 扫描与 S 层逐档一致", () => {
const near = (a: number, b: number, what: string): void => {
assert.ok(Math.abs(a - b) < 1e-3, `${what}: ${a} vs ${b}`);
};
const sweep = (scale: bigint, table: typeof golden.toleranceExact): void => {
for (const g of table) {
const eps = BigInt(g.epsQ16);
let analytic = 0;
let misses = 0;
let exhausted = 0;
let pen = 0;
let terr = 0n;
let stepsSum = 0;
for (const r of half) {
const ta = analyticHit(cfg, eye, r.d);
const res = march(scene, eye, r.d, cfg, scale, eps);
stepsSum += res.steps;
if (res.minF < 0n) pen += 1;
if (res.status === "maxsteps") exhausted += 1;
if (ta !== null) {
analytic += 1;
if (res.status === "miss") misses += 1;
else if (res.t !== null) {
const e = res.t > ta ? res.t - ta : ta - res.t;
if (e > terr) terr = e;
}
}
}
const permille = (1000 * misses) / analytic;
near(permille, g.missPermille, `eps=${g.epsQ16} 漏交率`);
near(stepsSum / half.length, g.meanSteps, `eps=${g.epsQ16} 平均步数`);
assert.equal(misses, g.misses, `eps=${g.epsQ16} 漏交数`);
assert.equal(exhausted, g.exhausted, `eps=${g.epsQ16} 耗尽数`);
assert.equal(pen, g.penetrations, `eps=${g.epsQ16} 穿透数`);
assert.equal(terr, BigInt(g.maxTErrQ16), `eps=${g.epsQ16} 最大命中误差`);
}
};
sweep(ONE, golden.toleranceExact);
sweep((5n * ONE) / 4n, golden.toleranceOver);
});
另一半——场放大后的漏交与「原场绝不穿透」的不变量——在 cg_trace_11_2_miss_vs_overestimate.test.ts。跑一遍:
# snippet: 本机实跑(工作区 lantern/)
/opt/homebrew/bin/python3 sim/cg_trace_11_2_ray_marching.py
node --test 'core/test/cg_trace_11_2_*.test.ts'
== 11.2 ray marching 求交 ==
主光线 3072(64x48),解析命中 2048,marching 命中 1995,真漏交 0,步数耗尽 53
基线 max|t - t_analytic| = 2046 Q16.16,过程最小场值 2(< 0 即穿透)
迭代次数分箱(每 8 步一箱),maxSteps = 256:
1.. 8 : 151
9.. 16 : 1245
17.. 24 : 667
25.. 32 : 288
33.. 40 : 160
41.. 48 : 90
49.. 56 : 105
57.. 64 : 31
65.. 72 : 36
73.. 80 : 10
81.. 88 : 32
89.. 96 : 13
105..112 : 48
113..120 : 36
121..128 : 29
129..136 : 1
137..144 : 1
145..152 : 21
153..160 : 21
161..168 : 13
169..176 : 8
177..184 : 2
249..256 : 64
容差—漏交率(原场 / 放大 1.25;漏交 = 真越过表面,耗尽 = 步数用完):
eps= 16 原场 漏交 0.0‰ 耗尽 32 步数 37.457 | 放大 漏交 0.0‰ 穿透 49 步数 31.57
eps= 32 原场 漏交 0.0‰ 耗尽 32 步数 36.072 | 放大 漏交 0.0‰ 穿透 49 步数 30.053
eps= 64 原场 漏交 0.0‰ 耗尽 26 步数 34.721 | 放大 漏交 0.0‰ 穿透 49 步数 28.367
eps=128 原场 漏交 0.0‰ 耗尽 9 步数 32.919 | 放大 漏交 0.0‰ 穿透 49 步数 26.632
eps=256 原场 漏交 0.0‰ 耗尽 0 步数 30.767 | 放大 漏交 0.0‰ 穿透 49 步数 24.882
eps=512 原场 漏交 0.0‰ 耗尽 0 步数 28.484 | 放大 漏交 0.0‰ 穿透 49 步数 23.121
步长放大—穿透(原场 / 放大 1.25):
x0.5 原场 穿透 0 漏交 0.0‰ | 放大 穿透 0 漏交 0.0‰ max|Δt| 810
x0.75 原场 穿透 0 漏交 0.0‰ | 放大 穿透 0 漏交 0.0‰ max|Δt| 808
x1.0 原场 穿透 0 漏交 0.0‰ | 放大 穿透 49 漏交 0.0‰ max|Δt| 14577
x1.25 原场 穿透 49 漏交 0.0‰ | 放大 穿透 64 漏交 0.0‰ max|Δt| 62151
x1.5 原场 穿透 57 漏交 0.0‰ | 放大 穿透 143 漏交 0.0‰ max|Δt| 1703079
x2.0 原场 穿透 178 漏交 0.0‰ | 放大 穿透 268 漏交 0.0‰ max|Δt| 180504
无地面(球+盒):场放大倍数 -> 真漏交(Lipschitz 被违反时):
x1.0 解析命中 231 真漏交 0( 0.0‰)穿透 0 max|Δt| 1251
x1.1 解析命中 231 真漏交 0( 0.0‰)穿透 212 max|Δt| 40263
x1.25 解析命中 231 真漏交 70(303.0303‰)穿透 161 max|Δt| 100811
x1.5 解析命中 231 真漏交 231( 1000.0‰)穿透 0 max|Δt| 0
x2.0 解析命中 231 真漏交 231( 1000.0‰)穿透 0 max|Δt| 0
csv/json/png : out/cg_trace_11_2_{march.json,iterations,tolerance,stepscale}.csv
小结
- sphere tracing 的三个出口是命中(
f < eps)、逃逸(t > tmax)、耗尽(走满maxSteps);过程最小场值min_f < 0单独记「穿透」。迭代次数是重尾分布:1245/3072 条落在 9–16 步,尾巴拖到 184 步,另有 64 条落在 249–256(其中 53 条是耗尽)。 eps调的是尾部不是精度:16 → 512 让耗尽从 32 条降到 0、平均步数从 37.457 降到 28.484,漏交始终 0。原场的步长临界点在 1.0 与 1.25 倍之间:1.0 倍 0 穿透,1.25 倍 49 条,2.0 倍 178 条。- 漏交率与穿透条数是互补指标:场轻微越界时穿透涨而漏交为 0(1.1 倍:212 穿透 / 0 漏交),越界加剧才转成漏交(1.25 倍:303.0303‰)。
- 有地面时漏交恒为 0,但
max|Δt|会涨到 26 个世界单位——「有命中」不等于「命中了该命中的东西」。下一节给这套求交配上法线与软阴影,并把同一套搬进 WebGL2 着色器。
阅读导航:上一节:11.1 SDF 的构造与布尔运算 · 下一节:11.3 SDF 的法线与软阴影 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。