[从零开始的Unity网络同步] 5.服务器将状态同步给客户端(状态缓存,状态插值,估算帧)

[从零开始的Unity网络同步] 5.服务器将状态同步给客户端(状态缓存,状态插值,估算帧)

在上一篇文章中,已经可以在服务器上直接根据服务器自己的操作指令,模拟得出结果,修改球的位置了,接下来,将要考虑如何将服务器模拟的位置如何同步到客户端.

1.服务器向客户端发送单位实体(Entity)状态

首先需要设定一个发包的频率(SendRate),目前设置的是每10个模拟帧发送一次,对于60模拟帧每秒的游戏世界来说,这也相当于6个包每秒.这个包的数据应该是描述Entity在当前模拟帧的状态.

public class State
{
    public int frame;                                               //模拟的帧号

    public Entity entity;                                          //所属的Entity

    public List<Property> properties;                              //需要描述的属性

    public int Pack(Packet packet)
    {
        packet.Write(frame);
        //将属性数据写入消息包packet
    }

    public int Read(Packet packet)
    {
        frame = packet.ReadInt();
        //从消息包中取出属性数据
    }
}

发送的方法:

public void FixedUpdate()
{
    if (Core.frame % SendRate == 0)                    //每隔10帧发送一次
    {
        foreach (var conn in connections)
        {
            conn.Send();       
        }
    }
}

//connection中发送的方法
public void Send()
{
    Packet packet = PacketPool.Get();

    foreach(Entity entity in entities)
    {
        entity.currentState.Pack(packet);                  //将当前状态数据写入消息包
    }
    _connection.Send(CustomMsgTypes.InGameMsg,  packet.msg_untiy);        //通过UnityEngine.Networking组件的Connection发送数据
}

这样就把Entity的状态打包发向所有的客户端了.

2.客户端接收到服务端的状态包

客户端接收到服务端的数据包,然后从数据包中拿到描述Entity状态的数据后,需要考虑的是,如果是第一个状态,可以直接拿来应用到Entity上,如果不是第一个状态的话,那就不能直接应用,因为网络传输抖动的因素,服务端虽然是每隔10帧发一个包,但是客户端收包频率不一定是每隔10帧就收到的,如果直接应用的话,必然会导致抖动.这个时候,我们就需要在客户端对服务器端进行状态缓存(StateBuffer)状态插值(StateInterpolation).

1.为什么需要状态缓存和状态插值

客户端收到的状态包都是带帧号(Frame),帧号表示了这个状态是服务器在那帧模拟得到的状态,客户端想要,去除抖动,平滑的渡过的状态之间的时间的话.就需要在State_A与State_B进行插值计算.插值计算的公式应该是这样

Current = MathUtils.Interpolate(State_A, State_B, ???? / (State_B.frame -State_A.frame ))

在公式右侧,除了????,其他都是已知的,想要得到插值结果,那么????应该是什么呢?

因为分母的两个状态的帧号差,所以分子应该也是帧号才对,客户端的帧号跟服务端帧号不一致(因为服务器肯定早就启动了,客户端是后来才连接服务器的),这个时候就要新增一个变量用来表示客户端估算出来的服务器帧(RemoteEstimatedFrame).

这个估算帧用来表示客户端在本地估测服务器模拟的帧号,它的第一次赋值应该是客户端收到服务器的帧号时,

// 调整远程估算帧
public void AdjustRemoteEstimatedFrame()
{
    if (packetsReceived == 1)
        remoteEstimatedFrame = remoteActualFrame;                    //当收到第一个包时,将包的帧号赋值给估算帧
}

估算帧也是按照模拟频率一直累加的,但是估算不一定总是准的,有时提前收到包,有时延迟收到包,甚至丢包.所以如果收到的包帧号跟估算帧相差太大的时候,就需要对估算帧重新调整

public void AdjustRemoteEstimatedFrame()
{
    if (packetsReceived == 1)
        remoteEstimatedFrame = remoteActualFrame;          //当收到第一个包时,将包的帧号赋值给估算帧
    else
    {
        remoteDiffFrame = remoteActualFrame - remoteEstimatedFrame;// 差异=实际收到的帧号-估算帧

        if (remoteDiffFrame < minDiff || (remoteDiffFrame > maxDiff)        //如果差异太大的话,估算帧就要重新赋值
        {
            remoteEstimatedFrame = remoteActualFrame;
        }
    }
}

效果如下:



从这个图可以看出,服务器移动很平滑,但是客户端移动可以明显看出抖动的情况,问题在哪呢?其实问题是出在估算帧的设置问题,从状态A插值到状态B的过程,由于估算帧等于(或者接近)状态A的帧号,而状态B的包客户端还没有收到,这就造成了在状态B到来之前,客户端没办法插值,只好原地等待,当状态B的包到来的时候,立即设置了位置,所以造成了抖动,那么如何解决这个问题呢?

做法是故意让估算帧的帧号在实际的状态包帧号之前,让客户端滞后:

public void AdjustRemoteEstimatedFrame()
{
    if (packetsReceived == 1)
        remoteEstimatedFrame = remoteActualFrame - delay;          //当收到第一个包时,估算帧 = 包帧号 - 延迟
    else
    {
        remoteDiffFrame = remoteActualFrame - remoteEstimatedFrame;// 差异=实际收到的帧号-估算帧

        if (remoteDiffFrame < minDiff || (remoteDiffFrame > maxDiff)        //如果差异太大的话,估算帧就要重新赋值
        {
            remoteEstimatedFrame = remoteActualFrame - delay;
        }
    }
}

delay = 10(因为服务器每10帧发个包)这样尽可能的预留出一个状态包用来做插值计算了,看看效果:



可以看到客户端的抖动几乎看不出来了,但是代价是延迟比较大了(为了更好的表现,这个牺牲是必要的)

3.小结

服务端模拟结果,下发状态给客户端基本就完成了,需要补充的是,在估算帧的计算中,可以根据估算帧和实际帧的差距动态的调整本地模拟的频率,比如:

如果估算帧滞后太多了,那客户端就每帧加2,甚至加3(默认是每个模拟帧加1)来追赶.

如果估算帧超前很多,那客户端就估算帧的累加可以暂停来等待,通过这样的方式来缓和.

现在客户端通过插值,实现了比较平滑的表现,但是有比较明显的延迟,这个可以通过加大发包的频率来缓解这个问题.

后续实现了客户端的预表现后,这个问题也就不那么重要了.

编辑于 2018-11-12 20:45