Наконец, некоторые детали реализации!

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

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

Что такое диспетчер компонентов?

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

Что означает «общая» часть и зачем она нам?

Концепция довольно проста. В каждом диспетчере компонентов хранится массив данных, содержащий набор структур (компонентов). Поскольку очевидно, что все менеджеры компонентов собираются делать по существу одно и то же, из этого естественно следует, что мы должны попытаться написать один набор кода, который будет правильно обрабатывать все компоненты. Создавая этот универсальный диспетчер компонентов, мы также можем делать некоторые действительно изящные вещи, например, создавать общий метод «добавить компонент в сущность», который сможет определить, какой диспетчер компонентов необходимо вызвать. Все эти преимущества можно использовать с помощью шаблонов C ++.

Реализация

Что должен уметь менеджер компонентов?

1. Добавление компонента
2. Доступ к компоненту из определенного объекта
3. Удаление компонента из объекта
4. Итерация по всем элементам

Давайте задумаемся на секунду, какие структуры данных нам понадобятся для обеспечения этой функциональности. Два требования, которые указывают нам на выбор хранилища, - это №2 и №4. Доступ к компоненту с данным идентификатором объекта кричит хэш-картой, где у нас есть идентификатор объекта, указывающий на фактические данные для доступа O (1). №4 предоставляет нам дополнительное требование непрерывного хранения данных в относительно непрозрачном виде.

Наши структуры данных будут представлять собой хеш-карту сверху (entityMap на картинке выше) с базовым массивом данных, которые можно повторять через (componentData). Теперь, когда мы это поняли, давайте посмотрим, как может выглядеть наш новый «универсальный менеджер компонентов»:

(если вы просматриваете это в приложении iOS medium, это будет отображаться как ссылка. Откройте запись в блоге в Safari, чтобы увидеть суть в строке)

Обратите внимание, что мы добавили концепцию ComponentInstance - по сути, это просто int, обозначающий индекс в массиве данных. Добавление этого дает нам немного больше ясности и избавляет нас от вопросов, «что означает этот unsigned int?».

Непрерывный массив данных

Основная причина, по которой мы реализуем диспетчер компонентов, заключается в том, что мы можем хранить наши данные в непрерывном массиве данных. Процессоры отлично справляются с итерацией по фиксированным массивам данных, поэтому объединение всех наших данных означает значительное повышение производительности на ресурсоемких игровых компонентах. Стоит добавить, что мы собираемся выделить эти данные заранее. Это означает, что этот непрерывный массив не будет изменять размер во время работы игр (поскольку изменение размера было бы дорогостоящей операцией, которая потребовала бы копирования тонны данных), поэтому, если мы инициализируем массив с 1024 точками, он останется равным 1024. Это означает, что нам также нужно будет отслеживать «текущий размер» массива, поскольку мы не можем просто опросить array- ›size ().

Для этого мы собираемся обернуть наш массив в структуру, чтобы его было немного легче читать:

Если мы инициализируем массив без компонентов, почему мы начали размер с 1? Это довольно хитрый трюк, который позволит нам в дальнейшем более надежно отлавливать ошибки. Установив размер равным 1, мы знаем, что любой код, который пытается получить доступ к ComponentInstance = 0, вероятно, имеет проблему, и мы можем обнаружить ее на раннем этапе. Если бы у нас не было этой проверки, мы бы случайно вернули любой компонент, который там хранился, что привело бы к гораздо более запутанной ошибке. Таким образом, индекс 0 становится эквивалентом индекса -1 (если бы мы использовали целые числа вместо целых чисел без знака) - если мы пытаемся получить к нему доступ или вернуть его, вероятно, что-то не существует или пошло не так.

Боковое примечание: я на самом деле подумываю о переходе на целые числа с целых чисел без знака, поэтому я использую один и тот же тип повсюду и могу подавлять предупреждения компилятора, не выполняя все приведение. А пока я оставлю код в этом посте, чтобы проиллюстрировать мою точку зрения.

Вы заметите, что мы используем std :: array вместо std :: vector или std :: list. Это почему?

Почему мы не используем std :: vector?

Поскольку у нас есть карта поверх массива (ссылающаяся на индексы в массиве), нам нужно, чтобы карта оставалась в основном действительной после удаления из массива. Если мы используем std :: vector и затем нам нужно удалить наш первый компонент (vec.erase (vec.begin ())), все наши индексы сдвигаются (vec [2] становится vec [1] и т. Д.). Это аннулирует всю нашу карту и потребует полного обновления карты.

Почему мы не используем std :: list?

std :: list - это связанный список, который на самом деле не соответствует нашим требованиям из-за того, как работают удаления (аналогично std :: vector), и не предназначен для произвольного доступа по индексу.
Итак, мы заканчиваем с желанием массивов в стиле C, а std :: array дает нам это с небольшим синтаксическим сахаром и проверкой границ (через .at ()). На самом деле нет разницы в производительности между std :: array и обычными массивами в стиле C, поэтому нет ничего плохого в получении дополнительной безопасности с помощью std :: array.

Здесь может проявляться мое незнание C ++ (это мой первый настоящий проект), поэтому, если есть лучший способ сделать это, дайте мне знать в комментариях :)

Добавление компонента

При добавлении компонента нам просто нужно убедиться, что обе наши структуры данных правильно обновлены. Нам нужно добавить компонент в конец нашего списка, а также добавить отображение Entity в индекс в списке. Это выглядит примерно так:

Доступ к компоненту

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

Удаление компонента

Удаление компонента может показаться простым, но реализация более сложна, чем просто array.erase (). Мы должны помнить, что имеем дело со статическим массивом, а не с вектором. Если бы мы просто стирали индексы из массива всякий раз, когда они удалялись, у нас были бы дыры во время работы движка и удаления элементов. Это не только разрушает наше представление о «плотно упакованном массиве», но также в конечном итоге переполняет наш массив и оставляет нас в грязи.

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

Вот как это будет выглядеть:

А вот код для его реализации:

Обратите внимание, что на самом деле нам не нужно «очищать» теперь реплицированный компонент по адресу componentData [componentData.size - 1], поскольку он будет перезаписан следующим добавляемым компонентом.

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

Перебрать все элементы

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

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

//Every tick
component.position += component.velocity;
component.velocity += component.acceleration;

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

Итак, чтобы сделать то, что мы написали выше, нам просто нужно позвонить:

componentManager.iterateAll([](positionComponent c){
  c.position += c.velocity;
  c.velocity += c.acceleration;
}

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

Текущий прогресс

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

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