13.1 定点与浮点的分歧

把同一份《烛影山谷》规格分别喂给 Q16.16 定点管线与 float64 镜像管线:矩阵链最大元素差 5.007e-04,132/487 个顶点的屏幕坐标或深度不同;给定同一条整数坐标时两种光栅化器的像素缓冲逐字节相同,端到端则分歧 153 个像素并给出位置分布与通道差直方图。

13.1 定点与浮点的分歧

前面十二章把《烛影山谷》一帧的每个阶段都拆成了整数运算。这一章要正面回答一个更尖锐的问题:既然浮点也能渲染,为什么还要坚持定点? 站内的 《渲染引擎》 讲的是 TypeScript 引擎的架构——RHI 抽象、frame graph、双后端怎么拼装;本卷和它分工不同:那套书关心「引擎怎么搭」,本卷关心同一条算法在两个宿主上能不能给出逐字节相同的结果。换句话说,同一个场景必须在 Python 与 TypeScript 下给出同一个 sha256——这才是「可复现」的定义。

要证明这件事,光喊「定点更稳」没用。本节的做法是把两种算术并排跑:同一份 config/scene_spec.json,一条走 Q16.16 定点(sim/cglib.py),一条走 float64 镜像(sim/cg_raster_13_floatlib.py),然后逐顶点、逐像素比对。分歧出现在哪一步、有多少个像素、差在哪几个通道,全部量化出来。

「分歧」这个词要拆成两层理解:一层是数学上的分歧(同一个实数公式用不同精度算,结果本就会差),一层是实现上的分歧(同一条公式在两门语言里因为整除语义、舍入方向不同而差)。本节先量出总分歧,再用「给定同一条整数坐标」这一步把它归因到唯一的一层。

同一条算法,两种算术

两条管线共享同一套公式,唯一差别是标度与舍入。定点侧把实数存成 Q16.16 的整数(1.0 = 65536),矩阵乘的中间量用 Python int(任意精度),降精度一律用算术右移 >>;浮点侧把同样的公式原样写成 float64,>> 换成真除法。

这里有一条铁律:Q16.16 的降精度一律用 >>,禁用 / 与 //。原因是 Python 的 // 是向下取整,而 JS 的 BigInt 除法是向零截断——两者在负数上差 1(-1 // 65536 = -1,而 -1n / 65536n = 0n)。定点除法必须显式写成向零截断(本卷的 fdiv),否则同一份代码在两种语言下会得到不同的符号位。浮点镜像库要做的,就是把这套「标度 + 舍入」抽掉,只留下纯实数运算,看究竟是数学分歧还是量化分歧。

矩阵链与所有中间量全程用 Python int;只有逐像素层(量级已证 $\le 2^{49.6} < 2^{53}$)才降到 numpy int64。

分歧起点:矩阵链与顶点变换

第一步是矩阵链。把定点的 MVP 与浮点的 MVP 逐元素相减,最大绝对差是 5.007e-04。这个数小得几乎可以忽略,但它不是零——矩阵乘里每一层 >>16 都在丢低位,几百个顶点的误差会在后续被放大。

把它传导到顶点变换:对 487 个顶点各做一次投影 + 透视除法 + 屏幕 Q6 量化,比对屏幕坐标与深度。

MVP 最大元素差  : 5.007e-04(矩阵链分歧起点)
阶段 A 顶点变换 : x 分歧 39  y 分歧 61  深度分歧 59  任一 132 / 487
                  第一处分歧顶点 #4

132 个顶点(约 27%)的屏幕坐标或深度至少有一项不同。x 分歧 39、y 分歧 61、深度分歧 59——分歧同时出现在三个通道,说明不是某一个轴的偶发舍入,而是整条链的系统性低位丢失。第一处分歧出现在顶点 #4,一个离相机很近的三角形,透视除法对它的误差最敏感。这就是分歧的起点:它不在光栅化,而在顶点变换。

给定同一条整数坐标,光栅化器逐字节相同

既然分歧在顶点变换,那光栅化阶段本身干不干净?验证方法很直接:把同一条整数坐标(定点算出来的屏幕 Q6 坐标)分别喂给两个光栅化器——定点的 cglib.rasterize 与浮点的 rasterize_float——再逐字节比对像素缓冲。

阶段 B 同坐标   : 整数 vs 浮点像素缓冲逐字节相同 = True

True。给定完全相同的整数顶点坐标,两个光栅化器产出的颜色缓冲、深度缓冲、覆盖掩码逐字节相同。这意味着边函数、重心插值、深度测试这几步里没有任何隐藏的浮点污染——只要输入是整数,输出就是整数。这一步很关键:它把「分歧」从整条管线收窄到了唯一一个源头,也就是顶点变换的量化。

为什么这条比对成立得这么干净?因为光栅化阶段的每一个量都是整数:边函数 $E(A,B,P)$ 是坐标差的乘积之差,重心权重是边函数本身,深度与颜色是「权重乘顶点值再除以面积」。面积已知非负,故逐像素层的整除用 Math.floor(num / area) 或 Python num // area 都行——分子非负时 floor 与截断一致。这条「只在非负分子上整除」的限定,正是浮点镜像能逐字节对齐的前提;一旦允许分子为负,// 的 floor 语义就会和向零截断分道扬镳。

端到端:153 个分歧像素

现在用浮点坐标走完整条管线,与定点成片整幅比对。

阶段 C 端到端   : 覆盖 43731 vs 43730;分歧 153(覆盖归属 1)
                  第一处分歧像素 #30188  bbox [1, 94, 319, 214]
                  通道差分布 {1: 146, 8: 1, 11: 1, 62: 1, 67: 1, 70: 1, 71: 1, 90: 1}
定点 sha256     : bac659770178037ad3f6a6ad184945a96ff37a7fe4d754ef1ebc5b0aaa538ced
浮点 sha256     : fe877ab101df3bfee062429c2e01cfb9c01c245be31bf33cc0d4dd8c825d495b

153 个分歧像素,占 43731 个覆盖像素的 3.5‰。把通道差拆开看,146 个像素的通道差恰好是 1——这就是顶点坐标量化到 Q6 后、重心插值在边缘像素上的 ±1 舍入。剩下 7 个像素差得很多(62、67、70、71、90),它们是深度排序翻转:两个三角形在某个像素上的深度几乎相等,定点与浮点给出了相反的先后,于是整个像素换了颜色。覆盖归属差异只有 1 个像素,说明两个管线的轮廓几乎重合。

153 与 43731、bac659… 与 fe877a…——两组数字就是本节的结论:定点与浮点在同一份规格上给出不同的像素缓冲。这不是 bug,是两种算术的必然结果。也正因为它们不同,才逼出了「跨语言逐字节一致」这条纪律:只有定点那条,Python 与 TypeScript 才能对上 sha256。

那浮点这条就一无是处吗?也不是。浏览器里的 WebGL2 走的是另一条路——硬件光栅化器用浮点插值再舍入,和 CPU 的整数实现不可能逐位相同。对 W 层,本卷只做声明容差的比对(MAE ≤ 1.0、PSNR ≥ 30 dB、覆盖归属差异 ≤ 20‰),绝不写「逐位相等」。所以本卷的层级是清楚的:S 层与 P 层之间是逐字节相同的硬断言,S 层与 W 层之间是声明容差的软断言,两条纪律各管一段,不混用。

可运行产物:三段比对脚本

下面的脚本把上面三段比对全部变成可复现的数字。它调用 cglib(定点)与 cg_raster_13_floatlib(浮点镜像),不重写任何定点标量、向量或矩阵。

#!/usr/bin/env python3
# file: lantern/sim/cg_raster_13_1_fixed_vs_float.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_raster_13_1_fixed_vs_float.py
"""13.1 定点与浮点的分歧。

同一份 config/scene_spec.json,两条独立管线:定点 Q16.16(cglib)与 float64
(cg_raster_13_floatlib)。三段比对定位第一处分歧:
  A 顶点变换:逐顶点比对屏幕 Q6 坐标与深度
  B 边函数与插值:把**同一条**整数坐标分别喂给整数与浮点光栅化器
  C 端到端:用浮点坐标光栅化,与定点成片整幅比对

产物:out/cg_raster_13_1_float.png、out/cg_raster_13_1_diff.png、
      out/cg_raster_13_1_rows.csv、out/cg_raster_13_1_fixed_float.json。
"""

import hashlib
import json
import math
import os
import sys

import numpy as np

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cglib  # noqa: E402
import render_raster  # noqa: E402
import cg_raster_13_floatlib as fl  # noqa: E402

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SUB1 = cglib.SUB1


def assemble(screen, tris, vcolors):
    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]))
    return rtris


def digest_of(color):
    return hashlib.sha256(np.clip(color, 0, 255).astype(np.uint8).tobytes()).hexdigest()


def main():
    spec = cglib.load_spec()
    width, height, bg = spec["width"], spec["height"], spec["backgroundQ8"]
    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)

    fview = fl.f_mat_look_at(tuple(fl._f(c) for c in cam["eyeQ16"]),
                             tuple(fl._f(c) for c in cam["targetQ16"]),
                             tuple(fl._f(c) for c in cam["upQ16"]))
    fproj = fl.f_mat_perspective(fl._f(cam["tanHalfFovYQ16"]), width / height,
                                 fl._f(cam["nearQ16"]), fl._f(cam["farQ16"]))
    fmvp = fl.f_mat_mul(fproj, fview)
    mvp_diff = max(abs(fl._f(mvp[i]) - fmvp[i]) for i in range(16))

    verts, tris, vcolors = render_raster.build_scene(spec)

    # 阶段 A:顶点变换逐顶点比对
    fixed, flt = [], []
    for v in verts:
        p = cglib.project(mvp, v)
        fixed.append(None if p is None else cglib.to_screen(p[0], p[1], width, height) + (p[2],))
        q = fl.f_project(fmvp, (fl._f(v[0]), fl._f(v[1]), fl._f(v[2])))
        flt.append(None if q is None else fl.f_to_screen(q[0], q[1], width, height)
                   + (int(math.floor((q[2] + 1.0) * 0.5 * 65536.0)),))
    n_sx = sum(1 for a, b in zip(fixed, flt) if a and b and a[0] != b[0])
    n_sy = sum(1 for a, b in zip(fixed, flt) if a and b and a[1] != b[1])
    n_depth = sum(1 for a, b in zip(fixed, flt) if a and b and a[2] != b[2])
    n_any = sum(1 for a, b in zip(fixed, flt) if a and b and a != b)
    first_v = next((i for i, (a, b) in enumerate(zip(fixed, flt)) if a and b and a != b), -1)

    # 阶段 B:同一条整数坐标,整数 vs 浮点光栅化
    rtris_fixed = assemble(fixed, tris, vcolors)
    color_fix, depth_fix, cov_fix, culled = cglib.rasterize(rtris_fixed, width, height, bg)
    color_b, depth_b, cov_b, _ = fl.rasterize_float([tuple(float(x) for x in t) for t in rtris_fixed],
                                                    width, height, bg)
    stage_b = bool(np.array_equal(color_fix, color_b) and np.array_equal(depth_fix, depth_b)
                   and np.array_equal(cov_fix, cov_b))

    # 阶段 C:浮点坐标端到端
    color_flt, _df, cov_flt, _cf = fl.rasterize_float(assemble(flt, tris, vcolors), width, height, bg)
    diff = (color_fix != color_flt).any(axis=2)
    cov_diff = cov_fix != cov_flt
    n_diff, n_covdiff = int(diff.sum()), int(cov_diff.sum())
    chan = np.abs(color_fix - color_flt).max(axis=2)
    hist = {int(k): int(v) for k, v in zip(*np.unique(chan[diff], return_counts=True))} if n_diff else {}
    ys, xs = np.nonzero(diff)
    first_px = int(ys[0]) * width + int(xs[0]) if n_diff else -1
    bbox = [int(xs.min()), int(ys.min()), int(xs.max()), int(ys.max())] if n_diff else [0, 0, 0, 0]

    out = os.path.join(ROOT, "out")
    png = cglib.save_png(os.path.join(out, "cg_raster_13_1_float.png"), color_flt)
    diff_img = np.zeros((height, width, 3), dtype=np.int64)
    diff_img[diff] = (255, 80, 40)
    diff_img[cov_diff & ~diff] = (60, 140, 220)
    cglib.save_png(os.path.join(out, "cg_raster_13_1_diff.png"), diff_img)
    cglib.write_csv(os.path.join(out, "cg_raster_13_1_rows.csv"), ["py", "divergent_px"],
                    [(y, int(diff[y].sum())) for y in range(height) if diff[y].any()])

    sha_fix, sha_flt = digest_of(color_fix), digest_of(color_flt)
    record = {
        "canvas": [width, height], "vertices": len(verts), "mvp_max_abs_diff": mvp_diff,
        "fixed_sha256": sha_fix, "float_sha256": sha_flt,
        "vertices_sx_diff": n_sx, "vertices_sy_diff": n_sy, "vertices_depth_diff": n_depth,
        "vertices_any_diff": n_any, "first_divergent_vertex": first_v,
        "stage_b_identical": stage_b, "covered_fixed": int(cov_fix.sum()),
        "covered_float": int(cov_flt.sum()), "divergent_pixels": n_diff,
        "coverage_diff_pixels": n_covdiff, "first_divergent_pixel": first_px,
        "divergent_bbox": bbox, "divergent_channel_delta_hist": hist, "culled": culled,
        "float_screen": [[s[0], s[1], s[2]] if s else None for s in flt],
    }
    with open(os.path.join(out, "cg_raster_13_1_fixed_float.json"), "w", encoding="utf-8") as fh:
        json.dump(record, fh, ensure_ascii=False, indent=2)

    print(f"canvas          : {width}x{height}  顶点 {len(verts)}  剔除 {culled}")
    print(f"MVP 最大元素差  : {mvp_diff:.3e}(矩阵链分歧起点)")
    print(f"阶段 A 顶点变换 : x 分歧 {n_sx}  y 分歧 {n_sy}  深度分歧 {n_depth}  任一 {n_any} / {len(verts)}")
    print(f"                  第一处分歧顶点 #{first_v}")
    print(f"阶段 B 同坐标   : 整数 vs 浮点像素缓冲逐字节相同 = {stage_b}")
    print(f"阶段 C 端到端   : 覆盖 {int(cov_fix.sum())} vs {int(cov_flt.sum())};分歧 {n_diff}(覆盖归属 {n_covdiff})")
    print(f"                  第一处分歧像素 #{first_px}  bbox {bbox}  通道差分布 {hist}")
    print(f"定点 sha256     : {sha_fix}")
    print(f"浮点 sha256     : {sha_flt}")
    print(f"png             : float {png} bytes")
    return 0


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

脚本输出与上文逐条对应。产物写进 out/:浮点成片 cg_raster_13_1_float.png、分歧热图 cg_raster_13_1_diff.png(红=颜色差、蓝=覆盖差)、逐行分歧计数 cg_raster_13_1_rows.csv,以及含全部数字的 cg_raster_13_1_fixed_float.json(还存了每个顶点的浮点屏幕坐标,供 P 层复算)。

跨语言断言

S 层的数字要当基准,前提是 TypeScript 侧能逐字节复现。下面的断言读取 S 层 JSON,用 P 层定点内核重渲染,验证定点 sha256、浮点成片的 sha256、分歧像素数与首处位置。

✔ 定点成片:主管线复现 S 层 sha256
✔ 浮点坐标整幅比对:TS 复现 Python 侧分歧像素数与首处位置
✔ 负例:顶点变换若量化不同,成片摘要必然分叉
ℹ tests 3  ℹ pass 3  ℹ fail 0

3 条全绿。注意那条负例:它故意改动顶点量化,断言成片 sha256 必然分叉——判据如果连负例都不拒绝,就不值得信。下一节把镜头对准光栅化内部:共享边像素到底归谁,用整数把 top-left rule 钉死。

阅读导航:上一节:12.3 纹理过滤与 mipmap · 下一节:13.2 边函数规则的整数化 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

  1. 14.3 中间缓冲的可视化
  2. 14.2 覆盖率与线框
  3. 14.1 深度图与法线图