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.16 | k/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) |
|---|---|---|---|
| sphere | 65536 | 1000.0 | 0 |
| box | 65540 | 1000.061 | 0 |
| plane | 65536 | 1000.0 | 0 |
| union | 65540 | 1000.061 | 0 |
| intersect | 65536 | 1000.0 | — |
| difference | 65536 | 1000.0 | — |
| smin | 65540 | 1000.061 | — |
| terrain | 71048 | 1084.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) |
|---|---|---|
| sphere | 0 / 2197 | 0 |
| box | 0 / 2197 | 0 |
| plane | 0 / 2197 | 0 |
| union | 0 / 2197 | 0 |
四个场一个点都没有高估,最大值恰好是 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 求交 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。