16.2 三卷一致性总检
站内的 图形学与 GPU 编程专题 底下挂着三卷书,它们各自的封版摘要必须能并排放进同一张表——否则「三卷」只是三个互不相干的工程。本节做总检:把三卷的基线在同一个进程里各重算一次(避免三个摘要来自三个不同时刻的构建),再把计数、容差与判据并成一张表,最后引第 13 章与第 14 章的实测锚,说清一件反直觉的事。
三卷基线总表
| 卷 | 路径 | 产物 | sha256 | 计数 | 判据 |
|---|---|---|---|---|---|
| 卷一 | 顶点光栅化(插值颜色) | out/raster_hollow.rgb | bac659770178037a… | 覆盖 43731 / 剔除 290 | S↔P 逐字节相同 |
| 卷二 | 逐像素光栅化(插值法线) | out/shade_hollow.rgb | 0d997343ca547ed5… | 覆盖 43731 / 剔除 290 | S↔P 逐字节相同 |
| 卷三 | 光线追踪(主光线 + 阴影光线) | out/trace_hollow.rgb | 213b413069ca66c9… | 命中 43731 | S↔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 交付与后续 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。