Как и почему мы написали свой ECS

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

В прошлой статье я описал технологии и подходы, которые мы используем при разработке нового мобильного fast paced шутера. Т.к. это была обзорная и даже поверхностная статья — сегодня я копну глубже и подробно расскажу, почему мы решили написать собственный ECS-фреймворк и не стали использовать существующие. Будут примеры кода и небольшой бонус в конце.
Как и почему мы написали свой ECS - 1

Что такое ECS на примере

Кратко я уже описывал, что такое Entity Component System, и на Хабре есть статьи про ECS (в основном, правда, переводы статей — смотрите мой обзор самых интересных из них в конце статьи, в качестве бонуса). А сегодня расскажу, как используем ECS мы — на примере нашего кода.

На схеме выше описаны сущность Player, её компоненты и их данные, и системы, которые работают с игроком и его компонентами. Ключевым объектом на схеме является игрок:

  • может перемещаться в пространстве — компоненты Transform и Movement, система MoveSystem;
  • имеет некоторое кол-во здоровья и может погибнуть — компонент Health, Damage, система DamageSystem;
  • после смерти появляется на точке возрождения (respawn) — компонент Transform для положения, система RespawnSystem;
  • может быть неуязвимым — компонент Invincible.

Опишем это кодом. Для начала заведем интерфейсы для компонентов и систем. У компонентов могут быть общие вспомогательные методы, у системы — всего один метод Execute, которому на вход на обработку подается состояние мира:

public interface IComponent
{
   // <вспомогательные методы>
}

public interface ISystem
{
   void Execute(GameState gs);
}

Для компонентов мы создаем классы-заготовки, которые используются нашим генератором кода для преобразования их в реально используемый код компонентов. Заведем заготовки для Health, Damage и Invincible (для остальных компонентов будет похоже).

[Component]
public class Health
{
   [Max(1000)] // максимальное кол-во жизней 1000
   public int Hp; // кол-во жизней игрока

   public Health(int hp) {}
}

[Component]
public class Damage
{
   [DontSend] // не посылать этот параметр по сети, клиенту не обязательно это знать
   public uint Amount; // кол-во урона
   public Entity Victim; // кому нанесен урон
   public Entity Source; // кто нанес урон

   public Damage(uint amount, Entity victim, Entity source) {}
}

[Component]
public class Invincible // не содержит данных, индикатор того, что игрок неуязвим
{
}

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

Атрибут Component используется генератором для нахождения классов-заготовок для компонентов. Атрибуты Max и DontSend нужны как подсказки при сериализации и уменьшения размера состояния мира, передаваемого по сети или сохраняемого на диск. В данном случае сервер не будет сериализовать поле Amount и посылать его по сети (потому что клиенты не используют этот параметр, он нужен только на сервере). А поле Hp можно хорошо упаковать в несколько бит, учитывая максимальное значение здоровья.

У нас также есть класс-заготовка Entity, куда мы добавляем информацию о всех возможных компонентах у любой сущности, а генератор уже создаст из него реальный класс:

public class Entity
{
   public Health Health;
   public Damage Damage;
   public Invincible Invincible;
   // ... <другие компоненты>
}

После этого наш генератор создаст код классов-компонентов Health, Damage и Invincible, которые уже будут использоваться в игровой логике:

public sealed class Health : IComponent
{
   public int Hp;

   public void Reset()
   {
       Hp = default(int);
   }

   // ... <другие вспомогательные методы>
}

public sealed class Damage : IComponent
{
   public int Amount;
   public Entity Victim;
   public Entity Source;

   public void Reset()
   {
       Amount = default(int);
       Victim = default(Entity);
       Source = default(Entity);
   }

   // ... <другие вспомогательные методы>
}

public sealed class Invincible : IComponent
{
}

Как видите, в классах остались данные и добавились методы, например, Reset. Он нужен для оптимизации и переиспользования компонентов в пулах. Другие методы вспомогательные, не содержат бизнес-логику — их не буду приводить для краткости кода.

Также будет сгенерирован класс для состояния мира, который содержит список всех компонентов и сущностей:

public sealed class GameState
{
   // компоненты
   public Table<Movement> Movements;
   public Table<Health> Healths;
   public Table<Damage> Damages;
   public Table<Transform> Transforms;
   public Table<Invincible> Invincibles;

   // вспомогательные методы
   public Entity CreateEntity() { /* <реализация> */ }
   public void Copy(GameState gs2) { /* <реализация> */ }
   public Entity this[uint id] { /* <реализация> */ }

   // ... <другие сгенерированные члены класса>
}

И наконец, сгенерированный код для Entity:

public sealed class Entity
{
   public uint Id; // идентификатор сущности
   public GameState GameState; // ссылка на объекты мира

   // сгенерированные методы для удобства использования:
  
   public Health Health
   {
       get { return GameState.Healths[Id]; }
   }
  
   public Damage Damage
   {
       get { return GameState.Damages[Id]; }
   }

   public Invincible Invincible
   {
       get { return GameState.Invincibles[Id]; }
   }

   // … доступ к другим компонентам

   public Damage AddDamage()
   {
       return GameState.Damages.Insert(Id);
   }

   public Damage AddDamage(int total, Entity victim, Entity source)
   {
       var c = GameState.Damages.Insert(Id);
       c.Amount = total;
       c.Victim = victim;
       c.Source = source;
       return c;
   }

   public void DelDamage()
   {
       GameState.Damages.Delete(Id);
   }

   // … <сгенерированные члены класса для других компонентов>
}

Класс Entity — это, в сущности, лишь идентификатор компонента. Ссылка на объекты мира GameState используются лишь в вспомогательных методах для удобства написания кода бизнес-логики. Зная идентификатор компонента, мы можем использовать его для сериализации связей между сущностями, реализации ссылок в компонентах на другие сущности. Например, компонент Damage содержит ссылку на сущность Victim для определения, кому нанесен урон.

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

  • создание/удаление сущностей;
  • добавить/удалить/скопировать компонент, получить к нему доступ, если он существует;
  • сравнить два состояния мира;
  • сериализовать состояние мира;
  • дельта-компрессия;
  • код веб-страницы или окна Unity для отображения состояния мира, сущностей, компонентов (см. подробности ниже);
  • и др.

Перейдем к коду систем. Они определяют бизнес-логику. Напишем для примера код системы, которая начисляет урон игроку:

public sealed class DamageSystem : ISystem
{
   void ISystem.Execute(GameState gs)
   {
       foreach (var damage in gs.Damages)
       {
           var invincible = damage.Victim.Invincible;
           if (invincible != null) continue;
          
           var health = damage.Victim.Health;
           if (health == null) continue;

           health.Hp -= damage.Amount;
       }
   }
}

Система проходится по всем компонентам Damage в мире и смотрит, есть ли на потенциально поврежденном игроке (Victim) компонент Invincible. Если он есть — игрок неуязвим, урон не начисляется. Далее получаем компонент Health жертвы и уменьшаем здоровье игрока на размер урона.

Рассмотрим ключевые особенности систем:

  1. Система — это обычно stateless-класс, не содержит никаких внутренних данных, не пытается сохранить их куда-то, кроме данных о мире, передаваемых извне.
  2. Cистемы обычно проходятся по всем компонентам определенного типа и работают с ними. Называются, обычно, по типу компонента (DamageDamageSystem) или по действию, которые они осуществляют (RespawnSystem).
  3. Система реализует минимальную функциональность. Например, если пойти дальше, то после выполнения системы DamageSystem другая система RemoveDamageSystem удалит все компоненты Damage. На следующем тике еще одна система ApplyDamageSystem на основе стрельбы игрока может снова навесить компонент Damage с новым уроном. А далее система PlayerDeathSystem проверит здоровье игрока (Health.Hp) и, если оно меньше или равно 0, уничтожит все компоненты игрока, кроме Transform, и добавит компонент-флаг Dead.

Итого, получаем следующие классы и связи между ними:
Как и почему мы написали свой ECS - 2

Некоторые факты о ECS

У ECS есть свои плюсы и минусы, как подхода к разработке и способа представления мира игры, поэтому каждый сам для себя решает — использовать его или нет. Начнем с плюсов:

  • Композиция против множественного наследования. В случае множественного наследования может наследоваться куча ненужного функционала. В случае ECS функционал появляется/исчезает при добавлении/удалении компонента.
  • Разделение логики и данных. Возможность менять логику (менять системы, удалять/добавлять компоненты), не ломая данные. Т.е. можно в любой момент отключить группу систем, отвечающих за определенную функциональность, все остальное продолжит работать и это не затронет данные.
  • Упрощается игровой цикл. Появляется один Update, а весь цикл разбивается на системы. Данные обрабатываются «потоком» в системе, независимо от движка (нет миллионов вызовов Update, как в Unity).
  • Сущность не знает, какие классы на нее влияют (и не должна знать).
  • Эффективное использования памяти. Это зависит от реализации ECS. Можно переиспользовать созданные объекты сущностей и компонент, используя пулы; можно использовать типы-значения для данных и хранить их в памяти рядом (Data locality).
  • Проще тестировать, когда данные отделены от логики. Особенно если учесть, что логика — это небольшая система в несколько строк кода.
  • Просмотр и редактирование состояния мира в реальном времени. Т.к. состояние мира это просто данные, мы написали тулзу, которая на веб-странице отображает все состояние мира в матче на сервере (а также сцену матча в 3D). Любой компонент любой сущности можно просмотреть, изменить, удалить. То же самое можно сделать в редакторе Unity для клиента.

Как и почему мы написали свой ECS - 3

А теперь минусы:

  • Нужно учиться думать, проектировать и писать код по-другому. Думать в рамках сущностей, компонент и систем. Многие паттерны проектирования в ECS реализуются совсем по-другому (см. пример реализация паттерна Состояние (State) в одной из обзорных статей в конце).
  • Больше кода. Спорно. С одной стороны, из-за того, что мы разбиваем логику на мелкие системы, вместо того, чтобы описать всю функциональность в одном классе, классов становится больше, но именно кода не намного больше.
  • Порядок вызова систем влияет на работу всей игры. Обычно, системы зависимы друг от друга, порядок их выполнения задается списком и они выполняются в этом порядке. Например, сначала DamageSystem считает урон, затем RemoveDamageSystem удаляет компонент Damage. Если случайно поменять порядок, то все станет работать по-другому. В целом, это актуально и для обычного ООП-случая, если поменять порядок вызова методов, но в ECS ошибиться проще. Например, если часть логики работает на клиенте для prediction, то порядок должен быть такой же, как на сервере.
  • Нужно как-то связывать данные и события логики с представлением. В случае с Unity у нас MVP:

    — Model — GameState из ECS;
    — View — у нас это исключительно стандартные MonoBehaviour-классы Unity (Renderer, Text и т.д.) и префабы;
    — Presenter использует GameState для определения событий появления/исчезания сущностей, компонент и т.д., создает объекты Unity из префабов и меняет их в соответствии с изменением состояния мира.

А знаете ли вы, что:

  • ECS — это не только про data locality. Для меня это больше парадигма программирования, паттерн, еще один способ проектирования игрового мира — назовите как угодно. Data locality — это лишь оптимизация.
  • В Unity нет ECS! Часто на собеседовании в команду спрашиваешь кандидатов — а что вы знаете про ECS? Если не слышали, рассказываешь им, а они в ответ: «А, так это ж как в Unity, тогда знаю!». Но нет, это не как в движке Unity. Там данные и логика объединены в компоненте MonoBehaviour, а GameObject (если сравнивать с сущностью в ECS) обладает дополнительными данными — имя, место в иерархии и др. Разработчики Unity сейчас работают над нормальной реализацией ECS в движке и пока видится, что она будет хороша. Они наняли специалистов в этой области — надеюсь, получится круто.

Наши критерии выбора ECS-фреймворка

Когда мы решили делать игру на ECS, мы начали искать готовое решение и выписали требования к нему на основе опыта одного из разработчиков. И расписали, насколько существующие решения соответствуют нашим требованиям. Это было год назад, на текущий момент что-то могло уже измениться. В качестве решений мы рассматривали:

  • Entitas
  • Artemis C#
  • Ash.NET
  • ECS — наше собственное решение на момент, когда мы его задумывали. Т.е. наши предположения и хотелки, что мы можем сделать сами.

Мы составили таблицу для сравнения, куда я также включил наше текущее решение (обозначил его как ECS(now)):

Как и почему мы написали свой ECS - 4
Красный цвет — решение не поддерживает наше требование, оранжевый — поддерживает частично, зеленый — поддерживает полностью.

Для нас аналогией операций доступа к компонентам, поиска сущностей в ECS были операции в sql-базе данных. Поэтому мы использовали понятия типа table (таблица), join (операция соединения), indices (индексы) и т.д.

Распишем наши требования и насколько сторонние библиотеки и фреймворки им соответствовали:

  • separate data sets (history, current, visual, static) — возможность отдельно получить и хранить состояния мира (например, текущее состояние для обработки, для отрисовки, история состояний и т.д.). Все из рассматриваемых решений поддерживали это требование.
  • entity ID as integer — поддержка представления сущности ее идентификатором-числом. Нужно для передачи по сети и возможности связывать сущности в истории состояний. Ни в одном из рассматриваемых решений поддержки не было. Например, в Entitas сущность представлена полноценным объектом (как GameObject в Unity).
  • join by ID O(N+M) — поддержка относительно быстрой выборки по компонентам двух типов. Например, когда нужно получить все сущности с компонентами типа Damage (допустим, их N штук) и Health (M штук) для расчета и нанесения урона. Была полная поддержка в Artemis; в Entitas и Ash.NET она быстрее O(N²), но медленнее O(N+M). Точнее оценку сейчас уже не помню.
  • join by ID reference O(N+M) — то же самое, что выше, только когда в компоненте одной сущности есть ссылка на другую, и у последний нужно получить другой компонент (в нашем примере компонент Damage на вспомогательной сущности ссылается на сущность игрока Victim и оттуда нужно получить компонент Health). Не поддерживалось ни одним из рассмотренных решений.
  • no query alloc — нет лишних аллокаций памяти при запросе компонентов и сущностей из состояния мира. В Entitas в определенных кейсах она была, но незначительная для нас.
  • pool tables — хранение данных мира в пулах, возможность повторного использования памяти, аллокации только когда пул пустой. Была «какая-то» поддержка в Entitas и Artemis, полное отсутствие в Ash.NET.
  • compare by ID (add, del) — встроенная поддержка событий создания/уничтожения сущностей и компонент по ID. Нужно для уровня отображения (View), чтобы показывать/скрывать объекты, проиграть анимации, эффекты. Не поддерживалось ни одним из рассмотренных решений.
  • Δ serialisation (quantisation, skip) — встроенная дельта-компрессия при сериализации состояния мира (например, для уменьшения размера пересылаемых по сети данных). «Из коробки» не поддерживалась ни в одном из решений.
  • Interpolation — встроенный механизм интерполяции между состояниями мира. Ни одно из решений не поддерживало.
  • reuse component type — возможность использовать один раз написанный тип компонента в разных типах сущностей. Поддерживал только Entitas.
  • explicit order of systems — возможность задать свой порядок вызова систем. Все решения поддерживали.
  • editor (unity/server) — поддержка просмотра и редактирования сущностей в реальном времени, как для клиента, так и для сервера. Entitas поддерживал возможность просматривать и редактировать сущности и компоненты только в редакторе Unity.
  • fast copy/replace — возможность дешевого копирования/замены данных. Ни одно из решений не поддерживало.
  • component as value type (struct) — компоненты как типы-значения. В принципе, хотелось на основе этого добиться хорошей производительности. Не поддерживалось ни одной из систем, везде были компоненты-классы.

Необязательные требования (ни одно из решений на тот момент их не поддерживало):

  • indices — индексирование данных как в БД.
  • composite keys — сложные ключи для быстрого доступа к данным (как в БД).
  • integrity check — возможность проверки целостности данных в состоянии мира. Полезно при отладке.
  • content-aware compression — лучшее сжатие данных на основе знания о природе данных. Например, если мы знаем, максимальный размер карты или максимальное кол-во объектов мира.
  • types/systems limit — ограничение на кол-во типов компонент или систем. В Artemis на тот момент нельзя было создать больше 32 или 64 типов компонент и систем.

Как видно по таблице, самостоятельно мы хотели реализовать все требования, кроме необязательных. На деле на текущий момент мы не сделали:

  • join by ID O(N+M) и join by ID reference O(N+M) — выборка по двум разным компонентам у нас до сих пор занимает O(N²) (фактически, вложенный цикл for). С другой стороны, сущностей и компонент на матч не так уж и много.
  • compare by ID (add, del) — не понадобилось на уровне фреймворка. Это мы реализовали на уровне выше, в MVP.
  • fast copy/replace и component as value type (struct) — в какой-то момент мы поняли, что работать с структурами будет не так удобно, как с классами, и остановились на классах — предпочли удобство разработки вместо лучшей производительности. Кстати говоря, разработчики Entitas поступили в итоге так же.

При этом мы все таки реализовали одно из изначально необязательных на наш взгляд требований:

  • content-aware compression — за счет него нам удалось значительно (в десятки раз) уменьшить размер передаваемого по сети пакета. Для мобильных сетей передачи данных очень важно уместить размер пакета в MTU, чтобы «по дороге» его не разбивали на мелкие части, которые могут потеряться, дойти в другом порядке, и которые потом нужно будет собирать по частям. Например, в Photon, если размер данных не умещается в заданный в библиотеке MTU, он разбивает данные на пакеты и посылает их как reliable (с гарантированной доставкой), даже если вы сами «сверху» посылаете их как unreliable. Проверено с болью на собственном опыте.

Особенности нашей разработки на ECS

  • Мы в ECS пишем исключительно бизнес-логику. Никакой работы с ресурсами, представлением и т.д. Так как код ECS-логики одновременно работает на клиенте в Unity и на сервере — он должен быть максимально независим от других уровней и модулей.
  • Стараемся минимизировать компоненты и системы. Обычно на каждую новую задачу мы заводим новые компоненты и системы. Но иногда бывает, что модифицируем старые, добавляем в компоненты новые данные, а системы «раздуваем».
  • В нашей реализации ECS нельзя добавить на одну сущность несколько компонентов одного типа. Поэтому, если в один тик игроку нанесли урон несколько раз (например, несколько противников), то обычно мы создаем на каждый урон новую сущность и добавляем на нее компонент Damage.
  • Иногда, представлению недостаточно той информации, которая есть в GameState. Тогда приходится добавлять специальные компоненты или дополнительные данные, которые в логике не участвуют, но нужны представлению. Например, на сервере выстрел моментальный, живет один тик, а на клиенте визуально он дольше. Поэтому для клиента выстрелу добавляется параметр «время жизни выстрела».
  • События/запросы мы реализуем за счет создания специальных компонент. Например, если игрок умер, мы вешаем на него компонент без данных Dead, что является событием для других систем и View-уровня о том, что игрок умер. Или если нам нужно заново возродить игрока на точке, мы создаем отдельную сущность с компонентом Respawn с дополнительной информацией кого возродить. Отдельная система RespawnSystem в самом начале игрового цикла проходится по этим компонентам и уже создает сущность игрока. Т.е. фактически первая сущность является запросом на создание второй.
  • У нас есть специальные «singleton»-компоненты/сущности. Например, у нас есть сущность с ID=1, на которой висят специальные компоненты — настройки игры.

Бонус

В процессе решения — а нужна ли Хабру статья про ECS — я провел небольшое исследование. Как и обещал в начале, вот мой небольшой обзор статей по этой теме, а вы решайте, читать или нет:

Автор: Антон Григорьев

Источник


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


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