我们如何使用网络延迟补偿算法为移动射击游戏制定弹道计算机制

我们如何使用网络延迟补偿算法为移动射击游戏制定弹道计算机制

大家好,我是 Nikita Brizhak,Pixonic 的服务器开发人员。 今天我想谈谈补偿移动多人游戏中的延迟。

很多文章都是关于服务器延迟补偿的,包括俄语的。 这并不奇怪,因为自 90 年代末以来,这项技术就被积极用于多人 FPS 的创建。 例如,您可能还记得《QuakeWorld》模组,它是最早使用它的模组之一。

我们还在我们的移动多人射击游戏 Dino Squad 中使用它。

在这篇文章中,我的目标不是重复已经写了一千遍的内容,而是讲述我们如何在游戏中实现延迟补偿,同时考虑到我们的技术堆栈和核心游戏功能。

关于我们的大脑皮层和技术的几句话。

Dino Squad 是一款网络移动 PvP 射击游戏。 玩家控制配备多种武器的恐龙,以6v6的组队形式互相战斗。

И клиент, и сервер у нас на Unity. Архитектура довольно классическая для шутеров: сервер ― авторитарный, а на клиентах работает клиентское предсказание. Игровая симуляция написана с использованием in-house ECS и используется как на сервере, так и на клиенте.

如果这是您第一次听说滞后补偿,这里简要介绍一下这个问题。

在多人 FPS 游戏中,比赛通常在远程服务器上进行模拟。 玩家将他们的输入(有关按下的按键的信息)发送到服务器,作为响应,服务器会根据收到的数据向他们发送更新的游戏状态。 通过这种交互方案,按下前进键和玩家角色在屏幕上移动的时刻之间的延迟将始终大于 ping。

虽然在本地网络上这种延迟(通常称为输入延迟)可能不易察觉,但通过互联网玩时,控制角色时会产生“在冰上滑动”的感觉。 这个问题与移动网络密切相关,玩家 ping 为 200 毫秒的情况仍然被认为是良好的连接。 通常 ping 可以是 350、500 或 1000 毫秒。 那么,在输入延迟的情况下玩快速射击游戏几乎是不可能的。

解决这个问题的方法是客户端模拟预测。 这里,客户端本身将输入应用到玩家角色,而不等待服务器的响应。 当收到答案时,它只是比较结果并更新对手的位置。 在这种情况下,按下按键和在屏幕上显示结果之间的延迟是最小的。

理解这里的细微差别很重要:客户端总是根据其最后的输入来绘制自己,而敌人 - 有网络延迟,根据服务器数据的先前状态。 也就是说,当向敌人射击时,玩家会看到他相对于自己的过去。 有关客户预测的更多信息 我们之前写过.

这样,客户端预测解决了一个问题,却又产生了另一个问题:如果玩家在敌人过去所在的点射击,在服务器上同一点射击时,敌人可能已经不在那个地方了。 服务器延迟补偿试图解决这个问题。 当武器开火时,服务器会恢复玩家在开枪时在本地看到的游戏状态,并检查他是否真的可以击中敌人。 如果答案是“是”,则计算命中,即使此时敌人不再在服务器上。

有了这些知识,我们开始在 Dino Squad 中实施服务器延迟补偿。 首先我们要明白如何在服务器上恢复客户端看到的内容? 到底需要恢复什么? 在我们的游戏中,武器和能力的命中是通过光线投射和叠加来计算的,即通过与敌人的物理碰撞器的交互来计算。 因此,我们需要在服务器上重现玩家在本地“看到”的这些碰撞器的位置。 当时我们使用的是Unity 2018.x版本。 物理 API 是静态的,物理世界存在于单个副本中。 无法保存其状态然后从盒子中恢复它。 那么该怎么办?

解决方案就在表面上;我们已经使用它的所有元素来解决其他问题:

  1. 对于每个客户,我们需要知道他在什么时候按下按键时看到了对手。 我们已经将此信息写入输入包并使用它来调整客户端预测。
  2. 我们需要能够存储游戏状态的历史记录。 正是在其中,我们将占据对手(以及他们的碰撞者)的位置。 我们已经在服务器上有了状态历史记录,我们用它来构建 三角洲。 知道了正确的时间,我们就可以很容易地找到历史上正确的状态。
  3. 现在我们已经掌握了历史中的游戏状态,我们需要能够将玩家数据与物理世界的状态同步。 现有的碰撞器 - 移动,丢失的碰撞器 - 创建,不必要的碰撞器 - 销毁。 这个逻辑也已经写好了,由几个ECS系统组成。 我们使用它在一个 Unity 进程中容纳多个游戏室。 由于物理世界是每个进程一个,因此必须在房间之间重复使用。 在每次模拟之前,我们都会“重置”物理世界的状态,并使用当前房间的数据重新初始化它,尝试通过巧妙的池化系统尽可能地重用 Unity 游戏对象。 剩下的就是为过去的游戏状态调用相同的逻辑。

通过将所有这些元素放在一起,我们得到了一台“时间机器”,可以将物理世界的状态回滚到正确的时刻。 代码很简单:

public class TimeMachine : ITimeMachine
{
     //История игровых состояний
     private readonly IGameStateHistory _history;

     //Текущее игровое состояние на сервере
     private readonly ExecutableSystem[] _systems;

     //Набор систем, расставляющих коллайдеры в физическом мире 
     //по данным из игрового состояния
     private readonly GameState _presentState;

     public TimeMachine(IGameStateHistory history, GameState presentState, ExecutableSystem[] timeInitSystems)
     {
         _history = history; 
         _presentState = presentState;
         _systems = timeInitSystems;  
     }

     public GameState TravelToTime(int tick)
     {
         var pastState = tick == _presentState.Time ? _presentState : _history.Get(tick);
         foreach (var system in _systems)
         {
             system.Execute(pastState);
         }
         return pastState;
     }
}

剩下的就是弄清楚如何使用这台机器来轻松补偿投篮和能力。

在最简单的情况下,当机制基于单个命中扫描时,一切似乎都很清楚:在玩家射击之前,他需要将物理世界回滚到所需状态,进行光线投射,计算命中或未命中,然后让世界回到最初的状态。

但恐龙小队里这样的机制却很少! 游戏中的大多数武器都会产生射弹 - 寿命长的子弹,可以飞行几个模拟滴答声(在某些情况下,可以飞行数十次)。 该怎么处理它们,它们应该什么时间起飞?

В 古文 关于Half-Life网络堆栈,来自Valve的人问了同样的问题,他们的答案是:弹丸滞后补偿是有问题的,最好避免它。

我们没有这个选择:基于射弹的武器是游戏设计的一个关键特征。 所以我们必须想出一些办法。 经过一番集思广益后,我们制定了两个似乎有效的选项:

1. 我们将射弹与创建它的玩家的时间联系起来。 服务器模拟的每一次滴答,对于每个玩家的每一颗子弹,我们都会将物理世界回滚到客户端状态并执行必要的计算。 这种方法使得服务器上的分布式负载和可预测的射弹飞行时间成为可能。 可预测性对我们来说尤其重要,因为我们在客户端上预测了所有射弹,包括敌方射弹。

我们如何使用网络延迟补偿算法为移动射击游戏制定弹道计算机制
图中,玩家在刻度 30 处预期发射导弹:他看到敌人正在朝哪个方向运行,并知道导弹的大致速度。 在本地,他看到自己在第 33 个刻度处达到了目标。 感谢延迟补偿,它也会出现在服务器上

2.我们所做的一切与第一个选项相同,但是,在计算了子弹模拟的一次滴答之后,我们不会停止,而是继续在同一服务器滴答内模拟其飞行,每次都使其时间更接近服务器一一勾选并更新碰撞器位置。 我们这样做直到发生以下两种情况之一:

  • 子弹已经过期了。 这意味着计算结束了,我们可以算出未击中或命中了。 而且这也是在开枪的同一时刻! 对我们来说,这既是优点也是缺点。 一个优点 - 因为对于射击玩家来说,这显着减少了命中和敌人生命值下降之间的延迟。 缺点是,当对手向玩家开火时,也会出现同样的效果:敌人似乎只发射了一枚缓慢的火箭,而且伤害已经计算在内。
  • 子弹已到达服务器时间。 在这种情况下,其模拟将在下一个服务器时钟周期中继续,而无需任何滞后补偿。 对于慢速射弹,与第一个选项相比,理论上这可以减少物理回滚的次数。 同时,模拟上的不均匀负载增加:服务器要么空闲,要么在一个服务器滴答中计算多个子弹的十几个模拟滴答。

我们如何使用网络延迟补偿算法为移动射击游戏制定弹道计算机制
与上图场景相同,但按照第二种方案计算。 导弹在射击发生的同一时间点“赶上了”服务器时间,并且命中最早可以在下一个时间点开始计算。 在第 31 个刻度处,在这种情况下,不再应用滞后补偿

在我们的实现中,这两种方法只有几行代码不同,因此我们创建了这两种方法,并且在很长一段时间内它们并行存在。 根据武器的力学和子弹的速度,我们为每种恐龙选择了一个或另一个选项。 这里的转折点是游戏中出现了“在某某时间击中敌人多次,获得某某奖励”的机制。 任何玩家击中敌人的时间起着重要作用的机制都拒绝使用第二种方法。 所以我们最终选择了第一个选项,它现在适用于游戏中的所有武器和所有主动能力。

另外,值得提出性能问题。 如果您认为所有这些都会减慢速度,我的回答是:确实如此。 Unity 在移动碰撞体以及打开和关闭它们方面非常缓慢。 在《恐龙小队》中,在“最坏”的情况下,战斗中可能会同时存在数百枚射弹。 移动对撞机来单独计数每个射弹是一种难以承受的奢侈。 因此,我们绝对有必要尽量减少物理“回滚”的次数。 为此,我们在 ECS 中创建了一个单独的组件,用于记录玩家的时间。 我们将其添加到所有需要滞后补偿的实体(射弹、能力等)中。 在我们开始处理这些实体之前,我们会将它们聚集在一起并一起处理,为每个集群回滚一次物理世界。

到了这个阶段,我们已经有了一个大致可以工作的系统。 它的代码有点简化:

public sealed class LagCompensationSystemGroup : ExecutableSystem
{
     //Машина времени
     private readonly ITimeMachine _timeMachine;

     //Набор систем лагкомпенсации
     private readonly LagCompensationSystem[] _systems;
     
     //Наша реализация кластеризатора
     private readonly TimeTravelMap _travelMap = new TimeTravelMap();

    public LagCompensationSystemGroup(ITimeMachine timeMachine, 
        LagCompensationSystem[] lagCompensationSystems)
     {
         _timeMachine = timeMachine;
         _systems = lagCompensationSystems;
     }

     public override void Execute(GameState gs)
     {
         //На вход кластеризатор принимает текущее игровое состояние,
         //а на выход выдает набор «корзин». В каждой корзине лежат энтити,
         //которым для лагкомпенсации нужно одно и то же время из истории.
         var buckets = _travelMap.RefillBuckets(gs);

         for (int bucketIndex = 0; bucketIndex < buckets.Count; bucketIndex++)
         {
             ProcessBucket(gs, buckets[bucketIndex]);
         }

         //В конце лагкомпенсации мы восстанавливаем физический мир 
         //в исходное состояние
         _timeMachine.TravelToTime(gs.Time);
     }

     private void ProcessBucket(GameState presentState, TimeTravelMap.Bucket bucket)
     {
         //Откатываем время один раз для каждой корзины
         var pastState = _timeMachine.TravelToTime(bucket.Time);

         foreach (var system in _systems)
         {
               system.PastState = pastState;
               system.PresentState = presentState;

               foreach (var entity in bucket)
               {
                   system.Execute(entity);
               }
          }
     }
}

剩下的就是配置详细信息:

1.及时了解限制最大移动距离是多少。

对我们来说,让游戏在移动网络条件较差的情况下尽可能方便地访问非常重要,因此我们将故事限制为 30 个刻度(刻度率为 20 Hz)。 这使得玩家即使在非常高的 ping 值下也能击中对手。

2. 确定哪些物体可以及时移动,哪些不能。

当然,我们正在推动我们的对手。 但例如可安装的能量护盾则不然。 我们认为最好优先考虑防守能力,就像在线射击游戏中经常做的那样。 如果玩家已经在当前放置了盾牌,则过去的滞后补偿子弹不应飞过它。

3.决定是否需要补偿恐龙的能力:咬合、尾击等。我们决定需要什么,并按照与子弹相同的规则进行处理。

4. 确定如何处理正在执行滞后补偿的玩家的碰撞器。 以一种好的方式,他们的位置不应该转移到过去:玩家应该在他现在在服务器上的同一时间看到自己。 然而,我们也回滚了射击玩家的碰撞器,这有几个原因。

首先,它改进了聚类:我们可以对所有具有接近 ping 值的玩家使用相同的物理状态。

其次,在所有光线投射和重叠中,我们始终排除拥有能力或射弹的玩家的碰撞器。 在《Dino Squad》中,玩家控制恐龙,按照射击游戏的标准,恐龙的几何形状相当不标准。 即使玩家以不寻常的角度射击并且子弹的轨迹穿过玩家的恐龙对撞机,子弹也会忽略它。

第三,我们甚至在延迟补偿开始之前就使用 ECS 的数据计算恐龙武器的位置或能力的应用点。

因此,滞后补偿播放器的碰撞器的真实位置对我们来说并不重要,因此我们采取了一条更高效、同时更简单的路径。

网络延迟不能简单地消除,只能被屏蔽。 与任何其他伪装方法一样,服务器延迟补偿也有其权衡。 它改善了正在射击的玩家的游戏体验,但以牺牲被射击的玩家为代价。 然而,对于恐龙小队来说,这里的选择是显而易见的。

当然,这一切也必须以服务器代码整体复杂性的增加为代价——对于程序员和游戏设计师来说都是如此。 如果说早期的模拟是系统的简单顺序调用,那么通过滞后补偿,其中会出现嵌套循环和分支。 我们还花了很多精力来使其使用起来更加方便。

在 2019 年版本(也许更早一些)中,Unity 添加了对独立物理场景的全面支持。 我们在更新后几乎立即在服务器上实现了它们,因为我们想快速摆脱所有房间共有的物理世界。

我们为每个游戏房间提供了自己的物理场景,因此无需在计算模拟之前从相邻房间的数据中“清除”场景。 首先,它显着提高了生产力。 其次,如果程序员在添加新游戏元素时在场景清理代码中犯了错误,那么它可以消除一整类错误。 此类错误很难调试,并且通常会导致一个房间场景中的物理对象的状态“流入”另一个房间。

另外,我们还研究了物理场景是否可以用来存储物理世界的历史。 也就是说,有条件地,为每个房间分配的不是一个场景,而是 30 个场景,并用它们制作一个循环缓冲区,用于存储故事。 总的来说,这个选项被证明是有效的,但我们没有实施它:它没有显示出生产力的任何疯狂提高,而是需要相当冒险的改变。 很难预测服务器在如此多的场景下长时间工作时的表现。 因此,我们遵循以下规则:“如果没有损坏,请不要修理它“。

来源: habr.com

添加评论