私はデータ指向に取り組んでいますエンティティコンポーネントシステムコンパイル時にコンポーネントタイプとシステムシグネチャがわかっています。
entityはコンポーネントの集合体です。コンポーネントは、実行時にエンティティに追加/エンティティから削除できます。
componentは、ロジックのない小さなクラスです。
signatureは、コンポーネントタイプのコンパイル時リストです。エンティティには、署名に必要なすべてのコンポーネントタイプが含まれている場合、署名に一致すると言われます。
短いコードサンプルは、ユーザー構文がどのように見えるか、および使用目的が何であるかを示します。
// 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
呼び出しのために、キャッシュを無効にして再計算する必要があります。
エンティティを署名に一致させるより速い方法はありますか?
コンパイル時に多くのことがわかっています(コンポーネントタイプのリスト、シグネチャタイプのリスト、...)あります) 「ビットセットのような」マッチングに役立つ、コンパイル時に生成できる補助データ構造はありますか?
マッチングのために、各コンポーネントタイプを一度に1ビットずつチェックしていますか?署名のすべてのコンポーネントがビットマスクに対して1つの命令で使用可能かどうかを確認することで、エンティティを調べることができるはずです。
たとえば、次のように使用します。
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.
}
エンティティが配列に連続して格納されている場合、それは地獄のように速いはずです。一度に3つのコンポーネントタイプをチェックするか、50のコンポーネントタイプをチェックするかは、パフォーマンスの観点からは重要ではありません。私はECSにこのタイプの担当者を使用していませんが、100万のエンティティがある場合でも、これは間違いなく長くはかからないはずです。それは瞬く間に完了するはずです。
最小限の状態を保存しながら、どのエンティティが特定のコンポーネントセットを提供するかを確認することは、実現可能な最も速い実用的な方法です。この担当者を使用しない理由は、ECSが新しいコンポーネントを登録するプラグインアーキテクチャを中心に展開しているためです。プラグインとスクリプトを介した実行時のタイプとシステム。そのため、コンポーネントタイプの数の上限を効果的に予測することはできません。私があなたのようなコンパイル時システムを持っていて、これらすべてを事前に予測するように設計されているなら、間違いなくこれが進むべき道だと思います。一度に1ビットずつチェックしないでください。
上記のソリューションを使用すると、簡単に 100万個のコンポーネントを1秒間に数百回処理できるはずです。多くのピクセルに1秒間に何回も掛けるCPUイメージフィルター処理を適用して同様のレートを達成する人々がいます。これらのフィルターは、反復ごとに1つのbitwise and
と1つのブランチよりもはるかに多くの作業を行います。
また、たとえば100万個のエンティティのうち12個だけを処理したいシステムがない限り、この超安価なシーケンシャルループをキャッシュすることすらしません。ただし、その時点で、1つのシステムが物事を一元的にキャッシュしようとするのではなく、システムのローカルエンティティをほとんど処理しないというまれなシナリオをキャッシュできる可能性があります。関心のあるシステムが、エンティティがシステムに追加されたとき、またはシステムから削除されたときを見つけて、ローカルキャッシュを無効にできることを確認してください。
また、これらのシグニチャに必ずしも凝ったメタプログラミングは必要ありません。結局のところ、エンティティのリストは実行時にしかわからないため、何かをチェックするエンティティをループすることを避けられないため、テンプレートメタプログラミングを使用して実際には何も保存していません。ここでは、コンパイル時に最適化する価値のある理論的なものは実際にはありません。あなたはただのようにすることができます:
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
それぞれの上に。
@MarwanBurelleのアイデアから少し影響を受けた別のオプション。
各コンポーネントは、そのコンポーネントを持つエンティティのソートされたコンテナを保持します。
署名に一致するエンティティを探すときは、エンティティのコンポーネントコンテナを反復処理する必要があります。
追加または削除は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;
}
次の解決策を検討しましたか?各署名には、その署名に一致するエンティティのコンテナがあります。
コンポーネントが追加または削除されたら、関連する署名コンテナを更新する必要があります。
これで、関数は署名エンティティコンテナに移動し、各エンティティに対して関数を実行できます。
署名の一致に対するコンポーネントの追加/削除の比率に応じて、エンティティへの参照を格納する一種のプレフィックスツリーの構築を試みることができます。
ツリー自体は静的であり、エンティティを含むリーフのみがランタイムで構築されたコンテナです。
このように、コンポーネントを追加(または削除)するときは、エンティティの参照を正しいリーフに移動するだけです。
署名に一致するエンティティを検索するときは、署名を含む葉のすべての和集合を取得して、それらを反復処理する必要があります。また、ツリーは(ほぼ)静的であるため、これらの葉を検索する必要もありません。
もう1つの優れた点:ビットセットを使用してツリー内のパスを表すことができるため、エンティティの移動は非常に簡単です。
コンポーネントの数が非現実的な数の葉を誘発し、コンポーネントのすべての組み合わせが使用されているわけではない場合、ツリーを、ビットセットがキーで値がエンティティ参照のセットであるハッシュテーブルに置き換えることができます。
これは具体的なものよりもアルゴリズム的なアイデアですが、エンティティのセットを反復処理するよりも合理的であるように思われます。
擬似コードについて:
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);
}