13.3 跨语言对照

同一份 scene_spec.json 交给 Python 与 TypeScript,像素缓冲 sha256 同为 213b4130…、五个计数逐条相同。把相机基从行换成列,命中像素从 43731 掉到 21519,两侧却依旧逐字节相同——跨语言一致抓不到「两边一起错」。再把 BigInt 换成 Number,右移只有 32 位,命中像素静默变成 8484。

13.3 跨语言对照

站内 /posts/books/渲染引擎/ 那套讲的是 TypeScript 引擎架构:RHI 抽象、帧图调度、可见性剔除,关心的是结构怎么摆、每帧的代价怎么摊。本节不碰架构,只做一件更窄也更硬的事:同一份 config/scene_spec.json 交给 Python 和 TypeScript,两边的像素缓冲能不能逐字节相同。13.1 量的是「一条光线会不会分叉」,13.2 量的是「一批光线怎么拼」,本节把这两层抬到语言边界上收口。

判据:像素缓冲的 sha256,不是 PNG 容器字节

不能比 PNG。PNG 是容器:zlib 版本、压缩级别、chunk 顺序、时间戳,任何一样不同都会让字节不同,而画面可以一模一样。所以判据取像素缓冲本身——320×240 的 8 位 RGB,连成一串算 sha256,再配上五个计数:

计数PythonTypeScript
主光线7680076800
阴影光线 / 遮挡77211 / 135777211 / 1357
BVH 访问节点32383333238333
三角形测试11198041119804
sha256(rgb)213b4130…ba71213b4130…ba71

P 层直接跑仓库里那个基线工具,它把两侧的摘要并排打出来,最后一行是 逐字节相同 : true,退出码 0;本机这次耗时 1138 ms。这条等式不是自动成立的,它靠三件事撑着:输入先整数化(scene_spec.json 里存的是 Q16.16 整数,不是浮点),每一步降精度都用算术右移(>>,不是 /),累加与乘法的中间量全程留在 BigInt 上。少任何一条,1/3 是 0.3333333333333333 还是 0.33333333333333331 就开始分叉。第三件事正是下面要拆的。

相机基:取列之后,两边一起错

matLookAt 返回的是行主序矩阵:世界空间的 right、up、back 分别躺在第 0、1、2 行。如果把它们当成第 0、1、2 列去读,得到的仍是一个「像样」的相机基——单位正交、能算出图、不会崩。只是画错了。

  • 命中像素:43731 → 21519(保留 49.2%,少了一半)。
  • t 序列摘要:c030dd7c… → bd9c99f9…。

要害在后面半句:改成列口径之后,Python 和 TypeScript 依然逐字节相同——两边都吐出 bd9c99f9…,五个计数也依然逐条对齐。跨语言一致这件事,在这里对「对错」完全没有发言权。它是一个一致性判据,不是一个正确性判据;一个共同的错误可以一致得完美无缺。

所以本卷从头到尾都把断言钉在外部参照上:第 7 章的基线计数、第 2.2 节量过的 |det| 分布、13.2 节暴力扫描出来的 t 并列数。跨语言等式只是其中一条,而且是必要不充分的一条。这也是本节把「相机基」这个真 bug 摆进来的原因——它比任何构造的例子都更能说明判据的边界。

Number 版求交:右移只有 32 位

既然 BigInt 是撑住等式的那根柱子,把它换掉会怎样?附录 A.2 量过本场景的三个量级:fmul 的原始乘积 5714018048147456(≈ 2^52.3,离 2^53 只有 1.57 倍余量)、fdiv 的分子 549345492992(≈ 2^39,是 2^31 的 255 倍)、det 约 2^20。

JS 的 >> 和 << 不是「对大整数移位」,而是先 ToInt32 再移位。于是:

  • (65536 * 65536) >> 16 在 Number 上是 0——2^32 先绕回 0,fmul(ONE, ONE) 直接归零;BigInt 侧同式给出 65536。
  • 5714018048147456 | 0 是 -2147483648,549345492992 | 0 是 -410320896:两个量级都被折成符号位。

这不是抛异常,是静默换一个答案。把求交链路整体换成 Number(本节的 S 层用 Python 的 float 复刻 JS 的 Number 语义,P 层直接用 | 0,也就是 ToInt32),实测:

  • 命中像素:43731 → 8484(少了 80.6%)。
  • 43439 条光线的命中 / 未命中状态发生翻转。
  • 两侧都命中但 t 不同的 4388 条,最大 |Δt| = 3209558 LSB(≈ 48.97 世界单位,是 EPSILON 的 50149 倍)。
  • t 序列摘要变成 bdc0b1ed…。

值得单独说一句:Python 的 float 复刻与 JS 的 | 0 复刻,给出的这四个数一字不差。也就是说「两边一致」这件事在错误上同样成立——它证明的是两条实现路径共享同一套语义,不证明那套语义是对的。这正是上一节那句话的第二次落地。

完整脚本

#!/usr/bin/env python3
# file: lantern/sim/cg_trace_13_3_cross_language.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_trace_13_3_cross_language.py
"""13.3 跨语言对照:同一份 scene_spec.json 的像素缓冲判据(不是 PNG 容器字节)。

  1. **基线**:直接光照 + 硬阴影的 sha256 与五个计数,P 层必须逐字节相同。
  2. **相机基取行不取列**:命中像素 43731 -> 21519,两侧却仍然逐字节相同——
     **跨语言一致抓不到「两边一起错」**。
  3. **Number 版求交**:`(a*b) >> 16` 的 `>>` 先做 ToInt32,而原始乘积摸到 2^53、
     `fdiv` 分子是 2^31 的 255 倍。本脚本用 Python 的 float 复刻 JS 语义量出分歧条数。

产物:out/cg_trace_13_3_cross_language.json
"""

import hashlib, json, os, struct, sys  # noqa: E401

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cglib  # noqa: E402
import cgtrace  # noqa: E402
import render_trace as rt  # noqa: E402

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
OUT = os.path.join(ROOT, "out")
ONE = cglib.ONE
FMUL, FDIV = cglib.fmul, cglib.fdiv
BASELINE_SHA = "213b413069ca66c9f0cc091bce5328eac0f540d075afa3bab37788aea335ba71"


def to_int32(x):                   # JS ToInt32:先向零截断,再取模 2^32 映射到有符号
    v = int(x) & 0xFFFFFFFF
    return v - 0x100000000 if v >= 0x80000000 else v


def num_fmul(a, b):                # JS `(a * b) >> 16`:>> 是 ToInt32 之后的算术右移
    return to_int32(a * b) >> 16


def num_fdiv(a, b):                # 向零截断;分子用 * 而非 <<(后者在 Number 上会绕回)
    q = (abs(a) * 65536.0) / abs(b)
    return -q if (a < 0) != (b < 0) else q


def mt_num(orig, dirv, v0, v1, v2):
    """Number 版 Möller–Trumbore:与 cgtrace.intersect_triangle 同式,但全用双精度。"""
    e1 = [float(v1[i] - v0[i]) for i in range(3)]
    e2 = [float(v2[i] - v0[i]) for i in range(3)]
    tv = [float(orig[i] - v0[i]) for i in range(3)]
    d = [float(x) for x in dirv]

    def cross(a, b):
        return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]

    def dot(a, b):
        return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]

    p = cross(d, e2)
    det = dot(e1, p)               # 三个 ~2^60 的项相加:双精度在这里丢位
    if det == 0:
        return None
    inv = num_fdiv(ONE, det)
    u = num_fmul(dot(tv, p), inv)
    q = cross(tv, e1)
    v = num_fmul(dot(d, q), inv)
    t = num_fmul(dot(e2, q), inv)  # `(nt * inv) >> 16`:nt*inv 摸到 2^54,ToInt32 绕回
    if u < 0 or v < 0 or u + v > ONE or t < 0:
        return None
    return t, u, v


def slab_num(lo, hi, orig, dirv, tmax):
    """Number 版 slab 判据(与 cgtrace._slab_hit 同式)。"""
    t0, t1 = 0, tmax
    for k in range(3):
        d = dirv[k]
        if d == 0:
            if orig[k] < lo[k] or orig[k] > hi[k]:
                return False
            continue
        inv = num_fdiv(ONE, d)
        a = num_fmul(lo[k] - orig[k], inv)
        b = num_fmul(hi[k] - orig[k], inv)
        if a > b:
            a, b = b, a
        t0 = a if a > t0 else t0
        t1 = b if b < t1 else t1
        if t0 > t1:
            return False
    return True


def bvh_num(nodes, order, tris, orig, dirv, tmax):
    """Number 版 BVH 遍历:与 cgtrace.bvh_intersect 同序,求交换成 mt_num。"""
    best_t, best = tmax, None
    stack = [0] if nodes else []
    while stack:
        node = nodes[stack.pop()]
        if not slab_num(node[0:3], node[3:6], orig, dirv, best_t):
            continue
        if node[6] < 0:
            for j in range(node[8], node[8] + node[7]):
                rec = tris[order[j]]
                r = mt_num(orig, dirv, (rec[0], rec[1], rec[2]), (rec[3], rec[4], rec[5]),
                           (rec[6], rec[7], rec[8]))
                if r is not None and r[0] < best_t:
                    best_t, best = r[0], (r[0], order[j])
        else:
            stack.append(node[6])
            stack.append(node[9])
    return best


def render_hits(spec, tris, nodes, order, basis, intersector):
    """主光线一遍:返回 (t 序列摘要, 命中数, t 列表)。`basis` 取 'row' 或 'col'。"""
    cam = spec["camera"]
    eye = tuple(cam["eyeQ16"])
    view = cglib.mat_look_at(eye, tuple(cam["targetQ16"]), tuple(cam["upQ16"]))
    if basis == "row":                                   # 世界空间基取**行**
        right, up, back = view[0:3], view[4:7], view[8:11]
    else:                                                # 取**列**:曾经的真 bug
        right, up, back = (view[0], view[4], view[8]), (view[1], view[5], view[9]), \
                          (view[2], view[6], view[10])
    w, h = spec["width"], spec["height"]
    th, tmax = cam["tanHalfFovYQ16"], cam["farQ16"]
    tx = FMUL(th, FDIV(cglib.f_from_int(w), cglib.f_from_int(h)))
    buf, ts, hits = bytearray(), [], 0
    for py in range(h):
        ay = FMUL(FDIV(cglib.f_from_int(2 * (h // 2 - py) - 1), cglib.f_from_int(h)), th)
        for px in range(w):
            ax = FMUL(FDIV(cglib.f_from_int(2 * (px - w // 2) + 1), cglib.f_from_int(w)), tx)
            d = cglib.v_norm((FMUL(ax, right[0]) + FMUL(ay, up[0]) - back[0],
                              FMUL(ax, right[1]) + FMUL(ay, up[1]) - back[1],
                              FMUL(ax, right[2]) + FMUL(ay, up[2]) - back[2]))
            hit = intersector(nodes, order, tris, eye, d, tmax)
            if hit is None:
                buf += struct.pack("<q", -1)
                ts.append(None)
            else:
                hits += 1
                buf += struct.pack("<q", int(hit[0]))
                ts.append(int(hit[0]))
    return hashlib.sha256(bytes(buf)).hexdigest(), hits, ts


def main():
    spec = cglib.load_spec()
    tris, _ = cgtrace.build_scene_tris(spec)
    nodes, order = cgtrace.build_bvh(tris)

    ctx = {"stats": {"shadow_rays": 0, "shadow_hits": 0}}
    color, stats = cgtrace.render_trace(spec, spec["width"], spec["height"], rt.MAX_DEPTH,
                                        rt.make_shader, ctx)
    base_sha = cgtrace.digest(color)
    bg = tuple(spec["backgroundQ8"])
    hit_px = int((~((color[:, :, 0] == bg[0]) & (color[:, :, 1] == bg[1])
                    & (color[:, :, 2] == bg[2]))).sum())

    row_sha, row_hits, row_ts = render_hits(spec, tris, nodes, order, "row",
                                            cgtrace.bvh_intersect)
    col_sha, col_hits, _ = render_hits(spec, tris, nodes, order, "col", cgtrace.bvh_intersect)
    num_sha, num_hits, num_ts = render_hits(spec, tris, nodes, order, "row", bvh_num)

    miss = sum(1 for a, b in zip(row_ts, num_ts) if (a is None) != (b is None))
    tdiff = sum(1 for a, b in zip(row_ts, num_ts) if a is not None and b is not None and a != b)
    max_dt = max((abs(a - b) for a, b in zip(row_ts, num_ts)
                  if a is not None and b is not None), default=0)

    print("== 13.3 跨语言对照(320x240,直接光照 + 硬阴影)==")
    print(f"基线 sha256       : {base_sha}")
    print(f"主光线/命中像素   : {stats['rays']} / {hit_px}")
    print(f"阴影光线/遮挡     : {ctx['stats']['shadow_rays']} / {ctx['stats']['shadow_hits']}")
    print(f"BVH 访问/测试     : {stats['visited']} / {stats['tested']}")
    print()
    print("== 相机基:行口径 vs 列口径 ==")
    print(f"行口径            : 命中 {row_hits},t 摘要 {row_sha[:16]}…")
    print(f"列口径            : 命中 {col_hits},t 摘要 {col_sha[:16]}…"
          f"({(col_hits - row_hits) * 1000.0 / row_hits:.1f}‰)")
    print()
    print("== Number 版求交(Python 复刻 JS 的 Number 语义)==")
    print(f"Number 版命中像素 : {num_hits}(BigInt 版 {row_hits})")
    print(f"命中/未命中翻转   : {miss} 条")
    print(f"两侧都命中但 t 不同: {tdiff} 条,最大 |Δt| = {max_dt} LSB")
    print(f"Number 版 t 摘要  : {num_sha[:16]}…")

    os.makedirs(OUT, exist_ok=True)
    payload = {
        "baselineSha": base_sha, "expectedBaselineSha": BASELINE_SHA,
        "primaryRays": stats["rays"], "hitPixels": hit_px,
        "shadowRays": ctx["stats"]["shadow_rays"], "shadowHits": ctx["stats"]["shadow_hits"],
        "bvhVisited": stats["visited"], "bvhTested": stats["tested"],
        "rowSha": row_sha, "rowHits": row_hits,
        "colSha": col_sha, "colHits": col_hits,
        "colDropPermille": round((col_hits - row_hits) * 1000.0 / row_hits, 4),
        "numSha": num_sha, "numHits": num_hits,
        "numMissFlip": miss, "numTDiff": tdiff, "numMaxAbsDt": max_dt,
    }
    with open(os.path.join(OUT, "cg_trace_13_3_cross_language.json"), "w", encoding="utf-8") as f:
        json.dump(payload, f, ensure_ascii=False, indent=2)
        f.write("\n")
    print("\n落盘 out/cg_trace_13_3_cross_language.json")
    return 0


if __name__ == "__main__":
    sys.exit(main())
# snippet: 本机实跑(工作区 lantern/);下面 text 块是两条命令的输出首尾相接
cd lantern
/opt/homebrew/bin/python3 sim/cg_trace_13_3_cross_language.py
node core/tools/render_trace_ts.ts
== 13.3 跨语言对照(320x240,直接光照 + 硬阴影)==
基线 sha256       : 213b413069ca66c9f0cc091bce5328eac0f540d075afa3bab37788aea335ba71
主光线/命中像素   : 76800 / 43731
阴影光线/遮挡     : 77211 / 1357
BVH 访问/测试     : 3238333 / 1119804

== 相机基:行口径 vs 列口径 ==
行口径            : 命中 43731,t 摘要 c030dd7ce5d1a3a1…
列口径            : 命中 21519,t 摘要 bd9c99f98f0efc6d…(-507.9‰)

== Number 版求交(Python 复刻 JS 的 Number 语义)==
Number 版命中像素 : 8484(BigInt 版 43731)
命中/未命中翻转   : 43439 条
两侧都命中但 t 不同: 4388 条,最大 |Δt| = 3209558 LSB
Number 版 t 摘要  : bdc0b1ed2d9b2c6f…

落盘 out/cg_trace_13_3_cross_language.json

canvas        : 320x240
triangles     : 896
primary rays  : 76800 (python 76800)
shadow rays   : 77211 / 1357 occluded (python 77211 / 1357)
bvh visited   : 3238333 (python 3238333)
bvh tested    : 1119804 (python 1119804)
sha256(rgb)   : 213b413069ca66c9f0cc091bce5328eac0f540d075afa3bab37788aea335ba71
python 侧     : 213b413069ca66c9f0cc091bce5328eac0f540d075afa3bab37788aea335ba71
逐字节相同    : true
elapsed       : 1214 ms

P 层

// snippet: 节选自 lantern/core/test/cg_trace_13_3_cross_language.test.ts
// verify: node --test 'core/test/cg_trace_13_3_cross_language.test.ts'
const numFmul = (a: number, b: number): number => (((a * b) | 0) >> 16);
  assert.equal(num.hits, golden.numHits);
  assert.equal(missFlip, golden.numMissFlip);
  assert.equal(tDiff, golden.numTDiff);
  assert.notEqual(num.hits, row.hits, "Number 版必须给出不同的命中数");
  assert.equal((65536 * 65536) | 0, 0, "2^32 绕回 0");
  assert.equal(fmul(65536n, 65536n), 65536n, "BigInt 侧同式给出 65536");

P 层重实现了整套 Number 版求交(slab 判据 + Möller–Trumbore + BVH 遍历),断言的不是「图长什么样」,而是四个分歧计数与 S 层逐条相等。这条断言的方向值得注意:它守的是「错误的复现」——如果哪天有人把 S 层的 float 复刻改成了别种近似,这四个数会先散架,而不是等到图像对不上才发现。另两条断言把基线钉死:P 层会真的去跑 core/tools/render_trace_ts.ts,要求它打出 逐字节相同 : true 并以 0 退出;同时要求行口径的 t 摘要等于 13.2 节落盘的 shaOriginal——同一批主光线、同一份 BVH 顺序,两章必须算出同一个字节。

小结

  • 判据是像素缓冲的 sha256,不是 PNG 容器字节。320×240 两侧同为 213b413069ca66c9f0cc091bce5328eac0f540d075afa3bab37788aea335ba71,五个计数(76800 / 77211 / 1357 / 3238333 / 1119804)逐条相同。
  • 相机基从行换成列,命中像素 43731 → 21519(保留 49.2%),而两侧依然逐字节相同——跨语言一致是一致性判据,不是正确性判据。
  • BigInt 换成 Number 后右移先做 ToInt32:(65536 * 65536) >> 16 变成 0;命中像素 43731 → 8484,43439 条光线翻转,最大 |Δt| 3209558 LSB。
  • Python 的 float 复刻与 JS 的 | 0 复刻给出同一组分歧计数——「两边一致」在错误上同样成立。
  • 语言边界收口了:本卷的三根柱子是输入整数化、降精度只用算术右移、中间量全程 BigInt。

至此「跨语言逐位一致」这条线走完:从 13.1 的一条光线的容差界,到 13.2 的一批光线的累加路径,再到本节的整幅图像与语言边界。下一章换一个对照面——不是两种语言,而是同一条算法在两条执行路径上(CPU 与 GPU)的等价与分歧。

阅读导航:上一节:13.2 累积的确定性 · 下一节:14.1 两条路径的等价与分歧 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

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