16.2 三种消费的联调
站内的 技术美术与管线工程
里,同一份资产规格要被 DCC、导出器、引擎三处消费,任何一处读错字段都会在下游放大;本节把同样的处境搬到渲染上:一份 config/scene_spec.json,三种宿主消费。上一节已经让 Python 与 TypeScript 两处定点实现跑出同一个 sha256,这一节把浏览器里的 WebGL2 着色器也拉进来,并回答一个必须先回答的问题——当宿主从整数换成浮点,原来那条「逐字节相同」的纪律还站得住吗?
同一份规格,三种宿主
三处消费的是同一份场景规格,区别只在谁在执行那段着色代码:
| 宿主 | 执行者 | 数值类型 | 判据 |
|---|---|---|---|
| S 层 | Python + numpy 软光栅化 | Python int / int64 定点 | 基准真源 |
| P 层 | Node 里的 TypeScript 内核 | BigInt 定点 | 与 S 层逐字节相同 |
| W 层 | 浏览器 WebGL2 片元着色器 | float(GLSL ES 3.0) | 按声明容差一致 |
这个划分不是随便定的。S 与 P 两侧执行的都是同一套整数运算:Q16.16 的矩阵链、Q6 的屏幕坐标、逐像素的整数插值,连降精度用的都是算术右移 >>(Python 的向下取整与 BigInt 的算术右移语义一致)。两侧既然是同一种数、同一套算子,结果就该一位不差——这不是「差不多」,而是一条能被 sha256 直接验的硬纪律。
W 层不行。GLSL 里没有 Q16.16,插值在浮点上做、片元法线的归一化也在浮点上做。要求它逐位对齐 CPU 定点,在数学上就是不可能的。所以 W 层只能走另一套判据,而且必须把「不可能」写清楚,而不是把阈值放宽到能过为止。
为什么要凑齐三处,而不是两处就够?因为两处只能证明「同一套定点实现写得一致」,证明不了「这套定点实现表达的光照是对的」。P 层与 S 层共享同一套算法设计,两边一起错是完全可能的——比如都把法线的 y 分量取反,两侧照样逐字节相同。W 层则是一条独立走到底的路径:法线由顶点属性上传、在片元着色器里归一化、在浮点里做 Lambert 与 clamp,除了场景规格以外不与 S/P 层共享任何中间结果。它给出的图像若与 CPU 定点落在容差内,才说明那份中间结果真的被两条互不相干的路径共同确认过。
三处之间还有一条单向关系值得记牢:S 层是唯一被允许定义「正确答案」的地方。P 层与 W 层都是被比对方,S 层的成片摘要一旦变动,另外两处的对账就全部作废——这也是为什么下一节的接口契约要把 S 层的输出格式一并封版。
S 层:基准真源
S 层脚本当场重跑一遍,把成片落盘、取摘要,再从像素缓冲反推覆盖掩码:
# snippet: 本机实跑(工作区 lantern/)
cd lantern
/opt/homebrew/bin/python3 sim/cg_shade_16_2_three_host.py
# snippet: lantern/sim/cg_shade_16_2_three_host.py 的覆盖掩码与三宿主判定
def mask_of(raw, background):
"""由像素缓冲推出覆盖掩码:与背景色不同即为被覆盖。"""
a = np.frombuffer(raw, dtype=np.uint8).reshape(-1, 3)
return np.any(a != np.asarray(background, dtype=np.uint8), axis=1)
verdict = {
"s_vs_p_byte_identical": identical,
"s_vs_p_sha256_equal": s_sha == p_sha,
"s_vs_w_tolerance_ok": bool(w and w["pass"]),
}
hosts = [
{"host": "S 层", "lang": "python", "sha256": s_sha, "ms": round(s_ms, 1),
"covered": int(s_cov.sum()), "role": "基准真源"},
{"host": "P 层", "lang": "typescript", "sha256": p_sha, "ms": p_ms,
"covered": int(p_cov.sum()), "role": "与 S 层逐字节相同" if identical else "与 S 层不同"},
{"host": "W 层", "lang": "webgl2", "sha256": None if w is None else w["w_sha256"],
"ms": None if w is None else w["w_ms"],
"covered": None if w is None else w["w_covered"],
"role": "按声明容差一致" if (w and w["pass"]) else "未验证"},
]
比对的对象是像素缓冲(out/shade_hollow.rgb 与 out/shade_hollow_ts.rgb),不是 PNG 容器字节——PNG 里含时间戳与压缩器版本,拿它当判据只会自找麻烦。覆盖掩码则不用任何中间量,直接从「这个像素是不是背景色」反推,这样 W 层才有一条与 S 层同口径的覆盖数。
对账表
跑完三处,把结果并排放到一张表上:
规格 : config/scene_spec.json -> 320x240
宿主 语言 sha256 前 16 耗时(ms) 覆盖像素 判据
S 层 python 0d997343ca547ed5 153.2 43731 基准真源
P 层 typescript 0d997343ca547ed5 64.0 43731 与 S 层逐字节相同
W 层 webgl2 9e1940fc88fd846e 46.7 43729 按声明容差一致
S vs W 容差 : MAE 0.5067 / PSNR 47.03 dB / 覆盖归属差 0.0781‰ / 显著差 0.3255‰
负例(片元颜色 ×0.5): MAE 21.3528 / PSNR 18.9 dB -> 被同一判据拒绝 = True
S vs P : 逐字节相同 = True(不同字节数 0),sha256 相同 = True
三宿主一致 : {"s_vs_p_byte_identical": true, "s_vs_p_sha256_equal": true, "s_vs_w_tolerance_ok": true}
三列数字各有含义。sha256 那一列里 S 与 P 完全相同,W 完全不同——这正是本节要讲的两种纪律。耗时那一列 S 层 153.2 ms、P 层 64.0 ms,W 层从绘制到回读只有 46.7 ms,但这不是「谁更快」的结论:三者做的工作量并不相同,W 层的顶点变换与场景装配是在 JS 里预先算好后当顶点属性上传的,着色器里只剩屏幕到 NDC 的线性映射与逐像素着色。覆盖像素那一列 S 与 P 都是 43731,W 是 43729,差的这 2 个像素是共享边的归属问题。
逐字节相同与声明容差
两条纪律的适用范围必须分得清,混用任何一条都会得到错误结论。
S ↔ P 用逐字节相同。 两侧执行的是同一套整数运算,任何一位不同都说明有一侧写错了,没有「容差」可谈。判据就是 sha256(S) == sha256(P) 且缓冲逐字节相同,实测不同字节数为 0。这条纪律的价值在于它极难被凑出来:要让两个独立实现的所有像素、所有通道逐字节相同,只能是真的算对了。
S ↔ W 用声明容差。 差异有两个无法消除的来源:其一,光栅化规则不同——WebGL2 用 top-left rule 决定共享边的像素归属,CPU 用整数边函数在像素中心判定,同一条边两边的像素归属可能不一致;其二,插值用浮点再舍入,片元法线的归一化也是浮点。所以判据必须选对舍入不敏感的统计量,并把覆盖归属单独设上限:
| 判据 | 阈值 | 实测 |
|---|---|---|
| MAE | ≤ 2.0 | 0.5067 |
| PSNR | ≥ 30 dB | 47.03 dB |
| 覆盖归属差异 | ≤ 20‰ | 0.0781‰ |
| 显著差异像素(任一通道差 > 2) | ≤ 1‰ | 0.3255‰ |
顺带说清一个不能当判据的量:本次「任一通道差 > 0 的像素占比」是 564.57‰(43359 / 76800 个像素),看起来「过半像素都不一致」。这个数字毫无区分力——±1 的舍入就会让一个像素被计为「不一致」,而 ±1 在浮点插值下必然发生。用它当判据,正确实现与错误实现都过不了,或者都过得去。 这就是为什么判据要用 MAE 与 PSNR,而不是「不一致像素占比」。
另外注意上表的 MAE 阈值写的是 2.0 而不是更严的 1.0:这是卷一沿用的口径,实测 0.5067 连 1.0 都远在门槛内。把阈值写得比需要更松、同时把实测值一起公布,比反过来诚实。
判据的区分力:负例
任何容差判据都有一个致命问题:放宽阈值总能过。所以判据必须配一个负例——故意写错的实现,用来证明这套阈值真的能拦住错误。
W 层页面里并排渲染了两张画布:一张是正确的片元着色器,一张把片元颜色整体乘以 0.5。用同一套判据去比,负例得到 MAE 21.3528、PSNR 18.90 dB,两条都远远越界,被拒绝。同一判据下正确实现 MAE 0.5067、PSNR 47.03 dB,两者相差 42 倍与 28 dB。判据不被负例拒绝,则 PASS 不可信——这条比阈值本身更重要。
P 层同样有负例:把着色内核里 k 的上限 clamp 去掉,让系数越过 ONE。这一改不会崩、不会报错,只会让球体最亮处过曝。逐字节判据立刻分叉,因为错内核算出的像素缓冲与 S 层不再相同。两个负例分别守着两条纪律。
P 层复现
P 层用 BigInt 重写同一套整数运算,并把三宿主判定落盘供 S 层对账表读取:
// file: lantern/core/test/cg_shade_16_2_three_host.test.ts
// tier: P
// verify: node --test 'core/test/cg_shade_16_2_three_host.test.ts'
// 16.2 的三宿主联调断言:同一份 config/scene_spec.json 在三处的口径必须同时成立。
// S vs P —— 逐字节相同(sha256),断言对象是像素缓冲不是 PNG 容器字节
// S vs W —— 按声明容差(MAE <= 2.0、PSNR >= 30 dB、覆盖归属差异 <= 20‰),
// 且负例(片元颜色 ×0.5)必须被同一判据拒绝
// P 侧负例 —— 漏掉 k 的上限 clamp 的错内核必须让逐字节判据分叉
// 本文件同时把 P 层的耗时/覆盖数落盘到 out/cg_shade_16_2_p.json,供 S 层对账表读取。
import assert from "node:assert/strict";
import { readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
import { buildSceneShaded, renderShaded, sha256hex } from "../src/lantern/cg_shade_16_scene.ts";
import { fmul } from "../src/lantern/fixed.ts";
import { type SceneSpec } from "../src/lantern/pipeline.ts";
import { type ShadeFn, rasterizeShaded } from "../src/lantern/shade.ts";
import { vDot } 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 SceneSpec;
const BASE = "0d997343ca547ed5cada5e4b1f75ce1782d79e524dba817278853de77307a6de";
const three = JSON.parse(
readFileSync(join(root, "out", "cg_shade_16_2_three_host.json"), "utf8"),
) as {
hosts: { host: string; sha256: string | null; ms: number | null; covered: number | null }[];
s_vs_p: { byte_identical: boolean; sha256_equal: boolean };
s_vs_w: {
mae: number;
psnr_db: number;
cover_diff_permille: number;
sig_permille: number;
negative: { mae: number; psnr_db: number };
verdict: Record<string, boolean>;
};
pass: boolean;
};
test("P 层复现 S 层 sha256 并落盘计时/覆盖数(供对账表读取)", () => {
const t0 = Date.now();
const f = renderShaded(spec);
const ms = Date.now() - t0;
assert.equal(sha256hex(f.rgb), BASE, "P 层摘要必须复现 S 层基线");
assert.equal(f.coveredCount, 43731);
assert.equal(f.culled, 290);
writeFileSync(
join(root, "out", "cg_shade_16_2_p.json"),
`${JSON.stringify({ sha256: BASE, covered: f.coveredCount, ms }, null, 2)}\n`,
);
});
test("S vs P 逐字节相同:落盘的 P 缓冲与 S 缓冲同摘要", () => {
const pBuf = new Uint8Array(readFileSync(join(root, "out", "shade_hollow_ts.rgb")));
const sBuf = new Uint8Array(readFileSync(join(root, "out", "shade_hollow.rgb")));
assert.equal(sha256hex(pBuf), sha256hex(sBuf));
assert.equal(sha256hex(pBuf), BASE);
assert.equal(pBuf.length, spec.width * spec.height * 3);
assert.ok(three.s_vs_p.byte_identical, "S vs P 必须逐字节相同");
assert.ok(three.s_vs_p.sha256_equal);
assert.ok(three.pass, "三宿主一致判定必须为真");
assert.equal(three.hosts[0].sha256, BASE);
assert.equal(three.hosts[1].sha256, BASE);
});
test("S vs W 按声明容差:三条判据同时成立", () => {
const w = three.s_vs_w;
assert.ok(w.mae <= 2.0, `MAE ${w.mae} 超出 2.0`);
assert.ok(w.psnr_db >= 30, `PSNR ${w.psnr_db} dB 低于 30 dB`);
assert.ok(w.cover_diff_permille <= 20, `覆盖归属差异 ${w.cover_diff_permille}‰ 超出 20‰`);
assert.ok(w.sig_permille <= 1, `显著差异像素 ${w.sig_permille}‰ 超出 1‰`);
assert.ok(w.verdict.negative_rejected, "负例必须被同一判据拒绝");
assert.ok(w.verdict.mae_ok && w.verdict.psnr_ok && w.verdict.cover_ok && w.verdict.sig_ok);
});
test("负例:W 层的错着色器与 P 层的错内核都必须被各自判据拒绝", () => {
assert.ok(three.s_vs_w.negative.mae > 2.0, "负例 MAE 必须超出 2.0");
assert.ok(three.s_vs_w.negative.psnr_db < 30, "负例 PSNR 必须低于 30 dB");
// P 层负例:漏掉 k 的上限 clamp,逐字节判据必须分叉
const dir = spec.directionalLight.dirQ16.map((n) => BigInt(n)) as [bigint, bigint, bigint];
const intensity = BigInt(spec.directionalLight.intensityQ16);
const ambient = BigInt(spec.ambientQ16);
const noClamp: ShadeFn = (normal, albedo) => {
const ndl = vDot(normal, dir);
const k = ambient + fmul(ndl < 0n ? 0n : ndl, intensity);
return [0, 1, 2].map((i) => Number((BigInt(albedo[i]) * k) >> 16n)) as [number, number, number];
};
const { tris } = buildSceneShaded(spec);
const r = rasterizeShaded(tris, spec.width, spec.height, spec.backgroundQ8, noClamp);
assert.notEqual(sha256hex(r.rgb), BASE, "漏 clamp 的错内核必须让逐字节判据分叉");
});
✔ P 层复现 S 层 sha256 并落盘计时/覆盖数(供对账表读取)
✔ S vs P 逐字节相同:落盘的 P 缓冲与 S 缓冲同摘要
✔ S vs W 按声明容差:三条判据同时成立
✔ 负例:W 层的错着色器与 P 层的错内核都必须被各自判据拒绝
ℹ tests 4 ℹ pass 4 ℹ fail 0
W 层的两个工程坑
W 层页面把 S/P 层用过的定点模块 bundle() 进单文件 HTML,这里有两个坑值得单独记一笔。
第一,bundle() 会把模块拼进同一作用域。 core/src/lantern/raster.ts 与 core/src/lantern/shade.ts 都在顶层声明了 const DEPTH_MAX = 2147483647,两个文件一起并入,构建期直接报 Identifier 'DEPTH_MAX' has already been declared。本节的 W 层只需要着色侧模块,所以打包清单里显式排除 raster.ts——着色器不负责光栅化,顶点变换与覆盖判定都在 CPU 侧先算好了。这类「两份代码各自都对、拼起来才炸」的问题不会出现在任何单文件测试里,只能由构建暴露。
第二,本机 headless 的 WebGL2 后端是软件实现。 实测拿到的 renderer 字符串是 ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (LLVM 10.0.0) (0x0000C0DE)), SwiftShader driver),也就是 ANGLE 之上的 SwiftShader——它是 CPU 上跑的软件光栅化器,不是真实 GPU。所以本节量到的 MAE 0.5067 与覆盖归属差 0.0781‰ 只能归因于**插值舍入与覆盖判据(top-left rule)**这两件事,不能拿它当真实硬件的性能或精度结论。要看真实驱动的差异,得换一台有真 GPU 的机器,那是 X 档的活。
三处消费到此闭环:同一份规格,两处定点实现给出同一个 sha256,一处浮点实现给出可解释、可复现、带负例守门的容差内结果。下一节把这条链和这份契约一并交给卷三。
阅读导航:上一节:16.1 着色管线的装配 · 下一节:16.3 交付卷三 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。