查看原文
其他

干货丨腾讯高级工程师宝爷:帧同步游戏在技术层面的实现细节

COCOS 2022-06-10
腾讯游戏高级工程师宝爷是我们的「Cocos Star Writer」,早年曾出版 Cocos2d-x 教程,并在论坛上发布了许多 Cocos Creator 相关技术教程。

「Cocos 开发者沙龙」深圳站上,宝爷以《游戏中的实时同步》为主题展开演讲,分享了游戏实时技术中的帧同步和状态同步,并通过对抗延迟、框架设计、断线重连和一致性问题多方面,介绍了帧同步游戏在技术层面的实现细节。下为演讲内容,有节选:



大家好,我是 Cocos 论坛的宝爷,来自光子工作室。今天我要分享的是游戏中的实时同步,主要包含帧同步和状态同步,这都是我之前在游戏项目中积累下来的经验。

帧同步技术追帧效果演示
 
我们先来看一下帧同步演示。这是我几年前用 Cocos2d-x 开发的一个 PVP 游戏,首先这里有个匹配,匹配完是 loading,loading 完有一个开场动画,接下来会演示一个追帧的效果。

https://v.qq.com/txp/iframe/player.html?width=500&height=375&auto=0&vid=wxv_2035421152281493510

现在另外一个客户端是卡住不动的,当我们恢复的时候,刚刚卡住客户端会加速把这个时间追回来。接下来是一个小重连,当我们在战斗中把网络断开,可以看到另外一端继续运行,当我们把网络恢复之后,它也可以正确的表现。
 
刚刚的小重连是我们在游戏中做了一些快捷键,可以快速的实现断线重联。就按一个快捷键就关,再按一个快捷键就开,这个也是非常好用的东西。

https://v.qq.com/txp/iframe/player.html?width=500&height=375&auto=0&vid=wxv_2035423551289163776

最后演示的是一个大重连,我们把在战斗中的游戏给退出,然后重新连接,进入到游戏后,它也可以把整个场景完全恢复,刚刚比较慢,主要是在加载资源。它并不像传统的帧同步,从头跑到尾的一个恢复过程。

https://v.qq.com/txp/iframe/player.html?width=500&height=375&auto=0&vid=wxv_2035424428234244097

游戏同步中的帧同步基础介绍
 
帧同步通过同步玩家的操作,保证所有人在每一帧上都获得相同的输入,执行相同的逻辑,最后得到一致的表现和结果。这里面包含两个同步,一个是时间同步,一个是指令同步。
 

我们希望所有玩家同时间开始游戏。在开始游戏之前,需要加载各种地图模型资源,这是非常耗时的,但有的玩家设备可能差一些,加载很慢,有的玩家设备好一些,加载很快,那怎么办呢?
 
我们一般需要一个 loading 界面来同步所有玩家的加载进度,在所有玩家加载完之后再一起开始游戏。但当你所有资源都加载完之后,开始游戏也不一定能做到很同步。因为进到游戏场景之后,你还要执行很多初始化的逻辑。在这个过程中,有的人执行很慢的话,他后面的游戏进程会明显比其他人要慢一点点。
 
但是,有一个开场动画就可以很好地消去我们前面的差异。比如说 A 玩家进来,他初始化花了一秒钟,然后 B 玩家进来,他初始化花了两秒钟。如果我们有一个三秒的开场动画,那么 A 玩家进来可能播两秒钟,B 玩家进来一秒钟播完,大家都在相同的时间点——开场动画播放之后才开始游戏。
 
最后,我们也需要进行多端时间的同步,这里要采用服务器的时间为准。在进来的时候,我们就会请求服务器的时间并同时计算这次请求的 ping 值,就是包到服务器然后再回来的时间。然后这个 ping 值除以2再加上服务器的返回的时间,就可以得到一个准确的当前服务器的时间。接着,后面游戏过程中的同步,我们也可以根据更小的 ping 值来修正时间。
 
指令同步就比较简单了,服务器每一帧会收集所有玩家的操作,然后广播给所有的玩家。

帧同步实现的细节问题
 
帧同步核心逻辑的实现主要包含两个部分,一个是命令队列的设计,还有一个是游戏的主循环实现,这两个是比较重要的。


命令队列设计大部分都有这么一个命令队列,所有的操作都不会直接作用到角色本身,都是进入到一个队列里面,然后从队列里面去执行它的逻辑。
 
我们会为单机和网络模式创建不同的监听器。如果是单机模式,我们创建的监听器效果是,当监听到玩家的操作时,就把操作塞到队列里面。如果是网络的监听,它监听到玩家的操作之后就把操作发到服务器,同时它也会监听服务器,把服务器返回的操作插到队列里面。
 
那么在这种模式设计下,单机模式跟网络模式基本上是一样的代码,唯一的差别就是你创建了不同的监听器,而且这种队列的设计也很容易去实现回放和观战这样的功能。
 
一般游戏的主循环实现,即我们游戏的逻辑基本上都写在 Update 函数里面由引擎来驱动的。但是,帧同步需要我们对整个游戏的执行顺序有严格的控制,所以一般不能直接用引擎的 Update。我们需要把所有的东西都控制在自己手上,首先要控制的是帧率。
 
帧率控制需要做两个事情,一个是我们要按特定的频率去运行游戏,比如说一秒10帧的一个逻辑帧去跑游戏,另外一个是我们要控制追帧,如果说他的游戏进度落后了,那么他应该要跑更多的逻辑帧,原本一秒10帧,追帧的情况下可能一秒需要跑60帧,快进到当前进度。
 
这个代码就演示了这个功能,比如说我们游戏卡了很久,那么 update 传进来 Delta 会很大,那么把这个 Delta 加进去之后,它会远远大于你原本所需要执行的帧数,这时候我们会加速执行,执行到一定的数量退出,然后下一次再加速执行,直到当前进度。
 
然后,逻辑的顺序我们也是严格控制的,在执行逻辑之前,我们会对所有的对象先走一次排序,然后按特定的顺序执行每个对象的 update 的方法去执行逻辑。
 
对抗网络延迟
 
网络延迟有可能导致游戏卡顿、客户端表现不一致等问题,它是客观存在没办法避免的,那我们应该怎么样去应对它呢?
 

首先,我们可以用增帧缓冲和前摇动画去掩盖延迟,这个是非常通用的一个做法。
 
其次,在底层我们可以用 UDP 去替换 TCP,比如说 KCP。但是,为什么要把 TCP 给替换掉呢?
 
这有几个原因。首先是因为 TCP 的 Nagle 算法,默认情况下它会收集尽量多的小包,然后一次性发送,这样可以减少带宽,但实时性比较差。我们可以用 TCP_NODELAY 选项去关闭这个功能。第二是它的一个超时重传机制,当我们有一帧的包没有收到的话,游戏的逻辑是无法正常执行的,直到这个包被重传。
 
那么什么时候会被重传呢?要么等到它超时了,TCP 这时候会重传;要么就是 TCP 的快速重传机制,就是当他收到三个重复的 ACK,这个时候会触发它的快速重传。但是这个时间相比 UDP 来说还是太久了。最后当你出现丢包之后,它的拥塞控制机制也会限制你的发包。


虽然我们前面做了一些优化,但是这些优化没有办法去避免延迟,那么网络延迟了怎么办呢?
 
为了保证结果一致,通用的做法就是卡住(锁帧同步)。如果这一帧的数据没有下来的话,这时候我卡住等这一帧的数据下来,之后我会加速追回到当前的进度。
 
在参与那个项目的时候,我使用了另外一种不锁帧的同步方案,是一种预测回滚的方案。就是如果客户端没有操作的话,服务器是不会下发空包的,有操作的时候才进行广播。客户端没有收到包也不会卡住,而是继续执行,但是如果我们收到的包延迟了怎么办呢?
 
这时候客户端的状态会出错,然后我们需要进行纠错。
 
一开始的版本,我们的服务器是会跑逻辑的,这个时候就要向服务器请求一份最新的状态,在客户端反序列化出来。另外,客户端本身也可以做“回滚然后重试”这样的一个机制,就是回滚到最近的一个正确的状态,然后再追回。
 
之所以如此设计这方案,是根据游戏的特性决定的。我们当时做的那个游戏,是一个策略类型的游戏,它的特点就是操作比较少,如果用前面这种方案(锁帧同步)的话,实际上它的空帧率非常高,有很多的帧其实都是玩家没有操作的,那么这些空帧如果出现网络延迟的话都会被卡住。
 
而使用了后面这种方案(不锁帧同步)的话,如果我们没有操作,那么网络不管有多卡,其实都不影响我的游戏,都不会导致我游戏卡顿。而且流量上也会更加节省。
 
至于刚刚说到的怎么样去序列化以及怎么样去回滚,这部分内容后面会详细的说一下。
 
战斗框架设计
 
回滚其实是一个很复杂的工作,但如果你的框架设计得好,这个操作复杂度会下降非常多。

整个战斗框架设计的核心是使用组件设计,把显示层和逻辑层进行分离,有点类似 ECS,但也没有 ECS 那么彻底。


ECS 是非常彻底的,它所有的组件都是纯数据,没有逻辑,所以它做那种序列化和反序列化会更好实现。
 
逻辑层跟显示层分离之后,它们可以按不同的频率跑,这样逻辑层执行的频率更加合理,渲染也可以执行得更加平滑,而显示层会根据逻辑层的状态变化去更新它的表现,一个组件它只做一件事情。这样规划之后,我们的序列化和反序列化也非常简单。
 
而且解耦后的逻辑层可以很轻易地在服务器上跑起来!这会带来很多便利!
 
首先是安全性高,一般帧同步都是信任客户端的,而这种方式可以在服务器上实时或者离线地校验战斗结果,对外挂有很好的防范效果。也便于我们批量地跑战斗,这不管是对测试还是平衡性的调整都很有帮助。
 
断线重连的策略优化
 
在断线重连这一块,由于我们有序列化和反序列化的设计,就不需要像普通帧同步的游戏那样,从头到尾跑一遍把当前的状态跑出来了。如果是普通的帧同步,游戏跑了很久,这时候来个大重连是非常痛苦的,我们是怎么处理的呢?
 

最初的版本是服务器跑逻辑,断线重连的时候它会直接下发最新的状态给客户端进行恢复,但是这样频繁地触发断线重连,服务器性能是会有比较大挑战的。
 
所以我们做了两个优化。一个是如果你是小重连,只是丢几帧的数据,那么我们就把这几帧的数据补给你就好了。如果你是一个大重连,这个时候我们序列化的数据会做一个5秒的缓存,如果在这段时间里重复地断线重连,我们都复用这一份缓存数据。
 
另外,我们还做了一个关键帧和定时帧的优化。这个优化是为了彻底解放服务器,因为我们发现整个战斗场景的序列化是非常快的,当我们收到一个正确的包时,我们会去做一次序列化,这是关键帧,如果每隔10秒钟没有序列化的话,我们又可以做一次序列化,这是定时帧。我们最多保留3份定时帧和一份关键帧,当我们需要回滚的时候,就从当前时间往前找到最近的一份可用的序列化数据,然后进行反序列化,拿着这个数据来恢复然后再加速。
 
最后,我们会把这个序列化存到磁盘里面。当你大重连的时候,即使内存里没有状态,你也可以从磁盘里面加载数据来做恢复,到了这一步,基本上你的服务器就已经不需要跑了,同时也还是具备那些安全性。
 
但是也有一个极其异常异常情况,就是如果你换了新的手机,再去做一个大重连这样的操作,新的手机里没有存档怎么办?
 
这个时候,其实服务器也是可以给他现跑一份当前的状态,然后下发给他。但是,这个事情是极其个别的这种异常情况,基本上很不常见,这样做完,服务器彻底不需要跑逻辑,整体的服务器成本也可以下来非常多。


实现序列化和反序列化并不难,无非是把所有属性写入 buffer,或从 buffer 读取恢复,其中需要注意的几个地方是:
 
要先序列化目录。目录相当于我们所有对象的一个表,我们需要先确保所有的对象都已经实例化出来了,后面在恢复对象属性的时候需要用到。比如说,我这个对象有一个攻击目标的 Target 属性指向另外一个角色,恢复的时候需要拿着这个角色的 ID,从表里面把 ID 对应的角色给找出来,然后设置到这个变量里面。
 
已经死亡的对象,如果还有其他地方引用到,也需要序列化和反序列化,这样才能保证逻辑的正确。
 
然后,我们还有需要注意的是它的一个副作用。比如说,当我们创建一个对象并添加到场景里面的时候,它可能会去执行一些方法,这些方法会带来一些副作用。
 
就比如,在反序列化恢复的时候,我添加对象进来的时候,会执行一个技能,这个技能会修改其他对象的一些属性,那么你就污染到了其他人身上的一些变量。或者说,你调用了一下随机方法,这个时候接下来你随机出来的结果就会跟别人不一样。
 
重连之后,我们还需要考虑各种情况下如何继续,这里就不细讲。我稍微举一个例子,我们断线重连回来之后,战斗结束怎么办?一开始是没有做这个处理的,然后后面处理了一下,就是当他的战斗结束的时候,战斗结果会缓存一段时间,等他断线重连回来再把最后的结果下发给他,他进来就可以直接看到战斗结果。
 

帧同步的一致性问题
 
最后一个是一致性的问题,一模一样的代码然后跑出不同的战斗结果,这个是非常常见的,而且在开发帧同步的时候,最痛苦的也是这一点。
 

常见的一致性问题大概有这些:
 
比如说使用浮点数计算,然后没有恰当的使用随机,或者是指针参与计算;
 
或者说有一些上一局用到的静态变量没有重置,然后他会影响到下一局;
 
或者是执行的顺序不同,比如说我们使用了一些不稳定的排序,有可能在两个客户端排出来,比如说血量最高的东西,或者距离最近的对象,那么他们有两个距离和血量一模一样,排出来查到的可能这边返回 a 那边返回 b;
 
还有比如说全局里面使用了一些主观逻辑,或者是逻辑层依赖显示层,还有前面说到的反序列化的副作用。
 
什么叫做主观逻辑?比如说有一些逻辑是先执行我然后再执行别人这样子,他的执行顺序就会根据我的变化而发生变化。所以在做帧同步游戏的时候,我们应该就是严格按照比如一号玩家、二号玩家这样的顺序客观地去执行,就跟我没有关系,显示层那里就没有什么限制。
 
开发帧同步游戏,定位一致性问题的能力非常重要,那么我们如何去定位不一致的问题呢?
 
首先,最原始的方法就是打大量的日志,相信很多人也用了这个方法。但这个方法这种方式效率低下,而且难以复现现场,别人那里跑出来的不一致,我们这里不一定能复现出来。
 
所以一个比较科学的方法是,写内存日志,并使用工具来快速对比差异。这个方法在《腾讯游戏开发精髓》这本书里面有详细的介绍,它的性能高消耗少,所以线上环境也可以使用。
 
然后我们基于那个方法可以做一件更棒的事情,比如说每次战斗结束之后,我们把这两个玩家他们的内存日志做一个哈希,上报到服务器那边。然后去做一个对比,如果对比说不一致的话,那么两个客户端就把这份详细的内存日志压缩然后上报到后台,这样子的话我们就可以捕获到很多线上玩家的不一致的情况,然后拿来分析原因,很快就可以把各种不一致的问题给消灭掉。因为线上环境往往能够暴露出更多的问题!
 
游戏同步中的状态同步基础介绍
 

状态同步跟帧同步差别很大,它是把大部分的状态和逻辑计算都放在服务器这边,然后服务器把结果下发给客户端,客户端只是根据服务器下发的结果来播放动画。
 
它的优点是可以支持更多的玩家,更大的时间,还有更长时间的运行,而且更安全,断线重连更快,实时性也会更好一些。但它的缺点就是实现的复杂度比较高,而且回放不太好实现。
 
状态同步的实现和框架设计

我现在业余时间就在为 Cocos Creator 3.x 设计一个开源的状态同步框架,吸取了 UE 网络同步框架的一些优秀理念,并且没有任何历史包袱的,会使用比较简洁清晰的代码去实现这样的一个框架,目前正在不紧不慢地开发中,希望农历过年前能出一个版本吧。
 
开发路线大概是样的,首先是单机、单对象的一个复制,然后是单机、多对象的一个复制。什么叫单机多对象呢?比如说我在一个场景左右创建两个层,就把这边的节点的东西所发生变化,能够实时同步到另外一边。然后再慢慢地增加网络,最后去实现一个独立服务器的同步。
 
这里面其实涉及到非常多的概念。比如说,主要为它去生成一个 JS 的服务端代码,里面涉及到的属性复制其实非常复杂,特别是在 JS 这边,也要去跟踪一个数组的历史变化,这都是非常麻烦的。
 
何况还要支持各种容器,比如说 Array、Map、Set 这些,然后还要支持他们的增量更新,确实是挺复杂。如果我们要做的比较好的话,还需要有一个无渲染的 Cocos 引擎,这样的话像一些动画才可以在服务器这边跑起来。
 
我分享的内容就到这里,谢谢。



以下是嘉宾问答环节:
 
Q:你好,我的问题刚才有一部分逻辑的追帧,在逻辑层追帧的时候,我们显示层应该是怎么样去处理?
 
因为在显示层里面,在 Cocos 里面有很多义务的东西,比如一些缓动定时器,一些动画都在显示层面,但是在逻辑层追帧的同时,我们应该不太可能说在形式上把所有的细节都放出来。比如说逻辑层可能追了三秒,但是三秒前放了一个三秒的大招,应该不太可能在这个过程中短时间内把动画播出来,这种问题有什么样的好的实践经验呢?
 
A:这个其实取决于你显示层怎么去实现。我的显示层是这样子实现的,我会有各种各样的显示组件,然后每个显示组件它只做一件事情,就是盯着我所关心的逻辑层的那些状态,然后去检测它的变化。比如说我逻辑层一下子跑了10帧,但是显示层在他跑了之后只是检测一次,然后检测它当前的状态有没有发生变化。
 
其实我在整个恢复的过程中,显示层我是什么都没有去处理。就是因为我希望,显示层它有一种能力,只要他拿到了正确的状态就能够正确的表现出来,每一个组件都会做这样的事情是吧?其实这个组件也不是很多。
 
Q:您好,我想了解一下浮点数的不一致性,有没有遇到什么情况下可能会导致不一致?之前我们尝试过的时候好像并不是所有情况下都会出现不一致的结果。
 
A:首先浮点数它是遵循 IEEE 754 标准,如果所有的硬件都严格按照这个标准来实现,浮点的计算应该不存在不一致的问题。
 
但现实的情况就是很多硬件没有严格按照这个标准去实现,然后可能有这样那样的一些细微的差异。这个问题其实我曾经跟很多人深入的去探讨过,但是因为这个问题太底层了,具体是哪个细微的差异,就涉及到硬件底层的实现了。

往期精彩

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存