Pull to refresh

Решение концептуальной проблемы с Unity ECS. Messenger для System

Level of difficultyHard
Reading time7 min
Views2.5K

Предисловие

Автор этой статьи - адепт 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 существенно расширит потенциал и простоту интеграции гибридных решений.

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

Tags:
Hubs:
Total votes 3: ↑1 and ↓2-1
Comments9

Articles