15.2 中间缓冲的着色验证
上一节证明了「着色会分叉」,但分叉只说明错了,不说明错在哪。要定位,先要有证据。站内的 《技术美术与管线工程》 用阶段预算和闸门卡住管线回归;本节用的是一组更细的证据:把逐像素着色器产出的四张中间缓冲各自单独导出,逐张断言。
四张缓冲,四条不变量
| 缓冲 | 不变量 | 断言的物理含义 |
|---|---|---|
| 法线图 | 每个覆盖像素的 ` | n |
| albedo 图 | 每个通道 ∈ [0, 255] | 属性插值没溢出 |
| 深度图 | 覆盖像素深度 ∈ [0, ONE],单三角形上单调 | 深度插值没串位 |
| 覆盖率图 | 与解析解逐像素相同 | 几何(覆盖判定)没错 |
「颜色不对」的第一手证据就是这四张图:颜色是最后一级,前面任何一级坏了都会污染它。把这四级分开导出,就把「颜色不对」拆成了「哪一级不对」。
这四张缓冲的排列不是随意的,它们恰好对应管线的四级:覆盖率对应几何(光栅化 + 剔除 + 深度),法线对应插值与归一化,albedo 对应属性插值,颜色对应着色本身。前三张都是中间量,第四张才是最终量。中间量比最终量好定位——最终量是前面所有环节的叠加,一个像素颜色不对,可能是法线坏了、可能是 albedo 坏了、也可能是两者都坏了;而中间量只承载一级信息,坏了就是那一级坏了。
覆盖率有一个可硬断言的等式
覆盖率最容易变成「看起来差不多」,所以它值得一个精确等式。像素被覆盖 ⇔ 至少一个正面三角形的三个边函数同时 ≥ 0。因为深度初始为 INT64_MAX,第一个覆盖该像素的三角形必然通过深度测试,所以:
「光栅器的
covered掩码」= 「全部正面三角形w ≥ 0掩码的并集」。
解析解独立于光栅器的深度测试,只算半平面并集;两者必须逐像素相同。这条等价把「几何对不对」变成一个可以硬断言的等式,而不是目测。
注意这条等式只在正面三角形上成立(area > 0):背面三角形被剔除,不参与覆盖。解析解与光栅器共用同一套边函数、同一套子像素中心,唯一的区别是解析解不做深度测试——所以两者一旦不同,差异必然来自深度测试或剔除逻辑,而不是边函数本身。
S 层:导出与断言
脚本 sim/cg_shade_15_2_buffers.py 在基线着色路径上捕获法线与 albedo,并独立重算解析覆盖率:
# snippet: sim/cg_shade_15_2_buffers.py 的解析覆盖率与法线模长断言(节选)
def analytic_coverage(tris, width, height):
"""解析解:全部正面三角形 w>=0 掩码的并集(独立于光栅器的深度测试)。"""
cov = np.zeros((height, width), dtype=bool)
half = SUB1 // 2
for t in tris:
x0, y0 = t[0], t[1]
x1, y1 = t[3], t[4]
x2, y2 = t[6], t[7]
area = (x1 - x0) * (y2 - y0) - (y1 - y0) * (x2 - x0)
if area <= 0:
continue
minx = max(0, min(x0, x1, x2) // SUB1)
maxx = min(width - 1, max(x0, x1, x2) // SUB1)
miny = max(0, min(y0, y1, y2) // SUB1)
maxy = min(height - 1, max(y0, y1, y2) // SUB1)
if minx > maxx or miny > maxy:
continue
px = np.arange(minx, maxx + 1, dtype=np.int64) * SUB1 + half
py = np.arange(miny, maxy + 1, dtype=np.int64) * SUB1 + half
PX, PY = np.meshgrid(px, py)
w0 = (x2 - x1) * (PY - y1) - (y2 - y1) * (PX - x1)
w1 = (x0 - x2) * (PY - y2) - (y0 - y2) * (PX - x2)
w2 = (x1 - x0) * (PY - y0) - (y1 - y0) * (PX - x0)
cov[miny : maxy + 1, minx : maxx + 1] |= (w0 >= 0) & (w1 >= 0) & (w2 >= 0)
return cov
# 法线图断言:|n| ∈ [ONE-δ, ONE]
lens = np.zeros(covered.sum(), dtype=np.int64)
k = 0
for i, j in zip(*np.nonzero(covered)):
lens[k] = cglib.v_len((int(nbuf[i, j, 0]), int(nbuf[i, j, 1]), int(nbuf[i, j, 2])))
k += 1
len_min, len_max = int(lens.min()), int(lens.max())
实跑(/opt/homebrew/bin/python3 sim/cg_shade_15_2_buffers.py):
canvas : 320x240 covered 43731 (569‰)
法线图 |n| : [65533, 65537] δ=3 (0.0458‰ of ONE)
albedo 图通道 : [70, 220]
深度图 : [60516, 65335] Q16.16
深度单调(单三角形): True 顶点 z [1000, 3000, 5000] 边中点 z [2000, 4000, 3000]
权重和 == 面积 : True
覆盖率 解析解 vs 光栅: 43731 vs 43731 差异 0 像素
四条断言全绿:法线模长落在 [65533, 65537](δ=3,即 ONE 的 0.0458‰),albedo 通道落在 [70, 220],深度落在 [60516, 65335],覆盖率解析解与光栅器差异 0 像素。
这四条断言的强度并不相同:法线模长与 albedo 值域是区间断言(落在区间内即可),覆盖率是等式断言(必须逐像素相同),深度是单调断言(沿边严格单调)。等式最强、区间最弱。把最强的等式留给覆盖率,是因为几何是唯一有闭式解的一级——其余三级都依赖插值,没有独立的解析解可比。
深度单调:一个手工可验的断言
深度这条不变量值得单独拎出来讲,因为它是四张缓冲里唯一没有闭式解、只能靠手工标尺验证的一张。
深度图那条不变量用一个单三角形做手工验证:顶点 (0,0)/(640,0)/(0,640),深度 1000/3000/5000。三个顶点的插值深度必须精确等于顶点值,三条边的中点必须严格介于两端之间:
- 顶点 z:
[1000, 3000, 5000] - 边中点 z:
[2000, 4000, 3000](分别是 AB、BC、AC 的中点) - 重心权重和
w0+w1+w2 == area:True
这三条一旦成立,深度插值就没有串位;w0+w1+w2 == area 是定点重心坐标的不变量(见卷一 6.2 节),它保证插值权重没有因整数舍入而丢失。
为什么深度要单独手算?因为深度图的断言最难自动化——覆盖像素的深度值没有闭式解(要逐像素算重心坐标),而单三角形上深度是线性的,可以手工验证。这个手算三角形成了一根「标尺」:光栅器的深度插值在这一个三角形上对了,它在整个场景上的插值就只是同一套公式的重复。
P 层:逐字节复现两张图
TypeScript 侧独立重算法线图与 albedo 图,断言编码后的缓冲 sha256 与 S 层逐字节相同,并复现模长与值域两条断言:
// file: lantern/core/test/cg_shade_15_2_buffers.test.ts
// tier: P
// verify: node --test 'core/test/cg_shade_15_2_buffers.test.ts'
// 15.2 的 P 层:TypeScript 侧独立重算四张中间缓冲,断言与 S 层
// sim/cg_shade_15_2_buffers.py 逐字节相同(法线图、albedo 图编码后的 sha256),
// 并复现法线模长与 albedo 值域两条断言。金标准由 S 侧产出:
// /opt/homebrew/bin/python3 sim/cg_shade_15_2_buffers.py
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
import { MODES, type Spec, captureMode } from "../src/lantern/cg_shade_15_cross_language.ts";
import { type Vec3, vLen } from "../src/lantern/vec.ts";
const root = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
const spec = JSON.parse(readFileSync(join(root, "config", "scene_spec.json"), "utf8")) as Spec;
const g = JSON.parse(
readFileSync(join(root, "out", "cg_shade_15_2_buffers.json"), "utf8"),
) as {
covered: number;
coverage_permille: number;
normal: { len_min_q16: number; len_max_q16: number; sha256: string };
albedo: { min: number; max: number; sha256: string };
depth: { min_q16: number; max_q16: number; monotonic_triangle: boolean };
coverage: { analytic_pixels: number; diff_pixels: number };
};
const W = spec.width;
const H = spec.height;
const sha = (b: Uint8Array): string => createHash("sha256").update(Buffer.from(b)).digest("hex");
const clamp255 = (v: number): number => Math.min(255, Math.max(0, v));
const cap = captureMode(spec, MODES.baseline);
/** 法线图编码:((n + ONE) * 255) >> 17,未覆盖处置 0(与 S 层同式)。 */
function encodeNormal(): Uint8Array {
const out = new Uint8Array(W * H * 3);
for (let i = 0; i < W * H; i += 1) {
if (cap.covered[i] === 0) continue;
for (let c = 0; c < 3; c += 1) {
out[i * 3 + c] = clamp255(((cap.normal[i * 3 + c] + 65536) * 255) >> 17);
}
}
return out;
}
function encodeAlbedo(): Uint8Array {
const out = new Uint8Array(W * H * 3);
for (let i = 0; i < W * H * 3; i += 1) out[i] = clamp255(cap.albedo[i]);
return out;
}
test("覆盖数与解析解一致(几何封版)", () => {
assert.equal(cap.coveredCount, g.covered);
assert.equal(cap.coveredCount, 43731);
assert.equal(g.coverage.diff_pixels, 0);
assert.equal(g.coverage.analytic_pixels, g.covered);
});
test("【核心】法线图编码后的缓冲与 S 层逐字节相同", () => {
assert.equal(sha(encodeNormal()), g.normal.sha256);
});
test("法线模长 ∈ [ONE−δ, ONE] 的上界与 S 层一致", () => {
let lo: bigint | null = null;
let hi: bigint | null = null;
for (let i = 0; i < W * H; i += 1) {
if (cap.covered[i] === 0) continue;
const n: Vec3 = [
BigInt(cap.normal[i * 3]),
BigInt(cap.normal[i * 3 + 1]),
BigInt(cap.normal[i * 3 + 2]),
];
const l = vLen(n);
if (lo === null || l < lo) lo = l;
if (hi === null || l > hi) hi = l;
}
assert.equal(Number(lo), g.normal.len_min_q16);
assert.equal(Number(hi), g.normal.len_max_q16);
});
test("albedo 图编码后的缓冲与 S 层逐字节相同,通道 ∈ [0, 255]", () => {
assert.equal(sha(encodeAlbedo()), g.albedo.sha256);
let lo = 255;
let hi = 0;
for (let i = 0; i < W * H; i += 1) {
if (cap.covered[i] === 0) continue;
for (let c = 0; c < 3; c += 1) {
const v = cap.albedo[i * 3 + c];
if (v < lo) lo = v;
if (v > hi) hi = v;
}
}
assert.equal(lo, g.albedo.min);
assert.equal(hi, g.albedo.max);
assert.ok(0 <= lo && hi <= 255);
});
ℹ tests 4 ℹ pass 4 ℹ fail 0
两条 P 层断言针对的是编码后的缓冲:法线图按 ((n + ONE) * 255) >> 17 编码成三通道,albedo 图按 8 位直接落盘。编码本身也可能出错(比如法线编码漏了 + ONE 的偏置),所以断言对象是编码后的字节,而不是内存里的定点值。
为什么这四条不变量够用
有人会问:四条不变量够吗?够。它们覆盖了「颜色从哪来」的每一条上游通路——几何决定哪些像素被着色,法线决定 N·L,albedo 决定基础色,深度决定谁在前。任何一处坏掉,都会在这四条里至少触发一条断言。
反过来,四条全绿而颜色仍错,只可能是着色函数本身写错了(比如 1/π 常数取错、求和顺序不同),那正是 15.1 五个负例覆盖的场景。所以这四条不变量与 15.1 的摘要判据是互补的:一个管「错没错」,一个管「错在哪」。
小结
四张缓冲、四条不变量,把「颜色不对」拆成了四级证据。它们不追求「好看」,只追求可断言:每一张都有一个明确的数值判据,而判据的正确性由 15.1 的五个负例反证过。下一节用这四级证据做二分定位:给定一张错了的图,逐级比对,把偏差锁到管线的某一段。
阅读导航:上一节:15.1 跨语言着色对照 · 下一节:15.3 偏差的定位方法 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。