游戏客户端和服务端的帧同步

client and server

Posted by liangcs71 on November 11, 2018

部分转载自 https://blog.csdn.net/wanzi215/article/details/82053036

帧同步的通信逻辑

这种属于囚徒式的帧同步,这种严格的帧同步需要服务器接受所有的客户端请求后再发出逻辑帧 这就会导致网速慢的玩家会影响到别人,让别人等他

到了后期包括王者荣耀等游戏,在服务端都会保存所有的用户操作,这就可以使用回放和断线重连功能 并且采用了定时不等待的方式解决囚徒式帧同步的弊端

这个时候服务器会定时发送一个逻辑帧来同步,就不会等网速慢的玩家,网速慢的玩家操作不一定会被服务端接收到

技能同步

这里用到了伪随机的概念,为了保证每个玩家计算的暴击伤害都一样,需要指定一个随机种子,一旦指定了随机种子, 产生的随机序列就是确定的,那么两个电脑接受相同的输入就会输出相同的结果。

所以在第一帧进行初始化的时候,服务器会发给所有客户端一个随机种子,这个随机种子是相同的。

帧同步原则

原则–》相同输入=相同结果

结果不会放到服务器上 ,因为这个原则,客户端收到相同的收入会得到相同的计算结果,服务器保存所有的用户操作, 所以同理,这个时候回放功能就是根据服务器上的用户操作,然后输入操作,客户端进行计算,放出画面

逻辑帧和渲染帧

首先需要知道逻辑帧和渲染帧的原理

逻辑帧是由游戏开发者自己制定的逻辑运行后,产生的数据结果,传给引擎,让引擎进行渲染

就像服务器说了,我不管你能执行成什么样子,我就发给你第一帧(逻辑帧) 玩家C放了技能1,D放了技能2,数据下发到C和D,C用的2018款的IPhoneXS,D用的四年前买的华为荣耀6Plus,C顺畅的天马行空,D卡的面红耳赤,自己放完技能2之后,要按技能3,技能3按了N次就是没反应,因为技能2的特效太炫酷,内存一下满了,卡的不动了。

但是D这里没反应,C无所谓啊,D的手机执行不过来渲染,就无法接受输入,在C的手机上就会到D站在那里发呆,一动不动,C上去一顿技能干掉了D。

无所谓啊,服务器无所谓,C无所谓,D卡你还来玩那是你自己的问题,自己找虐,谁有办法。

所以逻辑帧就是开启一个定时器,定时下发自己的需要下发的数据,每一次下发一次逻辑帧执行需要的数据。虽然帧同步需要在客户端执行逻辑,但逻辑帧的执行频率是服务器控制的

渲染帧:通过逻辑帧的输入进行预判,得到平滑的动画效果

帧同步算法

核心代码逻辑

m_fAccumilatedTime = m_fAccumilatedTime + deltaTime;

//如果真实累计的时间超过游戏帧逻辑原本应有的时间,则循环执行逻辑,确保整个逻辑的运算不会因为帧间隔时间的波动而计算出不同的结果
while (m_fAccumilatedTime > m_fNextGameTime) {

    //运行与游戏相关的具体逻辑
    m_callUnit.frameLockLogic();

    //计算下一个逻辑帧应有的时间
    m_fNextGameTime += m_fFrameLen;

    //游戏逻辑帧自增
    GameData.g_uGameLogicFrame += 1;
}

//计算两帧的时间差,用于运行补间动画
m_fInterpolation = (m_fAccumilatedTime + m_fFrameLen - m_fNextGameTime) / m_fFrameLen;

//更新渲染位置
m_callUnit.updateRenderPosition(m_fInterpolation);

m_fAccumilatedTime : 真实累计的运行时间 m_fNextGameTime : 理论累计运行时间(以逻辑帧时间为跨度) m_fFrameLen : 每逻辑帧的时间间隔

丢帧处理:

比如客户端 id4的帧没发出去 服务端会以为第3帧客户端没有处理,会连续发第3,4帧,这个时候3,4帧就丢失了操作,直接同步到最新帧(丢包) 基于udp没有收到的包,只是简单的全部重发,用户会丢失中间的操作,更新了别人的操作来同步

断线重连:

游戏中断线重连,会保留你断线的时候的帧,补发到最新帧中间的所有帧,快速计算完成同步,王者荣耀会有一个很快的渲染过程,看起来就跟快进一样,(根据补发的逻辑帧,快速进行渲染)

退出重连就是从第0帧开始计算,如果游戏已经进行了很久,比如20分钟,那么就会卡住很久

while(1){
	frame_opt;
	logic_update(1000/20);
}

到最新帧,这个时候等服务器来帧

b.syncTimer = skeleton.AfterFunc(time.Duration(b.bLogicInterval), func() {
	b.SyncRoutine()

	nowMilliSec := time.Now().UnixNano()/int64(time.Millisecond)

	// 超过了轮询时间间隔
	b.frameTimeChanged = false
	for nowMilliSec >= b.startTime + b.logicTime + b.bLogicInterval {
		// 逻辑时间+=逻辑间隔
		b.logicTime += b.bLogicInterval
		// 当前逻辑时间>当前帧时间+帧间隔
		if b.logicTime > b.frameTime + b.bFrameInterval {
			// 帧时间+=帧间隔
			b.frameTime += b.bFrameInterval
			// 帧时间改变=true
			b.frameTimeChanged = true
		}
	}

	// 如果帧时间已改变,且人数
	if !b.frameTimeChanged {
		return
	}

	//log.Debug("@@SyncRoutine_1")
	// 有玩家参与
	if b.users.Len() == 0 {
		return
	}


	//log.Debug("@@SyncRoutine_2")
	// 有操作
	//if len(b.opsCurFrame) == 0 {
	//	return
	//}

	//log.Debug("@@SyncRoutine_3")
	b.curFrameID++
	m := b.buildSyncData_GM2C()

	log.Error("TTTT%v", time.Now().UnixNano()-b.diffTime)
	b.diffTime = time.Now().UnixNano()
	b.BroadCast(m)

	// 把当前帧的操作和之前的操作合并后保存
	b.ops = append(b.ops, b.opsCurFrame...)
	// 清空当前帧操作
	b.opsCurFrame = b.opsCurFrame[:0]
})
}

客户端逻辑帧 laya引擎js代码


Laya.timer.loop(this.logicInterval, this, this.routineLoop);
 
 
SoccerPitch.prototype.routineLoop = function ()
    {
        // diffS = mydate.getTime();
        // 在上次执行更新之前------当前帧一共过去的时间
        // this.timeSum += Laya.timer.delta * this.i;
 
        var elapsedTicks = new Date().getTime();
        if (this.mLastElapsedTicks == elapsedTicks) // 便于时钟暂停后能立即停下来,哪怕是上次暂停后mLastUpdateTick还远远小于elapsedTicks,也会暂停
			return;
 
        this.mLastElapsedTicks = elapsedTicks;
 
        // 每
        if (elapsedTicks < this.mLastUpdateTick + this.logicInterval) {
			return;
        }
        else
        {
            this.mLastUpdateTick += this.logicInterval;
        }
			
        // 已跑到服务器同步过来的最新一回合了。
        if (this.logicInterval > this.svrFrameTime - this.logicTime) // 不够逻辑更新间隔,等下一次
        {
            // if (mWaitFlags == 0 && (elapsedTicks > mWaitStartTime + 400)) // 等待超过400ms,则提示信号弱效果
            // {
            //     mWaitFlags = 1;
            // }
            // ++mWaiteLimitCnt;
            //Log.Warning("pause1, mLogicTime:" + mLogicTime + " mLimitTime:" + mLimitTime);
            return;
        }
 
        var loops = 0;
 
        var idealTime = this.svrFrameTime;
        // 当前回合时间慢理想时间 2 帧以上时(this.OutdatedLength=1帧逻辑时间*3),本次更新需要多个回合(加速)
        var deltaTime = idealTime - this.logicTime;
        console.log("PPPPPPPPPPPPPP", this.svrFrameTime, this.logicTime, deltaTime, this.OutdatedLength);
        if (deltaTime > this.OutdatedLength) {
            loops = (deltaTime - this.OutdatedLength) / this.logicInterval + 1;
        }
        else {
            loops = 1;
        }
 
        console.log("==",loops, this.td);
 
        this.ResidueFrame = loops;
 
        for (var i = 0; i < loops*2; i++)
            this.onLogicUpdate(this.logicInterval);
 
    
}