12.1 浮点的容差口径

逐位相等在两个宿主之间不可能,容差必须可声明、可复算。本节把容差写成三条同时成立的判据——覆盖归属差异 ≤ 20‰、MAE ≤ 1.0、PSNR ≥ 30 dB,逐条给出阈值出处,并用同一份整数基线量出噪声地板与三条穿越点:太松会漏掉 2% 的全局偏置,太紧会把噪声本身判成错。

12.1 浮点的容差口径

《游戏引擎实现原理》卷二《渲染引擎》 那一卷把渲染器拆成顶点、光栅、纹理几块,每一步都用定点整数算,所以「同一份输入 → 同一个 sha256」在那里是成立的。本节要处理的是它没有正面回答的问题:当同一份场景落到两个不同的宿主上,「一致」到底该怎么定义? 这是全卷方法论的地基——后面所有跨实现、跨语言、跨宿主的比对,判据都长在这里。

容差从哪来:光栅化的规则不是标准的一部分

先把「为什么做不到逐位相等」说清楚。顶点变换之后,光栅化阶段有三处是规范留给实现的自由度:

  1. 子像素精度。OpenGL/D3D 只要求 8 位(1/256 像素)的顶点吸附精度,硬件通常给 4~8 位;本卷 S 层用的是 Q6(1/64 像素)。同一个顶点在两个宿主上会落到不同的亚像素格点上。
  2. 共享边的归属。一条边被两个三角形共享,这条边上的像素归谁?top-left rule 规定「上边与左边归本三角形」,但「上/左」依赖屏幕坐标轴的方向,而 S 层的屏幕空间 y 轴向下、WebGL 的窗口空间 y 轴向上,绕向正好相反。
  3. 插值的精度与路径。重心坐标用浮点还是定点、透视校正与否、累加顺序如何,都会让中间值差出几个 ULP。

再叠上着色器编译器的差异——normalize 的实现、pow/exp 的 ULP、融合乘加(FMA)是否启用——结论就是:两个宿主之间逐位相等在数学上不可能。这不是实现缺陷,是规范刻意留的口子。

所以判据不能写成「相等/不等」,只能写成一组带阈值的声明。

三元组:三条同时成立

本卷的跨宿主判据是三条同时成立的合取:

判据阈值出处
覆盖归属差异≤ 20‰覆盖是离散的:边界像素要么属 A 要么属 B,没有中间态。20‰ 是「一圈轮廓线」的量级——320×240 的 20‰ 是 1536 个像素,远大于实测的 6 个,又远小于任何真实的几何错误
MAE≤ 1.08 位量化下,「整幅均匀偏 1 个灰阶」的 MAE 正好是 1.0。阈值定在 1.0,等于说「平均误差不超过一个灰阶」
PSNR≥ 30 dB本卷口径 20·log10(255/MAE)。MAE = 1.0 → 48.13 dB,MAE ≈ 8.0 → 30.07 dB,故 30 dB 对应「平均误差 8 个灰阶」

这里必须立刻承认一件事:本卷的 PSNR 是 MAE 的单调变换,所以它并不提供独立信息。真按信息量算,这三条是「一条覆盖判据 + 一条幅度判据」。之所以仍然写成三条一起声明,是因为三条各自可被单独引用、单独报告;至于「分布敏感」的那条判据缺位的问题,12.2 会补上,本节末尾的校准扫描会先把这个缺口量出来。

实测:噪声地板长什么样

要定阈值,先得知道「什么都不做错」时差多少。做法是给整数基线造一个 CPU 浮点镜像:吃同一条整数屏幕坐标、同一组整数法线,只把三处口径换成浮点版——浮点重心插值、np.rint 就近取整、top-left 覆盖。它与基线的差异就只剩「插值精度」和「舍入」两个来源。

#!/usr/bin/env python3
# file: lantern/sim/cg_shade_12_1_floatmirror.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_shade_12_1_tolerance.py
"""12.1 的 CPU 浮点镜像光栅化器:给整数基线一个「GPU 式」的对照。

它与 cgshade.rasterize_shaded 吃**同一条**整数屏幕坐标、同一组整数法线,
差别只在三处口径:覆盖判据(top-left rule)、插值精度(浮点重心)、8 位舍入(就近取整)。
这三处正是 GPU 与 CPU 之间的差异来源,只是搬到了 CPU 上跑,故可复现。
"""

import numpy as np

SUB1 = 64
HALF = SUB1 // 2


def _owned(ax, ay, bx, by):
    """top-left rule:有向边 A->B 拥有其边界样本 iff dy<0 或 dy==0 且 dx>0。"""
    dy = by - ay
    return dy < 0 or (dy == 0 and bx - ax > 0)


def make_shader_float(spec):
    """浮点 Lambert(方向光 + 环境光),与整数侧同一公式,只是去掉定点标度。"""
    d = spec["directionalLight"]
    dirf = tuple(c / 65536.0 for c in d["dirQ16"])
    intf = d["intensityQ16"] / 65536.0
    amb = spec["ambientQ16"] / 65536.0

    def shade(n, albedo):
        ndl = n[0] * dirf[0] + n[1] * dirf[1] + n[2] * dirf[2]
        if ndl < 0.0:
            ndl = 0.0
        k = amb + ndl * intf
        if k > 1.0:
            k = 1.0
        return [albedo[i] * k for i in range(3)]

    return shade


def rasterize_float_shaded(tris, width, height, background, shade, topleft=True):
    """浮点镜像:top-left 覆盖 + 浮点重心插值 + 就近取整。返回 (uint8 颜色, 覆盖掩码, 剔除数)。"""
    color = np.zeros((height, width, 3), dtype=np.float64)
    color[:, :] = background
    depth = np.full((height, width), 1e30, dtype=np.float64)
    covered = np.zeros((height, width), dtype=bool)
    culled = 0

    for t in tris:
        x0, y0, d0 = float(t[0]), float(t[1]), float(t[2])
        x1, y1, d1 = float(t[3]), float(t[4]), float(t[5])
        x2, y2, d2 = float(t[6]), float(t[7]), float(t[8])
        area = (x1 - x0) * (y2 - y0) - (y1 - y0) * (x2 - x0)
        if area <= 0.0:
            culled += 1
            continue

        minx = max(0, int(min(x0, x1, x2)) // SUB1)
        maxx = min(width - 1, int(max(x0, x1, x2)) // SUB1)
        miny = max(0, int(min(y0, y1, y2)) // SUB1)
        maxy = min(height - 1, int(max(y0, y1, y2)) // SUB1)
        if minx > maxx or miny > maxy:
            continue

        for py in range(miny, maxy + 1):
            Y = py * SUB1 + HALF
            for px in range(minx, maxx + 1):
                X = px * SUB1 + HALF
                w0 = (x2 - x1) * (Y - y1) - (y2 - y1) * (X - x1)
                w1 = (x0 - x2) * (Y - y2) - (y0 - y2) * (X - x2)
                w2 = (x1 - x0) * (Y - y0) - (y1 - y0) * (X - x0)
                if w0 < 0.0 or w1 < 0.0 or w2 < 0.0:
                    continue
                if topleft:
                    if w0 == 0.0 and not _owned(x1, y1, x2, y2):
                        continue
                    if w1 == 0.0 and not _owned(x2, y2, x0, y0):
                        continue
                    if w2 == 0.0 and not _owned(x0, y0, x1, y1):
                        continue

                z = (w0 * d0 + w1 * d1 + w2 * d2) / area
                if z < depth[py, px]:
                    depth[py, px] = z
                    nv = [(w0 * float(t[9 + c]) + w1 * float(t[12 + c]) + w2 * float(t[15 + c])) / area
                          for c in range(3)]
                    ln = (nv[0] * nv[0] + nv[1] * nv[1] + nv[2] * nv[2]) ** 0.5 or 1.0
                    nv = [nv[c] / ln for c in range(3)]
                    av = [(w0 * float(t[18 + c]) + w1 * float(t[21 + c]) + w2 * float(t[24 + c])) / area
                          for c in range(3)]
                    color[py, px] = shade(nv, av)
                    covered[py, px] = True

    return np.clip(np.rint(color), 0, 255).astype(np.uint8), covered, culled

驱动脚本 sim/cg_shade_12_1_tolerance.py 拿它与整数基线整幅比对(/opt/homebrew/bin/python3 sim/cg_shade_12_1_tolerance.py):

canvas            : 320x240  896 三角形  dropped 0
S 层基线 sha256   : 0d997343ca547ed5…  复现卷二基线 = True
噪声地板(浮点镜像 vs 整数):
  不一致像素占比  : 43402 (565.1302‰)  ← 含 ±1,无区分力
  MAE / PSNR      : 0.4963 / 54.22 dB  最大通道差 60
  覆盖归属差异    : 0 (0.0‰)
  通道差分布      : [1-1] 38647  [2-2] 4752  [3-4] 0  [5-8] 0  [9-16] 0  [17-255] 3

第一行那个 565.13‰ 是个陷阱。 76800 个像素里有 43402 个「对不上」——超过一半。如果判据写成「不一致像素占比」,正确的浮点实现当场就会被判错。但看最后一行的分布:±1 的占 38647(89.05%),±2 的占 4752(10.95%),≥17 的只有 3 个。所以「不一致像素占比」测的是「有没有差」,不是「差得对不对」,它没有区分力。 判据必须带幅度。

覆盖归属差异是 0,因为镜像与基线吃的是同一条整数屏幕坐标——几何被完全钉死了,这一条在本节的对照里不产生噪声。它的作用要到 12.3 换成真 GPU 时才显现(那里是 0.08‰,6 个像素)。

最大通道差 60 的那 3 个像素值得单独说。 它们出现在退化三角形的终点附近:法线在屏幕空间被插值到接近零向量,归一化时方向翻了过去,k 从一个正值跳到 0。这是「个位数像素上的大误差」——MAE 看不见(0.4963 里它只贡献 60/230400),占比也看不见。只有最大通道差看得见它。这就是为什么三元组里必须有覆盖、必须有幅度,而且 12.2 还要再加一条分布。

负例:判据必须拒绝三类错实现

一条判据如果不拒绝明显写错的实现,它的 PASS 就不可信。三类负例,覆盖着色的三个环节:

负例(必须被同一三元组拒绝):
  N1_color_half        MAE   21.746  PSNR  21.38 dB  最大通道差 110   ← 颜色整体减半(输出量化环节)
  N2_skip_normalize    MAE  15.4307  PSNR  24.36 dB  最大通道差  88   ← 漏归一化(法线环节)
  N3_light_dir_flip    MAE   32.977  PSNR  17.77 dB  最大通道差 177   ← 光方向取反(光照环节)

三条的 MAE 都在噪声地板(0.4963)的 30 倍以上,PSNR 全部低于 30 dB,被同一判据干净地拒绝。注意 N2_skip_normalize 的 MAE(15.4307)比 N1_color_half(21.746)小——漏归一化比颜色减半「更像对的」,因为法线被整体放大 2 倍后,max(dot(n,dir),0) 仍然在大部分朝向光的一侧给出接近饱和的值,只有背光侧和掠射区露馅。这个排序本身说明判据的分辨率够用。

边界:太松与太紧

阈值是自由参数,所以必须扫一遍。做法是给基线施加可调的全局缩放,量出判据在哪里翻脸:

校准扫描(缩放幅度 -> 指标):
  scale 1.000  MAE      0.0  PSNR    inf dB  最大通道差   0
  scale 0.999  MAE      0.0  PSNR    inf dB  最大通道差   0
  scale 0.995  MAE    0.027  PSNR  79.49 dB  最大通道差   1
  scale 0.990  MAE   0.5405  PSNR  53.47 dB  最大通道差   2
  scale 0.980  MAE   0.8741  PSNR  49.30 dB  最大通道差   4
  scale 0.950  MAE   2.5869  PSNR  39.88 dB  最大通道差  11
  scale 0.900  MAE   4.8961  PSNR  34.33 dB  最大通道差  22
  scale 0.800  MAE   9.9332  PSNR  28.19 dB  最大通道差  44
  scale 0.500  MAE  24.8995  PSNR  20.21 dB  最大通道差 110
穿越点            : MAE>1.0 起于 scale=0.95  PSNR<30 起于 scale=0.8

太紧。 噪声地板是 MAE 0.4963,已经贴住 0.5。把阈值从 1.0 收到 0.5 会怎样?12.3 的 W 层实测 MAE 是 0.5073——那是本机 WebGL2 后端渲染的同一场景、同一套几何、同一套光照,只差宿主。阈值收到 0.5,它会立刻误报,而它是对的。所以 1.0 这个位置不是拍脑袋,是「噪声地板之上留一倍余量」。

太松。 2% 的全局偏置(scale 0.98)MAE 0.8741、PSNR 49.30 dB、最大通道差 4——三元组整条放行。0.5% 的偏置更是完全隐身:MAE 0.027,最大通道差 1,和噪声地板看不出区别。这是判据的实测盲区,必须写在明面上:这三条判据对「全局一致的小幅偏置」是盲的。 想抓它不能靠收紧全局阈值(收紧就误报),只能换判据——用对分布敏感的量,或者用一块已知 albedo 的参照区。12.2 给的是前者。

两条线不对称。 MAE 线在 scale 0.95 被穿过(2.5869),PSNR 线要到 0.80 才被穿过(28.19)。因为 30 dB 对应的是 MAE ≈ 8,比 MAE 阈值宽 8 倍。这就是「PSNR 是 MAE 的单调变换」的直接后果:两条线永远不会独立地翻脸,谁是更紧的那条谁说了算。

P 层:判据要能换一份实现复算

判据如果只有一份实现能算出来,那它是断言不是判据。P 层在 TypeScript 里重建同一个浮点镜像,重算噪声地板、通道差分布、三类负例与整条校准扫描(core/test/cg_shade_12_1_tolerance.test.ts):

// snippet: core/test/cg_shade_12_1_tolerance.test.ts(节选:top-left 归属 + 噪声地板断言 + 取整口径)
/** top-left rule:有向边 A->B 拥有边界样本 iff dy<0 或 dy==0 且 dx>0。 */
const owned = (ax: number, ay: number, bx: number, by: number): boolean =>
  by - ay < 0 || (by - ay === 0 && bx - ax > 0);

/** 就近取整,平局取偶 —— 对齐 S 侧的 np.rint(JS 的 Math.round 是平局向上,会差出 0.015 MAE)。 */
const rintEven = (v: number): number => {
  const f = Math.floor(v);
  const d = v - f;
  if (d > 0.5) return f + 1;
  if (d < 0.5) return f;
  return f % 2 === 0 ? f : f + 1;
};

test("噪声地板:TS 浮点镜像与整数基线的三元组、通道差分布与 S 侧一致", () => {
  const tris = buildShadedTris();
  const p = rasterizeShaded(tris, spec.width, spec.height, spec.backgroundQ8, shade);
  const m = floatMirror(tris, spec.width, spec.height, spec.backgroundQ8);
  const got = compare(p.rgb, m);
  const f = stats.noise_floor;
  assert.equal(got.diffPx, f.inconsistent_px);
  assert.equal(got.maxChan, f.max_channel_diff);
  assert.ok(Math.abs(got.mae - f.mae) < 5e-4, `MAE ${got.mae} vs ${f.mae}`);
  assert.ok(Math.abs(got.psnr - f.psnr_db) < 0.02, `PSNR ${got.psnr} vs ${f.psnr_db}`);
  for (const h of f.delta_hist) {
    let n = 0;
    for (let d = h.lo; d <= h.hi; d += 1) n += got.hist.get(d) ?? 0;
    assert.equal(n, h.pixels, `通道差 [${h.lo}-${h.hi}] 像素数不符`);
  }
  // 分布的形状本身就是结论:±1 舍入占绝大多数,大差只有个位数
  assert.ok(got.hist.get(1)! > got.diffPx * 0.85, "±1 舍入应占不一致像素的 85% 以上");
});

那段 rintEven 不是装饰。S 层的 np.rint 是「四舍六入五取偶」,JS 的 Math.round 是「四舍五入」,两者在 0.5 处的取法相反。第一版测试直接用 Math.round,scale 0.95 那一档算出的 MAE 是 2.5715 而 S 侧是 2.5869——差了 0.0154,比噪声地板本身还显眼。一条取整规则就能在 MAE 上留下比「正确实现之间的噪声」更大的痕迹,这正是本节要讲的事。

node --test 的结果:

✔ S 层整数基线复现卷二逐像素 sha256
✔ 噪声地板:TS 浮点镜像与整数基线的三元组、通道差分布与 S 侧一致
✔ 三类负例都被同一三元组拒绝
✔ 校准边界:太松与太紧都能被同一个三元组量出来
tests 4 / pass 4 / fail 0

最后一条里有两个反向断言:噪声地板必须落在 (0.45, 0.5) 区间(证明「阈值收到 0.5 就会误报」不是估计而是实测),以及 2% 偏置必须落在三元组之内——把判据的盲区也钉成断言,防止有人偷偷收紧阈值让盲区消失、然后声称判据更强了。

本节的口径

  • 容差 = 覆盖归属差异 ≤ 20‰ ∧ MAE ≤ 1.0 ∧ PSNR ≥ 30 dB,三条同时成立才叫一致。
  • 噪声地板(浮点镜像 vs 整数基线):MAE 0.4963、PSNR 54.22 dB、覆盖 0.0‰、最大通道差 60;分布上 ±1 占 89.05%、±2 占 10.95%、≥17 只有 3 个像素。
  • 「不一致像素占比」不作判据——它在本节的正确实现上就给出 565.13‰。
  • 已知盲区:全局一致的小幅偏置。2% 偏置 MAE 0.8741,整条放行。
  • 判据的边界是量出来的:MAE 线在 scale 0.95 穿过,PSNR 线在 0.80 穿过。

阅读导航:上一节:11.3 抗锯齿的度量 · 下一节:12.2 图像 digest 与误差度量 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

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