Подтвердить что ты не робот

Эффективный способ сопоставления объектов с системами в системе компонентов сущностей

Я работаю над ориентированной на данные системой компонентом сущностей, где типы компонентов и системные подписи известны во время компиляции.


Объект - это совокупность компонентов. Компоненты могут быть добавлены/удалены из объектов во время выполнения.

Компонент - это небольшой класс без логических элементов.

A подпись - это список типов компонентов компиляции. Сообщается, что сущность соответствует сигнатуре, если она содержит все типы компонентов, необходимые для подписи.


Пример короткого кода покажет вам, как выглядит синтаксис пользователя и каково его предполагаемое использование:

// User-defined component types.
struct Comp0 : ecs::Component { /*...*/ };
struct Comp1 : ecs::Component { /*...*/ };
struct Comp2 : ecs::Component { /*...*/ };
struct Comp3 : ecs::Component { /*...*/ };

// User-defined system signatures.
using Sig0 = ecs::Requires<Comp0>;
using Sig1 = ecs::Requires<Comp1, Comp3>;
using Sig2 = ecs::Requires<Comp1, Comp2, Comp3>;

// Store all components in a compile-time type list.
using MyComps = ecs::ComponentList
<
    Comp0, Comp1, Comp2, Comp3
>;

// Store all signatures in a compile-time type list.
using MySigs = ecs::SignatureList
<
    Sig0, Sig1, Sig2
>;

// Final type of the entity manager.
using MyManager = ecs::Manager<MyComps, MySigs>;

void example()
{
    MyManager m;

    // Create an entity and add components to it at runtime.
    auto e0 = m.createEntity();
    m.add<Comp0>(e0);
    m.add<Comp1>(e0);
    m.add<Comp3>(e0);

    // Matches.
    assert(m.matches<Sig0>(e0));

    // Matches.
    assert(m.matches<Sig1>(e0));

    // Doesn't match. (`Comp2` missing)
    assert(!m.matches<Sig2>(e0));

    // Do something with all entities matching `Sig0`.
    m.forEntitiesMatching<Sig0>([](/*...*/){/*...*/}); 
}

В настоящее время я проверяю, соответствуют ли сущности подписям, используя операции std::bitset. Однако производительность быстро ухудшается, как только увеличивается количество подписей и количество объектов.

псевдокод:

// m.forEntitiesMatching<Sig0>
// ...gets transformed into...

for(auto& e : entities)
    if((e.bitset & getBitset<Sig0>()) == getBitset<Sig0>())
        callUserFunction(e);

Это работает, но если пользователь вызывает forEntitiesMatching с одной и той же сигнатурой несколько раз, все объекты должны быть снова сопоставлены.

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

Я попытался использовать какой-то кеш, который создает карту времени компиляции (реализованную как std::tuple<std::vector<EntityIndex>, std::vector<EntityIndex>, ...>), где ключи являются типами подписи (каждый тип подписи имеет уникальный инкрементный индекс благодаря SignatureList), и значения являются векторами индексов сущностей.

Я заполнил кеш-кортеж чем-то вроде:

// Compile-time list iterations a-la `boost::hana`.
forEveryType<SignatureList>([](auto t)
{
    using Type = decltype(t)::Type;
    for(auto entityIndex : entities)
        if(matchesSignature<Type>(e))
            std::get<idx<Type>()>(cache).emplace_back(e);
});

И очистил его после каждого цикла обновления менеджера.

К сожалению, он выполнялся медленнее, чем "сырой" цикл, показанный выше во всех моих тестах. Он также имел бы большую проблему: что, если вызов forEntitiesMatching действительно удаляет или добавляет компонент к сущности? Кэш должен быть недействительным и пересчитан для последующих вызовов forEntitiesMatching.


Есть ли более быстрый способ сопоставления сущностей с сигнатурами?

Во время компиляции известно много вещей (список типов компонентов, список типов подписи...) - существует ли какая-либо вспомогательная структура данных, которая может быть сгенерирована во время компиляции, что помогло бы с "bitet-like" соответствие?

4b9b3361

Ответ 1

Наличие для типа подписи - это оптимальное решение теоретически (с точки зрения временной сложности).

Структура данных разреженного целочисленного набора позволяет эффективно проводить непрерывную итерацию O(N) по сохраненным целым числам, O(1) вставка/удаление целых чисел и O(1) запрос для определенного целого числа.

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

Пример: Diana, библиотека C и С++ с открытым исходным кодом, использует разреженный целочисленный набор для отслеживания сущностей в системы. Каждая система имеет свой собственный разреженный экземпляр целочисленного набора.

Ответ 2

Рассматривали ли вы следующее решение? Каждая подпись будет иметь контейнер объектов, соответствующих этой сигнатуре.

Когда компонент добавлен или удален, вам необходимо обновить соответствующий контейнер подписи.

Теперь функция может просто перейти к контейнеру сущности подписи и выполнить функцию для каждого объекта.

Ответ 3

В зависимости от отношения добавления/удаления компонентов к сопоставлению подписи вы можете попытаться создать дерево префикса, сохраняющее ссылки на сущности.

Само дерево является статическим, только листья, содержащие сущности, являются встроенными контейнерами времени выполнения.

Таким образом, при добавлении (или удалении) компонентов вам просто нужно переместить ссылку объекта на правильный лист.

При поиске сущностей, соответствующих сигнатуре, вам просто нужно взять все объединение листьев, подпадающих под подпись, и перебрать их. И поскольку дерево (почти) статично, вам даже не нужно искать эти листья.

Еще одна приятная точка: вы можете использовать битрейт для представления пути в дереве, поэтому перемещение объекта довольно просто.

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

Это более алгоритмические идеи, чем конкретные вещи, но это кажется более разумным, чем повторение множества объектов.

Ответ 4

Другая опция, которая немного зависит от идеи @Marwan Burelle.

Каждый компонент будет содержать отсортированный контейнер объектов, которые имеют этот компонент.

При поиске объектов, которые соответствуют сигнатуре, вам необходимо выполнить итерацию контейнера компонентов объектов.

Добавление или удаление - это O (nlogn), поскольку его нужно сортировать. но вам нужно только добавить/удалить его в/из одного контейнера, который также будет содержать меньше элементов.

Итерация по элементам немного тяжелее, поскольку она является фактором количества компонентов и количества объектов в каждом компоненте. У вас все еще есть элемент умножения, но количество элементов снова меньше.

Я записал упрощенную версию как POC.

Изменить: у моей предыдущей версии были некоторые ошибки, теперь, надеюсь, они исправлены.

// Example program
#include <iostream>
#include <string>
#include <set>
#include <map>
#include <vector>
#include <functional>
#include <memory>
#include <chrono>

struct ComponentBase
{
};

struct Entity
{
    Entity(std::string&& name, uint id)
        : _id(id),
        _name(name)
    {
    }

    uint _id;
    std::string _name;
    std::map<uint, std::shared_ptr<ComponentBase>> _components;
};

template <uint ID>
struct Component : public ComponentBase
{
    static const uint _id;
    static std::map<uint, Entity*> _entities;
};

template <uint ID>
std::map<uint, Entity*> Component<ID>::_entities;

template <uint ID>
const uint Component<ID>::_id = ID;

using Comp0 = Component<0>;
using Comp1 = Component<1>;
using Comp2 = Component<2>;
using Comp3 = Component<3>;

template <typename ...TComponents>
struct Enumerator 
{
};

template <typename TComponent>
struct Enumerator<TComponent>
{
    std::map<uint, Entity*>::iterator it;
    Enumerator()
    {
        it = TComponent::_entities.begin();
    }

    bool AllMoveTo(Entity& entity)
    {
        while (HasNext() && Current()->_id < entity._id)
        {
            MoveNext();
        }

        if (!Current())
            return false;
        return Current()->_id == entity._id;
    }

    bool HasNext() const
    {
        auto it_next = it;
        ++it_next;
        bool has_next = it_next != TComponent::_entities.end();
        return has_next;
    }

    void MoveNext()
    {
        ++it;
    }

    Entity* Current() const
    {
        return it != TComponent::_entities.end() ? it->second : nullptr;
    }
};

template <typename TComponent, typename ...TComponents>
struct Enumerator<TComponent, TComponents...>
{
    std::map<uint, Entity*>::iterator it;
    Enumerator<TComponents...> rest;

    Enumerator()
    {
        it = TComponent::_entities.begin();
    }

    bool AllMoveTo(Entity& entity)
    {
        if (!rest.AllMoveTo(entity))
            return false;

        while (HasNext() && Current()->_id < entity._id)
        {
            MoveNext();
        }
        if (!Current())
            return false;
        return Current()->_id == entity._id;
    }

    bool HasNext() const
    {
        auto it_next = it;
        ++it_next;
        bool has_next = it_next != TComponent::_entities.end();
        return has_next;
    }

    void MoveNext()
    {
        ++it;
    }

    Entity* Current() const
    {
        return it != TComponent::_entities.end() ? it->second : nullptr;
    }
};

template <typename ...TComponents>
struct Requires
{

};

template <typename TComponent>
struct Requires<TComponent>
{
    static void run_on_matching_entries(const std::function<void(Entity&)>& fun)
    {
        for (Enumerator<TComponent> enumerator; enumerator.Current(); enumerator.MoveNext())
        {
            if (!enumerator.AllMoveTo(*enumerator.Current()))
                continue;
            fun(*enumerator.Current());
        }
    }
};

template <typename TComponent, typename ...TComponents>
struct Requires<TComponent, TComponents...>
{
    static void run_on_matching_entries(const std::function<void(Entity&)>& fun)
    { 
        for (Enumerator<TComponent, TComponents...> enumerator; enumerator.Current(); enumerator.MoveNext())
        {
            if (!enumerator.AllMoveTo(*enumerator.Current()))
                continue;
            fun(*enumerator.Current());
        }
    }
};

using Sig0 = Requires<Comp0>;
using Sig1 = Requires<Comp1, Comp3>;
using Sig2 = Requires<Comp1, Comp2, Comp3>;

struct Manager
{
    uint _next_entity_id;

    Manager()
    {
        _next_entity_id = 0;
    }

    Entity createEntity() 
    { 
        uint id = _next_entity_id++;
        return Entity("entity " + std::to_string(id), id); 
    };

    template <typename Component>
    void add(Entity& e)
    {
        e._components[Component::_id] = std::make_shared<Component>();
        Component::_entities.emplace(e._id, &e);
    }

    template <typename Component>
    void remove(Entity& e)
    {
        e._components.erase(Component::_id);
        Component::_entities.erase(e._id);
    }

    template <typename Signature>
    void for_entities_with_signature(const std::function<void(Entity&)>& fun)
    {
        Signature::run_on_matching_entries(fun);
    }

};

int main()
{
    Manager m;
    uint item_count = 100000;
    std::vector<Entity> entities;
    for (size_t item = 0; item < item_count; ++item)
    {
        entities.push_back(m.createEntity());
    }

    for (size_t item = 0; item < item_count; ++item)
    {
        //if (rand() % 2 == 0)
            m.add<Comp0>(entities[item]);
        //if (rand() % 2 == 0)
            m.add<Comp1>(entities[item]);
        //if (rand() % 2 == 0)
            m.add<Comp2>(entities[item]);
        //if (rand() % 2 == 0)
            m.add<Comp3>(entities[item]);
    }

    size_t sig0_count = 0;
    size_t sig1_count = 0;
    size_t sig2_count = 0;

    auto start = std::chrono::system_clock::now();
    m.for_entities_with_signature<Sig0>([&](Entity& e) { ++sig0_count; });    
    m.for_entities_with_signature<Sig1>([&](Entity& e) { ++sig1_count; });
    m.for_entities_with_signature<Sig2>([&](Entity& e) { ++sig2_count; });

    auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::system_clock::now() - start);
    std::cout << "first run took " << duration.count() << " milliseconds: " << sig0_count << " " << sig1_count << " " << sig2_count << std::endl;

    for (size_t item = 0; item < item_count; ++item)
    {
        if (rand() % 2 == 0)
            m.remove<Comp0>(entities[item]);
        if (rand() % 2 == 0)
            m.remove<Comp1>(entities[item]);
        if (rand() % 2 == 0)
            m.remove<Comp2>(entities[item]);
        if (rand() % 2 == 0)
            m.remove<Comp3>(entities[item]);
    }

    sig0_count = sig1_count = sig2_count = 0;

    start = std::chrono::system_clock::now();
    m.for_entities_with_signature<Sig0>([&](Entity& e) { ++sig0_count; });    
    m.for_entities_with_signature<Sig1>([&](Entity& e) { ++sig1_count; });
    m.for_entities_with_signature<Sig2>([&](Entity& e) { ++sig2_count; });

    duration = std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::system_clock::now() - start);
    std::cout << "second run took " << duration.count() << " milliseconds: " << sig0_count << " " << sig1_count << " " << sig2_count << std::endl;
}

Ответ 5

Что касается псевдокода:

for(auto& e : entities)
    for(const auto& s : signatures)
         if((e.bitset & s.bitset) == s.bitset)
             callUserFunction(e);

Я не уверен, зачем нужен внутренний цикл.

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

template <typename T>
void forEntitiesMatching(const std::function<void(Entity& e)>& fun)
{
    for(auto& e : entities)
        if((e.bitset & T::get_bitset()) == T::get_bitset())
             fun(e);
}

Ответ 6

Для сопоставления проверяете ли вы каждый тип компонента по одному биту за раз? Вы должны иметь возможность вспахивать объекты, проверяя, доступны ли все компоненты для подписи в одной инструкции против битовой маски.

Например, мы можем использовать:

const uint64_t signature = 0xD; // 0b1101

... проверить наличие компонентов 0, 1 и 3.

for(const auto& ent: entities)
{
     if (ent.components & signature)
          // the entity has components 0, 1, and 3.
}

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

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

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

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

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

static const uint64_t motion_id = 1 << 0;
static const uint64_t sprite_id = 1 << 1;
static const uint64_t sound_id = 1 << 2;
static const uint64_t particle_id = 1 << 3;
...

// Signature to check for entities with motion, sprite, and 
// particle components.
static const uint64_t sig = motion_id | sprite_id | particle_id;

Если вы используете сущности-связанные биты, чтобы указать, какие компоненты имеют сущность, я рекомендую установить верхнюю границу общего количества типов компонентов, которые может обрабатывать ваша система (например: 64, вероятно, много, 128 - это лодка), так что вы можете просто проверить компоненты на битмаски, подобные этим за один раз.

[...] Что делать, если вызов forEntitiesMatching фактически удаляет или добавляет компонента для объекта?

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

Наихудший сценарий циклизации через все сущности последовательно - это если у вас есть система, которая, скажем, будет обрабатывать только 3% этих объектов. Если в вашем дизайне вашего двигателя есть такие системы, то это немного неудобно, но вы можете просто уведомить их, когда компоненты добавляются/удаляются, что они специально интересуются, в какой момент они могут аннулировать свой кэш объектов, а затем повторно использовать при следующем запуске системы Надеюсь, у вас нет системы, добавляющей/удаляющей компоненты по одному кадру типов, которые составляют 3% меньшинства компонентов. Если у вас есть сценарий с наихудшим сценарием, возможно, лучше вообще не беспокоиться о кешировании. Нет никакой пользы для кеша, который просто собирается отбросить каждый фрейм и попытаться обновить его причудливым способом, вероятно, не собирается вас много тратить.

Другие системы, которые, скажем, обрабатывают 50% или более объектов, вероятно, не должны даже беспокоить кэширование, так как уровень косвенности, вероятно, не стоит того, чтобы просто вспахивать через все сущности последовательно и делать грязь дешевой bitwise and над каждым.