小飞机工作笔记(二)追帧与快照同步

    还是先说下追帧的问题吧。飞机项目采用的是帧同步的方案,渲染层与逻辑层分离,由定时器一秒20帧来驱动逻辑层做update,而对于渲染层则是以一秒40帧的速度来驱动。渲染层轮循逻辑层做插值。在网络抖动的情况下,本地演算的帧LocalFrameId可能会落后或领先服务器下发的ServerFrameId。顺便提一句,ServerFrameId在这里主要是为了给各个客户端一个统一的参照系,并不会在Frame中下发其它客户端的命令。也可以不通过ServerFrameId,而是通过对比其它客户端的帧命令来校正各客户端的时钟尽可能地保持一致,我个人不推荐这种方案,这里也就不展开讨论了。那么当LocalFrameId与ServerFrameId差距较大时,我之前的方案会调整本地逻辑层演算的速率,即LocalFrameId落后于ServerFrameId时加快(也就是调小了定时器驱动逻辑层的间隔),而领先于ServerFrameId时则减慢。比如在领先5帧(也就是100MS)左右可能会调整逻辑层驱动间隔由50MS=>120MS,落后5帧时则50MS=>20MS。但是实际测试时效果反而变差了。网络不太好,但也不是那么差时会出现飞机忽快忽慢的现象。本来是希望快速调整差距的,没想到手感反而变差了。
    我仔细想了下,觉得之前的认识还是有一些问题的。首先为了排除干扰因素,我们假设客户端开始游戏后,其计时时钟与服务器计时时钟完全一致。对客户端而言,到底什么是网络抖动呢?事实上就是网络帧在某处大量累积,在某刻又突然大量涌入。这样导致在一段时间前LocalFrameId远远领先ServerFrameId,此时逻辑层的逻辑帧被调得很慢,而在之后后网络帧又突然涌入,导致LocalFrameId远远落后于ServerFrameId,逻辑帧又被调得很快。如果只考虑当前玩家的操作体验的话,在一定的网络抖动范围内(假设为500MS),不调节逻辑层帧率,对玩家而言手感上自然是更好的。只是在预测其它玩家或AI时,可能会过度预测一些,或者某些极少数情况下玩家的操作在本地演算是成功的,但是实际上并不能成功,会对玩家体验造成一些影响。但是一方面逻辑层回滚加上渲染层平滑插值会大大减轻这种不适感,二则很多时候的预测是正确或者偏差较小的,总体上来看是完全可以接受的。
    我接着做了一些修改,在-250MS~250MS的浮动范围内是50MS一帧,250MS~750MS范围内为60MS,-750MS~-250MS范围为40MS。简单来说就是逻辑帧的速率调整变得非常温和。游戏开始后服务器与客户端时钟可能有偏差,但是这个偏差在平均程度上也会在几秒内被追平。如果LocalFrameId落后或者领先ServerFrameId超过1S,则采用暴力的手段直接拉平即可。
    OK,追帧的算是说完了。还有个快照发送的问题,之前做得不彻底,今天回来的路上又整理了下,还是记下来吧。现在时间已经10点33分了。
    之前的一个目标是希望有一个在时间上无限制的竞技场,玩家可以不断地加入进来或者随时退出。如果是状态同步的方案那么就很好处理,客户端直接拉取服务器的战斗状态,重建战场即可。但是帧同步方案下,服务端是不保存状态的。有两种方案。一种是服务器起运算结点跑同一套战斗逻辑,这样服务器就有了状态,但这样也就丧失了帧同步节省服务器资源的优点了;二是由客户端定期向服务器上传状态快照,服务器保存快照以及自快照之后的所有客户端命令,客户端进来后拉取快照和命令演算至当前帧。粗粗一看,似乎状态快照的上传颇为简单,不过,很快你就会看到事实并非如此。
    快照上传的一个重点就是要保证这个快照是不会再改变的有效快照。ServerFrameId同步到客户端后,在一定的窗口范围内,[ServerFrameId-WindowSize,ServerFrameId+WindowSize],其中的帧可能会发生回滚。比如在收到ServerFrameId为100时又收到另一个客户端在第90帧的命令,此时客户端要回滚到第90帧重新演算。因此我们只需要发送ServerFrameId-WindowSize帧的快照给服务器即可。OK,思路上没有问题了,不过事情还没有完。
    直接的想法当然是在逻辑帧中直接发送ServerFrameId-WindowSize帧。但是这样可能出现几个问题:
    一是LocalFrameId<ServerFrameId-WindowSize,有效快照尚未演算出来,无法发送。
    二是一次计时器回调中可能会连续更新多个逻辑帧,此时ServerFrameId是不变的,每个逻辑帧内部都不加思考地发送ServerFrameId-WindowSize帧会导致重复发送。
    三是可能会发生漏帧,比如在LocalFrameId1时因为有效快照ServerFrameId1-WindowSize尚未演算出来,因此无法发送。但是在下一帧之前,ServerFrameId可能会改变为ServerFrameId2,那么在LocalFrameId2时即使ServerFrameId1-WindowSize已经演算出来,但是此时计算要发送的快照为帧ServerFrameId2-WindowSize,出现了漏帧的情况。
    其实仔细考虑下,这个问题是有简明的方案的。说来说去其实就两种情况。
    1)因为网络帧可能会一次性大量涌入,也就是说在网络的回调函数中可能会一次性扔出连续多个ServerFrameId。ServerFrameId-WindowSize可能会大于LocalFrameId;
    2)可能会出现在一次计时器回调中连续更新多个逻辑帧的情况,此时ServerFrameId是不变的。LocalFrameId+WindowSize可能会>ServerFrameId。
    也就是说,在网络回调和逻辑帧中都要考虑是否发送状态快照的问题。虽然也可以解决,不过这样的做法又繁琐又丑陋。可以将ServerFrameId与LocalFrameId的变化扔到一个单独的模块X中,那么问题变转变成在不超过两个数组的各自上限的条件下,尽可能计算可发送的元素范围。而X更是可以由自己来定制快照发送的策略。
    我记得之前还有更细微的一些地方要注意,不过我懒得再回想或深入考虑了,对这个问题而言,上面的这些讨论也已经差不多了。命令窗口的问题还要记录下,不过等下次吧。本想休息下的,谁知道竟23点23分了。烦哪。

posted on 2018-07-23 09:33  莫行  阅读(2237)  评论(0编辑  收藏  举报

导航