2.1 向量运算的定点化

本节把向量的加、减、点积、叉积搬进 Q16.16 定点整数:加减是同分母整数运算故逐位精确,乘积必须右移 16 降精度。我们用同一套确定性向量在 Python 与 TypeScript 下算出逐位相同的点积与叉积并以 sha256 锁定,最后给出点积误差恒落在 [0,3) ulp 的实测上界。

2.1 向量运算的定点化

站内的 图形数学基础 用浮点向量与矩阵库搭起渲染所需的线性代数;本节关心的是另一件事——同一串向量运算在两台机器、两种语言上是否给出逐位相同的结果。为此我们把向量运算全部搬进 Q16.16 定点整数,先钉死加、减、点积、叉积的舍入语义,再用 sha256 证明 Python 与 TypeScript 两侧逐位一致。

Q16.16 的表示与范围

Q16.16 用一个整数表示实数:低 16 位是小数,ONE = 1 << 16 = 65536 代表 1.0。把它塞进 32 位有符号整数,可表示范围是 [-32768.00000, 32767.99998]。Python 的 int 与 JS 的 BigInt 都是任意精度,故顶点层不受这个范围约束——但约定仍是 Q16.16,因为逐像素层的量级已被证明 ≤ 2^49.6,可以在 float64 的整数精确区里用 Number 跑,无需 BigInt。

加减为什么精确

加减是「同分母」的整数运算:两个 Q16.16 相加就是把两个整数相加,分母不变,没有舍入。实测 1.5 + 2.25 = 98304 + 147456 = 245760,还原成实数正好是 3.75;2.25 - 1.5 = 49152,正好是 0.75。唯一的风险是溢出——32 位下越过 ±32768 就翻车,这正是顶点层坚持任意精度整数的理由。

乘积必须降精度,且只能右移

两个 Q16.16 相乘,小数位变成 32 位,必须右移 16 位砍回去:fmul(a, b) = (a * b) >> 16。这一步只能右移,不能除。理由是一条实测结论:

Python  -1 >> 16       : -1   (算术右移 = floor)
Python  -1 // 65536    : -1   (整除 = floor)
JS BigInt -1n / 65536n : 0    (向零截断)
JS BigInt -1n >> 16n   : -1   (算术右移 = floor)

Python 的 // 是 floor,JS BigInt 的 / 是向零截断,两者在负数上分叉;而 >> 在两侧都是 floor。定点内核里只要有一处用 / 或 // 降精度,跨语言的 sha256 就会立刻不一致。点积与叉积都由乘积组成,故每一项都走 fmul;叉积是「乘积之差」,中间结果同样只做 >>。

点积的误差上界

每次 fmul 引入的误差是 真值 - floor(真值) ∈ [0, 1) ulp——floor 只会向下,故误差恒非负。点积是三项之和,于是误差落在 [0, 3) ulp,这是一个单边界:定点点积永远不大于精确值。用 64 个确定性向量实测,误差落在 [0.365311, 2.615829],确实在界内。

两份实现,一个 sha256

下面是 S 层脚本,复用 sim/cglib.py 的 fmul / v_dot / v_cross,不重写内核:

#!/usr/bin/env python3
# file: lantern/sim/cg_raster_02_1_fixed_point.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_raster_02_1_fixed_point.py
"""2.1 向量运算的定点化:Q16.16 的表示、精确的加减、必须降精度的点积与叉积。

实测结论:
  1. 加/减是同分母整数运算,无精度损失(只可能溢出)。
  2. 乘积有 32 位小数,必须 `>> 16` 降精度;且只能用算术右移 `>>`——
     Python 的 `//` 是 floor,JS BigInt 的 `/` 向零截断,负数上不一致。
  3. 点积误差恒在 [0, 3) ulp:每项 floor 误差在 [0, 1),三项之和非负且 < 3。

产物:out/cg_raster_02_1_stats.json
"""

import json
import os
import sys
from fractions import Fraction

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

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ONE = cglib.ONE
MASK = 0x7FFFFFFF


def lcg(state):
    return (1103515245 * state + 12345) & MASK


def gen_vec(state):
    """从 LCG 生成一个三分量 Q16.16 向量,分量落在 [-4.0, 4.0)。"""
    comps = []
    for _ in range(3):
        state = lcg(state)
        comps.append(((state >> 8) % (8 * ONE)) - 4 * ONE)
    return state, tuple(comps)


def exact_dot(a, b):
    """点积的精确有理值(单位:ulp,即 1.0 = ONE)。"""
    return Fraction(a[0] * b[0] + a[1] * b[1] + a[2] * b[2], ONE)


def main():
    print(f"ONE                     : {ONE}")
    print(f"int32 Q16.16 范围       : [{(1 << 31) / -ONE:.5f}, {((1 << 31) - 1) / ONE:.5f}]")

    x, y = 3 * ONE // 2, 9 * ONE // 4  # 1.5 与 2.25
    print(f"1.5 + 2.25              : {x + y}  ({(x + y) / ONE:.5f})")
    print(f"2.25 - 1.5              : {y - x}  ({(y - x) / ONE:.5f})")
    assert x + y == 15 * ONE // 4 and y - x == 3 * ONE // 4

    print(f"Python  -1 >> 16        : {-1 >> 16}")
    print(f"Python  -1 // 65536     : {-1 // 65536}")
    print("JS BigInt -1n / 65536n  : 0   (向零截断)")
    print("JS BigInt -1n >> 16n    : -1  (算术右移)")
    assert (-1 >> 16) == -1 and (-1 // 65536) == -1
    p = cglib.fmul(3 * ONE // 2, 5 * ONE // 2)
    print(f"fmul(1.5, 2.5)          : {p}  ({p / ONE:.5f})")
    assert p == 15 * ONE // 4
    print(f"0.1 的 Q16.16 表示      : 6553 (真值 {0.1 * ONE:.1f})")

    N = 64
    state = 20261015
    vecs = []
    for _ in range(N):
        state, v = gen_vec(state)
        vecs.append(v)

    max_err, min_err = Fraction(0), None
    for i in range(N):
        a, b = vecs[i], vecs[(i + 1) % N]
        e = exact_dot(a, b) - cglib.v_dot(a, b)
        if e > max_err:
            max_err = e
        if min_err is None or e < min_err:
            min_err = e
        assert 0 <= e < 3, f"点积误差 {e} 越界 [0,3)"
    print(f"点积样本数              : {N}")
    print(f"误差范围 (ulp)          : [{float(min_err):.6f}, {float(max_err):.6f}]  (< 3)")

    a, b = vecs[0], vecs[1]
    print(f"样本 0  a={a}  b={b}")
    print(f"  v_dot={cglib.v_dot(a, b)}  exact={float(exact_dot(a, b)):.6f}")
    print(f"v_cross(a, b)           : {cglib.v_cross(a, b)}")

    out = os.path.join(ROOT, "out")
    os.makedirs(out, exist_ok=True)
    stats = {
        "one": ONE,
        "samples": N,
        "dot_err_min_ulp": float(min_err),
        "dot_err_max_ulp": float(max_err),
        "dot_err_bound_ulp": 3,
        "sample0_dot": cglib.v_dot(a, b),
        "sample0_exact": float(exact_dot(a, b)),
    }
    with open(os.path.join(out, "cg_raster_02_1_stats.json"), "w", encoding="utf-8") as fh:
        json.dump(stats, fh, ensure_ascii=False, indent=2)
    print("out                     : cg_raster_02_1_stats.json")
    return 0


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

P 层是同一套确定性 LCG 的 TypeScript 版本:它生成同样的 64 个向量,断言点积与叉积序列的 sha256 与 Python 侧相同,并用一个「改用截断除法」的负例证明判据有区分力。

// file: lantern/core/test/cg_raster_02_1_fixed_point.test.ts
// tier: P
// verify: node --test 'core/test/cg_raster_02_1_fixed_point.test.ts'
// 2.1 的 P 层:与 sim/cg_raster_02_1_fixed_point.py 用同一套确定性 LCG 生成同样的向量,
// 断言两侧的点积与叉积逐位相同(sha256 比对),并锁定 `>>` 的舍入语义。
//
// 金标准由 S 侧独立产出:
//   /opt/homebrew/bin/python3 sim/cg_raster_02_1_fixed_point.py

import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { test } from "node:test";

import { ONE, fmul } from "../src/lantern/fixed.ts";
import { type Vec3, vCross, vDot } from "../src/lantern/vec.ts";

const MASK = 0x7fffffffn;

function lcg(state: bigint): bigint {
  return (1103515245n * state + 12345n) & MASK;
}

function genVec(state: bigint): [bigint, Vec3] {
  const comps: bigint[] = [];
  let s = state;
  for (let i = 0; i < 3; i += 1) {
    s = lcg(s);
    comps.push(((s >> 8n) % (8n * ONE)) - 4n * ONE);
  }
  return [s, [comps[0], comps[1], comps[2]]];
}

// 由 S 侧 sim/cg_raster_02_1_fixed_point.py 产出
const GOLDEN_DOT_SHA = "2774812048ca6c619b971d17f7b27b3c7a4e3c57c1954109ebabe77a881dfe37";
const GOLDEN_CROSS_SHA = "280d567b9ca694157c11a05fdeefae435cddfa3f71b802c85df7ddfaac71dc5f";
const GOLDEN_DOT0 = -585784n;
const GOLDEN_DOT_LAST = 161286n;

const N = 64;
const vecs: Vec3[] = [];
{
  let state = 20261015n;
  for (let i = 0; i < N; i += 1) {
    const [s, v] = genVec(state);
    state = s;
    vecs.push(v);
  }
}

test("舍入语义:算术右移 >> 与 BigInt / 在负数上分叉", () => {
  // BigInt 的 / 向零截断 -> 0;>> 是 floor -> -1。Python 的 // 也是 floor(= -1)。
  assert.equal(-1n / 65536n, 0n, "BigInt / 向零截断");
  assert.equal(-1n >> 16n, -1n, "BigInt >> 是 floor,与 Python // 同义");
  assert.notEqual(-1n / 65536n, -1n >> 16n, "两者在负数上必须分叉,故降精度只能用 >>");
});

test("fmul 金标准:1.5 * 2.5 = 3.75 逐位精确", () => {
  const p = fmul(3n * ONE / 2n, 5n * ONE / 2n);
  assert.equal(p, 15n * ONE / 4n);
  assert.equal(p, 245760n);
});

test("【核心】点积序列 sha256 与 Python 侧逐位相同", () => {
  const dots: bigint[] = [];
  for (let i = 0; i < N; i += 1) {
    dots.push(vDot(vecs[i], vecs[(i + 1) % N]));
  }
  assert.equal(dots[0], GOLDEN_DOT0);
  assert.equal(dots[N - 1], GOLDEN_DOT_LAST);
  const digest = createHash("sha256").update(dots.join(",")).digest("hex");
  assert.equal(digest, GOLDEN_DOT_SHA);
});

test("叉积序列 sha256 与 Python 侧逐位相同", () => {
  const crs: string[] = [];
  for (let i = 0; i < N; i += 1) {
    const c = vCross(vecs[i], vecs[(i + 1) % N]);
    crs.push(`${c[0]},${c[1]},${c[2]}`);
  }
  const digest = createHash("sha256").update(crs.join(",")).digest("hex");
  assert.equal(digest, GOLDEN_CROSS_SHA);
});

test("负例:改用截断除法降精度 -> 序列 sha256 必须分叉", () => {
  const truncMul = (a: bigint, b: bigint): bigint => {
    const p = a * b;
    return p / ONE; // 向零截断,而不是 floor
  };
  const dots: bigint[] = [];
  for (let i = 0; i < N; i += 1) {
    const a = vecs[i];
    const b = vecs[(i + 1) % N];
    dots.push(truncMul(a[0], b[0]) + truncMul(a[1], b[1]) + truncMul(a[2], b[2]));
  }
  const digest = createHash("sha256").update(dots.join(",")).digest("hex");
  assert.notEqual(digest, GOLDEN_DOT_SHA, "换成截断除法后必须与金标准分叉");
});

真跑:

# snippet: 复现命令(在 lantern 工作区根执行)
cd $HOME/.cgbook/lantern
/opt/homebrew/bin/python3 sim/cg_raster_02_1_fixed_point.py
node --test 'core/test/cg_raster_02_1_fixed_point.test.ts'

S 侧输出(节选):

ONE                     : 65536
int32 Q16.16 范围       : [-32768.00000, 32767.99998]
1.5 + 2.25              : 245760  (3.75000)
2.25 - 1.5              : 49152  (0.75000)
fmul(1.5, 2.5)          : 245760  (3.75000)
0.1 的 Q16.16 表示      : 6553 (真值 6553.6)
点积样本数              : 64
误差范围 (ulp)          : [0.365311, 2.615829]  (< 3)
样本 0  a=(-144774, -89454, 74686)  b=(235959, 177850, 156392)
  v_dot=-585784  exact=-585782.306732
v_cross(a, b)           : (-416150, 614385, -70810)

P 侧结果:

ℹ tests 5
ℹ pass 5
ℹ fail 0

点积序列的 sha256 是 2774812048ca6c61…,叉积是 280d567b9ca69415…,两侧完全相同。这就是「可证伪」的含义:任何一处舍入改动——哪怕只把一次 >> 换成 /——都会让 sha256 立刻变掉,负例正是这样被抓住的。

阅读导航:上一节:1.3 最小渲染器的骨架 · 下一节:2.2 点与向量的区别 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

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