12.3 CPU 与 GPU 的比对

把同一份场景交给 WebGL2 渲染,再与 S 层定点基线逐像素比对:MAE 0.5073、PSNR 46.95 dB、覆盖归属差异 0.08‰,与 12.1 的 CPU 浮点镜像 0.4963 落在同一噪声地板上。不一致像素按「共享边归属 0.0% / 插值精度 11.3% / 舍入 88.6%」三类归因,并如实交代本机后端是 ANGLE/SwiftShader 软件实现,不是真实 GPU。

12.3 CPU 与 GPU 的比对

《游戏引擎实现原理》卷二《渲染引擎》 的渲染器是纯 CPU 的定点实现,所以「换个宿主会怎样」在那一卷里没有答案。12.1 用CPU 浮点镜像逼近过一次,但它终究还是 CPU。本节把同一份场景真正交给 WebGL2 渲染,再拿 12.1 的三元组去量——顺带检验那条判据在完全不同的光栅化器上还能不能站住。

两个宿主怎么对齐

比对的公平性取决于「哪些东西被钉死、哪些东西允许不同」。钉死的是几何,允许不同的是光栅化与着色:

  • 顶点重合。W 层的 NDC 由 S 层算出的整数屏幕 Q6 坐标反推:ndcX = sx/(W·32) − 1、ndcY = 1 − sy/(H·32)、z = (d/65536)·2 − 1。这三行反推的正是 toScreen 的整数取整,所以两边的三角形顶点在数学上完全重合——差异里不含「顶点位置」这一项。
  • 深度测试。depthFunc(LESS) 对齐 S 层的 z < depth。
  • 背面剔除。S 层是屏幕空间 area <= 0 就丢(本场景剔除 290 个三角形)。WebGL 的窗口空间 y 轴向上、S 层屏幕空间 y 轴向下,绕向正好相反,所以取 frontFace(CW) + cullFace(BACK)。
  • 帧缓冲是归一化 [0,1] 的。8 位 albedo 必须先除 255 再写——这是本节踩到的第一个坑,也是负例 A。
  • readPixels 首行是画布底部。要翻转成自上而下,才能与 S 层同序比对。

这套对齐的代价必须说清楚:顶点是由 S 层算出来的,W 层只是把它们搬到 NDC。也就是说,本节比较的是「光栅化 + 着色」,不含顶点变换。顶点变换自身的跨宿主差异(mat4 的乘法顺序、1/w 的求法、裁剪的时机)没有被这一节覆盖——要测那一块,得把 MVP 也在 GPU 侧重算一遍,本卷没有做,按 X 档处理。先把变量钉到只剩两个,再谈差异,这是比对能成立的前提。

片元着色器与 S 层是同一个公式,只差定点标度:

// snippet: web/build_shade12.mjs(片元着色器:与 S 层同一公式,只去掉定点标度)
#version 300 es
precision highp float;
uniform vec3 uDir;
uniform float uInt;
uniform float uAmb;
in vec3 vN;
in vec3 vA;
out vec4 o;
void main() {
  vec3 n = normalize(vN);
  float ndl = max(dot(n, uDir), 0.0);
  float k = min(uAmb + ndl * uInt, 1.0);
  o = vec4(vA * k / 255.0, 1.0);
}

实测:跨宿主的噪声地板

node web/build_shade12.mjs 出页面,NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_shade12.mjs 用 Playwright 无头跑它:

canvasDrawn     : true   已绘制像素 : 43729 / 76800
三角形          : 896   画布 : 320x240
GL 后端         : ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (LLVM 10.0.0) (0x0000C0DE)), SwiftShader driver)
GL 版本         : WebGL 2.0 (OpenGL ES 3.0 Chromium)   glError : 0
注              : 本机后端是 ANGLE/SwiftShader 软件实现,不是真实 GPU

① S 层 Q16.16 定点 vs WebGL2 逐像素着色:
   任一通道差>0 : 43364 / 76800 (564.64‰)  ← 含 ±1 舍入,不作判据
   覆盖归属不同 : 6 / 76800 (0.08‰)
   最大通道差   : 159
   MAE          : 0.5073   PSNR : 46.95 dB
   通道差分布   : 1:38442 2:4898 3:2 8:1 11:1 12:2 19:1 20:1 25:1 27:2 29:1 42:1 45:1 46:1 49:1 50:1 57:1 59:1 60:1 69:1 97:1 99:1 159:1
③ 确定性        : 两遍逐字节相同 = true

三元组读下来:MAE 0.5073 ≤ 1.0 ✓、PSNR 46.95 dB ≥ 30 dB ✓、覆盖归属差异 0.08‰ ≤ 20‰ ✓,三条同时成立。

真正有价值的是与 12.1 的对照。CPU 浮点镜像给出的噪声地板是 MAE 0.4963、覆盖 0.0‰、最大通道差 60;真·WebGL2 后端给的是 MAE 0.5073、覆盖 0.08‰、最大通道差 159。两个完全不同的光栅化器落在同一个噪声地板上(0.4963 对 0.5073,差 2%)。 这是本节最强的一条交叉验证:它说明 12.1 量出的那个地板是判据的地板,不是某一份实现的巧合。

确定性也成立:同一个页面渲两遍,逐字节相同。跨宿主不一致,但单宿主内确定——这正是 12.1 说的「规范留给实现的自由度」的样子:自由度是固定的,只是不同实现取的值不同。

顺带做一个不花额外成本的交叉核对:S 层基线覆盖 43731 个像素(569.41‰),WebGL2 渲染出 43729 个非背景像素,差 2 个。这个差额必须落在覆盖归属差异之内才自洽——43731 − 43729 = 2 ≤ 6 ✓。如果哪天两个数差出几十上百,就说明要么背景色判据在两边不一致,要么几何真的没对齐。这条核对能挡住「覆盖判据本身写错了」这一类错误,而它不需要多写一行渲染代码。

还有一条要交代:这里的 43729 个「已绘制像素」是按「非背景色」判定的,与 S 层的覆盖掩码同口径。背景色是一个约定(本场景 backgroundQ8 = [12,14,20])——如果哪天场景里有一块恰好是这个颜色的物体,覆盖判据就会把它算成背景。这是覆盖判据的已知脆弱点;换成 alpha 通道或独立的覆盖缓冲会更稳,本卷沿用背景色是因为 S 层与 W 层都能零成本拿到它,而代价在这里写明。

三类归因:不一致像素长什么样

只报「有 43364 个像素不一致」是没有信息量的(12.1 已经说过,564.64‰ 这个数在本节的正确实现上就会给出)。要把它们分开:

归因判据像素数占全部不一致像素
共享边归属不同覆盖掩码不一致(一边背景一边非背景)60.0%
插值精度不同两边都覆盖,通道差 ≥ 2491611.3%
舍入不同两边都覆盖,通道差 == 13844288.6%

三类不是三个独立阈值,而是同一判据下的解释,优先级自上而下:先判覆盖归属(这是一票否决的语义差异),再按幅度把「两边都覆盖」的差异切成插值与舍入。顺序不能反——一个像素如果覆盖归属都不同,它的颜色差是多少已经没有意义了。

还有一个 W 层的老坑要交代。Playwright 冷启动时,首个 page 读回 canvas 像素恒为空白——WebGL 上下文刚建立,第一次 readPixels 拿到的是一整块黑。处理办法是先开一个预热页、画个绿底、关掉,再开真正的页面;页面侧还要把 canvasDrawn 标志推迟到两次 requestAnimationFrame 之后才置位。断言也随之放宽成「已绘制 ≥ 1 px」(实测 43729 px)。这类环境噪声必须写在明面上——否则下一次有人在别的机器上跑出 0 个绘制像素,会以为是渲染器坏了。分类逻辑本身很短,抽成了一个可复用的模块:

// file: lantern/web/lib_shade12.mjs
// tier: W
// verify: NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_shade12.mjs
// 卷二 ch12 的 W 层共用件:把「与 S 层比对」拆成三个可复用的函数。
//
// 判据口径见 verify_shade12.mjs 的头部说明,这里只放机制:
//   isBg     —— 覆盖掩码的唯一判据:像素是否恰好等于配置的背景色
//   stats    —— 三元组(MAE / PSNR / 覆盖归属占比)+ 通道差直方图 + 三类归因
//   rejected —— 判据本体:MAE > 1.0 或 PSNR < 30 dB 即拒绝
//
// 三类归因是同一判据下的**解释**,不是三个独立阈值:
//   ① ownership —— 覆盖掩码不一致(一边背景一边非背景)
//   ② interp    —— 两边都覆盖,通道差 >= 2
//   ③ round     —— 两边都覆盖,通道差 == 1

/** 覆盖掩码的唯一判据:像素三通道恰好等于配置背景色,即视为未覆盖。 */
export function isBg(buf, i, bg) {
  return buf[i] === bg[0] && buf[i + 1] === bg[1] && buf[i + 2] === bg[2];
}

/** 三元组 + 直方图 + 三类归因。actual / expect 为等长的 RGB 8 位序列。 */
export function stats(actual, expect, bg) {
  if (actual.length !== expect.length) {
    throw new Error(`长度不一致: ${actual.length} vs ${expect.length}`);
  }
  let diffPx = 0;
  let coverDiff = 0;
  let sumAbs = 0;
  let sumSq = 0;
  let maxDiff = 0;
  const hist = new Map();
  const classes = { ownership: 0, interp: 0, round: 0 };
  const pixels = expect.length / 3;

  for (let i = 0; i < expect.length; i += 3) {
    let pxDiff = 0;
    for (let c = 0; c < 3; c += 1) {
      const d = Math.abs(actual[i + c] - expect[i + c]);
      if (d > pxDiff) pxDiff = d;
      sumAbs += d;
      sumSq += d * d;
    }
    hist.set(pxDiff, (hist.get(pxDiff) ?? 0) + 1);
    if (pxDiff > maxDiff) maxDiff = pxDiff;
    if (pxDiff > 0) diffPx += 1;
    const own = isBg(actual, i, bg) !== isBg(expect, i, bg);
    if (own) coverDiff += 1;
    if (pxDiff > 0) {
      if (own) classes.ownership += 1;
      else if (pxDiff >= 2) classes.interp += 1;
      else classes.round += 1;
    }
  }

  const mse = sumSq / expect.length;
  const mae = sumAbs / expect.length;
  const top = [...hist.entries()].filter(([k]) => k > 0).sort((a, b) => a[0] - b[0]);
  return {
    pixels, diffPx, coverDiff, maxDiff,
    diffPermille: (diffPx / pixels) * 1000,
    coverPermille: (coverDiff / pixels) * 1000,
    mae, psnr: mse === 0 ? Infinity : 10 * Math.log10((255 * 255) / mse),
    hist: top, classes,
  };
}

/** 判据本体。覆盖归属差异 ≤ 20‰ 由调用方单独断言,见 verify_shade12.mjs。 */
export function rejected(s) {
  return s.mae > 1.0 || s.psnr < 30;
}

88.6% 是舍入。 这与 12.1 的 CPU 镜像完全一致(那里 ±1 占 89.05%)——跨宿主差异的主体,就是 8 位量化最后一位的取整,不是算法分歧。

11.3% 是插值精度。 GPU 的重心坐标插值与 S 层的 Q16.16 定点插值在中间值上差出几个 ULP,落到 8 位输出上就是差 1~2 格。

0.0% 是共享边归属。 6 个像素,占比小到显示成 0.0%。但这 6 个像素才是本节的核心证据:S 层自己和自己比是 0 个(12.1 的镜像),而真·光栅化器在这里给出 6 个——这 6 个像素就是「top-left rule 在两个宿主上取值不同」的全部代价。它小,是因为 top-left rule 把绝大多数边界像素的归属定死了;它不为零,是因为 S 层的屏幕 y 轴方向与 WebGL 相反,边界情形必然有分歧。

分布尾部也值得看:差 ≥ 2 的像素里,除了 4898 个差 2 的,还有 21 个散落在 3 到 159 之间。12.1 的 CPU 镜像里对应的是 3 个像素、最大 60——同一类现象(退化处法线翻转),在本机这个软件后端上更明显。 它们都是「个位数像素上的大误差」,MAE 和占比都看不见,只有最大通道差看得见。

负例与几何语义对照

// snippet: web/verify_shade12.mjs(判据与三条负例断言)
const fail = [];
if (g.mae > 1.0) fail.push(`① MAE ${g.mae.toFixed(4)} 超出 1.0`);
if (g.psnr < 30) fail.push(`① PSNR ${g.psnr.toFixed(2)} dB 低于 30 dB`);
if (g.coverPermille > 20) fail.push(`① 覆盖归属差异 ${g.coverPermille.toFixed(2)}‰ 超出 20‰`);
if (!determ) fail.push("③ 两遍渲染不确定");
if (!rejected(badA)) fail.push("④ 负例 A(漏 /255)未被判据拒绝");
if (!rejected(badB)) fail.push("④ 负例 B(漏环境光)未被判据拒绝");
if (rejected(badC)) fail.push("④ 负例 C(漏 normalize)竟被拒绝 —— 与实测边界不符,须复核");
④ 负例(必须被同一判据拒绝):
   A 漏 /255      : MAE 102.0062  PSNR 5.48 dB  ← 8 位 albedo 直接写进归一化帧缓冲
   B 漏环境光     : MAE 12.0242  PSNR 23.88 dB
   C 漏 normalize : MAE 0.3514  PSNR 47.39 dB  ← 落在容差内,判据抓不到(实测边界)
⑤ 几何语义对照  : 关掉背面剔除 MAE 2.4887  ← 不是数值容差,是「该画哪些面」

负例 A(漏 /255) 的 MAE 是 102.0062,PSNR 5.48 dB——被干净地拒绝。它是本节的第一个坑:8 位 albedo 写进归一化帧缓冲会被截到 255,整幅亮成白色。这个错很蠢,但它真实发生过,而且第一版实现就是这么写的。

负例 B(漏环境光) MAE 12.0242、PSNR 23.88 dB,被拒绝。它的 MAE 只有 A 的八分之一,因为环境光在本场景里是 ambientQ16 = 13107,即 0.2:满照时 k 本来就被夹到 1.0,去掉环境光只降到 0.9,受光侧几乎不动;变的是背光侧——整片压到 0。它仍然被拒绝,说明判据的下限不是「大面积变白」这种剧烈错误。

负例 C(漏片元阶段的 normalize) 是判据抓不到的那一个:MAE 0.3514、PSNR 47.39 dB,比正确着色器的 0.5073 还小。原因不神秘:插值出来的法线在球面上已经接近单位长度,漏掉 normalize 只改变很小一点;而 S 层的整数基线本身带 Q16.16 的法线量化误差,两者互相抵消。所以「片元阶段不归一化」这类错误,全局容差判据看不见它。 要抓它得换判据——在掠射区单独取样,或者直接比对法线缓冲。验证脚本把这一点写成了反向断言:if (rejected(badC)) fail.push(...)——判据抓不到就必须承认抓不到,而不是把阈值收到刚好能抓到它。

⑤ 关掉背面剔除 MAE 2.4887,比噪声地板大 5 倍,会被判据抓住。但它不该被归进「容差」这一类:错的是「该画哪些面」,是几何语义,不是数值。把它和负例 A/B 混在一起谈,会让人以为容差阈值再紧一点就能解决——它不能。

本机后端:必须说清楚的前提

UNMASKED_RENDERER_WEBGL 报回来的是:

ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (LLVM 10.0.0) (0x0000C0DE)), SwiftShader driver)

这是 ANGLE 之上的 SwiftShader 软件实现,不是真实 GPU。 它在浏览器里提供 WebGL2 接口,但光栅化是 CPU 上跑的软件光栅化器。

这条前提决定了本节结论的边界:

  • 能外推的:判据本身能在一个与 S 层实现完全不同的光栅化器上跑通;三类归因的框架、噪声地板的量级、88.6% 是舍入这个结论的形状。
  • 不能外推的:具体比例。真实 GPU 的子像素精度、插值精度、编译器优化都与软件后端不同,「插值精度不同占 11.3%」这个数字不能搬到真实硬件上。
  • 本卷未验证的:真实 GPU 上的一切结论一律标 X 档。本节没有真实 GPU 的实测数据,所以不给任何「真实硬件会如何」的判断。

不过要说明:即便如此,本节的结论仍然成立。 因为它验证的对象是判据的可移植性,不是某个硬件的行为。判据如果在两个不同的软件光栅化器上都能给出「三条同时成立 + 负例被拒 + 三类可归因」,它就已经具备跨实现复算的能力;换成真实 GPU 需要重跑一遍数字,但不需要重写判据。

这也是 12.2 说的「digest 不够用」在跨宿主场景下的具体形态:这里连一个可以拿来当 digest 的目标都没有——S 层与 W 层输出的是两幅不同的图,逐位比必然不等,只能比「差多少、差在哪」。12.1 的三元组 + 本节的归因,就是「差多少、差在哪」这两问的答案。

本节的口径

  • 跨宿主比对钉死几何(顶点由 S 层整数屏幕坐标反推),允许光栅化与着色不同。
  • 三元组全部成立:MAE 0.5073 / PSNR 46.95 dB / 覆盖归属差异 0.08‰。
  • 与 12.1 的 CPU 浮点镜像(0.4963 / 0.0‰)落在同一个噪声地板上——判据的地板,不是某份实现的巧合。
  • 三类归因:共享边归属不同 0.0%(6 px)/ 插值精度不同 11.3% / 舍入不同 88.6%。
  • 判据的已知盲区在 GPU 上同样存在:漏片元 normalize 的 MAE 0.3514 比正确实现还小,抓不到。
  • 覆盖像素数交叉核对:S 层 43731 对 W 层 43729,差 2 ≤ 归属差异 6,自洽。
  • 本节不含顶点变换的跨宿主比对(顶点由 S 层整数屏幕坐标反推),那一块按 X 档处理。
  • 本机后端是 ANGLE/SwiftShader 软件实现,不是真实 GPU;真实硬件结论标 X 档。

阅读导航:上一节:12.2 图像 digest 与误差度量 · 下一节:13.1 材质的表示与继承 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

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