11.1 SDF 的构造与布尔运算

距离场要被 sphere tracing 当步长信任,必须同时满足下界性与 Lipschitz 界。本节用 40000 对随机点估计八个场的 Lipschitz 常数:球、盒、平面、交、差都落在 1000.0‰ 附近,高度场却到 1084.1‰;下界性实测球/盒/平面/并集的最大 f-d_true 恰为 0,高度场 648 个采样点里 426 个高估,最坏 3909 Q16.16。

11.1 SDF 的构造与布尔运算

站内 /graphics-signed-distance-fields/ 那篇讲的是 SDF 渲染工程:怎么用距离场把画面画好看,怎么在着色器里堆出分形、浮雕与软过渡。本卷反过来问一个更基础的问题——距离场凭什么能被当成求交用。答案不是「它能表示形状」,而是一条可以被证明、也可以被量出来的不等式:f(p) ≤ d_true(p)。本节把这句话在八个场上量一遍。

第 9 章的 ray marching 是沿光线固定步长采样;从本章开始要换成自适应步长——每一步走多远由场值自己决定。这只有在场「可信」时才成立,而「可信」在这里有两层含义,下面分别量。

两个必须成立的条件

设 f(p) 是某个形状的距离场,d_true(p) 是 p 到该形状边界的真实距离。

下界性:f(p) ≤ d_true(p) 处处成立。这是 sphere tracing 的安全前提——以 f(p) 为步长往前走,只要下界成立,这一步就绝不会跨过表面。下界不成立时步长会越界,光线直接穿过物体,这就是 11.2 里「真漏交」的来源。

Lipschitz 界:|f(p) - f(q)| ≤ L·|p - q|,L = sup |grad f|。L ≤ 1 时场是「一阶可用」的。L > 1 不会让结果立刻错,但会让收敛变慢(每一步只能走 f/L 那么多),并且让下界性更容易被破坏。

这两条不等价:下界性是值域上的约束,Lipschitz 是变化率上的约束。高度场恰好是「Lipschitz 略大于 1、下界性被破坏」的典型,本节最后专门量它。

四个基本场

球:    f(p) = |p - c| - r
盒:    f(p) = |max(q, 0)| + min(max(q.x, q.y, q.z), 0),  q = |p - c| - b
平面:  f(p) = p.y - h
高度场:f(p) = p.y - h(x, z)

前三个是精确距离场(f = d_true 处处成立)。第四个不是——它只是「到参考平面的高度差」,在陡坡上会严重高估。四个场连同它们共用的定点长度函数:

# snippet: sim/cg_trace_11_sdf.py(节选:定点长度与四个基本场)
# verify: /opt/homebrew/bin/python3 sim/cg_trace_11_1_sdf_construction.py
def len_q16(v):
    """Q16.16 向量的**精确**长度:`isqrt(v0^2 + v1^2 + v2^2)`(结果仍是 Q16.16)。"""
    return math.isqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2])


def sdf_sphere(p, c, r):
    return len_q16(v_sub(p, c)) - r


def sdf_box(p, c, b):
    d = v_sub(p, c)
    q = (abs(d[0]) - b[0], abs(d[1]) - b[1], abs(d[2]) - b[2])
    return len_q16((max(q[0], 0), max(q[1], 0), max(q[2], 0))) + min(max(q[0], q[1], q[2]), 0)


def sdf_plane(p, height):
    """过 `y = height` 的平面(半空间):`p.y - height`。精确距离场。"""
    return p[1] - height


def sdf_terrain(p, seed, hs):
    """高度场:`p.y - h(x,z)`。**不是**距离场——陡坡处 `|grad f| > 1`。"""
    return p[1] - cglib.terrain_height(p[0], p[2], seed, hs)

这里有一个定点陷阱值得单独说:len_q16 没有走底座的 v_dot。v_dot 对每个乘积先做 >>16(也就是 v_i² >> 16),当分量小于 256(约 0.0039 世界单位)时 v_i² < 65536,右移之后直接变成 0。而 SDF 在表面附近的分量恰恰就这么小——盒的 max(q, 0) 贴面时是 0.00x 量级。用 v_dot 会让 sdf_box 在离面还有几个千分之一世界单位时就返回 0,ray marching 把它当成命中。定点里开方只有一条正路:整数平方根(Python 用 math.isqrt,GLSL 用整数牛顿迭代),而不是「先算浮点开方再截断」。

布尔运算与平滑并

并集:   min(a, b)
交集:   max(a, b)
差集:   max(a, -b)          ← a 减去 b,不是 b 减去 a
平滑并: smin(a, b, k) = mix(b, a, h) - k·h·(1-h),h = clamp(0.5 + 0.5·(b-a)/k, 0, 1)

前三个是逐点运算:min/max 只挑一支,梯度来自被选中的那一支,|grad| ≤ 1 被原样继承。smin 不一样——它在两支「交接」的地方把两个场混起来,梯度是两支梯度的加权组合,模长有可能超过 1。超多少是量出来的,不是猜的:16³ = 4913 个格点、k = 32768(0.5 世界单位)下:

性质实测含义
smin > min 的点数0平滑并永远是并集的下界
smin < min - k/4 的点数0最多往下削 k/4
max(min - smin)8187 Q16.16k/4 = 8192,已贴到理论界

这两条合起来给出一个可用的保证:smin 是 min 的平滑下界,间隙不超过 k/4。所以 smin 造出来的场仍然能当下界用,只是比 min 更保守,ray marching 会多走几步。

Lipschitz 实测:布尔运算没有把界撑开

用 40000 对随机点估计 max |f(p)-f(q)| / |p-q|,点对步长 16384 Q16.16(0.25 世界单位):

场L(Q16.16)L(‰)max(f - d_true)
sphere655361000.00
box655401000.0610
plane655361000.00
union655401000.0610
intersect655361000.0—
difference655361000.0—
smin655401000.061—
terrain710481084.1064—

三件事值得读出来:

  • 布尔运算不改变 Lipschitz 界。 球、平面、交、差是精确的 1000.0‰;盒、并集、smin 是 1000.061‰,超出量 4 个 Q16.16(65540/65536 = 1.000061),在一个采样步长内可以忽略。这三个场都带折痕——盒的棱、min 的交接面、smin 的混合带——估计器在折痕附近会量出略大于 1 的值,但没有真正的越界。
  • smin 不破坏 Lipschitz。 它把两支混在一起,直觉上梯度应该叠加,实测却只到 1000.061‰。原因是混合权重 h 随位置变化的方向与两支梯度差的方向一致,两者在 mix 里互相抵消。所以 smin 可以放心用。
  • 高度场真的越界了:1084.1064‰。 比 1 高 8.4%。这个数字就是 11.2 里「步长放大到 1.25 倍就开始穿透」的伏笔。

下界性实测:谁在偷偷高估

对能解析算出真距离的四个场,在 13³ = 2197 个格点上量 f - d_true:

场高估点数max(f - d_true)
sphere0 / 21970
box0 / 21970
plane0 / 21970
union0 / 21970

四个场一个点都没有高估,最大值恰好是 0。这不是「差不多为 0」,而是逐点 f = d_true 的严格相等——球、盒、平面都是闭式精确距离场,min 又保留下界,所以并集也精确。

高度场没有闭式真距离,只能对曲面暴力采样(481² = 231361 个曲面点)取最近,再用 648 个场点量:

量实测
高估点数426 / 648
max(f - d_true)3909 Q16.16
比值上界 max(f / d_true)1056.6406‰

426/648 = 65.7% 的采样点高估,最坏处高估 3909 Q16.16(约 0.06 世界单位),比值上界 1056.6‰。这就是「高度场不是距离场」的量化版本:它给出的步长在三分之二的位置上是过大的,照走就会跨过曲面。

P 层把数钉死

P 层用 TypeScript 重写同一条链路,全程 BigInt:Lipschitz 走同一条 SplitMix64 采样序列,所以每一个场都必须与 S 侧逐位相同;下界性同样逐点比对。这一节的 P 层拆成两个文件,cg_trace_11_1_sdf_construction.test.ts 管 Lipschitz 与下界性:

// file: lantern/core/test/cg_trace_11_1_sdf_construction.test.ts
// tier: P
// verify: node --test 'core/test/cg_trace_11_1_sdf_construction.test.ts'
// 卷三第 11.1 节《SDF 的构造与布尔运算》的 P 层(上):Lipschitz 界与下界性。
//
// 两个断言对象:
//   1. 八个场的 Lipschitz 估计(同一条 SplitMix64 采样序列,必须与 S 侧逐位相同);
//   2. 四种可解析场的下界性 `max(f - d_true)`(球/盒/平面/并集都必须是 0)。
// 平滑并 `smin` 与高度场高估的近邻断言在 cg_trace_11_1_sdf_bounds.test.ts。
// 金标准由 S 侧产出:
//   /opt/homebrew/bin/python3 sim/cg_trace_11_1_sdf_construction.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,
  lenQ16,
  sdfBox,
  sdfPlane,
  sdfSphere,
  sdfTerrain,
} from "../src/lantern/cg_trace_11_sdf.ts";
import { rngNext } from "../src/lantern/cgtrace.ts";
import { ONE, fdiv, fmul } from "../src/lantern/fixed.ts";
import { type Vec3, vAdd, vNorm, vSub } 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_1_sdf.json"), "utf8"));

const pr = cfg.primitives;
const SC: Vec3 = [BigInt(pr.sphere.centerQ16[0]), BigInt(pr.sphere.centerQ16[1]), BigInt(pr.sphere.centerQ16[2])];
const SR = BigInt(pr.sphere.radiusQ16);
const BC: Vec3 = [BigInt(pr.box.centerQ16[0]), BigInt(pr.box.centerQ16[1]), BigInt(pr.box.centerQ16[2])];
const BB: Vec3 = [BigInt(pr.box.halfQ16[0]), BigInt(pr.box.halfQ16[1]), BigInt(pr.box.halfQ16[2])];
const PH = BigInt(pr.groundPlane.heightQ16);
const SEED = BigInt(pr.terrain.noiseSeed);
const HS = BigInt(pr.terrain.heightScaleQ16);

type Field = (p: Vec3) => bigint;

const sphere: Field = (p) => sdfSphere(p, SC, SR);
const box: Field = (p) => sdfBox(p, BC, BB);
const plane: Field = (p) => sdfPlane(p, PH);

function opUnion(a: bigint, b: bigint): bigint {
  return a < b ? a : b;
}
function opIntersect(a: bigint, b: bigint): bigint {
  return a > b ? a : b;
}
function opDifference(a: bigint, b: bigint): bigint {
  return opIntersect(a, -b);
}

const fields: Record<string, Field> = {
  sphere,
  box,
  plane,
  terrain: (p) => sdfTerrain(p, SEED, HS),
  union: (p) => opUnion(sphere(p), box(p)),
  intersect: (p) => opIntersect(sphere(p), box(p)),
  difference: (p) => opDifference(sphere(p), box(p)),
};

/** 点对采样估计 Lipschitz 常数:`max |f(p)-f(q)| / |p-q|`(Q16.16)。 */
function lipschitz(f: Field): bigint {
  const ext = BigInt(cfg.gradient.extentQ16);
  const delta = BigInt(cfg.gradient.deltaQ16);
  const samples = cfg.gradient.lipschitzSamples;
  let state = BigInt(cfg.gradient.lipschitzSeed);
  const span = 2n * ext + 1n;
  const twoOne = 2n * ONE + 1n;
  let mx = -1n;
  for (let i = 0; i < samples; i += 1) {
    const pt: Vec3 = [0n, 0n, 0n];
    for (let k = 0; k < 3; k += 1) {
      const [s, z] = rngNext(state);
      state = s;
      pt[k] = (z % span) - ext;
    }
    const comp: bigint[] = [];
    for (let k = 0; k < 3; k += 1) {
      const [s, z] = rngNext(state);
      state = s;
      comp.push((z % twoOne) - ONE);
    }
    const u = vNorm([comp[0], comp[1], comp[2]]);
    if (u[0] === 0n && u[1] === 0n && u[2] === 0n) continue;
    const q: Vec3 = vAdd(pt, [fmul(delta, u[0]), fmul(delta, u[1]), fmul(delta, u[2])]);
    const dist = lenQ16(vSub(q, pt));
    if (dist === 0n) continue;
    const df = f(pt) - f(q);
    const r = fdiv(df < 0n ? -df : df, dist);
    if (r > mx) mx = r;
  }
  return mx;
}

test("11.1 Lipschitz:八个场的点对估计与 S 层逐位相同", () => {
  for (const name of Object.keys(fields)) {
    assert.equal(lipschitz(fields[name]), BigInt(golden.lipschitz[name].lipschitzQ16), name);
  }
  // 可解析场(球/盒/平面/并集)的 Lipschitz 常数必须落在 1.0 附近
  for (const name of ["sphere", "box", "plane", "union"]) {
    const L = lipschitz(fields[name]);
    assert.ok(L >= ONE && L <= ONE + 64n, `${name} L=${L}`);
  }
  // 高度场必须明显越界,否则 11.2 的「穿透」一列无从解释
  assert.ok(lipschitz(fields.terrain) > ONE + 4096n, "高度场必须明显越界");
});

test("11.1 下界性:球/盒/平面/并集的 max(f - d_true) 必须为 0", () => {
  const ext = BigInt(cfg.gradient.extentQ16);
  const n = 12;
  const step = fdiv(2n * ext, BigInt(n) << 16n);
  const dTrue = (name: string, p: Vec3): bigint => {
    if (name === "sphere") return sphere(p);
    if (name === "box") return box(p);
    if (name === "plane") return plane(p);
    const a = sphere(p);
    const b = box(p);
    return a < b ? a : b;
  };
  for (const name of ["sphere", "box", "plane", "union"]) {
    let over = 0;
    let worst = -(1n << 60n);
    for (let iz = 0; iz <= n; iz += 1) {
      for (let iy = 0; iy <= n; iy += 1) {
        for (let ix = 0; ix <= n; ix += 1) {
          const p: Vec3 = [
            -ext + fmul(step, BigInt(ix) << 16n),
            -ext + fmul(step, BigInt(iy) << 16n),
            -ext + fmul(step, BigInt(iz) << 16n),
          ];
          const excess = fields[name](p) - dTrue(name, p);
          if (excess > 0n) over += 1;
          if (excess > worst) worst = excess;
        }
      }
    }
    assert.equal(worst, BigInt(golden.lowerBound[name].maxExcessQ16), `${name} 最大超出`);
    assert.equal(over, golden.lowerBound[name].overCount, `${name} 高估点数`);
    assert.equal(over, 0, `${name} 不允许高估`);
  }
});

另外一半——smin 的两条性质、高度场最坏点在 TS 侧重算——在 cg_trace_11_1_sdf_bounds.test.ts。两个文件合起来 4 条断言,跑一遍:

# snippet: 本机实跑(工作区 lantern/)
cd lantern
/opt/homebrew/bin/python3 sim/cg_trace_11_1_sdf_construction.py
node --test 'core/test/cg_trace_11_1_*.test.ts'
== 11.1 SDF 的构造与布尔运算 ==
Lipschitz 点对采样 40000 对,步长 16384 Q16.16
场            Lipschitz L(Q16.16 / 千分比)   max(f - d_true)
  sphere         65536 /   1000.0‰      0
  box            65540 / 1000.061‰      0
  plane          65536 /   1000.0‰      0
  terrain        71048 / 1084.1064‰      —
  union          65540 / 1000.061‰      0
  intersect      65536 /   1000.0‰      —
  difference     65536 /   1000.0‰      —
  smin           65540 / 1000.061‰      —
高度场高估:426/648 个采样点 f > d_true,最大高估 3909 Q16.16(比值上界 1056.6406‰)
smin <= min 违反 0 次 / 4913 点;最大间隙 min-smin = 8187 Q16.16
csv/json          : out/cg_trace_11_1_{lipschitz,sdf_slice}.csv / _sdf.json

小结

  • sphere tracing 的安全前提是下界性 f ≤ d_true,收敛快慢看 Lipschitz 界 L = sup|grad f|,两者不等价。
  • 球、盒、平面是精确距离场;min / max / max(a, -b) 保留下界性,实测 max(f - d_true) 恰为 0。
  • 布尔运算不改变 Lipschitz 界:八个场里最「坏」的是 1000.061‰,超出量 4 个 Q16.16。
  • smin 是 min 的平滑下界:smin ≤ min 处处成立,间隙不超过 k/4(实测 8187 ≤ 8192)。
  • 高度场不是距离场:Lipschitz 到 1084.1064‰,648 个采样点里 426 个高估,最坏 3909 Q16.16。
  • 表面附近的定点长度必须走整数平方根,不能用每个分量先 >>16 的 v_dot。

下一节把这三个场交给 sphere tracing,量迭代次数、容差与步长越界这三件事。

阅读导航:上一节:10.3 程序化场景的生成 · 下一节:11.2 ray marching 求交 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

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