[从零开始的Unity网络同步] 6.客户端本地预表现

发表于2018-11-15
评论6 6.5k浏览

上一篇文章已经介绍完在服务端控制的物体通过把状态发到客户端,客户端去”追赶”服务器的状态来实现同步的,现在来谈谈如何在客户端做本地预表现.

1.什么要本地预表现?为什么要本地预表现?

本地预表现(本地预测),就是玩家操作游戏角色时,按下按键立刻得到操作的反馈.
有些竞技游戏尤其FPS游戏,讲究及时的操作响应性,试想,如果没有本地预表现,那么玩家按下一个按键想要释放技能,却要等待服务器的回包之后才释放得出来,由于网络波动延迟的影响,回包的时间还不确定,如果延迟很低的话可能还可以接受,对于延迟很高的玩家就比较难受了.为了解决这样的用户体验,最好是能实现客户端的本地预表现.

2.客户端生成操作指令并且本地模拟.向服务器发送操作指令

对于需要本地预表现的单位来说,当它得到了操作输入指令(CommandInput)的时候,应该立即把这个指令拿去执行,而不需要等服务器的回包.

// 每个模拟帧要执行的方法
public void Simulate()
{
    OnSimulateBefore();
    if(isLocalPredicted)            //如果是需要本地预测的单位,获取指令,直接执行指令即可
    {
        Command cmd = new Command ();
        cmd.input =  CollectCommandInput();      // 获取指令      
        ExecuteCommand(cmd);                    // 执行指令
    }
    OnSimulateAfter();
}

这样客户端就是一直获取操作输入,然后执行操作指令,然后就需要把操作指令上传到服务端,客户端发包应该也有一个发包频率(ClientSendRate),因为客户端只跟服务器通信,所以它可以比服务器的发包频率快.
因为本地的模拟频率是60帧/秒,相当于每秒产生了60个Command,客户端需要按ClientSendRate把指令上传到服务端,所以需要把Command缓存进队列.

// 每个模拟帧要执行的方法
public void Simulate()
{
    OnSimulateBefore();
    if(isLocalPredicted)            //如果是需要本地预测的单位,获取指令,直接执行指令即可
    {
        Command cmd = new Command ();
        cmd.input =  CollectCommandInput();      // 获取指令      
        ExecuteCommand(cmd);                    // 执行指令
        cmd.flags |= CommandFlags.HAS_EXECUTED;        //标记这个命令执行过了
        commandQueue.Enqueue(cmd);              //已经执行过的指令,需要缓存
    }
    OnSimulateAfter();
}

客户端执行过的操作指令都缓存在队列里,然后就要队列指令都发送给服务端了.

public void PackInput(Packet packet)
{
    packet.Write(entity.commandQueue.Count);
    foreach(Command cmd in entity.commandQueue)
    {
        cmd.PackInput(packet);                  //将本地模拟过的操作输入写入消息包
    }
}

3.服务器接收到客户端的操作指令并且逐帧模拟.向客户端发送模拟结果

private void ReadInput(Packet packet)
{
    int count = packet.ReadInt();
    for(int i = 0; i < count; i++)
    {
        Command command = new Command();
        command.ReadInput(packet);
        entity.commandQueue.Enqueue(command);          //将客户端的指令存入指令队列
    }
}

服务器拿到客户端的操作输入之后.接下来就要为客户端模拟输入指令.

// 服务器为客户端执行指令(每个模拟帧执行一次)
private int ExecuteCommandsFromClient()
{
    foreach(Command cmd in commandQueue)
    {
        if (!(cmd.flags & CommandFlags.HAS_EXECUTED))        //如果这个指令未执行过
        {
             ExecuteCommand(cmd);                           //服务器执行这个指令,执行的逻辑两端应该是一致的
             cmd.flags |= CommandFlags.HAS_EXECUTED;        //标记这个命令执行过了
             break;
        }
    }
}

服务器把客户端的指令模拟完了以后,模拟的结果还是缓存在commandQueue中的(因为Command类包含了Input和Result),那么在服务器向客户端发包的时候,就需要把Result给发送到客户端了.

// 服务器打包操作结果
public void PackResult(Packet packet)
{
    packet.Write(entity.commandQueue.Count);
    foreach(Command cmd in entity.commandQueue)
    {
          cmd.PackResult(packet);                  //将本地模拟过的操作结果写入消息包
    }   
}

4.客户端与服务端发来的模拟结果对比

// 客户端收到操作结果
public void ReadResult(Packet packet)
{
    int count = packet.ReadInt();
    List<command> cmdsFromServer = new Li<command>();
    for(int i = 0; i < count; i++)
    {
        Command command = new Command();
        command.ReadResult(packet);                    //从消息包中取出Result       
        cmdsFromServer.Add(command);   
    }
}

终于到这里了,因为客户端也维护了一个指令队列(commandQueue),它包含了客户端本地预表现的所有执行过的指令输入和结果,当客户端收到了服务器下发的指令结果以后,就可以本地模拟的结果和服务器模拟的结果做对比.在如何实现确定性的网络同步中,定义的Command类中是有个sequence变量来表示指令序号的.

// 客户端收到操作结果
public void ReadResult(Packet packet)
{
    int count = packet.ReadInt();
    List<command> cmdsFromServer = new List<command>(); 
    for(int i = 0; i < count; i++)
    {
        Command command = new Command();
        command.ReadResult(packet);                    //从消息包中取出Result       
        cmdsFromServer.Add(command);   
    }
    Command lastFromserver = cmdsFromServer[cmdsFromServer.Count - 1];   //服务器最后模拟的指令
    foreach(Command localCmd in entity.commandQueue)
    {
        if(localCmd.sequence <= lastFromserver.sequence)          //如果客户端的指令序号 小于等于 服务器最后一个指令序号
        {
            localCmd.flags |= CommandFlags.VERIFIED;          //标记这个指令服务器已经确认过
        }
    }    
}

那么现在,客户端的指令队列(commandQueue)中包含了很多指令,因为上一篇文章服务器将状态同步给客户端说明了,服务器会将状态发给客户端,对于本地模拟的客户端来说,收到的状态包可以直接设置,这里就会出现一个问题了,如果直接设置的话,因为客户端本地预表现了,收到的状态是旧的.直接设置不就造成抖动了吗?所以解决的办法就是客户端在一帧把之前所有的执行过的指令(除了服务器验证过的)重新执行一遍.
守望先锋的文章也是这样说明的:
>客户端是一股脑的尽快接受玩家输入,尽可能地贴近现在时刻.
一旦从服务器回包发现预测失败,我们把你的全部输入都重播一遍直至追上当前时刻。
当客户端收到描述角色状态的数据包时,我们基本上就得把移动状态及时恢复到最近一次经过服务器验证过状态上去,而且必须重新计算之后所有的输入操作,直至追上当前时刻

// 每个模拟帧要执行的方法
public void Simulate()
{
    OnSimulateBefore();
    if(isLocalPredicted)            //如果是需要本地预测的单位,获取指令,直接执行指令即可
    { 
        foreach (Command cmd in commandQueue)
        {
            if((cmd.flags & CommandFlags.HAS_EXECUTED) && !(cmd.flags & CommandFlags.VERIFIED))          //本地已经执行过 且 没有被服务确认过的指令
            {
                 ExecuteCommand(cmd);            
            }               
        }
        Command cmd = new Command ();        
        cmd.input =  CollectCommandInput();      // 获取指令      
        ExecuteCommand(cmd);                    // 执行指令
        cmd.flags |= CommandFlags.HAS_EXECUTED;        //标记这个命令执行过了
        commandQueue.Enqueue(cmd);              //已经执行过的指令,需要缓存
    }
    OnSimulateAfter();
}

对于预表现的客户端,需要在模拟之前OnSimulateBefore()的时候直接应用服务器下发的状态,每个模拟帧,客户端都把本地已经执行过而且没有被服务确认过的指令都执行一遍,然后再生成新的指令.如此,预表现的实现就基本完成了.

总的流程应该是这样:

5.小结

对于客户端的预表现,核心在于要遵循确定性的原则,一个状态 + N个指令 = 新的状态,客户端跟服务器的模拟结果应该是一致的.这样就能保持稳定的同步.
对于丢包导致的预测失败,需要在客户端做丢包重发的机制,而服务器也可以适当的从之前的指令来推测客户端操作来模拟,以缓和丢包的情况.

如社区发表内容存在侵权行为,您可以点击这里查看侵权投诉指引