Приготовьтесь к тому, что ваши носки снесут
Этот пост является частью серии, в которой я рассказываю о своем опыте создания игрового движка ECS с нуля. Посетите домашнюю страницу этого проекта, чтобы получить дополнительные сообщения, информацию и исходный код.
Почти всему, чем я собираюсь с вами поделиться, я научился в этом сообщении в блоге. Многие из моих постов действительно лишние, если вы их уже читали. Я делаю этот пост для непрерывности своего кода, я не хочу пропустить какой-то раздел в моей серии руководств. Я также постараюсь добавить побольше красивых картинок и гифок, чтобы было легче следить за мной!
Если вам интересно обоснование наличия хранилища SoA (Struct of Array), посмотрите мой предыдущий пост, где я подробно объясню это:
Как и в случае с реализацией AoS (Array of Structs), наш интерфейс будет выглядеть примерно так:
По большей части логика методов будет такая же, как и в реализации AoS. Давайте посмотрим на компонент преобразования:
Transform { int x; int y; int z; }
Если вы помните, для реализации AoS наша структура ComponentData выглядела так:
struct ComponentData { unsigned int size = 1; std::array<ComponentType, 1024> buffer; }
Для SoA это будет примерно так:
struct ComponentData { unsigned int size = 1; std::array<int, 1024> xBuffer; std::array<int, 1024> yBuffer; std::array<int, 1024> zBuffer; }
Проблема здесь не в том, чтобы заставить эту структуру работать только для Transform, а для общего компонента. Мы можем упростить это, сохранив указатели на массивы вместо самих массивов в нашей структуре ComponentData, например:
struct ComponentData { unsigned int size = 1; std::array<int, 1024> * buffer[3]; //x, y, z }
Здесь все становится немного страшнее. Приведенный выше код предполагает две вещи:
- Что наш компонент состоит только из целых чисел
- Что у нашего компонента 3 члена
Чтобы решить первую проблему, нам, к сожалению, придется изменить наши дружественные массивы std :: array на страшные указатели на пустоту. Это означает, что мы собираемся управлять всей нашей памятью самостоятельно, и это должно быть нормально, если мы будем осторожны. Чтобы решить вторую проблему, нам нужно каким-то образом иметь доступ к количеству членов в нашем данном компоненте. Вот наша общая структура ComponentData!
struct ComponentData { unsigned int size = 1; void * buffer[MEMBER_COUNT]; }
Теперь мы куда-то идем!
Конструктор
Давайте начнем с размышлений о конструкторе для нашего диспетчера компонентов - нам нужно выделить буферы для наших членов. Поскольку мы эффективно управляем собственной памятью, мы можем просто использовать malloc (). Итак, наш конструктор будет выглядеть примерно так:
Не беспокойтесь о петле на L13, я объясню это ниже.
Самая сложная часть - это, наверное, петля на L13. Давайте посмотрим на это более внимательно. Когда мы впервые выделяем L10, мы выделяем пространство для всех наших компонентов. Для следующего gif предположим такой компонент:
struct Component { short a; // 2 bytes bool b; // 1 byte long c; // 4 bytes }
Сделайте пару небольших заметок:
- Вы можете подумать, что для
packedComponentSize
мы могли бы просто сделатьsizeof(ComponentType)
, но это проигнорирует заполнение структуры и заставит нас выделить больше памяти, чем нам действительно нужно для реализации. - 1024 здесь - максимальное количество компонентов данного типа. Очевидно, это будет добавлено где-нибудь как конфиг для реальной реализации.
А вот и суть нашей задачи. На данный момент у нас есть два вопросительных знака:
- Мы уже несколько раз упомянули
MEMBER_COUNT
, как на самом деле получить это значение? - Нам также нужен
TYPE_OF_ITH_MEMBER
, как мы узнаем, что это за тип?
Современное решение: структурированные привязки C ++ и magic_get
Если вы используете компилятор с поддержкой структурированных привязок, вы можете использовать библиотеку magic_get для выполнения всех трех задач, используя pfr::tuple_size
и pfr::get
. Это превосходное решение для свойства типа, которое я объясню ниже, но оно будет работать только с современными компиляторами (MSVC еще не поддерживает его).
Другое решение: типовые черты
Если у вас нет роскошных возможностей C ++ 17, мы всегда можем прибегнуть к решению, основанному на свойствах типа.
Мне нравится думать о свойствах типа, что это значения, которые можно привязать к типу. Это похоже на добавление дополнительного статического поля в структуру или класс, но это значение, которое хранится в области компиляции - где бы на него ни ссылались, оно будет заменено значением во время компиляции. Если вы хотите узнать о них больше, это лучшая статья, которую я нашел, чтобы объяснить основы.
Мы можем использовать свойства типов, чтобы предоставить нам нужную информацию, но это приведет к добавлению немного служебных данных в определения наших компонентов. Мы собираемся настроить пару методов для получения характеристик типов от наших компонентов:
Используя их, мы можем «пометить» структуру компонента некоторыми свойствами его членов, что позволит нам создать общий менеджер компонентов для всех компонентов. Теперь для каждого компонента мы сделаем что-то вроде этого:
Давайте воспользуемся нашими новыми характеристиками типа в нашем конструкторе:
Теперь у нас есть только одна проблема: int i
в наших циклах определяется во время выполнения, но нам нужно, чтобы параметры нашего шаблона были определены во время компиляции. Это означает, что мы не можем называть такие вещи, как GetType<ComponentType, i>
. Чтобы решить эту проблему, мы собираемся использовать цикл времени компиляции по методу класса. Я написал сообщение в блоге о его специфике, которое вы можете найти здесь:
Вот как выглядит наш конструктор после добавления циклов:
В нашем примере преобразования скомпилированный код конструктора нашего ComponentManager<Transform>
будет выглядеть примерно так:
Добавлять
Далее идет метод добавления нового компонента в наши буферы. С хранилищем SoA это будет означать примерно следующее:
Давайте посмотрим, как это выглядит в коде:
Мы уже выяснили, как получить MEMBER_COUNT
, а также TYPE_OF_ITH_MEMBER
, но теперь у нас есть новый метод, который можно добавить в смесь: Get_ith_member(i, c)
. Это нужно будет сделать с помощью другого метода, похожего на черту типа. Один из вариантов - использовать offsetof()
, но более чистый способ - использовать указатель на член. Указатель на член x нашей структуры Transform будет выглядеть так:
Теперь нам просто нужно добавить шаблонный метод, чтобы дать нам указатель на член для нашего компонента:
Теперь, когда у нас есть эта часть головоломки, наш метод добавления выглядит примерно так:
Как и в прошлый раз, мы не можем использовать i в качестве параметра шаблона, поэтому наш окончательный код выглядит следующим образом:
В нашем примере Transform скомпилированный код будет делать что-то вроде этого:
Получать
Завершив добавление, мы можем приступить к получению компонента. Помните, что для AoS наш поиск выглядел так:
Чтобы воспользоваться преимуществами когерентности кэша хранилища SoA, мы фактически не хотим перестраивать компонент при доступе к нему. Вместо этого нам действительно нужно разделить наш старый метод поиска на два метода: lookup
и get
. Поиск будет иметь очень простую роль, предоставляя ComponentInstance
для данной сущности, из которой мы можем получать значения членов.
Метод get
- это то, что мы фактически будем использовать для получения значений из ComponentInstance
. Один get
вернет один член компонента (например, getX()
). Вместо того, чтобы просто возвращать значение, мы собираемся вернуть ссылку на это значение. Таким образом, кто-то, кто извлекает x-позицию преобразования, может также установить для него новое значение, не обращаясь к нашему диспетчеру компонентов.
Код, использующий наши новые методы поиска и получения, может выглядеть следующим образом:
Entity player; ComponentManager<Transform> * mgr; ComponentInstance inst = mgr->lookup(player); // Move the player 1 unit forward int & x = mgr->get<0>(inst); x++;
Если этот код кажется неинтуитивным и раздражающим, не волнуйтесь - с помощью дескрипторов мы сделаем этот код более управляемым (в следующей публикации в блоге).
Удалять
Частично согласованность нашего кэша происходит из-за того, что наши данные плотно упакованы. Очевидно, удаляя компонент, мы начинаем оставлять дыры в нашем буфере. Для этого есть несколько решений, но первое, что мы собираемся реализовать для начала, - это просто переместить последний элемент в буфере, чтобы заменить удаленный компонент. Более подробно эта концепция объясняется в Реализации AoS в одном из моих предыдущих постов. Основное отличие состоит в том, что вместо того, чтобы просто перемещать всю структуру, мы должны перемещать каждый член индивидуально.
Код уничтожения нашего компонента будет выглядеть примерно так:
Еще раз, поскольку мы не можем использовать i
во время компиляции, используйте наш цикл класса:
Заключение
В этом посте нам удалось создать интерфейс для нашего диспетчера компонентов Struct of Arrays, который в основном отражает нашу реализацию Array of Structs из одного из моих предыдущих постов. Эта реализация, вероятно, является тем, чем я больше всего горжусь в моем игровом движке, поскольку в моей текущей реализации хранилище SoA / AoS может быть установлено во время компиляции с помощью свойства типа, например:
// Use SoA storage for Transform template<> struct GetStorageType<Transform> { typedef SoA Type; }; // Use AoS storage for Transform (this is also default) template<> struct GetStorageType<Transform> { typedef AoS Type; };
Это обеспечивает разработчикам огромную гибкость с очень небольшими накладными расходами - изменение хранилища буквально так же просто, как изменение SoA на AoS, остальной код остается прежним. Подробности того, как сделать так, чтобы системы SoA и AoS обеспечивали одинаковый интерфейс, будут рассмотрены в одном из следующих постов. Единственный текущий недостаток заключается в том, что без C ++ 17 мы заканчиваем тем, что наши компоненты нуждаются в характеристиках типа для GetType
и GetPointerToMember
для каждого отдельного члена каждого компонента. Однако это можно в значительной степени автоматизировать с помощью макросов, о которых я расскажу в одной из следующих статей. В моей текущей версии ECS определение компонента выглядит примерно так:
struct Transform { int x; int y; int z; } ANNOTATE_COMPONENT(Transform, x, y, z)
Что действительно неплохо, особенно потому, что в C ++ 17 макрос даже не нужен.
Текущий прогресс
Поработав над компоновкой кода моего движка, я взглянул на EntityX, ECS с открытым исходным кодом, и понял, что моя игра действительно должна быть разделена на 3 части:
- ECS, который включает в себя все, что я уже упоминал в своих сообщениях (системы, мир, компоненты, сущности)
- Движок, который обрабатывает рендеринг, анимацию, столкновения и т. Д.
- Игра, которая обрабатывает игровую логику и имеет настраиваемые компоненты и системы, специфичные для игры.
Такое разделение помогло прояснить, чего я пытаюсь достичь с каждым из них, и позволяет мне работать над каждым из них индивидуально, не беспокоясь о других частях. Насколько я понимаю, часть ECS практически завершена. Я доволен тем, как работает система, и (в ожидании более строгого профилирования) считаю, что она очень эффективна. С другой стороны, и движку, и игре предстоит много работы.
Мой план в дальнейшем - придумать очень простой «игровой» уровень - вероятно, один экран, который включает нажатие кнопки и убийство монстра. Я думаю, что создание этого поможет мне понять, какие части игры требуют больше всего работы, чтобы я мог и дальше расставлять приоритеты.