状态同步的预表现和确认机制

C/S架构下的同步

C/S(Client-Server)架构下,服务器和客户端需要保持一致的状态,且服务器权威。客户端的指令引发服务器世界状态的变化,服务器下发的快照(SnapShot)数据改变客户端游戏世界的状态。出于游戏逻辑的需要,客户端和服务器都需要感知彼此在当前时刻出于何种状态。

如何保证一致性?

  • 精确对时
  • 服务器和客户端选择合适的频率
    • 理论上频率越快越精确,但是出于性能和带宽的考虑,选择合适的即可
    • 计算频率
    • 同步频率

同步的数据

客户端和服务器传递数据,更改状态。

  • 上行:
    • 客户端发送指令(指令的内容和粒度跟游戏业务逻辑相关)
      • Send Commond
  • 下行:
    • 服务器发消息
      • Remote procedure calls (RPC)
    • 同步数据:封装成常用变量、数组、机构体等等
      • UnityNet:SyncVar
      • Unreal:Replication

RPC在游戏逻辑中通常用作不重要的逻辑调用(经验之谈),比如开枪后服务器回调客户端播放声音、枪火特效等。
数据的同步则常用来表示逻辑状态的变更。

状态预测与确认的方法

一些术语

  • rollback:回滚。当客户端和服务器状态不一致时,客户端将当前状态回退到服务器状态
  • rollforth:‘前滚’。因为回滚所依赖的数据必定是旧的数据,所以客户端回退状态以后,根据缓存的指令再次预表现到自认为正确的状态,以期达到客户端和服务器在时空上的一致。

上行的粒度选择

发送操作级别的指令

  • 逐Tick发送
    • 采用不可靠通道
    • 采用可靠通道
  • 倾向前者,需要服务器逻辑做一定的容错。后者基本违背了使用不可靠通道的初衷。

发送状态变化级别的指令

  • 状态变更时发送
  • 必须可靠通道

对比

优点 缺点
同步操作指令 粒度细、占用更多流量
方便做状态rollforth,可跨状态
服务器要有一定的容错逻辑
客户端计算开销更大
同步状态变化 粒度粗、比前者省流量
只能做单一状态内的rollforth
客户端和服务器对齐上会有偏差
客户端计算开销小

下行的状态确认

  • 理想状态:当客户端预表现状态A->B->C,并发送相应指令后,服务器若没有发生异常,会同步A->B->C。
  • 实际状态
    • A->B->C 的迁移非常迅速(一帧内),B没必要也通常不会下发
    • 下行通常采用‘不可靠’通道,只保证最终状态的重发。即中间状态丢了不会重发

基于Pattern

  • 完备的前提:理想状态
  • 在不完备,即实际状态下,有两种方式

前向确认

  • 预表现状态存在Queue或者RingBuffer中。服务器数据下发后,预表现队列队头出列,匹配成功静默不处理,失败则清空队列,客户端回滚到服务器状态。
  • 当发生循环时,会有以下几种出错的可能性
    • 服务器丢状态,导致预表现队列清空。后续状态会连续信赖服务器而回退,如
      • 客户端 A->B->C->A->B->C,服务器下发A->C->A->B->C。则A->B->C都会多执行一次(预表现+信任服务器)
    • 服务器在快速执行的过程中省略中间过程,会导致客户端预表现状态残留
      • 通常情况下,客户端的操作不希望进行无限制预表现。所以某些关键操作会检查预表现队列,如果有残余则禁止预表现。客户端 A->B->A->B,服务器忽略中间过程,同步起始A和最终B。预表现队列残留A->B,则在服务器下一个状态下发前,客户端无法进行关键动作预表现。

后向确认

  • 预表现状态存在栈中,总是从最新状态开始匹配。适用于不存在循环的情况,万一出现循环,则最后几个状态会被服务器拉车

结论

  • 基于Pattern的状态确认只有在‘理想状态’下才能完全生效

基于Tick or Sequence

  • 客户端状态变化时记录变化的Tick,服务器返回的状态也附带Tick,直接根据Tick比对。简单直接。
  • 适用于同步指令操作的方式

混合Pattern和Tick

  • 同步状态变化的方式下,Tick有可能有偏差。结合Pattern和Tick一起比对。

参考和实践

Overwatch参考

  • [GDC2017:Overwatch Gameplay Architecture and Netcode]的Netcode部分

关于确定性

在守望先锋中,保持一致性的术语其称之为确定性(determinism)。这里的确定性和LockStep的确定性不是一个意思,后者指的是统一的输入永远得到统一的输出,主要解决的是浮点数的不确定性问题。

保持确定性的方法的本质是使用一致的逻辑(游戏逻辑、物理逻辑),在相同的时刻以相同的频率进行模拟计算,也就是其列出的几个手段:

  • synchronized clock:对时来确保客户端和服务器逻辑触发时机的一致。
  • fixed update:固定频率更新以确保客户端和服务器计算频率一致
  • quantization:设置频率来提高或者降低计算的精度。

基于Tick的确认

属于

  • ‘饥饿’:服务器没有指令

客户端

  • 同步指令,同时缓存CommandQueue作为RollForth的依据
  • 状态不一致的时候回滚,并读取CommandQueue中剩余帧的操作进行RollForth
  • 当网络状况变糟糕时,加快发送指令的频率,期待服务器减少‘饥饿’状态,尽量‘内插’

    服务器

  • 根据客户端指令更改状态。在‘饥饿’状态时候复制上一帧的指令,类似‘外插’。

实际的运用和思考

(填坑中)

声明