The simplest implementation of Entity Component System

Hello!
 
 
We start the fourth stream "The C ++ Developer" , one of the most active courses we have, judging by real meetings, where to communicate with Dima Shebordaev come not only "crusaders" :) Well, in general, the whole course has grown to one of the largest in our country, it remains unchanged that Dima conducts open lessons and we select interesting materials before the start of the course.
 
 
Go!
 
 

Introduction


 
The Entity Component System (ECS) is now at the peak of popularity as an architectural alternative that emphasizes the principle of Composition over inheritance. In this article, I will not go into the details of the concept, since there are already enough resources on this topic. There are many ways to implement ECS, and, I usually choose pretty complex ones that can confuse beginners and require a lot of time.
 
 
In this post, I will describe a very simple way of implementing ECS, a functional version of which almost does not require code, but completely follows the concept.
 
 
The simplest implementation of Entity Component System
 
 

ECS


 
Speaking about ECS, people often mean different things. When I talk about ECS, I mean a system that allows you to define entities that have zero or more pure data components. These components are selectively processed by pure logic systems. For example, the entity E is tied to the position, speed, hitbox, and health of the component. They simply store the data in themselves. For example, a health component can store two integers: one for current health and one for maximum. The system can be a health regeneration system that finds all instances of the health component and increases them by 1 every 120 frames.
 
 

A typical implementation on C ++ is


 
There are many libraries offering ECS ​​implementations. Usually, they include one or more items from the list:
 
 
 
Inheritance of the base Component /System class GravitySystem: public ecs :: System ;  
Active use of templates;  
Both that, and another in some CRTP form;  
Class EntityManager , which manages the creation /storage of entities in an implicit way.  

 
A few quick examples from Google:
 
 

 
All these ways have the right to life, but there are some disadvantages in them. The way they process data opaque means that it will be difficult to understand what's going on inside, and whether the performance slows down. This also means that you have to examine the entire layer of abstraction and make sure that it fits well into the already existing code. Do not forget about the hidden bugs, which probably hidden a lot in the amount of code that you have to debug.
 
 
The template-based approach can greatly affect compilation time and how often you need to rebuild the build. While inheritance concepts can degrade performance.
 
 
The main reason why I think these approaches are excessive is that the problem they solve is too simple. In the end, these are simply additional data components related to the entity, and their selective processing. Below I will show a very simple way of how this can be realized.
 
 

My simple approach is


 
Essence
 
 
In some approaches, the Entity class is defined, in others they work with entities as ID /handle. In the component approach, the entity is nothing more than the components associated with it, and for this the class is not needed. The essence will clearly exist, based on the components associated with it. For this we define:
 
 
    using EntityID = int64_t; //only for the purposes of this article, int64_t is an arbitrary choice of    

 
Entity Components
 
 
Components are different types of data associated with existing entities. We can say that for each entity e, e will have zero and more accessible types of components. In fact, these are component-wise key-value relationships and, fortunately, for this, there are standard library tools in the form of maps.
 
 
So, I define the components as follows:
 
 
    struct Position
{
float x;
float y;
};};
struct Velocity
{
float x;
float y;
};};
struct Health
{
int max;
int current;
};};
template
using ComponentMap = std :: unordered_map ;
using Positions = ComponentMap ;
using Velocities = ComponentMap ;
using Healths = ComponentMap ;
struct Components
{
Positions positions;
Velocities velocities;
Healths healths;
};};

 
This is enough to denote entities through components, as expected from ECS. For example, to create an entity with a position and health, but without speed, you need:
 
 
    //given a Components instance with
EntityID newID = /* get new entity ID * /;
c.positions[newID]= Position {0.0f, 0.0f};
c.healths[newID]= Health {10? 100};

 
To destroy an entity with a given ID, we are just .erase () her from every card.
 
 
Systems
 
 
The last component we need is a system. It is a logic that works with components to achieve a certain behavior. Since I like to simplify everything, I use normal functions. The health regeneration system mentioned above may simply be the next function.
 
 
    void updateHealthRegeneration (int64_t currentFrame, Healths & healths)
{
if (currentFrame% 120 == 0)
{
for (auto &[id, health]: healths)
{
if (health.current < health.max)
++ health.current;
}
}
}
.

 
We can place a call to this function in a suitable place in the main loop and pass it to the health component store. Because the health store only contains entries for entities that have health, it can handle them in isolation. This also means that the function takes only the necessary data and does not touch irrelevant.
 
 
And what if the system works with more than one component? Say, a physical system that changes position based on speed. To do this, we need to intersect all the keys of all involved component types and iterate their values. At this point, the standard library is not enough, but writing helpers is not so difficult. For example:
 
 
    void updatePhysics (Positions & positions, const Velocities & velocities)
{
//this is a pattern of the vari- able function that takes N maps and
//returns the set of IDs present on all maps.
std :: unordered_set targets = mapIntersection (positions, velocities);
//now target'y will contain only those records in which
//there is both a position and a speed for safe access to the cards.
for (EntityID id: targets)
{
Position & pos = positions.at (id);
const Velocity & vel = velocities.at (id);
pos.x + = vel.x;
pos.y + = vel.y;
}
}

 
Or you can write a more compact helper, allowing more efficient access through iteration instead of searching.
 
 
    void updatePhysics (Positions & positions, const Velocities & velocities)
{
//this is a pattern of the vari- able function that defines the intersection
//keys on the cards. It will heter these keys and pass the data
//from maps directly to this functor.
intersectionInvoke (positions, velocities, ?[](EntityID id, Position & pos, const Velocity & vel)
{
.pos.x + = vel.x;
.pos.y + = vel.y;
}
);
}

 
Thus, we got acquainted with the basic functionality of the conventional ECS.
 
 

Advantages of


 
This approach is very effective, since it is built from scratch, without limiting abstraction. You do not have to integrate external libraries or adapt the code base to predefined ideas of what the entities /components /systems should be.
 
And since this approach is completely transparent, on its basis you can create any utilities and helpers. This implementation grows with the needs of your project. Most likely, for simple prototypes or games for game jam, you will have enough of the functionality described above.
 
 
Thus, if you are new to the whole ECS sphere, such a straightforward approach will help you understand the basic ideas.
 
 

Limitations


 
But, like in any other method, there are some limitations. In my experience, this is the implementation using unordered_map in any nontrivial game will lead to performance problems.
 
 
Iteration of key crossing on several instances unordered_map with a lot of entities scales poorly, because you are actually doing N * M search operations, where N is the number of intersecting components, M is the number of matching entities, and unordered_map not very good at caching. This problem can be eliminated by using a key-value repository that is more suitable for iteration instead of unordered_map .
 
 
Another limitation is boilerplating. Depending on what you are doing, identifying new components can become tedious. You may need to add an advertisement not only in the Components structure, but also in the spawn function, serialization, debugging utility functions, and so on. I ran into this myself and solved the problem by generating code - I defined the components in external json files and then generated the C ++ components and helper functions at the build stage. I'm sure you can find other ways on the basis of templates to eliminate any boilerplate-problems that you will encounter.
 
 
THE END
 
 
If there are questions and comments, you can leave them here or go to open lesson to Dima , listen to him and ask around there already.
+ 0 -

Add comment