网游流畅基础:帧同步游戏开发
在现代多人游戏中,多个客户端之间的通讯,多以同步多方状态为主要目标。为了实现这个目标,主要有两个方向的技术:
一种叫状态同步:
客户端发送游戏动作到服务器,服务器收到后,计算游戏行为的结果,然后通过广播下发游戏中各种状态,客户端收到状态后显示内容。这种做法类似于各个客户端都远程操作服务器上的软件。最早的mud,以及后来大量的国产网游,特别是回合制游戏,都是这种方式;
另外一种叫帧同步:
客户端发送游戏动作到服务器,服务器广播转发所有客户端的动作(或者客户端直接通过P2P技术发送),客户端根据收到的所有游戏动作来做游戏运算和显示。这种做法等于客户端之间互相远程控制其他客户端上的游戏软件。早期的IPX网络游戏,如红色警戒、帝国时代、星际争霸,甚至大量的支持网络连线双打的游戏机模拟器,都是这种方式。
帧同步这种同步方式,主要依靠客户端的能力,服务器仅仅是做一个转发,甚至客户端可以无需服务器,仅仅通过P2P方式来转发数据。由于只转发游戏行为,所以广播的数据量比状态同步要小很多,非常时候游戏行为非常频繁的动作游戏,比如飞行射击、FPS、RTS这类游戏。由于状态同步要把整个游戏的状态都广播下去,如果游戏中的对象特别多,比如满屏幕的子弹,很多怪物,那么要广播的数据量就很大了,这个时候帧同步的优势就比较明显,因为不管有多少“机器控制的角色”,仅仅需要广播玩家角色有关的操作即可。反过来说,如果游戏里是大量玩家聚集起来进行游戏的,那么帧同步和状态同步的差异就不明显了。反而状态同步能得到更多安全性上的好处,因为游戏运算在服务器上,比较容易防止外挂。
帧同步技术最重要的基础概念是:
相同的输入+相同的时机=相同的显示
意思是如果我们的游戏,接受了来自网络的多个客户端的操作,如果这些操作在各个客户端是一样的,那么多个客户端的显示也就一样了,这就带来了“同步”的效果。所以在这种情况下,各个客户端的运算要绝对一致,不能依赖诸如本地时间、本地随机数等等“输入”,而要一切以网络来的操作数据为主。
在一般的帧同步系统中,会有一个Relay Server负责广播(转发)所有客户端的数据。为了让各个客户端能持续的运行,而不是卡住,所以需要定时的下发一个个“网络帧”数据来驱动各个客户端。因为客户端已经放弃了本地的时间,本地的循环驱动,所以这些“网络帧”就必不可少了。这些网络帧大部分实际上是“空”的,只有当玩家有输入的时候,才会把玩家的游戏操作的数据,填入到网络帧数据包中。对于客户端来说,就好像有很多键盘、鼠标、游戏手柄在通过网络操作自己一样。
一般来说,大多数的游戏客户端引擎,都会定时调用一个接口函数,这个函数由用户填写内容,用来修改和控制游戏中各种需要显示的内容。比如在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=23,040字节/秒,加上一些底层协议包头也就是24kB/s,这个速度看起来已经要求手机是3G网络才能支持了(实测中GPRS一般很难稳定到这个速度)。
我们使用的游戏引擎,特别是3D游戏引擎,里面使用的位置数据,大多数是浮点数,大家知道,一个浮点数需要占用8个字节,这可比简单的整数4个字节大了足足一倍。而我们需要广播的游戏操作,往往不需要那么高的精确度,所以我们应该把这些浮点数,想办法变成整数来广播。有时候我们甚至有可能只用1~2个字节(0-256-65535)来表达一个操作所需要的数字(比如按键值、鼠标坐标)。这样就能大大降低广播的数据长度。最简单的方法,就是把浮点数乘以1000或100然后取整。
另外一个降低广播数据量的做法就是自己编写序列化函数:一般现代编程语言,特别是面向对象的语言,都带有把对象序列化和反序列化的功能。我们要广播游戏操作的时候,这些操作往往也是一个个的“对象”,因此最简单的方法就是使用编程语言自带的序列化库来把对象转换成字节数组去广播。但是这些编程语言的默认序列化功能,为了实现诸如反射等高级功能,会把很多游戏逻辑所“不必要”的数据也序列化了,比如对象的类名、属性名什么的。如果我们自己去针对特定的数据对象来编写序列化函数,就没有这个问题了,我们可以仅仅提取我们想要的数据,甚至能合并和裁剪一些数据项,达到最小化数据长度的目的。
在网络游戏中,各个客户端的运行条件和环境往往千差万别,有的硬件好一些,有的差一些,各方的网络情况也不一致;时不时玩家的网络还会在游戏过程中,发生临时的拥堵,我们称之为“网络抖动”。网络游戏有时候还会需要有中途加入游戏的需求(乱入),有游戏录像和观看、快进录像的功能。这些功能,都可能导致客户端收到“过去时间”里的一堆网络帧,因此,客户端必须要有处理这些堆积起来的网络数据的能力。最简单的做法就是加速播放(快进)——如果收到网络数据处理完游戏逻辑后,然后在同一个渲染帧(同一次Update()函数里)内,马上继续收下一个网络数据,然后又立刻处理。这样往往能在一个渲染帧的时间内,加速赶上服务器广播的最新游戏进度。但是这样做也会有副作用,如果客户端积累的包太多(比如游戏已经开始玩了10分钟,新的用户中途加入),会导致这个用户长时间卡住,因为程序正在疯狂的下载积累的帧同步包和运算快进。为了解决这个问题,有些程序员会限制每一个渲染帧中所快进的操作次数,这样用户还是能看到画面有活动。如果实在要快进的进度太多,就要采用“快照”技术,通过定时保存的游戏状态数据,来减少快进的进度了。这个快照功能这里就不展开了。
一般来说,我们的客户端的渲染帧率都会大大高于网络帧的接收频率。如果我们每个渲染帧都去发送一次玩家操作(比如触摸屏上的手指位置),那么可能会导致发送的游戏操作远远大于收到的操作,这样做要么会让游戏操作堆积在服务器上,导致操作的严重延迟,要么导致下行的网络包非常大(服务器每次都把收到的所有操作一次下发),这样会让网络带宽占满,同样是会感觉延迟。不管怎么处理,都是不太好的结果。正确的做法应该是控制发包频率,最好是至少收到一个网络下行帧,才发送一个上行的游戏操作,避免堆积。另外,刚刚讲到的“快进”,如果我们在快速播放游戏逻辑的时候,每次播放同时也采集玩家输入去发送,那么同样会导致短时间内发送一大堆上行数据给服务器,而这些数据很可能客户端接收时产生大量的延迟。所以最好是在快进的时候不采集玩家的输入,因为玩家在看到快进过程中,实际上也很难有效的做出合理的反应,一个常见的做法,就是快进的时候,给游戏覆盖一个“等待”或“Loading”的蒙皮层,让玩家不可以输入操作。
实时同步游戏最重要的是流畅,然而影响游戏流畅的因素很多,网络带宽的限制,CPU运算和渲染效率的限制,都是很大的问题。所幸游戏本身还是有很多可以取舍的因素,这让我们可以牺牲一些游戏不太重要的特性,去提高流畅度。
第一个可以用来交换流畅度的是“一致性”特性。我们做帧同步的目标是各个客户端都能看到一致的显示。但是游戏内容有很多,有一部分内容是可以容忍“不一致”的,比如我们做飞行射击弹幕游戏,满屏幕有很多子弹,而每一颗子弹本身的存在的时间很短,如果我们不是做对打的游戏(而是一起打电脑),那么这些子弹是可以不一致的。又比如我们做一个横版过关的配合游戏,几个玩家一起打电脑控制的怪物,大家关心的是怪物是怎么被打死的,而玩法本身又比较容忍不一致(横版动作游戏的攻击范围往往比较大),所以就算有些不一致问题也不大。在以上的条件下,我们就可以尝试,把更多的游戏逻辑,从网络帧的UpdateByNet()函数里面拿出去,放回到单机游戏中的Update()函数里去。这样就算网络有点卡,起码整个画面里还是有很多东西是不会被“卡住”的。但是必须注意的是,一般玩家控制的角色的动作,包括当前客户端控制的角色,还是应该从网络帧里面获得行为数据,因为如果玩家爱控制角色不一致的太多,整个游戏场面就会差更多。很多游戏中的怪物AI都是根据玩家角色来设定的,所以一旦玩家角色的行为是同步的,那么大多数的怪物的表现还是一致的。
第二个可以用来交换流畅度的特性是实时性。一般来说,我们都希望游戏中的角色控制是灵敏的,实时的。我们的游戏角色往往在会玩家输入操作后的几十分之一秒内,就开始显示变化。在帧同步游戏中,我们可以让玩家一输入完操作,就立刻发包,然后尽快在下一个收到的网络帧中收到这个操作,从而尽快的完成显示。然而,网络并不是那么稳定,我们常常会发现一会快一会慢,这样玩家的操作体验就非常奇怪,无法预测输入动作后,角色会在什么时候起反应。这对于一些讲求操作实时性的游戏是很麻烦的。比如球类游戏,控制的角色跑的一会儿快一会儿慢,很难玩好“微操”。要解决这个问题,我们一般可以学习传输语音业务的做法,就是接收网络数据时,不立刻处理,而是给所有的操作增加一个固定的延迟,后在延迟的时间内,搜集多几个网络包,然后按固定的时间去播放(运算)。这样相当于做了一个网络帧的缓冲区,用来平滑那些一会儿快一会儿慢的数据包,改成匀速的运算。这种做法会让玩家感觉到一个固定延迟:输入操作后,最少要隔一段时间,才会起反应。但是起码这个延迟是固定的,可预计的,这对于游戏操作就便捷很多了,只要掌握了提前量,这个操作的感觉就好像角色有一定的“惯性”一样:按下跑并不立刻跑,松开跑不会立刻停,但这个惯性的时间是固定的。
第三个用来交换流畅性的特性是公平性,这个特性其实和一致性有所类似。我们和其他玩家一起游戏的时候,有时候不希望对方因为电脑速度比较快,网络比较好,而能比我们更早的看到游戏的运行结果,从而提早作出操作。这一点在格斗对打游戏(如《街霸》)里面非常关键,在一些RTS(《星际争霸》)里面,提早看到游戏运行结果也是很有竞争优势的。因此我们为了让网络、硬件不一样的玩家能公平游戏,往往会使用一种叫“锁步”的策略:就好像一串绑着脚镣的囚犯,他们只能一起抬起左脚,然后再一起抬起右脚的走路,谁也不能走的更快。技术上的实现,就是每个客户端都定时(每N个渲染帧)发送一个网络帧到服务器上,就算玩家没操作,也类似心跳的这样发送空数据帧,所有客户端都要完整的收到所有的其他客户端的“心跳帧”才能开始运算一次游戏逻辑。这就是让所有的客户端,都互相等待,如果任何一个客户端卡了,其他的客户端都立刻就能知道,然后弹出界面让玩家停止输入来等待。因此在很多场合,帧同步的技术也被成为“锁步”技术,事实上,在没有统一的Relay Server服务器的时代(IPX局域网连机对战的时代),帧同步的网络帧其实就是上面所说的某个客户端的“心跳帧”,是由某个客户端产生并广播的(比如以前的局域网游戏,都会由一个客户端充当Host主机)。在《星际争霸》连机游戏中,如果有一个玩家掉线了,所有其他玩家就会发现有一个界面弹出来挡住画面,表示在等某某某。这种做法实际上是牺牲了流畅度的,因为你会发现一旦有网络、硬件卡的玩家加入游戏,所有其他玩家都受他的影响。为了减少这种对流畅度的影响,我们可以在需要“锁步”的时候,尽量少锁一点,比如不是发现缺了一帧就停下来,而是缺了若干帧,还是可以以“不公平”的方式继续玩一会儿(比如几秒),如果这段时间内还是没有补齐所缺的帧,才宣布锁住游戏等待。当然这个“容忍”的帧数我们可以调节到“最大”——就是没有。那么一个完全不锁步的游戏,肯定不是一个公平的游戏,但是也会在流畅性产生最大的好处,就是完全不受其他玩家影响。在那些不是PVP(玩家对战)的帧同步游戏中,不公平这个往往问题不大。我们完全可以在游戏的不同玩法里,打开、调整、甚至关闭这个“锁步”的机制,从而让游戏最大程度的平衡公平性和流畅性。
帧同步游戏技术,并不存在一种可以让游戏流畅的通用做法,而是需要和游戏具体做很多结合,在减少数据包,优化游戏快进体验,控制发包速度上尽量调优。同时还需要和游戏产品策划一起,平衡一致性、实时性、公平性的策略,才能真正达到流畅游戏的目的。
欢迎关注我的公众号:“韩大”(handa1740168)和我直接交流。
近期热文