Unity3D中实现帧同步 (一):对抗延迟

2015年12月08日 13:44 0 点赞 0 评论 更新于 2025-11-21 19:31

在帧同步模型里,每个客户端都会对整个游戏世界进行模拟。这种方式的优势在于减少了需要传输的信息。帧同步仅需发送用户的输入信息,而与之相对的中心服务器模型,单位信息的发送则是越频繁越好。

中心服务器模型与帧同步模型对比

中心服务器模型

以在游戏世界中移动角色为例,在中心服务器模型中,物理模拟仅在服务器端执行。客户端告知服务器角色的移动方向,服务器执行寻路并移动角色,随后会尽可能频繁地向每个客户端告知该角色的位置。对于游戏世界中的每个角色都要执行这样的流程。对于实时策略游戏而言,在中心服务器模型中同步成千上万的单位几乎是一项不可能完成的任务。

帧同步模型

在帧同步模型中,当用户决定移动角色后,会将该信息告知所有客户端。每个客户端都会执行寻路并更新角色位置。只有在用户输入时才需要通知每个客户端,之后每个客户端会自行更新物理状态和位置。

帧同步模型存在的问题

模拟一致性问题

每个客户端的模拟必须完全一致,这意味着物理模拟的更新次数要相同,且每个动作的执行顺序也要一致。若不满足这一条件,某个客户端的模拟进度就会领先或落后于其他客户端。当新命令发出时,进度过快或过慢的客户端所走出的路径就会不同,而这些差异会因游戏玩法的不同而有所变化。

跨平台确定性问题

跨不同机器和平台时,计算上的细微差异都可能对游戏产生蝴蝶效应。该问题将在后续文章中详细探讨。

实现方案及相关定义

实现方案灵感来源

此实现方案的灵感源自《1500个弓箭手》这篇文章。每个玩家的命令会在后续的两个回合中执行,在发送动作与处理动作之间设置延迟有助于对抗网络延迟。该实现还为根据延迟和机器性能动态调整每回合时长提供了空间,这部分内容将在后续文章中展开讨论。

相关定义

帧同步回合

帧同步回合可由多个游戏回合组成,玩家在一个帧同步回合中执行一个动作。帧同步回合的长度会根据性能进行调整,目前硬编码为200ms。

游戏回合

游戏回合是指游戏逻辑和物理模拟的更新。每个帧同步回合包含的游戏回合次数由性能控制,目前硬编码为50ms,即每个帧同步回合有4次游戏回合,也就是每秒有20次游戏回合。

动作

动作是玩家发起的一个命令,例如在某个区域内选中单位,或者将选中单位移动到目的地。需要注意的是,我们将不使用Unity3D的物理引擎,而是采用一个确定性的自定义引擎,具体实现会在后续文章中介绍。

游戏主循环

Unity3D的循环是单线程运行的,可通过在以下两个函数中插入自定义代码来实现特定功能:

  • Update():Unity3D的主循环每次遍历更新都会调用该函数。主循环会以最快速度运行,除非设置了固定的帧率。
  • FixedUpdate():该函数会根据设置每秒执行固定次数。在主循环遍历中,它的调用次数为零次或多次,具体取决于上次遍历所花费的时间。FixedUpdate()具有我们所期望的特性,即每次帧同步回合都执行固定时长。然而,其频率只能在运行前进行设置,而我们希望能够根据性能调节游戏帧率。

游戏帧回合

此实现的逻辑与在Update()函数中执行FixedUpdate()类似,主要区别在于我们可以调整频率。这是通过增加“累计时间”来实现的。每次调用Update()函数时,会将上次遍历所花费的时间(即Time.deltaTime)添加到累计时间中。若累计时间超过我们设定的固定游戏回合帧率(50ms),则调用gameframe()函数。每次调用gameframe()时,会从累计时间中减去50ms,直至累计时间小于50ms。

我们会跟踪当前帧同步回合中游戏帧的数量。当在帧同步回合中达到预定的游戏回合次数时,会将帧同步回合更新到下一轮。若帧同步还不能进入下一轮,则不会增加游戏帧,并且会在下一次继续进行帧同步检查。

在游戏回合中,物理模拟和游戏逻辑都会进行更新。游戏逻辑通过接口(IHasGameFrame)来实现,将实现该接口的对象添加到集合中,然后进行遍历。

IHasGameFrame接口包含一个名为GameFrameTurn的方法,该方法以当前每秒游戏帧的个数为参数。具体的带游戏逻辑的对象应基于GameFramesPerSecond进行计算。例如,若一个单位正在攻击另一个单位,且其攻击频率为每秒10点伤害,可将该伤害值除以GameFramesPerSecond来计算每次游戏帧的伤害。GameFramesPerSecond会根据性能进行调整。

IHasGameFrame接口还具有标记结束的属性,这使得实现该接口的对象能够通知游戏帧循环自身已经结束。例如,一个对象沿着路径行走,到达目的地后就不再需要参与后续的循环。

帧同步回合的同步检查

为了与其他客户端保持同步,每次帧同步回合都需要检查以下两个问题:

  • 我们是否已经收到了所有客户端的下一轮动作?
  • 每个客户端是否都确认收到了我们的动作?

我们使用两个对象ConfirmedActions和PendingActions,它们各自包含可能收到消息的集合。在进入下一个回合之前,会对这两个对象进行检查。

动作的通信

动作(即命令)通过实现IAction接口进行通信,该接口包含一个无参数的ProcessAction()函数。实现该接口的类必须是可序列化的,即该对象的所有字段都必须是可序列化的。当用户与UI交互时,会创建动作实例并将其发送到帧同步管理器的队列中。该队列通常在游戏运行较慢,用户在一个帧同步回合中发送多个命令时使用。虽然每次只能发送一个命令,但不会忽略任何命令。

当将动作发送给其他玩家时,动作实例会被序列化为字节数组,然后由其他玩家进行反序列化。当用户没有执行任何操作时,会发送一个默认的“非动作”对象,其他情况则根据特定游戏逻辑而定。这里有一个创建新单位的动作示例,该动作会依赖于SceneManager的静态引用。若不喜欢这种实现方式,可以修改IAction接口,使ProcessAction接收一个SceneManager实例。

实例代码可在下面找到: Bitbucket – Sample Lockstep

作者信息

洞悉

洞悉

共发布了 3994 篇文章