Unity中帧同步的实现(一):帧同步长度固定间隔下的实现
在帧同步模型里,每个客户端都会对整个游戏世界进行模拟。这种方式的优势在于减少了需要传输的信息。帧同步仅需发送用户的输入信息,而与之相对的中心服务器模型,单位信息的发送则是越频繁越好。
例如,在游戏世界中移动角色。在中心服务器模型下,物理模拟仅在服务器端执行。客户端告知服务器角色的移动方向,服务器执行寻路并移动角色,随后尽可能频繁地将该角色的位置信息告知每个客户端。对于游戏世界中的每个角色,都要重复这一过程。对于实时策略游戏而言,在中心服务器模型中同步成千上万的单位几乎是一项不可能完成的任务。
而在帧同步模型中,当用户决定移动角色后,会通知所有客户端。每个客户端都会自行执行寻路并更新角色位置。只有在用户输入时,才需要通知各个客户端,之后每个客户端会自行更新物理状态和位置。
不过,这种模型也带来了一些问题。首先,每个客户端的模拟必须完全一致,这意味着物理模拟的更新次数要相同,且每个动作的执行顺序也需一致。若不满足这一条件,某个客户端可能会领先或落后于其他客户端,当新命令发出后,速度过快或过慢的客户端所走出的路径就会出现差异,这种差异会因游戏玩法的不同而有所变化。
另一个问题是跨不同机器和平台的确定性问题。计算上的微小差异都可能对游戏产生蝴蝶效应,这个问题将在后续文章中详细探讨。
本文的实现方案灵感源自文章《1500个弓箭手》。每个玩家的命令都会在后续的两个回合中执行。在发送动作与处理动作之间设置延迟,有助于应对网络延迟。这种实现方式还为我们根据延迟和机器性能动态调整每回合时长提供了空间,这部分内容将在后续文章中展开讨论。
相关定义
帧同步回合
帧同步回合可由多个游戏回合组成。玩家在一个帧同步回合中执行一个动作。帧同步回合的长度会根据性能进行调整,目前硬编码为200ms。
游戏回合
游戏回合即游戏逻辑和物理模拟的更新。每个帧同步回合包含的游戏回合次数由性能控制,目前硬编码为50ms,即每个帧同步回合有4次游戏回合,也就是每秒有20次游戏回合。
动作
动作是玩家发起的一个命令,例如在某个区域内选中单位,或者将选中单位移动到目的地。
注意事项
我们将不使用Unity3D的物理引擎,而是采用一个确定性的自定义引擎,具体实现会在后续文章中给出。
游戏主循环
Unity3D的循环是单线程运行的,可通过在两个函数中插入自定义代码来实现特定功能。
Unity3D的主循环每次遍历更新时都会调用Update()方法。主循环会以最快速度运行,除非设置了固定帧率。
FixedUpdate()会根据设定每秒执行固定次数。在主循环遍历过程中,它会被调用零次或多次,具体取决于上次遍历所花费的时间。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