游戏同步方案——帧同步
帧同步(Lockstep)和状态同步(State Synchronization)
所谓同步,就是要多个客户端表现效果是一致的,例如我们玩王者荣耀的时候,需要十个玩家的屏幕显示的英雄位置完全相同、技能释放角度、释放时间完全相同,这个就是同步。就好像很多个人一起跳街舞齐舞,每个人的动作都要保持一致。而对于大多数游戏,不仅客户端的表现要一致,而且需要客户端和服务端的数据是一致的。所以,同步是一个网络游戏概念,当然只有网络游戏才需要同步,而单机游戏是不需要同步的。
目前主流的网络游戏同步方案中,主要分帧同步、状态同步,但实际复杂、成熟的项目,介于其各自的优缺点,都会将两者混合起来使用,尤其是帧同步,很多时候都要考虑到状态的校验,这里则不多说,先简述两者的基本概念(本文着重介绍帧同步)。
状态同步
同步的是游戏中的各种状态。一般的流程是客户端上传操作到服务器,服务器收到后计算游戏行为的结果,然后以广播的方式下发游戏中各种状态,客户端收到状态后再根据状态显示内容。
帧同步
同步的是客户端的操作指令。客户端上传操作到服务器,并且服务器并不做过多的处理,然后将当前帧间隔内收集到的操作指令广播给每一个客户端,各个客户端在一致环境下,处理同样的操作输入,则会得到同样的结果,这样服务器的计算压力、传输数据量就相对小很多,能更好的满足游戏对高实时性、高一致性的要求。其尤其适合RTS类这种有大量同质化单元的游戏,如星际争霸等。其核心原理如下图所示:
适用的游戏类型和代表作
帧同步 | 状态同步 | |
---|---|---|
FGT、ACT(格斗、动作类) | 街霸 | 怪物猎人 |
MOBA(多人在线战术竞技) | 王者荣耀、DOTA | 全面超神、LOL、DOTA2 |
RTS(即时战略类) | 星际争霸、魔兽争霸 | |
MMO(大型多人在线) | 全民斗战神 | 魔兽世界、轩辕传奇、天涯明月刀 |
FPS、TPS、STG(射击类) | DOOM | CSGO,守望先锋,逆战,PUBG |
其它 | 剑与家园、街机类等等 | QQ飞车,各类游戏皆可 |
帧同步的关键
运行环境一致
- 随机数的确定
以下RandomSeed供大家参考,在进入游戏逻辑前,各个客户端统一设置相同的seed(算子),则各客户端会产生相同的随机数。注意,非帧同步相关逻辑,不要调用RandomSeed,调用系统的Random函数,注意区分逻辑,以免混淆。
/*
* 根据随机算子产生伪随机数
*/
export class RandomSeed{
private static s_seed:number = 0;
public static set seed(value:number){
RandomSeed.s_seed = value;
};
public static get seed():number{
return RandomSeed.s_seed;
};
public static randomCount = 0;
public static Random(min?:number,max?:number){
++RandomSeed.randomCount;
min = min || 0;
max = max || 1;
RandomSeed.s_seed = (RandomSeed.seed * 9301 + 49297) % 233280;
let rnd:number = RandomSeed.seed / 233280.0;
return min + rnd * (max - min);
}
}
- 浮点数精度一致
- 协议数据顺序一致
- 客户端,服务器的处理逻辑一致
- 具有确定性的物理引擎
显示与逻辑分离
客户端逻辑、渲染分离主要解决以下问题:
1,抗网络抖动导致的渲染抖动(各种表现不平滑),不分离的话网络抖动导致逻辑抖动进而影响渲染
2,新建线程处理逻辑层,避免逻辑、渲染共用线程导致拖累渲染,平摊cpu资源,保证渲染资源不被占用。即无论逻辑层如何延迟,渲染层始终平滑。这样做还有好处是:逻辑和渲染可各自发挥最大效能,通常情况下逻辑层帧率在10到20间变化,可以轻松处理大几百单位的逻辑运算(寻路,避障,ai等)
3,逻辑层独立出来,可做服务端验算
举个简单的🌰
首先根据个人的理解,再次阐述一些比较容易混淆的关键概念:
- 逻辑帧
逻辑帧一般是处理一些跟服务器需要交互的数据,是各个客户端同步的关键数据,如玩家的移动指令,释放技能操作等。逻辑帧的帧率视不同游戏而定,一般推荐1秒10帧,要考虑操作响应及时、数据吞吐能力、网络稳定性等多个因素。 - 客户端帧
有了逻辑帧,为什么还需要客户端帧?考虑如下情况,一个高速飞行的子弹,假设速度为800m/s,我们定义的逻辑帧率是10,那实际子弹的轨迹,每个逻辑帧内是间距80m,如此大的间距,是很难去判断碰撞关系的,就很容易造成子弹穿过物体的情况。所以,一般而言,逻辑帧率是满足不了绝大部分游戏的判断精度要求勒,判断间隔过长。要注意,帧同步,所有的关键逻辑判断,都要在对应帧里面去判断,这样大家得到的结果、记录才是一致的。
客户端帧是依赖于逻辑帧下,表示当前逻辑帧内,依据当前的数据环境中,客户端依据客户端帧去计算逻辑。举个例子,逻辑帧率是10,客户端帧率是60,则一个逻辑帧,在客户端中,要对应6个客户端帧,假设在第100个逻辑帧时候,收到的数据是玩家A以60m/s(嗯,就是这么快)的速度冲刺终点线,且此时,玩家距离终点线只有2米,那在收到第一个逻辑帧的时候,其实对应的客户端帧就是第600帧,然后客户端依据当前的信息,客户端第601帧的时候,玩家还剩1米,第602帧的时候,玩家冲刺到终点线。我们很多的逻辑判断都是在客户端帧中去进行,逻辑帧更多是同步一些操作指令等,是处理数据上传、分发。客户端帧跟逻辑帧是严格对应的,且同样是有序,客户端帧率一般是逻辑帧的整数倍,客户端帧是受逻辑帧驱动的,逻辑帧增加,才会触发客户端的触发,从而推动游戏的逻辑计算。
客户端帧率的设定,要考虑到游戏的计算精度、计算量大小、设备兼容性等。一般可以考虑为30,40,60帧率。 - 显示与逻辑分离
为什么要显示与逻辑分离?我们先思考一下这些问题,逻辑帧是服务器分发的,虽然约定是1秒10帧,那怎么去保证客户端收到的数据就是均匀的 1秒10个数据呢?不能保证呀!客户端帧对逻辑帧进行细化分割而已,所以,客户端帧也不能保证是均匀的,及时的,所有不能直接用客户端帧去控制显示,不然你就试试看(千万别,局域网测试的时候还真不能暴露出问题来),用客户端帧去直接控制画面显示,画面会刷新得非常不均匀!还有,如上面例子,客户端帧是60帧,那我的电脑还是小时候爸爸奖励我的层运行过window98的古董机,又或者我的主机是今年双11刚配的2080ti呢,你让我客户端就60帧,多浪费呀。所有,实际我们游戏的FPS不应该受客户端帧的过多影响,显示归显示,逻辑处理归逻辑处理。
还是上面的例子,在玩家正准备以60m/s的速度冲刺最后2米的时候,突然断网勒,收不到之后的逻辑帧数据,对应客户端帧也会停止分发,但是咱们游戏还在运行,我们的还可以利用当前的数据环境,继续让玩家去奔跑,只不过你发现,玩家会一直奔跑,毫无刹车的意思,哪怕过了终点(当然这是简单的处理,实际显示的画面要跟实际的画面要进行靠齐的,这里就不多说),所以显示还是需要依靠每个客户端自己的运行情况去显示,这样你的2080ti才有意义,逻辑归逻辑,我的古董机也能结果跟2080ti保持运算结果一致。
下面结合个人实际项目经验,摘略一个简单的客户端处理帧同步的流程,供大家参考。
1,构建DataManager.gameFrameData,接收服务器分发的帧数据,具体的帧数据定义,根据自己具体游戏而定,但至少都要有一个帧号来区分顺序,对吧!!!
2,客户端构建FrameManager,在其update(参考cocos引擎)中去持续监测帧数据,以推动客户端逻辑按照帧数据执行,以下代码供大家参考,clientFrame为客户端帧,logicFrame为逻辑帧,两者帧率不一定相同,但建议客户端帧是逻辑帧的整数倍,方便运算。
export class FrameManager {
//ControlDefine.CLIENT_FRAME_RATE客户端的客户端帧数FPS,如 30,60帧
private static _clientFrameTime: number = 1.0 / ControlDefine.CLIENT_FRAME_RATE;
//当前逻辑帧(第几帧)
public _curlogicFrame: number = 0;
//逻辑帧数FPS,如10帧,能满足大部分游戏的操作需求
private _frameRate: number = 0;
//当前客户端帧(第几帧)
private _clientFrameNum: number = 0;
private _startTime: number = 0;
private _clientServerFrameRate: number;
//客户端帧数与逻辑帧数的倍率
public get clientServerFrameRate() { return this._clientServerFrameRate;}
public get frameRate(): number {
return this._frameRate;
}
constructor(frameRate) {
this._frameRate = frameRate;
//_clientServerFrameRate应该要是整数
this._clientServerFrameRate = ControlDefine.CLIENT_FRAME_RATE / this._frameRate;
}
public get clientFrameNum() { return this._clientFrameNum;}
public restart() {
this._clientFrameNum = 0;
this._startTime = 0;
this._curlogicFrame = 0;
}
//逻辑帧的更新,受服务器接收数据的驱动
public logicUpdate() {
//客户单已接收到的最新的帧
let lastSyncFrame: number = DataManager.gameFrameData.lastSyncFrame;
//已接受到当前帧的输入时可以进行当前帧的逻辑并继续向下推进逻辑帧
if (lastSyncFrame > this._curlogicFrame) {
if (lastSyncFrame > this._curlogicFrame + 1) {
//快进帧,发现客户端正在运行的逻辑帧落后服务器接收到的逻辑帧,则客户端快速跟进到最新
this.stepToLogicFrame(lastSyncFrame);
} else {
//保持正常速率处理帧数据
cc.director.getScheduler().setTimeScale(1);
}
}
}
stepToLogicFrame(frame) {
while (this._clientFrameNum < this._clientServerFrameRate * frame) {
this.updateClient(FrameManager._clientFrameTime, false);
}
this.updateClient(FrameManager._clientFrameTime, false);
}
public updateTime = 0;
public updateCount = 0;
public update(dt: number) {
this.logicUpdate();
this.updateClient(dt, true);
}
//更新客户端帧,主要是当前逻辑帧下,客户端帧的处理
//isUpdateShowFrame,是否需要更新玩具的可视层
public updateClient(dt: number, isUpdateShowFrame: boolean = true) {
if (this._clientServerFrameRate * DataManager.gameFrameData.lastSyncFrame > 0 && isUpdateShowFrame) {
GlobalEvent.Dispatch(GlobalEventType.CLIENT_SHOW_FRAME_UPDATE, dt);
}
//客户端帧过快,服务器帧有延迟
if (this._clientFrameNum > this._clientServerFrameRate * DataManager.gameFrameData.lastSyncFrame) {
return;
}
while (this._startTime >= FrameManager._clientFrameTime && this._clientFrameNum <= this._clientServerFrameRate * DataManager.gameFrameData.lastSyncFrame) {
if (DataManager.gameFrameData.lastSyncFrame >= this._curlogicFrame
&& this._clientFrameNum == this._curlogicFrame * this._clientServerFrameRate) {
GlobalEvent.Dispatch(GlobalEventType.LOGIC_FRAME_UPDATE, this._curlogicFrame);
this._curlogicFrame++;
}
if (this._clientFrameNum < this._clientServerFrameRate * DataManager.gameFrameData.lastSyncFrame) {
++this._clientFrameNum;
GlobalEvent.Dispatch(GlobalEventType.CLIENT_FRAME_UPDATE, this._clientFrameNum);
this._startTime -= FrameManager._clientFrameTime;
} else {
break;
}
}
}
}
注意观察以上代码,如何分发客户端帧(GlobalEventType.CLIENT_FRAME_UPDATE)、逻辑帧(GlobalEventType.LOGIC_FRAME_UPDATE)和客户端的显示事件(GlobalEventType.CLIENT_SHOW_FRAME_UPDATE)。
当然一个完善的FrameManager还需要有很多细节需要去处理,如:
- 客户端帧先跑,还是逻辑帧先跑
- 帧加锁,方便实现“暂停”功能
- 缓冲帧的逻辑
- 快进帧的逻辑(stepToLogicFrame)
- 客户端帧间隔如何均匀分发
- 客户端帧、逻辑帧处理的逻辑拆分
- 客户端帧和逻辑帧的误差的弥补
- 客户端帧的间隔即update(dt)的参数dt也是不稳定、不均匀的哦
3,对应事件的监听处理
- GlobalEventType.LOGIC_FRAME_UPDATE
收到该事件的时候,我们应该去处理玩家的一些操作指令,更新到各玩家对应的数据层中 - GlobalEventType.CLIENT_FRAME_UPDATE
根据当前玩家的数据,执行响应的数据更新、逻辑处理。如玩家碰撞到勒什么、移动到哪里勒等等,这些多是在数据层的更新 - GlobalEventType.CLIENT_SHOW_FRAME_UPDATE
同样根据当前玩家的数据,如玩家的位置坐标,去刷新画面,显示玩家的位置变化
showNodesMove(dt) {
...
//客户端帧控制下的当前坐标
let frameShowPos = this._frameMovePos[0];
let offset:cc.Vec2 = showNodePos.sub(frameShowPos);
let distance = offset.mag();
if(distance<=300) {
//显示的坐标跟客户端帧控制下的真实坐标如果差距不大,则适当去补偿误差
let realOffset = offset.mul(dt * 10);
headShowPos = showNodePos.add(moveVec).sub(realOffset);
}else{
//坐标差异过大,则把显示坐标强制重置为客户端坐标
headShowPos = this._frameMovePos[0];
}
...
}
以上一些流程,没有展开具体细节,如显示的画面,跟实际的结果是及时匹配的。这些也是客户端帧同步处理的核心、难点所在,而且不同游戏,不同团队有自己不同的处理差异,重要的是,客户端帧和逻辑帧一定要固定间隔,不可出现跳帧,这样至少保证计算的一致性,至少同步了呀😸。
防作弊
帧同步的游戏,外挂横行,典型的就是FPS类的游戏,其主要原因是每个客户端具有完整的游戏数据,这就包括勒其它玩家的信息。根据不同游戏类型,复杂程度,作弊与反作弊的方式都会不一样,这里仅简述个人的一些经验思考。
- 输入的合理性检测
- 数据的加密处理
- 定时的数据hash,检测异常
- 服务器运行一个精简的可信赖的客户端环境,得到可信赖的数据
Q&A
-
Q:客户端帧是否一定要有?
A:如果设定的逻辑帧能满足自己游戏的逻辑运算需求,客户端帧可以不需要。 -
Q:我的画面明明跟别人画面有一定延迟,而且也不太一样,这还能叫同步?
A:每个人的网络环境不一样,对应画面有一定延迟是难免的,而且不同客户端显示层的平滑处理是不会一样的,FPS高的画面会明显游戏更流畅,画面上给玩家的反馈也更及时,但是各个客户端在实际数据上的计算是一致的。 -
Q:我的网络很卡,难道不影响其它玩家吗?
A:如果玩家的网络卡,在帧同步的策略下,就经常发些自己的操作有延迟,或者画面展示的操作是无效的。但是,别的网络正常的玩家受到的影响不大,可能看到网络卡的角色是不动的。