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
- 当网络状况变糟糕时,加快发送指令的频率,期待服务器减少‘饥饿’状态,尽量‘内插’
服务器
- 根据客户端指令更改状态。在‘饥饿’状态时候复制上一帧的指令,类似‘外插’。
实际的运用和思考
(填坑中)
声明
- 本文固定链接: https://atlasz.github.io/2018/01/06/states-synchronization/
- 转载请注明: AtlasZ 2018年1月6日 于 GamePlay First 发表