记一下自己对网络同步的理解。
网络同步主要有二种,帧同步和状态同步。
帧同步
帧锁定同步算法。具体不介绍。
大致意思是客户端和客户端每一帧发送“cmd”,客户端收到所有的输入后,根据这些cmd模拟一帧。因此网络延迟与网络状况最不好的玩家有关。
状态同步
主要参考https://developer.valvesoftware.com/wiki/Source_Multiplayer_Networking
之前参与开发的一款FPS游戏就是基于这个架构。
假设是一个Client/Sever架构。对于一个游戏,理想的情况下,服务器每一帧把客户端的输入收集并计算结果发给客户端,客户端只负责表现结果(即像看一段实时视屏,没有运行逻辑)。
基本网络
正常情况下,服务端会有一个运行帧率。比如15ms,即每秒66.6帧。服务器每一帧处理用户的cmd,运行物理模拟,运行游戏逻辑,更新entity的状态。模拟结束后,服务器选择对客户端发送快照(snapshot,当前游戏的状态)。如果加大运行帧率会提高模拟的表现,但是对服务器的cpu负担和客户端的带宽就会有更高的要求。一般客户端的带宽是有限的,如果服务器发送的高频率数据,客户端会有数据包丢失。同样客户端发送的cmd也会根据自己的带宽设置(每秒发送多少个cmd)。
客户端每一帧向服务器发送指令(移动,开火等),服务器收到处理后返回游戏世界所有实体的快照,客户端收到这个快照(这个时间叫latency或者叫ping或者叫round trip time)。可以知道这个lantency越小,游戏的表现越好。
因为网络延迟是不可避免的,那么使得游戏的表现更好呢?
使用预测和延迟补偿技术就是为了提升游戏表现(减小高延迟玩家和低延迟玩家的不公平)。
差值
由于客户端收到的一个一个快照(正常是20个每秒),如果只是根据快照去更新的话,表现会非常卡,除非快照频率非常高(对服务器来说CPU和网络的压力会大大增加)。为了解决这个问题,客户端引入了快照缓存(snapshot history),根据网络状况和参数,计算一个延时值,比如当前时间的前0.1s(这个时间叫RenderTime),然后根据RenderTime计算在这个RenderTime二侧的SnapShot,根据时间进行差值然后渲染。这个时间一般是大于二个snapshot发送过来的时长,这样即使有一个snapshot由于网络原因丢失了,另外一个包还是可以进行差值。
按照上图为例,当前的时间是10.32s,最新收到的快照是344,假设差值延迟是0.1s,那么rendering time为10.22,当前的渲染帧数据可以根据340和342差值得到。如果342快照丢失了,我们还可以根据340和344快照来进行差值。如果342和344都丢失了,那么差值就会工作不正常,需要使用额外的差值(extrapolation, 一种简单的做法是复制当前帧)
预测
假设我们的网络延迟(latency)为150ms,我们在客户端按w,想要移动角色,客户端A把向前移动的cmd发送给服务器,服务器收到命令后,把客户端A的状态设置为向前,并同步给其他客户端,其他客户端看到A开始移动了。
上面的流程,表现在客户端A为不流畅,操作迟钝。使用客户端预测可以去除这个延迟并使操作更加流畅。不等到服务器把A的位置更新,客户端预测执行命令的结果。这需要客户端和服务器需要对这个命令有共同的处理结果(即需要共用代码)。预测结束后A移动到了新位置,服务器上A的数据还是在旧位置。
过了150ms,客户端收到了服务器的snapshot,snapshot包含了A在服务器上执行命令得到的新位置。客户端比较自己预测的位置和snapshot的位置。如果二种一样,嗯万幸预测成功了。如果不一致,说明预测出错了(可能A在服务器上计算时,被其他玩家冻结住,或者打击退了),说明客户端A在预测执行的时候并没有正确的其他玩家和环境的信息。服务器有绝对权,所以客户端需要纠正自己错误的位置,一种是直接拉回到服务器的位置,二是使用平滑差值到服务器发过来的位置。
预测只对自己有效,因为你知道客户端的命令,但是不能预测其他玩家,你不能预测其他玩家的命令。
延迟补偿
假设我们在10.5对敌人B开了一枪,这个开枪的命令通过网络发送到服务器。在这个开枪命令发送到服务器之前,服务器还是在对世界进行模拟,当命令达到服务器时,B可能移动到一个不同的地方,如果命令在10.6到达,那么这个开枪不会被命中,即使在客户端A完全对准了玩家B。
补偿系统是保存了所有玩家在过去1s的信息(可以是服务器发送snapshot的历史,它包含了说有的玩家信息)。
那么实际的击中模拟时间应该是
comandExecuteTime = ServerCurrentTime - Lantency - ClientViewInterpolation
服务器回到comandExecuteTime 这个时候的状态(只有玩家的位置和动作),计算开枪命中,计算结束后回到ServerCurrentTime的状态。
上图是一个例子。服务器有200ms的latency。左边红色是客户端100ms+差值时间之前的hit。当射击命令到达服务器,玩家B已经到了左边的位置,服务器回溯当前世界,玩家B现在是蓝色包围盒的位置,计算击中计算。红色和蓝色不完全一样是因为时间的精度差异(latency在实际的网络也不是一个固定的值200)。对于快速移动的物体,这个差异会更加明显。
实际使用
同步原理
其中ServerTime表示服务器的当前时间。
ClientTime表示客户端的时间。
DeltaTime表示服务端和客户端的时间差值。
RenderTime是当前渲染的时间。
对于最上一行,服务器发送一个snapshot给客户端,其中包含时间戳T1,客户端收到该snapShot,记录本地时间T2,计算出Delta=T1-T2,即网络延迟。对于每一个周期(每一帧,每一个Update(), Tick()),客户端都会向前走,RenderTime根据如下公式:
R
e
n
d
e
r
T
i
m
e
=
C
l
i
e
n
t
T
i
m
e
+
D
e
l
t
a
−
I
n
t
e
r
p
o
l
a
t
e
I
n
t
e
r
v
a
l
−
T
i
m
e
N
u
d
g
e
RenderTime = ClientTime + Delta - InterpolateInterval - TimeNudge
RenderTime=ClientTime+Delta−InterpolateInterval−TimeNudge
服务端以固定的周期(比如20帧)计算游戏状态,并把场景中所有的对象的状态打成一个snapshot发给客户端。
一个Snapshot包含如下信息:
- ServerTime
- Snapshot的序号
- 对象列表
- 最后计算的UserCmd
- 当前玩家信息
要求:客户端能够通过这些信息构建整个世界。
每个玩家收到的snapshot都是不相同的,Snapshot包含了玩家自己和其他人的状态信息。
回放
首先客户端对收到snapshot,按照snapshot中的serverTime进行排序,图中为S1,S2,S3,S4,S5。计算根据当前时间计算RenderTime,它会落在二个snapshot中间,根据时间对二个snapshot进行差值
S
c
u
r
=
a
∗
S
l
e
f
t
+
(
1
−
a
)
S
r
i
g
h
t
S_{cur} = a*S_{left} + (1-a)S_{right}
Scur=a∗Sleft+(1−a)Sright
根据这个公式得到回放数据。
举个例子:比如左边的snapshot玩家A的位置为1,右边的snapshot玩家A的位置为2,他们渲染时玩家A的位置会根据时间,进行位置差值。动画也是同样的道理,根据是动画当前播放的动画和normalizeTime,进行差值回放。
ps:之前差值动画的会有一个问题。比如服务器帧率为20,那么每秒发送snapshot,每个snapshot采样的是当前的状态,比如这一次采样(snapshot)玩家P播放的动画A,normalizeTime为0.9,下一次采样(snapshot)播放的动画是B,normalizeTime为0.1,那么客户端进行差值的时候,就会有问题,客户端什么时候玩家P动画从A变到B?
假设是A动画是正常结束,即normalize变为1时候结束,我们可以根据动画A的时长,计算剩余几秒动画A结束来进行差值(假设二个snapshot的时间时间差值就是动画运行时间)
还有一种可能,动画A是被打断的,即在0.9-1.0之间就结束了。
一种最简单的方法是这变化的那个时刻,把服务器的时间打上。
预测
- 客户端的每帧输入会打成一个包:UserCmd,这个UserCmd有个序号Seq
- 在每个服务器帧,服务端将累积未处理的UserCmd处理掉,并记录最新的Seq:LastCmd
- 客户端会保持一定数量的UserCmd,按照序号排序
客户端的预测都是基于最新的Snapshot,并根据该Snapshot的LastCmd,将之后的UserCmd应用到该Snapshot上。
上图为例子。首先服务器(运行帧率20)发送一个snapshot T1,id = 0,LastCmd = 0
客户端收到后,根据这个snapshot构建世界,根据这个T1状态,执行cmd1,发送cmd1给服务器和cmd2,发送cmd2到服务器。
服务器此时没有收到呵护短的cmd,发送snapshotT2,LastCmd=0,客户端收到后,比较LastCmd=0的snapShot,发现一致,预测成功。
客户端执行cmd3和cmd4,cmd5…
服务器又tick一次,这时收到了客户端的三个命令cmd1,cmd2,cmd3,它一次执行完这3个cmd,发送snapshot,id=2,Lastcmd=3。
客户端收到后,比较预测的cmd=3的snapshot是否和服务器一致,如果一致就继续预测执行。如果不一致,根据服务器的snapshotT3设置为当前的状态,重新执行命令cmd4和cmd5,得到当前世界的结果。然后客户端执行cmd6,这时发送给服务器的cmd6丢失了,下一次服务器返回snapshot是T4,LastCmd=5。下一次服务器执行cmd7,没有收到cmd6,发送snapshot T5给客户端,客户端大概率会不一致,根据T5,一次执行cmd8,跳过了cmd6.
可以看到,服务器即使没有收到cmd6,也可以照常执行,不会收到客户端网络的影响。
补偿
服务端保存一定历史记录的Snapshot。
当UserCmd中有射击命令时,根据UserCmd的RenderTime查找Snapshot并插值,得出可能被击中人物的位置和状态,并判断击中的结果。
总结
这边预测执行回滚,补偿,是有一个假设,
即世界是通过snapshot完全构建出来的+cmd来构建。
如果预测回滚老是出现不一致
有二种可能:
- 客户端和服务器执行的代码有一些不同(客户端和服务器公用同一套代码可以解决这个问题)
- snapshot里面的状态不能完整构建整个世界。比如你忘记同步一些变量,这些变量在执行cmd的过程中会影响snapshot里面的数据。
参考
http://t-machine.org/index.php/2008/03/13/entity-systems-are-the-future-of-mmos-part-4/
https://developer.valvesoftware.com/wiki/Source_Multiplayer_Networking
https://www.cnblogs.com/yangrouchuan/p/7436389.html