帧同步不是单指某个具体算法,而是泛指“保证每帧(逻辑帧)输入一致”的一系列算法
Doom采用P2P架构,每个客户端本地运行着一个独立的系统,该系统每0.02秒钟对玩家的动作 (鼠标操作和键盘操作,包括前后移动、使用道具、开火等) 采样一次得到一个 tick command 并发送给其他所有玩家,每个玩家都缓存来自其他所有玩家的 tick commands,当某个玩家收到所有其他玩家的 tick commands 后,他的本地游戏状态会推进到下一帧。在这里, tick command的采集与游戏的推进是相互独立的。
Bucket Synchronization把时间按固定时长划分为多个Bucket,所有的指令都在Bucket里面执行。考虑到网络延迟的情况,每个玩家在本地的命令不会立刻执行而是会推迟一个时延(该时延的长度约等于网络延迟),用来等待其他玩家的Bucket的到来。如果超过延迟没有到达,既可以选择放弃处理,也可以保存起来用于外插值(Extrapolation)或者使用前面的指令重新播放。在这种方式下,每个玩家不需要按照Lockstep的方式严格等待其他玩家的命令在处理,可以根据网络情况顺延到后面的bucket再执行。Bucket Synchronization可以认为是我们常说的“乐观帧锁定”
(1)Deterministic Lockstep存在的问题:
1)优点:简单
2)缺点:浮点数跨平台的同步问题、玩家数量增长带来的带宽问题以及显而易见的作弊问题(在P2P架构下几乎没有任何反作弊能力)
(2)锁步同步协议 Lockstep protocol对lookahead cheat类型的外挂针对
1)lookahead cheats定义:比如客户端A使用了外挂工具,每次都将自己的操作信息推迟发送,等到看到了别人的决策后再决定执行什么(或者假装网络信号不好丢弃第K步的操作,第K+1步再发送)
2)来源:在2001年,Nathaniel Baughman和Brian Neil Levine在IEEE上发表了论文,提出锁步同步协议 Lockstep protocol [8]来对抗lookahead cheat类型的外挂
①先针对要发送的明文信息进行加密,生成“预提交单向哈希(secure one-way commitment hash)”并发送给其他客户端。
②待本地客户端接收到所有其他客户端的第K步预提交哈希值之后,再发送自己第K+1步的明文信息
③等到收到所有其他客户端的第K步明文信息后,本地客户端会为所有明文信息逐个生成明文哈希并和预提交的哈希值对比,如果发现XXX客户端的明文哈希值和预提交哈希值不相等,则可以判定该客户端是外挂。反之,游戏正常向前推进。
虽然可以对抗外挂,但是很明显带来了带宽以及性能的浪费,而且网络条件好的客户端会时刻受到网络差的客户端的影响。
大体的思路是利用玩家角色的SOI(Spheres of Influence,和AOI概念差不多),两个玩家如果相距很远互不影响,就采用本地时钟向前推进(非Lockstep方式同步),如果互相靠近并可能影响到对方就变回到严格的LockStep同步,这里并不保证他们的帧序列是完全一致的。
(同样需要提前发送hash)
前面提到的Pipelined Lockstep protocol可以流畅的处理玩家互相不影响的情况,但是却没有很好的解决状态冲突与突发的高延迟问题。参考TimeWrap这种思路,我们可以将本地执行过的所有操作指令进行保存行成一个快照(Snapshot),本地按照Pipelined Lockstep protocol的规则进行推进,如果后期收到了产生冲突的指令,我们可以回滚到冲突指令的上一个状态,然后把冲突后续执行过的事件全部取消并重新将执行正确的指令。这样如果所有玩家之间没有指令冲突,他们就可以持续且互不影响的向前推进,如果发生冲突则可以按照回退到发生冲突前的状态并重新模拟,保持各个端的状态一致。
1)乐观帧锁定
2)把渲染与同步逻辑拆开
3)客户端预执行
4)将指令流水线化,操作回滚
5)要不要回滚?
6)要不要服务器跑一套完整逻辑?
7)操作要不要是键鼠?还是高级命令?
8)客户端要不要像视频播放器一样保证平滑缓存1-2帧?
9)要不要保证平滑加一层显示具体对象的对标插值?
等等
概念
网络同步,其目标就是时刻保证多台机器的游戏表现完全一致。由于网络延迟的存在,时刻保持相同是不可能的,所我们要尽可能在一个回合(turn)内保持一致,如果有人由于某些原因(如网络延迟)没有同步推进,那么我们就需要等他——这就是Lockstep(或者说一种停等协议)。LockStep其实是很朴素的一种思想,很早就被用于计算机仿真模拟、计算机数据同步等领域。后来网络游戏发展起来后,也很自然的被开发者们拿到了系统设计中。早期lockstep被广泛用于局域网游戏内(延迟基本可以保持在50ms以内),所以这种策略是很有效的。
理论
lockstep每个回合的触发,并不是由收到网络包驱动,也不是由渲染帧数驱动(当然渲染帧率稳定的话也可以以帧为单位,每N帧一个回合),而是采用客户端内在的时钟稳定按一定间隔( 比如100ms) 的心跳前进。游戏的一开始,玩家在本地进行操作,操作指令会被缓存起来。在回合结束前(网络延迟在50ms以内),我们会收到所有其他客户端的指令数据,然后和前面缓存的指令一同执行并推进到下一个回合。
优点(做回放系统容易)
假如一场游戏持续了20分钟,不考虑延迟的情况下整场游戏就是12000个回合(所有客户端都是如此)。现在我们反过去给每个回合添加指令,确保每个回合都收集到所有玩家的指令,那么就可以严格保证所有客户端每个回合的表现都是一样的。假如我们再把这些指令都存储起来,那么就推演出整场比赛,这也是为什么lockstep为什么做回放系统很容易。
具体内容与状态同步区别
至于lockstep为什么要发送指令而不是状态,其实是与当时的网络带宽有关。很多游戏都有明确的人数限制(一般不超过10个人),玩家在每个回合能做的操作也有限甚至是不操作,这样的条件下所有玩家的指令一共也占用不了多少带宽。如果换成同步每个角色的状态数据,那么数据量可能会膨胀10倍不止。
//lockstep操作指令的结构体
struct Input
{
bool up;
bool down;
bool left;
bool right;
bool space;
bool Z;
};
Id software就公开了新作——雷神之锤(Quake)。在Quake里他们舍弃了之前的P2P而改用CS架构,同时也舍弃了lockstep的同步方式。新的架构下,客户端就是一个纯粹的渲染器(称为Dumb Client),每一帧玩家所有的操作信息都会被收集并发送到服务器,然后服务器将计算后的结果压缩后发给客户端来告知他们有哪些角色可以显示,显示在什么位置上。
比如延迟过大,客户端性能浪费,服务器压力大等。而其中最明显的问题就是对带宽的浪费,对于一个物体和角色比较少的游戏,可以使用快照将整个世界的状态都存储并发送,但是一旦物体数量多了起来,带宽占用就会直线上升。所以,我们希望不要每帧都把整个世界的数据都发过去,而是只发送那些产生变化的对象数据(可以称为增量快照同步)。更进一步的,我们还希望将数据拆分的更细一些,并根据客户端的特点来定制发送不同的数据。基于这种思想,《星际部落:围攻》团队的开发者们开始对网络架构进行抽象和分层,构造出来一套比较完善的"状态同步"系统并以此开发出了Tribe游戏系列。
事件锁定结合UDP成NTP使用,NTP使用了一种树状、半分层的网络结构来部署时钟服务器,每个UDP数据包内包含多个时间戳以及一些标记信息用来多次校验与分析,
介绍
弥补客户端到服务器同步延迟的一项技术,该技术的核心是服务器在指定时刻对玩家角色进行位置的回滚与计算处理。假如客户端到服务器的延迟为Xms。当客户端玩家开枪时,这个操作同步会在Xms后到达服务器,服务器这时候计算命中就已经出现了延迟。为了得到更准确的结果,服务器会在定时记录所有玩家的位置,当收到一个客户端开枪事件后,他会立刻把所有玩家回退到Xms前的位置并计算是否命中(注意:计算后服务器立刻还原其位置),从而抵消延迟带来的问题
存在不公平性
不过,延迟补偿并不是一个万能的优化方式,采用与否应该由游戏的类型与设计决定。考虑一个ACT类型的网游,玩家A延迟比较低、玩家B延迟比较高。在A的客户端上,玩家A在T1时间靠近B,而后立刻执行了一个后滚操作,发送到服务器。在B的客户端上,同样在T1时间发起进攻,然后发送命令到服务器。由于A的延迟低,服务器先收到了A的指令,A开始后滚操作,这时候A已经脱离了B的攻击范围。然后当B的指令到达服务器的时候,如果采用延迟补偿,就需要把A回滚到之前的位置结果就是A收到了B的攻击,这对A来说显然是不公平的。如果该情况发生在FPS里面,就不会有很大的问题,因为A根本不知道B什么时候瞄准的A。
TimeWarp需要频繁的生成游戏快照进而占用大量内存(每次发送命令前都要生成一份),而且每次遇到过期信息就立刻回滚并可能产生大量的对冲事件(anti-message)。这种同步方式是不适合Quake这种类型的FPS游戏的。
最大的优势就是大大降低了快照的记录频率(由原来的按事件记录改为按延迟时间分开记录),同时他可以避免由于网络延迟造成的连续多次指令错误而不断回滚的问题(Leading State不负责触发回滚,Trailing State检测并触发)。
不过TSS同时维护了多个游戏世界的快照,也无形中增加了逻辑的复杂度,在最近几年的网络游戏中也并没有看到哪个游戏使用了这种同步算法。在我看来,其实我们不必将整个世界的快照都记录,只要处理好移动的快照同时使用服务器状态同步就可以满足大部分情况了。
作为一款游戏引擎,虚幻并没有将所有常见的同步手段都集成到引擎里面,只是将移动相关的优化方案(包括预测回滚、插值等)集成到了`移动组件·里面。其他的诸如延迟补偿,客户端预测等,他们放到了特定的Demo以及插件(GameplayAbility)当中。
1.在Quake诞生前,其实也存在直接传输游戏对象状态的游戏,但是那时候游戏都比较简单,相关的概念也并不清晰。当时的架构模型以P2P为主,考虑搭配带宽限制等原因,军事模拟、FPS等游戏都采用了“Lockstep”的方式进行同步
2.不过由于作弊问题
日益严重、确定性实现困难
重重等因素,CS架构逐渐代替P2P走向主流。我们也发现似乎所有的游戏状态信息都可以保存在服务器上,客户端只需要接受服务器同步过来的状态并渲染就可以了
3.快照同步太浪费带宽了,不同的玩家在一段时间内只能在很小的范围内活动,根本没有必要知道整个世界的状态。同时,每次发送的快照都与之前的快照有相当多重复的内容,确实过于奢侈。因此,星际围城:部落的开发团队构建出了一个比较完善的状态同步系统,用于对同步信息进行分类和过滤。
如今的状态同步是指包含增量状态同步、RPC(事件同步)两种同步手段,并且可以在各个端传递任何游戏信息(包括输入)的一种同步方式。
但是并不是所有物理现象都同步或计算,比如简单的抛物线运动,射线检测,与玩法无关的场景破碎等
想保证不同平台、编译器、操作系统、编译版本的指令顺序以及
浮点数精度完全一致几乎是不可能的
1.编译器优化后的指令顺序
2.约束计算的顺序
3.不同版本、不同平台浮点数精度问题[4][5]
(问题1与问题3其实是密切相关的)
如果游戏只是单个平台上发行,市面上常见的物理引擎(Havok,PhysX,Bullet)基本上都可以保证结果的一致性。因为我们可以通过使用同一个编译好的二进制文件
、在完全相同的操作系统
上运行来保证指令顺序并解决浮点数精度问题,同时打开引擎的确定性开关
来保证约束的计算顺序(不过会影响性能),这也是很多测试者在使用Unity等商业引擎时发现物理同步可以完美进行的原因。
写了物理同步相关的文章,
[9] Glenn Fiedler, “Introduction to Networked Physics”, Personal Blog,2014. Available: https://gafferongames.com/post/introduction_to_netw>orked_physics/[Accessed:2020-12-12]
假如在一个游戏中(带有预测,也就是你本地的对象一定快于远端)你和其他玩家分别控制一个物理模拟的小车朝向对方冲去,他们相互之间可能发生碰撞而彼此影响运动状态,就会面临下面的问题。
其实对于一般角色的非物理移动同步,二者只要相撞就会迅速停止移动,即使发生穿透只要做简单的位置“回滚”即可。然而在物理模拟参与的时候,直接作位置回滚的效果会显得非常突兀并出现很强的拉扯感,因为我们几乎没办法在本地准确的预测一个对象的物理模拟路径。如果你仔细阅读了前面Glenn Fiedler的文章(或者上面总结的技术点),你会发现里面并没有提到常见的预测回滚技术,因为他只有一个主控端和一个用于观察结果的模拟端,并不需要回滚。
《火箭联盟》的核心玩法是“用车踢球”,每个玩家控制一个汽车,通过撞击足球来将其“踢”进敌方的球门。由于是多人竞技游戏,所以一定要有一个权威服务器来避免作弊,最终的结果必须由服务器来决定。相比于《看门狗》,他们遇到的情况明显更复杂,除了不同玩家控制不同的小车,还有一个完全由服务器操控的小球。按照常规的同步方式,本地的主控玩家预测先行,其他角色的数据由服务器同步下发做插值模拟。但是在这样一个延迟敏感且带有物理模拟的竞技游戏中,玩家的Input信息的丢失、本地对象与服务器的位置不统一都会频繁的带来表现不一致的问题,而且FPS中常见的延迟补偿策略并不适合当前的游戏类型(简单来说就是延迟大的玩家会影响其他玩家的体验,具体原因我们在上一篇延迟补偿的章节也有讨论)。
为了解决这些问题,Jared Cone团队采用了“InputBuffer”
以及“客户端全预测”
两个核心方案。InputBuffer,即服务器缓存客户端的Input信息,然后定时的去buffer里面获取(buffer大小可以动态调整),这样可以减少网络延迟和抖动带来的卡顿问题。
仔细分析这两款游戏,你会发现他们采用都是“状态同步+插值+预测回滚”
的基本框架,这也是目前业内上比较合适的物理同步方案。