Blitz Engine & Battle Prime: ECS и сетевой код
Battle Prime — первый проект нашей студии. Несмотря на то, что многие члены команды имеют приличный опыт в разработке игр, мы, естественно, сталкивались с разными сложностями во время работы над ним. Они возникали как в процессе работы над движком, так и в процессе разработки самой игры.
В геймдев индустрии огромное количество разработчиков, которые охотно делятся своими историями, наработками, архитектурными решениями — в том или ином виде. Этот опыт, выложенный в публичное пространство в виде статей, презентаций и докладов, является отличным источником идей и вдохновения. Например, доклады команды разработки из Overwatch были для нас очень полезны при работе над движком. Как и сама игра, они очень талантливо сделаны, и я советую посмотреть их всем интересующимся. Доступны в GDC vault и на YouTube: www.youtube.com/channel/UC0JB7TSe49lg56u6qH8y_MQ
Это одна из причин, по которой мы также хотим вносить вклад в общее дело — и эта статья одна из первых, посвященная техническим деталям разработки движка Blitz Engine и игры на нем — Battle Prime.
Статья будет поделена на две части:
- ECS: имплементация Entity-Component-System паттерна внутри Blitz Engine. Этот раздел важен для понимания примеров кода в статье, и сам по себе является отдельной интересной темой.
- Неткод и геймплей: все, что касается высокоуровневой сетевой части и ее использования внутри игры — клиент-серверная архитектура, клиентские предсказания, репликация. Одной из важнейших вещей в шутере является стрельба, так что ей будет уделено большее количество времени.
Под катом много мегабайт гифок!
Внутри каждого раздела, помимо рассказа о функциональности и его использовании, я постараюсь описать и недостатки, которые он в себе несет — будь это его ограничения, неудобства в работе, или просто мысли по поводу его улучшений в будущем.
Я также постараюсь давать примеры кода и некоторую статистику. Во-первых, это просто интересно, а, во-вторых, дает немного контекста по поводу масштаба использования той или иной функциональности и проекта.
Внутри движка мы используем термин «мир» для описания сцены, содержащей иерархию объектов.
Миры работают по шаблону 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()
[M(), M(), M("Capture point")]
// Описание поля и его метаданные
.field("points_to_own", &CapturePointComponent::points_to_own)
[M(), M("Points to own")]
.field("visible_name", &CapturePointComponent::visible_name)
[M(), M("Name")]
// … остальные поля и методы
}
Отдельное внимание хочется уделить метаданным типов, полей и методов, которые объявляются с помощью выражения
M()
где `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()
.ctor_by_pointer()
.copy_ctor_by_pointer()
.field("source_id", &AvatarHitComponent::source_id)[M()]
.field("target_id", &AvatarHitComponent::target_id)[M()]
.field("hitbox_type", &AvatarHitComponent::hitbox_type)[M()];
}
Данный компонент помечает объект, который создается в результате попадания игрока по другому игроку. Он содержит в себе информацию об этом событии, такую как идентификаторы атакующего игрока и его цели, а также тип хитбокса, по которому произошло попадание. Упрощенно, этот объект создается внутри серверной системы подобным образом:
Entity hit_entity = world->create_entity();
auto* const avatar_hit_component = hit_entity.add();
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()[M("battle")]
.ctor_by_pointer()
.method("ExplosiveSystem::update", &ExplosiveSystem::update)[M(
TaskGroups::GAMEPLAY_END,
ReadAccess::set<
TimeSingleComponent,
WeaponDescriptorComponent,
BallisticComponent,
ProjectileComponent,
GrenadeComponent>(),
WriteAccess::set(),
InitAccess::set(),
UpdateType::FIXED,
Vector{ 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();
// Init new explosives
for (Component* component : new_explosives_group->components)
{
auto* explosive_component = static_cast(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();
// `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 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();
replication_component->enable_replication(Privacy::PUBLIC);
replication_component->enable_replication(Privacy::PUBLIC);
replication_component->enable_replication(Privacy::PRIVATE);
replication_component->enable_replication(Privacy::PRIVATE);
// ... и прочие компоненты
Для каждого компонента указывается приватность, которая используется при репликации. Приватные компоненты будут отправлены с сервера только игроку, владеющему данным оружием. Публичные компоненты будут отправляться всем. В данном примере, публичными являются `WeaponDescriptorComponent` и `WeaponBaseStatsComponent` — они содержат данные, необходимые для корректного отображения других игроков. Например, индекс слота, в котором лежит оружие и его тип нужны для анимаций. Остальные компоненты отправляются приватно игроку, который владеет этим оружием — параметры баллистики снарядов, информацию об общем кол-ве патронов, доступных режимах стрельбы и так далее. Есть и более специализированные режимы приватности: например, можно отправлять компонент только союзникам или только врагам.
Каждый компонент внутри своего описания обязан указать, какие именно поля должны реплицироваться в рамках этого компонента. Например, все поля внутри `WeaponComponent`а помечены как `Replicable`:
BZ_VIRTUAL_REFLECTION_IMPL(WeaponComponent)
{
ReflectionRegistrar::begin_class()
.ctor_by_pointer()
.copy_ctor_by_pointer()
.field("owner", &WeaponComponent::owner)[M()]
.field("fire_mode", &WeaponComponent::fire_mode)[M()]
.field("loaded_ammo", &WeaponComponent::loaded_ammo)[M()]
.field("ammo", &WeaponComponent::ammo)[M()]
.field("shooting_cooldown_end_ms", &WeaponComponent::shooting_cooldown_end_ms)[M()];
}
Этот механизм очень удобен в использовании. Например, внутри серверной системы, которая отвечает за «выброс» жетонов из убитых противников (в специальном игровом режиме) достаточно на подобный жетон добавить и настроить `ReplicationComponent`. Это выглядит подобным образом:
for (const Component* component : added_dead_avatars->components)
{
Entity kill_token_entity = world->create_entity();
// Настройка компонентов для физической симуляции и начального положения в мире
// ...
// Настройка репликации
auto* replication_component = kill_token_entity.add();
replication_component->enable_replication(Privacy::PUBLIC);
replication_component->enable_replication(Privacy::PUBLIC);
}
В данном примере физическая симуляция жетона при выпадении будет происходить на сервере, а итоговая трансформация жетона отправляться и применяться на клиенте. На клиенте также будет работать система интерполяции которая будет сглаживать движение этого жетона, учитывая частоту апдейтов, качество соединения с сервером и так далее. Остальные системы, связанные с этим режимом игры, будут добавлять визуальную часть на объекты с `KillTokenComponent` и следить за их подбором.
Единственное неудобство текущего подхода, на которое хочется обратить внимание и от которого хочется избавиться в будущем — невозможность задавать приватность для каждого поля компонента. Это не сильно критично, так как подобная проблема легко решается разбиением компонента на несколько: например, в игре присутствуют `ShooterPublicComponent` и `ShooterPrivateComponent` с соответствующими приватностями. Несмотря на то, что они привязаны к одной механике (стрельбе), требуется иметь два компонента для экономии трафика — часть полей просто не нужны на клиентах, не владеющих этими компонентами. Тем не менее, это добавляет работы программисту.
В общем случае, реплицируемые на клиент объекты могут иметь состояния за разные кадры. Поэтому была добавлена возможность группировать объекты, формируя репликационные группы. Все компоненты на объектах внутри одной группы всегда имеют состояние за один и тот же кадр на клиенте — это необходимо для корректной работы предсказаний (о них ниже). Например, оружие и персонаж, им владеющий, находятся в одной группе. Если объекты находятся в разных группах, то их состояния в мире могут быть за разные кадры.
Система репликации старается минимизировать объем трафика, в частности за счет сжатия пересылаемых данных (каждое поле внутри компонента может быть опционально помечено соответствующим образом для сжатия) и за счет передачи только разницы в значениях между двумя кадрами.
Клиентские предсказания
Клиентские предсказания (на английском используется термин client-side prediction) позволяют игроку получать мгновенный фидбек на большую часть его действий в игре. При этом, так как последнее слово всегда за сервером, при ошибке в симуляции (на английском используется термин mispredict, я в дальнейшем буду их называть просто «миспредиктами») клиент должен ее исправить. Подробнее про ошибки предсказания и как они корректируются будет рассказано ниже.
Клиентские предсказания работают по следующим правилам:
- Клиент симулирует себя вперед на N кадров;
- Весь инпут, сгенерированный клиентом, отправляется на сервер (в виде совершенных игроком action«ов);
- N зависит от качества соединения с сервером. Чем меньше это значение, тем более «актуальную» картину мира видит клиент (т.е. разрыв во времени между локальным игроком и остальными игроками меньше).
В результате и сервер, и клиент производят симуляцию на основе клиентского инпута. Затем сервер отправляет на клиент результаты этой симуляции. Если клиент определяет, что его результаты не совпадают с серверными, то он пытается скорректировать ошибку — откатывает себя на последнее известное серверное состояние, и снова симулирует на N кадров вперед. Дальше все продолжается по аналогичной схеме — клиент продолжает себя симулировать в будущем относительно сервера, а сервер отправляет ему результаты своей симуляции. Из этого следует, что весь код, который влияет на предсказания клиента, должен быть общим между клиентом и сервером.
Также, в целях экономии трафика, весь инпут предварительно сжимается на основе заранее установленной схемы. Затем он отправляется на сервер и сразу же обратно распаковывается на клиенте. Упаковка и последующая распаковка на клиенте необходимы, чтобы исключить разницу в значениях, ассоциированных с инпутом, между клиентом и сервером. При создании схемы указывается диапазон значений для данного action«а, и количество бит, в которые он должен быть упакован. Подобным образом выглядит объявление схемы паковки в Battle Prime внутри общей между клиентом и сервером системы:
auto* input_packing_sc = world->get_for_write();
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();
avatar_prediction_component->enable_prediction();
avatar_prediction_component->enable_prediction();
avatar_prediction_component->enable_prediction();
avatar_prediction_component->enable_prediction();
// ... и еще много других компонентов
}
Данный код означает, что поля внутри указанных выше компонентов будут постоянно сравниваться с аналогичными полями серверных компонентов — в случае, если будет замечено расхождение в значениях в рамках одного кадра, произойдет корректировка на клиенте.
Критерий расхождения зависит от типа данных. В большинстве случаев это просто вызов `operator==`, исключение составляют основанные на float данные — для них макс