11.2 ray marching 求交

sphere tracing 每一步走多远由场值自己决定,于是三件事必须量:迭代次数怎么分布、容差 eps 取多少、步长放大到几倍会出事。3072 条主光线的实测是解析命中 2048、marching 命中 1995、真漏交 0;无地面场景里场放大 1.1 倍只穿透不漏交,1.25 倍漏交 303.0303‰,1.5 倍直接 1000‰。

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_f2

四件事值得注意:真漏交是 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–8151105–11248
9–161245113–12036
17–24667121–12829
25–32288129–1361
33–40160137–1441
41–4890145–15221
49–56105153–16021
57–6431161–16813
65–7236169–1768
73–8010177–1842
81–8832249–25664
89–9613

上表是 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 倍平均步数
160.0‰3237.4570.0‰4931.570
320.0‰3236.0720.0‰4930.053
640.0‰2634.7210.0‰4928.367
1280.0‰932.9190.0‰4926.632
2560.0‰030.7670.0‰4924.882
5120.0‰028.4840.0‰4923.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.500.0‰—00.0‰810
0.7500.0‰—00.0‰808
1.000.0‰—490.0‰14577
1.25490.0‰—640.0‰62151
1.5570.0‰—1430.0‰1703079
2.01780.0‰—2680.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.023100.0‰01251
1.123100.0‰21240263
1.2523170303.0303‰161100811
1.52312311000.0‰00
2.02312311000.0‰00

这张表把「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 的法线与软阴影 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

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