《Cocos 游戏联机实战》2.1 确定性模拟与定点数

帧同步的前提是同一输入必须得到逐位相同的状态。本节解释浮点为何跨端不一致(超越函数实现各异、随机源与遍历顺序不同),给出 Q16.16 定点数的完整纯 TS 实现与 vitest 断言,并列出确定性陷阱清单,最后说明《方块冲刺》里哪些量必须定点、哪些可留浮点。

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.0655361 << 16
0.53276865536 / 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 是错的,原因有两层:

  1. a * b 的结果是 64 位(两个 32 位相乘),而 JS 的 * 返回 double,超过 2^53 就丢低位。位置在几千米、速度在几百的原始值可达 2^24,平方就是 2^48——还没越界,但再乘大一点就危险。
  2. 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 输入广播与帧对齐 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡
  2. 《Cocos 游戏联机实战》9.2 网络状态 UI 与提示
  3. 《Cocos 游戏联机实战》9.1 同步表现与插值动画