什么是帧同步?
古典帧同步
古典帧同步又叫Lockstep Synchronization(锁步同步),其一大特色在于当一个人未同步完成时,其他人都必须等待直到其同步完成,然后再执行下一帧的同步。
上图有两个客户端。客户端 B 的网络比较差,A 和 B 都在 T0 时间点向服务器发送了用户输入,A 的请求在 T1 到达服务端,B 的请求在 T2 到达服务端,前面我们提到,服务器需要收集“所有用户”的请求后才开始工作,因此需要到 T2 时间点才开始生成 frame。
因为 Client B 比较慢,我们“惩罚”了所有的玩家。而且为了服务器能正常工作,即使客户端没有产生任何指令也需要往服务器发送心跳包,这造成了流量开销。锁步同步可以用在确实需要玩家等待的回合制游戏或者对延迟不敏感的慢节奏游戏。
帧同步就对应于我们在上文中说的同步用户的输入的操作、或者这些操作所产生的一系列事件,保证所有人在每一帧上都获得相同的输入,执行相同的逻辑,最后得到一致的表现和结果。之所以被称之为“帧”同步,是因为帧同步是以固定频率(比如60Hz)同步玩家的下一帧的操作的。从中我们也能看出,帧同步包含了两个同步,一个是时间同步,另一个是指令同步。
保证一致性逻辑的几种方法
- 保持客户端版本一致(相同的逻辑模块)
- 不要依赖不确定性的外部逻辑(如UI交互逻辑)
- 限制外部逻辑(如UI)对核心逻辑的调用
- 谨慎使用多线程
时间同步
多端的时间同步通常采用服务器时间为准。在客户端发送的包传入服务器的时候,我们就会请求服务器的时间并同时计算这次请求的 ping
值,也就是这个数据包从客户端到服务器然后再回来的时间。
通常情况下,我们利用时钟同步算法来对齐帧同步的时间轴。通过计算包从客户端到服务器的时间差和从服务器到客户端的时间差进行求和,我们可以得到 ping
。通过 ping
值除以 2
就可以得到端到端的时间偏移量θ
,此时再加上服务器的返回的时间,就可以得到一个相对准确的当前服务器的时间。接着,后面游戏过程中的同步,我们也可以根据更小的 ping
值来修正时间。
指令同步
在指令同步的过程中,客户端将指令发送给服务端,服务端只做指令转发的操作(指令->指令),其他客户端接收到服务端传来的指令后进行对应的计算。由于计算是在客户端本地完成的,容易造成客户端篡改计算结果实现作弊。有关防作弊的内容,我会在之后写一篇文章进行讨论。
正由于最终同步体现在各个服务端接收到指令后计算结果的一致性上,因此帧同步最核心的依赖就是“同样的程序对于同样的输入会产生完全一致的结果”。如果这一前提无法被保证,就无法达成一致性。然而由于硬件平台的不同,还真可能出现计算结果不一致的情况(例如用 ios 和 win / Android 计算 sin(15)
得到的值不同,这是因为 ios 的 FPU 位宽更大),这样的小误差累计起来就可能影响同步结果。我们需要想尽办法避免这种情况出现,例如对于浮点数计算精度的问题,可以采用更改 FPU 位宽或用定点数计算。
客户端和解
通过上图,我们也会发现帧同步中的一些问题:Client1 在发送数据到接收到 Host 传回来的同步数据之前,中间的这段时间间隙该怎么表现同步呢?这就是我在前文中提到的“客户端障眼法”了。在还未获取玩家的状态或者帧的情况下,我们不能让玩家卡在某个地方(否则会带来很强的顿挫感),Client1 可以尝试用上一次同步时获得的数据模拟其他客户端(Client2)的行为。
因此,在预表现中,存在两个重要的算法:预测算法和插值算法。
-
预测算法
根据当前的状态预测下一个状态。最经典的预测算法是“航位推测算法(Dead Reckoning)”,常用于交通技术层面,但容易收到误差积累的影响。- 规则(人为设置的规则,辅助预测)
- 无用户输入(硬直状态,根据状态机进行判断)
- 寻找积分导数(位移是速度积分算的,速度是加速度积分算的,…)
- 用户输入有限(一个用户就十个指头,手速有限)
-
插值算法
根据两个状态,平滑地进行过度。插值其实是一个受时间影响的操作序列,主要起到视觉上的平滑效果,符合游戏规则。- 使用录像数据进行与训练
- 用户个性化样本训练
除了这两种方法之外,我们还可以通过视觉掩饰(例如设置前摇与后摇,提供缓冲时间)、设置缓存队列(缺点在于同步性可能大打折扣)等方式。
但问题在于, Client1 在模拟时并不知道 Client2 可能提交了什么操作,因此它的模拟结果很可能是错的,该怎么办呢?在下一帧收到数据包时,Client1 有机会对结果进行修正,这种修正错误的方式通常叫做 Reconcilation / 和解。
如何实现客户端和解?
-
客户端需要维护 2 个缓冲区,一个是用于预测的
PredictionBuffer
,一个是用于用户输入的InputBuffer
。它们是预测这个行为所需要的上下文,想预测 Tn 时刻,需要 Tn-1 的状态和 Tn-1 时刻的用户输入。两个缓冲区一开始都为空。
-
玩家点击鼠标,移动游戏角色到下一个位置。此时,玩家输入的移动信息
Input 0
存储在InputBuffer
中,客户端将生成预测Prediction 1
,存储在PredictionBuffer
中,预测将展示在玩家画面中
-
客户端收到服务器响应的
State 0
,发现与客户端的预测不匹配,我们将Prediction 1
替换为State 0
,并使用Input 0
和State 0
重新计算,得到Prediction 2
,这个重新计算的过程,就是和解
-
和解后,从缓冲区中删除
State 0
和Input 0
这种和解的方式有一个明显的缺点,如果服务器响应的游戏状态和客户端预测差异太大,则游戏画面可能会出现错误。例如我们预测敌人在 T0 时间点向南移动,但在 T3 时间点,我们意识到它在向北移动,然后通过使用服务器的响应进行和解,敌人将从北“飞到”正确的位置。可以考虑通过实体插值的方式优化预测效果。
服务端和解
同时我们需要考虑到一个情况,就是:由于网络延迟,一个原本应该在服务器 X 帧范围内到达的数据包延迟到了 X + 1 帧才到达。在游戏中,我们自然希望用户做出指定操作后所有客户端立即有反应,这种情况该怎么解决呢?这就涉及到服务端的和解。
服务端和解部分主要维护 3 个部分,如下:
- GameStateHistory,在一定时间范围内玩家在游戏中的状态
- ProcessedUserInput,在一定时间范围内处理的用户输入的历史记录
- UnprocessedUserInput,已收到但未处理的用户输入,也是在一定的时间内
服务端和解过程如下:
- 当服务端收到来自用户的输入时,首先将其放入 UnprocessedUserInput 中
- 等待服务端开始同步帧,检查 UnprocessedUserInput 中是否存在任何早于当前帧的用户输入
- 如果没有,只需要将最新的 GameState 更新为当前用户的输入,并执行游戏逻辑,然后广播到客户端
- 如果有,则表示之前生成的某些游戏状态由于缺少部分用户输入而出错,需要和解,也就是更正。首先需要找到最早未处理的用户输入,假设它在时间 N 上,我们需要从 GameStateHistory 中获取时间 N 对应的 GameState 以及从 ProcessedUserInput 获取时间 N 上用户的输入
- 使用这 3 条数据,就可以创建一个准确的游戏状态,然后将未处理的输入 N 移动到 ProcessingUserInput,用于之后的和解
- 更新 GameStateHistory 中的游戏状态
- 重复步骤 4 ~ 6,直到从 N 的时间点到最新的游戏状态
- 服务端将最新帧广播给所有玩家
帧同步的断线重连
就像我们在第一篇文章中介绍的那样,有的时候因为突然的网络波动,客户端可能失去与服务端的连接,进入断线重连的状态。采用了帧同步技术的王者荣耀就支持多次重连尝试:
帧同步能够实现断线重连的基础在于指令队列。在帧同步中,所有的指令都不会直接作用到玩家或者玩家镜像本身,而是会先进入到一个队列里面,然后从这个队列里面去执行逻辑计算。
追帧
当遇到网络波动触发重连时,客户端可能在断线 5 秒后重新连接进入游戏。得益于指令队列的存在,客户端可以从队列中断线的那一帧一直执行到最新的一帧以获取所有玩家当前的信息。而之所以我们叫它追帧,是因为帧同步实际上是以固定帧率(例如10fps)进行同步的,现在一个新加的客户端要“追赶”上当前的同步,就需要以更高的帧率(例如60fps)加快计算到最新状态。
重进游戏
重进游戏也就是“大重连”,客户端可能在隔了很长的时间间隔后才重新加入游戏,这时帧同步会怎么处理呢?完整跑一遍所有指令吗?这样对客户端的计算量显然太大了。为了减小重连的计算开销,服务端每隔一段时间(例如 5
s)会存储 GameStateHistory 的序列化文件,这样当客户端网络出现问题重连时,可以向服务端请求最新的序列化文件,再反序列化得到最近的正确状态,最后追帧到最新状态即可。
优化:减轻服务端压力
如果战斗框架设计的合理,序列化的过程是很快的,因此我们也可以把序列化的逻辑放到客户端上执行。
- 关键帧
当客户端收到一个正确的包时,我们会去做一次序列化,这就是关键帧。- 定时帧
如果每隔10秒钟没有序列化的话,我们又可以做一次序列化,这就是定时帧。
我们最多保留3份定时帧和一份关键帧,当客户端需要回滚的时候,就从当前时间往前找到最近的一份可用的序列化数据,然后进行反序列化,拿着这个数据来恢复然后再追帧。
最后,我们会把这个序列化存到磁盘里面。当发生大重连的时候,即使内存里没有状态也可以从磁盘里面加载数据来做恢复。
但是也有一个极端情况:假设用户换了新的设备重连,新的手机内存和磁盘里没有存档,该怎么办?这时服务器也是可以给客户端现跑一份当前的状态,然后下发给客户端的。不过这种情况非常少见,因此采用这种方式整体的服务器成本可以降低非常多。
优点与缺点
优点
- 服务器逻辑简单,负载低,不需要做任何计算
- 项目研发周期缩短
- 表现一致性高(所有指令都来自服务端)
- 同步流量小,带宽成本低(一帧的输入量小)
- 天然支持观战、录像、回放(记录了所有指令,逐个执行就是回放了)
- 实时性表现好(适用于act/ftg/spg/rts/moba等)
缺点
- 反外挂问题严峻(计算都在客户端上,容易被篡改)
- 网络延迟敏感度高(容易卡顿)
- 不同步问题较难定位和解决
- 单局规模受限(否则一帧内包含的指令太多),不适合中途加入角色的游戏
- 需要在客户端处理更多的状态和逻辑,比如我们前面提到的缓冲区和预测逻辑
参考资料
https://gameinstitute.qq.com/course/detail/10242
https://gwb.tencent.com/community/detail/133968
https://www.youxituoluo.com/528021.html