《王者荣耀》使用“指令->指令”同步方案,把计算量交给客户端。然而,不同客户端的硬件配置、网络环境是不同的,对于相同的指令,很难保证不同的客户端能有完全一样的计算结果。
举例来说,同样执行“向前走”的指令,运算能力强的客户端可能会让角色走得更远。“帧同步”是一种综合技术,在“指令->指令”方案的基础上,增加了一些用于确保不同客户端能有
相同运算结果的机制,再配合客户端的障眼法、可靠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轮,实在太慢无药可救,那也只能把它踢出游戏。