ECS库的本机实现

图片

本周,我开始研究Vagabond引擎,并开始实现实体组件系统模板。

在本文中,我想谈一谈我的实现,该实现可在GitHub上免费获得。 但是,我不仅要对代码进行注释,还要解释其结构是如何设计的。 因此,我将从编写的第一个实现开始,分析其优缺点,然后说明如何改进它。 最后,我将列出也可以改进的方面。

引言


动机


我不会谈论ECS优于面向对象方法的好处,因为在我之前的很多人都做得很好。 斯科特·比拉斯(Scott Bilas)是2002年GDC上第一个谈论ECS的人。 该主题的其他引人注目的介绍包括Mike West的Evolve Your Hierarchy和Robert Nistrom令人惊叹的Game Programming Patterns书中的Components章节。

简而言之,我将说ECS的任务是为游戏实体创建一种面向数据的方法,并方便地将数据与逻辑分离。 实体由包含数据的组件组成。 包含逻辑的系统将处理这些组件。

如果您要详细介绍,ECS中将使用composition(组合) ,而不是继承 。 此外,这种面向数据的方法可以更好地利用缓存,这意味着它可以实现出色的性能。

例子


在深入研究代码之前,我想向您展示我们将要设计的内容。

分配组件非常简单:

struct Position : public Component<Position> { float x; float y; }; struct Velocity : public Component<Velocity> { float x; float y; }; 

如您所见,我们将使用CRTP模板。

然后,出于技术原因(我将在后面解释),我们需要确定组件数和系统数:

 constexpr auto ComponentCount = 32; constexpr auto SystemCount = 8; 

接下来,您可以指定一个系统,该系统将接收同时具有两个组件的所有实体并更新其位置:

 class PhysicsSystem : public System<ComponentCount, SystemCount> { public: PhysicsSystem(EntityManager<ComponentCount, SystemCount>& entityManager) : mEntityManager(entityManager) { setRequirements<Position, Velocity>(); } void update(float dt) { for (const auto& entity : getManagedEntities()) { auto [position, velocity] = mEntityManager.getComponents<Position, Velocity>(entity); position.x += velocity.x * dt; position.y += velocity.y * dt; } } private: EntityManager<ComponentCount, SystemCount>& mEntityManager; }; 

系统仅使用setRequirements方法来setRequirementssetRequirements组件。 然后,在update方法中,它可以调用getManagedEntities来迭代遍历所有满足要求的实体。

最后,让我们创建一个实体管理器,注册组件,创建一个系统和几个实体,然后使用该系统更新它们的位置:

 auto manager = EntityManager<ComponentCount, SystemCount>(); manager.registerComponent<Position>(); manager.registerComponent<Velocity>(); auto system = manager.createSystem<PhysicsSystem>(manager); for (auto i = 0; i < 10; ++i) { auto entity = manager.createEntity(); manager.addComponent<Position>(entity); manager.addComponent<Velocity>(entity); } auto dt = 1.0f / 60.0f; while (true) system->update(dt); 

基准测试


我不会假装创建最好的ECS库。 我只是想自己写。 另外,我只做了一个星期。

但是,这不是制造完全无效的原因。 因此,让我们安装基准测试:

  • 第一个将创建实体;第二个将创建实体。
  • 第二个将使用该系统迭代遍历实体;
  • 后者将创建并销毁实体;

所有这些基准测试的参数是实体数,每个实体的组件数,最大组件数和最大系统数。 通过这种方式,我们可以看到我们的实现扩展的程度。 特别是,我将显示三种不同配置文件的结果:

  • 配置文件A:32个组件和16个系统;
  • AA配置文件:128个组件和32个系统;
  • AAA配置文件:512个组件和64个系统。

尽管这些基准将使我们对执行质量有所了解,但它们非常简单。 例如,在这些基准测试中,我们仅使用同类实体,并且它们的组成很小。

实作


精华液


在我的实现中,实体只是一个ID:

 using Entity = uint32_t; 

此外,在Entity.h中,我们还将定义一个别名Index ,稍后将派上用场:

 using Index = uint32_t; static constexpr auto InvalidIndex = std::numeric_limits<Index>::max(); 

我决定使用uint32_t代替64位类型或std::size_t来节省空间并改善缓存优化。 我们不会损失那么多:某人拥有数十亿个实体的可能性不大。

组成部分


现在让我们定义组件的基类:

 template<typename T, auto Type> class Component { public: static constexpr auto type = static_cast<std::size_t>(Type); }; 

模板类非常简单,它只存储类型ID,我们稍后将使用该ID根据组件的类型为数据结构建立索引。

第一个模板参数是组件的类型。 第二个是转换为std::size_t的值,它将用作组件类型id。

例如,我们可以如下定义Position组件:

 struct Positon : Component<Position, 0> { float x; float y; }; 

但是,枚举可能更方便:

 enum class ComponentType { Position }; struct Positon : Component<Position, ComponentType::Position> { float x; float y; }; 

在介绍性示例中,只有一个模板参数:我们不需要手动指定类型ID。 稍后我们将看到如何改善结构并自动生成类型标识符。

实体容器


EntityContainer类将负责管理实体和每个实体的std::bitset 。 这组位将指示实体拥有的组件。

因为我们将使用实体来索引容器,尤其是std::vector ,所以我们需要id尽可能小并占用更少的内存。 因此,我们将重用被破坏实体的ID。 为此,免费ID将存储在名为mFreeEntities的容器中。

这是EntityContainer

 template<std::size_t ComponentCount, std::size_t SystemCount> class EntityContainer { public: void reserve(std::size_t size); std::vector<std::bitset<ComponentCount>>& getEntityToBitset(); const std::bitset<ComponentCount>& getBitset(Entity entity) const; Entity create(); void remove(Entity entity); private: std::vector<std::bitset<ComponentCount>> mEntityToBitset; std::vector<Entity> mFreeEntities; }; 

让我们看看这些方法是如何实现的。

getEntityToBitsetgetBitset是通常的小吸气剂:

 std::vector<std::bitset<ComponentCount>>& getEntityToBitset() { return mEntityToBitset; } const std::bitset<ComponentCount>& getBitset(Entity entity) const { return mEntityToBitset[entity]; } 

create方法更有趣:

 Entity create() { auto entity = Entity(); if (mFreeEntities.empty()) { entity = static_cast<Entity>(mEntityToBitset.size()); mEntityToBitset.emplace_back(); } else { entity = mFreeEntities.back(); mFreeEntities.pop_back(); mEntityToBitset[entity].reset(); } return entity; } 

如果有自由实体,他将重用它。 否则,该方法将创建一个新实体。

remove方法只是在mFreeEntities添加要删除的实体:

 void remove(Entity entity) { mFreeEntities.push_back(entity); } 

最后一种方法是reserve 。 它的任务是为各种容器保留内存。 如我们所知,分配内存是一项昂贵的操作,因此,如果我们大约知道游戏中未来实体的数量,那么预留内存将加快工作:

 void reserve(std::size_t size) { mFreeEntities.resize(size); std::iota(std::begin(mFreeEntities), std::end(mFreeEntities), 0); mEntityToBitset.resize(size); } 

除了简单的内存备份外,它还会填充mFreeEntities

组件容器


ComponentContainer类将负责存储指定类型的所有组件。

在我的体系结构中,给定类型的所有组件都存储在一起。 也就是说,每种类型的组件都有一个大数组,称为mComponents

另外,为了能够在恒定时间内添加,接收或从实体中删除组件,我们需要一种从实体移动到组件以及从组件移动到实体的方法。 为此,我们还需要两个名为mComponentToEntitymEntityToComponent数据结构。

这是ComponentContainer声明:

 template<typename T, std::size_t ComponentCount, std::size_t SystemCount> class ComponentContainer : public BaseComponentContainer { public: ComponentContainer(std::vector<std::bitset<ComponentCount>>& entityToBitset); virtual void reserve(std::size_t size) override; T& get(Entity entity); const T& get(Entity entity) const; template<typename... Args> void add(Entity entity, Args&&... args); void remove(Entity entity); virtual bool tryRemove(Entity entity) override; Entity getOwner(const T& component) const; private: std::vector<T> mComponents; std::vector<Entity> mComponentToEntity; std::unordered_map<Entity, Index> mEntityToComponent; std::vector<std::bitset<ComponentCount>>& mEntityToBitset; }; 

您可以看到它继承自BaseComponentContainer ,其设置如下:

 class BaseComponentContainer { public: virtual ~BaseComponentContainer() = default; virtual void reserve(std::size_t size) = 0; virtual bool tryRemove(Entity entity) = 0; }; 

该基类的唯一目的是能够在容器中存储ComponentContainer所有实例。

现在让我们看一下方法的定义。

首先,考虑构造函数:它获得对包含实体位集合的容器的引用。 此类将使用它来检查实体中组件的存在,并在添加或删除组件时更新实体的位集:

 ComponentContainer(std::vector<std::bitset<ComponentCount>>& entityToBitset) : mEntityToBitset(entityToBitset) { } 

get方法很简单,我们只使用mEntityToComponentmEntityToComponent中查找实体组件的mComponents

 T& get(Entity entity) { return mComponents[mEntityToComponent[entity]]; } 

add方法使用其参数在mComponents的末尾插入新组件,然后准备从实体到组件以及从组件到实体的链接。 最后,它将与组件匹配的entity位集中的位设置为true

 template<typename... Args> void add(Entity entity, Args&&... args) { auto index = static_cast<Index>(mComponents.size()); mComponents.emplace_back(std::forward<Args>(args)...); mComponentToEntity.emplace_back(entity); mEntityToComponent[entity] = index; mEntityToBitset[entity][T::type] = true; } 

remove方法将相应的位组件设置为false ,然后将最后一个mComponents组件移动到我们要删除的组件的索引处。 它更新到我们刚刚移动的组件的链接,并删除我们要销毁的组件之一:

 void remove(Entity entity) { mEntityToBitset[entity][T::type] = false; auto index = mEntityToComponent[entity]; // Update mComponents mComponents[index] = std::move(mComponents.back()); mComponents.pop_back(); // Update mEntityToComponent mEntityToComponent[mComponentToEntity.back()] = index; mEntityToComponent.erase(entity); // Update mComponentToEntity mComponentToEntity[index] = mComponentToEntity.back(); mComponentToEntity.pop_back(); } 

我们可以通过在要破坏的索引处移动最后一个组件来执行恒定时间的移动。 实际上,我们只需要删除最后一个组件,就可以在恒定时间std::vector中完成此操作。

tryRemove方法在尝试删除实体之前先检查它是否具有组件:

 virtual bool tryRemove(Entity entity) override { if (mEntityToBitset[entity][T::type]) { remove(entity); return true; } return false; } 

getOwner方法返回拥有组件的实体,为此,它使用指针算法和mComponentToEntity

 Entity getOwner(const T& component) const { auto begin = mComponents.data(); auto index = static_cast<std::size_t>(&component - begin); return mComponentToEntity[index]; } 

最后一个方法是reserve ,其目的与EntityContainer的类似方法相同:

 virtual void reserve(std::size_t size) override { mComponents.reserve(size); mComponentToEntity.reserve(size); mEntityToComponent.reserve(size); } 

系统


现在让我们看一下System类。

每个系统都有一组mRequirements位, mRequirements位描述了所需的组件。 此外,它存储一组满足这些要求的mManagedEntities实体。 我重复一遍,为了能够在恒定时间内实施所有操作,我们需要一种从实体移到mManagedEntities索引的mManagedEntities 。 为此,我们将使用名为mEntityToManagedEntity std::unordered_map

这是System声明的样子:

 template<std::size_t ComponentCount, std::size_t SystemCount> class System { public: virtual ~System() = default; protected: template<typename ...Ts> void setRequirements(); const std::vector<Entity>& getManagedEntities() const; virtual void onManagedEntityAdded([[maybe_unused]] Entity entity); virtual void onManagedEntityRemoved([[maybe_unused]] Entity entity); private: friend EntityManager<ComponentCount, SystemCount>; std::bitset<ComponentCount> mRequirements; std::size_t mType; std::vector<Entity> mManagedEntities; std::unordered_map<Entity, Index> mEntityToManagedEntity; void setUp(std::size_t type); void onEntityUpdated(Entity entity, const std::bitset<ComponentCount>& components); void onEntityRemoved(Entity entity); void addEntity(Entity entity); void removeEntity(Entity entity); }; 

setRequirements使用卷积表达式来设置位值:

 template<typename ...Ts> void setRequirements() { (mRequirements.set(Ts::type), ...); } 

getManagedEntities是一个getter,生成的类将使用该getter来访问正在处理的实体:

 const std::vector<Entity>& getManagedEntities() const { return mManagedEntities; } 

它返回一个常量引用,以使生成的类不会尝试修改mManagedEntities

onManagedEntityAddedonManagedEntityRemoved为空。 它们将在以后重新定义。 将实体添加到mManagedEntities或将其删除时,将调用这些方法。

以下方法将是私有的,并且只能从声明为友好类的EntityManager访问。

实体管理器将调用setUp为系统分配ID。 然后可以使用它来索引数组:

 void setUp(std::size_t type) { mType = type; } 

当实体发生变化时,即onEntityUpdated被调用 添加或删除组件时。 系统检查是否满足要求以及是否已处理实体。 如果满足要求并且尚未处理,则系统将其添加。 但是,如果实体不满足要求并且已经被处理,则系统将其删除。 在所有其他情况下,系统不执行任何操作:

 void onEntityUpdated(Entity entity, const std::bitset<ComponentCount>& components) { auto satisfied = (mRequirements & components) == mRequirements; auto managed = mEntityToManagedEntity.find(entity) != std::end(mEntityToManagedEntity); if (satisfied && !managed) addEntity(entity); else if (!satisfied && managed) removeEntity(entity); } 

删除实体时,实体管理器将调用onEntityRemoved 。 如果该实体已由系统处理,则将其删除:

 void onEntityRemoved(Entity entity) { if (mEntityToManagedEntity.find(entity) != std::end(mEntityToManagedEntity)) removeEntity(entity); } 

addEntityremoveEntity只是辅助方法。

addEntity通过mManagedEntities索引将链接设置为从添加的实体mManagedEntities ,添加实体并调用onManagedEntityAdded

 void addEntity(Entity entity) { mEntityToManagedEntity[entity] = static_cast<Index>(mManagedEntities.size()); mManagedEntities.emplace_back(entity); onManagedEntityAdded(entity); } 

onManagedEntityRemoved首先调用onManagedEntityRemoved 。 然后,它将最后处理的实体移到要删除的实体的索引处。 它更新对已移动实体的引用。 最后,它从mManagedEntitiesmEntityToManagedEntity删除要删除的实体:

 void removeEntity(Entity entity) { onManagedEntityRemoved(entity); auto index = mEntityToManagedEntity[entity]; mEntityToManagedEntity[mManagedEntities.back()] = index; mEntityToManagedEntity.erase(entity); mManagedEntities[index] = mManagedEntities.back(); mManagedEntities.pop_back(); } 

实体管理器


所有重要的逻辑都在其他类别中。 实体经理只是将所有内容捆绑在一起。

让我们看一下他的广告:

 template<std::size_t ComponentCount, std::size_t SystemCount> class EntityManager { public: template<typename T> void registerComponent(); template<typename T, typename ...Args> T* createSystem(Args&& ...args); void reserve(std::size_t size); Entity createEntity(); void removeEntity(Entity entity); template<typename T> bool hasComponent(Entity entity) const; template<typename ...Ts> bool hasComponents(Entity entity) const; template<typename T> T& getComponent(Entity entity); template<typename T> const T& getComponent(Entity entity) const; template<typename ...Ts> std::tuple<Ts&...> getComponents(Entity entity); template<typename ...Ts> std::tuple<const Ts&...> getComponents(Entity entity) const; template<typename T, typename... Args> void addComponent(Entity entity, Args&&... args); template<typename T> void removeComponent(Entity entity); template<typename T> Entity getOwner(const T& component) const; private: std::array<std::unique_ptr<BaseComponentContainer>, ComponentCount> mComponentContainers; EntityContainer<ComponentCount, SystemCount> mEntities; std::vector<std::unique_ptr<System<ComponentCount, SystemCount>>> mSystems; template<typename T> void checkComponentType() const; template<typename ...Ts> void checkComponentTypes() const; template<typename T> auto getComponentContainer(); template<typename T> auto getComponentContainer() const; }; 

EntityManager类具有三个成员变量: mComponentContainers (用于存储std::unique_ptr BaseComponentContainer std::unique_ptrmEntities (仅是EntityContainer的实例)和mSystems (用于存储指向System unique_ptr指针)。

一个类有很多方法,但实际上它们都很简单。

首先让我们看一下getComponentContainer ,它返回一个指向处理T类型组件的组件容器的指针:

 template<typename T> auto getComponentContainer() { return static_cast<ComponentContainer<T, ComponentCount, SystemCount>*>(mComponentContainers[T::type].get()); } 

另一个辅助函数是checkComponentType ,它仅检查组件类型ID是否低于最大组件数:

 template<typename T> void checkComponentType() const { static_assert(T::type < ComponentCount); } 

checkComponentTypes使用卷积表达式执行几种类型的检查:

 template<typename ...Ts> void checkComponentTypes() const { (checkComponentType<Ts>(), ...); } 

registerComponent创建指定类型的组件的新容器:

 template<typename T> void registerComponent() { checkComponentType<T>(); mComponentContainers[T::type] = std::make_unique<ComponentContainer<T, ComponentCount, SystemCount>>( mEntities.getEntityToBitset()); } 

createSystem创建指定类型的新系统并设置其类型:

 template<typename T, typename ...Args> T* createSystem(Args&& ...args) { auto type = mSystems.size(); auto& system = mSystems.emplace_back(std::make_unique<T>(std::forward<Args>(args)...)); system->setUp(type); return static_cast<T*>(system.get()); } 

reserve方法调用ComponentContainerEntityContainerreserve方法:

 void reserve(std::size_t size) { for (auto i = std::size_t(0); i < ComponentCount; ++i) { if (mComponentContainers[i]) mComponentContainers[i]->reserve(size); } mEntities.reserve(size); } 

createEntity方法返回EntityManager管理器的create方法的结果:

 Entity createEntity() { return mEntities.create(); } 

hasComponent使用一组实体位来快速验证该实体是否具有指定类型的组件:

 template<typename T> bool hasComponent(Entity entity) const { checkComponentType<T>(); return mEntities.getBitset(entity)[T::type]; } 

hasComponents使用卷积表达式创建一组表示所需组件的位,然后将其与实体的一组位一起使用以检查该实体是否具有所有必需的组件:

 template<typename ...Ts> bool hasComponents(Entity entity) const { checkComponentTypes<Ts...>(); auto requirements = std::bitset<ComponentCount>(); (requirements.set(Ts::type), ...); return (requirements & mEntities.getBitset(entity)) == requirements; } 

getComponent将请求重定向到所需的组件容器:

 template<typename T> T& getComponent(Entity entity) { checkComponentType<T>(); return getComponentContainer<T>()->get(entity); } 

getComponents返回到请求的组件的链接的元组。 为此,它使用std::tie和一个卷积表达式:

 template<typename ...Ts> std::tuple<Ts&...> getComponents(Entity entity) { checkComponentTypes<Ts...>(); return std::tie(getComponentContainer<Ts>()->get(entity)...); } 

addComponentremoveComponent将请求重定向到所需的组件容器,然后调用onEntityUpdated系统onEntityUpdated

 template<typename T, typename... Args> void addComponent(Entity entity, Args&&... args) { checkComponentType<T>(); getComponentContainer<T>()->add(entity, std::forward<Args>(args)...); // Send message to systems const auto& bitset = mEntities.getBitset(entity); for (auto& system : mSystems) system->onEntityUpdated(entity, bitset); } template<typename T> void removeComponent(Entity entity) { checkComponentType<T>(); getComponentContainer<T>()->remove(entity); // Send message to systems const auto& bitset = mEntities.getBitset(entity); for (auto& system : mSystems) system->onEntityUpdated(entity, bitset); } 

最后, getOwner将请求重定向到所需的容器组件:

 template<typename T> Entity getOwner(const T& component) const { checkComponentType<T>(); return getComponentContainer<T>()->getOwner(component); } 

那是我的第一个实现。 它仅包含357行代码。 所有代码都可以在此线程中找到。

分析和基准


基准测试


现在是对我的第一个ECS实施进行基准测试的时候了!

结果如下:




模板可伸缩! 当增加实体数量并更改配置文件(A,AA和AAA)时,每秒处理的数量大约相同。

此外,随着实体中组件数量的增加,它可以很好地扩展。 当我们遍历具有三个组成部分的实体时,它们发生的速度比遍历具有一个组成部分的实体慢三倍。 这是可以预期的,因为我们需要获得三个组成部分。

缓存未命中


为了检查缓存未命中的数量,我运行了从此处获取的cachegrind示例。

这是10,000个实体的结果:

==1652== D refs: 277,577,353 (254,775,159 rd + 22,802,194 wr)
==1652== D1 misses: 20,814,368 ( 20,759,914 rd + 54,454 wr)
==1652== LLd misses: 43,483 ( 7,847 rd + 35,636 wr)
==1652== D1 miss rate: 7.5% ( 8.1% + 0.2% )
==1652== LLd miss rate: 0.0% ( 0.0% + 0.2% )


这是100,000个实体的结果:

==1738== D refs: 2,762,879,670 (2,539,368,564 rd + 223,511,106 wr)
==1738== D1 misses: 207,415,181 ( 206,902,072 rd + 513,109 wr)
==1738== LLd misses: 207,274,328 ( 206,789,289 rd + 485,039 wr)
==1738== D1 miss rate: 7.5% ( 8.1% + 0.2% )
==1738== LLd miss rate: 7.5% ( 8.1% + 0.2% )


结果很好。 为何在100,000个实体中有如此多的LLd未命中,这有点奇怪。

剖析


为了了解当前实现的哪些部分需要更长的时间,我使用gprof对示例进行了分析

结果如下:

Flat profile:

Each sample counts as 0.01 seconds.
% cumulative self self total
time seconds seconds calls ms/call ms/call name
57.45 1.16 1.16 200300000 0.00 0.00 std::__detail::_Map_base<unsigned int, std::pair<unsigned int const, unsigned int>, std::allocator<std::pair<unsigned int const, unsigned int> >, std::__detail::_Select1st, std::equal_to<unsigned int>, std::hash<unsigned int>, std::__detail::_Mod_range_hashing, std::__detail::_Default_ranged_hash, std::__detail::_Prime_rehash_policy, std::__detail::_Hashtable_traits<false, false, true>, true>::operator[](unsigned int const&)
19.31 1.55 0.39 main
16.34 1.88 0.33 200500000 0.00 0.00 std::_Hashtable<unsigned int, std::pair<unsigned int const, unsigned int>, std::allocator<std::pair<unsigned int const, unsigned int> >, std::__detail::_Select1st, std::equal_to<unsigned int>, std::hash<unsigned int>, std::__detail::_Mod_range_hashing, std::__detail::_Default_ranged_hash, std::__detail::_Prime_rehash_policy, std::__detail::_Hashtable_traits<false, false, true> >::_M_find_before_node(unsigned long, unsigned int const&, unsigned long) const
3.96 1.96 0.08 300000 0.00 0.00 std::_Hashtable<unsigned int, std::pair<unsigned int const, unsigned int>, std::allocator<std::pair<unsigned int const, unsigned int> >, std::__detail::_Select1st, std::equal_to<unsigned int>, std::hash<unsigned int>, std::__detail::_Mod_range_hashing, std::__detail::_Default_ranged_hash, std::__detail::_Prime_rehash_policy, std::__detail::_Hashtable_traits<false, false, true> >::_M_insert_unique_node(unsigned long, unsigned long, std::__detail::_Hash_node<std::pair<unsigned int const, unsigned int>, false>*)
2.48 2.01 0.05 300000 0.00 0.00 unsigned int& std::vector<unsigned int, std::allocator<unsigned int> >::emplace_back<unsigned int&>(unsigned int&)
0.50 2.02 0.01 3 3.33 3.33 std::_Hashtable<unsigned int, std::pair<unsigned int const, unsigned int>, std::allocator<std::pair<unsigned int const, unsigned int> >, std::__detail::_Select1st, std::equal_to<unsigned int>, std::hash<unsigned int>, std::__detail::_Mod_range_hashing, std::__detail::_Default_ranged_hash, std::__detail::_Prime_rehash_policy, std::__detail::_Hashtable_traits<false, false, true> >::~_Hashtable()
0.00 2.02 0.00 200000 0.00 0.00 std::_Hashtable<unsigned int, std::pair<unsigned int const, unsigned int>, std::allocator<std::pair<unsigned int const, unsigned int> >, std::__detail::_Select1st, std::equal_to<unsigned int>, std::hash<unsigned int>, std::__detail::_Mod_range_hashing, std::__detail::_Default_ranged_hash, std::__detail::_Prime_rehash_policy, std::__detail::_Hashtable_traits<false, false, true> >::find(unsigned int const&)


结果可能有些失真,因为我使用-O1标志进行了编译, -O1 gprof -O1有意义的内容。 看来,当提高优化级别时,编译器开始积极嵌入所有内容,而gprof几乎什么也没说。

根据gprof的说法,此实现中的明显瓶颈是std::unordered_map 。 如果我们想对其进行优化,那么就值得尝试摆脱它们。

与之比较 std::map


我开始好奇中之间的性能差异std::unordered_mapstd::map,所以我改变了代码全部std::unordered_mapstd::map这里 提供该实现,

下面是基准测试结果:




我们可以看到,这次随着实体数量的增加,实现无法很好地扩展。即使有1000个实体,其迭代速度也是版本c的两倍std::unordered_map

结论


我们已经创建了一个简单但已经实用的实体-组件-系统模板库。将来,我们将以此为基础进行改进和优化。

在下一节中,我们将展示如何通过替代来提高生产力std::unordered_mapstd::vector此外,我们将展示如何自动将ID类型分配给组件。

用std :: vector替换std :: unordered_map


正如我们所看到的,它们std::unordered_map是我们实施的瓶颈。因此,而不是std::unordered_map我们使用mEntityToComponentComponentContainermEntityToManagedEntitySystem载体std::vector

变化


更改将非常简单,您可以在此处查看它们

唯一的精妙之处在于一个事实,即vectormEntityToComponentmEntityToManagedEntity已经足够长,以指数的任何实体。为此,我决定将它们存储vector在中EntityContainer,在其中我们知道实体的最大ID。然后,我通过vector实体管理器中的引用或指针将向量传递到组件容器。

修改后的代码可以在此线程中找到

结果


让我们检查一下该版本比上一个版本如何更好地工作:




如您所见,使用大量组件和系统进行创建和删除变得有点
慢。

但是,迭代速度快了十倍!而且扩展性非常好。这种速度大大超过了创建和删除的速度。这是合乎逻辑的:实体的迭代将发生多次,但仅创建和删除一次。

现在让我们看看这是否减少了高速缓存未命中的次数。

这是具有10,000个实体的cachegrind的输出:这是100,000个实体的输出:我们看到此版本创建的链接减少了大约三倍,高速缓存未命中次数减少了四倍。

==1374== D refs: 94,563,949 (72,082,880 rd + 22,481,069 wr)
==1374== D1 misses: 4,813,780 ( 4,417,702 rd + 396,078 wr)
==1374== LLd misses: 378,905 ( 9,626 rd + 369,279 wr)
==1374== D1 miss rate: 5.1% ( 6.1% + 1.8% )
==1374== LLd miss rate: 0.4% ( 0.0% + 1.6% )




==1307== D refs: 938,405,796 (715,424,940 rd + 222,980,856 wr)
==1307== D1 misses: 51,034,738 ( 44,045,090 rd + 6,989,648 wr)
==1307== LLd misses: 5,866,508 ( 1,997,948 rd + 3,868,560 wr)
==1307== D1 miss rate: 5.4% ( 6.2% + 3.1% )
==1307== LLd miss rate: 0.6% ( 0.3% + 1.7% )




自动类型


我要讨论的最后一个改进是自动生成组件类型标识符。

变化


可在此处找到用于实现ID类型自动生成的所有更改

为此,为了能够为每种类型的组件分配一个唯一的ID,您需要使用CRTP和带有静态计数器的函数:

 template<typename T> class Component { public: static const std::size_t type; }; std::size_t generateComponentType() { static auto counter = std::size_t(0); return counter++; } template<typename T> const std::size_t Component<T>::type = generateComponentType(); 

您可能会注意到类型ID现在是在运行时生成的,以前在编译时就已知道。

更改后的代码可以在此线程中找到

结果


为了测试此版本的性能,我进行了基准测试:




对于创建和删除,结果大致相同。但是,您可以看到迭代速度变慢了一点,大约10%。

可以通过编译器在编译时知道类型标识符这一事实来解释这种速度下降,这意味着它可以更好地优化代码。

手动分配ID类型有点不方便,并且可能导致错误。因此,即使我们稍微降低了性能,它仍然是ECS库可用性的改进。

进一步改进的想法


在结束本文之前,我想与您分享其他改进的想法。到目前为止,我还没有实现它们,但也许将来会实现。

组件和系统的动态数量


事先以模板参数的形式指示组件和系统的最大数量是不方便的。我认为这将有可能取代std::arrayEntityManagerstd::vector没有强大的性能下降。

但是,它std::bitset需要在编译时知道位数。虽然我想纠正通过更换这个问题std::vector<bitset<ComponentCount>>EntityContainerstd::vector<char>并释放出足够的字节数来表示所有实体的位集合。然后,我们实现一个轻量级类BitsetView该类在输入处接收一对指向该位集合的开始和结束的指针,然后std::bitset在此存储范围内执行所有必要的操作

另一个想法:不再使用位集,而是检查mEntityToComponent实体是否具有组件。

简化的组件迭代


目前,如果系统要迭代处理其处理的实体的组件,则需要这样做:

 for (const auto& entity : getManagedEntities()) { auto [position, velocity] = mEntityManager.getComponents<Position, Velocity>(entity); ... } 

如果我们可以做这样的事情,那将是更漂亮,更简单的:

 for (auto& [position, velocity] : mEntityManager.getComponents<Position, Velocity>(mManagedEntities)) { ... } 

借助std::view::transformC ++ 20
的ranges库将更容易实现这一点

不幸的是,它还不存在。我可以使用Eric Nibler的范围库,但是我不想添加依赖项。

解决方案可能是实现一个类EntityRangeView该类将接收需要接收的组件类型作为模板参数,并使用std::vector实体引用作为构造函数参数然后,我们将不得不才意识到beginend和迭代器类型,以实现所需的行为。这不是很困难,但是会花费一些时间。

活动管理优化


在当前的实现中,当从实体中添加或删除组件时,我们称onEntityUpdated所有系统。这有点效率低下,因为许多系统对刚刚更改的组件类型不感兴趣。

为了最大程度地减少破坏,我们可以存储指向对数据结构中指定类型的组件感兴趣的系统的指针,例如std::array<std::vector<System<ComponentCount, SystemCount>>, ComponentCount>然后,在添加或删除组件时,我们只需调用onEntityUpdated对该组件感兴趣系统方法即可

由实体管理器而非系统管理的实体子集


我的最后一个想法将导致图书馆结构的更广泛的变化。

代替管理其实体集的系统,实体管理器可以做到这一点。这种方案的优势在于,如果两个系统对一组组件感兴趣,我们不会复制满足这些要求的实体子集。

系统可以简单地向实体经理声明其要求。然后,实体管理器将存储实体的所有不同子集。最后,系统将使用类似的语法查询实体:

 for (const auto& entity : mEntityManager.getEntitiesWith<Position, Velocity>()) { ... } 

结论


到目前为止,这是关于我的实体组件系统实现的文章的结尾。如果我进行其他改进,将来可能会写新文章。

本文中描述的实现非常简单:它由少于500行代码组成,并且具有良好的性能。所有交易均在固定时间内(摊销)实现。此外,在实践中,它最佳地使用了缓存并非常快速地接收和迭代实体。

我希望这篇文章对您很有意思甚至有用。

补充阅读


以下是一些有用的资源,可用于更深入地研究实体组件系统模式:

  • 米歇尔该隐,笔者entt,写了一个非常有趣的一系列关于所谓的实体组件系统文章的ECS回往复
  • 实体系统Wiki包含非常有用的信息和链接。

Source: https://habr.com/ru/post/zh-CN459288/


All Articles