struct Position : public Component<Position> { float x; float y; }; struct Velocity : public Component<Velocity> { float x; float y; };
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
method. Then, in the update
method, it can call getManagedEntities
to iteratively traverse all the entities that meet the requirements. 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);
using Entity = uint32_t;
Index
, which will come in handy later: using Index = uint32_t; static constexpr auto InvalidIndex = std::numeric_limits<Index>::max();
uint32_t
instead of the 64-bit type or std::size_t
to save space and improve cache optimization. We will lose not so much: it is unlikely that someone will have billions of entities. template<typename T, auto Type> class Component { public: static constexpr auto type = static_cast<std::size_t>(Type); };
std::size_t
, which will serve as the id of the component type.Position
component as follows: struct Positon : Component<Position, 0> { float x; float y; };
enum class ComponentType { Position }; struct Positon : Component<Position, ComponentType::Position> { float x; float y; };
EntityContainer
class will be responsible for managing entities and storing the std::bitset
for each of them. This set of bits will denote the components that the entity owns.std::vector
, we need id to be as small as possible and take up less memory. Therefore, we will reuse the id of the destroyed entities. To do this, the free id will be stored in a container called 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; };
getEntityToBitset
and getBitset
are the usual small getters: std::vector<std::bitset<ComponentCount>>& getEntityToBitset() { return mEntityToBitset; } const std::bitset<ComponentCount>& getBitset(Entity entity) const { return mEntityToBitset[entity]; }
create
method is more interesting: 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
method simply adds an entity to delete in mFreeEntities
: void remove(Entity entity) { mFreeEntities.push_back(entity); }
reserve
. Its task is to reserve memory for various containers. As we possibly know, memory allocation is a costly operation, so if we approximately know the number of future entities in the game, then memory reservation will speed up the work: void reserve(std::size_t size) { mFreeEntities.resize(size); std::iota(std::begin(mFreeEntities), std::end(mFreeEntities), 0); mEntityToBitset.resize(size); }
mFreeEntities
.ComponentContainer
class will be responsible for storing all components of the specified type.mComponents
.mComponentToEntity
and mEntityToComponent
.ComponentContainer
ad: 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
, which is defined as: class BaseComponentContainer { public: virtual ~BaseComponentContainer() = default; virtual void reserve(std::size_t size) = 0; virtual bool tryRemove(Entity entity) = 0; };
ComponentContainer
instances in a container. ComponentContainer(std::vector<std::bitset<ComponentCount>>& entityToBitset) : mEntityToBitset(entityToBitset) { }
get
method is simple, we just use mEntityToComponent
to find the index of the entity entity
in mComponents
: T& get(Entity entity) { return mComponents[mEntityToComponent[entity]]; }
add
method uses its arguments to insert a new component at the end of the mComponents
, and then it prepares links to go from entity to component and from component to entity. At the end, it assigns a true
bit to the entity
bit set bit that corresponds to the component: 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
method sets the corresponding component bit to false
, and then moves the last mComponents
component to the index of the one we want to remove. It updates the links to the component we just moved and removes one of the components we want to destroy: 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
in constant time.tryRemove
method checks whether an entity has components before attempting to delete it: virtual bool tryRemove(Entity entity) override { if (mEntityToBitset[entity][T::type]) { remove(entity); return true; } return false; }
getOwner
method returns the entity that owns the component, for this it uses pointer arithmetic and mComponentToEntity
: Entity getOwner(const T& component) const { auto begin = mComponents.data(); auto index = static_cast<std::size_t>(&component - begin); return mComponentToEntity[index]; }
reserve
, it has such a purpose as a similar method in the EntityContainer
: virtual void reserve(std::size_t size) override { mComponents.reserve(size); mComponentToEntity.reserve(size); mEntityToComponent.reserve(size); }
System
class.mRequirements
bits describing the components it needs. In addition, it stores a set of mManagedEntities
entities that meet these requirements. Again, to be able to implement all operations in constant time, we need a way to transition from an entity to its index in mManagedEntities
. To do this, we will use std::unordered_map
called mEntityToManagedEntity
.System
declaration looks like: 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
uses a convolution expression : template<typename ...Ts> void setRequirements() { (mRequirements.set(Ts::type), ...); }
getManagedEntities
is the getter that will be used by the generated classes to access the processed entities: const std::vector<Entity>& getManagedEntities() const { return mManagedEntities; }
mManagedEntities
.onManagedEntityAdded
and onManagedEntityRemoved
are empty. They will be redefined later. These methods will be called when an entity is added to mManagedEntities
or deleted.EntityManager
, which is declared as a friendly class.setUp
will be called by the entity manager to assign an id to the system. It can then use it to index arrays: void setUp(std::size_t type) { mType = type; }
onEntityUpdated
is called when the entity changes, i.e. when adding or removing a component. The system checks whether the requirements are satisfied and whether the entity has already been processed. If it meets the requirements and has not yet been processed, the system adds it. However, if the entity does not satisfy the requirements and has already been processed, the system deletes it. In all other cases, the system does nothing: 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
is called by the entity manager when deleting an entity. If the entity has been processed by the system, it deletes it: void onEntityRemoved(Entity entity) { if (mEntityToManagedEntity.find(entity) != std::end(mEntityToManagedEntity)) removeEntity(entity); }
addEntity
and removeEntity
are just helper methods.addEntity
sets a link to navigate from an added entity by its index in mManagedEntities
, adds an entity, and calls onManagedEntityAdded
: void addEntity(Entity entity) { mEntityToManagedEntity[entity] = static_cast<Index>(mManagedEntities.size()); mManagedEntities.emplace_back(entity); onManagedEntityAdded(entity); }
removeEntity
first calls onManagedEntityRemoved
. It then moves the last processed entity to the index of the one that is deleted. It updates the link to the moved entity. At the end, it deletes the entity to be deleted from mManagedEntities
and mEntityToManagedEntity
: 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
class has three member class variables: mComponentContainers
, which mComponentContainers
std::unique_ptr
to BaseComponentContainer
, mEntities
, which is simply an instance of EntityContainer
and mSystems
, which stores unique_ptr
pointers to System
.getComponentContainer
, which returns a pointer to a container of components that processes components of type T
: template<typename T> auto getComponentContainer() { return static_cast<ComponentContainer<T, ComponentCount, SystemCount>*>(mComponentContainers[T::type].get()); }
checkComponentType
, which simply checks that the id of the component type is below the maximum number of components: template<typename T> void checkComponentType() const { static_assert(T::type < ComponentCount); }
checkComponentTypes
uses a convolution expression to perform a check of several types: template<typename ...Ts> void checkComponentTypes() const { (checkComponentType<Ts>(), ...); }
registerComponent
creates a new container of components of the specified type: template<typename T> void registerComponent() { checkComponentType<T>(); mComponentContainers[T::type] = std::make_unique<ComponentContainer<T, ComponentCount, SystemCount>>( mEntities.getEntityToBitset()); }
createSystem
creates a new system of the specified type and sets its type: 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
method calls the reserve
methods of the ComponentContainer
and EntityContainer
: 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
method returns the result of the EntityManager
manager's create
method: Entity createEntity() { return mEntities.create(); }
hasComponent
uses a set of entity bits to quickly verify that this entity has a component of the specified type: template<typename T> bool hasComponent(Entity entity) const { checkComponentType<T>(); return mEntities.getBitset(entity)[T::type]; }
hasComponents
uses a convolution expression to create a bit set denoting the required components, and then use it with the entity bit set to check whether the entity has all the required components: 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
request to the correct container of components: template<typename T> T& getComponent(Entity entity) { checkComponentType<T>(); return getComponentContainer<T>()->get(entity); }
getComponents
returns a tuple of links to the requested components. To do this, it uses std::tie
and a convolution expression: template<typename ...Ts> std::tuple<Ts&...> getComponents(Entity entity) { checkComponentTypes<Ts...>(); return std::tie(getComponentContainer<Ts>()->get(entity)...); }
addComponent
and removeComponent
forward the request to the desired component container, and then call the onEntityUpdated
system 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
request to the required container component: template<typename T> Entity getOwner(const T& component) const { checkComponentType<T>(); return getComponentContainer<T>()->getOwner(component); }
==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% )
==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% )
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
flag -O1
that gprof -O1
something meaningful. It seems that as the level of optimization increases, the compiler starts building in everything aggressively and gprof reports almost nothing.std::unordered_map
. If we want to optimize it, then it is worth trying to get rid of them.std::map
std::unordered_map
and std::map
, so I replaced in the code all std::unordered_map
with std::map
. This implementation is posted here.std::unordered_map
.std::unordered_map
with std::vector
. In addition, we will show how to automatically assign type id to components.std::unordered_map
was a bottleneck of our implementation. Therefore, instead std::unordered_map
we use in mEntityToComponent
for ComponentContainer
and in mEntityToManagedEntity
for System
vectors std::vector
.vector
in mEntityToComponent
and mEntityToManagedEntity
be long enough to be indexed by any entity. To do this in a simple way, I decided to store these vector
in EntityContainer
, in which we know the maximum id of the entity. Then I pass the vectors to the vector
component containers by reference or pointer in the entity manager.==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% )
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();
std::array
in EntityManager
on std::vector
without a strong performance degradation.std::bitset
requires to know the number of bits at compile time. For now, I’m thinking of fixing this problem by replacing std::vector<bitset<ComponentCount>>
in EntityContainer
with std::vector<char>
and allocating enough bytes to represent the bit sets of all the entities. Then we implement a lightweight class BitsetView
that receives as input a pair of pointers to the beginning and end of the bit set, and then perform all the necessary operations with std::bitset
in that memory range.mEntityToComponent
whether the entity has components. for (const auto& entity : getManagedEntities()) { auto [position, velocity] = mEntityManager.getComponents<Position, Velocity>(entity); ... }
for (auto& [position, velocity] : mEntityManager.getComponents<Position, Velocity>(mManagedEntities)) { ... }
std::view::transform
in C ++ 20 . Unfortunately, it is not yet. I could use Eric Niebler's range library , but I don't want to add dependencies. The solution would be to implement a class that would receive as the template parameters the types of components to be obtained, and as a parameter of the constructor a reference to the entities. Then we would have had only to realize , and the iterator type to achieve the desired behavior. This is not particularly difficult, but a little time consuming to write.EntityRangeView
std::vector
begin
end
onEntityUpdated
all systems. This is a bit inefficient because many systems are not interested in the type of component that has just been modified.std::array<std::vector<System<ComponentCount, SystemCount>>, ComponentCount>
. Then, when adding or removing a component, we would simply call the method of onEntityUpdated
systems interested in that component. for (const auto& entity : mEntityManager.getEntitiesWith<Position, Velocity>()) { ... }
Source: https://habr.com/ru/post/459288/
All Articles