16.2 三卷一致性总检

把三卷基线的 sha256、计数与容差判据并成一张表:卷一顶点光栅化 bac65977…、卷二逐像素 0d997343…、卷三主光线 213b4130…,三者命中或覆盖都是 43731 像素;再引第 13.3 与第 14.3 节的实测锚给出反直觉结论——列口径真 bug 下两个语言实现仍逐字节相同,只有掩码对称差(4 对 22212)抓得出来。

16.2 三卷一致性总检

站内的 图形学与 GPU 编程专题 底下挂着三卷书,它们各自的封版摘要必须能并排放进同一张表——否则「三卷」只是三个互不相干的工程。本节做总检:把三卷的基线在同一个进程里各重算一次(避免三个摘要来自三个不同时刻的构建),再把计数、容差与判据并成一张表,最后引第 13 章与第 14 章的实测锚,说清一件反直觉的事。

三卷基线总表

卷路径产物sha256计数判据
卷一顶点光栅化(插值颜色)out/raster_hollow.rgbbac659770178037a…覆盖 43731 / 剔除 290S↔P 逐字节相同
卷二逐像素光栅化(插值法线)out/shade_hollow.rgb0d997343ca547ed5…覆盖 43731 / 剔除 290S↔P 逐字节相同
卷三光线追踪(主光线 + 阴影光线)out/trace_hollow.rgb213b413069ca66c9…命中 43731S↔P 逐字节 + 掩码锚在外部参照

卷三的计数比另两卷多一整套:主光线 76800、命中 43731、阴影光线 77211(遮挡 1357)、BVH 访问 3238333、三角形测试 1119804、BVH 511 节点、树高 9。三卷的「覆盖/命中」都是 43731,这既不是巧合也不是互相抄——它是同一条几何、同一套相机在三套遍历规则下应当给出的同一个答案。

一次总检脚本

#!/usr/bin/env python3
# file: lantern/sim/cg_trace_16_2_consistency.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_trace_16_2_consistency.py
"""16.2《三卷一致性总检》:三卷的基线在同一个进程里各重算一次,摆进同一张表。

三卷消费的是**同一份** config/scene_spec.json,走的是三条不同的路径:
  卷一 顶点光栅化(插值颜色)      -> out/raster_hollow.rgb  sha256 bac65977…
  卷二 逐像素光栅化(插值法线)    -> out/shade_hollow.rgb   sha256 0d997343…
  卷三 光线追踪(主光线 + 阴影光线)-> out/trace_hollow.rgb  sha256 213b4130…
本脚本重算三者并核对:三者都在同一进程里产出,避免「三个文件来自三个不同时刻的构建」。

再补两条**跨卷**的锚:卷三的命中掩码 vs 卷二逐像素光栅化的覆盖掩码(同一份几何、同一套
相机),以及引用第 13.3 节与第 14.3 节的实测锚(跨语言逐字节、跨路径掩码对称差)——
后者给出本卷最重要的反直觉结论:**C1 逐字节一致是「一致性」判据,不是「正确性」判据**。

产物 out/cg_trace_16_2_consistency.json 与 out/cg_trace_16_2_table.csv。
"""

import hashlib
import json
import os
import sys
import time

import numpy as np

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
sys.path.insert(0, HERE)
import cglib  # noqa: E402
import cgshade  # noqa: E402
import cgtrace  # noqa: E402
import render_raster  # noqa: E402
import render_shade  # noqa: E402
import render_trace  # noqa: E402

OUT = os.path.join(ROOT, "out")
BASELINE = {
    "volume1": "bac659770178037ad3f6a6ad184945a96ff37a7fe4d754ef1ebc5b0aaa538ced",
    "volume2": "0d997343ca547ed5cada5e4b1f75ce1782d79e524dba817278853de77307a6de",
    "volume3": "213b413069ca66c9f0cc091bce5328eac0f540d075afa3bab37788aea335ba71",
}


def sha_u8(img):
    return hashlib.sha256(np.asarray(img, dtype=np.uint8).tobytes()).hexdigest()


def render_volume1(spec):
    """卷一的路径:顶点变换 → 屏幕映射 → 插值**颜色**的光栅化(与 render_raster.main 同口径)。"""
    width, height = spec["width"], spec["height"]
    cam = spec["camera"]
    aspect = cglib.fdiv(cglib.f_from_int(width), cglib.f_from_int(height))
    view = cglib.mat_look_at(tuple(cam["eyeQ16"]), tuple(cam["targetQ16"]), tuple(cam["upQ16"]))
    proj = cglib.mat_perspective(cam["tanHalfFovYQ16"], aspect, cam["nearQ16"], cam["farQ16"])
    mvp = cglib.mat_mul(proj, view)
    verts, tris, vcolors = render_raster.build_scene(spec)
    screen = []
    for v in verts:
        p = cglib.project(mvp, v)
        screen.append(None if p is None else (*cglib.to_screen(p[0], p[1], width, height), p[2]))
    rtris = []
    for a, b, c in tris:
        sa, sb, sc = screen[a], screen[b], screen[c]
        if sa is None or sb is None or sc is None:
            continue
        rtris.append((sa[0], sa[1], sa[2], vcolors[a][0], vcolors[a][1], vcolors[a][2],
                      sb[0], sb[1], sb[2], vcolors[b][0], vcolors[b][1], vcolors[b][2],
                      sc[0], sc[1], sc[2], vcolors[c][0], vcolors[c][1], vcolors[c][2]))
    color, _depth, covered, culled = cglib.rasterize(rtris, width, height, spec["backgroundQ8"])
    return np.clip(color, 0, 255).astype(np.uint8), covered, int(culled)


def render_volume2(spec):
    """卷二的路径:同一几何 + 解析法线,插值**法线**的逐像素着色光栅化。"""
    width, height = spec["width"], spec["height"]
    tris, _total, _dropped = render_shade.build_scene_shaded(spec)
    color, _depth, covered, culled = cgshade.rasterize_shaded(
        tris, width, height, spec["backgroundQ8"], render_shade.make_shader(spec))
    return np.clip(color, 0, 255).astype(np.uint8), covered, int(culled)


def main():
    t0 = time.time()
    spec = cglib.load_spec()
    width, height = spec["width"], spec["height"]
    bg = tuple(spec["backgroundQ8"])
    os.makedirs(OUT, exist_ok=True)

    v1, cov1, cul1 = render_volume1(spec)
    v2, cov2, cul2 = render_volume2(spec)
    ctx = {"stats": {"shadow_rays": 0, "shadow_hits": 0}}
    v3, stats = cgtrace.render_trace(spec, width, height, 1, render_trace.make_shader, ctx)
    hit3 = ~((v3[:, :, 0] == bg[0]) & (v3[:, :, 1] == bg[1]) & (v3[:, :, 2] == bg[2]))

    tris, _d = cgtrace.build_scene_tris(spec)
    nodes, order = cgtrace.build_bvh(tris)

    def tree_height(i=0):
        if nodes[i][6] < 0:
            return 1
        return 1 + max(tree_height(nodes[i][6]), tree_height(nodes[i][9]))

    # 跨卷锚:卷三命中掩码 vs 卷二逐像素覆盖掩码(同一几何、同一相机)
    mask_diff = int((hit3 ^ cov2).sum())
    # 卷三命中掩码 vs 卷一覆盖掩码,作为第二个独立参照
    mask_diff_v1 = int((hit3 ^ cov1).sum())

    rows = [
        {"volume": "卷一", "path": "顶点光栅化(插值颜色)", "artifact": "out/raster_hollow.rgb",
         "sha256": sha_u8(v1), "coveredPixels": int(cov1.sum()), "culled": cul1,
         "criterion": "S↔P 逐字节相同"},
        {"volume": "卷二", "path": "逐像素光栅化(插值法线)", "artifact": "out/shade_hollow.rgb",
         "sha256": sha_u8(v2), "coveredPixels": int(cov2.sum()), "culled": cul2,
         "criterion": "S↔P 逐字节相同"},
        {"volume": "卷三", "path": "光线追踪(主光线 + 阴影光线)", "artifact": "out/trace_hollow.rgb",
         "sha256": cgtrace.digest(v3), "coveredPixels": int(hit3.sum()), "culled": 0,
         "criterion": "S↔P 逐字节相同 + 掩码锚在外部参照"},
    ]
    for r, key in zip(rows, ("volume1", "volume2", "volume3")):
        r["declaredSha256"] = BASELINE[key]
        r["matchesDeclared"] = r["sha256"] == BASELINE[key]

    # 第 13.3 节 / 第 14.3 节的实测锚(读产物,不重算)
    c13 = json.load(open(os.path.join(OUT, "cg_trace_13_3_cross_language.json"), encoding="utf-8"))
    c14 = json.load(open(os.path.join(OUT, "cg_trace_14_3_criteria.json"), encoding="utf-8"))
    c1 = c14["C1_crossLanguage"]["crossLanguage"]
    c2 = c14["C2_crossPathMask"]
    anchor = {
        "ch13_3": {"rowHits": c13["rowHits"], "colHits": c13["colHits"],
                   "colDropPermille": c13["colDropPermille"], "colSha": c13["colSha"],
                   "numHits": c13["numHits"], "numMaxAbsDt": c13["numMaxAbsDt"]},
        "ch14_3": {"rowBasisSha256": c14["C1_crossLanguage"]["rowBasisSha256"],
                   "columnBasisSha256": c14["C1_crossLanguage"]["columnBasisSha256"],
                   "rowMaskDiffPixels": c2["rowMaskDiffPixels"],
                   "columnMaskDiffPixels": c2["columnMaskDiffPixels"]},
    }
    verdict = {
        "C1_row_byteExact": bool(c1["row"]["byteExact"]),
        "C1_col_byteExact": bool(c1["col"]["byteExact"]),
        "C2_row_accept": bool(c2["verdictRow"]),
        "C2_col_reject": bool(c2["verdictColumn"]),
    }
    conclusion = ("C1(跨语言逐字节)对行口径与列口径**都通过**——两个实现犯的是同一个错;"
                  "C2(与光栅化覆盖掩码的对称差)接受行口径、拒绝列口径。"
                  "故逐字节一致是**一致性**判据,不是**正确性**判据。")

    out = {
        "canvas": [width, height],
        "table": rows,
        "volume3Counts": {
            "primaryRays": stats["rays"], "hitPixels": int(hit3.sum()),
            "shadowRays": ctx["stats"]["shadow_rays"], "shadowHits": ctx["stats"]["shadow_hits"],
            "bvhVisited": stats["visited"], "bvhTested": stats["tested"],
            "bvhNodes": len(nodes), "bvhHeight": tree_height(), "bvhOrdered": len(order),
        },
        "crossVolumeAnchor": {"rayVsVolume2Covered": mask_diff, "rayVsVolume1Covered": mask_diff_v1,
                              "volume2CoveredPixels": int(cov2.sum()),
                              "volume1CoveredPixels": int(cov1.sum())},
        "criteriaAnchor": anchor, "verdict": verdict, "conclusion": conclusion,
        "ms": int((time.time() - t0) * 1000),
    }
    with open(os.path.join(OUT, "cg_trace_16_2_consistency.json"), "w", encoding="utf-8") as fh:
        json.dump(out, fh, ensure_ascii=False, indent=2)
        fh.write("\n")
    with open(os.path.join(OUT, "cg_trace_16_2_table.csv"), "w", encoding="utf-8") as fh:
        fh.write("volume,path,artifact,sha256,coveredPixels,culled,criterion\n")
        for r in rows:
            fh.write(f"{r['volume']},{r['path']},{r['artifact']},{r['sha256']},"
                     f"{r['coveredPixels']},{r['culled']},{r['criterion']}\n")

    print("== 16.2 三卷一致性总检 ==")
    for r in rows:
        print(f"{r['volume']} {r['path']:<22s} {r['sha256']}")
        print(f"      覆盖 {r['coveredPixels']} 像素 / 剔除 {r['culled']}   "
              f"与声明基线相同 {r['matchesDeclared']}")
    print(f"卷三计数        : 主光线 {stats['rays']} / 命中 {int(hit3.sum())} / "
          f"阴影 {ctx['stats']['shadow_rays']}(遮挡 {ctx['stats']['shadow_hits']})")
    print(f"                  BVH {len(nodes)} 节点 高 {tree_height()} / "
          f"访问 {stats['visited']} / 测试 {stats['tested']}")
    print(f"跨卷掩码锚      : 光追命中 vs 卷二覆盖 对称差 {mask_diff} 像素"
          f"(vs 卷一覆盖 {mask_diff_v1})")
    print(f"判据锚          : C1 行 {verdict['C1_row_byteExact']} / C1 列 {verdict['C1_col_byteExact']}"
          f" / C2 行接受 {verdict['C2_row_accept']} / C2 列拒绝 {verdict['C2_col_reject']}")
    print(f"结论            : {conclusion}")
    print(f"耗时 {out['ms']} ms")
    return 0


if __name__ == "__main__":
    sys.exit(main())

实跑输出:

== 16.2 三卷一致性总检 ==
卷一 顶点光栅化(插值颜色)            bac659770178037ad3f6a6ad184945a96ff37a7fe4d754ef1ebc5b0aaa538ced
      覆盖 43731 像素 / 剔除 290   与声明基线相同 True
卷二 逐像素光栅化(插值法线)           0d997343ca547ed5cada5e4b1f75ce1782d79e524dba817278853de77307a6de
      覆盖 43731 像素 / 剔除 290   与声明基线相同 True
卷三 光线追踪(主光线 + 阴影光线)       213b413069ca66c9f0cc091bce5328eac0f540d075afa3bab37788aea335ba71
      覆盖 43731 像素 / 剔除 0   与声明基线相同 True
卷三计数        : 主光线 76800 / 命中 43731 / 阴影 77211(遮挡 1357)
                  BVH 511 节点 高 9 / 访问 3238333 / 测试 1119804
跨卷掩码锚      : 光追命中 vs 卷二覆盖 对称差 4 像素(vs 卷一覆盖 4)
判据锚          : C1 行 True / C1 列 True / C2 行接受 True / C2 列拒绝 True
结论            : C1(跨语言逐字节)对行口径与列口径**都通过**——两个实现犯的是同一个错;C2(与光栅化覆盖掩码的对称差)接受行口径、拒绝列口径。故逐字节一致是**一致性**判据,不是**正确性**判据。
耗时 6964 ms

跨卷掩码锚那一行是本节新加的:卷三的命中掩码对卷二逐像素光栅化的覆盖掩码,对称差同样是 4 像素。也就是说,第 14.1 节用卷一光栅化器做参照得到的 4 像素,换成卷二的光栅化器依然成立——参照物换了一个,结论不变。

反直觉的结论

第 13.3 节把相机基从行口径改成列口径,测到命中像素从 43731 掉到 21519(−507.9234‰),而 Python 与 TypeScript 两个实现照样逐字节相等:行口径两侧都是 213b4130…,列口径两侧都是 4d2416f9…(第 14.3 节落盘的 columnBasisSha256)。第 14.3 节把这件事写成了三类判据的对照:

判据比对对象行口径列口径
C1 跨语言逐字节同一条路径的两个语言实现通过(213b4130… 两侧相同)也通过(4d2416f9… 两侧相同)
C2 跨路径掩码光追命中掩码 vs 光栅化覆盖掩码接受(对称差 4 像素)拒绝(对称差 22212 像素)
C3 跨宿主容差WebGL2 vs CPU 定点接受(MAE 0.5067 / PSNR 47.03 dB)不适用

C1 的失效不是实现不够好,而是它比对的双方会一起错:两个实现共用一个错误的相机基,摘要自然一致。C2 之所以管用,是因为它拿一个外部参照(另一条路径产出的掩码)当尺子——两条路径不共用任何中间量,连「颜色」的定义都不一样,所以它们不可能一起错成同一个形状。

P 层:把「两侧同错」在 TypeScript 里再犯一次

P 层不满足于引用结论,它在 BigInt 里把行、列两个口径各渲染一遍,断言两件事:行口径的摘要等于卷三基线;列口径的摘要等于 Python 侧落盘的列口径摘要——即在 TS 里再犯一次同样的错,两侧仍然逐字节相同。

// snippet: 16.2 的 P 层断言(摘自 lantern/core/test/cg_trace_16_2_consistency.test.ts)
test("列口径(真 bug)同样跨语言逐字节相同——C1 抓不到两侧同错", () => {
  const row = renderBasis("row");
  const col = renderBasis("col");
  assert.equal(sha(row), golden.table[2].sha256);
  // TS 列口径的摘要 == Python 列口径的摘要 == 第 14.3 节落盘的 columnBasisSha256
  assert.equal(sha(col), golden.criteriaAnchor.ch14_3.columnBasisSha256 as string);
  assert.equal(hitPixels(col), golden.criteriaAnchor.ch13_3.colHits);
  assert.notEqual(sha(col), sha(row));
  assert.equal(golden.verdict.C1_row_byteExact, true);
  assert.equal(golden.verdict.C1_col_byteExact, true);
});

test("C2:掩码对称差才是锚在外部参照上的判据", () => {
  // 行口径 4 像素、列口径 22212 像素:同一条 C2 判据接受前者、拒绝后者
  assert.equal(golden.criteriaAnchor.ch14_3.rowMaskDiffPixels, 4);
  assert.equal(golden.criteriaAnchor.ch14_3.columnMaskDiffPixels, 22212);
  assert.equal(golden.crossVolumeAnchor.rayVsVolume2Covered, 4);
  assert.equal(golden.verdict.C2_row_accept, true);
  assert.equal(golden.verdict.C2_col_reject, true);
});
✔ 卷三基线:行口径与 S 层逐字节相同,计数与 S 层相同 (1152.009834ms)
✔ 列口径(真 bug)同样跨语言逐字节相同——C1 抓不到两侧同错 (1736.957417ms)
✔ C2:掩码对称差才是锚在外部参照上的判据 (0.09475ms)
ℹ tests 3  ℹ pass 3  ℹ fail 0

阅读导航:上一节:16.1 渲染器的全链路回放 · 下一节:16.3 交付与后续 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

  1. 16.3 交付卷三
  2. 16.2 三种消费的联调
  3. 16.1 着色管线的装配