为什么帧同步总是 desync:一个网页 RTS 的确定性踩坑实录

12 阅读15分钟

为什么帧同步总是 desync:一个网页 RTS 的确定性踩坑实录

我用原生 JS 写了个红警风格的网页 RTS,单文件、无框架、无构建,支持 WebSocket 联机对战和观战。整个过程里最耗时间的不是渲染、不是网络、也不是 AI,而是**"确定性"**这三个字。

这篇文章把帧同步上踩过的坑按成本从高到低排一遍,每个坑都附上实际代码。如果你正在做或打算做帧同步,希望能帮你少走几天弯路。


一、为什么选帧同步

RTS 这个品类有个特点:单位多、指令少、状态大

一局下来可能几十上百个单位,每个单位有坐标、血量、朝向、状态机、冷却……全量状态序列化一次几十 KB。但玩家的操作频率极低——点一下移动、造一个建筑,一秒钟能有两三条指令就算手速快了。

所以状态同步(广播状态)在这里非常不划算:你为了传 30 字节的"去那儿",得付出几十 KB 的带宽

帧同步反过来:只传指令,不传状态。每台客户端本地跑一份完整的模拟,输入相同 → 输出必须相同。

代价也随之而来:所有客户端的模拟结果必须逐位一致。差一点,几百 tick 之后就是雪崩。

下面全是在追这个"逐位一致"。


二、坑 1:浮点数是头号杀手

0.1 + 0.2 !== 0.3 大家都知道,但真正致命的不是这个。

致命的是:同一个浮点表达式的最后一位,在不同 CPU、不同浏览器、甚至同一引擎的不同 JIT 优化路径下,都可能不一样

单机游戏无所谓——你算出来 100.000000001,玩家看不出来。联机就是灾难:第 1000 tick 差 1 ulp,第 3000 tick 两边的坦克就在完全不同的位置,然后互相认为对方是挂。

解法:全整数定点数,模拟层里一个浮点都不留。

我用 Q16.16:

const SH=16, ONE=1<<16;
const fi=n=>(n<<SH)|0;      // int -> fx
const tf=a=>a/ONE;          // fx -> float(仅渲染/调试用)

所有实体坐标 x / y、血量、冷却都是 fx 整数。模拟层里的运算只有加减乘和整数除法。

但定点数有个反直觉的坑:开方

算距离要开方,而 Math.sqrt 返回浮点,不能用。我写了整数牛顿迭代 isqrt(),但直接开方会炸

// ❌ 这么写是错的
const d = isqrt(x*x + y*y);

算一下量级。地图 64 格 × 32 px = 2048 单位,换成 fx 是 2048 << 16 = 1.34 亿。平方之后是 1.8 × 10¹⁶

  • int32 上限是 2.1 × 10⁹ —— 早溢出了
  • double 的精确整数范围是 2⁵³ ≈ 9 × 10¹⁵ —— 也超了

也就是说 x*x + y*y 这个表达式本身就不可信,无论你用不用 |0

正确做法是先整体缩位、再开方、最后把位移补回来

// 向量长度:先整体缩位避免 x²+y² 溢出 int32/double 精确范围。
function flen(x,y){
  let ax=x<0?-x:x, ay=y<0?-y:y;
  if(ax===0)return ay; if(ay===0)return ax;
  let s=0; const m=ax>ay?ax:ay;
  while((m>>s)>=(1<<26))s++;      // 缩到 2^26 以内:平方和 < 2^53,正好卡在 double 精确范围里
  const sx=ax>>s, sy=ay>>s;
  return (isqrt(sx*sx+sy*sy)<<s)|0;
}

那个 1<<26 不是拍脑袋来的:两个小于 2²⁶ 的数平方和小于 2 × 2⁵² = 2⁵³,正好是 double 能精确表示整数的上界。再大一位就不可靠了。

代价是 flen() 返回的是近似距离,不是精确距离。这在帧同步里完全可以接受——只要两端跑的是同一个近似算法,结果就一致。但如果你拿它去 UI 上显示"距离 300 米",它可能不准。

这个坑教会我一件事:在帧同步里,确定性比精确性重要。 精度损失可以接受,不确定性不行。


三、坑 2:Math.random() 必须死

帧同步里任何一处 Math.random() 都是定时炸弹。同类还有:

  • Date.now() / performance.now()(除了主时钟节拍本身)
  • 依赖对象属性遍历顺序(不同引擎的 key 顺序不保证一致)
  • Array.prototype.sort相等元素上的顺序——规范里不保证稳定(现代引擎已经稳定了,但别赌)

我用 xorshift32:

let rngS=0x9e3779b9;
function rnd(){ let x=rngS; x^=x<<13;x|=0; x^=x>>>17; x^=x<<5;x|=0; rngS=x; return x>>>0; }

关键在于:rngS 必须进快照。

否则中途加入的玩家或断线重连的玩家,随机序列跟别人不一样,第 N 次调用 rnd() 的时候就分叉了。这类 bug 特别难查——因为它只在"中途加入"这个路径上出现,正常开局的人永远复现不了。


四、坑 3:快照必须包含"还没执行的指令"

这是我认为最隐蔽的一个坑。

帧同步的常规做法是指令延迟执行——你点一下,指令带着未来某个 tick 的编号广播出去,双方都等到那个 tick 才执行。这样即使网络抖动,指令也能"准时"生效,不会因为到达顺序不同而产生分歧。

const TICK_MS=50;      // 逻辑 20fps
const CMD_DELAY=3;     // 指令延迟 3 tick 执行

于是产生一个反直觉的后果:在 tick 100 做快照时,未来 3 个 tick 的指令还压在队列里没执行。

如果快照只序列化"当前世界状态",新加入的玩家就会丢掉这几条已广播、未执行的指令——然后从 tick 100 开始,他走的路和所有人都不一样,而且他不知道自己错了

所以:

/* 快照:序列化完整状态(含未应用的 cmdQ),用于中途加入/断线恢复。
   cmdQ 不能漏 —— 漏丢一条排队指令,对端未来就会分叉 */
function serialize(){
  const cq={};
  for(const [tk,arr] of cmdQ)
    cq[tk]=arr.map(c=>({tick:c.tick,side:c.side,k:c.k,ids:c.ids.slice(),x:c.x,y:c.y,t:c.t,seq:c.seq}));
  return { v:2, tick, nextId, seq, win, rngS, aiOn:[...], ents:[...], ore:[...], cmdQ:cq };
}

注意 deserialize() 里必须把它们按原 tick 重新塞回队列(走 pushCmd),而不是立刻执行:

function deserialize(s){
  // ...还原 tick / rngS / ents / ore
  cmdQ=new Map();
  for(const tk of Object.keys(s.cmdQ||{})){
    for(const c of s.cmdQ[tk]) pushCmd({tick:c.tick,side:c.side,k:c.k,ids:(c.ids||[]).slice(),x:c.x,y:c.y,t:c.t,seq:c.seq});
  }
  checksum=computeChecksum();
}

一句话总结:「快照」不只是"现在的状态",还包括"已经决定但还没发生的未来"。


五、坑 4:desync 检测——异步错位才是常态

最朴素的检测是"每 tick 交换校验和,不一样就是 desync"。但真实网络里两端的 tick 进度不是齐的

  • 主时钟(host)自由步进,跑得快
  • 跟随者要等主时钟的 tk 消息才步进,跑得慢

结果就是:你发 tick 100 的校验和时,对方可能刚跑到 98。如果你在收到对方校验和的当下就比较,比的是两个不同 tick 的值,必然假报 desync

我的做法是两边各存一份历史,凑齐同一个 tick 才比

let desync=false, desyncTick=-1, resyncFails=0, lastResyncTick=-99;
const chkHist=new Map(), peerChk=new Map();   // 我方 / 对端 每 tick 校验和,独立记录

function onPeerChk(n,c){ peerChk.set(n,c); checkDesync(n); }

function afterStep(){
  const st=SIM.getState();
  chkHist.set(st.tick, st.checksum);
  checkDesync(st.tick);
  if(st.tick%120===0){ /* 修剪 240 tick 之前的历史,防 Map 无限增长 */ }
  netSend({t:'chk', n:st.tick, c:st.checksum});
}

function checkDesync(n){
  if(chkHist.has(n)&&peerChk.has(n)&&chkHist.get(n)!==peerChk.get(n)){
    desync=true; desyncTick=n;
    const st=SIM.getState();
    // 只让跟随者向主时钟请求重同步(主时钟是权威源,避免互相污染)
    if(net.connected && net.live && net.pace!==net.side && resyncFails<3 && st.tick-lastResyncTick>=60){
      resyncFails++; lastResyncTick=st.tick;
      netSend({t:'reqsnap'});
    }
  }
}

这里有三个细节,每一个都是被坑出来的:

1)凑齐同一个 tick 才比。 异步错位不该算 desync,否则你会收到一堆假警报。

2)只有跟随者能请求重同步。 如果两边都发现不一致、都向对方要快照,就会互相污染,永远收敛不了。必须有且只有一个权威源。

3)限流:最多 3 次 + 60 tick 冷却。 否则一次真实的 desync 会触发无限重同步风暴——比 desync 本身还卡

校验和本身用 FNV-1a,遍历所有实体的位置/血量/状态:

function hpush(h,v){ let x=v|0; for(let i=0;i<4;i++){ h^=(x&0xff); h=Math.imul(h,0x01000193); x>>>=8; } return h; }

⚠️ 注意这里必须用 Math.imul。普通写 h * 0x01000193 会超过 2⁵³ 丢精度——又是一个浮点坑,藏在哈希函数里


六、坑 5:渲染层的迷雾 = 给 AI 开全图外挂

这个坑我觉得最有意思。

我给游戏加了红警 2 式的三态战争迷雾(未探索 / 已探索 / 当前可见)。第一版做得"很干净"——迷雾完全在渲染层,模拟层零改动

/* === FOV-START === */
// 战争迷雾:纯渲染/本地交互层,SIM 零改动。锁步要求 SIM 全知,迷雾只是视角过滤。
// FOVR 按 SIM.T_* 索引,单位:格(1 格 = TILE = 32px)。不写进 SIM.DEF(SIM 数据保持纯净)。
const FOVR=[9,10,10,10,12,16,0,12,11,12,12,11,14,16,10,12,9];

理由非常正当:锁步要求两端模拟完全一致,模拟层里不能有"只有我能看到"的信息。

但玩家马上发现:AI 能隔着迷雾打我。

因为 AI 是模拟层的一部分,而模拟层是全知的——它当然"看得见"你。你的迷雾只是不让你看它,并没有阻止它看你。

于是必须让 AI 也吃迷雾。这是这个项目里第一次动模拟层的逻辑,动了就有确定性风险。

做法是给 AI 加一张视野半径表 + 一个纯函数:

const AIFOV=[fi(288),fi(320),fi(320),fi(320),fi(384),fi(512),0,fi(384),fi(352),fi(384),fi(384),fi(352),fi(448),fi(512),fi(320),fi(384),fi(288)];
function aiCanSee(s,t){          // s 方是否有任一单位/建筑看见目标 t
  for(const r of ents){
    if(r.dead||r.side!==s) continue;
    if(flen(t.x-r.x,t.y-r.y)<=AIFOV[r.t]) return true;
  }
  return false;
}

出击逻辑从"直接打敌基地坐标"(全知)改成:

视野内有目标  → atk 最近的可见目标(集火)
视野内没目标  → move 朝敌基地方向推进(侦察)

推进过程中单位自己的自动索敌会在接近后接手,于是玩家能看到红军压到视野边界才开火,有了预警窗口。

三个值得单独说的点

1)aiCanSee 必须全程 fx 纯运算。 里面全是 flen,没有 Math.sqrt,没有浮点,两端结果逐位相同 → 锁步安全。这是它敢放进模拟层的前提。

2)AIFOV 不能复用渲染层的 FOVR

数值上两张表是一样的(都是"格数 × 32"),但渲染层的 FOV 代码块位于模拟块之后,模拟层引用它会破坏 "SIM 自包含"。所以我在模拟块里独立定义了一份

代价是:两份表要手动保持同步。改了一个忘了另一个,AI 视野和玩家视野就对不上(玩家看得见 AI、AI 看不见玩家,或者反过来)。这是个人为引入的耦合点,但比让模拟层依赖渲染层要好。

3)有个地方"不用改"反而更值得说。

AI 单位的自动索敌半径是 射程 + 40,而最小视野半径是 288 —— 索敌半径恒小于视野半径。所以自动索敌天然只会对视野内的目标开火,那段代码一行都不用动

这个不等式是数值设计出来的,不是碰巧。如果你要做类似的东西,先检查一下你的数值关系——可能整整一块逻辑就此省掉。

效果与代价(要诚实)

AI 不再凭空打你了,但局时变长。我的冒烟测试(双 AI 自动对打到底)从约 100 秒拉长到约 166 秒(3322 tick × 50ms),仍然能分出胜负,所以接受了这个代价。

顺便说一句:TICK_MS=50 意味着逻辑只有 20fps逻辑帧率和渲染帧率是两个独立的东西——画面跑 60fps,模拟只跑 20fps,中间做插值。这既是性能考量,也是确定性考量:模拟频率越高,浮点/定点误差累积的机会越多。


七、3D 渲染层的三个坑(和确定性无关,但同样费时间)

项目后期我加了 ?3d=1 的真 3D 模式(Three.js r160)。这里能放开手脚折腾,是因为渲染层不进校验和——前面"迷雾放渲染层"的同一个原则带来的红利。

坑 A:InstancedMesh.receiveShadow 在 r160 上失效

用 InstancedMesh 铺地形,开了阴影后发现地形不收影——坦克的影子浮在地面上,像隔了一层玻璃。查下来是 r160 的 InstancedMesh 对 receiveShadow 支持有问题。

解法:地形改回普通 Mesh,用 toNonIndexed() 把每个 quad 拆成 6 个独立顶点,逐格填顶点色:

/* 地形:单个 Mesh + 顶点色逐格
   (原 InstancedMesh.receiveShadow 在 three r160 失效 → 改 Mesh 才能收影)*/
const geo=new THREE.PlaneGeometry(MW*T, MH*T, MW, MH).toNonIndexed();
const pos=geo.attributes.position;
const colors=new Float32Array(pos.count*3);
const c=new THREE.Color();
for(let i=0;i<pos.count;i+=6){
  // ...算出这一格的 gx/gy,取地形色
  c.set(groundColor(gx,gy));
  for(let v=0;v<6;v++){ colors[(i+v)*3]=c.r; colors[(i+v)*3+1]=c.g; colors[(i+v)*3+2]=c.b; }
}
geo.setAttribute('color', new THREE.BufferAttribute(colors,3));
terrain=new THREE.Mesh(geo, new THREE.MeshLambertMaterial({vertexColors:true}));
terrain.receiveShadow=true;

toNonIndexed() 是关键。 索引几何里相邻格子共享顶点,你没法给同一顶点填两种颜色;拆成独立顶点之后,"逐格色块"和"能收阴影"两个需求才同时满足

坑 B:部件级实例化,drawcall 5×N → 4

每辆坦克原来是一个 Group(2 条履带 + 车身 + 炮塔 + 炮管 = 5 个 Mesh),N 辆坦克就是 5N 次 drawcall

改成部件级 InstancedMeshtrack / body / turret / barrel 各一个,每辆坦克占一个槽位,部件局部矩阵初始化时算一次:

for(const part of TANK_DEF){
  TANK_IMESH[part.i].setMatrixAt(i, _partDummy.matrix.copy(_imDummy.matrix).multiply(part.mLocal));
  TANK_IMESH[part.i].setColorAt(i, part.color3||COL);
}

drawcall 变成常数 4 次,跟坦克数量完全无关。

两个实现细节:

  • .copy().multiply() 而不是 .clone().multiply() 每帧要算 5×N 个矩阵,clone() 会分配 5N 个 Matrix4 对象,GC 压力很大。_partDummy 是复用的 scratch 对象。
  • 不用的槽位写 scale 0 隐藏,而不是改 count count 变化会导致 InstancedMesh 重建缓冲,反而更贵。

坑 C:翻 Y 坐标——三处必须同时取反

2D 画布 y 轴向下,3D 世界 y 轴向上。我的选择是不动模拟数据,在渲染时翻

位置:position.set(wx, -wy, 0)      // y 取负
朝向:rotation.z = -ang             // y 翻转后,角度也要取反
相机:up = (0, cosP, sinP)          // 俯仰角的符号

这三处必须同时改。只改一两处的话,画面会"看起来差不多但就是歪的"——最难查的一类 bug,因为你没有明确的错误信息,只有"感觉不对"。


八、一个测试方法:SAME 判定法

这个不是帧同步的坑,但我觉得值得单独讲,因为它是个方法论

无头 Chrome 里用 SwiftShader 软件渲染 WebGL 截 3D 图做验收时,我遇到一个假绿灯:截出来的图看着挺正常,但阴影根本没渲染——SwiftShader 的深度 pass 失败了,而且被静默吞掉,控制台没有任何报错。

因为"看起来对"完全靠不住,我改用一个笨办法:同 seed 跑两次,比像素

  • 开阴影 vs 关阴影 → 两张图必须不同(如果不同,说明阴影真的生效了)
  • 同参数跑两次 → 两张图必须相同(如果相同,说明没有随机噪声干扰判断)

如果"开阴影"和"关阴影"出来的图一模一样,那就说明阴影压根没渲染——无论它看起来多正常

我把这个叫 SAME 判定法用一个"已知应该产生差异"的变量,去验证你的观测手段本身是否有效。

(最后的结论是:headless SwiftShader 下阴影确实不渲染,真机 GPU 上一切正常。所以这个验收最后只能在真机上做——但至少我知道了"headless 截图通过"这件事本身不能当作证据。)


九、小结

按"踩坑成本"排序,帧同步里最贵的四件事:

  1. 确定性——浮点、随机、遍历顺序、排序稳定性,任何一处不确定,几百 tick 后就是雪崩。定点数不是性能优化,是正确性要求。
  2. 快照的完整性——尤其要注意"已经决定但还没发生"的部分,序列化时最容易漏。
  3. 权威源唯一——desync 之后必须有且只有一个权威。两边都想纠正对方,就永远收敛不了。
  4. 渲染层和模拟层的边界要画清楚——这条是收获,不是坑。

第 4 条多说一句。迷雾放渲染层、3D 放渲染层、插值放渲染层——模拟层只关心"逐位一致",渲染层可以随便折腾。这个边界一旦划清,后面加 3D 的时候我完全没担心过它会破坏联机,因为它在结构上就不可能破坏。

反过来说,唯一一次动模拟层(让 AI 吃迷雾),我就得专门跑两个模拟实例比 4000 tick 的校验和。代价是清晰的,也是值得的。


项目在这儿,浏览器打开就能玩(含联机对战和观战):

wqnlll.github.io/rts.html

同源的技术复盘我还写过一篇更偏"整体怎么做出来的",发在 dev.to 上,标题是 Five Browser Games, Zero Dependencies,感兴趣可以搜一下。

评论区欢迎聊帧同步相关的问题——这块我踩的坑应该比一般人多一些。