写这个文章是因为最近做的项目涉及到同步的相关内容,跟大家讨论了很久,干脆总结一下。
本文不讨论为什么需要同步,不讨论怎么筛选出哪些客户端需要同步。只讨论怎么做同步。
我接触游戏同步是从局域网时代开始的,最开始了解同步是从星际争霸这款游戏开始,赞美那个 RTS 游戏的黄金时代。
在局域网内,多个玩家之间需要针对一局游戏做相同的演算,来保证游戏的最终结果是一致的,不同玩家之间需要达成一致,这个时候就需要做同步。
其中最鼎鼎有名的恐怕是 lockstep 同步方式,很多人也叫它帧同步。
客户端设定一个时间间隔作为演算周期,在这个周期内收集所有其他客户端的操作,然后进行一步演算。这样每个客户端在进行一步演算的时候输入都是相同的,都是所有玩家的这个周期内的所有操作,那么自然演算的结果就都是一致的,可能有人说随机的情况怎么办,随机很容易处理,大家在开始游戏的时候约定一个相同的随机种子就好。如果超过周期还没有收集到某个客户端的操作,一般的处理就是停下来等待,比如我们以前在玩《魔兽争霸3》时最常见的遇到某个玩家掉线或者卡了,其他人就会弹窗出来让大家等。
当然,这并不要求每个客户端的演算是同时进行的,每个客户端只要收集到了这一帧的所有玩家操作就可以进行演算,然后进入到下一个演算周期,可能有的网络差一点的玩家会晚一点才收集到所有玩家操作,更晚进入下一个演算周期,这也意味着,网络不好的玩家会导致网络更好的玩家产生延迟,因为网络更好的玩家可能早已演算完并等待他们的下一个周期操作了。在这种同步方式下,客户端可以看成一个时间不等的回合制游戏,一局比赛的回合数是确定的,所有玩家都会经过这些回合,每个回合的所有玩家操作也是确定的,所以游戏每个回合的演算结果是确定的,游戏的最终结果也是确定的。
一般来讲,客户端在单个周期内能做的操作可以认为是无穷的,这也是为什么有些玩家 APM 更高就更强的原因,因为他们在一个时间周期内能够做更多的事情,同时,在一个周期内客户端也可以什么都不做,这个时候一般会由客户端自动发出一个空操作,代表本回合我不做任何事(关于这一点的细节可以看云风的文章1),对于没有服务器或者所谓主机的情况,局域网玩家间用 P2P 的方式进行同步,在有主机或者中心服务器的时候,所有客户端把自己的操作打包发给主机或者服务器,再进行转发给其他人。
在这种同步方式下,产生的网络差的客户端会拖慢网络好的客户端的情况,前辈们也想出了解决方案,让主机或者中心服务器,按照一个固定频率去广播所有人当前帧的状态,每个客户端依然是把自己的操作发给中心服务器,区别在于网络好的客户端会以一个固定的频率收集到当前帧所有玩家操作,网络差的客户端可能很长时间收不到别人的操作导致自己等待,或者突然收到很多帧的操作导致自己必须加速播放才能追上最新帧。(可以参考 skywind 的文章2,每次看前辈2007年写的这文章就觉得前辈真是厉害)
随着时代的发展,出现了一些游戏平台,比如我以前经常玩的 vs 对战平台、浩方对战平台。最开始大家只是借助平台提供一个远程服务器做转发,帮助处在互联网的玩家能够进行联机游戏,同步的本质还是上面谈到的 lockstep 类型,只不过主机或者中心服务器被放到了远端。后来大家发现一个问题,就是在这种同步方式下,每个客户端每时每刻其实都拥有游戏的全部数据跟状态,像《魔兽争霸3》这样的游戏,战争迷雾这样的游戏机制,会导致作弊的产生,客户端只需要通过修改内存的方式就可以让本该看不见的玩家出现在视野里,于是一场跟作弊开始的斗争打响了,本文不聊作弊,所以只谈服务器的改变,随着大家对作弊的认识,渐渐发现解决这个问题的唯一办法就是设置一个权威服务器,只让权威服务器保有所有的游戏状态跟数据,客户端只做游戏的表现。比如设立《魔兽争霸3》的权威服务器,玩家依然把自己的操作发给服务器,服务器依然按照上面的步骤设定一个演算周期,在周期内收集所有玩家的操作,然后进行演算,区别在于只把演算结果发给各个客户端,客户端只是对这些结果做表现,这样客户端的状态是离散的,那么就会涉及到一些插值之类的算法来平滑表现,这里不赘述,总的思想是只有服务器知道所有的状态和数据,客户端完全依赖服务器的数据进行表现,服务器可以选择把什么数据发给客户端,什么数据不发,比如对某个客户端来说那些处于他战争迷雾之中其他玩家的状态就不发。
有了权威服务器,就不必再像传统 lockstep 那样,必须要收集到所有玩家操作再进行演算,既然所有客户端都信任服务器,那么服务器完全可以在没有收集到某个玩家操作的时候认为它这一周期空操作,甚至有玩家掉线的时候,由服务器AI来接管玩家的后续操作,保证其他没掉线玩家的游戏体验。
以上的 lockstep 同步方式也存在一定的弊端,虽然它规避了客户端作弊的可能,但在网络游戏时代,它对带宽的要求很高,每个客户端都需要等待服务器的数据来帮助他们进行游戏表现,网络稍微有波动,就必然会导致客户端的卡顿,同时对服务器的性能要求也非常高,尤其在玩家数量变多的时候,服务器的压力非常大。
比较好的一点是并非所有类型的游戏都要求游戏状态的严格同步,即使出现不同步的情况也能忍受,比如大部分 mmorpg 游戏,同时还存在一些操作反馈敏感的游戏,比如动作格斗游戏,也没有办法做到必须收集齐所有玩家操作才开始做表现。这个时候就不能完全按照以往的 lockstep 方式去做同步,为了保证客户端的流畅,且兼顾服务器的压力,通常采用的不是固定的帧率去进行演算,客户端依然是把自己的操作发给服务器,服务器不再按照一个固定周期去演算,而是收到客户端的操作就进行演算,这样压力会比同时进行大量玩家演算要小的多。同时客户端也不再按照一个固定的步进方式去表现游戏,打破了 lockstep 的模式,在展开分析前,我们先来看看来自腾讯公布的《2018腾讯移动游戏技术评审标准与实践案例》3,可以看到在国内,即使是最常用的联网方式 4G 和 WIFI ,网络延时也在 50ms 左右。
(以下图片均来自《Fast-Paced Multiplayer》4文章)
假设网络延时 50ms,客户端发送操作,等待服务器返回再进行表现,会经过一来一回的网络延时。以最基础的位移为例,客户端从 (10,10) 移动到 (11,10),经过 100ms 的等待,才能进行表现。这会导致客户端的操作手感有卡顿感,有没有办法避免呢?有。
通常来说客户端进行动画表现是有时间的,任何一个动画都需要一段时间来表现,比如玩家从 (10,10) 移动到 (11,10),假设需要 100ms 的时间来完成移动动画,那么加上与服务器通信往来的时间,客户端从发起移动请求到最终达到 (11,10) 位置的总时间就是 200ms。
回想一下我们为什么设立权威服务器,其实是为了防止客户端作弊,但实际情况是大部分客户端都是正常客户端,作弊客户端始终是少数,在大部分客户端都不会作弊的情况下, 玩家的操作都会正常进行,客户端在发送移动指令的同时开始做动画表现并没有什么不合适的地方,等到服务器的消息返回了,我们只需要检查一下客户端的状态是否跟服务器一致就好了,这样客户端就没有任何反馈延迟,同时也没办法作弊,因为服务器还是权威的,只不过客户端预先进行表现了,一般我们把这种客户端的预先表现叫做客户端的预测。
预测也有自己的问题,因为并不是所有的动画表现跟消息的来回时间都恰好一致,如果消息来回需要 250ms,动画只需要 100ms,这个情况下,假设客户端进行了 2 次移动操作,并进行了预测,效果如下图:
客户端进行完 2 次预测之后,达到了 (12,10) 的位置,花了 200ms,接着过了 50ms,收到服务器对于客户端第一次移动操作的响应,告诉客户端应该到 (11,10),于是客户端被拉回到 (11,10),又过了 100ms,收到服务器对于客户端的第二次操作的响应,告诉客户端应该到 (12,10),于是客户端又从 (11,10) 跳到 (12,10),这对客户端来说是无法接受的。
解决这种问题的关键在于客户端需要意识到,服务器返回的消息,永远是对客户端过去状态的确认,这些确认始终是滞后于客户端当前状态的。当客户端收到来自服务器的状态确认时,要明白服务器并没有处理完客户端的所有请求。
一个常见的做法就是客户端给每个请求编上号,比如,第一次请求叫 #1,第二次请求叫 #2,服务器给客户端确认操作消息也带上编号,这样当客户端在 250ms 后收到服务器对于客户端 #1 操作的确认时,不需要把客户端的位置拉回 (11,10),而是校验一下,发现跟自己对 #1 操作的预测结果一致,自己的表现没有问题,于是抛弃掉 #1 的操作缓存,然后基于服务器的结果 (11,10) 应用自己的 #2 操作,发现结果是 (12,10) 跟自己的预测也一致,证明没什么问题,于是什么也不做,等到 350ms 收到服务器对 #2 的操作确认时,校验服务器对 #2 操作的结果是 (12,10) 跟自己的预测一致,丢弃 #2 缓存,保持不变。一轮下来,客户端表现顺滑,同时保证了服务器的权威。
总的来讲,这种客户端进行预测,并缓存操作序列,收到服务器对操作的确认,再重放确认之后的操作,基本上可以应用在现代网络游戏的各个功能上,不限于移动这样的基础功能,但这并没办法解决所有问题,这种预测在单客户端状态下作用的很好,一旦跟多个客户端交互就会遇到一些难以处理的情况,比如客户端预测一个怪物应该死亡,然后表现怪物死亡,但服务器一段时间后回消息说怪物没有死,死前被别的玩家奶了一口,客户端又得把已经表现死掉的怪物恢复活过来,这在客户端看来是一个难以接受的情况。所以一般对这些关键的数据或者表现,客户端会采取一种怀柔政策,比如客户端不会决定怪物死亡,必须等服务器通知怪物死亡才进行死亡表现,即使客户端自己预测怪物已经血量到 0 以下了,这样的情况非常多,我在开发我们的游戏的时候就遇到很多这种需要客户端特殊处理的例子。不过大体上客户端做预测的效果是非常好的,能让自己的手感达到一个不错的地步。
以上是对自己的处理,对于其他客户端的操作如何处理呢?
一般来说,服务器为了减小自己的同步压力,并不会针对某个客户端的操作立马进行广播,而是以一个自己的频率来进行广播,这会带来一个问题,客户端收到其他客户端的数据是离散的状态,如果不做任何处理,会导致其他玩家的状态是跳变的,如下图,客户端2 看到客户端1 的状态就是跳变的,从 (10,10) 突然变成 (11,10) 接着突然变到 (12,10)。
上面的问题在部分游戏机制上可以通过对其他客户端进行预测解决,客户端2 可以对客户端1 同样进行一个预测,有的文章管这种对别的客户端的预测叫航测,服务器在广播消息的时候不仅仅告诉所有人当前的位置,也告诉他们各自的速度、方向、加速度等,这样客户端2 收到客户端1 在 (11,10) 的运动状态后,可以对其进行跟对自己一样的预测,知道客户端1 接下来 100ms 会到 (12,10),所以可以立马开始表现移动动画,这样当收到服务器的第二个广播消息的时候就不需要做任何跳变。
但在某些类型的游戏中,很难做这种对其他客户端的预测,比如 3D 射击游戏,玩家通常以非常快的速度移动,同时非常迅速的转弯、停止、趴下。
之前我跟在刺激战场的朋友讨论过这个问题,他告诉了我一种叫作确定性状态同步的做法。
具体说来,对每个客户端来说,让其表现的其他客户端总是落后于自己一段时间,比如服务器按照 100ms 的时间间隔去做同步,那么每个客户端对于其他客户端进行表现总滞后 100ms,由于每个客户端都知道前 100ms 其他客户端做了什么操作,所以客户端对其他客户端的表现永远是经过服务器确定过的,这种做法不再是上面提到的,对其他客户端进行预测,其他客户端的表现都是经过确定的,准确无误的。同时为了便于客户端表现其他客户端在滞后间隔具体发生了什么,需要服务器对客户端的状态进行一个更加细致的采样缓存来帮助客户端完成表现,比如按照 10ms 进行一次状态采样,在广播的时候把采样数据也带给客户端,这样客户端在表现上就可以做的更好。
很多人可能已经想到了,如果客户端看到的其他客户端状态是 100ms 以前的,那么必然也会带来一些问题,比如 FPS 游戏里,如果我看到的玩家是 100ms 以前的,那么我开枪瞄准的位置必然是不准确的,这种情况怎么处理呢?
目前看到的游戏服务器,最常见的处理方法是保证射击人的体验优先,因为射击人跟被射击人之间你总要得罪一个,客户端在发送射击请求的同时带上自己世界当时的状态,自己看到目标的状态,服务器收到这些信息后,重建客户端当时的状态,并判断是否存在问题,如果校验通过则认为客户端击中目标,修改被射击人的状态。举个例子,你在玩 FPS 游戏,正在快速通过一片草坪躲进一个房间,可能在进入房间几十毫秒后,突然就倒地生亡了,因为另一个玩家在 100ms 之前对你的头部开枪了,直到这个时候你才收到被服务器确认并校验通过的结果。虽然这种情况发生的很少,但是确实存在这种可能,这里也给作弊也留下了空间。对于 FPS 游戏来说,服务器收到射击包重建玩家世界时的精细程度决定了游戏的作弊可能,服务器为了减小压力,在做命中判断时通常会进行模型简化,老牌 FPS 游戏,例如 CSGO,在命中判断中会考虑目标当时的动作模型,做的粗糙一点的游戏,目标可能就只是一个胶囊体。之前在网上还看到有玩家在射击前一刻断开网络,然后仔细瞄准后再射击,接着重连网络把射击包发出去这么骚的作弊操作,当然,这些都是有解决办法的。
最后我们游戏采用的同步方式,属于上述多种方式的一个杂合。以位置同步做了个例子,大致说明了基本的同步原理,做个记录。