任何对帧同步有疑问的人,都应该来看这篇文章,这是参考了2个帧同步模型,遇到各种问题并一一解决之后,彻底明白帧同步讲的是什么玩意的一篇文章。断断续续修改了将近2个月,说多了都是泪:(。
言归简短,书归正传。
关于帧同步实际的做法,网上一搜一大把,但是写这些文章的人并没有真正的为读者考虑。
很多人看了之后,似懂非懂。
那为什么不懂呢?
先不说别的,这里有几个在帧同步模型里的关键术语要搞懂。
1.帧。
2.逻辑帧。
3.渲染帧。
什么是帧?
帧在不同的语境里有不同的含义:
在动画里,帧是动画影像最小单位即单幅影像画面。
在网络传输中,帧是最小的数据传输单位。
在物理内存中,最小的存储单位也叫做帧。
这些帧的含义都是从英文Frame来翻译过来的,再看Frame的意思。框架,边框,有木架的,有架构的。
那么就可以知道了,帧并不是指特定的事物,倒不如说它是一个测量某种东西的最小单位,如斤,千克,厘米,这种某种度量衡的一种测量单位。
而且这种单位是架构的单位。到了这里回头看帧同步。
直接替换概念,就是最小单位同步。至于是什么事物的最小单位,因为动画和游戏的连续画面要在时间维度里才会存在,
所以这个最小单位其实指的是时间单位。
为什么说要替换概念,因为做游戏开发,做过客户端开发的,都知道FPS这个概念,Frames Per Second,每秒帧数。
如果不替换概念,很容易就把此帧(帧同步)当做彼帧(FPS的Frame帧)。这就是最蛋疼的地方了。为什么蛋疼?接着看。
渲染帧 在Unity,Cocos,Laya等游戏客户端引擎里都有一个函数叫Update。只要做过客户端开发的都不陌生。这在每一帧渲染之前会调用一次Update,在Update里面可以增加自己的逻辑处理(自己写的代码),如更新精灵位置、角度等,渲染的时候会去取精灵的这些属性进行绘制。就是在每一帧渲染显示到屏幕之前,都会执行这个Update里的代码,这个帧 就是渲染帧,这个帧的运行是由游戏引擎控制的,除了我们自己在Update函数中写的逻辑,剩下的都是引擎自己运行的,不以写程序的人的意志为转移。
游戏的常识里都知道要买好的显卡,这样游戏看起来流畅,不会卡成纸片人。为什么会这样,其实说白了,游戏就是玩家可以操控交互的程序,程序是什么?百度百科里说计算机程序是一组计算机能识别和执行的指令,运行于电子计算机上,满足人们某种需求的信息化工具。这些指令的目的呢?其实是处理数据啊。不管是用支付宝付款,还是打一把dota,不管是数据从硬盘里加载到内存里,在内存处理过后,经过显卡显示在屏幕上,还是玩家用鼠标操作英雄在游戏里厮杀,都是代表数据的电信号被程序处理的结果,当然这些处理随着数据的变化和显卡的最大处理能力都会有一个极限。
当把数据看做水流,显卡看做水管,水太多,水管不够粗的时候,水就会流的慢,在游戏程序里的表现就是卡顿,因为显卡处理不过来啊,数据不能快速顺畅的显示在屏幕上。
以上是卡顿的原因。
那渲染帧在其中起的什么作用?
渲染帧就是引擎处理数据直到处理完成后并渲染到屏幕上的一个最小时间内做的一切事。
打个比方就相当于大部分人平常上班,每天上一天班一样。
引擎处理一帧(渲染帧),类比在公司干活一天。
每周五天工作制的话,每天至少干8个小时,即使你没事做,在公司发呆也要呆够一天(8小时)。如果有事没做完,要加班,那么一天就不止8小时了。
每秒60渲染帧的话,每帧大概17毫秒,即使没事干的话,一帧也要执行跑满17毫秒。如果17毫秒还没处理完,渲染帧要延时,那么一帧就不止17毫秒了。
当然这个类比还可以细化,仅仅在公司呆8小时是不行的,如果在公司里不干活,而只是玩,公司也不会发薪水的。所以这里假定,每干活一天,就要填写一个进度管理系统,上报给公司,供上面查询你都做了什么。
这个上报自己的工作进度给公司领导看的过程,在游戏引擎里就类比把数据渲染到显示器上显示给玩家看的过程。
每天下班之前10分钟总结工作进度,并填写后台进度管理系统,上面就知道员工的工作进度了。即使某一天工作量很小,来到公司半小时干完之后,一天都没事干,也要一天一上报。这个就相当于游戏里即使什么操作都不做,每秒也会固定60帧,每帧17毫秒(假设第1-第10秒处理逻辑,第10秒开始渲染,第15秒渲染完成,剩余两秒空跑)。
至于为什么是每天,而不是每两天,或者每秒60帧,不是120帧,这都是自己根据某些原理定的,比如人眼动画连续的最少帧数等,比如每天要回家睡觉等等。
逻辑帧 呢?参考之前论述的帧的概念,逻辑帧 就是运行游戏逻辑的最小时间单位。在这个单位时间内,会有那么一套结构化的代码需要执行。前面说过渲染帧是由游戏引擎控制的,在渲染上面,玩家是没有办法对引擎做出任何控制的。但是怎么渲染?难道瞎渲染?肯定是要根据游戏开发者自己制定的逻辑运行后,运行结果所产生的数据来渲染。比如玩家按了一下跳跃按钮,在空中用摇杆挫了一个<- -> <- ->,并同时按下了 A B 两个按键,释放除了天罡火的技能。跳跃之后,控制角色的高度位置,在空中的用什么动画,释放天罡火的摇杆摇的对不对,按钮按的对不对,有没有能量条,天罡火的释放动画,怪物自主移动,等等判断和数据。在引擎渲染到屏幕上,玩家看到以前,这些都是需要先提前处理的,而这些东西,就是我们所谓的逻辑。最后的最后,【渲染】只是这一帧 (渲染帧)在处理完之前的逻辑之后,最后要做的动作。这里的这一帧指的是什么帧?好吧,是渲染帧。可不是逻辑帧。这个图下面的周一 其实画的不好 应该是 |工作|上报结果| 这样就更完美了。
这样会导致一个什么问题?比如员工A(使用了3年的IPhone5S)一天工作8小时(一帧17毫秒),18:30就上报自己的工作进度下班回家。于是A给产出定了一个闹钟。到了18:30就提醒自己打开后台进度管理系统,上报自己的工作进度。每次做完一份工作,才打开进度管理系统,接受新的工作任务。结果工作没做完,那只能加班了,加到20:20,终于搞完了,开始在进度管理系统提交自己的任务完成情况,20:30下班。本来一天只要8小时,结果干了10小时。
【这里必须要说一个前提,我们的渲染帧之间是不休息的,但是人是要休息的,没有以人工作24小时为例,是因为不现实,这里就假设下班晚多久,第二天就晚多久上班。】
A下班越来越晚,甚至按照管理系统的打卡记录来说,别人在做11月20号的事情时,A才做到11月9号的事,这样,A是越来越慢。。。
对于正常的其他18:30干完工作,下班回家的员工(IPhoneXS)来说,每天还是工作8小时。但是对于员工A来说,已经不是8小时了。
类比渲染帧,某一帧A,17毫秒没渲染完,只好延长A的处理时间,结果A用了25毫秒才渲染完,每帧已经不是17毫秒了。
因为A每天的工作只是一个项目中某个小任务,每个小任务都延期,导致整个项目几乎陷入了瘫痪,大领导的脸色越来越难看。
这样A的上层领导有意见了,给了你任务,就卡住,项目都TM停止了。搞的都不知道项目到底啥时候能完成,只看到工作时间越来越长。最后这次更夸张,从上一次写上班计划开始到现在已经过去了48个小时了,员工A还没有上报工作进度。还没做完啊~~,做完才能上报。
只发了白菜的薪水,也不可能吃出海鲜的味道。想Fire也是不现实的。
这TM搞的我都没法安排工作了啊,领导上面还有大领导,大领导还以为你这领导上台以后,工作进度老不不见前进,不想干了呢。
于是A的领导想了个办法,不管你下面的A把工作做到什么程度,做的快慢都好,我该按照我自己的计划布置任务,你上报进度也好,不上报进度也罢,我先把任务布置下去,这样大领导看我自己的每天任务进度就能看到我的工作情况,完成不了那是自己的下属不行。
领导找A谈话:A啊,你不管完成不完成你手中的任务,你先把任务都接了,对,不要像之前那样每完成一个任务才接下一个任务,你都接了。
A:o(╥﹏╥)o,做的慢咋办?
领导:没事,你慢慢做,做完一个接着做下一个。
这样大领导一看领导的工作进度,每天一收集下属的工作情况,每天再下发给下属新的任务,有条不紊嘛,这个可以的。
中层领导决定不管A每天的任务完成的怎样,每天按时下达部署自己需要下属完成的任务。至于A能做到什么程度,领导心里也得有点数啊,不可能太赶鸭子上架,为难A。
看到这里应该有感觉了吧,中层领导做的这个决策对应的就是逻辑帧的处理。
就像服务器说了,我不管你能执行成什么样子,我就发给你第一帧(逻辑帧) 玩家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卡你还来玩那是你自己的问题,自己找虐,谁有办法。
所以逻辑帧就是开启一个定时器,定时下发自己的需要下发的数据,每一次下发一次逻辑帧执行需要的数据。虽然帧同步需要在客户端执行逻辑,但逻辑帧的执行频率是服务器控制的。
转回员工A的例子,A接收到任务后,先放在一边,A想了,领导让我先没有完成当前的任务之前,也可以接受新的任务,让大领导到自己的后台一看,哇,同时做了这么多工作,很卖力啊。
既然这么体谅我,我也不能辜负领导厚爱。更何况领导分配的任务也不重,自己也刚把得吧。尽量在任务截止时间之前完成。
于是定了另一个闹钟,每小时(自己设置)都会检查一下任务管理系统,有什么任务到做的时间了,如果自己空闲,那么立刻就动手做。如果还在忙着,
领导熟悉了A的工作能力之后,又调整了一下工作任务量的每日安排,保证A能顺利的完成,这样,大老板看谁的项目进度都很满意。
所以A有两个闹钟,一个是定时上报自己工作任务进度的闹钟(对应渲染帧),一个是定时监测自己什么时间该做什么任务的闹钟(即逻辑帧)。
综上:
渲染帧是我们无法控制的(只能通过在它的函数里少执行逻辑,减少它在单渲染帧的执行时长);
逻辑帧是我们自己控制的,我们决定每一小段时间就监测一下是不是有服务器下发的数据,如果有对应时间的帧数据,就立刻执行帧逻辑。
所以在这里,我们把逻辑提出来,如图。
所以在服务器新建定时器,定时下发客户端发上来的数据。数据带上帧号。
客户端新建定时器,定时监测自己该执行哪一帧的数据,直接把数据执行了,并调用引擎的接口,设置到游戏的精灵中去。
这就是帧同步的全部秘密。
根渲染帧没有半毛钱的关系。
看到这里应该已经明白帧同步到底怎么回事了。自己写代码就可以。不过还是附上代码。
部分参考代码:
服务器部分:
代码语言:javascript复制// 向玩家同步操作
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代码:
代码语言:javascript复制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);
}
本期就到这里,下期再见。
同学们,兴趣是最好的老师;只争朝夕,不负韶华!加油!