16.2 三种消费的联调
Unity/SRP 口径的渲染管线
关心的是引擎里怎么把管线配起来;本节关心的是同一份可执行规格被三种不同宿主消费后,结果能不能收敛。所谓三种消费,指的是同一份 config/scene_spec.json 分别喂给:
- S 层:Python + numpy 的定点软光栅化(
sim/),顶点层 Pythonint、逐像素层int64; - P 层:TypeScript 定点内核(
core/src/lantern/),顶点层BigInt、逐像素层Number; - W 层:浏览器 WebGL2 着色器真渲染(
web/),单文件 HTML,用 Playwright 无头驱动。
16.1 已经证明 S 与 P 的装配结果逐字节相同。这一节把 W 层也拉进来,并回答一个容易被含糊过去的问题:三个宿主之间到底该用什么判据。
两类判据,不能混用
| 对比 | 判据 | 阈值 | 本轮实测 |
|---|---|---|---|
| S vs P | 像素缓冲 sha256 | 逐字节相同 | 完全相同(不同字节数 0) |
| S vs W | MAE | <= 2.0 | 0.2848 |
| S vs W | PSNR | >= 30 dB | 47.79 dB |
| S vs W | 覆盖归属差异 | <= 20‰ | 6 / 76800 = 0.08‰ |
| S vs W | 显著差异像素(>2 通道差) | <= 1‰ | 28 / 76800 = 0.36‰ |
S 与 P 都是定点整数实现,同一条公式、同一套舍入语义,所以可以用最硬的判据:逐字节相同。S 与 W 不行——W 层是浮点着色器,差异有两个无法消除的来源:① 插值用浮点再舍入;② 覆盖判据不同(GPU 走 top-left rule,共享边像素归属可能与整数实现相反)。所以 W 层只能按声明容差比对。
这里有一个反面教训值得写下来:「任一通道差 > 0 的像素占比」不能当主判据。本轮实测这个比例是 457.59‰——几乎所有像素都「不一致」,因为 ±1 的舍入差异到处都是,这个指标毫无区分力。有区分力的是 MAE、PSNR 和单独统计的覆盖归属差异。另外必须强调:本机无头环境的 WebGL2 后端是 ANGLE/SwiftShader 软件实现,不是真实 GPU,上面这些差异不得归因于真实硬件;需要真实硬件数据的论断一律属于 X 档,命令行不可复现。
S 层与 P 层:逐字节相同
驱动脚本读 S 层缓冲的摘要、与基线比对,再把 P 层落盘的缓冲逐字节比较,最后把 W 层的判定合并进来,写出一份三宿主对照的 JSON。
#!/usr/bin/env python3
# file: lantern/sim/cg_raster_16_2_three_host.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_raster_16_2_three_host.py
"""16.2 三种消费的联调:同一份 config/scene_spec.json 在三个宿主里的结果并排比对。
S 层(本脚本,Python 定点)与 P 层(node core/tools/render_raster_ts.ts)必须
**逐字节相同**——比对对象是像素缓冲,不是 PNG 容器字节。S 层与 W 层(浏览器
WebGL2,由 web/verify_raster16.mjs 产出 out/cg_raster_16_2_w.json)按**声明容差**
比对:MAE、PSNR、覆盖归属差异三条同时成立,且负例必须被拒绝。
本机 headless 的 WebGL2 是 ANGLE/SwiftShader **软件**后端,不是真实 GPU,差异只来自
插值舍入与覆盖判据(top-left rule),不得归因于真实硬件。
产物:out/cg_raster_16_2_three_host.json。
"""
import hashlib
import json
import os
import sys
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cglib # noqa: E402
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
BASE_SHA = "bac659770178037ad3f6a6ad184945a96ff37a7fe4d754ef1ebc5b0aaa538ced"
def sha_of(path):
with open(path, "rb") as fh:
return hashlib.sha256(fh.read()).hexdigest()
def main():
spec = cglib.load_spec()
out = os.path.join(ROOT, "out")
# ---- S 层:Python 定点软光栅化(基准真源)
s_path = os.path.join(out, "raster_hollow.rgb")
s_sha = sha_of(s_path)
assert s_sha == BASE_SHA, "S 层缓冲与基线不一致,先重跑 sim/render_raster.py"
with open(os.path.join(out, "raster_hollow_covered.bin"), "rb") as fh:
covered = int(np.frombuffer(fh.read(), dtype=np.uint8).sum())
# ---- P 层:TypeScript 定点内核(node core/tools/render_raster_ts.ts)
p_path = os.path.join(out, "raster_hollow_ts.rgb")
if not os.path.isfile(p_path):
print("缺少 P 层缓冲 out/raster_hollow_ts.rgb —— 先跑 node core/tools/render_raster_ts.ts")
return 1
p_sha = sha_of(p_path)
identical = open(s_path, "rb").read() == open(p_path, "rb").read()
# ---- W 层:浏览器 WebGL2(由 web/verify_raster16.mjs 产出)
w_path = os.path.join(out, "cg_raster_16_2_w.json")
w = json.load(open(w_path, encoding="utf-8")) if os.path.isfile(w_path) else None
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"])}
stats = {"canvas": [spec["width"], spec["height"]], "spec": "config/scene_spec.json",
"s_layer": {"lang": "python", "sha256": s_sha, "covered": covered, "bytes": len(open(s_path, "rb").read())},
"p_layer": {"lang": "typescript", "sha256": p_sha, "bytes": len(open(p_path, "rb").read())},
"s_vs_p": {"byte_identical": identical, "sha256_equal": s_sha == p_sha, "differing_bytes": 0 if identical else -1},
"s_vs_w": None if w is None else {k: w[k] for k in
("backend", "mae", "psnr_db", "cover_diff_permille", "sig_permille", "negative", "thresholds", "verdict")},
"verdict": verdict, "pass": all(verdict.values())}
with open(os.path.join(out, "cg_raster_16_2_three_host.json"), "w", encoding="utf-8") as fh:
json.dump(stats, fh, ensure_ascii=False, indent=2)
print(f"规格 : {stats['spec']} -> {spec['width']}x{spec['height']}")
print(f"{'宿主':<8}{'语言':<12}{'sha256 前 16':<20}{'覆盖像素':<10}判据")
print(f"{'S 层':<8}{'python':<12}{s_sha[:16]:<20}{covered:<10}基准真源")
print(f"{'P 层':<8}{'typescript':<12}{p_sha[:16]:<20}{covered:<10}与 S 层逐字节{'相同' if identical else '不同'}")
if w is not None:
print(f"{'W 层':<8}{'webgl2':<12}{'-':<20}{w['w_covered']:<10}"
f"MAE {w['mae']} / PSNR {w['psnr_db']} dB / 覆盖差 {w['cover_diff_permille']}‰")
print(f"负例(片元颜色 ×0.5): MAE {w['negative']['mae']} / PSNR {w['negative']['psnr_db']} dB"
f" -> 被同一判据拒绝 = {w['verdict']['negative_rejected']}")
else:
print("W 层 : 缺 out/cg_raster_16_2_w.json —— 先跑 node web/verify_raster16.mjs")
print(f"S vs P : 逐字节相同 = {identical}(不同字节数 0),sha256 相同 = {s_sha == p_sha}")
print(f"三宿主一致 : {json.dumps(verdict, ensure_ascii=False)}")
return 0 if stats["pass"] else 1
if __name__ == "__main__":
sys.exit(main())
规格 : config/scene_spec.json -> 320x240
宿主 语言 sha256 前 16 覆盖像素 判据
S 层 python bac659770178037a 43731 基准真源
P 层 typescript bac659770178037a 43731 与 S 层逐字节相同
W 层 webgl2 - 43729 MAE 0.2848 / PSNR 47.79 dB / 覆盖差 0.0781‰
负例(片元颜色 ×0.5): MAE 20.9498 / PSNR 19.12 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}
三行里 S 层与 P 层的 sha256 前 16 位完全一致,逐字节相同 = True、不同字节数 0。这正是 13.3 那条主张在整帧尺度上的复现:Python 与 TypeScript 之间是逐字节相同,这是可硬断言的。
P 层断言:三处口径同时成立
P 层测试不重新推导,而是读 S 层落盘的 out/cg_raster_16_2_three_host.json 做对账,同时自己再渲染一次核对摘要。
// file: lantern/core/test/cg_raster_16_2_three_host.test.ts
// tier: P
// verify: node --test 'core/test/cg_raster_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 侧负例 —— 写错的定点内核(降精度改成向零截断)必须让逐字节判据分叉
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 { type SceneSpec, renderScene } from "../src/lantern/pipeline.ts";
import { renderParam } from "../src/lantern/cg_raster_13_pipeline.ts";
const root = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
const spec = JSON.parse(readFileSync(join(root, "config", "scene_spec.json"), "utf8")) as SceneSpec;
const sha = (b: Uint8Array): string => createHash("sha256").update(Buffer.from(b)).digest("hex");
const BASE = "bac659770178037ad3f6a6ad184945a96ff37a7fe4d754ef1ebc5b0aaa538ced";
const three = JSON.parse(
readFileSync(join(root, "out", "cg_raster_16_2_three_host.json"), "utf8"),
) as {
s_layer: { sha256: string; covered: number };
p_layer: { sha256: string };
s_vs_p: { byte_identical: boolean; sha256_equal: boolean };
s_vs_w: {
mae: number;
psnr_db: number;
cover_diff_permille: number;
negative: { mae: number; psnr_db: number };
verdict: Record<string, boolean>;
};
pass: boolean;
};
test("P 层复现 S 层 sha256:同一份规格、同一条定点管线", () => {
const frame = renderScene(spec);
assert.equal(sha(frame.result.rgb), BASE);
assert.equal(frame.result.coveredCount, 43731);
assert.equal(frame.result.culled, 290);
assert.equal(three.s_layer.sha256, BASE);
assert.equal(three.p_layer.sha256, BASE);
});
test("S vs P 逐字节相同:落盘的 P 缓冲与 S 缓冲同摘要", () => {
const pBuf = new Uint8Array(readFileSync(join(root, "out", "raster_hollow_ts.rgb")));
const sBuf = new Uint8Array(readFileSync(join(root, "out", "raster_hollow.rgb")));
assert.equal(sha(pBuf), sha(sBuf));
assert.equal(sha(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, "三宿主一致判定必须为真");
});
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.verdict.negative_rejected, "负例必须被同一判据拒绝");
assert.ok(w.verdict.mae_ok && w.verdict.psnr_ok && w.verdict.cover_ok && w.verdict.sig_ok);
});
test("负例:W 层的错着色器与 P 层的错内核都必须被各自判据拒绝", () => {
// W 层负例:片元颜色 ×0.5,MAE 远超阈值、PSNR 跌破下限
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 层负例:降精度改用向零截断除法,逐字节判据必须分叉
const wrong = renderParam(spec, { sar: false });
assert.notEqual(sha(wrong.rgb), BASE, "写错的内核必须让逐字节判据分叉");
});
✔ P 层复现 S 层 sha256:同一份规格、同一条定点管线
✔ S vs P 逐字节相同:落盘的 P 缓冲与 S 缓冲同摘要
✔ S vs W 按声明容差:三条判据同时成立
✔ 负例:W 层的错着色器与 P 层的错内核都必须被各自判据拒绝
ℹ tests 4 ℹ pass 4 ℹ fail 0
W 层:只能按声明容差
W 层的验证脚本用 Playwright 打开构建出的单文件页面,等 canvasDrawn 置位后把 canvas 读回 Uint8Array,再与 S 层缓冲做统计比对。判据核心就是下面这段:
// snippet: W 层容差判据核心(摘自 lantern/web/verify_raster16.mjs)
/** 三条容差判据 + 显著差异像素上限(共享边归属,两边都算已覆盖)。 */
function stats(actual, expect, acov, ecov) {
let diffPx = 0;
let sigPx = 0;
let coverDiff = 0;
let maxDiff = 0;
let sumAbs = 0;
let sumSq = 0;
const pixels = expect.length / 3;
for (let i = 0; i < expect.length; i += 3) {
const p = i / 3;
let pxDiff = 0;
for (let c = 0; c < 3; c += 1) {
const d = Math.abs(actual[i + c] - expect[i + c]);
if (d > pxDiff) pxDiff = d;
sumAbs += d;
sumSq += d * d;
}
if (pxDiff > 0) diffPx += 1;
if (pxDiff > 2) sigPx += 1;
if (pxDiff > maxDiff) maxDiff = pxDiff;
if (acov[p] !== ecov[p]) coverDiff += 1;
}
const mse = sumSq / expect.length;
return {
pixels, diffPx, sigPx, coverDiff, maxDiff,
diffPermille: (diffPx / pixels) * 1000,
sigPermille: (sigPx / pixels) * 1000,
coverPermille: (coverDiff / pixels) * 1000,
mae: sumAbs / expect.length,
psnr: mse === 0 ? Infinity : 10 * Math.log10((255 * 255) / mse),
};
}
const verdict = {
mae_ok: g.mae <= 2.0,
psnr_ok: g.psnr >= 30,
cover_ok: g.coverPermille <= 20,
sig_ok: g.sigPermille <= 1,
negative_rejected: bad.mae > 2.0 && bad.psnr < 30,
};
const pass = Object.values(verdict).every(Boolean) && data.gl.ok;
canvasDrawn : true
场景 : 487 顶点 / 896 三角形(绘制 896,丢弃 0)
S 层 covered : 43731 W 层 covered : 43729
GL 后端 : ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (LLVM 10.0.0) (0x0000C0DE)), SwiftShader driver)
GL 版本 : WebGL 2.0 (OpenGL ES 3.0 Chromium) glError : 0
① CPU 定点(S 层) vs WebGL2(W 层):
任一通道差>0 : 35143 / 76800 (457.59‰) ← 含 ±1 舍入,不作判据
显著差异像素 : 28 / 76800 (0.36‰) ← 共享边归属,两边都算已覆盖
覆盖归属不同 : 6 / 76800 (0.08‰) ← 主判据
最大通道差 : 157
MAE : 0.2848 PSNR : 47.79 dB
② 负例(片元颜色 ×0.5): MAE=20.9498 PSNR=19.12 dB ← 必须被同一判据拒绝
③ 判据 : {"mae_ok":true,"psnr_ok":true,"cover_ok":true,"sig_ok":true,"negative_rejected":true}
① 那组数字把三个层次摊开了:457.59‰ 是「任一通道差 > 0」——不作判据;0.36‰ 是显著差异像素(>2 通道差),它们几乎全在共享边上,而共享边两侧的像素两边都算已覆盖,所以不计入覆盖差异;0.08‰ 才是真正的覆盖归属差异,也就是「一方有颜色、另一方是背景」的像素。② 是负例:把片元颜色乘以 0.5,MAE 从 0.2848 涨到 20.9498、PSNR 从 47.79 dB 跌到 19.12 dB,同一套阈值必须拒绝它。③ 把四条判据与负例拒绝合并成一个布尔值,全绿才算 PASS。
W 层有三个实测踩出来的坑,值得单独记一笔:① Playwright 冷启动时首个 page 读 canvas 像素恒为空白,所以 canvasDrawn 必须推迟到两次 requestAnimationFrame 之后,且断言只放宽为「已绘制(≥ 1 px)」,禁写 === 0;② NODE_PATH 对 ESM import 无效,复用全局 Playwright 必须先 createRequire(import.meta.url) 再 require("playwright");③ 顶点色在 GLSL 里必须归一化(aColor / 255.0),直接传 0–255 会被 clamp 成全白。
负例:判据不被拒绝就不算 PASS
三个宿主各自都配了负例。W 层是错着色器(颜色 ×0.5),P 层是错内核——把降精度从算术右移改成向零截断(renderParam(spec, { sar: false })),逐字节判据立刻分叉。判据若不能拒绝这些故意写错的实现,PASS 就没有意义。三种消费至此收敛:S 与 P 逐字节相同,S 与 W 按声明容差。下一节把参数拧出规格区间,看这条管线在哪里走样、在哪里相变、代价怎么涨。
阅读导航:上一节:16.1 光栅化管线的装配 · 下一节:16.3 交付卷二 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。