Unity DOTS

DOTS

Unity уже довольно давно представили миру свой новый дата-ориентированный технлогический стек (Data-Oriented Technology Stack или сокращенно DOTS). Возможно, вы уже что-то слышали о нем. Именно о DOTS сегодня и пойдет речь. Мы рассмотрим, что же это такое, с чем его едят и попробуем написать простое приложение с его помощью.

Думаю, ни для кого не секрет, что с производительностью в Unity дела обстоят далеко не самым лучшим образом. Да и производительности как таковой никогда не бывает много. В Unity, естесственно, знают, как обстоят дела с производительностью в их собственном движке; и именно поэтому и был представлен DOTSDOTS позволяет использовать всю мощь современных мультиядерных процессоров для получения высокопроизводительного кода относительно просто и безопасно. По заявлению разработчика, благодаря использованию DOTS вы не только получаете максимально доступную (на данный момент) в Unity производительность. Одновременно с этим упрощается разработка кода, последующие его чтения и даже переиспользование кода в других проектах. На самом деле, конечно же, не все так просто; и это не какая-то серебряная пуля, и все (как и обычно) зависит целиком и полностью от разработчика: как напишет так и будет).

Так что же из себя представляет DOTS?

DOTS включает в себя три основных компонента:

  • Job System;
  • Burst Compiler;
  • Entity Component System.

Job System

Job System позволяет довольно легко писать многопоточный код, используя в качестве языка программирования основной язык программирования в Unity, а именно C#. 

Burst Compiler

Burst Compiler, как понятно из названия, представляет из себя компилятор некоторого подмножества языка C# (HPC#), основной целью которого является реализация высокооптимизированного нативного кода. Большим плюсом является использование в качестве языка разработки для Burst Compiler’а C#, т.к. он позволяет продолжить писать весь код на одном языке. Когда возникла идея реализовать Burst Compiler, языком разработки могли стать  C/C++, некоторый собственный язык или подмножество языка C#. Но стоит учитывать, что это не тот самый C#, к которому все привыкли. Это довольно сильно ограниченный язык, в котором нет стандартной библиотеки (читай, прощай Linq, List, Dictionary и т.д.), запрещена операция выделения памяти (т.е. никаких классов, только структуры), нет виртуальных вызовов, нет рефлексии, отключен сборщик мусора и многое другое. Это жертвы, которые пришлось принести ради производительности.

Entity Component System

Entity Component System — это архитектурный паттерн, во главе которого стоят принципы композиции, а не наследования. И хотя по названию эта парадигма очень похожа на стандартную Unity парадигму (Component System), концептуально она довольно сильно от нее отличается. ECS в Unity, как это часто бывает, написана с учетом  Data-Oriented Design. Отсюда пошло название DOTS; и это же является одной из причин высокой производительности. Несмотря на плюсы, которые предлагает ECS,  есть и свои сложности, а именно: необходимость перестройки мышления (уход от классического ООП и компонентно-ориентированного программирования Unity). И хотя в этом ничего особо сложного нет, но какое-то время это все же займет.

Все три компонента подсистемы DOTS направлены на получение максимально возможной (на данный момент в Unity) производительности, при этом не обременяя разработчика, что не только не усложняет его жизнь, но и во многих аспектах делает ее проще. Чтобы продемонстировать всю мощь, которую можно получить используя DOTS, разработчики из Unity представили проект Megacity — футуристический город с летающими машинами, сотнями тысяч высокодетализированных игровых объектов и уникальных источников звуков. Так же доступен исходный код этого проекта.

 

Разработка под ECS значительно отличается от привычного в Unity компонентного подхода. В отличие от компонента поведения, который содержит и данные и логику работы с этими данными, в ECS данные и логика строго разделены. ECS состоит из трех основных частей: сущностей (Entity), компонентов (Component) и систем (System). Этот подход позволяет реализовать декомпозицию кода, т.е. явно отделить данные от поведения, что (в теории) благоприятно сказывается на качестве кода, скорости разработки, производительности (само собой) и переиспользуемости кода. Сущности чем-то похожи на стандартные игровые объекты в Unity — по своей сути они представляют собой объекты-контейнеры для компонентов. На каждую сущность можно навешивать разные компоненты. Причем, в отличие от игрового объекта, навесить несколько однотипных компонентов на одну сущность нельзя. Обычно это не большая проблема, т.к. можно создать столько сущностей, сколько требуется, и навесить по необходимому компоненту на каждую из них. Компоненты представляют собой блоки данных, и только данных. Как раз совокупности этих данных и определяют, что из себя будет представлять конкретная сущность. Системы — это классы для реализации некоторого поведения. Системы получают на вход список сущностей для обработки и выполняют с ними некоторые действия. Системы могут обрабатывать как все сущности, так и только те, что содержат определенные компоненты (что чаще всего и бывает). Четкое отделение данных от логики выполнения имеет свои преимущества. А именно: можно довольно легко менять логику выполнения (прямо на лету) путем добавления или удаления систем, изменения порядка выполнения или временного отключения систем, при этом не ломая данные. Так как сами данные отделены и представлены в виде компонентов, то с ними легко производить некоторые манипуляции, такие как сериализация и десериализация, пересылка по сети. Это никак не усложняет работу с ними, т.к. системам неважно, откуда данные взялись (сгенерированы другой системой, прочитаны с диска, переданы по сети). Все они обработаются одинаково и прозрачно. Таким образом, можно выделить следующие плюсы использования ECS при разработке:

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

Но так же можно выделить и некоторые минусы:

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

 

Еще до появления Unity ECS в Unity можно было использовать ECS подход с помощью фреймворков. Самыми популярными являются: EntitasLeoECSBrokenBricksECS и Svelto.ECS. Из этого списка нам приходилось работать только с Entitas, как пожалуй, с самым популярным сторонним решением. У этого решения есть свои плюсы и удобные возможности, которых у Unity ECS пока нет (а, возможно, никогда и не будет), например, реактивные системы. Реактивные системы — это такой подвид систем, которые позволяют обрабатывать только те сущности с заданными компонентами, данные которых поменялись с предыдущего запуска системы (довольно удобная возможность). Но, несмотря на то, что этот фреймворк является довольно производительным решением, в плане скорости тягаться с Unity ECS он, конечно, не может. Но зато (опять-таки, на данный момент) он является production ready решением.

Старый добрый Entity Component подход

Давайте для примера напишем небольшое приложения и посмотрим, как будет отличаться код этого приложения в EC стиле от ECS стиля. Визуально это будет выглядеть примерно так: 

У нас есть игровые объекты — герои (треугольники) и игровые объекты —  цели (кружки). Цель героя — найти ближайшую цель и переместиться к ней. После чего цель уничтожается, и герой ищет следующую цель. Все предельно просто. Как раз самое то, чтобы рассмотреть отличия без каких-то нибыло усложнений.

Для реализации этого функционала в EC подходе нам потребуется:  AppManager ,  HeroBehaviour и  TargetBehaviour . И собственно код:

Создаем заданное в компоненте количество героев и целей. Компонент цели не содержит код, а служит своего рода маркером для его дальнейшего поиска:

И вся логика поиска и поглощения целей находится в компоненте героя:

Если цель найдена (сохранена в поле  _target ), то перемещаемся в ее сторону до некоторого порога. А по окончании перемещения уничтожаем цель. Если же цель не найдена, то ищем все игровые объекты с компонентом-тегом «Цель», выбираем из них ближайшую и сохраняем ссылку на нее. Как и говорилось, реализовано все максимально просто и явно. Скорее всего, каждый хоть раз, да писал нечто подобное.

DOTS (однопоточный)

Теперь давайте рассмотрим, как примерно идентичный функционал выглядел бы в ECS стиле. Для реализации нам потребуются следующие компоненты: компонент героя, цели и компонент найденной героем цели. А для обработки этих компонентов нужно реализовать соответствующую систему. Начнем с компонентов (помним, что компоненты в ECS хранят только данные, т.е. никакой логики). Также стоит учитывать, что компоненты в Unity ECS  должны быть структурами и не должны содержать ссылочные типы данных (явно и не явно). Другими словами, в качестве данных обычных компонентов (есть и другие виды компонентов, у которых свои возможности и ограничения) могут выступать только простые типы ( float ,  int ,  bool и т.д.) и blittable типы. При попытке создать компонент со ссылочным типом будет выкинуто исключение. Это ограничение получилось из-за того, что данные компонентов всех сущностей хранятся в специальной памяти (чанках), с которой GC не может работать. Данные компонентов всех сущностей, относящихся к одному архитипу (архитип — уникальный набор компонентов), хранятся вместе в едином блоке памяти, что позволяет их все очень быстро обработать, т.к. нет нужды прыгать по памяти (как, например, в случае со стандартными ссылочными компонентами Unity), что дает прирост производительности. Поэтому-то и нельзя использовать ссылочные типы в таких компонентах. Вот так будет выглядеть компонент-макер нашего героя:

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

А вот для компонента для фиксации выбранной героем цели уже необходимы данные. И в качестве данных будем использовать «ссылку» на сущность цели:

А как же нам тогда отобразить что-то на экране, если мы не можем использовать ссылочные типы, спросите вы? Да, действительно, в так называемом Pure ECS этого нельзя сделать. Для решения этой проблемы есть Hybrid ECS (для этого нам необходим отдельный пакет Hybrid Renderer). В состоав этого пакета входят компоненты и системы для отрисовки мешей использую DOTS. Из этого пакета мы будем использовать следующие компоненты:  RenderMesh ,  LocalToWorld и  TranslationRenderMesh компонент — это структура, реализующая интерфейс  ISharedComponentData , благодаря чему мы можем хранить ссылочные типы (в нашем случае это меш и материал) в компонентах.  ISharedComponentData  компоненты хранятся в памяти, образуя группы по значению, т.е. компоненты с одной и той же ссылкой на меш будут храниться в одном чанке. Таким образом, если у вас куча компонентов, ссылающихся на одни и те же ссылочные данные, то такие компоненты будут храниться в одном блоке памяти. С другой стороны, если у вас куча компонентов с разными ссылками, то для каждой такой сущности будет создан отдельный чанк, что негативно скажется как на памяти, так и на обработке таких компонентов (отсюда и Shared в названии интерфейса).

В Unity ECS есть такой компонент как мир ( World ). Он владеет менеджером сущностей ( EntityManager ) и набором систем для обновления. Вы можете создать столько миров, сколько вам необходимо (например, это может быть мир для симуляции и для отрисовки или презентации). Unity создает так называемый мир «по умолчанию» и регистрирует в него все найденные системы автоматически. Менеджер сущностей, как понятно из названия, позволяет оперировать сущностями в мире. А именно: создавать, удалять сущности, добавлять, изменять и удалять компоненты с сущностей. В каждом мире существует только один менеджер сущностей. Стоит также учитывать, что менеджер сущностей работает только в главном потоке, поэтому использовать его просто так в Job системах не получится. Менеджер сущностей позволяет создать как просто сущность, так и сущность с заданным архитипом. А далее необходимо только выставить соответствующие данные компонентам. Рассмотрим, как будет выглядеть наш менеджер приложения:

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

При этом, само собой, ничего не двигается и не меняется, так как мы еще не реализовали систему обработки наших компонентов. Но зато уже отрисовывается, так что мы на верном пути. Кроме того, можно вопсользоваться отладчиком сущностей (Window->Analysis->Entity Debugger) и посмотреть список созданных сущностей, компонентов на них, а так же список зарегистрированных систем:

К сожалению, на данный момент функционал отладчика, да и вообще работы с сущностями в редакторе сильно ограничем. Кроме той информации, что видно на скриншоте, больше ничего сделать нельзя. А реализация работы с ECS например в Entitas гораздо шире и позволяет, кроме прочего, создавать сущности, навешивать компоненты и изменять данные компонентов, а также создавать свои редакторы для своих компонентов. Возможно в будущем эта ситуация исправится, но когда же наступит это будущее? Этот функционал необходим уже здесь и сейчас, тем более если учитывать, сколько уже времени ведется разработка Unity ECS. А ведь все еще доподлинно неизвестно, когда она уйдет в релиз…

Теперь нам необходимо реализовать функционал обработки наших сущностей. Для этого, как, думаю, уже должно быть понятно, нам нужно реализовать соответствующую систему. Для разогрева мы будем использовать самую простую систему, а именно  ComponentSystem . Эта система позволяет выполнить некоторые операции с сущностями. Но стоит учитывать, что эта система работает в основном потоке, поэтому, скорее всего, мы не получим какого-то существенного выигрыша в производительности. Так когда же стоит использовать такую систему? Такие системы стоит использовать, когда нам не важна скорость, во время разработки (такие системы проще тестировать, т.к. она работает в однопоточном режиме) или в случаях, когда нам нужно произвести какие-то изменения, которые можно выполнить только в основном потоке.  ComponentSystem — это абстрактный класс системы, который дает нам переопределить метод  OnUpdate, в котором, собственно, мы и должны выполнить некоторую работу. Есть еще и другие полезные виртуальные методы у этого класса. Для того, чтобы обработать сущности, нам необходимо выбрать, что именно мы хотим обработать (ведь не хотелось бы бегать вообще по всем сущностям, так ведь?). Чтобы это сделать, существует класс  EntityQuery (и более функциональный собрат  EntityQueryDesc), который позволяет описать список компонентов, которые должны находиться или не находиться на сущностях, которые мы хотим обработать. Здесь необходимо описывать только те компоненты, с которыми вам необходимо работать и те, что однозначно позволяют выделить необходимые сущности. Все остальные компоненты, которые могут в данный момент висеть на обрабатываемых сущностях, нет нужды описывать, т.к. это негативно скажется на производительности. Пример реализации системы:

Мы создали два запроса сущностей с задаными компонентами (для однозначной идентификации типа сущностей мы использовали компоненты-маркеры  Hero и  Target, хотя как таковой работы с этими компонентами производиться не будет). С помощью метода  RequireForUpdate мы зарегистрировали наши запросы, чтобы к моменту выполнения  OnUpdate все необходимые данные были собраны. С помощью метода  ToEntityArray мы получаем нативные массивы с необходимыми для обработки сущностями. Так как после работы с нативными массивами необходимо вручную очистить занимаемую ими память, мы использовали удобную в данном случае конструкцию языка  using. Далее все просто. Пробегаемся по всем героям; если герой уже имеет компонент  HasTarget, то осуществляем перемещение этого героя к этому компоненту (меняя компонент  Translation). Если же нет, то пробегаемся по всем целям, находим ближайшую к герою и добавляем компонент  HasTarget со ссылкой на ближайшую сущность цели. Если герой приблизился довольно близко к цели, то необходимо уничтожить эту цель. Стоит обратить внимание, что для этого не используем метод  EntityManager.DestroyEntity, т.к. тогда в нативном массиве запрошенных целей будет неактуальная ссылка на сущность, которую мы уже уничтожили. Чтобы обойти эту проблему, используется  EntityCommandBuffer, который позволяет записать некоторые действия над сущностями и выполнить их уже потом. Если запустить этот код, то он отработает примерно так же, как и наш первый вариант. Причем с примерно похожей производительностью (см. скриншоты в конце статьи). Так где же обещанная DOTS производительность, спросите вы? Дело в том, что по сути мы еще не использовали распараллеливание выполнения работы. И код в нашей системе отрабатывал в основном потоке. Поэтому говорить о каком-то значительном приросте производительности не приходится.

DOTS (многопоточный)

Теперь давайте попробуем написать наш функционал, используя  Job подсистему. В состав Unity ECS пакета уже входит базовый класс системы, позволяющий использовать всю мощь доступных устройству ядер для выполнения некоторой работы. Для этого необходимо реализовать систему обработки сущностей, унаследовавшись от класса  JobComponentSystem. В предыдущих примерах куча функционала, а именно поиск целевой сущности и перемещение к целевой сущности, была написана в одной системе. В этом же примере давайте разделим этот функционал на разные подсистемы. Начнем с системы перемещения к цели. Для реализации функционала перемещения нам необходимо выполнить две вещи: унаследоваться от  JobComponentSystem и реализовать собственно  Job, который и будет выполнять основную работу. Посмотрим, как это будет выглядеть:

Мы реализуем  Job перемещения, используя интерфейс  IJobForEachWithEntity, который позволяет выполнить некоторую работу над всеми подходящими сущностями (в данном случае нам подходят все сущности с компонентами HasTargetTranslation и Hero, т.е. все герои. Стоит обратить внимание на атрибут RequireComponentTag, который позволяет фильтровать сущности, при этом сами данные этого компонента не передаются в работу, т.к. они не нужны). Причем в  Job.Execute в таком случае будет передана ссылка на саму сущность и на необходимые нам компоненты. Кроме того, для работы  MoveJob нам потребуются дополнительные данные. А именно  deltaTime, список целевых сущностей  targets и соответствующий ему список компонентов перемещения целевых сущностей targetsTranslations (индексы соответствуют друг другу). Поля с нативными массивами помечены полезными атрибутами. Атрибут  ReadOnly  позволяет понять  Job подсистеме, что эти данные будут использоваться только на чтение, что, в свою очередь, позволяет оптимальнее составить список зависимостей. А атрибут  DeallocateOnJobCompletion позволяет автоматически освободить занимаемую этими массивами память по окончании выполнения работы. Далее мы проверяем: если текущий герой имеет корректную ссылку на целевую сущность, то осуществляем перемещение. Иначе помечаем целевую сущность на удаление. Если приблизились к целевой сущности достаточно близко, то удаляем целевую сущность и компонент наличия цели. Опять, стоит обратить внимание, что мы не можем использовать  EntityManager, т.к. он работает только в основном потоке. И для решения этой проблемы опять воспользуемся  EntityCommandBuffer.

В  MoveSystem.OnCreate мы составляем запрос на группу целевых сущностей, чтобы мы могли обеспечить данными  MoveJob . А также получаем ссылку на  EndSimulationEntityCommandBufferSystem, с помощью которой мы создадим соответствующий буфер для команд. В  MoveSystem.OnUpdate мы создаем экземпляр  MoveJob, заполняем его неоходимыми данными. Стоит обратить внимание: т.к.  MoveJob будет выполняться в многопоточном режиме, мы не можем использовать однопоточный вариант  EntityCommandBuffer; поэтому используется метод  ToConcurrent  для создания буфера, поддерживающего работу в многопоточной среде. Далее с помощью метода  Schedule мы планируем запуск нашего кода, передавая в качестве зависимостей указатель на входящие зависимости. Получившийся  JobHandle и возвращаем из метода. А, чтобы система обработки буферов команд знала, в какой момент ей уже можно запускаться, используем метод  AddJobHandleForProducer.

Теперь настала очередь системы  FindTargetSystem, цель которой — найти все сущности героев без компонента-маркера  HasTarget  и навесить этот компонент, если еще есть целевые сущности. Смотрим:

В реализации этой системы нам уже практически все знакомо. Стоит лишь упомянуть об атрибуте  ExcludeComponent, позволяющем исключить сущности с заданым компонентом из обработки.

DOTS (Burst)

Результаты (опять-таки, смотрите в конце статьи) работы этого варианта уже лучше, хотя и не являются чем-то неожиданными. Можно ли попытаться сделать еще лучше? Конечно, мы же еще не использовали Burst Compiler! А использовать его довольно просто. Достаточно добавить атрибут  BurstCompile на соответствующий  Job. Давайте попробуем это сделать с   FindJob. Добавили и запустили. Приложение некоторое время поработало, а потом упало с ошибкой:

Дело в том, что мы в своем  Job-е обращаемся к  commandBuffer, который, в свою очередь, пытается обратиться к типам (т.е. использовать рефлексию), что делать Burst Compiler, как мы уже знаем, не может. И как же быть в таком случае? Неужто мы не сможем использовать Burst Compiler, чтобы ускорить наш код? На самом деле мы можем разделить наш  Job на два, и в одном не использовать обращения к  commandBuffer . Давайте посмотрим, как это будет выглядеть:

Мы добавили  AddTargetJob, в который и вынесли всю работу с  commandBuffer, а в  TargetJob только сохраняем необходимую для работы  AddTargetJob информацию. Аналогичное преобразование можно сделать и для MoveTargetSystem, но это остается в качестве домашнего задания). Теперь можно запускать и смотреть на результат.

Итоги

Вариант без использования ECS:

Вариант с ECS, но без использования многопоточности:

Вариант с многопоточностью:

И вариант с Burst Compiler:

О результатах можете судить сами. Пускай это и не выдающийся результат, но некоторого прироста производительности мы смогли достичь. Стоит учитывать, что это довольно грубое сравнение производительности, которое направлено лишь на то, чтобы показать и сравнить примерные возможности. Возможно ECS код еще можно улучшить (если вы знаете как, то милости просим в комментарии). Unity ECS находится в превью, так что с выходом релиз версии все может измениться. Стоит это учитывать. Также стоит проверять производительность не в редакторе и в релизном билде. Но это все выходит за рамки данной статьи. Стоит ли использовать Unity ECS прямо сейчас в продакшене? Однозначно нет. API от версии к версии очень сильно меняется (обучающие статьи и видео годовалой давности уже не актуальны!). Еще довольно много чего нельзя реализовать, используя только Unity ECS. Но однозначно можно сказать, что в будущем Unity ECS будет развиваться и обрастать новым функционалом и возможностями. Также не стоит пугаться: отказываться от стандартного компонентного подхода в Unity пока не собираются. И все-таки, Unity ECS — это технология для продвинутых пользователей. С другой стороны, стоит учитывать, что некоторый функционал будет доступен, только используя Unity ECS. Например, Unity TinyТ.е. использовать Unity Tiny без ECS не получится. Так что возможно самое время начать разбираться с DOTS прямо сейчас?)

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

Добавить комментарий