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

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

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 List<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()的时候直接应用服务器下发的状态,每个模拟帧,客户端都把本地已经执行过而且没有被服务确认过的指令都执行一遍,然后再生成新的指令.如此,预表现的实现就基本完成了.

client 1.gif

总的流程应该是这样:
客户端预测逻辑图.png

5.小结

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,378评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,356评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,702评论 0 342
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,259评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,263评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,036评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,349评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,979评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,469评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,938评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,059评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,703评论 4 323
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,257评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,262评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,485评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,501评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,792评论 2 345

推荐阅读更多精彩内容