《游戏引擎实现原理》10.2 定点数:为确定性模拟准备

定点数把小数点钉死在固定位置,用纯整数运算换来跨平台逐位一致。本节实现 Q16.16:一个 int32 的低 16 位是小数、高 16 位是整数,乘法拆高低位避免溢出。实测同一段弹簧积分跑 10000 步:单精度与双精度相差 2.9e-7,而定点版本跑两遍结果逐位相同。

10.2 定点数:为确定性模拟准备

10.1 把结论钉死了:要跨端一致,就得离开浮点。 但离开浮点不等于不能做小数运算——只是要换一套表示。这一节把整数运算请回来,实现 helio 的定点数,并证明它在同一段模拟里能给出逐位相同的结果。

定点数不是新东西。1990 年代的主机游戏几乎全靠它做 3D 变换,因为那时的浮点单元要么慢要么没有。今天它重新变得重要,理由反过来了:CPU 上的浮点又快又准,但快和准的方式因平台而异,而定点数的「准」是逐位可复现的。

定点数:把小数点钉在一个固定位置

浮点数里,小数点的位置是「浮动」的——它用一部分位记录指数,好表示极大和极小的数。定点数把这个自由度砍掉:小数点固定在第 16 位之后。

一个 Q16.16 的定点数就是一个 32 位整数,低 16 位是小数部分、高 16 位是整数部分:

位区间含义权重
bit 0–15(低 16 位)小数部分 frac每单位 2^-16 ≈ 1.526e-5
bit 16–31(高 16 位)整数部分 int每单位 1

于是「1.0」不再写成 1,而是写成 1 << 16 = 65536;「0.5」写成 32768;「-2.5」写成 -163840。所有小数都被缩放成整数,从此运算全在整数域里做。

为什么是 16 位小数?因为要平衡「精度」和「范围」:16 位小数给出的最小刻度是 1/65536 ≈ 1.5e-5,对游戏里的位置、速度、角度足够;高 16 位给整数部分约 ±32767 的范围,也够用。这两段各占 16 位,是一个经典的工程折中——需要更大范围就挪几位给整数(比如 Q8.24),需要更高精度就反过来。

可运行产物:Q16.16 完整实现

下面是 helio 内核里 src/math/fixed-point.ts 的完整实现。它一共六个函数,全部是整数运算。层级 L1,Node 直跑。

// file: src/math/fixed-point.ts
// tier: L1
// verify: npx vitest run src/math/fixed-point.test.ts
//
// Q16.16 定点数:一个 int32 的低 16 位是小数、高 16 位是整数。
// 全部运算都是整数运算,同一输入在任何平台上逐位一致。

/** 小数位数。 */
export const FRAC_BITS = 16;
/** 缩放因子 2^16 = 65536,即「1.0」。 */
export const ONE = 1 << FRAC_BITS;
/** 能表示的最大值约为 32767.99998。 */
export const MAX = 0x7fffffff;

/** 浮点 -> 定点(四舍五入)。只在加载期使用。 */
export function fromFloat(x: number): number {
  return Math.round(x * ONE) | 0;
}

/** 定点 -> 浮点。只用于显示,绝不参与模拟。 */
export function toFloat(fx: number): number {
  return fx / ONE;
}

export function add(a: number, b: number): number {
  return (a + b) | 0;
}

export function sub(a: number, b: number): number {
  return (a - b) | 0;
}

/** 定点乘法:结果仍是 Q16.16。拆分高低位避免 a*b 溢出 2^53。 */
export function mul(a: number, b: number): number {
  const hi = a >> 16; // 整数部分(带符号)
  const lo = a & 0xffff; // 小数部分(0..65535)
  return (hi * b + Math.floor((lo * b) / ONE)) | 0;
}

/** 定点除法:b 为 Q16.16,结果仍是 Q16.16。 */
export function div(a: number, b: number): number {
  if (b === 0) throw new Error('fixed-point divide by zero');
  return Math.floor((a * ONE) / b) | 0;
}

mul 是这里唯一需要动脑子的函数。如果直接写 (a * b) >> 16,会有两个问题:一是 a * b 两个 32 位整数相乘可能高达 2^62,超过 JS 双精度能精确表示整数的 2^53 上限;二是 >> 会把操作数先转成 32 位,直接截断高位。所以把 a 拆成「整数部分 hi」和「小数部分 lo」:

a * b / 2^16 = (hi * 2^16 + lo) * b / 2^16 = hi * b + lo * b / 2^16

hi * b 最大约 2^46,lo * b 最大约 2^47,都在 2^53 以内,精确无虞。最后对 lo * b / 2^16 取 Math.floor——向下取整而非四舍五入,是为了让舍入方向永远一致(这是确定性的关键,10.1 说过:方向一致比方向「正确」更重要)。

测试:与 BigInt 参考实现逐位对齐

怎么知道 mul 的拆分没写错?用 BigInt 做一份「绝对精确」的参考实现,再逐组对比。BigInt 的乘法没有精度上限,正好用来当裁判。

// file: src/math/fixed-point.test.ts
// tier: L1
// verify: npx vitest run src/math/fixed-point.test.ts

import { describe, expect, it } from 'vitest';
import { ONE, add, div, fromFloat, mul, sub, toFloat } from './fixed-point.ts';

/** 用 BigInt 做参考实现:floor((a*b)/2^16)。 */
function mulRef(a: number, b: number): number {
  const r = (BigInt(a) * BigInt(b)) >> 16n;
  return Number(BigInt.asIntN(32, r));
}

describe('Q16.16 编解码', () => {
  it('1.0 编码为 65536', () => {
    expect(fromFloat(1)).toBe(ONE);
    expect(fromFloat(0.5)).toBe(32768);
    expect(fromFloat(-2.5)).toBe(-163840);
  });

  it('往返一致', () => {
    expect(toFloat(fromFloat(3.25))).toBe(3.25);
  });
});

describe('定点四则', () => {
  it('加减是整数加减', () => {
    expect(toFloat(add(fromFloat(1.5), fromFloat(2.25)))).toBe(3.75);
    expect(toFloat(sub(fromFloat(1.5), fromFloat(2.25)))).toBe(-0.75);
  });

  it('乘法与 BigInt 参考实现一致', () => {
    for (const [x, y] of [[1.5, 2.25], [-3.0, 4.5], [0.1, 0.2], [100.0, 100.0]] as const) {
      expect(mul(fromFloat(x), fromFloat(y))).toBe(mulRef(fromFloat(x), fromFloat(y)));
    }
  });

  it('1.0 * 1.0 精确等于 1.0', () => {
    expect(mul(ONE, ONE)).toBe(ONE);
  });

  it('除法可还原', () => {
    expect(div(fromFloat(1), fromFloat(4))).toBe(fromFloat(0.25));
    expect(() => div(ONE, 0)).toThrow(/divide by zero/);
  });
});

本机实测:npx vitest run src/math/fixed-point.test.ts,6 个用例全部通过。注意 fromFloat(1) 得到 65536、fromFloat(0.5) 得到 32768、fromFloat(-2.5) 得到 -163840——这三个数就是定点数「1.0 / 0.5 / -2.5」的真实编码。mul(ONE, ONE) === ONE 这一条尤其关键:1.0 * 1.0 必须精确回到 1.0,不能有一丝漂移,否则连乘法都不闭合。

实测:同一段模拟,浮点分岔、定点锁死

现在把浮点和定点放进同一段模拟里对撞。基准脚本 bench/fixed-point-sim.mjs(B 级)跑一个阻尼弹簧 p'' = -k*p,分别用双精度、单精度、Q16.16 定点迭代 10000 步。

一个刻意的设定:dt 取 1/64 而不是 1/60。因为 1/64 在二进制里精确可表示(fromFloat(1/64) 正好是 1024),这样就把「dt 本身的量化误差」这个干扰项排除掉,让三种精度的差异只来自运算过程中的舍入。

// file: bench/fixed-point-sim.mjs
// tier: B
// verify: node bench/fixed-point-sim.mjs
//
// 同一段阻尼弹簧积分(p'' = -k*p),分别用 f64 / f32 / Q16.16 定点跑 10000 步。
// DT 取 1/64——二进制可精确表示,排除「dt 量化」这个干扰项,
// 让三种精度的差异只来自运算本身的舍入。
// 定点版本跑两遍(模拟两台不同机器),结果必须逐位相同。

const f = Math.fround;
const ONE = 65536;
const fromF = (x) => Math.round(x * ONE) | 0;
const toF = (fx) => fx / ONE;
const fmul = (a, b) => (((a >> 16) * b + Math.floor(((a & 0xffff) * b) / ONE)) | 0);

const STEPS = 10000;
const DT = 1 / 64;
const K = 10;

// width = 64 用双精度,width = 32 用 Math.fround 强制单精度。
function simFloat(width) {
  const r = width === 32 ? f : (x) => x;
  let p = r(0);
  let v = r(1);
  const dt = r(DT);
  const k = r(K);
  for (let i = 0; i < STEPS; i++) {
    const a = r(-k * p);
    v = r(v + r(a * dt));
    p = r(p + r(v * dt));
  }
  return { p, v };
}

function simFixed() {
  let p = fromF(0);
  let v = fromF(1);
  const dt = fromF(DT);
  const k = fromF(K);
  for (let i = 0; i < STEPS; i++) {
    const a = (-fmul(k, p)) | 0;
    v = (v + fmul(a, dt)) | 0;
    p = (p + fmul(v, dt)) | 0;
  }
  return { p, v };
}

const a = simFloat(64);
const b = simFloat(32);
const c1 = simFixed();
const c2 = simFixed();

console.log(`f64    p=${a.p}  v=${a.v}`);
console.log(`f32    p=${b.p}  v=${b.v}`);
console.log(`|f64-f32| p=${Math.abs(a.p - b.p)}`);
console.log(`fixed  p=${toF(c1.p)}  v=${toF(c1.v)}`);
console.log(`fixed 两次逐位相同 = ${c1.p === c2.p && c1.v === c2.v}`);
console.log(`|fixed-f64| p=${Math.abs(toF(c1.p) - a.p)}`);

node bench/fixed-point-sim.mjs 本机输出如下:

f64    p=-0.25283521005441023  v=-0.620696788721661
f32    p=-0.25283491611480713  v=-0.6206955313682556
|f64-f32| p=2.939396031020891e-7
fixed  p=-0.2528839111328125  v=-0.6180572509765625
fixed 两次逐位相同 = true
|fixed-f64| p=0.000048701078402269005

读这张输出要抓住三点:

  • 单精度与双精度分岔了。 同一段源码,f32 的最终位置比 f64 差了 2.94e-7。这就是 10.1 讲的「位宽差异」——只是把积分从匀速换成了弹簧,漂移量级小了些,但方向没变。
  • 定点版本跑两遍逐位相同。 fixed 两次逐位相同 = true。因为定点的每一步都是整数运算,整数加减乘在任何平台上都是同一套规则,没有舍入方向的选择余地,也就没有平台差异。这就是「确定性」的字面含义:同样的输入,永远同样的输出,一个 bit 都不差。
  • 定点与浮点之间仍有误差。 定点比 f64 差了 4.87e-5。这很正常——Q16.16 的最小刻度是 1.5e-5,误差就在这个量级上。但关键在于:这个误差是「可复现的误差」,不是「随机漂移的误差」。 换一台机器,定点算出的还是 -0.2528839111328125;而浮点换台机器,f64 和 f32 谁对谁错都说不清。

定点换来了什么、失去了什么

定点数的账要算清楚,它不是一个纯粹的升级:

维度浮点定点 Q16.16
跨平台一致性不确定(结合律、FMA、位宽都可能不同)逐位一致
精度相对精度恒定(约 7 位 / 15 位十进制)绝对精度恒定(刻度 1.5e-5)
动态范围极大(约 1e±38 到 1e±308)窄(约 ±32767)
硬件加速SIMD 原生支持需要手写,SIMD 支持差
可读性直接写小数要经过 fromFloat / toFloat 转换

最值得注意的是**「相对精度 vs 绝对精度」这一行。浮点数无论数值大小,有效数字都是固定的 7 位(单精度);定点数无论数值大小,最小刻度都是固定的 1.5e-5。这意味着:定点数算大数时相对误差很小,算极小的数时相对误差会很大——因为刻度是绝对的。helio 的做法是把需要确定性的量控制在合理范围内**(世界坐标、速度、角度),别让它们跑到 1e-6 那种刻度以下。

所以定点数的定位很清楚:它不是「更好的浮点」,而是「一种为了可复现而放弃动态范围与硬件加速的表示」。 只有当「两台机器必须算出同样的 bit」是硬需求时,才值得付这个代价。对《微光岛》来说,这个需求出现在 18.2 的确定性 demo 里——同一颗种子、同样的输入,重放必须完全一致。

顺带回答一个常被问到的选择:为什么不用 BigInt 做定点? BigInt 的乘法确实没有精度上限,写起来也简单,但它有两个致命缺点:一是慢,BigInt 每次运算都要分配堆对象、走 GC,比原生 number 的整数运算慢一两个数量级;二是 BigInt 不能直接和 number 混合运算,BigInt(1) + 1 会抛错,边界代码会变得很啰嗦。模拟循环里每帧要跑几十万次乘法,BigInt 的分配开销会把性能吃掉——所以 helio 只在测试里用 BigInt 当参考实现(因为它足够慢、足够准),在生产代码里用拆高低位的 number 乘法。这个「测试用 BigInt、运行时用 number」的分工,是精度与性能各取所需。

还有一点容易忽略:定点数的加法不需要拆位,但必须防溢出。 add(a, b) 直接 (a + b) | 0 就够了——两个 int32 相加,| 0 会自然回绕(wrap around)。回绕在确定性上是好事(行为一致),在正确性上却是隐患:两个接近 32767 的坐标相加会变成负数,物体瞬移到地图另一端。helio 的做法是在写回位置前用 clamp(10.3)把值限制在合理范围,让溢出根本没有机会发生。

常见坑

  • 直接写 (a * b) >> 16 做乘法。 两处都错:a * b 可能超过 2^53 丢精度,>> 又先把操作数截成 32 位。必须拆高低位。
  • 用 Math.round 而不是 Math.floor 做乘法舍入。 四舍五入会让「正负抵消」的结果不对称;固定用 Math.floor 保证舍入方向一致,确定性才成立。
  • 忘了 | 0。 JS 的整数运算默认是双精度浮点,(a + b) 结果是 number;只有 | 0 才把它截回 32 位有符号整数。少了它,结果可能超出 int32 范围而不自知。
  • 把 toFloat 的结果拿去继续算。 一旦转回浮点,确定性立刻丢失。toFloat 只能用于显示或调试,绝不能回到模拟循环里。
  • 用 1/60 当 dt 还指望零误差。 1/60 在二进制里不精确,fromFloat(1/60) 会丢掉约 4.1e-6。要精度就用 1/64、1/128 这类二进制可精确表示的步长。
  • 无脑把所有数学都改成定点。 渲染、动画插值这些「每帧算完就丢、不需要跨端比对」的地方,用浮点又快又准,换成定点纯属自找麻烦。只在需要确定性的地方用定点。

下一节换一个方向:不管用浮点还是定点,数值计算里还有一类问题与「精度」无关,而与「边界」有关——比较该用多大的阈值、除法遇到零怎么办、NaN 怎么不扩散。那是 10.3 的数值稳健。

阅读导航:上一节:10.1 浮点确定性与跨端一致性 · 下一节:10.3 数值稳健:epsilon、饱和与除零 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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