转载于 Technical Implementation Details of Frame Synchronization in Games
演示帧同步技术追踪帧效果
让我们先来看一下帧同步演示。这是我几年前用Cocos2d-x开发的一款PVP游戏。首先,有一个匹配过程。匹配完成后,会有一个开场动画。接下来,我将演示追帧效果。
现在另一个玩家被卡住了,无法移动。当我们恢复时,刚刚被卡住的玩家会加速时间以回到原位。这是一个小型重新连接的问题。当我们在战斗中断开网络时,可以看到另一端继续运行。当我们恢复网络时,它也可以正常运行。
刚刚发生的小型重新连接是因为我们在游戏中设置了一些快捷方式,可以快速实现断开连接和重新连接。我可以通过按下一个快捷键来关闭它,再按下一个快捷键来重新启动它进行测试。这是很方便的。
这次演示是一次大规模的重新连接。我们在战斗中退出了游戏,然后重新连接。重新进入游戏后,它还可以完全恢复整个场景。只是速度较慢,主要是因为正在加载资源。这不像传统的帧同步处理方式,而是一个从头到尾运行的恢复过程。
游戏中帧同步机制
帧同步确保每个人在每一帧上都获得相同的输入并执行相同的逻辑,通过同步玩家的动作,最终获得一致的性能和结果。有两方面同步内容,一是同步时间,另一个是同步指令。
我们希望所有玩家同时开始游戏。在开始游戏之前,需要加载各种地图模型资源,这需要很长时间,但是一些玩家可能使用较旧的设备导致加载较慢,而一些玩家则使用更好的设备加载速度更快。此时该怎么办?
通常我们需要一个加载界面来同步所有玩家的加载进度,并在所有玩家加载完毕后开始游戏。但是在所有资源加载完毕后,开始游戏可能无法保持同步。因为进入游戏场景后,需要执行大量的初始化逻辑。在这个过程中,如果有些人的手机执行速度很慢,他们后面的游戏进程将会明显比其他玩家慢。
然而,有一种开场动画可以消除我们之间的差异。例如,如果玩家A进入游戏,需要1秒钟进行初始化,然后玩家B进入游戏,需要2秒钟进行初始化。如果我们有一个3秒钟的开场动画,玩家A可能进入游戏并播放2秒钟,而玩家B可能进入游戏并播放1秒钟。这样,每个人都会在同一时间点开始游戏——在开场动画播放完之后。
最后,我们还需要同步两个设备的时间,应该以服务器时间为准。当我们进入比赛时,我们会请求服务器的时间,并同时计算这个请求的延迟值,即数据包从客户端发送到服务器再返回的时间。然后将这个延迟值除以2,并加上服务器的返回时间,得到准确的当前服务器时间。接下来,在游戏中的后续同步中,我们可以根据较小的延迟值来修改时间。指令同步相对简单。服务器会收集每一帧中所有玩家的操作,并将它们广播给所有玩家。
帧同步实现细节
帧同步核心逻辑的特征主要包括两个部分:命令队列的设计和游戏主循环。这两个部分是最重要的。
大多数命令队列设计都将所有不直接影响角色本身的操作都放入队列中,然后从队列中取出指令执行。
我们将为单机模式和网络模式创建不同的监听器。如果是单机模式,我们创建的监听器的效果是在监听到玩家操作时将操作放入队列中。如果是网络模式,它将在监听到玩家操作后将操作发送到服务器。同时,它还将监听服务器,并将服务器返回的操作插入队列中。
在这种设计下,单机模式和网络模式基本上是相同的代码。唯一的区别是你创建不同的监听器,而这种队列设计可以轻松实现回放和观看战斗等功能。
通常游戏的主循环在由引擎驱动的 update 函数中编写。然而,帧同步要求我们对整个游戏的执行顺序有严格的控制,所以通常不能直接使用引擎的 update 函数。我们需要自己掌控一切,需要被控制的第一件事是帧率。
帧率控制
帧率控制需要做两件事情。一是我们需要以特定的频率运行游戏,比如每秒运行10帧的逻辑帧来运行游戏;二是我们需要监控帧的状态,以适应游戏的进度。如果游戏进度落后,那么就需要运行更多的逻辑帧。在原本每秒10帧的情况下,如果需要追帧,可能需要以每秒60帧的速度快进到当前进度。
这段代码展示了这个功能。例如,如果我们在游戏中卡了很长时间,当更新到来时,Delta 将会非常大。然后在添加了这个 Delta 之后,它将比你最初需要执行的帧数要大得多。我们将加快执行速度,执行到一个确定的帧数,然后再次加快执行速度,直到达到当前的进度。
我们需要严格控制这个逻辑的顺序。在执行逻辑之前,我们会先对所有对象进行排序,然后按照特定的顺序执行每个对象的更新方法来执行逻辑。
对抗网络延迟
网络延迟可能会导致游戏卡顿和客户端性能不稳定等问题。这是客观存在的,无法避免。那么我们应该如何处理呢?
首先,我们可以增加帧缓冲区和前置loading动画来掩盖延迟。这是一种非常常见的做法。其次,可以在底层使用UDP来替代TCP,例如KCP。
为什么要替换TCP
有几个原因导致这种情况。首先是因为TCP的Nagle算法。默认情况下,它会尽可能地收集多个小数据包,然后同时发送,这样可以减少带宽占用,但实时性相对较差。我们可以使用TCP_NODELAY选项来关闭这个功能。其次是它的超时重传机制。当我们没有收到一个数据帧的数据包时,游戏的逻辑无法正常执行,直到数据包被重新传输。
那么什么时候会重新传输呢?要么等待超时,TCP会在这个时候重新传输;要么使用TCP的快速重传机制,也就是当它接收到三个重复的ACK时,会触发快速重传。但与UDP相比,这个时间仍然太长。最后,当你丢失数据包时,TCP的拥塞控制机制也会限制你的数据包发送。
锁帧
尽管我们已经进行了一些优化,但这些优化无法避免延迟,那么如果网络延迟了,我该怎么办呢?为了确保结果的一致性,一般的做法是进行帧同步锁定。如果这一帧的数据没有传输下来,我会被卡住等待这一帧的数据传输下来,然后我会加速追赶到当前的进度。
参与那个项目时,我使用了另一种没有帧锁的同步方案,这是一种用于预测回滚的方案。如果客户端没有操作,服务器将不会发送空包,只有在有操作时才进行广播。客户端不接收该包并且不会被卡住,而是继续执行。但是如果我们接收到的包被延迟了怎么办?这时,客户端的状态将会出错,然后我们需要纠正这个错误。
在初始版本中,我们的服务器会运行逻辑。此时,我们必须从服务器请求最新状态的副本,并在客户端上进行反序列化。此外,客户端本身还可以实现一种“回滚和重试”的机制,即回滚到最新正确的状态,然后进行恢复。
这个设计的原因是基于那款游戏的特点。我们当时制作的游戏是一款策略游戏。它的特点是操作较少。如果使用之前的方案(锁定帧同步),它的空帧率非常高,而且有很多帧。如果玩家不操作,这些空帧在存在网络延迟的情况下会卡住。
如果我们使用后一种方案(不锁定帧同步),如果我们不进行操作,无论网络有多卡,都不会影响我的游戏或导致游戏冻结。而且会省不少流量。
关于如何反序列化和如何回退刚才提到的内容,这部分将在后面详细讨论。
战斗框架设计
回退实际上是一个非常复杂的任务,但如果你的框架设计得很好,这个操作的复杂性将大大降低。整个战斗框架设计的核心是使用组件设计来分离显示和逻辑层,这在某种程度上类似于ECS,但没有ECS那么彻底。
ECS非常彻底。它的所有组件都是纯数据,没有逻辑,因此最适合进行这种序列化和反序列化操作。
在将逻辑层与显示层分离后,它们可能以不同的频率运行。逻辑层以更合理的频率执行可以使渲染更加流畅。显示层可以根据逻辑层变化的状态更新其表现,然后组件只需完成一项任务。按照这种方式进行规划后,我们的序列化和反序列化变得简单明了。
而且,通过在服务器上高效运行解耦的逻辑层,这将带来很多好处!首先是高安全性。通常情况下,客户端负责帧同步。这种方法可以实时或离线在服务器上验证战斗结果,并对外部链接具有出色的预防效果。同时,这也方便我们批量运行战斗,对于测试和平衡调整非常有帮助。
断线和重连的优化策略
对于断线和重新连接,因为我们有序列化和反序列化的设计,所以不需要像典型的帧同步游戏那样从头到尾运行来获取当前状态。如果是通常的帧同步机制,在游戏已经运行了很长时间后,获取一个长时间的重新连接实例会非常痛苦。
在初始版本中,服务器会运行逻辑。当连接断开并重新连接时,它会直接向客户端发送最新的状态以进行恢复。然而,频繁的断线和重新连接会使服务器性能产生问题。
所以我们进行了两个优化。第一个,如果只是一个短时的重新连接,只是丢失了几帧数据,我们会同步这几帧数据给客户端。如果是一个时间较长的重新连接,在这个时候,服务器会将当前的状态信息同步给客户端,并且在客户端会用这个数据恢复游戏进程,并且序列化当前状态数据后进行缓存,缓存时间为5秒。如果在这段时间内连接反复断开和重新连接,我们将重复使用这个缓存的数据。
此外,我们还进行了关键帧和定时帧优化。这个优化是为了解放服务器,因为我们发现整个战斗场景的序列化速度非常快。当我们接收到正确的数据包时,我们会进行序列化,这就是关键帧。如果每10秒钟,发现还没有进行序列化,我们可以进行另一次序列化,这就是定时帧。我们保留最多3个定时帧和一个关键帧。当我们需要回退时,我们从当前时间开始找到最新可用的序列化数据,然后进行反序列化,并使用这些数据进行恢复,然后再次加速。
最后,我们将把这个序列化数据保存到磁盘上。当玩家重新连接时,即使内存中没有状态,也可以从磁盘加载数据进行恢复。基本上,在这一步骤中,服务器不需要运行,但仍然具有这些安全性。
但也存在一种非常异常的情况,即如果玩家更换了新手机并进行了长时的重新连接操作,新手机中没有存档,应该怎么办?
服务器可以运行他当前状态的副本,然后将其发送给他。然而,这种情况非常异常,非常罕见。在完成这个操作之后,服务器就不需要运行逻辑了,整体服务器成本也可以大大降低。
实现序列化和反序列化并不难。它只是将所有属性写入缓冲区或从缓冲区读取和恢复。其中,有几个地方需要注意:
- 首先,需要对对象列表进行序列化。我们需要确保所有对象都已实例化,并且在恢复对象属性时需要使用它。例如,我的对象有一个攻击目标的目标属性,指向另一个角色。在恢复时,需要保存该角色的ID,从对象列表中找到对应ID的角色,并将其设置给这个变量。
- 如果死亡对象在其他地方被引用,为了确保正确的逻辑,它也必须被序列化和反序列化。
- 然后,要注意其中一个副作用。当我们创建一个对象并将其添加到场景中时,它可能会执行一些方法。例如,在反序列化恢复期间,当我添加一个对象时,它会执行一个技能,这个技能会修改其他对象的一些属性,从而污染其他人的一些变量。换句话说,如果你调用一个随机方法,在这个时候你得到的随机结果将与其他人不同。重新连接后,我们仍然需要考虑如何在各种情况下进行处理。
让我给你举个例子。当玩家断开连接然后重新连接后,发现战斗已经结束后会发生什么?战斗结果将被缓存一段时间,当他断开连接然后重新连接时,我们会在他进入时将最终结果发送给他。他可以直接看到战斗的结果。
一致性问题
最后一个问题是一致性的问题。在开发帧同步时,同一段代码往往会产生不同的战斗结果,这是最令人痛苦的地方。
典型的一致性问题大致如下:
使用浮点数计算,然后不适当地使用随机性,或者指针参与计算。换句话说,上一轮中使用的一些静态变量没有重置,然后它们会影响下一轮。
执行顺序不同。例如,我们使用了一些不稳定的排序方法。有可能将两个客户端排列在一起,比如按照最高的生命值或最近的物体进行排序,然后它们的距离和生命值恰好相同,那么进行结算时可能会返回A,而在另一种情况下可能会返回B。
在全局事务中掺杂了一些主观逻辑(需要依赖具体对象来安排执行顺序的逻辑),或者逻辑层依赖于显示层和之前提到的反序列化的副作用。
主观逻辑是什么?例如,如果某个逻辑先为我执行,然后再为其他人执行,他的执行顺序将根据我的更改而改变。因此,在进行帧同步游戏时,我们应该客观地严格遵循玩家1和玩家2的顺序。显示层没有这种限制。
在开发帧同步游戏时,定位一致性问题非常重要,那么我们如何定位不一致性问题呢?
首先,最原始的方法是制作大量的日志。我相信很多人也使用这种方法。但是这种方法在这方面是低效的,而且很难复现场景。从其他人那里跑出来的不一致可能无法在这里复现。
因此,一种更科学的方法是编写一个内存日志,并使用工具快速比较差异。这种方法在《腾讯游戏开发精髓》一书中有详细描述。它具有高性能和低消耗,因此也可以在在线环境中使用。
然后我们可以基于这种方法做得更好。例如,每次战斗结束后,我们对这两个玩家的内存日志进行哈希处理,并将其报告给服务器,然后进行比较。如果比较结果不一致,两个客户端会压缩这些详细的内存日志并将其报告给后台。通过这种方式,我们可以捕捉到许多在线玩家之间的不一致性,然后分析原因。所有的不一致性都可以很快被消除,因为在线环境往往能够暴露更多问题!
游戏中状态同步机制
状态同步与帧同步非常不同。它将大部分状态和逻辑计算放在服务器端,然后服务器将结果发送给客户端,客户端只根据服务器发送的结果播放动画。
它的优点是可以支持更多的玩家和更长的运行时间,而且更安全、断开连接和重新连接更快,实时性更好。但它的缺点是实现复杂度相对较高,回放不容易实现。
问答部分
-
Q: 你好,我的问题涉及到逻辑层追帧。当逻辑层追帧时,我们应该如何处理显示层?以 Cocos 为例,在显示层中有许多职责,例如一些缓动计时器和一些动画都在显示层上。然而,在逻辑层追帧时,我们不能说放弃所有形式的显示细节。例如,逻辑层可能已经追了三秒钟,但是一个三秒钟的大动作在三秒钟前就已经释放了。在这个过程中,应该不可能在短时间内广播动画。对于这种问题,有什么好的做法或经验吗?
A: 这取决于你的显示层是如何实现的。我的显示层是这样实现的。我有各种显示组件,每个显示组件只做一件事:盯着我关心的逻辑层的状态,然后检测它是否发生了变化。例如,我的逻辑层一次运行了10帧,但显示层只在运行后检查一次,并检查它的当前状态是否发生了变化。事实上,在整个恢复过程中,我没有处理显示层。这是因为我希望显示层具有一定的能力。只要它获得正确的状态,它就能正确显示,并且每个组件都会做这样的事情,对吗? -
Q: 你好,我想了解浮点数的不一致性。有没有确定的可以导致不一致的情况?我们之前尝试过,似乎并不是所有情况下都会出现不一致的结果。
A: 首先,浮点数遵循IEEE 754标准。如果所有硬件严格按照这个标准实现,浮点数计算应该没有不一致之处。但实际情况是,很多硬件并不严格按照这个标准实现,因此可能存在一些细微的差异。事实上,我已经与很多人深入讨论过这个问题,但由于这个问题涉及到底层硬件的实现方式,具体的差异是与底层硬件的实现相关的。