Приготовьтесь к тому, что ваши носки снесут

Этот пост является частью серии, в которой я рассказываю о своем опыте создания игрового движка 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
}

Здесь все становится немного страшнее. Приведенный выше код предполагает две вещи:

  1. Что наш компонент состоит только из целых чисел
  2. Что у нашего компонента 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
}

Сделайте пару небольших заметок:

  1. Вы можете подумать, что для packedComponentSize мы могли бы просто сделать sizeof(ComponentType), но это проигнорирует заполнение структуры и заставит нас выделить больше памяти, чем нам действительно нужно для реализации.
  2. 1024 здесь - максимальное количество компонентов данного типа. Очевидно, это будет добавлено где-нибудь как конфиг для реальной реализации.

А вот и суть нашей задачи. На данный момент у нас есть два вопросительных знака:

  1. Мы уже несколько раз упомянули MEMBER_COUNT, как на самом деле получить это значение?
  2. Нам также нужен 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 части:

  1. ECS, который включает в себя все, что я уже упоминал в своих сообщениях (системы, мир, компоненты, сущности)
  2. Движок, который обрабатывает рендеринг, анимацию, столкновения и т. Д.
  3. Игра, которая обрабатывает игровую логику и имеет настраиваемые компоненты и системы, специфичные для игры.

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

Мой план в дальнейшем - придумать очень простой «игровой» уровень - вероятно, один экран, который включает нажатие кнопки и убийство монстра. Я думаю, что создание этого поможет мне понять, какие части игры требуют больше всего работы, чтобы я мог и дальше расставлять приоритеты.

Дополнительная литература / ссылки