Сколько уже было мануалов "Как сделать игру на Unity за 3 часа", "Делаем Counter-Strike за вечер" и т.п.? Низкий порог входа — это, несомненно, главный плюс и минус Unity. Действительно, можно накидать “ассетов”, дописать несколько простых “скриптов”, обмотать синей изолентой и это даже будет как-то работать. Но когда проект обрастает игровыми механиками, сложной логикой поведения, то проблемы при подобном подходе нарастают как снежный ком. Для внедрения новых механик требуется переписывание кода во многих местах, постоянная проверка и переделывание префабов из-за побившихся ссылок на компоненты логики, не говоря уже об оптимизации и тестировании всего этого. Разумеется, архитектуру можно продумать изначально, но на практике это всегда недостижимая цель — дизайн-документ довольно часто меняется, какие-то части выкидываются, добавляются абсолютно новые и никак не связанные со старой логикой поведения. Компоненты в Unity — это шаг в правильном направлении в виде декомпозиции кода на изолированные блоки, но особенности реализации не позволяют достичь необходимой гибкости, а самое главное, производительности. Разработчики придумывают свои фреймворки и велосипеды, но чаще всего останавливаются на ECS (Entity Component System). ECS – одно из решений, продолжающее идею компонентной модели Unity, но придающее ей ещё больше гибкости и сильно упрощающее рефакторинг и дальнейшее расширение приложения новым функционалом без кардинальных изменений в текущем коде.


Что такое ECS


ECS — это шаблон проектирования "Сущность Компонент Система" (Entity Component System, не путать с Elastic Cloud Storage :). Если совсем по-простому, то есть “Сущности” (Entity) — объекты-контейнеры, не обладающие свойствами, но выступающие хранилищами для “Компонентов”. “Компоненты” — это блоки данных, определяющие всевозможные свойства любых игровых объектов или событий. Все эти данные, сгруппированные в контейнеры, обрабатываются логикой, существующей исключительно в виде “Систем” — “чистых” классов с определенными методами для выполнения. Данный паттерн является независимым от какого-либо “движка” и может быть реализован множеством способов. Все “сущности”, “системы” и “компоненты” должны где-то храниться и каким-то образом инициализироваться — все это является особенностями реализации каждого ECS решения для конкретного “движка”.


Постойте, скажете вы, но ведь в Unity всё так и есть! Действительно, в Unity Сущность — это GameObject, а Компонент и Система — это наследники MonoBehaviour. Но в этом и заключается основное различие между компонентной системой Unity и ECS — логика в ECS обязательно должна быть отделена от данных. Это позволяет очень гибко менять логику (даже удалять / добавлять её), не ломая данные. Другой бонус — данные обрабатываются “потоком” в каждой системе и независимо от реализации в “движке”, в случае с MonoBehaviour происходит довольно много взаимодействия с “Native”-частью, что съедает часть производительности. Об особенностях внутреннего устройства вызова методов у наследников MonoBehaviour можно почитать в официальном блоге Unity: 10000 вызовов Update()


Пример работы ECS


Задача от дизайнера: “надо сделать перемещение игрока и загрузку следующего уровня, когда он доходит то точки Х”.
Разбиваем задачу на несколько подзадач, по одной на “систему”:


  • UserInputSystem — пользовательский ввод.
  • MovePlayerSystem — перемещение игрока на основе ввода.
  • CheckPointSystem — проверка достижения точки игроком.
  • LoadLevelSystem — загрузка уровня в нужный момент.

Определяем компоненты:


  • UserInputEvent — событие о наличии пользовательского ввода с данными о нем. Да, события — это тоже компоненты!
  • Player — хранение текущей позиции игрока и его скорости.
  • CheckPoint — точка взаимодействия на карте.
  • LoadLevelEvent — событие о необходимости загрузки нового уровня.

И вот как это всё примерно работает:


  • Загружается сцена и инициализируются все системы в указанной выше последовательности. Да, порядок обработки систем можно контролировать без сложных телодвижений- это ещё один приятный бонус.
  • Создаются сущности игрока (с добавлением на него компонента Player) и сущности контрольной точки (с добавлением на неё компонента CheckPoint).
  • Тут стартует основной цикл обработки систем — по сути аналог метода MonoBehaviour.Update.
  • UserInputSystem проверяет пользовательский ввод через стандартное Unity-api и создает новую сущность с компонентом UserInputEvent и данными о вводе (если он был).
  • MovePlayerSystem проверяет — есть ли сущности с компонентом UserInputEvent и есть ли сущности с компонентом Player. Если пользовательский ввод есть — обрабатываем всех найденных “игроков” (даже если он один) с полученными данными, а сущность с компонентом UserInputEvent удаляем полностью. Да, это работает очень быстро, не вызывает работы сборщика мусора — все уходит во внутренний пул для последующего переиспользования.
  • CheckPointSystem проверяет — есть ли сущности с компонентом CheckPoint и есть ли сущности с компонентом Player. Если есть и то и то — в цикле проверяет дистанции между каждым игроком и точкой. Если один из “игроков” находится достаточно близко для срабатывания — создает новую сущность с компонентом LoadLevelEvent.
  • LoadLevelSystem проверяет — есть ли сущности с компонентом LoadLevelEvent и выполняет загрузку новой сцены при наличии. Все сущности с таким компонентом удаляются перед этим.
  • Повторяем основной цикл обработки систем.

Выглядит как чрезмерное усложнение кода по сравнению с одним “MonoBehaviour” классом в десяток строк, но изначально:


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


Особенности ECS


Исходя из примера выше, можно вывести основные особенности ECS по отношению к компонентной модели Unity.


Плюсы


  • Гибкость и масштабируемость (добавление новых, удаление старых систем и компонентов).
  • Эффективное использования памяти (особенность реализации, мы можем переиспользовать инстансы “чистых” C#-классов как угодно в отличие от “MonoBehaviour”).
  • Простой доступ к объектам (выборка (фильтрация) сущностей с определенными компонентами производится ядром ECS без потери скорости и аллокаций памяти — это именно то, чего не хватает компонентной системе Unity).
  • Понятное разделение логики и данных.
  • Проще тестировать (легко воспроизводить тестовое окружение).
  • Возможность использования логики на сервере без Unity (нет зависимостей от самого “движка”).

Минусы


  • Больше кода
  • Для событий самой Unity необходимо каким-то образом пробрасывать их в ECS-окружение через “MonoBehaviour”-обертки.

Для многих, кто долго работал с Unity и ни разу не использовал ECS, поначалу будет сложно привыкнуть к такому подходу. Но вскоре, начинаешь “думать” компонентами / системами и всё собирается быстрее и легче, чем при сильно связанных компонентах на базе “MonoBehaviour”.


Встроенное ECS-решение в Unity


Сейчас даже сами разработчики Unity поняли, что пора что-то менять в их компонентной системе, чтобы повысить производительность приложений. Где-то год назад было анонсировано, что ведётся разработка собственной ECS и C# Job system. И вот, в 2018.1 версии, мы уже можем примерно представить, что же это будет в будущем, пусть даже и в Preview статусе.



Со штатной Unity ECS – пока ничего не понятно. Разработчики нигде не пишут, что она подходит только для ограниченного спектра задач, но когда возникают вопросы в результате переписывания с других ECS-решений — отвечают в стиле “вы неправильно используете ECS”. Т.е. по сути это получается не “multipurpose”-решение, что довольно странно. Релиза не было, всё еще могут поменять несколько раз, есть проблемы с передачей ссылочных типов (например, string), поэтому я не могу порекомендовать делать что-то большое на штатной ECS в её текущем состоянии.


Альтернативные ECS-решения для Unity


ECS-паттерн был придуман не вчера и на https://github.com можно найти множество его реализаций, включая версии для Unity. Относительно свежие и обновляющиеся:



Я имел дело только с двумя первыми вариантами.


Entitas — самое популярное и поддерживаемое большим сообществом решение (потому что было первым). Оно достаточно быстрое, есть интеграция с Unity-редактором для визуализации ECS-объектов, присутствует кодогенерация для создания оберток с удобным api поверх пользовательских компонентов. За последний год кодогенератор отделился в независимый проект и стал платным, так что это скорее минус. Еще один достаточно весомый минус (особенно для мобильных платформ) — память выделяется под все возможные варианты компонентов на каждой сущности, что не очень хорошо. Но в целом, он хорош, отлично документирован и готов к использованию на реальных проектах. Размер: 0.5mb + 3mb поддержки редактора.
Примеров с использованием Entitas достаточно много, но и существует / пиарится проект давно. Из примеров с исходниками можно посмотреть Match 1.
Общая производительность Entitas оценивается примерно так:


С LeoECS я знаком лучше, потому что делаю на нём новую игру. Оно компактное, не содержит закрытого кода в виде внешних сборок, поддерживает assembly definitions из Unity 2017, более оптимизировано по использованию памяти, практически нулевой GC (только на первичном наборе пулов), никаких зависимостей, C# v3.5 с опциональной поддержкой inline-ов для FW4.6. Из приятных вещей: DI через разметку атрибутами, интеграция с Unity-редактором для визуализации ECS-объектов и готовая обвязка для событий uGUI. Размер: 18kb + 16kb поддержки редактора.
В качестве готового примера с исходниками можно посмотреть классическую игру "Змейка".
Сравнение скорости Entitas и LeoECS: результаты достаточно близки с небольшим перевесом в ту и другую сторону.


Заключение


Я не эксперт в данном вопросе (только недавно начал использовать Unity в связке с ECS), поэтому и решил поделиться своими наблюдениями и мыслями в первую очередь с теми, кто "собирает" игры на Unity из ассетов с кучей скриптов на каждом. Да, это работает. Сам такой был. Но если вы делаете не прототип или какую-нибудь одноразовую игру без необходимости её поддержки и дальнейшего развития, то подумайте 10 раз — вам же потом во всём этом разбираться и переделывать.


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

Комментарии (29)


  1. Griboks
    09.05.2018 00:52

    Если пишите про компонентную систему, то надо тогда и про систему зависимостей пост в догонку написать.


    1. Leopotam
      09.05.2018 09:54

      Что есть «система зависимостей»? Фильтрация сущностей с определенными компонентами, включая составные?


      1. Griboks
        09.05.2018 10:02

        Точнее, система внедрения зависимостей. Да вообще всё, что с ними связано. Это очень важная система, про которую стоит написать. Например, https://habr.com/post/245589 или как совместить эту систему с ECS. И какие есть нюансы, какие решения. В общем, тема для статьи, однозначно.


        1. Leopotam
          09.05.2018 10:19

          Это зависит от реализации ECS, применительно к той, которой пользуется автор — все делается автоматически через разметку атрибутами без использования конструкторов:

          sealed class TweenPosition {
              public Transform Target;
              public Vector3 From;
              public Vector3 To;
              public float Time = 0f;
              public float Factor = 0f;
          }
          sealed class TweenPositionSystem : IEcsRunSystem {
              [EcsWorld]
              GameWorld _world;
          
              [EcsFilterInclude (typeof (TweenPosition))]
              EcsFilter _positionFilter;
          
              void IEcsRunSystem.Run () {
                  for (var i = 0; i < _positionFilter.EntitiesCount; i++) {
                      var entity = _positionFilter.Entities[i];
                      var tween = _world.GetComponent<TweenPosition> (entity);
                      tween.Factor += Time.deltaTime / tween.Time;
                      tween.Target.localPosition = Vector3.Lerp (tween.From, tween.To, tween.Factor);
                      if (tween.Factor >= 1f) {
                          tween.Target.localPosition = tween.To;
                          _world.RemoveComponent<TweenPosition> (entity);
                      }
                  }
              }
          }
          

          "_world" — это инстанс-контейнер для всех сущностей, так же предоставляющий апи по управлению ими.
          "_positionFilter" — сюда будут автоматически набиваться сущности, на которых гарантированно есть компонент «TweenPosition».
          Регистрация систем выглядит примерно так:
          var world = GameWorld.Instance;
          _systems = new EcsSystems (world)
          #if DEV_ENV
              .Add (new Client.Systems.DebugHelpers.FpsCounterSystem ())
          #endif
              .Add (new UserSettingsSystem ())
              .Add (new UserProgressSystem ())
              .Add (new InitLevelSystem ())
              .Add (new InitEnvironmentSystem ())
              .Add (new InitStaticsSystem ())
              .Add (new InitDynamicsSystem ())
              .Add (new InitSwitchesSystem ())
              .Add (new InitPlatformsSystem ())
              .Add (new FinishLevelSystem ())
              .Add (new GameUiBoxesSystem ())
              .Add (new UserDesktopInputSystem ())
              .Add (new UserMobileInputSystem ())
              .Add (new FastTurnSystem ())
              .Add (new TrapDetectSystem ())
              .Add (new TweenPositionSystem ())
          // ...
              .Add (new InitGameUiSystem ());
          _systems.Initialize ();
          

          Обновление вызывается примерно так:
          void Update () {
              _systems.Run ();
          }
          

          Это все есть в README у каждой реализации ECS и на статью это не тянет.


          1. Griboks
            09.05.2018 11:21

            Да и этот пост на статью не тянет, но его зачем-то написали. Тем более, эта тема интересна с точки зрения теории, а не кода.


            1. Leopotam
              09.05.2018 11:23

              Да и комментарий не стоило писать про конкретные особенности DI в реализации, если интересовала только теория.


              1. Griboks
                09.05.2018 11:34

                Неужели вы думаете, что какую-либо реализацию нельзя развить в теорию? Это распространённое заблуждение, что, ограничивая задачу с одной стороны, мы ограничиваем её в целом.
                Я имел в виду особенности совместимости ECS и DI в данном случае.


                1. Leopotam
                  09.05.2018 11:40

                  Нельзя, потому что это особенность реализации. Где-то DI делается исключительно через конструктор + все остальные поля запрашиваются руками через апи, что дает много бойлерплейта (Entitas), где-то делается автоматически через reflection (штатная ECS). Никакой разницы нет, функции выполняются идентичные, поэтому не совсем понимаю, почему эта тема показалась настолько важной.


                  1. Griboks
                    09.05.2018 11:46

                    Гибкость и масштабируемость (добавление новых, удаление старых систем и компонентов)

                    А потом вдруг оказывается, что никакой гибкости нету, и системы, например DI, намертво зашиты в ECS. Какая-то нестыковочка получается.


                    1. Leopotam
                      09.05.2018 11:49

                      Так гибкость дается в управлении состояниями компонентов, а не в инициализации, которая является особенностью реализации. Если так рассуждать, то можно докопаться и до того, что юнити нас ограничивает в любом случае необходимостью использования MonoBehaviour-ов, что вообще невозможно использовать — негибко и зашито намертво.


                      1. Griboks
                        09.05.2018 11:54

                        А разве автор не также рассуждает? И приходит к выводу, что встроенная ECS действительно плохая, поэтому и рассказывает о новой «правильной» ECS, которая позволяет сделать всё то, для чего она предназначена.


                        1. Leopotam
                          09.05.2018 12:07

                          Советую перечитать статью, автор об этом не говорит, говорят об этом сами юнитехи, когда говорят, что нельзя использовать их ECS в роли основного архитектурного фреймворка для всего приложения. Еще есть проблемы с референсными типами (решения нет): forum.unity.com/threads/alternative-to-using-string.523240


                          1. Griboks
                            09.05.2018 12:22

                            Но в этом и заключается основное различие между компонентной системой Unity и ECS — логика в ECS обязательно должна быть отделена от данных. Это позволяет очень гибко менять логику (даже удалять / добавлять её), не ломая данные

                            Но разве это не говорит о том, что можно просто заменить DIS, и ничего другое не пострадает? И речь тут идёт не об инициализации самой системы, а её функционировании — внедрении зависимостей по ходу работы редактора и самой игры.

                            Т.е. я могу заменить поиск объектов на кэш объектов, и всё будет прекрасно работать дальше. Иными словами, я могу выбрать любую (или же нет?) DIS (какую?) и подключить её обособленно (или с бубном?). Про это и следовало бы написать. Всё-таки, это и наиболее проблемная и часто используемая грань разработки.


                            1. Leopotam
                              09.05.2018 12:30

                              Кеш тут не сильно поможет — его нужно будет валидировать руками на каждое изменение в компонентах, а «выборки» могут быть составными (например, 4-5 компонентов по AND + 2 компонента по NOT), над такими кешами придется хорошо подумать. Собственно, всю эту ручную работу и делает любой ECS фреймворк. Как именно — особенности реализации.


                              1. Griboks
                                09.05.2018 12:38

                                А если я захочу использовать свою DIS, то мне придётся отказаться от ECS? Что же это за ECS такая?


                                1. Leopotam
                                  09.05.2018 12:40

                                  Никто не мешает использовать свое решение для инжекта своих данных в системы. Данные, связанные с ECS, инжектятся самой ECS.


                                  1. Griboks
                                    09.05.2018 12:45

                                    Какие данные? Скорость бега игрока? Кастомные компоненты? Цвет полосы загрузки? Как отличить эти данные, от используемых в самой игре? Как тогда называется модель организации всех этих данных, логики и системы, разрабатываемых лично моими руками? ECSOS? А зачем тогда нужна ECS, если она не работает с моими данными? Вы меня запутали(


                                    1. Leopotam
                                      09.05.2018 12:53

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


                        1. mopsicus Автор
                          09.05.2018 12:22

                          Действительно, стоит перечитать статью — я так не говорил, вы немного переврали.
                          Это статья про ECS в Unity, и можно вполне говорить про неё в отрыве от DI, потому что как уже написали — это особенности реализации.


                          1. Griboks
                            09.05.2018 12:31

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


                            1. mopsicus Автор
                              09.05.2018 12:38

                              Так мы говорим про разные системы видимо

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

                              В ECS, «системы» — это внутренняя логика приложения, разбитая на модули, каждая система выполняет определенную задачу. Смотрите пример.

                              А вы говорите про DI, которая внешняя, и может быть разная в каждой ECS.


  1. indiega
    09.05.2018 00:52

    Entitas есть и бесплатный, но рефакторинг без рослина, превращается в попаболь.


    1. Leopotam
      09.05.2018 09:40

      В статье написано про кодогенератор, который бесплатный есть только как часть старой версии Entitas.


  1. Katsuko
    09.05.2018 01:19

    Может я плохо понял идею нового подхода, но что мне мешало раньше (в принципе я так и делал) отделять тот же input от основной логики (реализация которых чаще всего была не в MonoBehaviour классах) моего приложения. Из прочитанной статьи я не уловил профита новой системы.


    1. Leopotam
      09.05.2018 09:53

      Основная идея в том, что логику можно удалять / добавлять путем редактирования списка активных систем в ECS — при этом ничего остального менять не потребуется. В юнити этого можно достичь только через менеджеры, основанные на MonoBehaviour-ах, но это будет все-равно медленнее чем чистые классы. Вторая особенность — это быстрая выборка сущностей-GameObject-ов с определенными компонентами, по сути аналог FindObjectsOfType(), но позволяет работать с комплексными типами, например:

      [EcsFilterInclude (typeof (IsBoxAnimationStarted))]
      [EcsFilterExclude (typeof (TweenPosition))]
      EcsFilter _tweenedBoxes;
      

      Звучит примерно как «хочу получить все сущности, на которых висит компонент IsBoxAnimationStarted и отсутствует компонент TweenPosition». Это делается без потери производительности — по сути все совместимые фильтры обновляются только в момент добавления / удаления компонентов по событию, а не каждый фрейм. Сильно напоминает выборки из реляционной базы данных.


    1. evnuh
      09.05.2018 15:50

      Ни в статье, ни в комментариях, нигде никто не упомянул, зачем ECS вообще нужна — для распараллеливания логики по ядрам. Это всё, ради чего оно и задумывалось, вместе с Job System. В ECS ваши системы работают независимо друг от друга, а значит могут быть спокойны распараллелены, а так же обрабатывать сущности (Entities) пачками, тоже разделяя пачки по ядрам.


      1. Leopotam
        09.05.2018 16:06

        Это ни коим образом не задача ECS, а просто побочный эффект и часть реализации в штатном решении Unity ECS, что ограничивает применение обработки в Job-ах (запрет на референсные типы — как часть этой проблемы).
        Нужно смотреть на время синхронизации потоков. По моим тестам количество сущностей должно быть не менее 1к на каждый поток с обработкой сложнее простого перебора в цикле — тогда имеет смысл параллелить, иначе все сожрет синхронизация и получится даже медленнее: leopotam.com/16


  1. Belfegnar
    10.05.2018 00:18

    Спасибо за статью) Есть пара вопросов.
    1) Существуют ли спидтесты — сравнения производительности Unity ECS (с включенным оптимизатором) и описанных в статье альтернативных систем?
    2) Как вообще уживается ECS и data-oriented design? В Unity ECS, насколько я понимаю, все ограничения связаны как раз с решением объединить эти два подхода (и внедрением своего компилятора, оптимизирующего все и вся)


    1. Leopotam
      10.05.2018 09:54

      1. Спидтестов к штатной ECS нет, потому что оно еще не в релизе + придется ограничивать себя MarshalByValue типами внутри компонентов — получается синтетика вместо реальных тестов. Нельзя использовать ссылки на инстансы классов, нельзя использовать строки и т.п — все то, что в любом случае необходимо использовать для использования ECS в качестве основного архитектурного паттерна для приложения.
      2. DOD — это не про расположение данных, а про сам механизм обработки: «одна команда (система) — много данных (компонентов)», или SIMD. Т.е по сути ECS можно называть паттерном, построенным по принципам DOD. То, что «используем только MarshalByValue для плотного расположения в памяти и надежде на использование кеша» — это особенности реализации, когда меняем постоянное копирование больших блоков данных внутри «движка» на скорость обработки этих данных пользовательским кодом.