16.1 渲染器的全链路回放

把 config/scene_spec.json 到像素缓冲的八级链路逐级重放:规格摘要、相机基行口径、76800 条主光线方向摘要、BVH 511 节点高 9、命中 43731 像素、阴影光线 77211(遮挡 1357)、遍历 3238333 节点,末级 sha256 与卷三基线逐字节相同,P 层在 BigInt 里复现每一级摘要。

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…
4BVH 构建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 三卷一致性总检 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

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