Как мы писали сетевой код мобильного PvP шутера: синхронизация игрока на клиенте

в 7:59, , рубрики: android, C#, ecs, Gamedev, iOS, mobile development, online multiplayer, pvp, unity, unity3d, Блог компании Pixonic, геймдев, мобильные игры, мультиплеер, проектирование, Проектирование и рефакторинг, разработка игр, разработка мобильных приложений, шутер, юнити

В одной из предыдущих статей мы провели обзор технологий, которые используются на нашем новом проекте — fast paced шутере для мобильных устройств. Теперь хочу поделиться, как устроена клиентская часть сетевого кода будущей игры, с какими трудностями мы столкнулись и как их решали.

Как мы писали сетевой код мобильного PvP шутера: синхронизация игрока на клиенте - 1

В целом подходы к созданию быстрых мультиплеерных игр за последние 20 лет не особо изменились. Можно выделить несколько методов в архитектуре сетевого кода:

  1. Рендеринг состояния мира на сервере без предсказания для локального игрока и с возможностью потери ввода игрока (инпута). Такой подход, кстати, используется на другом нашем проекте в разработке — про него можно почитать тут.
  2. Lockstep.
  3. Синхронизация состояния мира без детерминированной логики с предсказанием для локального игрока.
  4. Синхронизация по инпуту с полностью детерминированной логикой и предсказанием для локального игрока.

Особенность заключается в том, что в шутерах максимально важна отзывчивость управления — игрок нажимает кнопку (или двигает джойстик) и хочет сразу же увидеть результат своего действия. В первую очередь, потому что состояние мира в таких играх изменяется очень быстро и необходимо моментально реагировать на ситуацию.

Вследствие этого проекту не подходили подходы без механизма предсказаний действия локального игрока (prediction) и мы остановились на методе с синхронизацией состояния мира, без детерминированной логики.

Плюс подхода: меньшая сложность в реализации по сравнению с методом синхронизации при обмене инпутом.
Минус: увеличение объема трафика при отсылке всего состояния мира на клиент. Нам пришлось применить несколько различных техник оптимизации трафика, чтобы игра стабильно работала в мобильной сети.

В основе архитектуры геймплея у нас лежит ECS, про которую уже рассказывали. Такая архитектура позволяет удобно хранить данные об игровом мире, сериализовать, копировать и передавать их по сети. А также выполнять один и тот же код как на клиенте, так и на сервере.

Симуляция игрового мира происходит с фиксированной частотой 30 тиков в секунду. Это позволяет уменьшить лаг на инпут игрока и почти не использовать интерполяцию для визуального отображения состояния мира. Но здесь есть один существенный недостаток, который следует учитывать при разработке такой системы: для корректной работы системы предсказания локального игрока клиент должен выполнять симуляцию мира с той же частотой, что и сервер. И мы потратили уйму времени, чтобы оптимизировать симуляцию достаточно для целевых устройств.

Механизм предсказания действий локального игрока (prediction)

Механизм предикшена на клиенте реализован на базе ECS за счет выполнения одних и тех же систем как на клиенте, так и на сервере. Однако на клиенте выполняются не все системы, а только те, которые отвечают за локального игрока и не требуют актуальных данных о других игроках.

Пример списков систем выполняемых на клиенте и сервере:

Как мы писали сетевой код мобильного PvP шутера: синхронизация игрока на клиенте - 2

На данный момент у нас порядка 30 систем, выполняющихся на клиенте и обеспечивающих предикшн игрока и около 80 систем, которые выполняются на сервере. Но мы не выполняем предсказания таких вещей, как нанесение урона, использование способностей или лечение союзников. В этих механиках существуют две проблемы:

  1. Клиент ничего не знает о вводе других игроков и предсказание таких вещей, как урон или лечение почти всегда будет расходиться с данными на сервере.
  2. Создание новых сущностей локально (выстрелов, снарядов, уникальных способностей), порожденных одним игроком, несет проблему сопоставления с сущностями, созданными на сервере.

Для таких механик лаг скрывается от игрока другими способами.

Пример: рисуем эффект попадания от выстрела сразу, а жизни противника обновляем только после того, как получим подтверждение о попадании с сервера.

Общая схема работы сетевого кода в проекте

Как мы писали сетевой код мобильного PvP шутера: синхронизация игрока на клиенте - 3
Клиент и сервер синхронизируют время по номерам тиков. Из-за того, что передача данных по сети требует некоторого времени, клиент всегда находится впереди сервера на величину половины RTT + размер буфера ввода на сервере. На диаграмме выше показано, что клиент отправляет инпут для тика 20 (a). В этот же момент на сервере обрабатывается тик 15 (b). К моменту, когда инпут клиента дойдет до сервера, на сервере будет обрабатываться тик 20.

Весь процесс состоит из следующих шагов: клиент отсылает инпут игрока на сервер (a) → этот инпут обработается на сервере через время HRTT + input buffer size (b) → сервер шлет результирующее состояние мира на клиент (с) → клиент применит подтвержденное состояние мира с сервера через время RTT+input buffer size + game state interpolation buffer size (d).

После того как клиент получит новое подтвержденное состояние мира с сервера (d), ему необходимо выполнить процесс согласования (reconciliation). Дело в том, что клиент выполняет предсказание мира основываясь только на инпуте локального игрока. Инпуты других игроков ему не известны. И при расчете состояния мира на сервере игрок может находиться в другом состоянии, отличном от того, что предсказал клиент. Это может произойти в тех случаях, когда игрок попадает под оглушение или его убивают.

Процесс согласования состоит из двух частей:

  1. Сравнения предсказанного состояния мира для тика N, полученным с сервера. В сравнении участвуют только данные относящиеся к локальному игроку. Остальные данные мира всегда берутся с серверного состояния и не участвуют в согласовании.
  2. Во время сравнения могут возникнуть два случая:

— если предсказанное состояние мира совпало с подтвержденным с сервера, то клиент, используя предсказанные данные для локального игрока и новые данные для всего остального мира, продолжает симуляцию мира в обычном режиме;
— если же предсказанное состояние не совпало, то клиент использует всё серверное состояние мира и историю инпутов от клиента и пересчитывает новое предсказанное состояние мира игрока.

В коде это выглядит примерно так:

GameState Reconcile(int currentTick, ServerGameStateData serverStateData,   GameState currentState, uint playerID)
{

  var serverState =  serverStateData.GameState;
  var serverTick = serverState.Time;

  var predictedState = _localStateHistory.Get(serverTick);

  //if predicted state matches server last state use server predicted state with predicted player
  if (_gameStateComparer.IsSame(predictedState, serverState, playerID))
  {
     _tempState.Copy(serverState);
     _gameStateCopier.CopyPlayerEntities(currentState, _tempState, playerID);
     return _localStateHistory.Put(_tempState); // replace predicted state with correct server state
  }

  //if predicted state doesn't match server state, reapply local inputs to server state
  var last = _localStateHistory.Put(serverState); // replace wrong predicted state with correct server state
  for (var i = serverTick; i < currentTick; i++) 
  {
     last = _prediction.Predict(last); // resimulate all wrong states
  }
  return last;
}

Сравнение двух состояний мира происходит только для тех данных, которые относятся к локальному игроку и участвуют в системе предсказания. Выборка данных происходит по ID игрока.

Метод сравнения:

public bool IsSame(GameState s1, GameState s2, uint avatarId)
    {
        if (s1 == null && s2 != null ||  s1 != null && s2 == null)
            return false;

        if (s1 == null && s2 == null)
            return false;

        var entity1 = s1.WorldState[avatarId];
        var entity2 = s2.WorldState[avatarId];

        if (entity1 == null && entity2 == null)
            return false;

        if (entity1 == null || entity2 == null)
            return false;

        if (s1.Time != s2.Time)
            return false;
        
        if (s1.WorldState.Transform[avatarId] != s2.WorldState.Transform[avatarId])
            return false;
        
        foreach (var s1Weapon in s1.WorldState.Weapon)
        {
            if (s1Weapon.Value.Owner.Id != avatarId)
                continue;
            
            var s2Weapon = s2.WorldState.Weapon[s1Weapon.Key];
            if (s1Weapon.Value != s2Weapon)
                return false;

            var s1Ammo = s1.WorldState.WeaponAmmo[s1Weapon.Key];
            var s2Ammo = s2.WorldState.WeaponAmmo[s1Weapon.Key];
            if (s1Ammo != s2Ammo)
                return false;

            var s1Reload = s1.WorldState.WeaponReloading[s1Weapon.Key];
            var s2Reload = s2.WorldState.WeaponReloading[s1Weapon.Key];
            if (s1Reload != s2Reload)
                return false;
        }

        if (entity1.Aiming != entity2.Aiming)
            return false;

        if (entity1.ChangeWeapon != entity2.ChangeWeapon)
            return false;
        
        return true;
    }

Операторы сравнения конкретных компонентов у нас генерируются вместе со всей структурой EC, специально написанным генератором кода. Для примера приведу сгенерированный код оператора сравнения Transform компонента:

Код

public static bool operator ==(Transform a, Transform b)
{
    if ((object)a == null && (object)b == null)
        return true;
    if ((object)a == null && (object)b != null)
        return false;
    if ((object)a != null && (object)b == null)
        return false;
    if (Math.Abs(a.Angle - b.Angle) > 0.01f)
        return false;
    if (Math.Abs(a.Position.x - b.Position.x) > 0.01f || Math.Abs(a.Position.y - b.Position.y) > 0.01f)
        return false;
    return true;
}

Следует отметить, что Float значения у нас сравниваются с довольно высокой погрешностью. Это сделано для того, чтобы снизить количество рассинхронизаций между клиентом и сервером. Для игрока такая погрешность будет незаметна, но это значительно экономит вычислительные ресурсы системы.

Сложность механизма согласования в том, что в случае рассинхронизации состояния клиента и сервера (misprediction) необходимо повторно выполнить симуляцию всех предсказанных состояний клиента, о которых еще нет подтверждения с сервера, вплоть до текущего тика за один кадр. В зависимости от пинга игрока это может быть от 5 до 20 тиков симуляции. Нам пришлось существенно оптимизировать код симуляции, чтобы вложиться во временные рамки: 30 фпс.

Для выполнения процесса согласования на клиенте необходимо хранить два типа данных:

  1. Историю предсказанных состояний игрока.
  2. И историю инпутов.

Для этих целей мы используем циклический буфер. Размер буфера равен 32 тикам. Что при частоте 30 HZ дает около 1 секунды реального времени. Клиент может безболезненно продолжать работу на механизме предсказания, без получения новых данных от сервера, вплоть до заполнения данного буфера. Если же разница между временем клиента и сервера начинает составлять больше одной секунды — происходит принудительное отключение клиента с попыткой переподключится. У нас такой размер буфера обусловлен затратами на процесс согласования в случае расхождения состояний мира. Но если разница между клиентом и сервером больше одной секунды — дешевле выполнить полное переподключение к серверу.

Уменьшение времени лага

На схеме выше показано, что в игре существует два буфера в схеме передачи данных:

  • буфер инпутов на сервере;
  • буфер состояний мира на клиенте.

Назначение этих буферов одинаковое — компенсировать сетевые скачки (jitter). Дело в том, что передача пакетов по сети происходит неравномерно. А так как сетевой движок работает с фиксированной частотой в 30 HZ, данные на вход в движок должны подаваться с той же частотой. У нас нет возможности «подождать» несколько ms, пока очередной пакет дойдет до получателя. Мы используем буферы для данных ввода и состояний мира для того, чтобы иметь запас времени на компенсацию jitter-а. Также мы используем буфер геймстейтов для интерполяции, если один из пакетов потерялся.

На старте игры клиент начинает синхронизацию с сервером только после того, как получит от сервера несколько состояний мира и буфер геймстейтов заполнится. Обычно размер этого буфера равен 3-м тикам (100 ms).

В то же время, когда клиент синхронизируется с сервером, он «забегает» вперед от времени сервера на величину буфера инпута на сервера. Т.е. клиент сам контролирует насколько впереди сервера ему находиться. Стартовый размер буфера инпутов у нас также равен 3-м тикам (100 ms).

Первоначально мы реализовали размер этих буферов как константы. Т.е. в не зависимости от того, существовал ли реально jitter в сети или нет, существовала фиксированная задержка в 200 ms (input buffer size + game state buffer size) на обновление данных. Если добавить к этому средний предполагаемый пинг на мобильных устройствах где-то в 200 ms, то реальная задержка между применением инпута на клиенте и подтверждением применения со стороны сервера выходила 400 ms!

Нас это не устраивало.

Дело в том, что некоторые системы выполняются только на сервере — такие как, например, расчет HP игрока. При такой задержке игрок делает выстрел и только через 400 ms видит, как убивает соперника. Если это происходило в движении, то обычно игрок успевал забежать за стену или в укрытие и уже там умирал. Плейтесты внутри команды показали, что такая задержка полностью ломает весь геймплей.

Решением этой проблемы стала реализация динамических размеров буферов инпута и геймстейтов:

  • для буфера геймстейтов клиент всегда знает текущее наполнение буфера. В момент расчета очередного тика, клиент проверяет сколько стейтов уже находится в буфере;
  • для буфера инпутов — сервер, помимо геймстейта, начал отправлять на клиент значение текущего заполнения буфера инпутов для конкретного клиента. Клиент в свою очередь анализирует эти два значения.

Алгоритм изменения размера буфера геймстейтов примерно следующий:

  1. Клиент считает среднее значение размера буфера за какой-то период времени и дисперсию.
  2. Если дисперсия в пределах нормы (т.е. за заданный промежуток времени не было больших скачков в заполнении и чтении из буфера) — клиент проверяет значение среднего размера буфера за этот период времени.
  3. Если среднее заполнение буфера было больше верхнего граничного условия (т.е. буфер бы заполнен больше, чем требуется) — клиент «уменьшает» размер буфера путем совершения дополнительного тика симуляции.
  4. Если же среднее заполнение буфера было меньше нижнего граничного условия (т.е. буфер не успевал заполнятся, прежде чем клиент начинал чтение из него) — в этом случае клиент «увеличивает» размер буфера путем пропуска одного тика симуляции.
  5. В случае, когда дисперсия была выше нормы, мы не можем полагаться на эти данные, т.к. сетевые скачки за данный промежуток времени были слишком большие. Тогда клиент отбрасывает все текущие данные и начинает сбор статистики заново.

Компенсация лага на сервере

Из-за того, что клиент получает обновления мира с сервера с задержкой (лагом), игрок видит мир немного не таким, как он существует на сервере. Игрок видит себя в настоящем, а весь остальной мир — в прошлом. На сервере же весь мир существует в одном времени.

Как мы писали сетевой код мобильного PvP шутера: синхронизация игрока на клиенте - 4
Из-за этого происходит ситуация с тем, что игрок локально стреляет в цель, которая находится на сервере в другом месте.

Для компенсации лага мы используем перемотку времени на сервере. Алгоритм работы примерно такой:

  1. Клиент с каждым инпутом дополнительно отсылает на сервер время тика, в котором он видит остальной мир.
  2. Сервер валидирует это время: входит ли разница между текущим временем и видимым временем мира клиента в доверительный интервал.
  3. Если время валидно, сервер оставляет игрока в текущем времени, а весь остальной мир откатывает в прошлое к тому состоянию, которое видел игрок, и просчитывает результат выстрела.
  4. Если игрок попал, то урон наносится в текущем серверном времени.

Перемотка времени на сервере работает следующим образом: на севере хранится история мира (в ECS) и история физики (поддерживается движком Volatile Physics). В момент просчета выстрела данные игрока берутся с текущего состояния мира, а остальных игроков — из истории.

Код системы валидации выстрела выглядит примерно так:

public void Execute(GameState gs)
{
    foreach (var shotPair in gs.WorldState.Shot)
    {
        var shot = shotPair.Value;
        var shooter = gs.WorldState[shotPair.Key];
        var shooterTransform = shooter.Transform;
        var weaponStats = gs.WorldState.WeaponStats[shot.WeaponId];

        // DeltaTime shouldn't exceed physics history size
        var shootDeltaTime = (int) (gs.Time - shot.ShotPlayerWorldTime);
        if (shootDeltaTime > PhysicsWorld.HistoryLength)
        {
            continue;
        }

        // Get the world at the time of shooting.
        var oldState = _immutableHistory.Get(shot.ShotPlayerWorldTime);
        
        var potentialTarget = oldState.WorldState[shot.Target.Id];
        var hitTargetId = _singleShotValidator.ValidateTargetAvailabilityInLine(oldState, potentialTarget, shooter,
            shootDeltaTime, weaponStats.ShotDistance, shooter.Transform.Angle.GetDirection());

        if (hitTargetId != 0)
        {    
            gs.WorldState.CreateEntity().AddDamage(gs.WorldState[hitTargetId], shooter, weaponStats.ShotDamage);
        }
    }
}

Один существенный недостаток в подходе это то, что мы доверяем клиенту в данных о времени тика, который он видит. Потенциально, игрок может получить преимущество за счет искусственного повышения пинга. Т.к. чем больше у игрока пинг, тем дальше в прошлом он производит выстрел.

Некоторые проблемы, с которыми мы столкнулись

В ходе реализации данного сетевого движка мы столкнулись с множеством проблем, некоторые из них достойны отдельной статьи, но здесь я затрону только некоторые из них.

Симуляция всего мира в системе предсказания и копирование

Изначально все системы в нашей ECS имели только один метод: void Execute (GameState gs). В таком методе обычно обрабатывались компоненты относящиеся ко всем игрокам.

Пример системы движения в изначальной реализации:

public sealed class MovementSystem : ISystem
{
  public void Execute(GameState gs)
  {
    foreach (var movementPair in gs.WorldState.Movement)
    {
      var transform = gs.WorldState.Transform[movementPair.Key];
      transform.Position += movementPair.Value.Velocity * GameState.TickDuration;
    }
  }
}

Но в системе предсказания локального игрока нам необходимо было обрабатывать только компоненты, относящиеся к конкретному игроку. Изначально мы реализовали это с помощью копирования.

Процесс предсказания происходил следующим образом:

  1. Создавалась копия геймстейта.
  2. На вход ECS подавалась копия.
  3. Проходила симуляция всего мира в ECS.
  4. Из нового полученного геймстейта копировались все данные, относящиеся к локальному игроку.

Метод предикшена выглядел так:

void PredictNewState(GameState state)
{
  var newState = _stateHistory.Get(state.Tick+1);
  var input = _inputHistory.Get(state.Tick);
  newState.Copy(state);
  _tempGameState.Copy(state);
  _ecsExecutor.Execute(_tempGameState, input);
  _playerEntitiesCopier.Copy(_tempGameState, newState);
}

Проблем в данной реализации было две:

  1. Т.к. мы используем классы, а не структуры — копирование для нас довольно дорогая операция (примерно 0.1-0.15 ms на iPhone 5S).
  2. Симуляция всего мира тоже занимает немало времени (порядка 1.5-2 ms на iPhone 5S).

Если учесть, что при процессе согласования необходимо пересчитать от 5 до 15 состояний мира за один кадр, то с такой реализацией все жутко тормозило.

Решение было довольно простым: научиться симулировать мир по частям, а именно симулировать только конкретного игрока. Мы переписали все системы так, чтобы можно было передавать ID игрока и симулировать только его.

Пример системы движения после изменений:

public sealed class MovementSystem : ISystem
{
  public void Execute(GameState gs)
  {
    foreach (var movementPair in gs.WorldState.Movement)
    {
        Move(gs.WorldState.Transform[movementPair.Key], movementPair.Value);
    }
  }

  public void ExecutePlayer(GameState gs, uint playerId)
  {
    var movement = gs.WorldState.Movement[playerId];
    if(movement != null)
    {
        Move(gs.WorldState.Transform[playerId], movement);
    }
  }

  private void Move(Transform transform, Movement movement)
  {
    transform.Position += movement.Velocity * GameState.TickDuration;
  }
}

После изменений мы смогли избавиться от лишних копирований в системе предсказания и снизить нагрузку на систему согласования.

Код:

void PredictNewState(GameState state, uint playerId)
{
  var newState = _stateHistory.Get(state.Tick+1);
  var input = _inputHistory.Get(state.Tick);
  newState.Copy(state);
  _ecsExecutor.Execute(newState, input, playerId);
}

Создание и удаление сущностей в системе предсказания

В нашей системе сопоставление сущностей на сервере и клиенте происходит по целочисленному идентификатору (id). Для всех сущностей у нас используется сквозная нумерация идентификаторов, каждая новая сущность имеет значение id = oldID+1.

Такой подход очень удобен в реализации, однако у него есть один существенный недостаток: очередность создания новых сущностей на клиенте и сервере может быть разная и вследствие этого идентификаторы у сущностей будут отличаться.

Эта проблема у нас проявилась, когда мы реализовывали систему предсказания выстрелов игрока. Каждый выстрел у нас — это отдельная сущность с компонентом shot. У каждого клиента id сущностей выстрелов в системе предсказания были последовательны. Но если в этот же момент другой игрок стрелял — то на сервере id всех выстрелов отличались от клиентский.

Выстрелы на сервере создавались в другой очередности:

Как мы писали сетевой код мобильного PvP шутера: синхронизация игрока на клиенте - 5

Для выстрелов мы обошли это ограничение, исходя из геймплейных особенностей игры. Выстрелы — это быстро-живущие сущности, которые уничтожаются в системе через доли секунд после создания. На клиенте мы выделили отдельный диапазон ID, которые не пересекаются с серверными ID и перестали учитывать выстрелы в системе согласования. Т.е. выстрелы локального игрока рисуются в игре всегда только по системе предсказания и не учитывают данные с сервера.

При таком подходе игрок не видит артефактов на экране (удаление, пересоздание, откаты выстрелов), а расхождения с сервером — незначительные и никак не влияют на геймплей в целом.

Этот метод позволил решить проблему с выстрелами, но не всю проблему создания сущностей на клиенте целиком. Мы еще работает над возможными методами решения сопоставления созданных объектов на клиенте и сервере.

Также следует заметить, что данная проблема касается только создания новых сущностей (с новыми ID). Добавление и удаление компонентов на уже созданных сущностях выполняется без проблем: компоненты не имеют идентификаторов и каждая сущность может иметь только один компонент конкретного типа. Поэтому обычно мы создаем сущности на сервере, а в системах предсказания только добавляем/удаляем компоненты.

В заключении хочу сказать, что задача реализации мультиплеера не самая легкая и быстрая, но информации о том, как это делать, довольно много.

Что почитать

Автор: Алексей Дюдя

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js