网络同步有两种同步方法--状态同步和指令同步(帧同步是一种常见的同步技术)。
根据所传递的消息的类型:物体的位置、旋转这样的状态值和“向前走”这样的指令值。前者是状态同步,后者是指令同步。
由于存在网络延迟和抖动,很难做到精确的同步。
真实的网络情况存在两个问题:
一、消息传播需要时间,会有延迟。
二、消息到达时间并不稳定,有时两条消息会相隔较长时间,有时候却相隔很短。
网络延迟问题基本无解,只能权衡,比如发送更少的数据,数据越少,发生数据丢失重传的概率就越小,平均数据越快。
或者在客户端上做一些特殊的处理(预测),让玩家感受不到延迟。
状态同步:同步状态信息
一、直接状态同步:客户端定时向服务端报告位置等信息,其他玩家收到转发的信息后,直接将对方操作的物体移动到指定的位置。
分析:
假设玩家1为发送位置信息的一方,玩家2为同步方,网络延迟为200ms。玩家1在经过B点时发送同步信息,经过一定的网络延迟,当玩家1已经走到C点时,玩家2才收到消息,这是两个客户端的误差为“速度 * 延迟”。假设玩家1在C点时又发送了位置信息,那么玩家2看到的同步坦克是瞬移的,直接从B跳到C,很不自然。
所以一般不会直接地使用同步位置。
二、跟随算法:一种障眼法,在收到同步协议后,客户端不会直接移动物体到目的地,而是让物体以一定的速度移动到目的地。Vector3.Lerp();
分析:
假设玩家1经过B点发送同步信息,玩家2收到后,将物体以同样的速度从A点移动到B点。这时小伙伴就要发问了,不是有网络延迟吗,当玩家2收到消息时,玩家1说不定已经移动到C点了。但是很多时候,游戏并不需要精确的同步,只要同步频率足够高(比如每秒发送30次),这些误差就可以忽略,比如打王者时,我们可以看到网络延时在30ms时,玩的是很流畅的。甚至100ms以内也勉强能玩。
三、预测算法:跟随算法的一大缺陷就是误差会变得很大。那么有没有办法可以减少误差呢,在某一些 规律可循的条件下,比如匀速运动,或者匀加速运动,我们能够预测坦克在接下来某个时间点的位置,让物体提前做到预测的位置上。这就是预测算法。我们打王者时可以明显的发现王者有使用这种算法:在网络卡顿时,人物就会漂移或者说飞出去。
分析:
假设物体匀速前进,玩家1经过B点时发送位置信息,玩家2根据“距离 = 速度 * 时间”,可以计算出下一次收到同步信息时,物体应移动到C点,于是让物体移向C点,玩家1和玩家2之间的误差就会很小。当然,如果预测的位置和实际传过来的消息位置不对,那么就要进行误差修正,这就比较复杂了(需要去网上查一下资料)。
帧同步:帧同步是指令同步的一种,即同步操作信息。基本上所有指令同步方法都结合了帧同步,两者可以视为一体。这里的帧与Unity中“每一帧执行一次Update”的Unity帧不同,需要自己独立实现。
指令同步:使用状态同步的话,一般最少需要同步6个值(三个坐标值和三个旋转值),而只传输玩家的操作指令的话,数据量就会减少很多。上面说过缓解网络延迟的一种方法就是减少传输的数据量。
分析:
当玩家1要移动物体,按下键盘的 ‘W’ 键时,玩家1会发送“向前走”的消息给服务端,经由转发,玩家2收到后,让同步物体向前移动。当玩家1要停止移动物体,会放开按键,发送 “停止” 指令,玩家2收到后,让坦克停止移动。
缺点:
上诉过程的一大缺点就是误差的积累,有些电脑速度快,有些慢,尽管玩家2收到了玩家1的指令,但只要两者的运行速度不同,可能就会有人看到物体走了很远,有人却看到物体只移动了一点点的距离。为了解决这个问题,人们在操作同步的基础上,引入了 “同步帧” 的概念。
由于采用 “速度 * 时间” 的计算公式,理论上说,无论电脑运行速度快慢,物体移动的路程都能够保持一致,因为当电脑很慢时,Update的执行次数就会变少,但Time.deltaTime的值会变大,反之异然,但物体移动的路程保持不变。
但是这样还不能保证经由网络同步的坦克能够有一致的行为,因为网络延迟的存在,从发出 “前进” 到 “停止” 指令之间的时间可能不一致,物体移动的路程也就不同。一种解决办法就是,在发送指令的时候附带时间信息,客户端根据指令的时间信息去修正路程的计算方式,使所有客户端表现一致。人们定义了一种名为 “帧” 的概念,来表示时间(为区别Unity本身的帧,这里称为“同步帧”)。
什么是同步帧:
假设我们自己实现一个类似Update的方法,称之为FrameUpdate,程序会在固定每隔0.1s就调用它一次。每一次调用FrameUpdate称之为一帧,第一次调用称为第1帧,第2次调用称为第2帧,以此类推。在第0.1s时执行第1帧,在第0.2s时执行第2帧。
然而这是一种理想情况。现实往往很残酷,比如在执行第2帧时,系统突然卡顿了一下,这一帧的执行时间变长,超过0.1s,这回导致第3帧无法按时执行。为了保证后面的帧能够按时执行,程序需要做出调整,即减少第2帧和第3帧之间,第3帧和第4帧之间的时间间隔,使程序在第0.5s时,执行到第5帧。
同步帧的具体实现如下:
点击查看代码
// 当前执行到第几帧
int frame = 0
// 两帧之间的理想间隔时间
float interval = 0.1f;
public void Update(){
while(Time.time > frame * interval){
FrameUpdate();
frame++;
}
}
在上述程序中,如果某几帧的执行时间太长,程序会立即调用下一帧,间隔时间为0,程序尽量保证在第N秒的时候,执行到10 * N帧,FrameUpdate每执行一次,表示执行一次同步帧。如果程序运行了较长的时间,FrameUpdate的执行频率会相对稳定。
同步帧所保证的,就是各个客户端在执行到同一个 “同步帧”时,表现效果完全一样。如果将移动物体的逻辑卸载FrameUpdate里,无论这一帧的执行时间多长,每一帧移动的距离都设定为 “速度 * 0.1s”,只要执行相同的帧数,移动的距离必然相同。
指令的执行:
为了保证所有客户端有一样的表现,往往需要做一些妥协,有两种常见的妥协方案。
一、有的客户端运行速度块,有的运行慢,如果要让他们表现一致,那只能让块的客户端去等待慢的客户端,所有客户端和最慢的客户端保持一致,才有可能表现一致,毕竟,慢的客户端如何都快不了。这种方案对速度快的客户端较为不利。达成此方案的一个方法称为延迟执行,如果客户端1在第3帧发出向前的指令,由于网络延迟,客户端2可能在第5帧才收到,所以客户端1的物体也只能在第5帧(或之后的某一帧)才开始前进。
二、对于速度慢的客户端所发送的,丢弃那些已经过时的指令,知道它赶上来。这种方案也称之为乐观帧同步,对速度慢的玩家较为不利,因为某些操作指令会被丢弃。比如发出 “前进” 指令,但该指令被丢弃了,物体不会移动。
所以,帧同步是一种为了保证多个客户端表现一致,让某些客户端做妥协的方案,而且如果启用了延迟执行,在玩家发出 “前进” 指令之后,要隔一小段时间物体才能移动,玩家会感受到延迟。但无论如何,只要帧率足够高,玩家就不会感觉到明显的延迟。
在方案一中,为了让各个客户端知道对方是否执行完某一帧,我们假定客户端每一帧都需要向服务端发送指令,没有操作也要发送一个代表 “没有操作” 的指令。服务端要收集各个客户端的指令,收集满时,才在接下来的某一帧广播出去。而客户端也只有在收到服务端的消息时,才执行下一帧。此时客户端的帧调用完全由服务端控制。
按照每秒执行30帧的频率,客户端和服务端之间的信息交流可能太过频繁,会带来较大的网络负担,于是人们把多个帧合称为一轮(比如4帧组成一轮),每一轮向服务端同步一次指令。
帧同步还可以配合投票法来防止作弊,例如,某一个玩家击中另外一个玩家,由于所有客户端的运行结果严格一致,它们都可以向服务端发送 “谁击中了谁” 的消息。服务端可以收集这些信息,如果半数以上的玩家都发送了击中信息,才认为有效。