哪些游戏适合这种帧同步技术?
在现代多人游戏中,多个客户端之间通信的主要目标是同步多方状态。为了实现这一目标,技术有两个主要方向:
一种称为状态同步:
客户端将游戏动作发送给服务器,服务器接收到后,计算出游戏行为的结果,然后通过广播发送游戏中的各种状态,客户端在收到状态后展示内容。这类似于每个客户端在服务器上远程操作软件的方式。最早的MUD,以及后来大量的国产网络游戏,尤其是回合制游戏,都是这样;
另一种称为帧同步
客户端将游戏动作发送到服务器,服务器广播转发客户端的所有动作(或客户端直接通过P2P技术发送),客户端根据接收到的所有游戏动作进行游戏计算和显示。这相当于客户端在其他客户端上远程控制游戏软件。早期的IPX网络游戏,如《红色警戒》、《帝国时代》、《星际争霸》,甚至大量支持在线连接替身的主机模拟器,都是这样。
帧同步是一种主要取决于客户端能力的同步方式,服务器只做一个转发,甚至客户端在没有服务器的情况下只能通过P2P转发数据。由于只转发游戏行为,因此广播的数据量远小于状态同步,游戏行为非常频繁的动作游戏在非常时期非常频繁,如飞行射击、FPS、RTS等游戏。由于状态同步想要广播整个游戏的状态,如果游戏中有很多物体,比如屏幕上的子弹,很多怪物,那么要播出的数据量就非常大了,而帧同步的优势此时就更加明显了,因为无论有多少个“机器控制的角色”, 只需要广播与玩家角色相关的动作。反之,如果一个游戏是和大量玩家一起玩的,帧同步和状态同步的区别就不明显了。相反,状态同步可以获得更多的安全优势,因为游戏操作在服务器上,更容易防止作弊。
帧同步技术的基本概念
帧同步技术最重要的基本概念是:
相同的输入 + 相同的时序 = 相同的显示
这意味着,如果我们的游戏接受来自网络的多个客户端的操作,如果这些操作在每个客户端上都是相同的,那么多个客户端的显示将是相同的,这就带来了“同步”的效果。因此,在这种情况下,每个客户端的操作应该是绝对一致的,不应该依赖本地时间、本地随机数等“输入”,而应该基于网络的运行数据。
在典型的帧同步系统中,有一个中继服务器负责广播(转发)来自所有客户端的数据。为了让每个客户端连续运行而不卡住,有必要定期发送“网络帧”数据来驱动每个客户端。因为客户端已经放弃了本地时间,本地环路驱动器,所以这些“网络帧”是必不可少的。这些网络帧其实大多是“空”的,只有当玩家有输入时,才会将玩家的游戏操作数据填充到网络帧数据包中。对于客户端来说,就好像有很多键盘、鼠标和游戏手柄在网络上运行一样。
一般来说,大多数游戏客户端引擎都会周期性地调用一个接口函数,由用户填写,用于修改和控制游戏中需要展示的各种内容。例如,它在 Flash 中称为 OnEnterFrame(),在 Unity 中称为 Update()。这种类型的函数通常在渲染每一帧之前调用,当用户更改游戏中每个角色的位置和大小时,它就会显示在下一帧中。在帧同步游戏中,这个 Update() 函数仍然存在,但大部分内容需要移动到另一个类似的函数,我们可以调用 UpdateByNet() 函数——网络层持续接收服务器发送的“网络帧”数据包,并且每次收到这样的数据包,都会调用一次 UpdateByNet() 函数, 这样游戏就可以通过本地 CPU 由 Update() 函数驱动。更改为基于网络的 UpdateByNet() 函数驱动程序。显然,网络发送的同步帧速度会明显慢于本地CPU,这就对我们的游戏逻辑开发提出了更高的要求——如何在保证流畅性的同时同步?
帧同步的技术要点
在帧同步游戏中,广播的频率非常高,因为数据需要“每一帧”广播,这就要求数据足够小,适合每次广播。每个网络帧最好少于一个MTU,这样可以有效降低底层网络的时延。出于同样的原因,为了提高实时性能,我们一般更喜欢使用 UDP 而不是 TCP,这样底层处理会更有效率。但是,这也带来了丢包和乱序的可能性。因此,我们经常使用冗余方法——例如,每个帧包实际上包含最后 2 帧的数据,即每次发送的 3 帧的数据,以对抗丢包。也就是说,只要三个包中的一个没有丢失,就不会影响游戏。此外,我们会在 RelayServer 上存储大量客户端上传的数据,如果客户端发现数据包丢失(如果出现故障,则视为丢失),那么它会发起“下载”请求,从服务器重新下载丢失的帧数据包(这可能使用 TCP)。所有这些都取决于每个帧是否足够小。因此,我们一般要求客户端每次发送的数据都应小于 128 字节。你可以粗略计算一下,如果我们的游戏有 4 个玩家,我们的冗余是 3 帧,那么一个下行网络帧报文的大小将是 128x4x3=1536 字节,我们每秒发送 15 个网络帧,那么占用的带宽将是 1536x15=23040 字节/秒,再加上一些底层协议报头是 24kB/s, 这个速度似乎需要手机有3G网络支持(GPRS在实际测量中一般很难稳定到这个速度)。
这
我们使用的游戏引擎,尤其是 3D 游戏引擎,使用其中的大部分位置数据都是浮点数,如您所知,浮点数占用 8 个字节,是 4 个字节的简单整数的两倍多。而我们需要广播的游戏操作往往不需要那么准确,所以我们应该把这些浮点数,想办法把它们变成整数来广播。有时我们甚至可能只用 1~2 个字节 (0-256-65535) 来表示一个操作所需的数字(如键值、鼠标坐标)。这大大缩短了广播的数据长度。最简单的方法是将浮点数乘以 1000 或 100 并将其四舍五入。
减少广播数据量的另一种方法是编写自己的序列化函数:一般而言,现代编程语言,特别是面向对象语言,具有序列化和反序列化对象的能力。当我们要广播游戏操作时,这些操作通常也是“对象”,所以最简单的方法就是使用编程语言内置的序列化库将对象转换为字节数组进行广播。但是,这些编程语言默认的序列化功能,为了实现反射等高级功能,也会序列化大量对于游戏逻辑来说“不需要”的数据,比如对象的类名、属性名等。如果我们为特定的数据对象编写自己的序列化函数,我们就不会有这个问题,我们可以提取我们想要的数据,甚至可以合并和修剪一些数据项以最小化数据的长度。
在网络游戏中,每个客户端的运行条件和环境往往差异很大,有的硬件更好,有的更差,各方的网络状况也不一致;在游戏过程中,玩家的网络也会不时出现暂时的拥塞,我们称之为“网络抖动”。网络游戏有时需要在游戏过程中加入(随机进入)网络游戏开发,并且有游戏录制、观看、快进录制等功能。所有这些功能都可能导致客户端在“过去时间”接收到一堆网络帧,因此客户端必须有能力处理这些网络数据堆。最简单的方法是加快播放速度(快进)——如果你收到网络数据并处理游戏逻辑,那么立即在同一渲染帧内(在同一个 Update() 函数中),继续接收下一个网络数据,然后立即处理它。这通常会加快服务器在单个渲染帧中广播的最新游戏进度。但是,这也有一个副作用,如果客户端累积的包太多(例如,游戏已经玩了 10 分钟,游戏中途有新用户加入),会导致用户长时间卡顿,因为程序正在疯狂下载累积的帧同步包并快进计算。为了解决这个问题,一些程序员限制了每个渲染帧的快进操作次数,以便用户仍然可以看到处于活动状态的图像。如果实在是太想快进太多了,就需要使用“快照”技术,通过定时保存游戏状态数据来减少快进的进度。此处不会展开此快照功能。
通常,我们的客户将以比网络帧接收频率高得多的帧速率进行渲染。如果我们在每个渲染帧发送一次玩家动作(例如触摸屏上的手指位置),那么它可能会导致游戏动作的发送量比接收到的动作大得多,这将导致游戏动作堆积在服务器上,导致操作出现显着延迟,或者下游网络数据包将非常大(服务器将同时交付它收到的所有动作time),这将填满网络带宽,也会感觉延迟。不管你怎么处理,都不是一个好结果。正确的做法应该是控制报文的递送频率,最好在发送上行游戏操作前至少接收到一个下行帧,避免累积。另外,前面提到的“快进”,如果我们每次玩游戏逻辑时都收集玩家输入并发送,也会造成大量的上游数据在短时间内发送到服务器,而这些数据在客户端接收到时很可能会造成很大的延迟。因此,快进时最好不要收集玩家的输入,因为玩家在看到快进过程时,其实很难做出有效的合理反应,一种常见的做法是在快进时用“等待”或“加载中”的皮肤层覆盖游戏,这样玩家就无法输入操作。
优化流畅度
这
游戏实时同步最重要的是流畅度,但影响游戏流畅度的因素很多,比如网络带宽的限制、CPU计算和渲染效率等都是大问题。幸运的是,有很多权衡可以作为游戏不太重要的功能的权衡,以支持流畅性。
可以用来交换流利度的第一件事是“一致性”功能。帧同步的目标是在客户端之间看到一致的显示。但是游戏中有很多内容,而且有一部分内容可以容忍“不一致”,比如我们做一个飞射弹幕游戏,屏幕上有很多子弹,而且每颗子弹本身存在的时间很短,如果我们不是在做陪练游戏(而是一起玩电脑), 那么这些项目符号可能不一致。再比如,我们做了一个横向卷轴游戏,几个玩家一起对抗电脑控制的怪物,大家关心怪物是怎么被杀死的,游戏玩法本身对不一致的容忍度比较高(横向卷轴动作游戏的攻击范围往往更大),所以即使有一些不一致,也不是什么大问题。在上述条件下,我们可以尝试从网络帧的 UpdateByNet() 函数中取出更多的游戏逻辑,并将其放回单人游戏中的 Update() 函数中。这样一来,就算网络有点卡住了,至少整个画面中还有很多东西不会被“卡住”。但是必须注意的是,一般玩家控制的角色的动作,包括目前由客户端控制的角色,还是应该从网络帧中获取行为数据,因为如果玩家喜欢控制角色太不一致,整个游戏场景会差很多。很多游戏中的怪物AI都是根据玩家角色设置的,所以一旦玩家角色的行为同步,那么大多数怪物的行为还是一样的。
可用于交换流利度的第二个功能是实时。总的来说,我们都希望游戏中的角色控制具有响应性和实时性。我们的游戏角色往往会在玩家输入后的十分之一秒内开始显示变化。在帧同步游戏中,我们可以让玩家在输入操作后立即发送包裹,然后在下一个接收到的网络帧中尽快接收到操作,从而尽快完成显示。但是网络游戏开发,网络并不那么稳定,我们经常发现它有快有慢,这让玩家有一种奇怪的体验,无法预测角色何时会对输入动作做出反应,这很奇怪。对于需要实时操作的游戏来说,这可能是一个麻烦。例如,在球类游戏中,被控制的角色一会儿跑快一会儿慢,很难玩好“微操”。为了解决这个问题,我们一般可以学习传输语音业务的做法,即在接收网络数据时,不会立即处理,而是在所有操作中增加一个固定的延迟,然后在延迟时间内再收集几个网络数据包,然后按照固定的时间播放(计算)。这相当于制作了一个网络帧的缓冲区,用于平滑那些一会儿快慢的数据包,然后把它们改成匀速操作。这将导致玩家感知到一个固定的延迟:在输入动作之后,它至少需要一段时间才能做出反应。但至少这个延迟是固定的,可预测的,这对于游戏操作来说要方便得多,只要掌握了前进的量,这个操作感觉好像角色有一定的“惯性”:按下奔跑不会立即运行,释放奔跑不会立即停止,但这种惯性的时间是固定的。
用于交换流畅性的第三个特征是公平性,这类似于一致性。当我们和其他玩家一起玩的时候,有时候我们不希望对方比我们更早看到游戏结果,因为电脑更快,网络更好,这样他们就可以更早行动。这在格斗游戏(如《街头霸王》)中非常重要,在一些RTS(星际争霸)游戏中,看看游戏早期的玩法也非常有竞争力。因此,为了让拥有不同网络和硬件的玩家公平发挥,我们经常使用一种叫做“锁步”的策略:就像一串戴着镣铐的囚犯,他们只能左脚并拢走路,然后右脚一起抬起,谁也走得更快。技术实现是每个客户端定期向服务器发送一个网络帧(每N个渲染帧),即使玩家不操作,它也会像心跳一样发送空数据帧,并且所有客户端都必须接收到所有其他客户端的“心跳帧”才能开始计算游戏逻辑。这是为了让所有客户端互相等待,如果有客户端卡住了,其他客户端会立即知道,然后会弹出界面让玩家停止打字等待。事实上,在没有统一中继服务器的时代(IPX LAN网络对战的时代),帧同步网络帧实际上是上面提到的客户端的“心跳帧”,它是由客户端生成和广播的(例如,在之前的局域网游戏中,客户端会充当主机)。在《星际争霸》主机游戏中,如果一个玩家掉落,所有其他玩家都会发现弹出一个界面挡住了屏幕,表示他们在等某某。这实际上是以牺牲流畅性为代价的,因为您会发现,一旦拥有互联网或硬件卡的玩家加入游戏,所有其他玩家都会受到他的影响。为了减少这种对流畅度的影响,我们可以在需要“锁定步进”的时候尽量少锁一点,比如说,当我们发现缺少一帧时,而不是停止,但是如果我们错过了几帧,我们可以继续以“不公平”的方式玩一段时间(比如几秒钟), 如果我们在这段时间内仍然没有弥补缺失的帧数,我们将宣布锁定游戏并等待。当然,我们可以将这个“容忍”的帧速率调整到“最大”——只是没有。完全没有锁定的游戏当然不是公平的游戏,但它在流畅度方面也会有最大的好处,那就是完全不受其他玩家的影响。在非 PVP 的帧同步游戏中,不公平通常不是什么大问题。我们可以用不同的玩法开启、调整甚至关闭这个“锁步”机制,从而最大限度地平衡游戏的公平性和流畅性。
总结
帧同步游戏技术没有可以使游戏流畅的通用做法,而是需要与游戏相结合,减少数据包,优化游戏快进体验,并尽可能控制数据包的传递速度。同时,还需要配合游戏产品策划,平衡一致性、实时性、公平性的策略,才能真正达到流畅游戏的目的。