这个演讲其实并没有谈及预测和同步的具体技术,而是谈 ECS 怎么帮助降低利用这些技术的实现复杂度。同时也提及了一些有趣的细节。
比如说,ECS 规定每个需要根据输入表现的 System 都提供了一个 UpdateFixed 函数。守望先锋的同步逻辑是基于 60fps 的,所以这个 UpdateFixed 函数会每 16ms 调用一次,专门用于计算这个逻辑帧的状态。服务器会根据玩家延迟,稍微推迟一点时间,比客户端晚一些调用 UpdateFixed 。在我去年谈同步的 blog 中也说过,玩家其实不关心各个客户端和服务器是不是时刻上绝对一致(绝对一致是不可能做到的),而关心的是,不同客户端和服务器是不是展现了相同的过程。就像直播电影,不同的位置早点播放和晚点播放,大家看到的内容是一致的就够了,是不是同时在观看并不重要。
但是,游戏和电影不一样的地方是,玩家自己的操作影响了电影的情节。我们需要在服务器仲裁玩家的输入对世界的影响。玩家需要告知服务器的是,我这个操作是在电影开场的几分几秒下达的,服务器按这个时刻,把操作插入到世界的进程中。如果客户端等待服务器回传操作结果那就实在是太卡了,所以客户端要在操作下达后自己模拟后果。如果操作不被打断,其实客户端模拟的结果和服务器仲裁后的结果是一样的,这样服务器在回传后告之客户端过去某个时间点的对象的状态,其实和当初客户端模拟的其实就是一致的,这种情况下,客户端就开开心心继续往前跑就好了。
只有在预测操作时,比如玩家一直在向前跑,但是服务器那里感知到另一个玩家对他释放了一个冰冻,将他顶在原地。这样,服务器回传给玩家的位置数据:他在某时刻停留在某地就和当初他自己预测的那个时刻的位置不同。产生这种预测失败后,客户端就需要自己调节。有 ECS 的帮助,状态回滚到发生分歧的版本,考虑到服务器回传的结果和新了解到的世界变化,重新将之后一段时间的操作重新作用到那一刻的状态上,做起来就相对简单了。
对于服务器来说,它默认客户端会持续不断的以固定周期向它推送新的操作。正如前面所说,服务器的时刻是有意比客户端延后的,这样,它并非立刻处理客户端来的输入,而是把输入先放在一个缓冲区里,然后按和客户端固定的周期 ( 60fps ) 从缓冲区里取。由于有这个小的缓冲区的存在,轻微的网络波动(每个网络包送达的路程时间不完全一致)是完全没有影响的。但如果网络不稳定,就会出现到时间了客户端的操作还没有送到。这个时候,服务器也会尝试预测一下客户端发生了什么。等真的操作包到达后,比对一下和自己的预测值有什么不同,基于过去那个产生分歧的预测产生的状态和实际上传的操作计算出下一个状态。
同时,这个时候服务器会意识到网络状态不好,它主动通知客户端说,网络不太对劲,这个时候的大家遵循的协议就比较有趣了。那就是客户端得到这个消息就开始做时间压缩,用更高的频率来跑游戏,从 60fps 提高到 65fps ,玩家会在感受到轻微的加速,结果就是客户端用更高的频率产生新的输入:从 16 ms 一次变成了 15.2 ms 一次。也就是说,短时间内,客户端的时刻更加领先服务器了,且越领先越多。这样,服务器的预读队列就能更多的接收到未来将发生的操作,遇到到点却不知道客户端输入的可能性就变少了。但是总流量并没有增加,因为假设一局游戏由一万个 tick 组成,无论客户端怎么压缩时间,提前时刻,总的数据还是一万个 tick 产生的操作,并没有变化。
一旦度过了网络不稳定期,服务器会通知客户端已经正常了,这个时候客户端知道自己压缩时间导致的领先时长,对应的膨胀放慢时间(降低向服务器发送操作的频率)让状态回到原点即可。
btw, 守望先锋 是基于 UDP 通讯的,从演讲介绍看,对于 UDP 可能丢包的这个问题,他们处理的简单粗暴:客户端每次都将没有经过服务器确认的包打包在一起发送。由于每个逻辑帧的操作很少,打包在一起也不会超过 MTU 限制。
ECS 在这个过程中真正发生威力的地方是在预测错误后纠正错误的阶段。一旦需要纠正过去发生的错误,就需要回滚、重新执行指令。移动、射击这些都属于常规的设定,比较容易做回滚重新执行;技能本身是基于暴雪开发的 Statescript 的,通过它来达到同样的效果。ECS 的威力在于,把这些元素用 Component 分离了,可以单独处理。
比如说射击命中判定,就是一个单独的系统,它基于被判定对象都有一个叫做 ModifyHealthQueue 的组件。这个组件里记录的是 Entity 身上收到的所有伤害和治疗效果。这个组件可以用于 Entity 的筛选器,没有这个组件的对象不会受到伤害,也就不需要参与命中判定。真正影响命中判定的是 MovementState 组件,它也参与了命中判定这个系统的筛选,并真正参与了运算。命中判定在查询了敌对关系后从 MovementState 中获取应该比对的对象的位置,来预测它是否被命中(可能需要播放对应的动画)。但是伤害计算,也就是 ModifyHealthQueue 里的数据是只能在服务器填写并推送给客户端的。
MovementState 会因为需要纠正错误预测而被回退,同时还有一些非 MovementState 的状态也会回退,比如门的状态、平台的状态等等。这个回退是 Utility 函数的行为,它可能会影响受击的表现,而受伤则是另一种固定行为(服务器确定的推送)的后果。他们发生在 Entity 的不同组件切片上,就可以正交分离。
射击预测和纠正可以利用对象的活动区域来减少判定计算量。如果能总是计算保持当前对象在过去一段时间的最大移动范围(即过去一段时间的包围盒的并集),那么当需要做一个之前发生的射击命中判定时,就只需要把射击弹道和当前所有对象的检测区域比较,只有相交才做进一步检测:回退相关对象到射击发生的时刻,做严格的命中校验。如果当初预测的命中结果和现在核验的一致就无所谓了,不需要修正结果(如果命中了,具体打中在哪不重要;如果未命中,也不管子弹射到哪里去了)。
如果 ping 值很高,客户端做命中预测往往是没有什么意义的,徒增计算量。所以在 Ping 超过 220ms 后,客户端就不再提前预测命中事件,直接等服务器回传。
ECS 框架在这件事上可以做到只去回滚和重算相关的 Component ,一个 System 知道哪些 Entity 才是它真正关心的,该怎么回退它所关心的东西。这样开发的复杂度就减少了。游戏本身是复杂的,但是和网络同步相关的影响到游戏业务的 System 却很少,而且参与的 Component 几乎都是只读的。这样我们就尽可能的把这个复杂的问题和引擎其它部分解耦。