原理很简单,游戏开始时,每个客户端按照帧同步的方案推进着游戏,但是如果遇到服务器没能及时返回其他玩家操作的时候,给对应的玩家预测一个操作(复制该玩家最后一次操作),并继续推进游戏,如果在其后收到了服务器玩家关于这个人的操作,则把游戏回滚到预测开始的那一帧重新计算一遍,然后和现在游戏世界的表现和解。

  如果服务器迟迟没有收到某个玩家的消息,则会给这个玩家预测一个消息(复制该玩家的最后一次操作)然后推送给所有玩家,包括那个掉线的玩家。其他玩家会以这个预测操作为准计算接下来的游戏世界,而这个掉线玩家也会收到这个预测操作,并且替换掉玩家实际进行的操作,重新计算一遍游戏世界。保证每个客户端的输入一致。

  原理说起来简单,但是其实有几个难点。

  第一个难点就在于回滚,如何回滚到预测开始的那一帧呢,要记录下每一帧的变化,然后逐帧退回吗?还是把每一帧的数据做一个快照保存下来?

  其实这个问题实现起来不难,关键是从性能考虑,如果把每一帧的数据都快照下来,内存可能会吃紧,如果做逐帧退回的方式,实现起来相对复杂,并且在性能上也可能有问题。

  这里就引入了ECS架构帮助我简化了这一问题,在ECS架构中,C 也就是component(组件),它是纯数据的集合,并且 E 也就是 Entity(实体) 集中存放在一起,这方便了我对它们的集中操作,

  在ECS架构的帮助下,我实现了对组件进行快照式的存储,对实体进行了增量式的存储,实现了对数据的回滚。

  第二个难点在于和解,由于预测操作和玩家真实操作的不同,重计算出来的世界必然预测的世界有差异,那么怎样以尽量不引人注意的形式,把预测世界过渡到真实世界呢,这一点守望先锋的分享中提到了一部分,但是没有完全解答这个问题。

  实际上解决这个问题的思路是,先确定哪些是可以和解的,哪些是不可以和解的,然后分头处理。怎么分头处理呢,就是可以和解的在预测计算中就表现,不可以和解的,要等到真正的数据来了才进行表现。

  那么哪些是可以和解的呢?就是在玩家不知不觉间就可以过渡到的,比如说物体的位置,动画。这里有很多的技术可以做这种和解,比如说影子跟随算法。

  不可以和解的比如说冒出的血条数字,你不能说伤害数字都冒出来了,然后又塞回去。

  但其实有一个难点是,飞弹能不能和解?

  显然,飞弹的位置是可以和解的,但是飞弹的创建与销毁呢?这里涉及到一个游戏表现的问题,如果飞弹的创建要等到服务器回包才出现,那么这个表现在网络差的时候就太糟糕了。
  所以一定要可以和解,不能和解创造条件也要和解。

  下面是解决方案

  其实一部分解决方法在难点1已经提到了,首先要建立一个对实体的回滚系统,保证飞弹能回滚。
  但是还有一个问题,在回滚的过程中要先把这个飞弹销毁,但是如果重计算的结果是仍然创建这个飞弹呢?难道要再把这个飞弹再创建一次?虽然我们可以用池来避免频繁的创建销毁,但是粒子系统从池中取出仍然有重新构建的开销。

  很自然的想到可以延迟派发创建的事件,在数据层面这个实体已经被重计算的很多次了,但只要这个实体仍然存在我就不再派发这个实体的创建事件。销毁也是一样。

  但是我如何确定我两次创建的实体的是一个呢?要知道我们框架的设计目标是开发时尽量避免对同步系统的感知,也就是我们游戏逻辑并不知道现在的数据是真实的数据还是预测的数据,要在创建这个体的的时候判断这个实体是否已经在预测时创建过了显然不应该是我们游戏逻辑应该做的,可我们的框架又如何确定两个实体是否一致呢。

  直接比较它们两个是否相等肯定不行,把他们的数据取出来一一比对又太耗时。
  我采用的方法是我称之为特征码的方法,在构建一个实体的时候,用一个字符串去描述这个实体,这个字符串要尽量简略而又不能与其他特征码重复,然后自己实现的hash方法(.NET自带的GetHashCode有平台差异)把这个字符串转化为一个Int作为这个实体的唯一标识符,在创建实体的时候,只需要判断这个实体ID和缓存中的ID是否一致就可以判断这个实体是否已经在预测中存在了,从而实现延迟派发。

  第三个难点是重计算的性能,在我开发的早期版本时,游戏逻辑执行一帧要耗时5ms,如果此时客户端预测了5帧,那么收到服务器消息再重计算需要25ms才能计算的完,在网络延迟更大的时候,游戏性能是不可接受的。

  解决这个问题要从优化游戏性能和限制预测帧数入手,我优化了ECS的几个最基本API的执行性能,再用四叉树优化了碰撞系统,把游戏逻辑的执行时间缩短到1ms左右,然后又通过服务器控制客户端的预测帧数,使其不至于过大导致沉重的重计算负担。

再说一点其他的技术细节

Last modification:April 3rd, 2021 at 02:13 pm
如果觉得我的文章对你有用,请随意赞赏