16.1 渲染器的全链路回放
站内的 图形学与 GPU 编程专题
把实时管线与离线管线分成两棵树:前者按屏幕逐片元推进,后者按光线逐路径推进。本节是卷三的收束——把 config/scene_spec.json 到像素缓冲的整条链首尾相接成一条可回放的流水线,并给每一级都配一个可断言的量。装配的难点从来不在任何单个环节,而在接口:上一级的出口必须正好是下一级的入口,一处量纲错位,成像就会整体偏掉,而且往往偏得「看着还挺像」。
同一份规格,第三条消费路径
config/scene_spec.json 在这一套书里被消费了三次。卷一按屏幕逐像素插值颜色;卷二把插值对象换成法线,逐像素调用一次着色函数;卷三则完全不遍历屏幕——它从相机出发,为每个像素发一条主光线,命中谁就着色谁。
| 卷 | 消费方式 | 遍历的对象 | 像素颜色的来源 |
|---|---|---|---|
| 卷一《几何变换与光栅化》 | 顶点光栅化 | 896 个三角形 | 顶点色插值 |
| 卷二《着色、光照与纹理》 | 逐像素光栅化 | 896 个三角形 | 片元法线 + albedo 着色 |
| 卷三《光线追踪与全局光照》 | 光线追踪 | 屏幕上的 76800 个像素 | 主光线命中点 + 阴影光线 |
三条路径共用同一套相机参数(mat_look_at + mat_perspective)、同一套几何(487 顶点 / 896 三角形)、同一份定点底座,所以它们的结果可以逐像素相减——第 14 章的两路径对照正是靠这一点才成立的。
八级链路
| 级 | 环节 | 入口 | 出口 | 本节落下的可断言量 |
|---|---|---|---|---|
| 1 | 规格 | scene_spec.json | 相机与光源参数 | 文件 sha256 0728cdc6… |
| 2 | 相机基 | mat_look_at 的行主序矩阵 | right / up / back | -back 精确等于 eye→target(误差 0) |
| 3 | 主光线生成 | 像素坐标 | 单位方向 | 76800 条方向的摘要 09458700… |
| 4 | BVH 构建 | 896 个世界空间三角形 | 511 节点 / 高 9 | 形状与最大叶容量 4 |
| 5 | 求交 | 主光线 + BVH | 命中记录 | 命中 43731 像素(569.4141‰) |
| 6 | 着色 | 命中点 + 法线 + albedo | 三个 8 位通道 | 阴影光线 77211 / 遮挡 1357 |
| 7 | 遍历代价 | — | 访问 / 测试计数 | 3238333 / 1119804 |
| 8 | 输出 | 颜色缓冲 | 像素缓冲 | sha256 213b4130… |
第 2 级值得单独说一句。mat_look_at 返回的是行主序的 4×4 矩阵,世界空间的 right / up / back 是它的第 0 / 1 / 2 行;若误取列,up 的 z 分量与 back 的 y 分量会各反一个号,主光线整体朝上偏。判据不是「看起来对不对」,而是一条恒等式:-back 必须精确等于归一化的 eye→target(实测最大误差 0 LSB)。第 3 级的摘要把这条判据放大到 76800 条光线上——任何一条方向变了,摘要立刻分叉。
逐级回放(S 层)
#!/usr/bin/env python3
# file: lantern/sim/cg_trace_16_1_full_chain.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_trace_16_1_full_chain.py
"""16.1《渲染器的全链路回放》:把 config/scene_spec.json 到像素缓冲的八级链路逐级重放。
第 1–15 章每一章只动链路的一段;本节把整条链首尾相接,并给**每一级**配一个可断言的量:
规格摘要、相机基(行口径)、76800 条主光线方向的摘要、BVH 形状、命中像素数、阴影光线数、
BVH 遍历计数、像素缓冲 sha256。逐级重放必须与 `cgtrace.render_trace` 的整链结果落在同一个
摘要上——否则说明「级与级之间的接口」有量纲错位,而不是某一级算错了。
另加一次独立复核:每 8 像素重走一遍 BVH,摘要 (t, u, v),用来证明命中掩码不是从颜色缓冲
反推出来的巧合。
产物 out/cg_trace_16_1_full_chain.json。
"""
import hashlib
import json
import os
import struct
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 cgtrace # noqa: E402
import render_trace # noqa: E402
OUT = os.path.join(ROOT, "out")
CONFIG = os.path.join(ROOT, "config", "scene_spec.json")
def bvh_height(nodes, i=0):
"""BVH 树高,叶子计 1。"""
if nodes[i][6] < 0:
return 1
return 1 + max(bvh_height(nodes, nodes[i][6]), bvh_height(nodes, nodes[i][9]))
def main():
t0 = time.time()
spec = cglib.load_spec()
width, height = spec["width"], spec["height"]
bg = tuple(spec["backgroundQ8"])
# ---- 级 1:规格 ----
raw = open(CONFIG, "rb").read()
spec_sha = hashlib.sha256(raw).hexdigest()
canon_sha = hashlib.sha256(
json.dumps(json.loads(raw), sort_keys=True, separators=(",", ":")).encode()
).hexdigest()
# ---- 级 2:相机基(mat_look_at 行主序,世界空间基取**行**)----
cam = spec["camera"]
eye = tuple(cam["eyeQ16"])
view = cglib.mat_look_at(eye, tuple(cam["targetQ16"]), tuple(cam["upQ16"]))
right, up, back = view[0:3], view[4:7], view[8:11]
axis = cglib.v_norm(cglib.v_sub(tuple(cam["targetQ16"]), eye))
basis_err = max(abs(-back[k] - axis[k]) for k in range(3))
# ---- 级 3:76800 条主光线方向 ----
tan_half = cam["tanHalfFovYQ16"]
tan_x = cglib.fmul(tan_half, cglib.fdiv(cglib.f_from_int(width), cglib.f_from_int(height)))
half_w, half_h = width // 2, height // 2
def ray_dir(px, py):
sy = cglib.fdiv(cglib.f_from_int(2 * (half_h - py) - 1), cglib.f_from_int(height))
ay = cglib.fmul(sy, tan_half)
sx = cglib.fdiv(cglib.f_from_int(2 * (px - half_w) + 1), cglib.f_from_int(width))
ax = cglib.fmul(sx, tan_x)
return cglib.v_norm(tuple(cglib.fmul(ax, right[k]) + cglib.fmul(ay, up[k]) - back[k]
for k in range(3)))
dirs = hashlib.sha256()
for py in range(height):
for px in range(width):
dirs.update(struct.pack("<iii", *ray_dir(px, py)))
# ---- 级 4:BVH ----
tris, dropped = cgtrace.build_scene_tris(spec)
nodes, order = cgtrace.build_bvh(tris)
leaves = [n for n in nodes if n[6] < 0]
# ---- 级 5–8:整链渲染(主光线 → BVH 遍历 → 求交 → 着色 → 像素缓冲)----
ctx = {"stats": {"shadow_rays": 0, "shadow_hits": 0}}
color, stats = cgtrace.render_trace(spec, width, height, 1, render_trace.make_shader, ctx)
hit = ~((color[:, :, 0] == bg[0]) & (color[:, :, 1] == bg[1]) & (color[:, :, 2] == bg[2]))
hit_px = int(hit.sum())
digest = cgtrace.digest(color)
# ---- 独立复核:每 8 像素重走 BVH,摘要 (t, u, v),不复用整链渲染的内部状态 ----
probe, probe_hits, probe_rays = hashlib.sha256(), 0, 0
for py in range(0, height, 8):
for px in range(0, width, 8):
probe_rays += 1
r = cgtrace.bvh_intersect(nodes, order, tris, eye, ray_dir(px, py), cam["farQ16"])
if r is not None:
probe_hits += 1
probe.update(struct.pack("<qii", r[0], r[2], r[3]))
base = json.load(open(os.path.join(OUT, "cg_trace_baseline.json"), encoding="utf-8"))
checks = {
"相机基 -back == eye→target": basis_err == 0,
"主光线数 == 320x240": stats["rays"] == width * height == base["primaryRays"],
"命中像素 == 基线": hit_px == base["hitPixels"],
"阴影光线 == 基线": ctx["stats"]["shadow_rays"] == base["shadowRays"],
"遮挡计数 == 基线": ctx["stats"]["shadow_hits"] == base["shadowHits"],
"BVH 访问 == 基线": stats["visited"] == base["bvhVisited"],
"BVH 测试 == 基线": stats["tested"] == base["bvhTested"],
"BVH 节点 == 基线": len(nodes) == base["bvhNodes"],
"像素缓冲 sha256 == 基线": digest == base["sha256"],
}
out = {
"canvas": [width, height],
"stage1_spec": {"sha256": spec_sha, "canonicalSha256": canon_sha},
"stage2_camera": {"right": list(right), "up": list(up), "back": list(back),
"basisMaxAbsErrQ16": basis_err},
"stage3_primaryRays": {"count": stats["rays"], "dirSha256": dirs.hexdigest()},
"stage4_bvh": {"nodes": len(nodes), "orderedTriangles": len(order),
"leaves": len(leaves), "maxLeafSize": max(n[7] for n in leaves),
"height": bvh_height(nodes), "triangles": len(tris), "dropped": dropped},
"stage5_intersection": {"hitPixels": hit_px,
"hitPermille": round(hit_px * 1000.0 / (width * height), 4),
"probeRays": probe_rays, "probeHits": probe_hits,
"probeTuvSha256": probe.hexdigest()},
"stage6_shading": {"shadowRays": ctx["stats"]["shadow_rays"],
"shadowHits": ctx["stats"]["shadow_hits"]},
"stage7_traversal": {"visited": stats["visited"], "tested": stats["tested"],
"visitedPerRay": round(stats["visited"] / stats["rays"], 4)},
"stage8_output": {"sha256": digest, "bytes": int(color.size)},
"checks": checks, "allPass": all(checks.values()), "ms": int((time.time() - t0) * 1000),
}
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "cg_trace_16_1_full_chain.json"), "w", encoding="utf-8") as fh:
json.dump(out, fh, ensure_ascii=False, indent=2)
fh.write("\n")
print("== 16.1 全链路回放 ==")
print(f"级 1 规格 : {spec_sha[:16]} (规范化 {canon_sha[:16]})")
print(f"级 2 相机基 : right{list(right)} up{list(up)} back{list(back)}")
print(f" -back == eye→target 最大误差 {basis_err} LSB")
print(f"级 3 主光线 : {stats['rays']} 条 方向摘要 {dirs.hexdigest()[:16]}")
print(f"级 4 BVH : {len(nodes)} 节点 / {len(order)} 有序三角形 / 高 "
f"{bvh_height(nodes)} / 最大叶容量 {max(n[7] for n in leaves)}")
print(f"级 5 求交 : 命中 {hit_px} 像素({out['stage5_intersection']['hitPermille']}‰)"
f" 抽样 {out['stage5_intersection']['probeRays']} 条光线命中 {probe_hits}"
f" t/u/v 摘要 {probe.hexdigest()[:16]}")
print(f"级 6 着色 : 阴影光线 {ctx['stats']['shadow_rays']} 遮挡 {ctx['stats']['shadow_hits']}")
print(f"级 7 BVH 遍历 : 访问 {stats['visited']} 节点 / 测试 {stats['tested']} 三角形")
print(f"级 8 输出 : sha256 {digest}")
print("逐级对账:")
for k, v in checks.items():
print(f" {'OK ' if v else 'FAIL'} {k}")
print(f"全过 {out['allPass']} 耗时 {out['ms']} ms")
return 0
if __name__ == "__main__":
sys.exit(main())
实跑输出(本机 Apple M1 Pro / 单线程 Python):
== 16.1 全链路回放 ==
级 1 规格 : 0728cdc68b31daaf (规范化 6c142d7753955f95)
级 2 相机基 : right[65537, 0, 0] up[0, 64645, -10775] back[0, 10774, 64644]
-back == eye→target 最大误差 0 LSB
级 3 主光线 : 76800 条 方向摘要 094587005c8b4a16
级 4 BVH : 511 节点 / 896 有序三角形 / 高 9 / 最大叶容量 4
级 5 求交 : 命中 43731 像素(569.4141‰) 抽样 1200 条光线命中 666 t/u/v 摘要 34ab8d2653b1cdd5
级 6 着色 : 阴影光线 77211 遮挡 1357
级 7 BVH 遍历 : 访问 3238333 节点 / 测试 1119804 三角形
级 8 输出 : sha256 213b413069ca66c9f0cc091bce5328eac0f540d075afa3bab37788aea335ba71
逐级对账:
OK 相机基 -back == eye→target
OK 主光线数 == 320x240
OK 命中像素 == 基线
OK 阴影光线 == 基线
OK 遮挡计数 == 基线
OK BVH 访问 == 基线
OK BVH 测试 == 基线
OK BVH 节点 == 基线
OK 像素缓冲 sha256 == 基线
全过 True 耗时 6996 ms
逐级对账的 P 层
P 层把同一批量在 BigInt 里重算一遍——不是「再渲染一次看看像不像」,而是逐个摘要比对:规格文件的字节、76800 条方向拼成的 921600 字节、每 8 像素抽样重走 BVH 得到的 (t, u, v) 三元组、以及末级的像素缓冲。抽样那一路刻意不复用整链渲染的内部状态,否则「命中掩码」就只是颜色缓冲的自我印证。
// snippet: 16.1 的 P 层断言(摘自 lantern/core/test/cg_trace_16_1_full_chain.test.ts)
const buf = Buffer.alloc(width * height * 12);
let o = 0;
for (let py = 0; py < height; py++) {
for (let px = 0; px < width; px++) {
const d = rayDir(px, py);
buf.writeInt32LE(Number(d[0]), o);
buf.writeInt32LE(Number(d[1]), o + 4);
buf.writeInt32LE(Number(d[2]), o + 8);
o += 12;
}
}
assert.equal(sha(buf), golden.stage3_primaryRays.dirSha256);
// 独立复核:每 8 像素重走 BVH,摘要 (t, u, v),不复用整链渲染的内部状态
const probeTris = buildSceneTris(spec).tris;
const bvh = buildBvh(probeTris);
const probe = Buffer.alloc(golden.stage5_intersection.probeRays * 16);
o = 0;
let hits = 0;
for (let py = 0; py < height; py += 8) {
for (let px = 0; px < width; px += 8) {
const r = bvhIntersect(bvh, probeTris, eye, rayDir(px, py), BigInt(cam.farQ16));
if (r !== null) {
hits += 1;
probe.writeBigInt64LE(r[0], o);
probe.writeInt32LE(Number(r[2]), o + 8);
probe.writeInt32LE(Number(r[3]), o + 12);
o += 16;
}
}
}
assert.equal(hits, golden.stage5_intersection.probeHits);
assert.equal(sha(probe.subarray(0, o)), golden.stage5_intersection.probeTuvSha256);
✔ 级 1–4:规格摘要、相机基、主光线方向摘要、BVH 形状与 S 层相同 (78.027333ms)
✔ 级 5–7:命中像素、抽样 (t,u,v) 摘要、阴影光线、BVH 遍历计数与 S 层相同 (1244.513167ms)
✔ 级 8:像素缓冲 sha256 与 S 层逐字节相同 (1179.9175ms)
ℹ tests 3 ℹ pass 3 ℹ fail 0
接口接错会怎样
把第 2 级的方向取法从行换成列,本节的八级回放依然「跑得通」:链路没有一处抛错,像素缓冲照常落盘,P 层也照样复现出同一个摘要——因为两个语言实现犯的是同一个错。变的是第 5 级的出口:命中像素从 43731 掉到 21519(少 49.2%),而第 4 级的 BVH 形状、第 6 级的阴影光线数全都还在正常量级里。这正是下一节要展开的那件事:逐字节一致是「一致性」判据,不是「正确性」判据;把它抓出来的是第 5 级出口与光栅化覆盖掩码的比对。
阅读导航:上一节:15.3 参数与质量的取舍 · 下一节:16.2 三卷一致性总检 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。