Вступление
Battle Prime — первый проект нашей студии. Несмотря на то, что многие члены команды имеют приличный опыт в разработке игр, мы, естественно, сталкивались с разными сложностями во время работы над ним. Они возникали как в процессе работы над движком, так и в процессе разработки самой игры.
В геймдев индустрии огромное количество разработчиков, которые охотно делятся своими историями, наработками, архитектурными решениями — в том или ином виде. Этот опыт, выложенный в публичное пространство в виде статей, презентаций и докладов, является отличным источником идей и вдохновения. Например, доклады команды разработки из Overwatch были для нас очень полезны при работе над движком. Как и сама игра, они очень талантливо сделаны, и я советую посмотреть их всем интересующимся. Доступны в GDC vault и на YouTube: www.youtube.com/channel/UC0JB7TSe49lg56u6qH8y_MQ
Это одна из причин, по которой мы также хотим вносить вклад в общее дело — и эта статья одна из первых, посвященная техническим деталям разработки движка Blitz Engine и игры на нем — Battle Prime.
Статья будет поделена на две части:
- ECS: имплементация Entity-Component-System паттерна внутри Blitz Engine. Этот раздел важен для понимания примеров кода в статье, и сам по себе является отдельной интересной темой.
- Неткод и геймплей: все, что касается высокоуровневой сетевой части и ее использования внутри игры — клиент-серверная архитектура, клиентские предсказания, репликация. Одной из важнейших вещей в шутере является стрельба, так что ей будет уделено большее количество времени.
Под катом много мегабайт гифок!
Внутри каждого раздела, помимо рассказа о функциональности и его использовании, я постараюсь описать и недостатки, которые он в себе несет — будь это его ограничения, неудобства в работе, или просто мысли по поводу его улучшений в будущем.
Я также постараюсь давать примеры кода и некоторую статистику. Во-первых, это просто интересно, а, во-вторых, дает немного контекста по поводу масштаба использования той или иной функциональности и проекта.
ECS
Внутри движка мы используем термин “мир” для описания сцены, содержащей иерархию объектов.
Миры работают по шаблону Entity-Component-System (описание на википедии: en.wikipedia.org/wiki/Entity_component_system):
- Entity — объект внутри сцены. Является хранилищем для набора компонентов. Объекты могут быть вложенными, формируя иерархию внутри мира;
- Component — представляет из себя данные, необходимые для работы какой-либо механики, и определяющий поведение объекта. Например, `TransformComponent` содержит в себе трансформацию объекта, а `DynamicBodyComponent` — данные для физической симуляции. Некоторые компоненты могут не иметь в себе дополнительных данных, простое их присутствие в объекте описывает состояние этого объекта. Например, в Battle Prime используются `AliveComponent` и `DeadComponent`, которыми помечены живые и мёртвые персонажи соответственно;
- System — периодически вызываемый набор функций, которые поддерживают решение поставленной им задачи. При каждом вызове система обрабатывает объекты, удовлетворяющие какому-то условию (как правило, имеющие определенный набор компонентов) и, по необходимости, модифицирует их. Вся игровая логика и большая часть движка реализованы на уровне систем. Например, внутри движка есть `LodSystem`, которая занимается вычислением LOD (level of detail) индексов для объекта на основании его трансформации в мире и других данных. Этот индекс, содержащейся в `LodComponent`е затем используется другими системами для своих задач.
Подобный подход позволяет легко комбинировать различные механики в рамках одного объекта. Как только сущность получает достаточно данных для работы какой-то механики, системы, отвечающие за эту механику, начинают обрабатывать этот объект.
На практике добавление нового функционала сводится к новому компоненту (или набору компонентов) и новой системе (или набору систем), которые этот функционал реализуют. В подавляющем большинстве случаев, работать по этому паттерну удобно.
Рефлексия
Перед тем как переходить к описанию компонентов и систем, я остановлюсь немного на механизме рефлексии, так как она часто будет использоваться в примерах кода.
Рефлексия позволяет получать и использовать информацию о типах во время работы приложения. В частности, доступны следующие возможности:
- Получить список типов по определенному критерию (например, наследников какого-то класса или имеющих специальный тег),
- Получить список полей класса,
- Получить список методов внутри класса,
- Получить список значений enum’ов,
- Вызвать какой-то метод или изменить значение поля,
- Получить метаданные поля или метода, которые могут использоваться для какого-то конкретного функционала.
Многие модули внутри движка используют рефлексию для своих целей. Некоторые примеры:
- Интеграции скриптовых языков используют рефлексию для работы с типами, объявленными в C++ коде;
- Редактор использует рефлексию для получения списка компонентов, которые могут добавляться в объект, а также для отображения и редактирования их полей;
- Сетевой модуль использует метаданные полей внутри компонентов для ряда функций: в них указаны параметры репликации полей с сервера на клиенты, квантизации данных при репликации и так далее;
- Различные конфиги десериализуются в объекты соответствующих типов с помощью рефлексии.
Мы используем собственную реализацию, интерфейс которой не сильно отличается от других существующих решений (например, github.com/rttrorg/rttr). На примере `CapturePointComponent`а (который описывает точку захвата для игрового режима), добавление рефлексии в тип выглядит следующим образом:
// Описание в заголовочном файле
class CapturePointComponent final : public Component
{
// Индикация наличия рефлексии для данного типа и указание его базового класса
BZ_VIRTUAL_REFLECTION(Component);
public:
float points_to_own = 10.0f;
String visible_name;
// … другие поля
};
// Имплементация в .cpp файле
BZ_VIRTUAL_REFLECTION_IMPL(CapturePointComponent)
{
// Начало описания класса и его метаданные
ReflectionRegistrar::begin_class<CapturePointComponent>()
[M<Serializable>(), M<Scriptable>(), M<DisplayName>("Capture point")]
// Описание поля и его метаданные
.field("points_to_own", &CapturePointComponent::points_to_own)
[M<Serializable>(), M<DisplayName>("Points to own")]
.field("visible_name", &CapturePointComponent::visible_name)
[M<Serializable>(), M<DisplayName>("Name")]
// … остальные поля и методы
}
Отдельное внимание хочется уделить метаданным типов, полей и методов, которые объявляются с помощью выражения
M<T>()
где `T` — это тип метаданных (внутри команды мы просто используем термин “мета”, в дальнейшем буду использовать его). Они используются разными модулями для своих целей. Например, редактор использует `DisplayName` для отображения имен типов и полей внутри редактора, а сетевой модуль получает список всех компонентов, и среди них ищет поля помеченные как `Replicable` — они будут отправляться с сервера на клиенты.
Описание компонентов и их добавление в объект
Каждый компонент является наследником базового класса `Component` и может описать с помощью рефлексии поля, которые он использует (если это необходимо).
Вот как объявлен и описан `AvatarHitComponent` внутри игры:
/** Component that indicates avatar hit event. */
class AvatarHitComponent final : public Component
{
BZ_VIRTUAL_REFLECTION(Component);
public:
PlayerId source_id = NetConstants::INVALID_PLAYER_ID;
PlayerId target_id = NetConstants::INVALID_PLAYER_ID;
HitboxType hitbox_type = HitboxType::UNKNOWN;
};
BZ_VIRTUAL_REFLECTION_IMPL(AvatarHitComponent)
{
ReflectionRegistrar::begin_class<AvatarHitComponent>()
.ctor_by_pointer()
.copy_ctor_by_pointer()
.field("source_id", &AvatarHitComponent::source_id)[M<Replicable>()]
.field("target_id", &AvatarHitComponent::target_id)[M<Replicable>()]
.field("hitbox_type", &AvatarHitComponent::hitbox_type)[M<Replicable>()];
}
Данный компонент помечает объект, который создается в результате попадания игрока по другому игроку. Он содержит в себе информацию об этом событии, такую как идентификаторы атакующего игрока и его цели, а также тип хитбокса, по которому произошло попадание. Упрощенно, этот объект создается внутри серверной системы подобным образом:
Entity hit_entity = world->create_entity();
auto* const avatar_hit_component = hit_entity.add<AvatarHitComponent>();
avatar_hit_component->source_id = source_player_id;
avatar_hit_component->target_id = target_player_id;
avatar_hit_component->hitbox_type = hitbox_type;
// Ниже добавляются остальные необходимые компоненты
// Например включающие репликацию на клиенты
// ...
Объект с `AvatarHitComponent` затем используется разными системами: для воспроизведения звуков попадания по игрокам, сбора статистики, слежения за достижениями игрока и так далее.
Описание систем и их работа
Система — объект с типом, отнаследованным от `System`, который содержит в себе методы, реализующие выполнение той или иной задачи. Как правило, одного метода достаточно. Несколько методов необходимо, если они должны выполняться в разные моменты времени в рамках одного кадра.
Аналогично компонентам, описывающим свои поля, каждая система описывает методы, которые должны выполняться миром.
Например, `ExplosiveSystem`, отвечающая за взрывы, объявлена и описана следующим образом:
// System responsible for handling explosive components:
// - tracking when they need to be exploded: by timer, trigger zone etc.
// - destroying them on explosion and creating separate explosion entity
class ExplosiveSystem final : public System
{
BZ_VIRTUAL_REFLECTION(System);
public:
ExplosiveSystem(World* world);
private:
void update(float dt);
// Приватные данные и методы, необходимые для работы системы
// ...
};
BZ_VIRTUAL_REFLECTION_IMPL(ExplosiveSystem)
{
ReflectionRegistrar::begin_class<ExplosiveSystem>()[M<SystemTags>("battle")]
.ctor_by_pointer<World*>()
.method("ExplosiveSystem::update", &ExplosiveSystem::update)[M<SystemTask>(
TaskGroups::GAMEPLAY_END,
ReadAccess::set<
TimeSingleComponent,
WeaponDescriptorComponent,
BallisticComponent,
ProjectileComponent,
GrenadeComponent>(),
WriteAccess::set<ExplosiveComponent>(),
InitAccess::set<ExplosiveStatsComponent,
LocalExplosionComponent,
ServerExplosionComponent,
EntityWasteComponent,
ReplicationComponent,
AbilityIdComponent,
WeaponBaseStatsComponent,
HitDamageStatsComponent,
ClusterGrenadeStatsComponent>(),
UpdateType::FIXED,
Vector<TaskOrder>{ TaskOrder::before(FastName{ "ballistic_update" }) })];
}
Внутри описания системы указываются следующие данные:
- Тег, к которому относится система. Каждый мир содержит набор тэгов, и по ним находятся системы, которые должны в этом мире работать. В данном случае, тег `battle` означает мир, в котором происходит бой между игроками. Другими примерами тэгов являются `server` и `client` (система выполняется только на сервере или клиенте соответственно) и `render` (система выполняется только в режиме с GUI);
- Группа, внутри которой выполняется эта система и список компонентов, которые использует эта система — на запись, чтение и создание;
- Update type — должна ли эта система работать в normal update’е, fixed update’е или других;
- Явные разрешения зависимости между системами.
Подробнее про группы систем, зависимости и update type'ы будет рассказано ниже.
Объявленные методы вызываются миром в нужный момент времени для поддержания функционала этой системы. Содержимое метода зависит от системы, но, как правило это проход по всем объектам, соответствующим критерию данной системы, и их последующее обновление. Например, обновление `ExplosiveSystem` внутри игры выглядит следующим образом:
void ExplosiveSystem::update(float dt)
{
const auto* time_single_component = world->get<TimeSingleComponent>();
// Init new explosives
for (Component* component : new_explosives_group->components)
{
auto* explosive_component = static_cast<ExplosiveComponent*>(component);
init_explosive(explosive_component, time_single_component);
}
new_explosives_group->components.clear();
// Update all explosives
for (ExplosiveComponent* explosive_component : explosives_group)
{
update_explosive(explosive_component, time_single_component, dt);
}
}
Группы в примере выше (`new_explosives_group` и `explosives_group`) — вспомогательные контейнеры, которые упрощают реализации систем. `new_explosives_group` — контейнер с новыми объектами, которые необходимы этой системе и которые еще ни разу не были обработаны, а `explosives_group` — контейнер со всеми объектами которые необходимо обрабатывать каждый кадр. За заполнение этих контейнеров отвечает непосредственно мир. Их получение системой происходит в ее конструкторе:
ExplosiveSystem::ExplosiveSystem(World* world)
: System(world)
{
// `explosives_group` будет содержать в себе все объекты с `ExplosiveComponent`
explosives_group = world->acquire_component_group<ExplosiveComponent>();
// `new_explosives_group` будет добавлять в себя все новые объекты
// с `ExplosiveComponent` - за чистку этого контейнера отвечает система
new_explosives_group = explosive_group->acquire_component_group_on_add();
}
Обновление мира
Мир, объект типа `World`, каждый кадр вызывает необходимые методы у ряда систем. Какие системы будут вызваны зависит от их типа.
Часть систем обязательно обновляются каждый кадр (внутри движка используется термин “normal update”) — к такому типу относятся все системы, влияющие на отрисовку кадра и звуки: скелетные анимации, частицы, UI и так далее. Другая часть выполняется с фиксированной, заранее заданной, частотой (мы используем термин “fixed update”, а для количества fixed update’ов в секунду — FFPS) — в них обрабатывается большая часть геймплейной логики и все, что должно быть синхронизировано между клиентом и сервером — например, часть инпута от игрока, движение персонажа, стрельба, часть физической симуляции.
Частота выполнения fixed update’а должна быть сбалансирована — слишком маленькое значение приводит к неотзывчивому геймплею (например, инпут игрока обрабатывается реже, а значит с большей задержкой), а слишком высокое — к большим требованиям к производительности от устройства, на котором работает приложение. Это также означает, что чем больше частота, тем больше затраты на серверные мощности (меньшее количество боев может работать одновременно на одной машине).
В гифке ниже, мир работает с частотой 5 fixed update’ов в секунду. Можно заметить задержку между нажатием на кнопку W и стартом движения, а также задержку между отпусканием кнопки и остановкой движения персонажем:
В следующей гифке, мир работает с частотой 30 fixed update’ов в секунду, что дает значительно более отзывчивое управление:
На данный момент в Battle Prime fixed update мира выполняется 31 раз в секунду. Такое “некрасивое” значение выбрано специально — на нем могут проявляться баги, которых не было бы в других ситуациях, когда количество обновлений в секунду является, например, круглым числом или кратным частоте обновления экрана.
Порядок выполнения систем
Одним из моментов, осложняющим работу с ECS, является задание порядка выполнения систем. Для контекста, на момент написания статьи, в клиенте Battle Prime во время боя между игроками работает 251 система и их число только растет.
Система, которая по ошибке выполняется в неправильный момент времени может приводить к трудноуловимым багам или же к задержке в работе какой-то механики на один кадр (например, если система нанесения урона работает в начале кадра, а система полета снаряда в конце, то урон наносится с задержкой в один кадр).
Порядок выполнения систем можно задавать разными способами, например:
- Явное указание порядка;
- Указание численного “приоритета” системы и последующая сортировка по приоритету;
- Автоматическое построение графа зависимостей между системами и установка их в нужные места в порядке выполнения.
На данный момент у нас используется третий вариант. Каждая система указывает, какие компоненты она использует на чтение, какие на запись, и какие компоненты она создает. Затем, системы автоматически выстраиваются между собой в нужном порядке:
- Система, читающая компонент A, идет после системы, пишущей в компонент A;
- Система, пишущая в или читающая компонент B, идет после системы, создающей компонент B;
- Если обе системы пишут в компонент С, порядок может быть любым (но может быть указан вручную при необходимости).
В теории, такое решение сводит к минимуму управление порядком выполнения, достаточно лишь задать маски компонентов для системы. На практике, с ростом проекта это приводит к большему и большему числу циклов между системами. Если система-1 пишет в компонент A, и читает компонент B, а система-2 читает компонент А и пишет в компонент B — это цикл, и он должен быть разрешен вручную. Часто, в цикле больше двух систем. Их разрешение требует времени и явных указаний зависимости между ними.
Поэтому в Blitz Engine есть “группы” систем. Внутри группы системы автоматически выстраиваются в нужном порядке (а циклы все так же разрешаются вручную), а порядок групп задан явно. Это решение — что-то среднее между полностью ручным порядком и полностью автоматизированным, и на его эффективность серьезно влияют размеры групп. Как только группа становится слишком большой, программисты снова начинают часто сталкиваться с проблемам циклов внутри них.
На данный момент в Battle Prime 10 групп. Этого все еще недостаточно, и мы планируем увеличить их количество, выстраивая строгую логическую последовательность между ними, и используя автоматическое построение графа внутри каждой из них.
Указание того, какие компоненты используются системами на запись или чтение также позволит в будущем автоматически группировать системы в “блоки”, которые будут выполняться параллельно друг с другом.
Ниже показана вспомогательная утилита, которая отображает список систем и зависимости между ними внутри каждой из групп (полные графы внутри групп выглядят устрашающе). Оранжевым цветом показаны явно заданные зависимости между системами:
Общение между системами и их конфигурация
Задачи, которые выполняют внутри себя системы, могут в той или иной степени зависеть от результатов выполнения других систем. Например, система обрабатывающая столкновения двух объектов зависит от симуляции физики, которая эти столкновения регистрирует. А система нанесения урона зависит от результатов работы баллистической системы, которая отвечает за движение снарядов.
Самый простой и очевидный способ общения между системами — использование компонентов. Одна система складывает результаты своей работы в компонент, а вторая система читает эти результаты из компонента и на их основе решает свою задачу.
Подход, основанный на компонентах, может быть неудобен в некоторых случаях:
- Что, если результат работы системы не привязан напрямую к какому-то объекту? Например, система собирающая статистику боя (количество выстрелов, попаданий, смертей и так далее) — собирает ее глобально, на основе всего боя;
- Что, если работу системы нужно каким-то образом сконфигурировать? Например, системе физической симуляции необходимо знать, какие типы объектов должны регистрировать коллизии между собой, а какие нет.
Для решения этих проблем, мы используем подход, который позаимствовали у команды разработки Overwatch — Single Component’ы.
Single component — это компонент, который существует в мире в единственном экземпляре и получается напрямую из мира. Системы могут использовать его для складывания результатов своей работы, которые затем используются другими системами, либо для настройки их работы.
На данный момент в проекте (модули движка + игра) порядка 120 Single Component’ов, которые используются для разных целей — от хранения глобальных данных мира до конфигурации работы отдельных систем.
“Чистота” подхода
В самом “чистом” виде подобный подход к системам и компонентам предполагает наличие данных только внутри компонентов и наличие логики только внутри систем. По моему мнению, на практике это ограничение редко имеет смысл строго соблюдать (хотя дебаты по этому поводу все еще периодически поднимаются).
Можно выделить следующие доводы в пользу менее “строгого” подхода:
- Часть кода должна быть общей — и выполняться синхронно из разных систем или при установке каких-то свойств компонентов. Подобная логика описывается отдельно. В рамках движка мы используем термин Utils. Например, внутри игры `DamageUtils` содержит в себе логику, связанную с применением урона — который может наноситься из разных систем;
- Нет смысла держать приватные данные системы в каком-то месте, кроме самой этой системы — они никому кроме нее не понадобятся, и их вынос в другое место не несет особой пользы. Из этого правила есть исключение, которое связано с функционалом клиентских предсказаний — о нем будет написано в разделе ниже;
- Компонентам полезно иметь небольшое количество логики — в большинстве своем это умные геттеры и сеттеры, которые упрощают работу с компонентом.
Неткод
Battle Prime использует архитектуру с авторитарным сервером и клиентскими предсказаниями. Это позволяет игроку получать мгновенный фидбек от своих действий даже на высоких пингах и потерях пакетов, а проекту в целом — минимизировать читерство со стороны игроков, т.к. сервер диктует все результаты симуляции внутри боя.
Весь код внутри проекта игры поделен на три части:
- Клиентский — системы и компоненты, которые работают только на клиенте. К ним относятся такие вещи как UI, автострельба и интерполяция;
- Серверный — системы и компоненты, которые работают только на сервере. Например, все, что связано с нанесением урона и спавном персонажей;
- Общий — это все, что работает и на сервере, и на клиенте. В частности, все системы, вычисляющие передвижение персонажа, состояние оружия (количество патронов, кулдауны) и все остальное, что требуется предсказывать на клиенте. Большая часть систем, отвечающих за визуальные эффекты также являются общими — сервер может быть опционально запущен в GUI режиме (по большей части только для отладки).
Пользовательский ввод (инпут)
Перед тем как переходить к деталям репликации и предсказаний на клиенте, следует остановиться на работе с инпутом внутри движка — детали этого будут важны в разделах ниже.
Весь ввод от игрока делится на два типа: низкоуровневый и высокоуровневый:
- Низкоуровневый инпут — это события от устройств ввода, такие как нажатие клавиш, прикосновение к экрану и так далее. Подобный инпут редко обрабатывается геймплейными системами;
- Высокоуровневый инпут — представляет из себя действия пользователя, совершенные им в контексте игры: выстрел, смена оружия, движение персонажа и так далее. Для подобных высокоуровневых действий мы используем термин `Action`. Также, с действием могут быть ассоциированы дополнительные данные — такие как направление движения или индекс выбранного оружия. Подавляющее большинство систем работают именно с Action’ами.
Высокоуровневый инпут генерируется либо на основе биндингов из низкоуровневого, либо программно. Например, действие стрельбы может быть завязано на нажатие кнопки мыши, либо же сгенерировано системой, отвечающей за автострельбу — как только игрок навел прицел на врага, эта система генерирует action выстрела, если у пользователя включена соответствующая настройка. Действия также могут быть отправлены UI-системой: к примеру, по нажатию на соответствующую кнопку или при движении экранного джойстика. Системе, которая производит саму стрельбу, неважно как этот action был создан.
Логически связанные друг с другом действия объединены в группы (объекты типа `ActionSet`). Группы могут отключаться если в текущем контексте они не нужны — например, в Battle Prime есть несколько групп, среди которых:
- Действия для управления передвижением персонажа,
- Действия для стрельбы из автоматического оружия,
- Действия для стрельбы из полуавтоматического оружия.
Из последних двух групп в один момент времени активна только одна, в зависимости от типа выбранного оружия — они отличаются тем, каким образом генерируется действие FIRE: пока нажата кнопка (для автоматического оружия) или же только один раз при нажатии на кнопку (для полуавтоматического оружия).
Подобным образом создаются и настраиваются группы действий внутри игры внутри одной из систем:
static const Map<FastName, ActionSet> action_sets = {
{
// Действия для передвижения персонажа
ControlModes::CHARACTER_MOVEMENT,
ActionSet
{
{
DigitalBinding{ ActionNames::JUMP, { { InputCode::KB_SPACE, DigitalState::just_pressed() } }, nullopt },
DigitalBinding{ ActionNames::MOVE, { { InputCode::KB_W, DigitalState::pressed() } }, ActionValue{ AnalogState{0.0f, 1.0f, 0.0f} } },
// Прочие действия для передвижения...
},
{
AnalogBinding{ ActionNames::LOOK, InputCode::MOUSE_RELATIVE_POSITION, AnalogStateType::ABSOLUTE, AnalogStateBasis::LOGICAL, {} }
// Прочие действия для передвижения...
}
}
},
{
// Действия для стрельбы из автоматического оружия
ControlModes::AUTOMATIC_FIRE,
ActionSet
{
{
// FIRE будет генерироваться все время, пока нажата левая кнопка мыши
DigitalBinding{ ActionNames::FIRE, { { InputCode::MOUSE_LBUTTON, DigitalState::pressed() } }, nullopt },
// Прочие действия для стрельбы в автоматическом режиме...
}
}
},
{
// Действия для стрельбы из полуавтоматического оружия
ControlModes::SEMI_AUTOMATIC_FIRE,
ActionSet
{
{
// FIRE будет генерироваться на каждое отдельное нажатие левой кнопки мыши
DigitalBinding{ ActionNames::FIRE, { { InputCode::MOUSE_LBUTTON, DigitalState::just_pressed() } }, nullopt },
// Прочие действия для стрельбы в полуавтоматическом режиме...
}
}
}
// Другие режимы управления...
};
В Battle Prime описано около 40 action’ов. Часть их них используется только для отладки или записи роликов.
Репликация
Репликация — процесс передачи данных с сервера на клиенты. Все данные передаются через объекты в мире:
- Их создание и удаление,
- Создание и удаление компонентов на объектах,
- Изменение свойств компонентов.
Репликация настраивается при помощи соответствующего компонента. Например, подобным образом в игре настраивается репликация оружия игрока:
auto* replication_component = weapon_entity.add<ReplicationComponent>();
replication_component->enable_replication<WeaponDescriptorComponent>(Privacy::PUBLIC);
replication_component->enable_replication<WeaponBaseStatsComponent>(Privacy::PUBLIC);
replication_component->enable_replication<WeaponComponent>(Privacy::PRIVATE);
replication_component->enable_replication<BallisticsStatsComponent>(Privacy::PRIVATE);
// ... и прочие компоненты
Для каждого компонента указывается приватность, которая используется при репликации. Приватные компоненты будут отправлены с сервера только игроку, владеющему данным оружием. Публичные компоненты будут отправляться всем. В данном примере, публичными являются `WeaponDescriptorComponent` и `WeaponBaseStatsComponent` — они содержат данные, необходимые для корректного отображения других игроков. Например, индекс слота, в котором лежит оружие и его тип нужны для анимаций. Остальные компоненты отправляются приватно игроку, который владеет этим оружием — параметры баллистики снарядов, информацию об общем кол-ве патронов, доступных режимах стрельбы и так далее. Есть и более специализированные режимы приватности: например, можно отправлять компонент только союзникам или только врагам.
Каждый компонент внутри своего описания обязан указать, какие именно поля должны реплицироваться в рамках этого компонента. Например, все поля внутри `WeaponComponent`а помечены как `Replicable`:
BZ_VIRTUAL_REFLECTION_IMPL(WeaponComponent)
{
ReflectionRegistrar::begin_class<WeaponComponent>()
.ctor_by_pointer()
.copy_ctor_by_pointer()
.field("owner", &WeaponComponent::owner)[M<Replicable>()]
.field("fire_mode", &WeaponComponent::fire_mode)[M<Replicable>()]
.field("loaded_ammo", &WeaponComponent::loaded_ammo)[M<Replicable>()]
.field("ammo", &WeaponComponent::ammo)[M<Replicable>()]
.field("shooting_cooldown_end_ms", &WeaponComponent::shooting_cooldown_end_ms)[M<Replicable>()];
}
Этот механизм очень удобен в использовании. Например, внутри серверной системы, которая отвечает за “выброс” жетонов из убитых противников (в специальном игровом режиме) достаточно на подобный жетон добавить и настроить `ReplicationComponent`. Это выглядит подобным образом:
for (const Component* component : added_dead_avatars->components)
{
Entity kill_token_entity = world->create_entity();
// Настройка компонентов для физической симуляции и начального положения в мире
// ...
// Настройка репликации
auto* replication_component = kill_token_entity.add<ReplicationComponent>();
replication_component->enable_replication<TransformComponent>(Privacy::PUBLIC);
replication_component->enable_replication<KillTokenComponent>(Privacy::PUBLIC);
}
В данном примере физическая симуляция жетона при выпадении будет происходить на сервере, а итоговая трансформация жетона отправляться и применяться на клиенте. На клиенте также будет работать система интерполяции которая будет сглаживать движение этого жетона, учитывая частоту апдейтов, качество соединения с сервером и так далее. Остальные системы, связанные с этим режимом игры, будут добавлять визуальную часть на объекты с `KillTokenComponent` и следить за их подбором.
Единственное неудобство текущего подхода, на которое хочется обратить внимание и от которого хочется избавиться в будущем — невозможность задавать приватность для каждого поля компонента. Это не сильно критично, так как подобная проблема легко решается разбиением компонента на несколько: например, в игре присутствуют `ShooterPublicComponent` и `ShooterPrivateComponent` с соответствующими приватностями. Несмотря на то, что они привязаны к одной механике (стрельбе), требуется иметь два компонента для экономии трафика — часть полей просто не нужны на клиентах, не владеющих этими компонентами. Тем не менее, это добавляет работы программисту.
В общем случае, реплицируемые на клиент объекты могут иметь состояния за разные кадры. Поэтому была добавлена возможность группировать объекты, формируя репликационные группы. Все компоненты на объектах внутри одной группы всегда имеют состояние за один и тот же кадр на клиенте — это необходимо для корректной работы предсказаний (о них ниже). Например, оружие и персонаж, им владеющий, находятся в одной группе. Если объекты находятся в разных группах, то их состояния в мире могут быть за разные кадры.
Система репликации старается минимизировать объем трафика, в частности за счет сжатия пересылаемых данных (каждое поле внутри компонента может быть опционально помечено соответствующим образом для сжатия) и за счет передачи только разницы в значениях между двумя кадрами.
Клиентские предсказания
Клиентские предсказания (на английском используется термин client-side prediction) позволяют игроку получать мгновенный фидбек на большую часть его действий в игре. При этом, так как последнее слово всегда за сервером, при ошибке в симуляции (на английском используется термин mispredict, я в дальнейшем буду их называть просто “миспредиктами”) клиент должен ее исправить. Подробнее про ошибки предсказания и как они корректируются будет рассказано ниже.
Клиентские предсказания работают по следующим правилам:
- Клиент симулирует себя вперед на N кадров;
- Весь инпут, сгенерированный клиентом, отправляется на сервер (в виде совершенных игроком action’ов);
- N зависит от качества соединения с сервером. Чем меньше это значение, тем более “актуальную” картину мира видит клиент (т.е. разрыв во времени между локальным игроком и остальными игроками меньше).
В результате и сервер, и клиент производят симуляцию на основе клиентского инпута. Затем сервер отправляет на клиент результаты этой симуляции. Если клиент определяет, что его результаты не совпадают с серверными, то он пытается скорректировать ошибку — откатывает себя на последнее известное серверное состояние, и снова симулирует на N кадров вперед. Дальше все продолжается по аналогичной схеме — клиент продолжает себя симулировать в будущем относительно сервера, а сервер отправляет ему результаты своей симуляции. Из этого следует, что весь код, который влияет на предсказания клиента, должен быть общим между клиентом и сервером.
Также, в целях экономии трафика, весь инпут предварительно сжимается на основе заранее установленной схемы. Затем он отправляется на сервер и сразу же обратно распаковывается на клиенте. Упаковка и последующая распаковка на клиенте необходимы, чтобы исключить разницу в значениях, ассоциированных с инпутом, между клиентом и сервером. При создании схемы указывается диапазон значений для данного action’а, и количество бит, в которые он должен быть упакован. Подобным образом выглядит объявление схемы паковки в Battle Prime внутри общей между клиентом и сервером системы:
auto* input_packing_sc = world->get_for_write<InputPackingSingleComponent>();
input_packing_sc->packing_schema = {
{ ActionNames::MOVE, AnalogStatePrecision{ 8, { -1.f, 1.f }, false } },
{ ActionNames::LOOK, AnalogStatePrecision{ 16, { -PI, PI }, false } },
{ ActionNames::JUMP, nullopt },
// .. еще много различных action'ов
};
Критическим условием эффективности работы клиентских предсказаний является необходимость инпута успевать попадать на сервер к моменту симуляции кадра, к которому этот инпут относится. В случае, если инпут не успел прийти на сервер к нужному кадру (такое может случиться при, например, резком скачке пинга), сервер попробует использовать инпут этого клиента с прошлого кадра. Это резервный механизм, который может помочь избавиться от миспредиктов на клиенте в некоторых ситуациях. Например, если клиент просто бежит в одном направлении и его инпут не меняется в течении относительно долгого времени, использование инпута за прошлый кадр пройдет успешно — сервер “угадает” его, и расхождения между клиентом и сервером не произойдет. Подобная схема используется в Overwatch (была упомянута в лекции на GDC: www.youtube.com/watch?v=W3aieHjyNvw).
На данный момент клиент Battle Prime предсказывает состояния следующих объектов:
- Аватар игрока (положение в мире и все что на него может влиять, состояние скиллов и т.д.);
- Все оружие игрока (количество патронов в магазине, кулдауны между выстрелами и т.д.).
Использование клиентских предсказаний сводится к добавлению и настройке `PredictionComponent`а на клиенте нужным объектам. Например, подобным образом включается предсказание аватара игрока в одной из систем:
// `new_local_avatars` содержит в себе объект аватара текущего игрока,
// который был реплицирован с сервера
for (Entity avatar : new_local_avatars)
{
auto* avatar_prediction_component = avatar.add<PredictionComponent>();
avatar_prediction_component->enable_prediction<TransformComponent>();
avatar_prediction_component->enable_prediction<CharacterControllerComponent>();
avatar_prediction_component->enable_prediction<ShooterPrivateComponent>();
avatar_prediction_component->enable_prediction<ShooterPublicComponent>();
// ... и еще много других компонентов
}
Данный код означает, что поля внутри указанных выше компонентов будут постоянно сравниваться с аналогичными полями серверных компонентов — в случае, если будет замечено расхождение в значениях в рамках одного кадра, произойдет корректировка на клиенте.
Критерий расхождения зависит от типа данных. В большинстве случаев это просто вызов `operator==`, исключение составляют основанные на float данные — для них максимально допустимая ошибка на данный момент фиксирована и равна 0.005. В будущем, есть желание добавить возможность задавать точность для каждого поля компонента отдельно.
Схема работы репликации и клиентских предсказаний основана на том, что все данные, необходимые для симуляции, содержатся в компонентах. Выше, в разделе про ECS, я писал, что системам разрешается держать часть данных — это может быть удобно в некоторых случаях. Это не распространяется на любые данные, которые влияют на симуляцию — они всегда обязаны быть внутри компонентов, так как системы работы клиентских и серверных снапшотов работают только с компонентами.
Помимо предсказания значений полей внутри компонентов, есть возможность предсказывать создание и удаление компонентов. Например, если в результате использования способности на персонажа накладывается `SpeedModifierComponent` (который модифицирует скорость передвижения, например — ускоряет игрока), то он должен быть добавлен на персонажа и на сервере, и на клиенте на одном и том же кадре, в противном случае это приведет к неверному предсказанию положения персонажа на клиенте.
Предсказание создания и удаления объектов на данный момент не поддерживается. Это может быть удобно в некоторых ситуациях, но также усложнит сетевые модули. Возможно, мы вернемся к этому в будущем.
Ниже показана gif-ка, в которой управление персонажем происходит при RTT около 1.5 секунды. Как видно, управление персонажем происходит мгновенно, несмотря на высокую задержку: перемещение, стрельба, перезарядки, броски гранат — все происходит не дожидаясь информации с сервера. Также, можно заметить, что захват точки (зона, ограниченная треугольниками) начинается с задержкой — эта механика работает только на сервере и не предсказывается клиентом.
Миспредикты и ресимуляции
Миспредикт — расхождение между результатами симуляций сервера и клиента. Ресимуляция — процесс корректировки этого расхождения клиентом.
Первая причина появления миспредиктов — это резкие скачки пинга, под которые клиент не успел подстроиться. В такой ситуации, на сервер может не успеть попасть инпут от игрока, и сервер некоторое время будет пользоваться описанным выше резервным механизмом с дублированием последнего инпута, а через некоторое время перестанет использовать и его.
Вторая причина — это взаимодействие персонажа с объектами, которые полностью управляются сервером и не предсказываются локально клиентом. Например, коллизия с другим игроком вызовет миспредикт — так как они, по сути, живут в двух разных периодах времени (локальный персонаж находится в будущем относительно другого игрока — чье положение приходит с сервера и интерполируется).
Третья, и самая неприятная причина — это баги в коде. Например, система может по ошибке использовать не реплицируемые данные для управления симуляцией, либо же системы работают в неправильном порядке, или даже в разных порядках на сервере и клиенте.
Поиск этих багов порою занимает приличное время. Для того чтобы упростить их поиск мы сделали несколько вспомогательных инструментов — во время работы приложения можно посмотреть:
- Реплицируемые компоненты,
- Количество миспредиктов,
- На каких кадрах они произошли,
- Какие данные были на сервере и на клиенте в разошедшихся компонентах,
- Какой инпут был применен на сервере и на клиенте за этот кадр.
К сожалению, даже с ними поиск причин ресимуляций все еще занимает приличное количество времени. Инструментарий и валидации несомненно нужно развивать, чтобы уменьшить вероятность появления багов и упростить их поиск.
Для того, чтобы поддержать работу ресимуляций, система должна наследоваться от определенного класса `ResimulatableSystem`. В ситуации, когда произойдет миспредикт, мир “откатит” все объекты на последнее известное серверное состояние, и затем сделает необходимое число симуляций вперед чтобы исправить эту ошибку — в этом будут участвовать только Resimulatable системы.
В общем случае ресимуляции на клиенте не должны быть сильно заметны игрокам. Когда они происходит, все поля компонентов плавно интерполируются в новые значения чтобы визуально сгладить возможные “дергания”. Тем не менее, критически важно держать их количество минимально возможным.
Стрельба
Нанесение урона игрокам полностью диктуется сервером — клиентам нельзя доверять в такой важной механике, чтобы уменьшить вероятность читерства. Но, как и передвижение, стрельба на клиенте должна происходить максимально отзывчиво и без задержек — игроку нужно получать мгновенный фидбек в виде эффектов и звуков — muzzle flash, след от полета снаряда, а также эффекты от попадания снаряда по окружению и другим игрокам.
Поэтому все состояние персонажа, связанное со стрельбой, предсказывается клиентом — сколько патронов в магазине, разброс во время стрельбы, задержки между выстрелами, время последнего выстрела и так далее. Также на клиенте работают те же системы, отвечающие за движение снарядов, что и на сервере — это позволяет симулировать выстрелы на клиенте не дожидаясь результатов их симуляции на сервере.
Сама баллистика снарядов не предсказывается — так как они летят с очень большой скоростью, и, как правило, заканчивают свое движение за несколько кадров, то снаряд уже успеет попасть в какую-то точку в мире и проиграть эффект, перед тем как мы получим результаты симуляции это снаряда с сервера (или отсутствие результатов, если из-за миспредикта клиент выпустил снаряд по ошибке).
Немного отличается схема работы медленно летящих снарядов. Если игрок кидает гранату, но в результате миспредикта окажется, что граната не была брошена — она будет уничтожена на клиенте. Аналогично, если клиент неправильно предсказал уничтожение гранаты (на сервере она уже взорвалась, а на клиенте еще нет), то клиентская граната также будет уничтожена. Вся информация о взрывах, которые показываются на клиенте, приходит с сервера чтобы избежать ситуаций, когда в результате клиентской ошибки на сервере взрыв произошел в одном месте, а на клиенте в другом.
В идеале, медленно летящие снаряды хотелось бы полностью предсказывать в будущем — не только время жизни, но и их позиции.
Компенсация лага
Компенсация лага — это техника, которая позволяет нивелировать влияние задержки между сервером и клиентом на точность стрельбы. В этом разделе я буду предполагать, что стрельба всегда происходит из “hitscan” оружия — т.е. снаряд, выпущенный оружием, путешествует с бесконечной скоростью. Но все, что здесь описано так же имеет значение и при других видах оружия.
Следующие моменты обусловливают необходимость компенсации лага при стрельбе:
- Персонаж под контролем игрока находится в будущем относительно сервера (предсказывая на некоторое количество кадров вперед свое состояние);
- Следовательно, остальные игроки находятся относительно него в прошлом;
- При выстреле, соответствующий action отправляется клиентом на сервер и применяется на том же кадре, на котором он был применен на клиенте (по возможности).
Если предположить, что игрок целится пробегающему мимо врагу в голову и нажимает кнопку выстрела, то получается следующая картина:
- На клиенте: стрелок на кадре N1 производит выстрел в голову врагу, находящемуся на кадре N0 (N0 < N1);
- На сервере: стрелок на кадре N1 производит выстрел в голову врагу, также находящемуся на кадре N1 (на сервере все находятся в одном моменте времени).
Результатом этого, с большой вероятностью, является промах при выстреле. Так как клиент целится на основе своей картины мира, не совпадающей с картиной мира сервера, то, чтобы попасть во врага, ему нужно целиться в него наперед даже при использовании hitscan оружия, причем расстояние, наперед которого он должен стрелять, зависит от качества соединения с сервером. Это, мягко говоря, не самый хороший опыт для стрелка.
Чтобы избавиться от этой проблемы, используется компенсация лага. Схема ее работы следующая:
- Сервер держит ограниченную в размере историю снапшотов мира;
- При выстреле, враги (или часть врагов) “откатываются” таким образом, чтобы мир на сервере соответствовал миру, который видел у себя клиент — клиент находится в “настоящем” (момент выстрела), а враги — в прошлом;
- Работает механика hit detection’а, регистрируются попадания;
- Мир возвращается в оригинальное состояние.
Так как картина мира на клиенте также зависит от работы системы интерполяции, то для того чтобы “откатить” мир в максимально точное клиентское состояние на сервере, клиент передает ему дополнительные данные — разницу между текущим кадром клиента и кадром, за который он видит всех остальных игроков (на данный момент это два байта на кадр), а также время генерации инпута выстрела относительно начала кадра.
Компенсация лага существует на уровне отдельного модуля внутри движка и не привязана к конкретному проекту. С точки зрения разработчика геймплейной механики, ее использование выглядит следующим образом:
- На игрока добавляется `LagCompensationComponent`, и заполняется список хитбоксов, которые требуется хранить в истории;
- При стрельбе (или другой механики, требующей компенсации — например при атаках ближнего боя), вызывается `LagCompensation::invoke`, куда передается функтор, который будет выполнен в “компенсированном”, с точки зрения конкретного игрока, мире. В нем должен происходить весь необходимый hit detection.
Код с примером использования компенсации лага из Batle Prime при движении баллистических снарядов:
// `targets_data` содержит информацию о том,
// куда был “откачен” тот или иной противник,
// используется для отладки
const auto compensated_action = [this](const Vector<LagCompensation::LagCompensationData>& targets_data) {
process_projectile(projectile, elapsed_time);
};
LagCompensation::invoke(
observer, // игрок, используя чью перспективу нужно откатить мир
projectile_component->input_time_ms, // время, в которое был произведен выстрел
compensated_entities, // объекты, которые нужно компенсировать
compensated_action // функтор, который будет вызван в компенсированном мире
);
Хочется также заметить, что лаг компенсация — это схема, которая ставит опыт стрелка выше опыта цели, в которую он стреляет. С точки зрения цели, враг может попасть в него в тот момент, когда он уже находится за препятствием (частая жалоба на игровых форумах). Для этого у компенсации лага ограничено количество кадров, на которое цели могут быть “откачены”. На данный момент в Battle Prime стрелок при RTT около 400 миллисекунд сможет комфортно попадать во врагов. Если RTT выше — придется стрелять наперед.
Пример стрельбы без компенсации — нужно стрелять наперед, чтобы стабильно попадать по врагу:
И с компенсацией — можно комфортно целиться прямо в противника:
На наших билд-агентах также периодически запускаются автотесты, которые проверяют работу разных механик. Среди них есть и автотест на точность стрельбы при включенной компенсации лага. В гифке ниже показан этот тест — персонаж просто стреляет в голову пробегающему мимо врагу и считает число попаданий по нему. Для отладки дополнительно отображаются хитбоксы врага, которые были на сервере на момент выстрела (белым цветом), и хитбоксы, которые использовались для хит детекшена внутри компенсированного мира (синим цветом):
Дополнительный фактор, который влияет на точность стрельбы — это положения хитбоксов на персонаже. Хитбоксы зависят от скелетных анимаций, а их фазы в данный момент никак не синхронизируются, поэтому возможна ситуация, когда хитбоксы различаются между клиентом и сервером. Последствия этого зависят от самих анимаций — чем больше диапазон движения внутри анимации, тем больше потенциальная разница в положениях хитбоксов между сервером и клиентом. На практике, подобная разница слабо заметна игроку и больше влияет на нижнюю часть тела, которая является менее критичной по сравнению с верхней (голова, туловище, руки). Тем не менее, в будущем хотелось бы более подробно заняться вопросом синхронизации анимаций между сервером и клиентом.
Заключение
В этой статье я постарался описать фундамент, на котором строится Battle Prime — это имплементация паттерна ECS внутри Blitz Engine, а также сетевой модуль, который отвечает за репликацию, клиентские предсказания и сопутствующие механики. Несмотря на некоторые недостатки (над исправлением которых мы продолжаем работать), использовать этот функционал сейчас просто и удобно.
Чтобы показать общую картину работы Battle Prime, пришлось затронуть большое количество тем. Многим из них могут в будущем быть посвящены отдельные статьи, в которых они будут описаны в более детальном виде!
Игра уже проходит тест в Турции и на Филиппинах: можно не только почитать, но и посмотреть вживую по ссылке apps.apple.com/ph/app/battle-prime/id1411304149?ls=1.
С предыдущими нашими статьями можно ознакомиться по следующим ссылкам:
Автор: BlitzTeam