Предисловие

Автор этой статьи - адепт data-oriented дизайна и ECS фреймворков. И поэтому мной было решено разработать игру, на первый взгляд не совсем подходящую по жанру для ECS. А именно казуальный раннер. Отсюда и вылилась рассматриваемая далее проблема.

Игра подразумевает бесконечное появление объектов перед игроком, при взаимодействии (попадания в них) с которыми происходит уникальное для типа объекта действие. Тоесть: у нас много разновидностей событий (кастомной логики) и происходят они не постоянно.

Звучит как идеальная работа для virtual метода, не так ли? Но есть нюанс. В ECS не существует абстракций (если не считать различные костыли). Поэтому проблему нужно разрешить с помощью композиции.

Наивное решение

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

[WithAll(typeof(Interacted))]
public partial struct NaiveJob : IJobEntity
{
    private void Execute(in SomeComponent component)
    {
        // Logic
    }
}

Но какие недостатки у такого решения?

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

  • Создание (schedule) джоба также не бесплатно и предположительно сама логика действия займёт меньше, чем расходы на сам джоб.

  • Исполнение логики внутри джоба также вводит ряд ограничений, поскольку они выполняются не на главном потоке, а на рабочих (worker threads).

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

Всё это привело к тому, что добавление новой логики требовало написания огромного количества шаблонного кода, а дополнительная нагрузка по производительности (хоть и не значительная) влияла на всю игру постоянно, каждый фрейм, несмотря на то, что само взаимодействие с объектами происходит обычно раз в 2-3 секунду.

Вариация наивного решения

Чтобы не переписывать весь существующий код я попробовал исправить то, что возможно: структурные изменения. Я решил использовать IEnablableComponent, в качестве альтернативы структурному Interacted. Таким образом этот компонент существовал на всех сущностях-объектов постоянно, а за счёт особенностей кодогенерации IJobEntity код систем и джобов остался тем же.

Однако вместо ожидаемого улучшения производительности - напротив, она заметно ухудшилась. Причина почему это произошло кроется в особенностях IEnablableComponent. Поскольку включение и выключение компонента не является структурным изменением система не может определить, что query пустая во время своего выполнения и вынуждена создавать джоб не только когда существуют нужные сущности, а каждый фрейм.

Поиск решения

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

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

А примерный шаблон для логики объекта начал выглядеть следующим образом.

Сам компонент Interacted вернулся к первоначальному решению со структурными изменениями.

[UpdateInGroup(typeof(SomeMainThreadGroup))]
public partial struct System : ISystem
{
    public void OnUpdate(ref SystemState state)
    {
        foreach (var someComponent in SystemAPI.Query<SomeComponent>().WithAll<Interacted>())
        {
            // Logic
        }
    }
}

Итак: это отчасти решило проблему производительности и существенно расширило возможности для исполнения логики (а также даёт нам возможность использовать SystemAPI). Однако осуществление структурных изменений внутри SystemAPI.Query всё также невозможно, а использование альтернативных решений в виде query.ToEntityArray и прочее приводит к использованию шаблонного кода и повышенному использованию производительности.

P.S. конечно в рамках разрабатываемой игры всё это было абсолютно незначительными недостатками, но я решил найти концепт, который бы можно было реализовать и в более серьёзных проектах.

Ивенты и ECS

Сами по себе ивенты являются анти-паттерном в рамках ECS, однако соблазн экспериментировать одержал вверх и я выяснил следующее:

Кодогенерация позволяет подписываться на ивенты внутри колбэков SystemBase и использовать все прелести SystemAPI внутри. Например:

public partial class System : SystemBase
{
    protected override void OnCreate()
    {
        SomeClass.someCallback += Handler;
    }

    private void Handler(SomeParameter parameter)
    {
        // Logic
    }
}

Хотя подобная реализация максимально проста и позволяет использовать значительно меньше шаблонного кода, создавать джобы внутри подобных колбэков небезопасно и использование Burst невозможно/невероятно затруднено.

Кульминация

В конце концов меня осинило - почему бы не пересоздать велосипед?

А именно: почему бы не выполнять всю логику внутри системного OnUpdate, но при этом определять когда эта система будет выполняться за счёт данных, не хранящихся на сущностях?

Как это должно выглядеть?

У нас есть игрок, который взаимодействует с объектом. Во время этого создадим сообщение со всеми релевантными данными о событии.

Для примера это будет только сама Entity объекта.

public struct InteractedMessage
{
    public Entity interactedEntity;
}

У нас есть конкретная система, которая должна выполнятся, когда игрок взаимодействует с конкретным объектом. И для реализации этой логики ей достаточно лишь получения InteractedMessage.

public partial struct LogicSystem : ISystem, IEventSystem<InteractedMessage>
{
    private InteractedMessage _message;

    public void OnUpdate(ref SystemState state)
    {
        if (SystemAPI.HasComponent<SomeComponent>(_message.entity))
        {
            // Logic
        }
    }
}

Таким образом я создал велосипед ECS без Entities.

Реализация

Нам остаётся лишь связать эту систему и сообщение и обеспечить исполнение системы, только когда релевантное сообщение было отправлено.

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

public struct Messenger : IComponentData
{
    internal NativeList<byte>.ParallelWriter data;

    public unsafe void Send<T>(T message) where T : unmanaged, INativeMessage
    {
        var size = sizeof(T);
        var hash = BurstRuntime.GetHashCode32<T>();
        const int hashSize = sizeof(int);

        var length = hashSize + size;
        var idx = Interlocked.Add(ref data.ListData->m_length, length) - length;
        var ptr = (byte*)data.Ptr + idx;
        UnsafeUtility.MemCpy(ptr, UnsafeUtility.AddressOf(ref hash), hashSize);
        UnsafeUtility.MemCpy(ptr + hashSize, UnsafeUtility.AddressOf(ref message), size);
    }
}

Как это работает:

  1. Получаем хэш типа с помощью встроенного метода Burst.

  2. Получаем размер хэша и самого сообщения.

  3. С помощью атомарной операции увеличиваем Length буфера и сохраняем указатель на зарезервированный нами участок памяти.

  4. Записываем хэш в виде заголовка (header) и сообщение в буфер.

P.S. конкретная реализация не является лучшим решением в плане производительности и может оказаться бутылочным горлышком при массивном использовании, и также имеет ограничение по записи, определяемое ёмкостью буфера. Но на текущий момент ограничимся этим.

Таким образом мы имеем простой потокобезопасный контейнер для по сути любых сообщений. То есть - у нас есть данные. И теперь нам остаётся лишь выполнить все релевантные для этого сообщения системы.

Чтобы полноценно интегрировать разработку в экосистему Unity.Entities я создал специальную системную группу где все эти систему и будут обновляться. Учёв опыт предыдущих решений, группа находится в специальном положении, чтобы не создавать точку сихнронизации.

    [UpdateInGroup(typeof(SimulationSystemGroup), OrderFirst = true)]
    [UpdateAfter(typeof(BeginSimulationEntityCommandBufferSystem))]
    public unsafe partial class NativeEventSystemGroup : ComponentSystemGroup

Чтобы корректно распаковать буфер сообщений нам нужно знать все отправляемые сообщения заранее. Есть разные варианты как это сделать через рефлексию. Наиболее универсальным мне показался метод через цикл по системам, созданным в системной группе (причём не важно созданы они встроенным или кастомным загрузчиком).

Я опущу детали конкретной реализации (т.к. это очень много не очень интересного кода) и просто опишу её принцип работы.

  1. Делаем цикл по всем созданным системам в нашей группе.

  2. С помощью рефлексии определяем тип сообщения в системе.

  3. Обеспечиваем систему всеми необходимыми данными для инъекции сообщений для систем.

В конце концов результат выглядит следующим образом, где:

  • _systemHandles - лист систем со всей необходимой информацией.

  • _dataMap - словарь для буферов сообщений и их размером.

        private struct EventSystemHandle
        {
            public bool isManaged;
            public int messageHash;
            public bool isSingle;
            public TypeDataHandle typeDataHandle;
            public void* lastPtr;

            public SystemHandle handle;

            // Unmanaged
            public int fieldOffset;
        }

        private struct TypeDataHandle
        {
            public int size;
            public NativeList<byte> dataBuffer;
        }

        private NativeList<EventSystemHandle> _systemHandles;
        private NativeHashMap<int, TypeDataHandle> _dataMap;

И наконец переходим к распаковке:

где data - NativeList<byte> из Messenger

            var ptr = data.GetUnsafeReadOnlyPtr();
            var iterator = 0;
            while (iterator < data.Length)
            {
                var hash = UnsafeUtility.AsRef<int>(ptr + iterator);
                var dataPtr = ptr + iterator + sizeof(int);

                var handle = _dataMap[hash];
                handle.dataBuffer.AddRange(dataPtr, handle.size);

                iterator += sizeof(int) + handle.size;
            }

Что происходит:

  1. Читаем заголовок из буфера.

  2. Определяем размер сообщения и записываем сообщение в соответствующий буфер.

  3. Повторяем, пока буфер не закончится.

Затем осуществляем цикл по _systemHandles:

Если буфер сообщения, связанный с системой не пуст, значит эту систему нужно обновить.

Прочитать сообщения система может по разному, но конкретно моя реализация сделана по подобию Dependency Injection:

Если это ISystem, то при инициализации мы определяем офсет указателя на поле с сообщением (которое маркируется через аттрибут) и затем просто воспользуясь memcpy пишем сообщение/массив сообщений в это поле.

Если это SystemBase, то просто реализуем абстракный тип EventSystemBase, где нужное поле уже будет создано, а сообщение может быть получено из свойств (property).

Познакомиться с полными исходниками реализации можно тут.

Выводы

Разработанная реализация мессенджера решает поставленную проблему: реализация кастомной логики через сообщения. Для создания логики требуется минимум шаблонного кода. Отправка сообщений возможна из любой точки игровой логики. Кастомная логика может быть использована с Burst и может создавать джобы. В условиях полного отсутствия сообщений система имеет практически нулевой overhead.

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

Размышления на будущее

На текущий момент реализация позволяет подписываться только на конкретные сообщения и делать это могут только ECS системы.

Добавление возможности подписываться любым объектам к сообщениям из конкретного мира ECS существенно расширит потенциал и простоту интеграции гибридных решений.

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

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


  1. Vilyx
    15.05.2023 22:49

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


    1. bustedbunny Автор
      15.05.2023 22:49
      +1

      Ну это как раз размышления для будущего.

      Первостепенно проблема заключается в исполнении кастомной логики без постоянного оверхеда, с чем решение справилось удовлетворительно.

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


  1. DizzyJump
    15.05.2023 22:49

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

    Вообще для разруливания логики между объектами разных "типов" в ECS существуют фильтры. Если нам нужно например написать кастомную логику интеракшена с допустим дверью то мы заводим под это систему и пишем в ней фильтр который отбирает сущности с компонентами Door и Interacted и пишем туда свою кастомную логику, посути это и есть аналог виртуального метода. Судя по статье автор об этом знает, на этом и следовало остановится дабы остатся в рамках ecs-way.

    Что касается ивентов они в ECS так же реализуются через ентити с компонентом события, который отлавливается системой в обычном порядке и обрабатываются.

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


    1. bustedbunny Автор
      15.05.2023 22:49
      +1

      Если нам нужно например написать кастомную логику интеракшена с допустим дверью то мы заводим под это систему и пишем в ней фильтр который отбирает сущности с компонентами Door и Interacted и пишем туда свою кастомную логику, посути это и есть аналог виртуального метода.

      Это самый первый - наивный метод, рассмотренный в статье. Его проблемы там же и описаны.

      Что касается ивентов они в ECS так же реализуются через ентити с компонентом события, который отлавливается системой в обычном порядке и обрабатываются.

      Этот подход я тоже пробовал, но при написании статьи он совсем вылетел у меня из головы.

      В рамках Unity ECS он оказался невероятно медленным и скейлить его по этой причине - невозможно.


      1. DizzyJump
        15.05.2023 22:49

        Я не знаю почему он у вас оказался медленным тут надо в код смотреть и искать где именно Вы ошиблись. Когда я писал проект в котором активно взаимодействовали (перестреливались) тысячи объектов одновременно там была просто тьма тмущая ecs-событий и создавалось/уничтожалось много сущностей каждый кадр и всё это отлично работало без тормозов даже на телефоне десятилетней давности.

        Вот можете полюбопытствовать: https://www.youtube.com/shorts/NERogo5dyoQ

        Этот проект так же писался на DOTS-е причём древнющей версии, новые версии подозреваю еще производителней.


        1. bustedbunny Автор
          15.05.2023 22:49
          +1

          Я не знаю почему он у вас оказался медленным тут надо в код смотреть и искать где именно Вы ошиблись.

          Структурные изменения в Unity ECS происходят только на главном потоке. Чтобы запустить эти изменения из привычной логики, внутри джобов, требуется командный буфер, который точно также воспроизводит эти структурные изменения, просто медленней и чуть позже. Скейлится это всё очень плохо, почему я и решил обойти стороной структурные изменения.

          По сути, с точки зрения логики-обработчика это одно и тоже. Отличается лишь то, как именно данные добираются до него.


          1. DizzyJump
            15.05.2023 22:49

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

            Ну ок если в Ваших руках он плохо скейлится остаётся только посочувствовать.


            1. bustedbunny Автор
              15.05.2023 22:49
              +1

              но это происходит очень быстро

              В малых масштабах - да. Но далеко не так быстро, как могло бы быть, что собственно и причина для статьи.


  1. MANAB
    15.05.2023 22:49

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