Простейшая реализация Entity Component System

Всем привет!

У нас стартует четвёртый поток «Разработчик C++», один из самых активных курсов у нас, если судить по реальным встречам, где для того чтобы пообщаться с Димой Шебордаевым приходят далеко не только «крестоносцы» 🙂 Ну и вообще в целом курс уже разросся до одного из крупнейших у нас, осталось неизменным то, что Дима проводит открытые уроки и мы подбираем интересные материалы перед стартом курса.

Поехали!

Вступление

Entity Component System (ECS, «сущность-компонент-система») — сейчас на пике популярности в качестве архитектурной альтернативы, которая подчеркивает принцип Composition over inheritance. В этой статье я не буду вдаваться в подробности концепции, так как уже существует достаточно ресурсов на эту тему. Есть множество способов имплементации ECS, и, я но, чаще всего, выбирают довольно сложные, которые способны запутать новичков и требуют много времени.

В этом посте я опишу очень простой способ имплементации ECS, функциональная версия которого почти не требует кода, но полностью следует концепции.

ECS

Говоря о ECS, люди зачастую подразумевают разные вещи. Когда я говорю об ECS, я имею в виду систему, которая позволяет определять сущности, имеющие ноль и более компонентов pure data. Эти компоненты выборочно обрабатываются системами pure logic. Например, к сущности E привязаны позиция, скорость, хитбокс и здоровье компонента. Они просто хранят в себе данные. Например, компонент здоровья может хранить два целых числа: одно для текущего здоровья и второе для максимального. Система может представлять собой систему регенерации здоровья, которая находит все инстансы компонента здоровья и увеличивает их на 1 каждые 120 кадров.

Типичная реализация на C++

Существует множество библиотек, предлагающих имплементации ECS. Обычно, они включают в себя один и более пунктов из списка:

  • Наследование базового Component/System класса GravitySystem : public ecs::System;
  • Активное использование шаблонов;
  • И то, и другое в некотором CRTP виде;
  • Класс EntityManager, который управляет созданием/хранением сущностей неявным способом.

Несколько быстрых примеров из гугла:

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

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

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

Мой простой подход

Сущность

В некоторых подходах определяется класс Entity, в других же работают с сущностями как ID/handle. В компонентном подходе сущность — не что иное, как компоненты, с ней связанные, и для этого класс не нужен. Сущность будет явно существовать, основываясь на связанных с ней компонентах. Для этого определим:

using EntityID = int64_t; //только для целей этой статьи, int64_t - произвольный выбор

Компоненты Entity

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

Итак, я определяю компоненты следующим образом:

struct Position {     float x;     float y; };  struct Velocity {     float x;     float y; };  struct Health {     int max;     int current; };  template <typename Type> using ComponentMap = std::unordered_map<EntityID, Type>;  using Positions = ComponentMap<Position>; using Velocities = ComponentMap<Velocity>; using Healths = ComponentMap<Health>;  struct Components {     Positions positions;     Velocities velocities;     Healths healths; };

Этого достаточно, чтобы обозначить сущности через компоненты, как и ожидалось от ECS. Например, чтобы создать сущность с позицией и здоровьем, но без скорости, нужно:

//given a Components instance c EntityID newID = /*obtain new entity ID*/; c.positions[newID] = Position{0.0f, 0.0f}; c.healths[newID] = Health{100, 100}; 

Чтобы уничтожить сущность с заданным ID, мы просто .erase() её с каждой карты.

Системы

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

void updateHealthRegeneration(int64_t currentFrame, Healths& healths) {     if(currentFrame % 120 == 0)     {         for(auto& [id, health] : healths)         {             if(health.current < health.max)                 ++health.current;         }     } } 

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

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

void updatePhysics(Positions& positions, const Velocities& velocities) {     //это шаблон вариативной функции, который берет N карт и     // возвращает набор ID, присутствующих на всех картах.      std::unordered_set<EntityID> targets = mapIntersection(positions, velocities);      //теперь target’ы будут содержать только те записи, в которых     //есть и позиция, и скорость для безопасного доступа к картам.     for(EntityID id : targets)     {         Position& pos = positions.at(id);         const Velocity& vel = velocities.at(id);          pos.x += vel.x;         pos.y += vel.y;     } }

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

void updatePhysics(Positions& positions, const Velocities& velocities) {     //это шаблон вариативной функции, который определит пересечение     //ключей на картах. Он проитерирует эти ключи и передаст данные     //из карт напрямую данному функтору.     intersectionInvoke<Position, Velocity>(positions, velocities,         [] (EntityID id, Position& pos, const Velocity& vel)         {             pos.x += vel.x;             pos.y += vel.y;         }     ); }

Таким образом, мы ознакомились с базовым функционалом обычной ECS.

Преимущества

Этот подход очень эффективен, так как он строится с нуля, не ограничивая абстракции. Вам не придется интегрировать внешние библиотеки или адаптировать кодовую базу под предопределенные идеи того, какими должны быть Сущности/Компоненты/Системы.
А поскольку такой подход полностью прозрачен, на его основе вы можете создавать любые утилиты и хелперы. Такая реализация растет вместе с нуждами вашего проекта. Скорее всего, для простых прототипов или игр для game jam’ов, вам будет достаточно функционала, описанного выше.

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

Ограничения

Но, как и в любом другом методе, здесь есть некоторые ограничения. По моему опыту, именно такая реализация с использованием unordered_map в любой нетривиальной игре приведет к проблемам производительности.

Итерация пересечения ключей на нескольких инстансах unordered_map с множеством сущностей плохо масштабируется, поскольку вы фактически делаете N*M операций поиска, где N — количество пересекающихся компонентов, M — количество совпадающих сущностей, а unordered_map не очень хорошо дружит с кэшированием. Такую проблему можно устранить, используя более подходящее для итерирования хранилище ключ-значение вместо unordered_map.

Еще одно ограничение — boilerplating. В зависимости от того, что вы делаете, определение новых компонентов может стать утомительным. Возможно потребуется добавить объявление не только в структуре Components, но и в функции spawn’а, сериализации, утилитые функции отладки и тд. Я столкнулся с этим сам и решил проблему при помощи генерации кода — я определял компоненты во внешних файлах json, а затем генерировал C++ компоненты и функции-хелперы на этапе сборки. Уверен, вы сможете найти другие способы на основе шаблонов для устранения любых boilerplate-проблем, с которыми вы столкнетесь.

THE END

Если есть вопросы и комментарии, то можно оставить их тут или зайти на открытый урок к Диме, послушать его и поспрашивать уже там.

FavoriteLoadingДобавить в избранное
Posted in Без рубрики

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *