漫话帧同步


前言

  帧同步作为网络游戏经常使用的一种同步方案,网络上介绍帧同步原理和具体实现的技术文章俯拾皆是。因此,本文也不再班门弄斧,而只是将一些写得很好的文章加以归纳整理,并结合自己在实际应用中对帧同步的一些理解对一些观点进行补充,以期达到让没接触过帧同步的人在看完本文章之后能快速了解帧同步的相关知识。


网络游戏究竟在同步什么

  对于一个电子游戏来说,其最重要的要素可以粗略地分为三个部分:玩家输入游戏逻辑画面反馈。当玩家通过UI交互或者其他方式产生游戏输入的时候,游戏逻辑会执行相应的运算产生游戏结果,然后再交给游戏引擎的渲染模块渲染成相应的画面,给予玩家反馈。如果是网络游戏,那么玩家除了希望看到自己输入产生的画面反馈外,他必定也想看到其他玩家的输入产生的画面反馈。
  因此,不难知道,网络游戏除了需要同步游戏结果(数据同步),还需要同步游戏画面(表现同步)。数据同步是服务器端的操作,而表现同步就是让客户端服务器端同步过来的数据进行进一步的处理从而达到游戏画面上的一致。


状态同步和帧同步

  文章细谈网络同步在游戏历史中的发展变化(上)详细地谈到了网络游戏的发展历程。从把每个玩家把自己操作数据同步给其他玩家再分别运算出游戏结果的P2P架构,再到采用专有服务器来收集、处理、转发玩家请求的CS架构,后面很自然地就出现了以某个玩家作为Host主机的CS架构,这样每个玩家都可以作为服务器,不需要维护专门的服务器,节省服务器的运行和开发成本,这种方式被其为Packet Server。不过,虽说叫CS架构,但这种架构本质上还是P2P模型,依旧存在P2P模型的缺点(Host主机如果网络不好会影响到所有玩家;所有的逻辑都在Host主机上执行,存在Host主机作弊对其他玩家不公平的风险)

  在网络游戏刚出现的时候,大部分网络游戏都属于弱交互游戏(对操作的实时性要求不高), 可以将它们简单理解为一种回合制游戏:在每个回合开始时,所有玩家一同思考并把相关操作指令信息发送给其他玩家,其他玩家收到了别人的消息后就会在本地处理然后结束当前回合,如果没有收到就会进入无限期的等待。由于每个回合有比较长的思考和操作时间,所以网络延迟可以忽略不计,只要保证在回合结束的时候,所有玩家的状态和数据保持一致即可。这种游戏采用的同步方式是一种很自然也很简单的同步模型,但随着游戏的种类和玩法复杂性的提升,其面对的问题也接踵而来:

  节选至细谈网络同步在游戏历史中的发展变化(上):

  1. 在CS架构下逻辑在客户端执行还是在服务器执行?如果逻辑都在服务器执行,那么客户端的操作都会被发送到服务器运算,服务器计算出结果后通知客户端,客户端拿到结果后再做表现,这样的好处是所有的逻辑由服务器处理和验证,客户端无法作弊,但坏处是会造成客户端的资源被浪费,服务器运算压力过大。如果逻辑在各个客户端执行,那么玩家可以在本地计算后再把本地得到的结果告知服务器,服务器只进行简单的转发,这样的好处是玩家的本地表现很流畅,但坏处是很容易在本地进行作弊。而对于P2P架构,反作弊更是一个严重的问题,我连一个权威服务器都没有,根本无法验证其他客户端消息的真伪,怎么知道其他玩家有没有作弊?
  2. 要发送什么数据来进行同步?如果发送每个对象当前的状态,那么如果一个游戏里面有大量的角色,就会大规模的占用网络带宽,造成数据拥塞、丢包等等问题。如果发送玩家指令,那这个指令是要服务器执行还是服务器转发?而且对于大型多人在线游戏又没必要处理所有不相关的玩家信息,同样浪费网络资源。
  3. 选择哪种计算机网络协议来进行同步?TCP、UDP还是Http?

  对于不同的游戏类型,在考虑上述的问题时很自然地会有不同的解决方案。对于某些不同的解决方案来说,它们可能会存在一些共性(因为本来就是解决同一类问题),为了方便在开发新的网络游戏时快速地选定合适的游戏同步方案,许多开发团队不约而同地尝试对不同类型的同步方案做广义上的区分,因此便有了帧同步状态同步这样的说法,用于描述侧重点不同适合不同游戏类型的两大类同步方案。

  当然,也正因为状态同步帧同步只是两个广义上的分类,所以在为某个具体的游戏来选择同步方案的时候,需要开发团队做一些更加细节、更加具体的抉择和优化

  一般来讲,状态同步泛指允许各个客户端的外在表现不同,只确保它们内部的逻辑状态统一弱同步方案,典型的状态同步方案便是客户端将玩家操作或者局部状态交给服务器,由服务器来运算出游戏内的全局状态,最后由服务器把游戏内的全局状态分发给所有客户端,让客户端根据这些状态渲染出对应的画面。

  与之相对的,帧同步则泛指保证各个客户端在每个逻辑帧输入一致,并得到相同的结果强同步方案,典型的帧同步方案便是客户端将玩家操作交给服务器,而服务器只负责将在单个逻辑帧内收集到的所有客户端的玩家操作转发给所有客户端,由客户端自己运算出游戏内的全局状态,再渲染出对应的画面。

  文章关于“帧同步”说法的历史由来中提到了一个很有趣的说法:因为帧同步强调的是每个逻辑帧运算结束后都得到相同的结果,所以帧同步应该叫帧间同步


帧同步和Lockstep

  一个常见的误解是,许多人会将Lockstep翻译成帧同步,其实这并不准确。严格来说,Lockstep应该翻译成锁定步进算法。Lockstep这个术语由军事语境引入,用来表示队伍中的所有人都执行一致的动作步伐向前行军。

  最早的Lockstep算法被称为确定性锁定步进算法(Deterministic Lockstep),它里面的帧其实是一个虚拟概念,将其称为回合(Turn)或许会更加直观。在每个Turn里,只有当服务器收集到了所有玩家的输入之后,服务器才会将所有输入转发给每个玩家进行计算,然后进入下一个Turn。只有通过这种方式确保每个玩家推进Turn的速度一致,才能保证每个玩家的帧一致性。

Deterministic Lockstep
  从上图可以明显看到,在第二个Turn的时候,因为Player1有延迟,而导致Player2也停下来等待,从而延迟了往下推进下一个Turn的时间。

  显而易见,这种同步算法最大的缺点就是,当一个玩家网络很差的时候,其他玩家也将陷入无尽的等待中。因此,后期为了解决这个问题,有人提出了Bucket同步算法(Bucket Synchronization)。Bucket同步算法是一个Lockstep的改良算法,服务器会把时间按固定时长划分为多个Bucket,在每个Bucket时间节点,服务器会将收集到的所有指令同步给所有玩家,而不需要严格等待收集齐所有玩家的命令再处理,因此网络好的玩家也就不会受到网络差的玩家限制。Bucket同步算法不严格要求每个玩家以同样的进度推进游戏,也就是常说的乐观帧锁定算法。

Bucket Synchronizationp
  从上图可以知道,网络较好的PlayerA并不需要停下来等待PlayerB。

  虽然Bucket Synchronization解决了Deterministic Lockstep的致命性缺陷,但它并不能解决显而易见的作弊问题。比如有种被称为Lookahead Cheats作弊手段,玩家可以使用工具,每次都将自己的操作信息推迟发送,等到看到了别人的决策后再决定执行什么,或者假装网络信号不好丢弃第K步的操作,第K+1步再发送。为了对抗lookahead cheat类型的作弊手段,有人提出了锁定步进协议(Lockstep Protocol)

Lockstep Protocol的基本步骤:

  1. 先针对要发送的明文信息进行加密,生成预提交哈希值并发送给其他客户端;
  2. 待本地客户端接收到所有其他客户端的第K步预提交哈希值之后,再发送自己第K步的明文信息
  3. 收到所有其他客户端的第K步明文信息后,本地客户端会为所有明文信息逐个生成明文哈希值并和预提交哈希值对比;
  4. 如果发现有客户端的明文哈希值预提交哈希值不同,则可以判定该客户端是外挂,若没有发现异常,则游戏正常向前推进;
    Lockstep Protocol

  因为Lockstep Protocol是通过对玩家的操作进行二次校验的方式来对抗外挂,所以会浪费大量网络带宽,且网络条件好的客户端会时刻受到网络差的客户端的影响。因此,后来有人对Bucket synchronization、Lockstep protocol等方法进一步分析并针对存在的缺点进行优化,并提出了Pipelined Lockstep Protocol。Pipelined Lockstep Protocol的核心思路是,当前玩家的指令行为不与其他人产生冲突,就可以连续发送的自己的指令而不需要等待其他人的指令。当然,为了防止Lookahead Cheats外挂同样需要提前发送Hash。

Pipelined Lockstep Protocol
  如果假设玩家接下来的三个操作都必然不会和其他玩家产生冲突,那么可以看到Pipelined Lockstep Protocol允许客户端在没收到其他玩家的预提交哈希时,连续发送自己后面三个指令的预提交哈希值,而并不需要去等待网络较差的玩家。

  自Pipelined Lockstep Protocol出现之后,操作同步不等待超时玩家的特性逐渐成为帧同步的标准,被广泛应用于使用帧同步开发的网络中。但值得一提的是,现在大部分使用帧同步的网络游戏并非用的Pipelined Lockstep Protocol,而是在Bucket Synchronization的基础上,让每个客户端在相同的时机将本地的关键数据计算成Hash,然后上报服务器,由服务器判断是否有人作弊。网上大部分讲帧同步实现细节的文章,也基本是按照Bucket Synchronization的基本原理来实现的。


帧同步的技术要点

  如果充分理解了状态同步和帧同步的定义,那么就不难知道状态同步和帧同步的最大区别在于:状态同步的游戏逻辑在服务端,帧同步的游戏逻辑在客户端。游戏逻辑就是所谓的GamePlay,它包括了实体相关的逻辑(移动、碰撞、攻击、AI和属性等),还包括了具体的玩法逻辑(如胜利失败的条件和关卡流程等)。

  这就回答了前面所提到的、网络游戏面临的问题里的其中两个:在CS架构下逻辑在客户端执行还是在服务器执行要发送什么数据来进行同步。状态同步给出的答案是:在服务器执行,服务器下发游戏中每个实体当前的状态进行同步。而帧同步给出的答案则是:在客户端执行,服务器转发每个玩家的指令(或者说输入)进行同步。

  对于状态同步来说,因为战斗逻辑写在服务端,那么服务器只需要将计算好的游戏结果以一定频率下发给客户端,让客户端渲染出具体的画面即可。在这个过程中,客户端收到什么数据就负责渲染什么画面,并不需要去关心不同的客户端之间的表现是否一致(此时的客户端,更像是服务器的一个表现层)。因为对于服务器来说,不管所有客户端的表现是否一致,本场游戏的结果都是唯一确定的,那就是服务器算出来的结果。因此,状态同步泛指允许各个客户端的外在表现不同,只确保它们内部的逻辑状态统一弱同步方案

状态同步图解

  而对于帧同步来说,由于游戏逻辑写在客户端,服务器只负责转发所有玩家的操作,所以客户端需要时刻关心不同客户端之间的计算出来的游戏结果是否一致。这也是帧同步泛指保证各个客户端在每个逻辑帧输入一致,并得到相同的结果强同步方案的原因。为了确保这一点,帧同步需要做大量的工作,如:

  1. 使用定点数(Fixed Point)来表示浮点数,并进行一些浮点数运算,以规避不同设备、不同平台上的浮点数存储精度不同导致的游戏结果不同问题;
  2. 使用确定性的随机数算法确定性的容器及算法(排序、增加、移除和遍历)
  3. 使用可靠的网络传输确保游戏过程中,所有客户端收到的输入完全一样
  4. 对游戏逻辑进行严格分层从而确保不同客户端的UI交互等本地逻辑不会影响到游戏结果,导致与其他客户端的游戏结果不一致;
  5. 在结束一局游戏时,严格清理所有相的数据,确保不会影响下一局游戏的初始数据;
  6. 间隔一段时间就使用游戏中的关键数据来计算Hash,从而判断客户端之间的游戏结果是否出现了不一致;

帧同步图解

补充说明:

  1. 很多人会将帧同步中分出来的层称为表现层逻辑层,其中表现层则是渲染画面和处理表现相关的逻辑部分,而逻辑层就是用来计算游戏逻辑的部分。在我个人看来,表现层逻辑层这个叫法并不准确,将其叫做客户端层服务器层则更能让人清楚每一层应该拥有哪些逻辑。

    • 一个常见的错误是直接在表现层中处理游戏逻辑:例如点击UI执行一些影响游戏结果的操作:点击UI这一逻辑,显然应该被归到表现层,但很多人会直接在UI的点击回调方法里直接去调用逻辑层的接口去执行影响游戏结果的操作。如果将逻辑层命名为服务器层,将表现层命名为客户端层,那么显然所有人都会意识到,直接在客户端上操作服务器的逻辑是非常不科学的行为;
    • 另外一个常见的分层错误是将一些表现层的逻辑放进逻辑层里:例如在攻击时产生攻击特效,攻击这一操作显然是需要放进逻辑层里的,但很多时候为了方便,会有人将产生攻击特效这一操作也放在攻击的逻辑里。如果将逻辑层命名为服务器层,那么显然所有人都知道,在服务器上创建一个特效是极其不合理的行为;
    • 如果将逻辑层命名为服务器层,将表现层命名为客户端层,那么显而易见的是,表现层直接从逻辑层取用数据是非常合理的,这个过程就类似于客户端向服务器请求数据,只是服务器位于本地,并不需要网络传输;
  2. 帧同步中使用游戏关键数据计算Hash的方法,直接判断出客户端是否发生了不同步。如果需要判断是哪个客户端作弊,还需要引入第三方判断

    • 如果是超过两个玩家的多人游戏,可以使用客户端自验证的方式,只要有一个玩家和其他玩家的Hash都不一致,就可以判断是该客户端作弊。
    • 对于只有两个玩家的多人游戏来说,最常用的第三方判断方法则是服务器验证,也就是把前面提到的逻辑层(或者更准确的叫服务器层)放到服务器(也就是所谓的校验服)上计算出权威的Hash,只要有玩家的Hash与之不同,就可以判断是该玩家作弊。

帧同步的手感优化

  手感是影响玩家游戏体验的关键因素之一,但手感是一个极其模糊的概念,可能在不同的游戏里会有不同的具体要求。从程序的角度出发,我认为手感应该指的是能否在玩家执行相应的操作之后给予玩家预期的反馈,例如当玩家按下闪避键的时候,玩家希望能立马看到游戏里的角色开始切换到闪避的动作,并在玩家预计的时间内流畅地闪避至预期的位置。如果是单机游戏,在不考虑性能影响的前提下,对玩家操作的响应基本没有任何延迟。但对于网络游戏来说,网络同步过程中的网络延迟和网络波动,会极大地影响游戏响应玩家操作的实时性和流畅性,进而影响游戏的手感。

  为了降低网络延迟,需要选用延迟比较低的网络协议。TCP(Transmission Control Protocol)是最常用的网络传输协议,它提供了可靠传输流量控制拥塞控制等特性,使开发者无需担心数据丢失和重传等细节问题。但在要求及时响应的网络游戏中,TCP为了提供这些特性而带来的延迟会极大地影响玩家的体验,毕竟TCP协议设计之初就不是为了及时响应的。也正因如此,帧同步通常会使用延迟更低的UDP(User Datagram Protocol)。但因为UDP只会尽最大能力交付,并不保证数据的可靠传输,所以采用UDP来作为开发网络游戏的网络协议时,需要开发者自己实现可靠UDP

可靠UDP一般分为两种:

  1. 基于可靠传输的UDP(Reliable UDP),指在UDP上加一层封装,在传输层实现重传等类似TCP的特性,保证上层逻辑在处理数据包的时候,不需要考虑数据丢失和重传等细节,如Enet,KCP等;
  2. 冗余信息的UDP,指的是直接使用原始的UDP,然后在上层逻辑处理数据丢包、乱序和重传等问题,常见的实现方式就是在数据里插入确认信息,一旦客户端没收到服务端已确认其发送的数据,就会一直重传直到服务端确认为止。文章动作手游实时PVP帧同步方案(客户端)详细地介绍了如何实现冗余信息的UDP

  除了网络协议的对比,文章细谈网络同步在游戏历史中的发展变化(下)还提到了许多优化的要点,其中一个最重要的点就是帧率稳定。为了对抗网络波动的影响,让游戏的渲染帧率保持稳定,传统的帧同步框架加入了缓冲的概念,也就是在接收到服务器同步过来的数据之后,并不立即执行,而是延后几帧再执行。但显而易见的是,使用缓冲会极大地降低游戏响应玩家操作的实时性,所以大部分帧同步游戏(如《王者荣耀》和《火影忍者手游》)不采用缓冲策略,而是通过加入预表现来减轻网络波动所造成的帧率不稳。

  1. 缓冲策略类似于网络播放器的提前缓存,确保在网络较差时,用户仍能获得一个比较良好、流畅的观看体验;

  2. 对于帧同步游戏来讲,预表现有两种实现方式:

    1. 在逻辑层的预测:在没有收到服务器的数据帧的情况下,直接在逻辑层根据预测的输入往前推进,然后在预测失败的时候将逻辑层的状态回退到开始预测时的状态。这种方式的难点在于,一旦开始预测,就需要保存逻辑层的关键数据生成快照,并需要确保回退时能正确重置逻辑层的状态,否则将出现不同步的问题;
    2. 在表现层的预测:在没有收到服务器的数据帧的情况下,不推进逻辑层,而是在表现层根据预测的输入去模拟逻辑层的运算来得到预测的结果,一旦预测失败,只需要使用逻辑层的数据来重置表现层的状态即可。这种方式的难点在于,需要在表现层实现一套和逻辑层几乎一样的运算逻辑,或者在不影响逻辑层状态的前提下调用逻辑层的运算接口,并需要精确地管理什么时候使用表现层预测的数据、什么时候使用逻辑层的真实数据。因此,一般情况下,大多数游戏只会在RunIdle动作实现预表现;

帧同步的优缺点

  帧同步的同步方式是通过服务器收集、转发所有客户端的操作来确保各个客户端的游戏状态一致,当玩家数量增多书,服务器需要收集和转发的数据量也会大大增加,且无法像状态同步一样只同步玩家周围的玩家的数据。因此,帧同步并不适合有许多玩家的游戏。

  但帧同步的优势则在于,所有的游戏结果都是客户端根据服务器转发的玩家输入计算得出,因此,客户端可以任意地获取当前的游戏状态,只需要调用逻辑层(或者说服务器层)的接口获取数据即可,而不需要设计复杂的网络协议去同步数据。对于那些角色状态极为复杂的游戏(如动作游戏,有复杂的buff和攻击、受击状态),用帧同步则极为合适。

  当然,帧同步适合什么游戏、不适合什么游戏,只是出于性能和开发效率的考虑之后得出来的一种经验性总结。如果不考虑性能和开发效率,理论上任何游戏都能采用帧同步实现。


拓展阅读


文章作者: RainbowCyan
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 RainbowCyan !
 上一篇
《C++回顾笔记》取模运算与取余运算 《C++回顾笔记》取模运算与取余运算
  对于整型数a,b来说,取模运算和取余运算的计算方法是一样的: 求整数商:c = a/b; 取余和取模:r = a - c * b;   取模运算和取余运算的区别在于第一步对计算结果舍入的方式不
下一篇 
《C++回顾笔记》进程的内存空间分配 《C++回顾笔记》进程的内存空间分配
  现代操作系统会为每个进程都分配一个虚拟内存地址空间,虚拟内存技术使得每个进程都可以独占整个内存空间,地址从零开始,直到内存上限。进程会把整个地址空间(从低地址到高地址)分为不同的区间用于不同的用途:  &e
  目录