一. 简述

我们用最精简的模型来描述一下帧同步。

客户端检测服务器的逻辑帧 -> 拿到逻辑帧 -> 进行处理 -> 处理出结果 -> 完成本次逻辑帧处理 -> 表现层将处理结果渲染到屏幕上 -> 结束

客户端检测用户操作 -> 打包成action -> 上报到服务器 -> 结束

在此基础上,客户端可以通过缓存帧,平滑帧,预测,插值,等方案优化表现层的效果及手感,但是底层模型是一样的。

比如缓存帧就是客户端检测到新的逻辑帧之后不立即处理,而是先缓存到一个队列中,等到满足一定数量之后,才对队列popFrame进行处理。

而平滑帧,则是在缓存帧的基础上,将popFrame的操作做成定时操作,从而抵抗网络波动导致逻辑帧到达的时间间隔不一致问题。

帧同步游戏一定要分为逻辑层和表现层。

表现层怎么折腾都可以,但是逻辑层必须保证确定性。

那么哪一部分属于逻辑层呢?

拿到逻辑帧 -> 进行处理 -> 处理出结果 -> 完成本次逻辑帧处理

打包成action -> 上报到服务器

所以,平缓帧的实现方案中,才能用普通的setTimeout来直接定时popFrame而没有问题。

再举个例子,比如用户操作摇杆,摇杆获取到的方向是浮点型,将浮点型直接打包成action是否ok呢?答案是不行的,因为一旦这么处理,服务器的逻辑帧就包含了浮点数。

那么如果将浮点型先转换为string再打包到action中是否OK呢,这样就是可以的。但是有个条件,就是客户端取到逻辑帧中的这个字段需要做数学运算时,需要从string直接转为定点数,一定不能出现浮点数。

二. 容易导致不一致的坑

注意:以下说的都是逻辑层。

初始化/释放物理实体的顺序

比如之前在start里面将entity加入到物理世界,而每个客户端的start函数执行顺序不确定,导致物理世界的entities的顺序不一致。从而在进行碰撞检测的时候,检测出来的结果顺序会不一致。

我们具体来验证一下start/onDestroy函数的执行顺序问题。

cocos creator对一个对象生命周期主要包含几个方面:

ctor

onLoad

start

...

onDestroy

而我们可以通过测试代码来确定他们的执行顺序:

cc.log('ins before');

// 使用给定的模板在场景中生成一个新节点

let objStar = cc.instantiate(this.starPrefab);

cc.log('addChild before');

// 将新增的节点添加到 Canvas 节点下面

this.node.addChild(objStar);

cc.log('addChild after');

打印出来的结果为:

ins before

ctor

addChild before

onLoad

addChild after

并没有直接打印start。可见,start不会在当前立刻执行,而是要等之后才执行。

也即start顺序不可控。

那么onDestroy函数呢?测试代码如下:

cc.log('destroy before');

other.custom.node.destroy();

cc.log('destroy after');

输出结果为:

destroy before

destroy after

并没有直接打印onDestroy,可见onDestroy函数也不是立刻执行,而是要等之后才执行。

也即,onDestroy顺序不可控。

所以不要依赖于cocos自带的start/onDestroy回调函数来增加/删除物理实体,会导致不一致。

字典keys/values的遍历顺序

不同的字典实现的排序方案是不一样的,这也导致keys/values的顺序很可能无法统一。

为了安全,如果一定要遍历字典,需要先对keys做一次sort,而values需要通过遍历sorted_keys来进行获取。

数学运算确定性

有几个关键点:

定点数

定点数的使用务必保证正确,可以使用 string/int/定点数 来创建定点数。但是绝对不能用double/float来创建定点数,拿来中转也不行。

随机数

随机数生成器的种子要一致,并且需要将随机数生成器实例私有化,避免与其他业务公用。比如js中的Math.random()就绝对不可以使用。

逻辑帧率是否越高越好

并非如此,建议15帧/秒。

逻辑帧率增加带来的影响如下:

逻辑层计算次数增多,cpu消耗越大。

网络流量消耗越多

对服务器CPU和带宽压力增大

三. 表现层优化方案

I. 插值

在精简的帧同步模型中,我们提到了

表现层将处理结果渲染到屏幕上

但由于逻辑帧率一般比较低(15左右),远不能达到表现层的帧率要求,所以看起来就会是卡卡的,实际上是因为物体的位置是离散的。

我们可以使用cocos creator中的缓动动画很容易的解决这一点。

// 这是最简单的方案,但是效果比较差

// 因为相当于渲染层的帧率与逻辑层一致了

syncPosWithEntity: function() {

this.node.setPosition(AppUtils.conventToCCV2(

this.entity.getPos(),

));

},

// 尝试平滑移动到物理层的位置

smoothSyncPosWithEntity: function() {

// 第一次赋值的位置

// 在一个帧间隔内,移动过去

// 说明没有变化

if (this.moveDstPos != null && this.moveDstPos.equals(this.entity.getPos())) {

// cc.log("here");

return;

}

this.moveDstPos = this.entity.getPos().clone();

if (this.moveTween != null) {

this.moveTween.stop();

}

// 使用设定的逻辑帧间隔

// let duration = this.game.dLogicDt.toNumber();

// 使用客户端实际接收到逻辑帧的间隔。如果要再复杂一点,就算最近一段时间的平均值。

let duration = this.game.frameRecvDt || 0;

// 限制最小值

duration = Math.max(

duration,

this.game.dLogicDt.toNumber()

);

// 限制最大值

duration = Math.min(

duration,

this.game.dLogicDt.toNumber() * 3

);

// cc.log('duration:', duration);

// 这样,如果动画慢了的话,会自然追上

this.moveTween = cc.tween(this.node)

.to(duration, {

position: AppUtils.conventToCCV2(

this.moveDstPos

)

}).start()

},

为什么是moveto动画呢,这样当新的逻辑帧处理完产生了新的逻辑位置,而我们表现层还没有移动到指定位置时,新的moveto动画会自动加速,不用我们人工干预了。

当然,在物体刚刚创建并指定了位置的时候,需要调用一次syncPosWithEntity(),否则就会出现物体刚出生,表现层就从(0,0)位置往出生位置移动的动画了。

至于其中的duration值得好好聊一聊。

一开始我们是使用

let duration = this.game.dLogicDt.toNumber();

经过测试后发现,当逻辑帧率越低的时候,这种方式表现越好。比如在逻辑帧为30帧/秒的时候,卡顿的感觉很明显,但是15帧/秒就比较正常。

但是核心的原因都是:因为逻辑帧率越低,让动画中断的次数越少。

所以我们想尽量减少动画的中断。

本能的想到解决方案就是让动画的播放时间稍微长一点,即让动画能够尽量看起来是在连续播放的(虽然实际上还是先stop后又创建的)。

this.game.dLogicDt.toNumber() * 1.5

上面的1.5,其实就是给了下个逻辑帧一点缓冲时间,相当于我们多等了0.5个逻辑帧间隔。 只要下个逻辑帧在这个时间内到达,就不会出现表现层的动画停止。

但是这样其实还是有问题,因为这个1.5是写死的,并且寄希望于逻辑帧能够在这个时间范围内到达,万一这个时候网速更差呢?

有没有更好的方法呢?

有的,就是将客户端算出的当前逻辑帧与上一个逻辑帧的实际间隔时间传入进去。或者通过算法,取出一段时间内的平均值,来反映出平均网络情况。

let nowMs = Date.now();

if (this.frameRecvTimeMs != null) {

this.frameRecvDt = (nowMs - this.frameRecvTimeMs) / 1000;

}

this.frameRecvTimeMs = nowMs;

之后将这个时间与上面那个时间取较大值,但是我们也不能让这个值无限大,所以还要再取一次较小值。其中的3是可以自己调整的,看业务需要。

// 使用客户端实际接收到逻辑帧的间隔。如果要再复杂一点,就算最近一段时间的平均值。

let duration = this.game.frameRecvDt || 0;

// 限制最小值

duration = Math.max(

duration,

this.game.dLogicDt.toNumber()

);

// 限制最大值

duration = Math.min(

duration,

this.game.dLogicDt.toNumber() * 3

);

但是即使我们做了上面这一切后,单独使用插值的效果也不是特别好。

直到我们将插值与下面的缓存帧+平滑帧的方案结合,并将逻辑帧率设置为15帧/秒,效果才特别优秀。

另外有人可能会问,万一逻辑层正在追帧呢?也就是说虽说在渲染层只看到了一次pos变化,但是逻辑层其实经过了好几次变化,那用一个逻辑帧间隔时间作为duration会不会有问题呢?。

答案是:没问题。既然是追帧,表现层当然要保持与现实时间一致,所以快速追上pos是合理的。

II. 缓存帧+平滑帧

一般这两个方案是要结合在一起使用的,也可以和第三大项中的插值一起使用。

简单的类比就是:看视频很卡的时候,我们会先缓存一会,之后以一个恒定的速度来稳定播放视频。

虽然说延迟了一点,但是体验会舒服很多。

这里就直接贴出cocos creator的代码了,比较简单,大家参考一下就好:

constructor() {

this._frameIntervelMS = 30;

// 初始化为-1,确保第一次一定会启动

this._smoothFramePopIntervalFactor = -1;

// 软上限。软上限的设置与calcSmoothFramePopIntervalFactor中factor=0时的设置一致

this._smoothFrameSoftLimitCount = 5;

}

setPlayInterval() {

gg.intervalManager.setIntervalByKey('loopSmoothFramePop', () => {

let factor = this.calcSmoothFramePopIntervalFactor();

if (factor === this._smoothFramePopIntervalFactor) {

// console.log('equal factor, return');

return;

}

this._smoothFramePopIntervalFactor = factor;

// console.log('not equal factor:', this._usingSmoothFramePopIntervalFactor);

// 如果已经存在,会直接覆盖

gg.intervalManager.setIntervalByKey('handleSmoothFramePop',

() => {

// 这里只能用临时函数,用类的内部函数会丢失this

// cc.log("this.popArray.length:", this._popArray.length);

if (this._popArray.length <= 0) {

return;

}

// 总要先执行一次

do {

this.receiveFrameData(this._popArray.shift());

} while (this._popArray.length > this._smoothFrameSoftLimitCount);

gg.gameManager.gameScene.arrayLengthLabel.string = this._popArray.length;

},

this._frameIntervelMS * factor);

}, 10);

}

// 计算出使用的帧间隔系数

calcSmoothFramePopIntervalFactor () {

let framesLen = this._popArray.length;

let factor = 1;

if (framesLen === 0) {

// 说明网络帧有点慢,pop速度可以慢一点

factor = 1.2;

}

else if (framesLen === 1) {

factor = 1.1;

}

else if (framesLen <= 3) {

// 以同样的速度首发

factor = 1;

}

else if (framesLen <= 5) {

factor = 1 / framesLen;

}

else {

factor = 0;

}

return factor;

}

缓存帧+平滑帧带来的效果还是很好的,手感明显的好了很多。但也会有个小问题。

因为存在追帧的问题,所以有时候会出现隔空碰撞的表现。其实就是因为逻辑层同时处理了多个逻辑帧,而表现层只表现出了碰撞前和碰撞后的画面,中间过程给跳过了。可以通过调整_smoothFrameSoftLimitCount来调整。

另外代码中使用的定时器一定要使用cocos creator或者其他引擎内部自带的timer来实现,不要使用js的SetInterval,性能很低。

当然,引擎内部的timer依赖于渲染帧的帧间隔,也就是精度是无法高于一个帧间隔时间的。

所以我们为什么要把逻辑帧通常设置为15帧/秒而不是30帧/秒,其实也是这个原因,因为担心表现层的定时器会处理不过来。

当然,设置为30帧/秒也有好处就是表现层即使不做插值看起来也很流畅就是了,总之各有各的好处吧。

四. 架构设计

帧同步的游戏架构还是有很多写法的,只要保证最关键的前提:逻辑层与表现层分离。

我个人总结的几个比较好的架构是:

方案I(适合大部分游戏)

彻底分离逻辑层与表现层代码。

在目录上即完全分开。

逻辑层游戏可以脱离界面运行,你可以理解为是在写一个命令行程序。

逻辑层与表现层的交互主要通过事件完成。

即逻辑层发送事件,表现层监听事件。

表现层允许读取逻辑层,但是不允许修改。逻辑层禁止读写表现层。

以位移举例,逻辑层的物体位置是离散的,每次位置变化的时候,就广播一个事件出去。

渲染层可以通过一个主类来监听,之后分发处理;也可以直接让表现层的类直接监听自己感兴趣的事件。

比如DisplayPlayer收到LogicPlayer发来的位置变化事件后,就可以选择赋值位置/插值的方式来进行渲染。

方案II(适合物理模拟类游戏)

每个表现层对象都有一个逻辑对象,并且逻辑对象作为一个插件挂载到表现层对象上。

这里千万要注意逻辑对象的初始化顺序不能依赖表现层对象的onLoad/onStart,而是应该在主类里统一初始化。

表现层对象在每一次update函数中,从逻辑对象获取并更新数据。

还是以位移为例,整个游戏是受物理引擎驱动的,当逻辑层对象的位置发生变化后,表现层对象在update函数中就会检测到,从而选择赋值位置/插值的方式来进行渲染。

先这样,其他的等想到了再补充吧。

帧同步分离逻辑层和渲染层_帧同步的一些坑相关推荐

  1. div自动滚动_从手机滚动丢帧问题,学习浏览器合成与渲染层优化

    一个 CSS 属性引发的血案 Web 页面性能是前端开发特别需要关注的重点,评判前端 Web 页面性能的指标有很多,页面的流畅度是其中的一种,如何让页面变得 "柔顺丝滑",要讨论起 ...

  2. 小程序:Thu May 05 2022 11:03:00 GMT+0800 (中国标准时间) 渲染层错误

     (中国标准时间) 渲染层错误 渲染层有错,点击右上侧详情,本地设置,调试库改成2.13.1试一下

  3. 小程序弹出层禁止列表滑动_是时候展现真正的技术了!小程序教程来了——百战Web前端课程更新05.07...

    百战程序员十大精品课程,实时更新,保持行业领先.本次更新课程Web前端第二十九阶段安心食疗-微信小程序全部7个章节及课程资料.小程序是依托微信而生的,是一种不用下载就能使用的应用,也是一项创新,经过近 ...

  4. 小型三维引擎设计实现-渲染层的设计总结

    1 设计目标: 1.1: 易于开发,对渲染API做抽象,向上层提供更容易使用的接口, 另外还可以扩充渲染API功能,比如增加自定义GLSL常量, GLSL结构体,GLSL公用函数,自定义GLSL un ...

  5. 一个项目中说系统分为表现层、控制层、逻辑层、DAO层和最终数据库五层架构...

    表现层就是看到的东西,比如你现在看到的当前页面控制层就将你的请求从页面传到后台代码逻辑层就是处理你的请求的代码DAO层就是将数据存到数据库中的代码数据库就是数据库了,存东西用的DAO层就是将访问数据库 ...

  6. java系统项目分为哪五大层次?控制层_业务_一个项目中说系统分为表现层、控制层、逻辑层、DAO层和最终数据库五层架构-转...

    表现层就是看到的东西,比如你现在看到的当前页面 控制层就将你的请求从页面传到后台代码 逻辑层就是处理你的请求的代码 DAO层就是将数据存到数据库中的代码 数据库就是数据库了,存东西用的 ,DAO层就是 ...

  7. 多线程操作数据库时为了防止数据的增删改的混乱该在数据库层还是程序层面上进行同步?

    多线程操作数据库时为了防止数据的增删改的混乱该在数据库层还是程序层面上进行同步? [问题点数:60分,结帖人jiao_zg] 不显示删除回复 显示所有回复 显示星级回复 显示得分回复 只显示楼主 收藏 ...

  8. android ui层 交互层 业务逻辑层 服务层,表现层(UI)、业务逻辑层(BLL)、数据访问层(DAL)...

    三层架构(3-tier application) 通常意义上的三层架构就是将整个业务应用划分为:表现层(UI).业务逻辑层(BLL).数据访问层(DAL).区分层次的目的即为了"高内聚,低耦 ...

  9. 三层架构:表示层-业务逻辑层-数据访问层

    三层架构和MVC是两个东西. 非要相关的话: 三层架构中"表现层"的aspx页面对应MVC中的View(继承的类不一样) 三层架构中"表现层"的aspx.cs页 ...

最新文章

  1. C++ 笔记(32)— 预处理、文件包含include、宏替换define、条件包含ifndef、define
  2. python如何处理表格_Python是如何处理Excel表格的?方法简单!
  3. html jquery ajax乱码问题,jquery使用ajax提交中文乱码问题的解决
  4. Property #39;sqlSessionFactory#39; or #39;sqlSessionTemplate#39; are required
  5. Spark-Streaming基础
  6. ui automator viewer 怎么获取界面名_ui交互设计怎么样
  7. 二十、子程序设计(函数)
  8. 通过浏览器启动php cli,Cron作业PHP脚本失败但脚本将通过CLI或浏览器运行
  9. 动态规划---01背包问题
  10. 自动化运维python学习笔记一
  11. linux安装杰奇远程采集,杰奇linux远程采集,采集器网站分离
  12. 卷积码树状图怎么画_卷积码编码器怎么画 浅谈卷积码编码器设计
  13. Unity-Xlua
  14. pthread编译时报错的解决方法
  15. mtv和mcv开发模式
  16. 【CUDA】判断电脑CUDA和cuDNN是否安装成功(Windows)
  17. C++ “switch“ 语句的简单讲解
  18. mcpe服务器网页控制台教程,mcpe服务器指令
  19. Scikit-learn机器学习实战之Kmeans
  20. 自定义类加载器加载冲突类(一)-ClassLoader

热门文章

  1. python链接FTP下载数据
  2. 【电源设计】08电源中电容大小的计算
  3. Android Fundamentals
  4. 端午重磅福利:40本好书等你认领
  5. 56. 数据增广 / 图像增广
  6. python 拟合sigmoid曲线_使用python+sklearn实现概率校准曲线
  7. 关于C语言中两个惊叹号(!)的问题
  8. 德州中级职称计算机考试题,考试资讯 - 中星睿典 - 全国职称计算机考试|职称计算机考试模块|全国职称计算机考试试题...
  9. python批量读取图片并批量保存_Python爬虫:批量抓取花瓣网高清美图并保存
  10. Echarts 入门实例--金山打字折线图