2.1 确定性模拟与定点数
第 1 章把联机的分层、协议与时钟打好了底。从这一章起,我们让两台设备跑同一局游戏。
站内 game/server/ 的 315 篇与书稿《游戏服务端编程实践》43 篇讲的是服务端怎么转发、裁决、匹配;本卷只讲客户端这一侧要做什么,服务端只给消息格式与接口约定。帧同步的客户端侧,第一件事就是「确定性」——同一串输入,在任何设备上跑,得到逐位相同的状态。这一节把这件事讲透,并给出可跑的定点数实现。
2.1.1 帧同步要的到底是什么
帧同步(lockstep)的模型很简单:所有客户端跑同一套模拟代码,服务端只负责把每帧的输入汇总后广播回来。
帧 N:玩家 0 按右,玩家 1 不按
↓ 服务端汇总广播
所有客户端都用「玩家0=右, 玩家1=不按」跑第 N 帧
↓
所有客户端得到同一份状态
只要每条输入都对齐到同一帧、模拟代码完全相同,各端的状态就永远一致——不需要每帧传状态,只需传输入。带宽极省,但代价是一个前提:模拟必须是确定性的。只要有一台设备算出的结果和别的不一样,哪怕只差 1,几十帧后画面就会完全分叉。
2.1.2 浮点为什么不能跨端一致
很多人以为「浮点运算不精确,所以不确定」。这半对半错,值得说清楚:
- 四则运算
+ - * /是确定的。IEEE 754 双精度规定了每个结果的舍入方式,0.1 + 0.2在任何符合标准的引擎上都得到同一个 double。 - 超越函数不确定。
Math.sin、Math.cos、Math.pow、Math.exp、Math.log、Math.atan2这类函数,ECMAScript 规范只要求「实现近似」(implementation-approximated),不要求逐位相同。V8、JavaScriptCore、微信小游戏的运行时、原生 QuickJS 各自用不同的多项式逼近或库函数,结果可能差最后几位。
// 同一段代码,不同引擎可能打印不同的最后几位
const y = Math.sin(0.123456789) * 1000;
console.log(y);
差最后几位听着无所谓,但在帧同步里会被放大:它改变了一个位置,位置改变了下一次碰撞判定,碰撞改变了速度……误差像滚雪球一样在几十帧内发散。此外还有两个更隐蔽的来源:
- 随机源不同。
Math.random()的实现每个引擎都不一样,同一句Math.random()在不同设备上返回完全不同的数。 - 遍历顺序依赖。
for...in与Object.keys的顺序依赖键的插入顺序;如果两台设备构造对象的顺序不同(比如从网络消息里解析字段的顺序不同),遍历出来就是两种顺序,累加结果自然不同。
2.1.3 定点数:把小数变成整数
要让模拟完全确定,最稳的办法是干脆不用小数:把所有参与模拟的数值表示成整数,用整数运算代替浮点运算。这就是定点数。
Q16.16 是最常用的格式:一个 32 位有符号整数,低 16 位是小数部分,高 16 位是整数部分。它的「单位」是 2^-16 = 1/65536,所以:
| 真值 | Q16.16 原始值 | 说明 |
|---|---|---|
| 1.0 | 65536 | 1 << 16 |
| 0.5 | 32768 | 65536 / 2 |
| -2.25 | -147456 | -2.25 * 65536 |
| 可表示范围 | ±32767.99998 | 高 16 位有符号 |
原始值只要落在 [-2^31, 2^31) 内就是合法的 int32。加减就是普通的整数加减,溢出用 | 0 归到 int32。
2.1.4 L1 实现:Fixed 模块
先写基础模块。它是一组纯函数,不依赖任何引擎,能在 Node 里直接跑。
// assets/scripts/net/fixed.ts
export type Fixed = number;
export const FIXED_SHIFT = 16;
export const FIXED_ONE = 1 << FIXED_SHIFT; // 65536
export function fxFromInt(n: number): Fixed {
return (n << FIXED_SHIFT) | 0;
}
export function fxFromFloat(x: number): Fixed {
return Math.round(x * FIXED_ONE) | 0;
}
export function fxToFloat(a: Fixed): number {
return a / FIXED_ONE;
}
export function fxAdd(a: Fixed, b: Fixed): Fixed {
return (a + b) | 0;
}
export function fxSub(a: Fixed, b: Fixed): Fixed {
return (a - b) | 0;
}
fxFromFloat 只在边界调用一次(把策划配的常数、或一次性的初始值换算进定点世界),进了模拟循环就不再有浮点。
2.1.5 乘法为什么要拆成两半
加减简单,乘法是坑。直觉写法 (a * b) >> 16 是错的,原因有两层:
a * b的结果是 64 位(两个 32 位相乘),而 JS 的*返回 double,超过2^53就丢低位。位置在几千米、速度在几百的原始值可达2^24,平方就是2^48——还没越界,但再乘大一点就危险。Math.imul(a, b)只给出乘积的低 32 位,再>> 16拿到的是低位的错误切片,不是我们要的高位。
正确做法是把每个操作数拆成高 16 位和低 16 位,分四块算:
export function fxMul(a: Fixed, b: Fixed): Fixed {
const aHi = a >> 16; // 有符号整数部分
const aLo = a & 0xffff; // 无符号小数部分 [0, 65535]
const bHi = b >> 16;
const bLo = b & 0xffff;
// (aHi + aLo/65536)(bHi + bLo/65536) * 65536
// = aHi*bHi*65536 + (aHi*bLo + aLo*bHi) + aLo*bLo/65536
const hi = Math.imul(aHi, bHi);
const cross = Math.imul(aHi, bLo) + Math.imul(aLo, bHi);
const low = Math.floor((aLo * bLo) / FIXED_ONE);
return (hi * FIXED_ONE + cross + low) | 0;
}
三块各自都在安全范围内:hi 最大约 2^30,hi * 65536 是 2^46(double 精确);cross 是 2^32 量级(double 精确);low 由两块无符号小数相乘再除 65536 得到,最大 65535。三者相加最大约 2^47,仍在 2^53 内,| 0 之后就是一个确定的 int32。
舍入约定:Math.floor 与 >> 16 都是向负无穷取整。乘法、除法都遵循这个约定,符号两侧行为一致,不会出现「正数截断、负数多减一」的不对称。
除法同理,先把被除数左移 16 位再整除:
export function fxDiv(a: Fixed, b: Fixed): Fixed {
if (b === 0) throw new Error('fixed divide by zero');
return Math.floor((a * FIXED_ONE) / b) | 0;
}
a * FIXED_ONE 最大 2^47,在 double 里精确;除以 b 之后取整。除零必须显式抛错——在帧同步里,一个 NaN 会污染整条状态链,必须让它尽早炸出来,而不是悄悄传播。
2.1.6 用定点跑一帧玩家模拟
有了 Fixed,写一个最小的玩家模拟:按键(bit0 表示「向右」)给一个加速度,每帧再乘阻尼。
// assets/scripts/net/determinism.ts
import { Fixed, fxAdd, fxFromFloat, fxMul } from './fixed';
export interface PlayerState {
x: Fixed; // 位置
vx: Fixed; // 速度
}
const ACCEL = fxFromFloat(0.25);
const FRICTION = fxFromFloat(0.9);
export function stepPlayer(s: PlayerState, keys: number): PlayerState {
const ax = (keys & 1) === 1 ? ACCEL : 0;
const vx = fxMul(fxAdd(s.vx, ax), FRICTION);
return { x: fxAdd(s.x, vx), vx };
}
export function simulate(keysPerFrame: number[]): PlayerState {
let s: PlayerState = { x: 0, vx: 0 };
for (const keys of keysPerFrame) s = stepPlayer(s, keys);
return s;
}
整个函数里没有一个浮点:ACCEL / FRICTION 在模块加载时一次性换算成整数,之后全是整数加减乘。
2.1.7 vitest:断言「逐位相同」
确定性测试不需要容差,直接比整数:
import { describe, expect, it } from 'vitest';
import { fxAdd, fxFromFloat, fxMul } from '../net/fixed';
import { simulate } from '../net/determinism';
describe('定点数确定性', () => {
it('同一串输入两次运行得到逐位相同的状态', () => {
const keys = [1, 1, 0, 1, 0, 0, 1, 1, 1, 0];
const a = simulate(keys);
const b = simulate(keys);
expect(a.x).toBe(b.x);
expect(a.vx).toBe(b.vx);
});
it('定点加法让 0.1 + 0.2 精确等于 0.3', () => {
expect(fxAdd(fxFromFloat(0.1), fxFromFloat(0.2))).toBe(fxFromFloat(0.3));
});
it('乘法在整数域内闭合', () => {
expect(fxMul(fxFromFloat(1.5), fxFromFloat(2.0))).toBe(fxFromFloat(3.0));
});
});
第一个断言用 toBe(严格相等)而不是 toBeCloseTo。这正是定点的意义:状态是整数,两端要么完全一样,要么就是有 bug——不需要「差不多」。第二个断言是浮点做不到的:0.1 + 0.2 === 0.3 在浮点里是 false,而在 Q16.16 里两者都归到同一个整数 19661,因此相等。
注意:定点不等于「更准」。
0.1在 Q16.16 里是6554/65536 ≈ 0.100006,精度反而比 double 差。定点买到的是确定性,不是精度。
2.1.8 确定性陷阱清单
即使全用定点,下面这些仍会破坏确定性,逐条自查:
| 陷阱 | 为什么会破坏 | 正确做法 |
|---|---|---|
Math.sin/cos/pow/exp/log | 超越函数跨引擎近似不同 | 用查表 + 定点插值,或自己写整数版 |
Math.sqrt | 规范只要求实现近似 | 写整数平方根(牛顿迭代) |
Math.random() | 各引擎随机源不同 | 用带种子的整数 LCG / xorshift |
for...in / Object.keys | 顺序依赖插入顺序 | 用固定顺序的数组遍历 |
Map / Set 迭代 | 顺序是插入序,两端可能不同 | 排序后遍历,或键用整数 |
| 未初始化字段 | undefined 参与运算产生 NaN | 状态用工厂函数显式初始化每个字段 |
NaN | 一旦出现就沿状态链传播 | 定点除零抛错;断言里查 Number.isInteger |
toFixed | 返回字符串,舍入规则与定点不同 | 用 fxToFloat + 定点舍入 |
| 时间戳 | Date.now() 两端不同 | 逻辑帧号驱动,不用墙钟 |
2.1.9 《方块冲刺》里哪些量必须定点
不是所有东西都要定点。原则是:参与模拟、且会被别的模拟读到的量必须定点;只用来画的量可以浮点。
| 量 | 是否定点 | 理由 |
|---|---|---|
| 玩家位置、速度 | 必须 | 参与碰撞与判定,会被后续帧读到 |
| 计时器、冷却帧数 | 必须 | 用整数帧计数,不用秒 |
| 道具刷新点、随机种子 | 必须 | 随机必须可复现 |
| 渲染插值位置 | 可浮点 | 只在 lateUpdate 里算,不写回状态 |
| 相机平滑、粒子 | 可浮点 | 纯表现,各端不一致也无所谓 |
| UI 数字动画 | 可浮点 | 同上 |
一句话:逻辑层全整数,表现层随便浮。第 9 章讲表现层插值时,还会回到这条边界。
2.1.10 接进 Cocos:把 Fixed 装进组件
L1 的 Fixed 模块是纯 TS,直接放进 Cocos 工程就能用。L2 侧只需一个组件把「固定步长 + 定点状态」跑起来:
import { _decorator, Component } from 'cc';
const { ccclass, property } = _decorator;
// 定点状态:Fixed 是 int32,Q16.16。这里内联一份最小实现,真实工程从 net/fixed 引入。
type Fixed = number;
const FX_ONE = 65536;
const fxToFloat = (a: Fixed): number => a / FX_ONE;
const fxAdd = (a: Fixed, b: Fixed): Fixed => (a + b) | 0;
@ccclass('DeterministicRunner')
export class DeterministicRunner extends Component {
@property
fixedStepPerSecond = 60;
private x: Fixed = 0;
private vx: Fixed = 0;
private accumulator = 0;
update(dt: number): void {
this.accumulator += dt;
const step = 1 / this.fixedStepPerSecond;
while (this.accumulator >= step) {
this.vx = fxAdd(this.vx, 16384); // 0.25/帧 的加速度
this.x = fxAdd(this.x, this.vx);
this.accumulator -= step;
}
this.node.setPosition(fxToFloat(this.x), 0);
}
}
update(dt) 的 dt 是可变的浮点,但只在累加器里用;真正的模拟步进是固定步长,状态是定点整数。注意 this.x 是逻辑状态,setPosition 收的是浮点——这一层转换(fxToFloat)是允许的,因为它只影响画面。
2.1.11 小结与下一节
这一节把帧同步的地基浇好了:同一输入 → 同一状态,靠的是定点整数运算与一份「确定性陷阱清单」。下一节解决输入从哪来、怎么对齐——第 N 帧要集齐所有人的第 N 帧输入,模拟才能往前走。
阅读导航:上一节:目录 · 下一节:2.2 输入广播与帧对齐 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。