网络多人游戏架构pdf_技术 | 多人网络游戏同步探究(上)

本文探讨了多人游戏开发中的同步问题,包括插值与补偿、确定性问题、传输协议选择和同步策略。介绍了线性、曲线插值方法,强调了浮点数计算和随机数生成在确定性中的作用。讨论了TCP、UDP和KCP协议的特点,以及帧锁定和事件锁定两种同步策略的优缺点。
摘要由CSDN通过智能技术生成

前言

在进行多人游戏开发过程中,我们期望游戏在不同设备上保持一致的表现,或者说是结果一致,许多动作类游戏,多人游戏的要求就更高,我们不希望不同客户端看到的结果是不一样的。这要求我们要保证物理计算的一致,包括随机数等等。

  • 预测式输入,我们不希望长时间等待服务器响应,当然我们在网络波动延迟较大的情况下,可以做出相应的提示,让玩家感知到当前网络较差。

  • 插值处理,如线性插值,在单纯的直线运动,我们可以直接采取,k*x+b = y的方式,得到中间值(x,y)。在update(或者协程中)中去lerp

  • 定点数计算,设备浮点数计算是有误差的,所以必要时候舍弃设备自带的四舍五入方式,采取手写确定,保持计算一致。

bc23777fdeedbdaf7584558dc2f394b0.png

BUGyyc/SyncProjectgithub.com19e9e7395928f87549768ddb420f3630.png

下面将对多人网络游戏研发中,遇到的问题进行探究,探讨一下解决客户端表现差异的方法,以及一些同步方案的思路。


插值与补偿

线性插值

204cda35aef02d76214d89759158fd4e.png

直线式的补偿,可以满足要求不高的同步游戏。线性插值其实是取当前点与网络目标点,得到线段,并且得到线段的线性函数,k*x+b = y 。只需求解k,b,在进行插值时,我们已知两个点(当前点与网络同步过来的目标点),已知两个不重合的点,可以求出直线方程,所有k,b求出结果。

d204a22fea5f48981c61475e05b4c16f.png

     //服务器的帧消息    onUdpFrameInfo(bytes) {
let msg ...
//执行同步逻辑 ...
//对应渲染鸡,更改目标坐标 this.onRenderAll(index, msg);
},

//渲染需要移动的鸡 onRenderAll(index, msg) {
...
//转成对象 let data = this.byteData2Object(...);
...
//发出消息更改鸡的目标坐标 SoulGame.EventDispatcher.dispatchEvent("Sync_Pos", data);
},

/// //鸡对象的部分代码
onLoad() {
SoulGame.EventDispatcher.addEventListener("Sync_Pos", this.onUpdatePos, this);
},

//更新目标点 onUpdatePos(data) {
if (parseInt(data.id) == this.id) {
//除以100,是因为服务器整数计算,放大了100倍,减少浮点数 let pos = (data.pos / 100);
this.targetPos = pos;
}
},

//每帧逻辑 onEnterFrame() {
//插值补偿 let vec = this.node.position.lerp(this.targetVec, 0.5);
this.node.position = vec;
},

/ lerp官方介绍
/** !#en TODO !#zh 线性插值。 @param to to @param ratio the interpolation coefficient @param out optional, the receiving vector */
lerp(to: Vec2, ratio: number, out?: Vec2): Vec2;

线性内插

线性内插法的基本计算过程是根据一组已知的未知函数自变量的值和它相对应的函数值, 利用等比关系去求一种求未
知函数其他值的近似计算方法,是一种求位置函数逼近数值的求解方法。
公式表示:Y=Y1+(Y2-Y1)×(X-X1)/(X2-X1)

线性外插

外插亦称外推,是插值法的基本类型之一。当自变量 x 不是插值节点,且 x 位于插值区间之外时,用插值函数 P(x)
的值作为被插值函数 f(x) 的近似值,称为外插或外推。

[摘记]数值方法02--内插法和外推法 - 筱夏 - 博客园www.cnblogs.com

曲线插值

游戏对象的运行轨迹很多情况下是曲线,所以上面所提及的线性插值是无法满足需求的,我们希望通过一定数量的点模拟出非常真实光滑的曲线轨迹,下面将介绍几个常用的曲线插值的方法。

二次样条插值

已知不在同一直线上的三点P1、P2、P3,要求通过给定的这三点定义一条抛物线。

表达式为:

P(t) = A1 + A2t + A3t^2 (0≤t≤1)

A1、A2、A3为表达式的系数,且是向量形式。若是二维平面曲线,则为二维向量;若是三维空间曲线,则为三维向量。

ace01d5b9e081100ce02b6422867de20.png

https://blog.csdn.net/zl908760230/article/details/53967828blog.csdn.net

三次样条插值

fd54cd80fa89273d681c5a193c72c053.png

已知四个点,求解出近似的轨迹方程,然后分段取点,客户端按帧去更新点。

表示的公式:

ax^3+bx^2+c*x+d = y;

求解出a,b,c,d系数

阿贵:三次样条(cubic spline)插值zhuanlan.zhihu.com3cc1f44c3c167dbed2bff15b9388f004.png

贪吃蛇的实现方式

客户端与服务器保持一致的三次样条插值方式,服务器采取10帧计算频率,客户端保持30帧左右的更新频率,也就是说客户端插值计算得到空缺帧的中间点,使得蛇可以沿着曲线光滑移动。

//帧数据处理(加入战场后,服务器主动下发)    onFrameInfo (msg) {
...
//更新蛇 this.updateSnakeParams(frameInfos);
...
},

//更新所有蛇的状态 updateSnakeParams (msg) {
...
if (snake) {
let p = snake.node.convertToNodeSpaceAR(...);
if (snake._isDead) {//蛇死了 ...
} else {
...
//插值分段得到的轨迹 snake.AddBodyPosSvr_Head(p.x,p.y);
//修改蛇头角度 snake.directTo(...);
//修改蛇的速度 snake.setSpeed(...);
}
} else {
...
}
},

// //snake脚本
AddBodyPosSvr_Head(x,y){
...
//加到数组头部 this.m_BodyPosArr_SvrX.unshift(x);
this.m_BodyPosArr_SvrY.unshift(y);
//将数组进行填补,输出一个新数组 Utils.resize(this.m_BodyPosArr_SvrX,iEvalCount);
Utils.resize(this.m_BodyPosArr_SvrY,iEvalCount);
},

//渲染蛇 rendererSnake(){
...
this.SplineSvr();
...
this.localMove();
...
this.LinearInterpolation();
},

//样条处理 SplineSvr(){
...
//三次样条插值 FitParametricDt(...);
...
},

//本地预测式移动 localMove(){
//差值时间 let curFrameTurnSpeed = Game.m_dTurnSpeed;
let now = this.getCurMilliseconds();
let iTime = now - this.m_iLastUpdateTime;
...
//移动蛇头 this.updateHead(distantce * scale ,curFrameTurnSpeed * scale);
...
},

//将本地预测移动后的结果与服务器结果比较,差异大进行纠错移动 LinearInterpolation(){
...
let real = cc.v2(this.m_BodyPosArr_SvrX_Splined[i], this.m_BodyPosArr_SvrY_Splined[i]);
let now = this.m_BodyPosList_Show[i];
//差一大的话进行纠错 let result = Utils.lerp_Perc(now, real, 0.016 * this.g_LinearInterpolation);
...
//进行纠错移动 },

三次样条插值源代码

https://github.com/BUGyyc/myMd/blob/master/%E6%B8%B8%E6%88%8F%E5%BC%80%E5%8F%91%E6%8A%80%E5%B7%A7/%E4%B8%89%E6%AC%A1%E6%A0%B7%E6%9D%A1%E6%8F%92%E5%80%BC/CubicSpline.hgithub.com


确定性问题

多数物理引擎在不采取任何手段的情况下,是无法保证确定性的。

这意味着

相同的状态 + 相同的输入 = 不同输出结果

造成这种现象的原因包括但不限于:

  • 浮点数精度问题

  • 随机数问题

  • 时间的细微差异问题

浮点数计算

浮点计算因为设备硬件差异,会有计算误差,所以必要时候,需要定点计算,提供一个定点计算数学库。

浮点数计算不仅仅在不同设备上存在不一致的可能性,甚至是速度也不如整数计算快,所以往往我们把浮点数放大特定倍数,例如放大1000倍,好比是在原有的基础上保留三位小数,客户端接收到放大后的数据,只需要缩小即可

随机数问题

事实上,大多数在计算领域用到的随机数都不是真正随机的,而是由伪随机数生成器生成的。伪随机数生成器都是确定性的算法,并且是唯一一种可不需要使用外界熵,诸如热噪声或用户行为来生成随机数的算法。

伪随机数生成器

伪随机数生成器通过对一个内部状态进行计算,来生成一个看似随机的数列。这个初始的状态被称作种子。为一个特定算法选一个好种子是非常困难的。通常情况下,算法使用返回的随机值作为内部状态。因为这个内部状态是有限的,伪随机数生成器会在一些情况下重复。一个随机数生成器的周期就是它开始重复之前所能返回的随机数个数。一个使用n位内部状态值得伪随机数算法最多只有 2^n 的周期。用相同的种子开始一个伪随机数生成器,将得到相同的随机数数列,不过这个特性在调试其他模块时非常有用。当伪随机数生成器需要一个“随机”的种子时,通过会使用系统或者外部硬件的熵来源。

随机数生成算法

  • 平方取中法:取一个10位数作为种子,取平方,返回中间10位作为下一个数和种子,这个算法有统计缺陷,已经不再使用

  • 线性同余生成器(LCG):

742a7bcee789545ca1c8a12dd6d2f8e6.png
  • WELL算法:

6343d0ca9bc90f1bd14fc3ab5bc4f0f2.png

传输协议的选择

TCP

TCP提供了一种可靠的面向连接的字节流传输层服务。TCP将用户数据打包构成报文段,它发送数据后启动一个定时器,另一端对收到的数据进行确认,对失序的数据进行重新排序,丢弃重复数据,TCP提供端到端的流量控制,并计算和验证一个强制性的端到端的检验和。

为什么TCP具有可靠性

  • 当TCP发出一个段后,它启动一个定时器,等待目的端确认收到这个报文段。

  • 当TCP收到发自TCP连接另一端的数据,它将发送一个确认。

  • TCP将保持它首部和数据的检验和。这是一个端到端的检验和,目的是检测数据在传输 过程中的任何变化。如果收到段的检验和有差错, TCP将丢弃这个报文段和不确认收到 此报文段(希望发端超时并重发)。

  • 既然TCP报文段作为IP数据报来传输,而IP数据报的到达可能会失序,因此 TCP报文段 的到达也可能会失序。如果必要, TCP将对收到的数据进行重新排序,将收到的数据以 正确的顺序交给应用层。

  • 既然IP数据报会发生重复,TCP的接收端必须丢弃重复的数据。

  • TCP还能提供流量控制。TCP连接的每一方都有固定大小的缓冲空间。TCP的接收端只

  • 允许另一端发送接收端缓冲区所能接纳的数据。这将防止较快主机致使较慢主机的缓冲 区溢出。

BUGyyc/myMdgithub.com19e9e7395928f87549768ddb420f3630.png

UDP

UDP是面向报文的。发送方的UDP对应用程序交下来的报文,在添加首部后就向下交付给IP层。既不拆分,也不合并,而是保留这些报文的边界,因此,应用程序需要选择合适的报文大小。

BUGyyc/myMdgithub.com19e9e7395928f87549768ddb420f3630.png

KCP

TCP是为流量设计的(每秒内可以传输多少KB的数据),讲究的是充分利用带宽。而 KCP是为流速设计的(单个数据包从一端发送到一端需要多少时间),以10%-20%带宽浪费的代价换取了比 TCP快30%-40%的传输速度。TCP信道是一条流速很慢,但每秒流量很大的大运河,而KCP是水流湍急的小激流。KCP有正常模式和快速模式两种,通过以下策略达到提高流速的结果:

  • 选择性重传 vs 全部重传: TCP丢包时会全部重传从丢的那个包开始以后的数据,KCP是选择性重传,只重传真正丢失的数据包。

  • 快速重传:发送端发送了1,2,3,4,5几个包,然后收到远端的ACK: 1, 3, 4, 5,当收到ACK3时,KCP知道2被跳过1次,收到ACK4时,知道2被跳过了2次,此时可以认为2号丢失,不用等超时,直接重传2号包,大大改善了丢包时的传输速度。

  • 延迟ACK vs 非延迟ACK:TCP为了充分利用带宽,延迟发送ACK(NODELAY都没用),这样超时计算会算出较大 RTT时间,延长了丢包时的判断过程。KCP的ACK是否延迟发送可以调节。

skywind3000/kcpgithub.comcc961549e9ec149bb4696d95acc6c957.png

UDP实现可靠传输问题

事实上,有不少行家指出,UDP基础上实现可靠传输存在许多无法预料的问题

c883cff053156a89de5e5d9882da2a46.png

同步策略

帧锁定与事件锁定

  • 帧锁定:早期局域网游戏,采取的是帧锁定的技术来实现同步,在这个帧锁定的约定下,客户端每一帧都会发送一个更新信息,它包含此帧时间内用户的所有操作输入,这个信息将直接发送至其他客户端或者服务器。如果不是局域网内,而是在一个拥有中央服务器的网络架构环境下,出现延迟,那么网络延迟的那个客户端将会拖慢其他客户端的节奏,所以帧锁定也只适用于局域网内。

  • 事件锁定:我们希望在网络延迟大的客户端不影响其他网络状况良好的客户端,客户端的操作行为之前,将发送对应行为的请求至服务器,服务器收到请求后,服务器验证合法后,将下发广播至所有的客户端。客户端收到指令后,做出指令的行为。这规避了帧锁定引起的网络延迟的客户端拖慢节奏的情况,不过事件锁定也同样存在一些问题。因为不同网络条件下的客户端,各自的网络延迟是不一样的,有的网络流畅,有的网络卡顿,所有收到服务器广播指令的时机也就不一样了,在不同时机去响应广播指令,很可能导致游戏结果也不一样,所以,在广播指令中,我们加入一些信息,例如:做出操作的时间点,做出操作的地点等等。如果一个延迟较大的客户端收到一个广播指令进行移动操作,我们可以通过本地时间与指令内的时间进行计算,得到延迟时间,当差值较大时,我们可以加快移动节奏,使得延迟的客户端最终与服务器移动一致。

时间同步

对客户端进行时间同步,可以改善事件锁定的造成的执行时机差异。例如,广播一个移动指令,客户端接收到的是,“移动游戏对象A,使得A在未来的时间点B到达地点C位置”

锁步协议

所有的客户端从同一个游戏状态开始,其游戏时间则被分为若干个周期。在每个周期内,每个客户端都在自己的游戏世界里接收一个动作以前进一步,在此周期的结尾,每台客户端都会告诉其他所有的客户端,用户究竟做了哪些操作。由于所有的客户端都知道其他客户端的操作内容,因此它们就可以共同得到下一个游戏周期的世界,从而保持同步。

锁步协议也存在一个缺点,就是每秒游戏的周期是受到往返网络延迟的限制的。在每个周期的结尾,客户端都必须收到从所有客户端来的至少一个确认信息,这样才能得到下一个游戏周期的世界。

作者:Delevin

专栏地址:https://zhuanlan.zhihu.com/p/83010584

往期回顾:

【Creator】H5小游戏开发(三)帧同步

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值