16.2 三种消费的联调

同一份 config/scene_spec.json 分别喂给 S 层 Python 定点软光栅化、P 层 TypeScript 定点内核与 W 层浏览器 WebGL2:S 与 P 逐字节相同,S 与 W 按 MAE、PSNR、覆盖归属三条声明容差比对,并让片元颜色 ×0.5 的负例被同一判据拒绝。

16.2 三种消费的联调

Unity/SRP 口径的渲染管线 关心的是引擎里怎么把管线配起来;本节关心的是同一份可执行规格被三种不同宿主消费后,结果能不能收敛。所谓三种消费,指的是同一份 config/scene_spec.json 分别喂给:

  • S 层:Python + numpy 的定点软光栅化(sim/),顶点层 Python int、逐像素层 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 WMAE<= 2.00.2848
S vs WPSNR>= 30 dB47.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 交付卷二 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

  1. 14.3 中间缓冲的可视化
  2. 14.2 覆盖率与线框
  3. 14.1 深度图与法线图