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、饱和与除零 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。