2
\$\begingroup\$

Introduction

I'm a new to C++ so please take me easy :) I am currently working on a low-level game engine using C++, OpenGL, and GLFW; I've implemented the Event System and the Input Manager, which you can check out here: Improved Event System & InputManager Using C++.

Graph

For clarity, I've included a graph:

enter image description here

Components

  • Entity: This is a simple type alias for size_t. Each entity in the system is represented by a unique Entity ID.
  • Component: This is an abstract base class for all components. Components are the data that entities can possess.
  • System: This is an abstract base class for all systems. Systems are where the logic of the game lives. Each system operates on entities that have a specific set of components. For example, a MovementSystem might operate on all entities that have both PositionComponent and VelocityComponent.
  • BaseComponentPool and ComponentPool: These classes manage the storage of components. Each type of component has its own ComponentPool. The BaseComponentPool is an interface that allows the ComponentManager to store a collection of ComponentPool objects of different types.
  • ComponentManager: This class manages all the ComponentPools. It provides methods to add and retrieve components to/from entities.
  • EntityManager: This class manages the creation and destruction of entities.
  • SystemContext: This class provides the context for systems to operate. It provides access to the EntityManager and ComponentManager, allowing systems to query entities and their components.
  • SystemManager: This class manages all the systems. It provides methods to add, remove, enable, and disable systems. It also provides a method to update all systems, which is typically called once per game loop.
  • ECSManager: This is the main interface to the ECS. It provides methods to create and destroy entities, add components to entities, and add systems. It also provides a method to update all systems, which is typically called once per game loop.

Control Flow

The flow of the ECS is as follows:

Entities are created using the ECSManager. Components are added to entities using the ECSManager, which delegates to the ComponentManager. Systems are added using the ECSManager, which delegates to the SystemManager. Each game loop, the ECSManager's updateSystems method is called, which delegates to the SystemManager's updateSystems method. Each system is updated, operating on the entities and their components as needed.

Concerns

  1. Question: Should I replace the simple Entity = size_t, with an Object Entity? This would improve type safety but would also reduce speed and increase complexity, since now every call to get the Entity's ID would be through the interface of the object.
  2. Problem: This design does not adhere to the dependecy inversion principle, because SystemContext, which is a high-level object, is exported to the Systems (which are low-level). Normally, this would be solved by using interfaces, but not here, because the SystemCotext object consits of templated methods, which cannot be virtual. However, I do not think this is a mark of bad design becuase the DIP is just a principle, not a hard-rule.

Any feedback/suggestions would be very much appreciated.

Source Code

Entity.h

#pragma once

using Entity = size_t;

Component.h

#pragma once

class Component
{
public:
    virtual ~Component() = default;
};

ComponentPoolExceptions.h

#pragma once

#include <stdexcept>

#include "Entity.h"

class ComponentEntityNotFoundException : public std::runtime_error
{
public:
    ComponentEntityNotFoundException(Entity entity, const std::string& componentType)
        : std::runtime_error(entity + " not found in component pool: " + componentType) { }
};

ComponentPool.h

#pragma once

#include <stdexcept>
#include <vector>
#include <memory>

#include "Component.h"
#include "ComponentPoolExceptions.h"

class BaseComponentPool
{
public:
    virtual ~BaseComponentPool() = default;
    virtual void destroyEntityComponent(Entity entity) = 0;
    virtual bool hasComponent(Entity entity) const = 0;
};

template <typename ComponentType>
class ComponentPool : public BaseComponentPool
{
public:
    void addComponent(Entity entity, std::unique_ptr<ComponentType> component)
    {
        if (entity >= pool.size())
            pool.resize(entity + 1);

        pool[entity] = std::move(component);
    }

    void destroyEntityComponent(Entity entity) override
    {
        if (entity < pool.size())
            pool[entity].reset();
    }

    const ComponentType& getComponent(Entity entity) const
    {
        if (entity >= pool.size() || pool[entity] == nullptr)
            throw ComponentEntityNotFoundException(entity, typeid(ComponentType).name());

        return *pool[entity];
    }

    bool hasComponent(Entity entity) const override
    {
        return entity < pool.size() && pool[entity] != nullptr;
    }

private:
    std::vector<std::unique_ptr<ComponentType>> pool;
};

ComponentManager.h

#pragma once

#include <typeindex>

#include "ComponentPool.h"

class ComponentManager
{
public:
    template<typename ComponentType, typename... Args>
    void addComponent(Entity entity, Args&&... args)
    {
        auto component = std::make_unique<ComponentType>(std::forward(args)...);
        getComponentPool<ComponentType>().addComponent(entity, std::move(component));
    }

    template<typename ComponentType>
    const ComponentType& getComponent(Entity entity) 
    {
        return getComponentPool<ComponentType>().getComponent(entity);
    }

    template<typename ComponentType>
    bool hasComponent(Entity entity) 
    {
        return getComponentPool<ComponentType>().hasComponent(entity);
    }

    void destroyEntityComponents(Entity entity)
    {
        for (auto& [type, pool] : componentPools)
            pool->destroyEntityComponent(entity);
    }

private:
    template<typename ComponentType>
    ComponentPool<ComponentType>& getComponentPool()
    {
        std::type_index typeIndex(typeid(ComponentType));

        auto it = componentPools.find(typeIndex);
        if (it == componentPools.end()) {
            auto newPool = std::make_unique<ComponentPool<ComponentType>>();
            it = componentPools.emplace(typeIndex, std::move(newPool)).first;
        }

        return static_cast<ComponentPool<ComponentType>&>(*it->second);
    }

private:
    std::unordered_map<std::type_index, std::unique_ptr<BaseComponentPool>> componentPools;
};

EntityExceptions.h

#pragma once

#include <stdexcept>
#include <string>

#include "Entity.h"

class EntityOutOfBoundsException : std::runtime_error
{
public:
    EntityOutOfBoundsException(Entity entity)
        : runtime_error("Entity: " + std::to_string(entity) + " is out of bounds!") { }
};

EntityManager.h

#pragma once

#include <queue>
#include <unordered_set>

#include "Entity.h"

class EntityManager
{
public:
    Entity createEntity();
    void destroyEntity(Entity entity);

    const std::unordered_set<Entity>& getActiveEntities() const { return activeEntities; }

private:
    std::queue<Entity> freeEntities;
    std::unordered_set<Entity> activeEntities;
    Entity nextEntity = 0;
};

EntityManager.cpp

#include "EntityManager.h"

#include "EntityExceptions.h"

Entity EntityManager::createEntity()
{
    Entity entity;

    if (!freeEntities.empty()) {
        entity = freeEntities.front();
        freeEntities.pop();
    }
    else {
        entity = nextEntity++;
    }

    activeEntities.insert(entity);
    return entity;
}

void EntityManager::destroyEntity(Entity entity)
{
    if (activeEntities.find(entity) == activeEntities.end())
        throw EntityOutOfBoundsException(entity);

    freeEntities.push(entity);
    activeEntities.erase(entity);
}

SystemContext.h

#pragma once

#include "ComponentManager.h"
#include "EntityManager.h"

class SystemContext
{
public:
    SystemContext(EntityManager& EntityManager, ComponentManager& componentManager)
        : EntityManager(EntityManager), componentManager(componentManager)  { }

    template<typename ComponentType>
    requires std::derived_from<ComponentType, Component>
    const ComponentType& getComponent(Entity entity)
    {
        return componentManager.getComponent<ComponentType>(entity);
    }

    template<typename ComponentType>
    requires std::derived_from<ComponentType, Component>
    bool hasComponent(Entity entity)
    {
        return componentManager.hasComponent<ComponentType>(entity);
    }

    template<typename... ComponentTypes>
    requires (std::derived_from<ComponentTypes, Component> && ...)
    std::vector<Entity> getEntitiesWithComponents()
    {
        std::vector<Entity> entities;

        auto& activeEntites = EntityManager.getActiveEntities();
        for (const auto& entity : activeEntites)
            if ((componentManager.hasComponent<ComponentTypes>(entity) && ...))
                entities.push_back(entity);

        return entities;
    }

private:
    EntityManager& EntityManager;
    ComponentManager& componentManager;
};

System.h

#pragma once

#include "SystemContext.h"

class System
{
public:
    virtual ~System() = default;

    virtual void onAdded() = 0;
    virtual void update(float deltaTime, SystemContext& context) = 0;
    virtual void onRemoved() = 0;

    void enable(bool enabled) { this->enabled = enabled; }
    bool isEnabled() const { return enabled; }
private:
    bool enabled = true;
};

SystemExceptions.h

#pragma once

#include <stdexcept>

class SystemNotFoundException : public std::runtime_error
{
public:
    SystemNotFoundException(const std::string& systemType)
        : std::runtime_error("System not found: " + systemType) {}
};

class SystemAlreadyAddedException : public std::runtime_error
{
public:
    SystemAlreadyAddedException(const std::string& systemType)
        : std::runtime_error("System is already added: " + systemType) {}
};

SystemManager.h

#pragma once

#include <list>
#include <memory>
#include <stdexcept>
#include <unordered_map>

#include "System.h"
#include "SystemExceptions.h"

class SystemManager
{
public:
    template<typename SystemType, typename... Args>
    void addSystem(Args&&... args)
    {
        std::type_index typeIndex(typeid(SystemType));
        if (systemLookup.find(typeIndex) != systemLookup.end())
            throw SystemAlreadyAddedException(typeid(SystemType).name());

        std::unique_ptr<System> system = std::make_unique<SystemType>(std::forward<Args>(args)...);

        system->onAdded();
        systems.emplace_back(std::move(system));
        systemLookup[typeIndex] = systems.size() - 1;
    }

    template<typename SystemType>
    void removeSystem()
    {
        std::type_index typeIndex(typeid(SystemType));
        auto it = systemLookup.find(typeIndex);

        if (it == systemLookup.end())
            throw SystemNotFoundException(typeid(SystemType).name());

        systems[it->second]->onRemoved();
        systems.erase(systems.begin() + it->second);
        systemLookup.erase(it);
    }

    template<typename SystemType>
    bool hasSystem() const
    {
        std::type_index typeIndex(typeid(SystemType));
        return systemLookup.find(typeIndex) != systemLookup.end();
    }

    template<typename SystemType>
    void enableSystem(bool enabled)
    {
        auto system = getSystem<SystemType>();
        
        system->enabled(enabled);
    }

    void updateSystems(float deltaTime, SystemContext& context) const 
    {
        for (auto& system : systems)
            if(system->isEnabled())
                system->update(deltaTime, context);
    }

private:
    std::vector<std::unique_ptr<System>> systems;
    std::unordered_map<std::type_index, size_t> systemLookup;
};

ECSManager.h

#pragma once

#include "SystemManager.h"

class ECSManager
{
public:
    ECSManager()
        : context(entityManager, componentManager) { }

    Entity createEntity()
    {
        return entityManager.createEntity();
    }

    void destroyEntity(Entity entity)
    {
        componentManager.destroyEntityComponents(entity);
        entityManager.destroyEntity(entity);
    }

    template<typename ComponentType, typename... Args>
    requires std::derived_from<ComponentType, Component>
    void addComponent(Entity entity, Args&&... args)
    {
        componentManager.addComponent<ComponentType>(entity, std::forward(args)...);
    }

    template<typename SystemType, typename... Args>
    requires std::derived_from<SystemType, System>
    void addSystem(Args&&... args)
    {
        systemManager.addSystem<SystemType>(std::forward<Args>(args)...);
    }

    template<typename SystemType>
    requires std::derived_from<SystemType, System>
    void removeSystem()
    {
        systemManager.removeSystem<SystemType>();
    }

    template<typename SystemType>
    requires std::derived_from<SystemType, System>
    bool hasSystem() const
    {
        return systemManager.hasSystem<SystemType>();
    }

    template<typename SystemType>
    requires std::derived_from<SystemType, System>
    void enableSystem(bool enabled)
    {
        systemManager.enableSystem<SystemType>(enabled);
    }

    void updateSystems(float deltaTime) 
    {
        systemManager.updateSystems(deltaTime, context);
    }

private:
    EntityManager entityManager;
    ComponentManager componentManager;
    SystemManager systemManager;
    SystemContext context;
};
\$\endgroup\$
2
  • 2
    \$\begingroup\$ "Entity: This is a simple type alias for size_t. Each entity in the system is represented by a unique Entity ID." That's nuts. Entities are uniquely identifiable objects, they are objects with ids. What they are not is the entity ids themselves. Just call the type EntityId for clarity. \$\endgroup\$
    – slepic
    Jul 1, 2023 at 6:42
  • \$\begingroup\$ In my personal code, I've implemented the object Entity, which encapsulates an ID; I've also changed the ComponentPool to use an unordered map, because i don't think the components will be tightly packed \$\endgroup\$ Jul 1, 2023 at 9:32

1 Answer 1

2
\$\begingroup\$

About your concerns

  1. Question: Should I replace the simple Entity = size_t, with an Object Entity? This would improve type safety but would also reduce speed and increase complexity, since now every call to get the Entity's ID would be through the interface of the object.

It would indeed improve type safety, but there is no reason why this would reduce speed, and only perhaps a little complexity is added to the source code. Consider:

struct Entity {
    std::size_t id;
};

Now consider how we have to rewrite, for example, hasComponent() to use this:

bool hasComponent(Entity entity) const override
{
    return entity.id < pool.size() && pool[entity.id] != nullptr;
}

Nothing changed except the addition of a few .ids. Also, since this struct only contains a std::size_t, the assembly code generated by the compiler will be exactly the same as when using Entity = std::size_t, even with optimization disabled!

  1. Problem: This design does not adhere to the dependecy inversion principle, because SystemContext, which is a high-level object, is exported to the Systems (which are low-level). Normally, this would be solved by using interfaces, but not here, because the SystemContext object consists of templated methods, which cannot be virtual. However, I do not think this is a mark of bad design because the DIP is just a principle, not a hard-rule.

As long as you only depend on the public interface, not the internals, I think it's fine. You don't need to make an abstract base class for everything.

Technically speaking, you can still replace SystemContext with a completely different implementation, and if it has the same interface, your code will still compile fine. The only drawback of not having an abstract base class is that you cannot call System::update() with different types of derived SystemContexts. You probably could make it work by using type erasure, but that would require drastic changes to your code base.

Unused class Component

What does class Component actually do? I don't see it used in any of the code. It also only has a virtual destructor, no other members, so the only thing you could use it for is to store pointers-to-base in a std::vector. But you're not doing that in your code. I would just remove it, otherwise it will just add dead weight to classes that inherit from it.

ComponentPool can store components without using std::unique_ptr

Since an instance of ComponentPool will only ever store components of the same type, there is no need to use std::unique_ptr to store them in a std::vector, you could just write:

std::vector<ComponentType> pool;

Of course, that only makes sense if ComponentPool::addComponent() gets the component as a raw value or reference, but that's easy: just make ComponentManager::addComponent() pass a component by value. Or even better: have it forward Args...:

class ComponentManager
{
public:
    template<typename ComponentType, typename... Args>
    void addComponent(Entity entity, Args&&... args)
    {
        getComponentPool<ComponentType>().addComponent(
            entity,
            std::forward<Args>(args)...
        );            
    }
    …
};

template <typename ComponentType>
class ComponentPool: public BaseComponentPool
{
public:
    template<typename... Args>
    void addComponent(Entity entity, Args&&... args)
    {
        if (entity >= pool.size())
            pool.resize(entity + 1);

        pool[entity] = Component(std::forward<Args>(args)...);
    }
    …
private:
    std::vector<ComponentType> pool;
};

One issue now is how to have ComponentPool know whether a given entity actually has a valid component associated with it. You could keep track of that in a separate datastructure, like a std::vector<bool> hasComponent, or you can store std::optional<ComponentType>s in the std::vector pool.

Another reason not to do this is if ComponentType is a large type: this would waste space in the pool for entities that don't have that component associated with them. But more about that below.

Avoid temporary memory allocations

It looks like in a system's update() function, it's supposed to call context.getEntitiesWithComponents() to get a std::vector<Entity> with all the entities that have the required components. This requires several memory allocations, as that std::vector is being grown dynamically. Then the caller has to loop over that vector to do its update thing on each entity in that std::vector. However, that is unnecessary; consider instead having a way to pass a function to SystemContext that it will apply to any matching entities:

template<typename... ComponentTypes>
requires (std::derived_from<ComponentTypes, Component> && ...)
updateEntitiesWithComponents(std::function<void(Entity)> update)
{
    auto& activeEntites = entityManager.getActiveEntities();
    for (const auto& entity : activeEntites)
        if ((componentManager.hasComponent<ComponentTypes>(entity) && ...))
            update(entity);
    }
}

Performance

The code looks clean and functionally correct. However, especially since Entity Component Systems were designed to handle lots of objects, you might want to think about performance, both in terms of CPU efficiency and memory efficiency.

Consider for example something like activeEntities. This is a std::unordered_set<Entity>. You might think that since it has \$O(1)\$ insertions and lookups, it will be fast. However, each insertion will cause memory to be allocated for a single Entity and some associated bookkeeping for the hash table. All the allocations might be spread around instead of being close together in memory. When doing a lookup, a hash value has to be calculated and pointers have to be followed.

What if you had used a std::vector<bool> instead to keep track of which entity IDs are active? With most C++ standard library implementations, this uses only one bit per entity ID, and all those bits are stored sequentially in memory. Doing an insertion and lookup is just a few assembler instructions. The only drawback is that if you have very little active entities compared to the largest entity ID, then you might be wasting more memory than std::unordered_set, although you'll probably never reach that point in practice in a well-designed game.

I already mentioned that you can avoid the need for std::unique_ptr in ComponentPool, and the need for temporary storage when updating systems. However, there still is the issue that each time update() is called, it has to scan through all entities to find out which ones it should update. And the set of entities to update might not even change since the last time update() as called. Ideally, a System has a densely packed std::vector<Entity> of all the entities belonging to that system. In that case, each time update() is called it can just loop over that vector, without having to do any other checks. This would require some bookkeeping though, and might increase the overhead of adding and removing entities from the ECS.

Related to that, it would also be nice if ComponentPools would have densely packed std::vector<ComponentType>s to store the components. When updating a system with only one component, you can then just linearly go through one such vector. For systems with multiple components it's not that easy, but you'd still save memory by densely packing components. The drawback of course is that you need some other forms of bookkeeping to keep track of which entities have which components.

Consider using static inline template member variables

It's quite likely that you only need one ECS in a given program. In that case, you can make use of static inline template member variables, which have been possible since C++17. This is how it looks:

class ComponentManager
{
public:
    template<typename ComponentType, …>
    void addComponent(Entity entity, …)
    {
        componentPools<ComponentType>.addComponent(entity, …);
    }
    …
private:
    template<typename ComponentType>
    static inline ComponentPool<ComponentType> componentPools;
};

The static and template part are just how they always worked. The inline ensures that even if multiple source files cause instantiations of pools of the same ComponentType, they will be merged at link time (see also the One Definition Rule).

Take inspiration from existing ECS frameworks

You might want to look at some existing ECS frameworks to get some inspiration for the interface and/or performance optimizations. A well known framework for C++ is EnTT.

\$\endgroup\$
1

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.