《游戏引擎实现原理》10.1 浮点确定性与跨端一致性

同一份浮点代码在不同平台上会算出不同结果,根源是加法不满足结合律、FMA 改变了舍入时机、单精度与双精度位宽不同。本节在 Node 里实测:(0.1+0.2)+0.3 与 0.1+(0.2+0.3) 相差 1.1e-16,一段积分跑 10 万步后单精度比双精度差了 0.2,有无 FMA 甚至能把 4.4e-16 变成 0。

10.1 浮点确定性与跨端一致性

9.x 把变换、四元数、曲线都补齐了,helio 的数学层看起来已经能用了。但有一个问题一直悬着没碰:同一份浮点代码,在不同平台上跑出来的结果可能不一样。

对单机游戏,这无所谓——玩家自己机器上的画面和物理是自洽的。可一旦涉及回放、联机同步、服务器校验,它就是致命的:两台机器各算各的,位置只要差一点点,几百帧后就天各一方,服务器和客户端对不上账。

这一节不写引擎功能,只做一件事:把「浮点为什么不可复现」从一句人云亦云的话,变成一组你在本机就能复跑、能断言的数字。定点数怎么用它换确定性,放在 10.2。

本章与站内既有内容的分工

站内已经有文章讲过游戏数学与引擎组织,先把边界划清楚,避免重复:

既有文章它负责什么本节只做什么
/graphics-math-foundations/向量、矩阵、四元数、投影的数学推导与公式假定公式都对,只追问「同样的公式在不同平台上算出同样的数吗」
/posts/game/engine/引擎选型与入门,Unity / Unreal / Defold 的取舍不比较引擎,只实现 helio 自己的确定性工具
/defold-ecs-architecture/引擎子系统的组织方式与 ECS 视角不碰 ECS,只处理单次浮点运算的结果

一句话:别人讲数学「是什么」,本章讲浮点结果「为什么对不上、怎么测出来」。 确定性模拟与定点数在站内是空白话题,所以这一章承担的是「从零补一块地基」。

浮点不可复现的三条来源

浮点数不是「近似实数」,它是一套有明确规则的离散表示。问题出在规则本身有几处「实现自由」。

来源一:加法不满足结合律

小学就学过的 a + b + c,在浮点里换个括号就换个结果。因为每一次加法都要把结果舍入到能表示的最近值,而舍入会丢掉低位信息。三次加法的舍入顺序不同,丢掉的信息就不同。

本机(Node 26.5.0)跑出来的真实数字:

表达式结果与右式之差
(0.1 + 0.2) + 0.30.60000000000000011.1102230246251565e-16
0.1 + (0.2 + 0.3)0.6—
(1e16 + -1e16) + 111
1e16 + (-1e16 + 1)0—

第一组差了 1.1e-16,看着微不足道;第二组直接差了整整 1——因为 1e16 大到「加上 1 也还是 1e16」,那个 1 被整个吃掉了,而先加 1 再抵消就留下了它。这就是「大数吃小数」。

对引擎的意义:编译器、向量化、并行归约都可能改变加法的分组顺序。sum(xs) 在不同平台上按不同顺序累加,就会得到不同的和。

来源二:FMA 改变了舍入时机

现代 CPU 有 FMA(fused multiply-add,融合乘加)指令,a * b + c 只舍入一次。没有 FMA 的平台则要先把 a * b 舍入一次,再加 c 舍入一次——两次舍入。同一条源码,两种指令序列,结果可能不同。

本机实测:取 a = 1.0000000000000002、b = 1.0000000000000002、c = -1,

  • 没有 FMA(先舍乘积再相加):Math.fround(Math.fround(a * b) + c) 得到 0;
  • 有 FMA(乘积保持精度、加完再舍):Math.fround(a * b + c) 得到 4.440892098500626e-16。

同一个公式,一个平台算出 0,另一个算出 4.4e-16。这在「判断两个量是否相等」的地方会直接改变分支走向。

来源三:单精度与双精度的位宽差异

float(32 位)只有约 7 位有效十进制数字,double(64 位)有约 15 位。同一段代码,一边用 32 位、一边用 64 位,误差会在迭代中被放大。

JavaScript 的 number 永远是 64 位双精度,但 Math.fround 能把任意数舍入到最近的 32 位浮点值,用它就能在双精度环境里模拟「只有单精度」的平台。本机实测:

  • 双精度的 0.1 是 0.10000000000000000555;
  • Math.fround(0.1) 是 0.10000000149011612;
  • 两者不相等——0.1 这个数在单精度里的最近值,和双精度里的最近值,压根不是同一个。

把 v += a * dt 这样的积分跑 10 万步,单精度与双精度的最终值差到 0.2(见下节实测表)。位宽差异不是「精度少一点点」,而是「跑得越久差得越多」。

顺带一提:x87 的扩展精度

老一代 x86 的 x87 浮点单元内部用 80 位扩展精度做中间运算,只在写回内存时才舍到 64 位。于是「中间结果留在寄存器里」和「中间结果存进内存再读回」会得到不同答案——取决于编译器有没有溢出寄存器。现代 64 位平台普遍改用 SSE/AVX 的 64 位运算,这条来源已经基本退出历史舞台,但它是「浮点结果依赖编译器和平台」这一事实最早的注脚。

可运行产物:把「不可复现」变成断言

光看现象不够,我们要把它固化成能复跑、能断言的代码。下面是 helio 内核里 src/math/float-determinism.ts 的完整实现——它不做业务,只把上面三条来源变成可调用的函数。层级 L1,Node 直跑。

// file: src/math/float-determinism.ts
// tier: L1
// verify: npx vitest run src/math/float-determinism.test.ts
//
// helio 内核:浮点确定性的实验工具。
// 这些函数不做业务,只把「浮点结果依赖顺序 / 依赖位宽」这件事
// 变成可断言的事实。

/** 从左到右累加——浮点加法的默认顺序。 */
export function sumLeftToRight(xs: readonly number[]): number {
  let s = 0;
  for (let i = 0; i < xs.length; i++) s += xs[i];
  return s;
}

/** 从右到左累加:只换了遍历方向,公式一个字没改。 */
export function sumRightToLeft(xs: readonly number[]): number {
  let s = 0;
  for (let i = xs.length - 1; i >= 0; i--) s += xs[i];
  return s;
}

/** Kahan 补偿求和:把被大数吃掉的低位误差单独攒起来。 */
export function kahanSum(xs: readonly number[]): number {
  let sum = 0;
  let c = 0;
  for (let i = 0; i < xs.length; i++) {
    const y = xs[i] - c;
    const t = sum + y;
    c = t - sum - y;
    sum = t;
  }
  return sum;
}

/** 每步都舍入到单精度,模拟「浮点只有 32 位」的平台。 */
export function integrateF32(v0: number, a: number, dt: number, steps: number): number {
  let v = Math.fround(v0);
  const step = Math.fround(a * dt);
  for (let i = 0; i < steps; i++) {
    v = Math.fround(v + step);
  }
  return v;
}

/** 同样的积分,全程双精度。 */
export function integrateF64(v0: number, a: number, dt: number, steps: number): number {
  let v = v0;
  for (let i = 0; i < steps; i++) v += a * dt;
  return v;
}

/** 乘积先舍到 f32 再相加——没有 FMA 的平台。 */
export function mulAddNoFma(a: number, b: number, c: number): number {
  return Math.fround(Math.fround(a * b) + c);
}

/** 乘积保持精度、加完再舍——有 FMA 的平台。 */
export function mulAddFused(a: number, b: number, c: number): number {
  return Math.fround(a * b + c);
}

配套的单测 src/math/float-determinism.test.ts 把三条来源都钉成断言。注意它断言的是具体数值——一旦某个平台的浮点行为变了,测试立刻红,这正是我们要的效果。

// file: src/math/float-determinism.test.ts
// tier: L1
// verify: npx vitest run src/math/float-determinism.test.ts

import { describe, expect, it } from 'vitest';
import {
  integrateF32,
  integrateF64,
  kahanSum,
  mulAddFused,
  mulAddNoFma,
  sumLeftToRight,
  sumRightToLeft,
} from './float-determinism.ts';

describe('浮点加法不满足结合律', () => {
  it('(0.1+0.2)+0.3 与 0.1+(0.2+0.3) 不相等', () => {
    const l = 0.1 + 0.2 + 0.3;
    const r = 0.1 + (0.2 + 0.3);
    expect(l).not.toBe(r);
    expect(l).toBe(0.6000000000000001);
    expect(r).toBe(0.6);
  });

  it('换遍历方向就能改结果', () => {
    const xs = [1e16, -1e16, 1];
    expect(sumLeftToRight(xs)).toBe(1);
    expect(sumRightToLeft(xs)).toBe(0);
  });

  it('Kahan 求和与方向无关', () => {
    expect(kahanSum([1e16, -1e16, 1])).toBe(1);
  });
});

describe('单精度与双精度会分道扬镳', () => {
  it('100000 步积分后 f32 与 f64 差了 0.2', () => {
    const f64 = integrateF64(0, 0.1, 1 / 60, 100_000);
    const f32 = integrateF32(0, 0.1, 1 / 60, 100_000);
    expect(f64).toBe(166.66666666653134);
    expect(f32).toBe(166.46539306640625);
    expect(Math.abs(f64 - f32)).toBeGreaterThan(0.2);
  });
});

describe('FMA 改变舍入时机', () => {
  it('同输入下有无 FMA 结果不同', () => {
    const a = 1.0000000000000002;
    const b = 1.0000000000000002;
    expect(mulAddNoFma(a, b, -1)).toBe(0);
    expect(mulAddFused(a, b, -1)).toBe(4.440892098500626e-16);
  });
});

本机实测:npx vitest run src/math/float-determinism.test.ts,5 个用例全部通过。Kahan 求和那一组尤其值得看——[1e16, -1e16, 1] 这个朴素求和会因为顺序得到 1 或 0,而 Kahan 补偿求和稳定给出 1,因为它把「被吃掉的低位」单独记在一个补偿项 c 里,最后再补回来。

实测:同一段积分的三条岔路

把上面这些现象放到一段更接近游戏的积分里,差距会变得直观。基准脚本 bench/float-order.mjs(B 级)跑 v = v + a * dt,分别在双精度、单精度下迭代,并单独打印非结合性与 FMA 的数字。

// file: bench/float-order.mjs
// tier: B
// verify: node bench/float-order.mjs
//
// 打印三组真实数字:
// 1) 加法的非结合性(同三个数,换括号就换结果)
// 2) 同一段积分,f32 与 f64 的最终值差多少
// 3) 同输入下有无 FMA 的结果差

const f = Math.fround;

// 1) 非结合性
const triples = [
  [0.1, 0.2, 0.3],
  [1e16, -1e16, 1],
  [1, 1e16, -1e16],
];
for (const [a, b, c] of triples) {
  const l = (a + b) + c;
  const r = a + (b + c);
  console.log(`(${a}+${b})+${c} = ${l}   ${a}+(${b}+${c}) = ${r}   equal=${l === r}`);
}

// 2) 积分漂移:v = v + a*dt,跑 100000 步
function integrate(round, v0, a, dt, steps) {
  let v = round(v0);
  const step = round(a * dt);
  for (let i = 0; i < steps; i++) v = round(v + step);
  return v;
}
const id = (x) => x;
for (const steps of [10, 1000, 100000]) {
  const d = integrate(id, 0, 0.1, 1 / 60, steps);
  const s = integrate(f, 0, 0.1, 1 / 60, steps);
  console.log(`steps=${steps}  f64=${d}  f32=${s}  delta=${d - s}`);
}

// 3) FMA
const a = 1.0000000000000002;
const b = 1.0000000000000002;
console.log(`无 FMA: f(f(a*b)+c) = ${f(f(a * b) + -1)}`);
console.log(`有 FMA: f(a*b+c)    = ${f(a * b + -1)}`);

node bench/float-order.mjs 本机输出如下。这些是单机实测值,绝对数值不可移植,请关注量级与趋势,不要记小数点。

(0.1+0.2)+0.3 = 0.6000000000000001   0.1+(0.2+0.3) = 0.6   equal=false
(10000000000000000+-10000000000000000)+1 = 1   10000000000000000+(-10000000000000000+1) = 0   equal=false
(1+10000000000000000)+-10000000000000000 = 0   1+(10000000000000000+-10000000000000000) = 1   equal=false
steps=10  f64=0.016666666666666666  f32=0.01666666753590107  delta=-8.692344032057431e-10
steps=1000  f64=1.6666666666666916  f32=1.6666651964187622  delta=0.000001470247929402646
steps=100000  f64=166.66666666653134  f32=166.46539306640625  delta=0.20127360012509143
无 FMA: f(f(a*b)+c) = 0
有 FMA: f(a*b+c)    = 4.440892098500626e-16

把积分那三行单独拎出来看:

步数双精度结果单精度结果差值
100.0166666666666666660.01666666753590107-8.7e-10
10001.66666666666669161.66666519641876221.47e-6
100000166.66666666653134166.465393066406250.2013

误差随步数放大。 10 步时差 8.7e-10,几乎可忽略;1000 步时差 1.5e-6;到 10 万步已经差了 0.2。原因很朴素:每一步的单精度舍入都留下一点点残差,而积分是累加——残差不会抵消,只会一层层叠上去。这就是为什么「只在最后比较两个浮点位置」的联机方案一定会炸:误差是随时间线性甚至更快增长的。

这对引擎意味着什么

把三条来源合起来,helio 得出三条工程结论:

  • 需要跨端一致的量,绝不能用浮点累加。 物理位置、速度、能量累积,只要参与跨端比对,就必须换成整数运算(10.2 的定点数)或定长步的整数累加。
  • 需要跨端一致的比较,绝不能用 ===。 浮点相等判断要用 epsilon(10.3),而且 epsilon 的尺度要随量级缩放,不能用固定的绝对值。
  • 归约顺序要固定。 求和一个数组时,谁先加谁后加、是否并行归约,都要写死。sumLeftToRight 和 sumRightToLeft 在同一台机器上都不同,更别说跨平台了。

一句话:确定性的敌人不是「浮点不精确」,而是「浮点不精确的方式因平台而异」。 精度损失本身可以容忍,不可复现才致命。

常见坑

  • 以为「同一份源码 = 同样的结果」。 编译器能否用 FMA、会不会重排加法、向量宽度是多少,都可能不同。源码相同不代表机器码相同。
  • 用 a === b 比较浮点。 即使两条路径在数学上等价,浮点里也常常不相等。0.1 + 0.2 === 0.3 就是 false。
  • 用固定绝对 epsilon。 比较 1e8 和 1e8 + 1 时,绝对 epsilon 要么太松要么太紧;要用随量级缩放的相对 epsilon(10.3)。
  • 只在最后比较一次结果。 误差是累积的,10 步看不出来、10 万步就翻天。要么每步都比对(暴露得早),要么从一开始就用确定性表示。
  • 以为 Math.fround 是「降低精度」。 它做的是「舍入到最近的 32 位浮点值」,用来模拟单精度平台,不是随手丢精度。
  • 把 Kahan 求和当成万能药。 它让求和与顺序无关,但代价是每步多几次运算,而且它只解决「累加」,解决不了乘法与 FMA 的差异。

浮点这条路走到这里,结论很清楚:要确定性,就得离开浮点。 下一节把整数运算请回来——定点数用「把小数点钉死」的办法,让同一份代码在任何平台上逐位一致。

阅读导航:上一节:目录 · 下一节:10.2 定点数:为确定性模拟准备 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

  1. 《游戏引擎实现原理》工程与实战 目录
  2. 《游戏引擎实现原理》9.3 选型与自研的判断标准
  3. 《游戏引擎实现原理》9.2 同一个特性在不同引擎里的实现路径