同步算法-帧同步

同步算法-帧同步

《王者荣耀》使用“指令->指令”同步方案,把计算量交给客户端。然而,不同客户端的硬件配置、网络环境是不同的,对于相同的指令,很难保证不同的客户端能有完全一样的计算结果。

举例来说,同样执行“向前走”的指令,运算能力强的客户端可能会让角色走得更远。“帧同步”是一种综合技术,在“指令->指令”方案的基础上,增加了一些用于确保不同客户端能有

相同运算结果的机制,再配合客户端的障眼法、可靠UDP等技术,为玩家提供良好的游戏体验。

本节,我们将从服务端的角度,探讨帧同步技术。

一、帧同步究竟是什么

想象一下,假如要开发一款多人象棋游戏,一种可行的实现方法是,玩家操作一步之后,客户端A把“炮2平5”这样的操作指令发送到服务端,服务端只做转发,客户端B收到后也走一步“炮2平5"。

由于每局游戏开始时,初始棋盘都是一样的,每一回合的操作指令也一样,因此两个客户端能够保持同样的棋盘状态。

如果把象棋每回合的时间缩短,再让多名玩家可以同时操作,那么游戏看起来就是连续运行的。

如图所示,玩家A按下“向右”按钮,玩家B按下“向左”按钮,客户端将这些指令发送给服务端(图中的①代表玩家A发送的指令,②代表玩家B发送的指令)。

每一回合,服务端都会收集指令,然后将它们组合广播出去(图中的③和④代表服务端广播的内容,③和④都包含了①和②的全部信息)。

客户端收到后分别执行,将角色A右移一格,再将角色B左移一格,两个客户端的画面保持一致。

我们把一个回合称为一轮,上图中每一轮的时间都是0.1秒。客户端虽然每0.1秒才同步一次数据,但我们可以做一些障眼法来提高游戏体验。

比如,把每一轮分成4个表现帧,当客户端收到“以10米/轮的速度,向左移动1轮”的指令时,它会转换成“以2.5米/帧的速度,向左移动4帧”,这种转换可以让玩家感觉像是在连续地移动。

帧同步方案的实现需要克服两大难点,具体说明如下其一,从客户端的角度看,各客户端的硬件配置和软件环境不同,要保证“同样的输入"能有“同样的输出”,需要自行实现客户端的循环(如Unitv中的Update)机制,规范好逻辑写法。

现成的寻路、物理碰撞模块,大多不能保证在不同的硬件条件下能产生同样的运算结果,只能自己重写。举例来说,同样是“移动0.1米”,由于浮点数精度不同,有些机器会用0.0999999999999979表示0.1,有些则会用0.0999755859375表示。

一次次的误差积累时间一久,不同客户端的画面可能就会出现很大的差异。

其二,帧同步对网络质量的要求很高,上图中,每回合0.1秒,意味着如果延迟高于100毫秒,那基本上就没法玩了。100毫秒是“最大值”,这就要求大部分时间的延迟要低于50毫秒这将是一个很大的挑战。

二、代码范例

有了理论基础,还要结合代码才能深刻理解。在上图中,客户端通过①②两条协议传达玩家的操作指令。

比起每次操作都发送一条协议,定时收集、合并指令可以减少通信量,客户端可以收集玩家一段时间内(如0.1秒)的所有操作,再一次性发给服务端,这种处理方式效率更高。

下面代码展示了①②两条协议的具体内容,其中,turn代表这是第几轮的操作,服务端会进行判定,摒弃过期的指令;ops代表操作指令,由于客户端收集的是一段时间内的所有操作,因此ops是个数组。

local msg = {

_cmd = "client_sync", -- 协议名

turn = 3, -- 轮(回合数)

-- 各玩家的操作指令

players = { -- 操作指令

[1] = {

playerid = 101,

opts = {

[1] = {"movce",0,1}, -- 向(0,1)方向移动

[2] = {"skill", 1001}-- 释放1001号技能

}

},

[2] = {

playerid = 103,

opts = {

[1] = {"movce",1,1}, -- 向(0,1)方向移动

}

}

}

}

服务端收集所有客户端的操作之后,将广播“各玩家在第N轮的操作指令”的协议,具体内容如代码所示。

其中,turn代表这是第几轮的操作,players是玩家列表,用于存放各玩家的操作指令,例如,玩家103只有move一项操作。玩家101包含move和skill两项操作

服务端要管理很多场战斗。假设服务端采用的是如图所示的架构,每场战斗对应于一个战斗服务,由战斗服务(下文称之为“战斗服”)来实现帧同步功能。每场战斗都会开启新的战斗服,图中忽略了一些辅助服务,如agent管理、登录服务等。

战斗服需要保存如下代码所示的几个数据。myturn代表服务端当前的轮(回合数),ops用于保存整场战斗的操作指令,players是玩家列表,用于记录参与战斗的玩家。

local myturn = 0 -- 轮

local opts = {} -- 客户端的所有操作

local players = {} -- 玩家(角色)列表

收到玩家的操作协议之后(如下代码),战斗服先把它存入ops表,

local msg = {

_cmd = "client_sync", -- 协议名

turn = 3, -- 轮

opts = {

[1] = {"move",0,1}, -- 向(0,1)方向移动

[2] = {"skill", 1001}-- 释放1001号技能

}

}

如代码所示。考虑到客户端可能出错比如,发送了错误的轮数,对此,代码中进行了一些判断,保证一旦存入玩家某一轮的操作,就不再变更。

--处理客户端协议

function msg_client_sync()

--丢弃错误帧

if myturn ~= msg.turn then

return

end

local next = myturn+1 -- 下一帧

opts[next] = opts[next] or {}

--已经存入,不再变更

if opts[next][playerid] then

return

end

--插入

opts[next][playerid] = msg.ops

end

ops是战斗服最重要的数据结构(如下图),它保存着从战斗开始以来各轮各玩家的操作指令,上面代码所实现的是向该结构中添加数据。

如下图,假设战场中有101、102和103共3名玩家,第1轮中,玩家101有操作,玩家102移动,玩家103释放技能:第4轮中,玩家101移动和放技能,玩家102没有操作,玩家103移动。

只有战斗服保存了整场战斗的操作指令,才能实现战场回放、断线重连等功能。

若要实现战场回放,则只需在战斗结束后,保存ops表,在需要回放时模拟发送协议即可;

若要实现断线重连功能,则需要把断线期间的所有指令都发给断线的客户端,让它还原状态保存了客户端协议之后,还要把协议适时广播出去。

战斗服需要开启定时器,每间隔一段时间,判断是否收集了全部玩家的操作,如果收集完整,则广播协议。

为配合“收集全部操作之后才进入下一轮”的策略,就算玩家没有操作,客户端也要定时发送协议,只是ops表为空。具体实现如代码所示。

--每隔0.1秒调用一次on_turn

function on_turn()

local next_turn = myturn+1

local next_op = opts[next]

local count = #next_op

if count >= player_count then -- player_count 代表战场玩家总数

myturn = next -- 进入下一轮

smsg = tomsg(next_op)-- 生成消息,具体实现略

broadcast(smsg) -- 广播消息,具体实现略

end

end

上述代码的实现称为“严格帧同步”,对应于如图,如果某一客户端运行得很慢,那么其他客户端就要等待它。

sX(s1、s2、s3)代表服务端广播的协议;cX(c1、c2)代表客户端发送的协议;ctX(client turn)代表某客户端当前的轮数,stX(server turn)代表服务端当前的轮数。

opX(operation)代表客户端收集某一轮操作的时段。

战斗开始时,服务端广播s1,各客户端进入初始状态。经过op1的收集,客户端发送操作指令c1,由于各客户端的运行速度和网速不同,因此服务端需要等待最慢客户端的数据,然后广播s2。

由于客户端A运行速度较快,在收到s2之前已经完成了第1轮的逻辑,因此它只能等待(或者在客户端实施些障眼法来提高游戏体验,但本质不变),客户端B运行速度慢,

在进入c2之前就收到了s2,客户端B可以将指令缓存起来,待ct1完成后再执行s2的指令。

下图展示了一种更夸张的情形,两客户端的性能差距很大,客户端A的运算速度极快,客户端B极慢,执行1轮逻辑,客户端B是客户端A的4倍时长(比较ct1的长度)。

在严格同步的机制下,服务端只有集齐所有玩家操作才会进入下一轮,运行快的客户端A只能等待。就是因为“严格”,所以客户端间的误差不会超过一轮。

严格帧同步适用于网络环境很稳定且延迟较短的场景,例如,局域网对战类游戏(如图所示)。如果做公网游戏,则还需做点优化(后面章节将详细介绍)。

三、确定性计算

在介绍适用于公网的“乐观帧同步”之前,我们先解答一个难题。各客户端软硬件环境不同,通过服务端控制进度(即轮数)可以解决运行速度差异的问题,但还是会存在几种可能的差异,具体如下。

浮点数精度

不同系统可能会使用不同的位数表示浮点数,精度不同。一种简单粗暴的方法是,不使用浮点数,全部转为整数单位。例如,“角色以0.1米/秒的速度移动0.3秒”,可以转换成“角色以10厘米/毫秒的速度移动300毫秒”,从而避免浮点数的运算。

随机数

游戏经常会用到随机数,例如“技能"斩龙诀"有30%的概率打出两倍伤害”,如果客户端各自随机,那么结果也会有所不同。一种解决办法是,各客户端都使用同一套伪随机算法,具体做法是在战斗开始时,由服务端同步同一个随机种子,然后基于相同的规则生成随机数。

遍历的顺序

如果使用诸如 for(int i=0:i<100:i++) 的语句遍历数组,则可以确定数组的遍历顺序,但如果使用foreach语句遍历数组,则不能完全保证顺序,foreach只能保证把每个元素都遍历一遍。如果游戏逻辑依赖于遍历的顺序foreach可能会导致不同的计算结果

多线程、异步和协程

由于多线程、异步和协程的调度并不由开发者控制,因此如果游戏逻辑中使用了这些技术,需要特别关注不同时间执行线程、异步和协程的代码是否会导致不同的运算结果。

帧同步的公式具体如图所示。

四、乐观帧同步

“严格帧同步”看似完美,它让快的客户端等待慢的,客户端误差很小。但如果网络环境不好,快的客户端就会频繁等待,玩家的游戏体验就会很糟糕。公网环境下,一般会采取“定时不等待”的策略,即“乐观帧同步”。

“乐观帧同步”是指服务端定时广播操作指令,以推进游戏进程,而不必等待慢客户端。“乐观帧同步”的广播策略比“严格帧同步”简单许多,如代码所示。

--每隔0.1秒调用一次

function on_fixed_trun()

myturn = myturn + 1

next_op = next_op(myturn)

smsg = tomsg(frame)

broadcast(smsg)

end

如图所示,客户端A比客户端B快,当服务端走完一轮(st1)时,只收到客户端A的指令(c1)服务端不等待客户端2的c1,直接广播s2不过,乐观帧同步所采取的收集策略更为复杂,图中,服务端在第2轮(st2)才收到客户端A的c2和客户端B的c1,服务端会在s3中合并它们,作为第三轮的操作指令。

在第3轮(st3),服务端收到客户端B的c2和c3两条协议,服务端会将它们合并在一起,与客户端A的c3组成s4。

代码展示了收集策略的具体实现。

--收到客户端协议

function msg_client_sync(playerid, msg)

local next = myturn + 1

-- 太旧的不要

if msg.turn < myturn - 5 then

return

end

-- 防止同一玩家同一轮的操作被覆盖

-- 用recv记录已收到哪个玩家哪一轮的协议

recv[msg.turn] = recv[msg.turn] or {}

if recv[msg.turn][player_id] then

return

end

recv[msg.turn][playerid] = true

-- 插入

ops = frames[next][playerid]

ops = append(ops, msg.ops) -- 把msg.ops插入ops中,具体实现略

end

再回看上图,客户端B的c1指令要等到第3轮才执行(客户端A的c1指令在第2轮执行),c2也要等到第4轮才执行,玩家B按下按钮要等待较长时间才能看到反应。

乐观帧同步确实是以牺牲慢玩家的游戏体验为代价,以保证整体的正常运行。无论采用哪种策略,帧同步都要保证处于同一轮的客户端具有同样的状态,但由于快的客户端不再等待慢的,因此客户端之间可能会有轮数的差异,进而导致游戏画面存在差异。

可以设定最大允许的轮数差异,如果某客户端比别人慢20轮,实在太慢无药可救,那也只能把它踢出游戏。

相关文章

2025年炒股软件哪个好?全面评测与推荐
365bet365网址

2025年炒股软件哪个好?全面评测与推荐

07-05 阅读: 1769
同人的解释
bt365软件下载

同人的解释

09-02 阅读: 7590
dnf偷学技能任务怎么做 地下城好用的偷学技能盘点 待收藏
《神武4》新鲜事:发现了一个蓬莱规律
bt365软件下载

《神武4》新鲜事:发现了一个蓬莱规律

09-26 阅读: 2516
AMOLED 屏幕:它们是什么、它们如何工作以及它们的优点和缺点
标致301怎么样
bt365软件下载

标致301怎么样

12-23 阅读: 2072