作为一个程序员,你有没有想象过多人游戏是如何实现的?
在外行人看来游戏很神奇:两个或者更多的玩家在网络上分享共同的经历,就像他们真实的存在于相同的虚拟的世界一样。游戏看起来犹如一个巨大的魔术,奇妙而又刺激,但作为一个开发人员我们知道,真实的情况和我们所看到的并不一样,那只是一种错觉。你感受到的共享现实,实际上是在那个时刻内,由你自己的独特视角和位置所感知的近似情况。
一、Peer-to-Peer 帧同步
最初的游戏是通过peer-to-peer来联网的,每个计算机通过网状拓扑的结构的彼此连接并交换信息。你仍然可以看到这种模型存在于RTS游戏中,而且基于某些原因它还很有趣,也许是因为它是大多数人认为游戏网络工作方式的第一种方式。
处理游戏信息的基本思想就是把游戏的数据抽象并转换成一系列命令消息,当处理每个转换的时候就直接演变为游戏的状态。比如:移动单位、攻击物体、建造建筑。这一切都需要在线的每个玩家机器,从一个初始化命令开始之后,都运行完全相同的命令和转换数据。
当然了,这只是一个过于简单的解释,同时也隐去了很多细节,不过我们通过这个基本的思路可以知道RTS游戏的网络是如何工作的。
这些看起来是如此简单和优雅,但不幸的它们有几个因素限制者我们。
第一个限制,要保证游戏状态完全确定一致的是异常困难,特别是保持每台机器上每个转换输出都保持相同。比如,一个单位在两台机器上有略微不同的路径,在一台机器上早一些到达并开始了战斗,结果反败为胜,而在另一台机器上,由于稍微晚一些到达而失败。就像一只蝴蝶扇动了翅膀,然后在世界的另一边导致了飓风的出现,随着时间的推移,一个微小的区别就会导致两边完全的不同步。
第二个限制,为了保证游戏的所有玩家输出一致,这就需要等到所有玩家的当前回合数据都到达之后才可以模拟播放这一回合动作。这就意味着游戏中的每一个玩家都需要等待网络延迟最高的那个玩家。RTS游戏通常代表性地通过立即提供音频反馈与(或是)播放吟唱(过渡)动画来掩盖这段延迟,但是最终真正影响游戏的动作要在这段延迟过去之后才能进行。
第三个限制,因为游戏中状态改变的同步是通过发送命令信息来同步的。所以为了游戏中玩家状态都一致,需要所有的玩家都要从相同的初始状态来开始游戏。这意味着每个玩家必须在开始游戏之前先加入房间然后一起开始游戏,尽管理论上也可以支持让某些玩家晚些加入游戏,但是在一场进行中的游戏中获得一个完全确定的起始点的难度相当大,所以这种情况并不常见。
尽管有这些因素限制困扰者我们,不过这个模型还是很适合RTS游戏的,并且它仍然存在于今天的游戏当中,例如“Command and Conquer”、“Age of Empires”与“Starcraft”等。原因就是在RTS游戏中,里面包含了上千多的单位,这些单位都有自己的状态需要同步,而且他们数据量都太大了,很难用来在玩家之间交换。别无选择,我们只能通过这些游戏状态改变的命令来同步。所以以上这些就是 peer-to-peer 帧同步的网路游戏模型的介绍了,对于其他类型的游戏,最先进的技术已经开始出现了。让我们现在从Doom, Quake 以及 Unreal经典游戏中开始一起观察动作游戏的技术演化。
二、客户端/服务器(c/s架构)
在动作游戏的时代,以上帧同步的限制在Doom 游戏中变得更加明显,尽管在局域网中体验还不错,但在对于互联网的用户来说它体验太糟糕了:
尽管可以使用一个猫(调制解调器)把两个 Doom 机器通过互联网连接在一起,但他们一起游戏会异常缓慢。范围从无法游戏(例如:14.4Kbps PPP 连接)到稍微可以玩(例如 :28.8Kbps 猫运行一个被SLIP驱动压缩的数据)之间游戏联机都异常缓慢。由于这些连接方式只是边际效用,本文将仅关注直接的网络连接。
这个问题是因为Doom网络部分本来就是只为局域网而设计的,并且使用了前面介绍的peer-to-peer 帧同步模型。每一回合每个玩家的输入的信息(比如关键按键等)都与其他人进行同步通知,并且任何玩家在播放这一帧动画之前,必须得等到所有其他玩家的关键按键信息都被接收到,才可以去模拟播放。
也就是说,在你可以转身(转换),移动或者射击之前,你必须等待延迟最大的猫(调制调解器)玩家的输入。只是想想上述那个人所写的“这些连接方式只是边际效用”就会让人咬牙切齿和沮丧了。
为了改变这种现状,只能在局域网以及大学网络和大型企业才能获得良好连接而进行游戏,是需要改变这种网络模型了。在1996年,这变成了现实并被实现了,John Carmack当时 发布雷神之锤,他采用客户端/服务器(C/S)架构代替了P2P模型。
如今游戏中的玩家可以不必再运行相同的代码以及直接相互通信,每个玩家的机器是都是一个“客户端”,他们都通过一台叫做“服务器”的机器进行通信交互。游戏的最终状态确定不再依赖于每台客户端机器来共同确认,而是由服务器来确定最终结果。每个客户端如同一个哑终端,用来展示一个近似值的表演,真是的游戏状态是运行于服务器之上。
在一个纯粹的c/s架构中,你不必在本地运行游戏代码,而是把一些例如按键、鼠标移动,点击等输入信息发送到服务器。服务器会在游戏世界中更新你的玩家状态,然后再封包一个包含你角色信息以及临近玩家数据的包回复给你的客户端。所有的客户端在每个消息更新的间隙做一个插值预测,以改善在每个状态更新期间,物体可以平滑的移动,如此,你就有一个可以联网的客户端/服务器架构的游戏了。
这已经是向前迈出了极大的一步。游戏的体验依赖于客户端和服务器的连接,而不是游戏中延迟最大的那个玩家。如此可以支持玩家在游戏中自由的进入和退出,同时由于客户端/服务器降低了平均每位玩家的带宽,从而可以增加更多的在线玩家。
但是这里仍然有一些问题存在于 c/s 架构中:
我记得我交代了所有从DOO到Quake中关于网络的决策,但是重要的是我正在使用错误的假设来做一个好的网络游戏。我原先设计的目标是网络延迟<200ms。人们通过一个好的网络供应商连接互联网,从而可以获得一个好的游戏体验。但事与愿违,世界上99%的用户使用猫(调制调解器)通过 slip或者ppp 进行连接,而他们常常都会通过槽糕而又拥挤的ISP。这会带来最低300+ms 的 网络延迟。一个消息要经过,客户端>用户猫>ISP猫>服务器>ISP猫>用户猫>客户端。上帝,这太逊了。
OK,我做了一个错误的设定。我在家里使用T1 宽带,所以我只是不了解在PPP网络下的生活。我现在就解决它。
这个问题当然是延迟。
接下来John在他发布QuakeWorld的时候将改变这个行业。
三、客户端预测(Client-Side Prediction)
在原来的Quake游戏中,你会感觉到电脑与服务器之间的延迟。比如,你按键向前移动,在你真正移动之前,你需要等到数据包发送服务器然后再回复到你的客户端,你才可以真正的移动。按键开火,在你的射击之前同样需要相同的等待。
如果你玩过任何FPS游戏,比如:Modern Warfar,你会发现并没有延迟发生。那么fps游戏是如何做到在多人情况下,你的动作看起来并没有延迟?
这个问题被分为两个部分来解决。第一个部分是客户端移动预测,这事John Carmack 为 QuakeWorld游戏多开发的,后来被合并到了Tim Sweeney的虚幻网络模块。第二个部分就是延迟补偿,它是有Valve公司的Yahn Bernier在Counterstrike所开发。那么在这个章节,我们把焦点放在第一部分——隐藏用户移动的延迟。
当写到关于他即将发布的QuakeWorld计划的时候,John Carmack 讲到:
我现在允许客户端可以预测用户的移动,直到服务器的权威信息回复之前。这是一个重大的结构变更。客户端需要知道关于对象的硬度、摩擦力、重力等一系列基础属性。我很伤心的看到,客户端仅作为一个终端存在将会离开,但作为一个实用主义者,我必须超越这种理想情怀。
那么现在我们为了消除延迟,客户端需要运行更多的代码。它现在不再是一个只把输入发送给服务器然后再把返回信息进行插入的哑终端。现在客户端的机器可以运行一部分游戏代码,它可以在本地预测你的角色移动并且可以即时响应你的输入。
现在当你即刻按键向前,你的游戏会立刻向前移动,不会再去等待数据往返一次客户端和服务器之间才来回应你的操作。
这种方式的难点不在于预测,这种预测工作,就像正常的游戏代码一样 —— 根据玩家的输入,及时地更新游戏角色的状态。而难点在于,当客户端和服务器对于玩家角色所做的事情(动作)核检不一致的时候,客户端如何基于服务器信息进行更正。
现在你会想,hey,如果代码运行在客户端——为何不以客户端的信息为准?客户端可以自己的为角色模拟运行代码,并且只需要在每次发送数据包时告知服务器这些信息。如果每个客户端都对服务器发送相同的信息,告诉服务器“这是我现在的位置信息”,那么将会带来这样的问题。客户端会很容易被黑客攻击并控制,这样在RPG游戏中,一个作弊便可以立即躲避对方技能击中,或者当你射击的时候瞬间移动到你的身后。
所以在FPS游戏中,尽快每个玩家的客户端可以预测他们自己的角色进行操作移动,但最终每个玩家的角色状态绝对以服务器为准。
这就是有趣的地方。如果客户端和服务器产生了不一致,客户端必须基于服务器的信息为准并更新,但是由于客户端和服务器之前有延迟,服务器的修正必然是过去的动作。比如,如果信息从客户端到服务器耗时100ms,然后返回又耗时100ms,那么任何服务器的的修正都是客户端200ms之前的行为动作,这个时间正好是客户端预测角色移动的时间。
如果客户端每个动作都会被服务器修正,那么你将会看客户端被拉回了原先的位置,如此客户端将做不了任何预先预测的运算。那么我们如何解决这个问题,依然可以保持客户端提前预测?
解决方案就是在客户端创建一个buffer,然后用来循环保持角色的状态以及本来玩家的输入。当客户端收到了服务器的更正信息时候,它首先丢弃掉buffer里面比(服务器回复的)更正状态要老的状态信息,然后基于(更正的)正确的状态重放存储在buffer里面的输入信息,重发的这些输入信息的范围是从正确状态到当前预测时间之间。如此实际上,客户端只是看似无形中“倒带和重放”当地玩家角色运动的最后n帧,同时保持世界其他地方没有变化。
这种方法可以让玩家感觉在控制游戏的时候没有延迟,同时也改善了客户端和服务器之间代码运行的一致性——在同等输入的情况下保持一致的结果。当然了,修正的情况很少发生,Tim Sweeney 如此描述:
…对于客户端和服务器最好的是:所有情况下,服务器都是权威的。几乎所有的时间,客户端模拟的和服务器的数据都是一致,所以客户端的位置很少被修正。只有的少数罕见的情况下,例如一个玩家被火箭击中,或者和一个敌人(怪物)碰撞上,那么客户端本地的情况有可能需要被修正。
也就是说,只有当玩家的角色被一些外部事件影响玩家的输入,并且这些不能被客户端预测时,玩家的位置(行为)需要被服务器修正。当然,如果玩家试图作弊,必然会被服务器修正。