概述
背景/前提
1.基于确定性的网络同步原理
2.仅提供Unity引擎实现方式
实现原理
1.客户端与服务器使用相同频率进行模拟刷新
渲染帧实现:Unity引擎内 Update, LateUpdate 帧数可能根据机器性能变化
处理物理刷新及同步:FixedUpdate,不受渲染效率的影响,以固定的时间间隔调用,
2.当时状态 + 操作指令列表 = 更新后的新状态
定义指令类Command
public class Command
{
public long ID;
//指令Id
public CmdInput CMDInput;
//保存输入指令
public CmdResult CMDResult;
//保存指令计算结果
public int CommandFlag;
//按位标记指令状态
}
public void FixedUpdate()
{
//省略:同步前置处理
Command cmd = new Command ();
cmd.CMDInput =
GetCommandInput();
// 获取指令
ExecuteCommand(cmd);
// 执行指令
//省略:同步后置处理
}
public override void ExecuteCommand(Command command)
{
CmdResult mResult = new CmdResult();
//省略:计算指令执行结果过程
command.CMDResult = mResult;
}
3.客户端接收玩家指令并预测命令结果
客户端缓存本地指令后直接执行这些命令的渲染帧刷新,而不需要等服务器的回包。
注意:客户端并不会把预测执行这些命令的结果作为最终结果,最终状态仍然以服务器状态为准。
每个客户端(角色)拥有一个操作指令队列,固定一个发包频率(ClientSendRate),定时向服务器发送整个指令列表(特殊情况:遇到关键帧,如人物跳起/射击的操作,则直接发送指令列表,不等待频率发送)。
一般来说,客户端渲染刷新频率会远高于网络通信的发包频率。
逻辑帧处理:
if(bClientPredicted)
{
Command cmd = new Command ();
cmd.CMDInput =
GetCommandInput();
ExecuteCommand(cmd);
//客户端直接执行这些指令
cmd.commandFlag |= CommandFlag.HAS_EXECUTED;
//标记命令已执行
commandQueue.Enqueue(cmd);
//缓存到指令队列
}
4.封装网络消息包Packet
(1).定义Packet结构
public class Packet
{
public int posIndex;
//标记数据读/写的位置
public int length;
//长度
public byte[] data;
//数据数组,读写的数据都在这
public long checkFlag;
//客户端在关键帧计算出的校验flag,可用于校验角色状态是否同步,防止外挂和不同步等情况的出现
}
(2).根据项目情况自定义数据加密细节
(3).以二进制数据流方式读取/写入数据数组,封装网络接口供上层业务实现
(4).一些需要特别关注的点:
a.浮点数用定点方式存储及计算
b.数据接口尽量采用二进制位运算替代常规运算
c.不使用不稳定排序,和顺序不确定的数据结构
d.封装特殊处理大数运算的接口
5.服务器接收到客户端的指令队列并逐帧模拟.向客户端发送模拟的角色结果状态
// 服务器逐帧模拟客户端指令
private int ExecuteCommands()
{
foreach(Command cmd in commandQueue)
{
if (!(cmd.CommandFlag & CommandFlag.VERIFIED)) //指令未完成过
{
ExecuteCommand(cmd);
cmd.CommandFlag |= CommandFlag.VERIFIED; //标记完成
break;
}
}
}
// 服务器将逐帧模拟后的结果写入数据包的CMDResult中
public void CreateResult(Packet packet)
{
packet.Write(entity.commandQueue.Count);
foreach(Command cmd in entity.commandQueue)
{
packet.CMDResult.PackResult(cmd);
}
}
6.客户端收到服务器发来的角色状态结果,在逻辑帧进行处理
//获取从packet中获取resultCmd列表
public List<Command> ReadResult(Packet packet)
{
int count = packet.ReadInt();
List<Command> cmds = new List<Command>();
for(int i = 0; i < count; i++)
{
Command command = new Command();
command.ReadResult(packet);
cmds.Add(command);
}
return cmds;
}
//对每个角色的命令队列进行处理
foreach(Command localCmd in actor.commandQueue)
{
//客户端的指令序号在服务器最后一个指令序号之前
if(localCmd.sequence <= lastFromserver.sequence)
{
localCmd.CommandFlag |= CommandFlag.VERIFIED;
//标记指令已被服务器确认过
}
}
基于前文的客户端预测,可能会产生两种结果:
1.预测成功,直接设置角色状态
2.预测失败,把全部服务器确认的有效命令重播一遍直至追上当前时刻。
7.客户端插值处理
插值处理两种情况:
1.弱网(客户端与服务器角色状态基本一致,但由于网络抖动导致同步不及时,收到服务器结果时与当前状态差距较大)
2.客户端预测后结果与服务器计算结果有一定差距,直接同步服务器状态会产生跳帧/抖动/卡顿等状态,使用客户端插值进行平滑处理
【移动示例分析】
CurrentState = Mathf.Lerp(State1, State2, timeStep));
重点:从State1到State2的变化过程,可能包含多个渲染帧,如何确定timeStep的取值以保证画面流畅平滑?
解决方案:引入用来表示客户端估算出来的服务器帧变量(EstimatedFrame).
(1).这个估算帧用来表示客户端在本地预估服务器模拟的帧号, 在第一次收到服务器帧号时赋值。
(2).客户端逐帧更新估算帧
(3).每次收到服务器同步的状态时,计算帧号差异
public void CalculateEstimatedFrame()
{
if (packetsReceived == 1) //伪码简化,暂时标记第一个数据包判断方法,实际可根据项目需求调整
EstimatedFrame = ActualFrame; //收到第一个包时,将包的帧号赋值给估算帧
else
{
DifferencesFrame = ActualFrame - EstimatedFrame; //帧号差异=实际帧号-估算帧号
if (DifferencesFrame < 0 || DifferencesFrame > maxDiff) //如果估算帧与实际帧差异过大,重新赋值
{
EstimatedFrame = ActualFrame;
}
}
}
}
得到timeStep(插值的第三个参数)
timeStep = (State2.frame - DifferencesFrame) / (State2.frame - State1.frame)
进一步优化:在发包频率不高情况下的抖动处理,在状态2到来之前,客户端无法计算插值只能等待,当收到状态2的包,立即同步位置,发包频率较低时仍然会有抖动
方法:增加延迟时间(Delay),让客户端滞后,使得估算帧的帧号在实际的状态包帧号之前,Delay具体值可根据开发过程中实际测试状况调整
public void CalculateEstimatedFrame()
{
if (packetsReceived == 1)
EstimatedFrame = ActualFrame;
else
{
DifferencesFrame = ActualFrame - EstimatedFrame;
if (Differences < 0 || Differences > maxDiff)
{
EstimatedFrame = ActualFrame - delay; //重点:增加delay
}
}
}
最后
以上就是义气冷风为你收集整理的【游戏开发】状态同步的网络通信原理与实现(确定性的网络同步)背景/前提实现原理的全部内容,希望文章能够帮你解决【游戏开发】状态同步的网络通信原理与实现(确定性的网络同步)背景/前提实现原理所遇到的程序开发问题。
如果觉得靠谱客网站的内容还不错,欢迎将靠谱客网站推荐给程序员好友。
发表评论 取消回复