最后更新于 .

一. 简述

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

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

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

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

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

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

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

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

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

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

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

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

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

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

二. 容易导致不一致的坑

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

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

    比如之前在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回调函数来增加/删除物理实体,会导致不一致。

  2. 字典keys/values的遍历顺序

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

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

  3. 数学运算确定性

    有几个关键点:

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

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

  4. 逻辑帧率是否越高越好

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

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

    1. 逻辑层计算次数增多,cpu消耗越大。
    2. 网络流量消耗越多
    3. 对服务器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帧/秒也有好处就是表现层即使不做插值看起来也很流畅就是了,总之各有各的好处吧。

四. 架构设计

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

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

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

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

      在目录上即完全分开。

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

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

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

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

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

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

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

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

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

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

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

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

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

Pingbacks

Pingbacks已打开。

Trackbacks

引用地址

评论

暂无评论

发表评论