Предисловие
Автор этой статьи - адепт 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);
}
}Как это работает:
Получаем хэш типа с помощью встроенного метода Burst.
Получаем размер хэша и самого сообщения.
С помощью атомарной операции увеличиваем
Lengthбуфера и сохраняем указатель на зарезервированный нами участок памяти.Записываем хэш в виде заголовка (header) и сообщение в буфер.
P.S. конкретная реализация не является лучшим решением в плане производительности и может оказаться бутылочным горлышком при массивном использовании, и также имеет ограничение по записи, определяемое ёмкостью буфера. Но на текущий момент ограничимся этим.
Таким образом мы имеем простой потокобезопасный контейнер для по сути любых сообщений. То есть - у нас есть данные. И теперь нам остаётся лишь выполнить все релевантные для этого сообщения системы.
Чтобы полноценно интегрировать разработку в экосистему Unity.Entities я создал специальную системную группу где все эти систему и будут обновляться. Учёв опыт предыдущих решений, группа находится в специальном положении, чтобы не создавать точку сихнронизации.
[UpdateInGroup(typeof(SimulationSystemGroup), OrderFirst = true)]
[UpdateAfter(typeof(BeginSimulationEntityCommandBufferSystem))]
public unsafe partial class NativeEventSystemGroup : ComponentSystemGroupЧтобы корректно распаковать буфер сообщений нам нужно знать все отправляемые сообщения заранее. Есть разные варианты как это сделать через рефлексию. Наиболее универсальным мне показался метод через цикл по системам, созданным в системной группе (причём не важно созданы они встроенным или кастомным загрузчиком).
Я опущу детали конкретной реализации (т.к. это очень много не очень интересного кода) и просто опишу её принцип работы.
Делаем цикл по всем созданным системам в нашей группе.
С помощью рефлексии определяем тип сообщения в системе.
Обеспечиваем систему всеми необходимыми данными для инъекции сообщений для систем.
В конце концов результат выглядит следующим образом, где:
_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;
}Что происходит:
Читаем заголовок из буфера.
Определяем размер сообщения и записываем сообщение в соответствующий буфер.
Повторяем, пока буфер не закончится.
Затем осуществляем цикл по _systemHandles:
Если буфер сообщения, связанный с системой не пуст, значит эту систему нужно обновить.
Прочитать сообщения система может по разному, но конкретно моя реализация сделана по подобию Dependency Injection:
Если это ISystem, то при инициализации мы определяем офсет указателя на поле с сообщением (которое маркируется через аттрибут) и затем просто воспользуясь memcpy пишем сообщение/массив сообщений в это поле.
Если это SystemBase, то просто реализуем абстракный тип EventSystemBase, где нужное поле уже будет создано, а сообщение может быть получено из свойств (property).
Познакомиться с полными исходниками реализации можно тут.
Выводы
Разработанная реализация мессенджера решает поставленную проблему: реализация кастомной логики через сообщения. Для создания логики требуется минимум шаблонного кода. Отправка сообщений возможна из любой точки игровой логики. Кастомная логика может быть использована с Burst и может создавать джобы. В условиях полного отсутствия сообщений система имеет практически нулевой overhead.
Помимо примера с игрой, подобные взаимодействия очень удобны для обработки различных глобальных игровых событий, инпута игрока или осуществления моста между миром ECS и MonoBehaviour.
Размышления на будущее
На текущий момент реализация позволяет подписываться только на конкретные сообщения и делать это могут только ECS системы.
Добавление возможности подписываться любым объектам к сообщениям из конкретного мира ECS существенно расширит потенциал и простоту интеграции гибридных решений.
Также в некоторых случаях может понадобиться сразу несколько мессенджеров, например, если обработчики сообщений должны быть между симуляцией и презентацией.
