页面

帧同步里的定点数:整数不是目的,统一规则才是

← 返回兔子洞总览 · simulation 主题地图

定点数不是一种能自动保证同步的魔法。它真正做的,是把连续小数压成整数刻度,并把表示、舍入和空间边界都变成可测试、可序列化、可纳入协议的规则。

我追问的链

  • 帧同步为什么怕浮点数;同样的代码难道不会算出同样的结果吗?
  • 定点数是不是把所有小数都乘以一个倍率,存成整数?
  • 倍率为什么选 1024,不选 100、1000 或越大越好?
  • 加减乘除之后,那个倍率怎样保持不变?
  • Math.trunc、Math.floor 和 Math.round 有什么本质区别?
  • 为什么乘除向 0 截断,负坐标转格子却必须向负无穷取整?
  • JavaScript 的 number 本身仍是浮点数,凭什么可以拿它保存定点整数?
  • 角色速度不能整除一格时,怎样保证最终仍落在格心?
  • 边界刚好接触算不算碰撞;这个细节为什么也会导致状态分叉?
  • 定点数与固定帧、快照、Hash 和表现插值到底怎样咬合?

1. 先纠正一个误解:问题不只是“浮点数不精确”

二进制浮点无法精确表示很多十进制小数:

1
0.1 + 0.2 !== 0.3

但这还不是帧同步问题的完整答案。同一运行环境、相同运算顺序下,简单浮点运算往往仍能重复得到相同结果。真正危险的是,游戏状态会经历成千上万次迭代,并把很小的差异送进分支条件:

1
2
3
4
这一帧是否已经越过格心?
碰撞盒是否碰到墙?
火焰是否覆盖这个格子?
角色能否在这一帧转向?

一旦两个客户端在某个边界上作出不同判断,之后执行的逻辑、随机数调用甚至实体生成顺序都可能不同。微小数值差异会被离散决策放大成整个状态历史的分叉。

定点数的目标不是证明“浮点一定不同”,而是减少隐含数值规则:不用反复累计二进制小数,也不让每个系统自行决定何时取整。

2. 定点数到底是什么

先约定一个固定倍率:

1
2
ONE = 1024
1 格 = 1024 个定点单位

真实量与存储值的关系是:

1
2
存储值 = real × ONE
真实量 = fixed / ONE

于是常用位置变成:

逻辑长度 定点值
0 格 0
1/8 格 128
1/4 格 256
1/2 格 512
1 格 1024
1.5 格 1536

格子 c 的中心位置是:

1
center(c) = c × 1024 + 512

第 0、1、2 格的中心因此分别是 512、1536、2560。权威状态里只保存这些整数,渲染时才把它换成像素坐标。

3. 为什么这里选择 1024

1024 是 2^10。对格子游戏来说,它有几个朴素好处:

  • 1/2、1/4、1/8、1/16 格都能精确表示;
  • 128、256、512、1024 这些边界容易辨认和调试;
  • 对逐格移动与矩形碰撞已经足够细;
  • 数值规模不大,给 JavaScript 安全整数留下更多余量。

倍率不是越大越好。倍率越大,最小刻度越细,但乘法的中间值也增长得越快,更容易越过安全整数范围。

倍率也不必是 2 的幂。选择 1024 是当前游戏规则的一部分,不代表应该用 JavaScript 位移运算代替乘除。一旦快照、回放或联网协议已经使用它,把 1024 改成 1000 就是模拟版本变化,而不是普通的实现重构。

4. 加减容易,乘除必须恢复一次倍率

相同单位的定点数可以直接加减:

1
2
3
1 格 + 0.5 格
= 1024 + 512
= 1536

两个定点数相乘,会多出一份倍率:

1
fixedMultiply(a, b) = trunc(a × b / 1024)

例如:

1
2
3
4
1.5 × 1.5
= trunc(1536 × 1536 / 1024)
= 2304
= 2.25 格

定点除法则要先给被除数补一份倍率:

1
fixedDivide(a, b) = trunc(a × 1024 / b)

例如:

1
2
3
4
1 / 3
= trunc(1024 × 1024 / 3072)
= 341
≈ 0.333 格

这里最容易漏掉的不是最终结果,而是中间值。a × b 或 a × 1024 如果已经失去整数精度,后面再除回来也无法恢复。因此定点实现必须检查输入、中间值和最终值,而不是只检查函数返回值。

5. 舍入不是工具细节,而是模拟规则

这个实验中的定点乘除统一使用 Math.trunc,也就是向 0 截断:

1
2
3
trunc( 341.8) =  341
trunc(-341.8) = -341
trunc( -0.5) = 0

选择 trunc、round 或 floor 没有脱离上下文的唯一答案。重要的是:所有客户端、所有相关系统和所有测试必须遵守同一个答案。

但坐标转格子不能沿用 trunc。空间被划分成半开区间:

1
2
3
第 -1 格:[-1024, 0)
第 0 格:[0, 1024)
第 1 格:[1024, 2048)

所以坐标归属应该使用:

1
cell(x) = floor(x / 1024)

关键反例是原点左侧的 x = -1:

1
2
trunc(-1 / 1024) =  0  // 错:被划进第 0 格
floor(-1 / 1024) = -1 // 对:属于第 -1 格

因此两种取整各有职责:

  • trunc 定义定点乘除丢弃余数的方式;
  • floor 定义一个坐标属于哪个空间区间。

为了代码风格统一而强行只留一种取整函数,反而会破坏规则。

6. 定点空间必须和固定逻辑帧配套

如果位置虽然存成整数,移动时却仍把渲染帧的 deltaTime 乘进权威状态,设备调度差异又回来了:

1
错误边界:position += speedPerSecond × renderDeltaTime

更稳定的权威规则是:

1
2
3
position += speedPerLogicFrame
bomb.explodeFrame = placedFrame + fuseFrames
flame.expireFrame = createdFrame + durationFrames

例如逻辑频率为 30 Hz,每帧移动 128 个定点单位:

1
2
1024 / 128 = 8 帧走一格
30 / 8 = 3.75 格/秒

无论设备每秒渲染 30、60 还是 144 帧,只要执行的是同一批逻辑帧,权威位置就相同。渲染可以在相邻逻辑状态之间插值,但插值结果只能用于显示,不能写回模拟。

1
权威定点状态 → 表现插值 → Cocos 节点坐标

这条数据流必须保持单向。

7. 速度不能整除一格,为什么也不会漂移

角色从一个格心走向下一个格心。如果每帧无条件加速度,speedPerFrame = 100 时会这样:

1
2
3
4
目标距离:1024
前 10 帧:移动 1000
第 11 帧:再移动 100
最终越过格心 76

一旦越过,position % 1024 === 512 可能再也不能在预期时刻成立,转向和碰撞都会错位。

解决办法不是要求所有速度都能整除 1024,而是让最后一步只走剩余距离:

1
actualMove = min(speedPerFrame, distanceToNextCenter)

同样使用速度 100:

1
2
3
前 10 帧:10 × 100 = 1000
第 11 帧:只移动剩余 24
最终位置:精确落在下一格心

这里真正维持的是状态不变量:

  • 静止或转向时,角色位于格心;
  • 横向移动时 y 保持在格心;
  • 纵向移动时 x 保持在格心;
  • 两个轴不会同时偏离格心。

快照恢复也应该重新验证这些不变量。字段都是整数,只能证明数据“长得像”状态,不能证明这个状态真的可能由游戏规则产生。

8. 碰撞边界也必须只有一个答案

角色用中心点和半宽表示。判断它与一个格子是否重叠时,可以使用严格不等式:

1
2
3
4
playerRight  > cellLeft
playerLeft < cellRight
playerBottom > cellTop
playerTop < cellBottom

这样“边界刚好接触”不算重叠。如果改成 >=,角色站在两个格子的分界线上时,可能同时被判定占用两格。

把碰撞盒最大边界换算成格子索引时,还有一个不起眼的 -1:

1
maxCell = floor((center + halfSize - 1) / 1024)

假设右边界正好是 2048,它只接触第 2 格的左边缘,并未进入第 2 格。计算 2047 所在的格子,才符合 [min, max) 的半开区间语义。

这类边界规则必须同时出现在移动、碰撞、快照校验与测试中。只修改其中一处,就可能出现“运行时允许、恢复时拒绝”或相反的矛盾。

9. JavaScript number 为什么还能保存定点数

TypeScript 的 number 在运行时是 IEEE-754 双精度浮点数,但它能精确表示安全整数区间内的每一个整数:

1
[-(2^53 - 1), 2^53 - 1]

所以可以用 number 保存定点整数,前提是每个权威值始终满足:

  • 是有限值,不是 NaN 或 Infinity;
  • 没有小数部分;
  • 在 Number.MIN_SAFE_INTEGER 到 Number.MAX_SAFE_INTEGER 之间;
  • 乘除的中间值也没有越界。

JSON 没有独立的整数类型。网络收到 1024 后,运行时仍把它解析成 number,所以快照和配置进入模拟前必须重新校验,不能用 TypeScript 的 as BattleState 假装外部数据已经可信。

也不要用下面这些写法“加速”定点计算:

1
2
3
x | 0
x >> 10
x << 10

JavaScript 位运算会先把值转换成 32 位整数,远早于安全整数上限就可能截断或翻转符号。PRNG 或 Hash 如果采用 32 位位运算,那是它们各自的算法定义,不能推广到坐标。

10. 快照和 Hash 为什么要直接吃原始整数

快照应该序列化权威定点值,而不是 Cocos 节点的像素坐标。回滚或中途加入时,客户端恢复的是可以继续计算的模拟状态,而不是一次显示近似值。

状态 Hash 也应按稳定字段顺序读取同一批原始整数:

1
2
3
4
5
6
7
8
9
相同初始状态
+ 相同配置与模拟版本
+ 相同逻辑帧输入
+ 相同系统与实体顺序
+ 相同定点和边界规则
↓
相同状态
↓
相同 Hash

以下内容都属于协议兼容性的一部分:

  • 定点倍率 ONE;
  • 乘除的舍入方式;
  • 坐标转格子的取整方式;
  • 格心定义;
  • 接触边界是否算重叠;
  • 每帧速度及合法范围。

修改其中任何一项,都可能让旧回放和旧快照产生不同结果。正确做法是升级模拟版本,并阻止不同版本进入同一房间。

11. 定点数解决不了什么

定点数只统一数值表示与取整边界,它不能替代完整的确定性设计。下面任何一项不同,状态仍然会分叉:

  • 一端多执行或少执行一个逻辑帧;
  • 系统、实体或集合遍历顺序不同;
  • 随机算法、种子、状态或调用次数不同;
  • 客户端使用不同配置或模拟版本;
  • 把墙钟时间、Cocos 物理结果或节点坐标写回权威状态;
  • 表现层为了好看而补出的插值、粒子或音效反过来影响逻辑。

因此正确关系是:

1
2
3
4
5
6
7
确定性模拟
├─ 固定逻辑帧
├─ 定点数值与统一边界
├─ 稳定执行顺序
├─ 确定性随机数
├─ 版本化配置与协议
└─ 快照、回放与 Hash 验证

定点数只是其中一根支柱。

12. 为什么这个炸弹人实验不选其他方案

只保存格子索引

适合棋盘式瞬移,却无法表达角色在两个格心之间的移动,也不方便验证途中碰撞和表现插值。

使用 BigInt

能扩大整数范围,但会增加旧版 Cocos Creator 目标环境、JSON 序列化和工具链的兼容成本。当前地图尺度很小,安全整数加入口与中间值检查已经够用。

引入完整定点物理库

当前规则只有格子移动、矩形边界、炸弹和火焰,没有旋转刚体、冲量、摩擦或复杂多边形碰撞。完整物理库会扩大规则与验证面积,却不能代替固定帧、稳定顺序和确定性随机数。

13. 四个最小实验

实验一:速度能整除一格

令 speedPerFrame = 128,角色应恰好用 8 个逻辑帧从一个格心到达下一个格心。

实验二:速度不能整除一格

令 speedPerFrame = 100,第 11 帧应该只移动剩余的 24。取消最后一步截断后,观察角色越过格心,以及 Hash 从哪一帧开始分叉。

实验三:负坐标归属

1
2
3
4
toCell(-1)   = -1
toCell(0) = 0
toCell(1023) = 0
toCell(1024) = 1

这个实验直接说明空间换算为什么不能使用向 0 截断。

实验四:非法快照

构造 x、y 两个轴都偏离格心,或横向移动时 y 不在格心的状态。快照解码器应该拒绝它,而不是因为字段类型都是 number 就放行。

逻辑闭环 / 锚点

这条兔子洞追到底,地基不是“整数比浮点高级”,而是:

帧同步要求所有参与者对同一状态转换作出唯一解释。定点数把连续空间变成离散刻度;舍入、边界、固定帧和版本协议再把“唯一解释”钉死。

只有表示而没有规则,整数照样会分叉;只有相同代码而没有输入、顺序和版本约束,定点数也救不了同步。

证据边界

本文中的 1024 倍率、向 0 截断、负坐标向下取整、速度 100 的格心截断和快照状态不变量,均来自一个 Cocos Creator 2.4.13 炸弹人帧同步实验的当前实现与单元测试。

这些测试能证明当前 JavaScript 测试环境满足断言,不能单独证明所有目标设备、原生构建和未来代码版本都必然一致。发布前仍应分别进行目标平台构建、相同输入回放、逐帧 Hash 对比与首个分叉字段诊断。

关联

  • 和 事件循环 vs 游戏帧循环 共同说明:渲染帧、逻辑帧和 JavaScript 任务调度不是同一层时间。
  • 和 分层与封装 是同一个母题:权威模拟与表现层各自维护清晰边界。
  • 后续可继续追问固定帧、确定性随机数、状态 Hash、预测与回滚。

来源:Cocos Creator 2.4.13 炸弹人帧同步实验与代码验证,2026-09。