做过帧同步(lockstep)的都知道,这套架构最大的噩梦不是网络,是不同步。
帧同步的核心假设是:所有客户端跑同一套逻辑、喂同一批输入,那算出来的结果必然一模一样。基于这个假设,网络上只需要同步玩家的输入指令,不用同步任何游戏状态,带宽省到离谱。一场几百个单位的 RTS,每帧传的数据可能就几十字节。
但这个假设有个前提——你的逻辑真的是完全确定性的。一旦有哪怕一个单位在某一帧的血量差了 1,两台机器就分道扬镳,而且误差会像滚雪球一样越滚越大,几秒钟后画面就完全对不上了。
问题是,不同步发生的时候你往往不知道。玩家 A 看到自己赢了,玩家 B 看到自己赢了,谁也没报错,游戏也没崩。等你意识到不对劲,早就过了几百上千帧,根本没法回溯到底哪一帧开始歪的。
每帧状态哈希就是用来抓这个的。
思路:把整个世界压成一个数
原理特别朴素。每一帧逻辑跑完,把当前游戏世界的所有关键状态揉在一起,算出一个哈希值。这个哈希代表了"这一帧结束时,世界长什么样"。
然后各个客户端把自己算出来的哈希报给服务器(或者互相对比)。同一帧的哈希如果对不上,立刻就知道不同步了,而且能精确定位到是哪一帧开始出的问题。
帧 100: 客户端A → hash = 0x8F3A... 客户端B → hash = 0x8F3A... ✓ 一致 帧 101: 客户端A → hash = 0x2C71... 客户端B → hash = 0x2C71... ✓ 一致 帧 102: 客户端A → hash = 0x9B04... 客户端B → hash = 0xE55D... ✗ 从这帧开始歪了有了这个,排查范围一下子从"整局游戏"缩到"第 102 帧的那次逻辑更新",事情就好办太多了。
哈希什么,不哈希什么
这是第一个要想清楚的问题。不是把内存里所有东西都塞进哈希——那样又慢又没必要。
要哈希的:一切参与逻辑运算、会影响后续帧计算结果的状态。单位的位置、血量、朝向、当前状态机、随机数种子、寻路目标、技能冷却……凡是逻辑层的数据都得算进去。
绝对不能哈希的:任何表现层的东西。粒子特效、动画播放进度、UI、摄像机位置、音效——这些每台机器可以完全不一样,本来就不该影响逻辑。你要是不小心把摄像机坐标算进哈希了,那哈希天天对不上,等于白做。
逻辑层和表现层的严格分离,是帧同步的地基。状态哈希这件事会逼着你把这条线划得清清楚楚,某种程度上它也是个架构约束的检验工具。
怎么算
publicuintComputeFrameHash(GameWorldworld){uinthash=2166136261;// FNV-1a 初始值// 随机数种子必须算进去,它直接决定后续所有随机结果hash=Combine(hash,world.RandomSeed);// 遍历所有单位——注意顺序必须确定!foreach(varunitinworld.Units)// Units 得是有序的{hash=Combine(hash,unit.Id);hash=Combine(hash,unit.PositionX);// 定点数hash=Combine(hash,unit.PositionY);hash=Combine(hash,unit.Health);hash=Combine(hash,(uint)unit.State);// ... 其他逻辑字段}returnhash;}privateuintCombine(uinthash,uintvalue){hash^=value;hash*=16777619;// FNV-1a 质数returnhash;}用什么哈希算法其实不太讲究,FNV-1a、CRC32 这类简单快速的就够了。我们不需要密码学强度,只需要"不同的状态大概率产生不同的哈希",而且要快——毕竟每帧都要算一次,几百个单位每个好几个字段,性能不能拉胯。
三个必踩的坑
坑一:浮点数
如果你的逻辑层还在用float,那状态哈希基本没法用,因为float运算在不同 CPU、不同编译器、不同平台上结果可能有细微差别。这个差别小到肉眼看不见,但足以让哈希对不上。
这其实反过来说明了:帧同步的逻辑层根本就不该用浮点数,必须用定点数(fixed-point)。状态哈希只是把这个隐藏的问题暴露了出来。如果你哈希天天不一致,先检查是不是逻辑里混进 float 了。
坑二:遍历顺序
foreach(varunitinworld.Units)这个Units容器的遍历顺序,在所有客户端上必须完全一致。
如果你用的是Dictionary或者HashSet这种无序容器,那遍历顺序可能因为插入顺序、哈希桶分布不同而不一样。同样一批单位,A 机器先遍历 1 号 B 机器先遍历 5 号,算出来的哈希就不同——但这其实是个假的不同步,两边状态明明一样,只是遍历顺序坑了你。
所以要么用有序容器(List并保证增删顺序一致),要么遍历前先按单位 ID 排序。这个坑很隐蔽,因为它只在哈希对比时才暴露,逻辑本身可能没错。
坑三:哈希粒度
只在每帧末尾算一个总哈希,能告诉你"第 102 帧歪了",但歪在哪个系统、哪个单位、哪个字段还是不知道。
生产环境里通常做分级哈希。除了每帧一个总哈希,还可以按系统分别算(移动系统一个、战斗系统一个、AI 一个),甚至在开发调试时把每个单位每个字段的值都 dump 出来。这样一旦对不上,把两台机器第 102 帧的详细快照拉出来 diff,一眼就能看到是"7 号单位的血量 A 是 100 B 是 99",直接定位到出问题的那行逻辑。
当然这么细的粒度开销大,一般只在调试构建里开,正式版只留每帧总哈希。
什么时候算、算了怎么用
哈希要在每帧逻辑完全跑完之后算,这时候世界状态是稳定的。
对比策略有几种。最简单的是客户端定期把哈希上报给服务器,服务器发现对不上就记日志、踢人或者提示重连。也有 P2P 架构下客户端互相广播哈希对比的。带宽上完全不是负担,一个 uint 才 4 字节,就算每帧上报也没多少。
实际项目里通常不会每帧都上报,而是每隔若干帧报一次,或者本地缓存一批一起报,进一步省流量。反正不同步一旦发生就会持续存在,晚几帧发现问题不大,能定位到大致范围就够了。
说到底
每帧状态哈希本身没多少技术含量,就是遍历一遍算个数。但它是帧同步项目里性价比最高的一个基础设施——花不了多少代码,却能在不同步这个最难查的问题上,把你从"大海捞针"救到"按图索骥"。
我的建议是,帧同步项目一开始就把它加上,别等出了不同步再来补。因为它不光是排查工具,更是一道持续运行的红线检测:只要哈希开始飘,就说明你的确定性被破坏了,逼着你当场就去查,而不是等到测试后期甚至上线才发现一堆诡异的不同步。
下一篇可以聊聊定点数,帧同步确定性的另一块基石,坑同样不少。