В одной из предыдущих статей мы провели обзор технологий, которые используются на нашем новом проекте — fast paced шутере для мобильных устройств. Теперь хочу поделиться, как устроена клиентская часть сетевого кода будущей игры, с какими трудностями мы столкнулись и как их решали.
В целом подходы к созданию быстрых мультиплеерных игр за последние 20 лет не особо изменились. Можно выделить несколько методов в архитектуре сетевого кода:
- Рендеринг состояния мира на сервере без предсказания для локального игрока и с возможностью потери ввода игрока (инпута). Такой подход, кстати, используется на другом нашем проекте в разработке — про него можно почитать тут.
- Lockstep.
- Синхронизация состояния мира без детерминированной логики с предсказанием для локального игрока.
- Синхронизация по инпуту с полностью детерминированной логикой и предсказанием для локального игрока.
Особенность заключается в том, что в шутерах максимально важна отзывчивость управления — игрок нажимает кнопку (или двигает джойстик) и хочет сразу же увидеть результат своего действия. В первую очередь, потому что состояние мира в таких играх изменяется очень быстро и необходимо моментально реагировать на ситуацию.
Вследствие этого проекту не подходили подходы без механизма предсказаний действия локального игрока (prediction) и мы остановились на методе с синхронизацией состояния мира, без детерминированной логики.
Плюс подхода: меньшая сложность в реализации по сравнению с методом синхронизации при обмене инпутом.
Минус: увеличение объема трафика при отсылке всего состояния мира на клиент. Нам пришлось применить несколько различных техник оптимизации трафика, чтобы игра стабильно работала в мобильной сети.
В основе архитектуры геймплея у нас лежит ECS, про которую уже рассказывали. Такая архитектура позволяет удобно хранить данные об игровом мире, сериализовать, копировать и передавать их по сети. А также выполнять один и тот же код как на клиенте, так и на сервере.
Симуляция игрового мира происходит с фиксированной частотой 30 тиков в секунду. Это позволяет уменьшить лаг на инпут игрока и почти не использовать интерполяцию для визуального отображения состояния мира. Но здесь есть один существенный недостаток, который следует учитывать при разработке такой системы: для корректной работы системы предсказания локального игрока клиент должен выполнять симуляцию мира с той же частотой, что и сервер. И мы потратили уйму времени, чтобы оптимизировать симуляцию достаточно для целевых устройств.
Механизм предсказания действий локального игрока (prediction)
Механизм предикшена на клиенте реализован на базе ECS за счет выполнения одних и тех же систем как на клиенте, так и на сервере. Однако на клиенте выполняются не все системы, а только те, которые отвечают за локального игрока и не требуют актуальных данных о других игроках.
Пример списков систем выполняемых на клиенте и сервере:
На данный момент у нас порядка 30 систем, выполняющихся на клиенте и обеспечивающих предикшн игрока и около 80 систем, которые выполняются на сервере. Но мы не выполняем предсказания таких вещей, как нанесение урона, использование способностей или лечение союзников. В этих механиках существуют две проблемы:
- Клиент ничего не знает о вводе других игроков и предсказание таких вещей, как урон или лечение почти всегда будет расходиться с данными на сервере.
- Создание новых сущностей локально (выстрелов, снарядов, уникальных способностей), порожденных одним игроком, несет проблему сопоставления с сущностями, созданными на сервере.
Для таких механик лаг скрывается от игрока другими способами.
Пример: рисуем эффект попадания от выстрела сразу, а жизни противника обновляем только после того, как получим подтверждение о попадании с сервера.
Общая схема работы сетевого кода в проекте
Клиент и сервер синхронизируют время по номерам тиков. Из-за того, что передача данных по сети требует некоторого времени, клиент всегда находится впереди сервера на величину половины RTT + размер буфера ввода на сервере. На диаграмме выше показано, что клиент отправляет инпут для тика 20 (a). В этот же момент на сервере обрабатывается тик 15 (b). К моменту, когда инпут клиента дойдет до сервера, на сервере будет обрабатываться тик 20.
Весь процесс состоит из следующих шагов: клиент отсылает инпут игрока на сервер (a) → этот инпут обработается на сервере через время HRTT + input buffer size (b) → сервер шлет результирующее состояние мира на клиент (с) → клиент применит подтвержденное состояние мира с сервера через время RTT+input buffer size + game state interpolation buffer size (d).
После того как клиент получит новое подтвержденное состояние мира с сервера (d), ему необходимо выполнить процесс согласования (reconciliation). Дело в том, что клиент выполняет предсказание мира основываясь только на инпуте локального игрока. Инпуты других игроков ему не известны. И при расчете состояния мира на сервере игрок может находиться в другом состоянии, отличном от того, что предсказал клиент. Это может произойти в тех случаях, когда игрок попадает под оглушение или его убивают.
Процесс согласования состоит из двух частей:
- Сравнения предсказанного состояния мира для тика N, полученным с сервера. В сравнении участвуют только данные относящиеся к локальному игроку. Остальные данные мира всегда берутся с серверного состояния и не участвуют в согласовании.
- Во время сравнения могут возникнуть два случая:
— если предсказанное состояние мира совпало с подтвержденным с сервера, то клиент, используя предсказанные данные для локального игрока и новые данные для всего остального мира, продолжает симуляцию мира в обычном режиме;
— если же предсказанное состояние не совпало, то клиент использует всё серверное состояние мира и историю инпутов от клиента и пересчитывает новое предсказанное состояние мира игрока.
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 фпс.
Для выполнения процесса согласования на клиенте необходимо хранить два типа данных:
- Историю предсказанных состояний игрока.
- И историю инпутов.
Для этих целей мы используем циклический буфер. Размер буфера равен 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 видит, как убивает соперника. Если это происходило в движении, то обычно игрок успевал забежать за стену или в укрытие и уже там умирал. Плейтесты внутри команды показали, что такая задержка полностью ломает весь геймплей.
Решением этой проблемы стала реализация динамических размеров буферов инпута и геймстейтов:
- для буфера геймстейтов клиент всегда знает текущее наполнение буфера. В момент расчета очередного тика, клиент проверяет сколько стейтов уже находится в буфере;
- для буфера инпутов — сервер, помимо геймстейта, начал отправлять на клиент значение текущего заполнения буфера инпутов для конкретного клиента. Клиент в свою очередь анализирует эти два значения.
Алгоритм изменения размера буфера геймстейтов примерно следующий:
- Клиент считает среднее значение размера буфера за какой-то период времени и дисперсию.
- Если дисперсия в пределах нормы (т.е. за заданный промежуток времени не было больших скачков в заполнении и чтении из буфера) — клиент проверяет значение среднего размера буфера за этот период времени.
- Если среднее заполнение буфера было больше верхнего граничного условия (т.е. буфер бы заполнен больше, чем требуется) — клиент «уменьшает» размер буфера путем совершения дополнительного тика симуляции.
- Если же среднее заполнение буфера было меньше нижнего граничного условия (т.е. буфер не успевал заполнятся, прежде чем клиент начинал чтение из него) — в этом случае клиент «увеличивает» размер буфера путем пропуска одного тика симуляции.
- В случае, когда дисперсия была выше нормы, мы не можем полагаться на эти данные, т.к. сетевые скачки за данный промежуток времени были слишком большие. Тогда клиент отбрасывает все текущие данные и начинает сбор статистики заново.
Компенсация лага на сервере
Из-за того, что клиент получает обновления мира с сервера с задержкой (лагом), игрок видит мир немного не таким, как он существует на сервере. Игрок видит себя в настоящем, а весь остальной мир — в прошлом. На сервере же весь мир существует в одном времени.
Из-за этого происходит ситуация с тем, что игрок локально стреляет в цель, которая находится на сервере в другом месте.
Для компенсации лага мы используем перемотку времени на сервере. Алгоритм работы примерно такой:
- Клиент с каждым инпутом дополнительно отсылает на сервер время тика, в котором он видит остальной мир.
- Сервер валидирует это время: входит ли разница между текущим временем и видимым временем мира клиента в доверительный интервал.
- Если время валидно, сервер оставляет игрока в текущем времени, а весь остальной мир откатывает в прошлое к тому состоянию, которое видел игрок, и просчитывает результат выстрела.
- Если игрок попал, то урон наносится в текущем серверном времени.
Перемотка времени на сервере работает следующим образом: на севере хранится история мира (в 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;
}
}
}
Но в системе предсказания локального игрока нам необходимо было обрабатывать только компоненты, относящиеся к конкретному игроку. Изначально мы реализовали это с помощью копирования.
Процесс предсказания происходил следующим образом:
- Создавалась копия геймстейта.
- На вход ECS подавалась копия.
- Проходила симуляция всего мира в ECS.
- Из нового полученного геймстейта копировались все данные, относящиеся к локальному игроку.
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);
}
Проблем в данной реализации было две:
- Т.к. мы используем классы, а не структуры — копирование для нас довольно дорогая операция (примерно 0.1-0.15 ms на iPhone 5S).
- Симуляция всего мира тоже занимает немало времени (порядка 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 всех выстрелов отличались от клиентский.
Выстрелы на сервере создавались в другой очередности:
Для выстрелов мы обошли это ограничение, исходя из геймплейных особенностей игры. Выстрелы — это быстро-живущие сущности, которые уничтожаются в системе через доли секунд после создания. На клиенте мы выделили отдельный диапазон ID, которые не пересекаются с серверными ID и перестали учитывать выстрелы в системе согласования. Т.е. выстрелы локального игрока рисуются в игре всегда только по системе предсказания и не учитывают данные с сервера.
При таком подходе игрок не видит артефактов на экране (удаление, пересоздание, откаты выстрелов), а расхождения с сервером — незначительные и никак не влияют на геймплей в целом.
Этот метод позволил решить проблему с выстрелами, но не всю проблему создания сущностей на клиенте целиком. Мы еще работает над возможными методами решения сопоставления созданных объектов на клиенте и сервере.
Также следует заметить, что данная проблема касается только создания новых сущностей (с новыми ID). Добавление и удаление компонентов на уже созданных сущностях выполняется без проблем: компоненты не имеют идентификаторов и каждая сущность может иметь только один компонент конкретного типа. Поэтому обычно мы создаем сущности на сервере, а в системах предсказания только добавляем/удаляем компоненты.
В заключении хочу сказать, что задача реализации мультиплеера не самая легкая и быстрая, но информации о том, как это делать, довольно много.
Что почитать
- Мультиплеер в быстрых играх — перевод статьи Fast-Paced Multiplayer (Part I): Introduction (на мой взгляд, это лучшая статья на Хабре о сетевом взаимодействии в играх).
- GDC Vault Overwatch Gameplay Architecture and Netcode — лекция с GDC 17, о ECS и сетевом коде в Overwatch (к сожалению, доступ платный).
- GDC Vault: 8 Frames in 16ms: Rollback Networking in Mortal Kombat and Injustice 2 — о том, как это делают в файтингах.
- Source Multiplayer Networking — как работает мультиплеер в Counter Strike.
- Gaffer on Games — в целом о сетевом коде в играх.
- UDP in Game Engines.
- GDC Vault: I Shot you first networking — как работает мультиплеер в Halo: Reach.
Автор: Алексей Дюдя