The design goals for our ECS was for it have as much code as possible run in parallel and to do it completely lock-less. The structure we came up with alternate between two phases, Run and Sync. We made it so that a system can take in any combination of components since we found the one system per component structure restrictive. During Run all systems run in parallel
Once all systems have run the ECS can go over to Sync. At the start of Sync the outputs are collected from every system and sorted by type and target entity. These sorted outputs are sent to the target component’s sync-function. Each component type has its own user-defined sync function so the outputs can be handled as deltas, absolutes or a combination of the two. It also defines how the component behaves when receiving multiple outputs. When all the outputs have been processed the systems have their data buffers reset and the ECS is ready for the Run phase.
We determined that creating an ECS would make it possible to build our games with great modularity.
ECS is becoming more common and it also share similarities with component-based structures that are industry standard. Since we previously had mostly worked with object-oriented programming, we felt like it would be something useful to gain experience working with.
We wanted to build an engine that was heavily threaded so that we take advantage of as much CPU power as possible. It would also make it easier for us to dedicate a thread for heavier systems such as AI and Rendering. The ECS structure lent itself very well to this.
ECS is inherently very difficult to debug which is something we realised and discussed before implementing it in our engine (Memory Leek). We however did not come up with a way to mitigate this but concluded that we would most likely learn to handle the problem along the way.
Implementation & Structure
At first Oscar Åkesson and I spent some time planning the structure and building prototypes for the ECS. Later I wrote the version that ended up in the engine. Further along the year as things came up, we discussed structures and implementations but as Oscar focused on other tasks and I wanted to iterate on the ECS I was the one that implemented them.
The structure we came up was in run time split up in two parts, run and sync. During run each system run parallel and can use an arbitrary collection of components. To take advantage of as much of the CPU as possible we also ensure that it runs lockless. Since this means that a component can be handled by multiple systems at once we made the components const during run. Each system instead output the delta, or absolute state depending on component, for each component it wants to change. During sync all the outputs are collected and given to the component through its sync function. It is there all the outputs are processed and applied to the component. Afterwards the ECS goes back to run.
The ECSManager also contains an instance each of the other classes that manages the ECS, which are EntityManager, ComponentManager, SystemManager and BitmaskManager.
The BitmaskManager stores what component types has been registered and assigns them an ID. A bitmask of any component combination can be generated via a variadic template function. These bitmasks are what is used to identify component combinations across the ECS.
The EntityManager contains an array of entities. Each entry is a bool and a bitmask to tell if the entity is alive and what components it has. The ID of the entity is it index in the array. The EntityManager can take in a bitmask and return a vector of the entities to which it applies. This is used in the systems but can also be called from outside the ECS to get a list of entities with a certain set of components.
The ComponentManager holds an array for each component type. By having all components of given type at one place like this helps avoid cache-misses when the system iterates over them
Alongside the array the ComponentManager also stores the sync-function for the component. The purpose of the sync function is to update the component according to the deltas that has been outputted during Run. The sync function is what determines how the component handles the output, may it be delta, absolute or something else.
The SystemManager holds an entry for each registered system and few simple workers that functions as a threadpool. The SystemManagers purpose is to start every system during run and give it as many threads as it asks for. It is also build in such a way that by defining a macro it can go between running threaded and unthreaded. This is to help with debugging.
A SystemEntry contains an instance of the system to run, the bitmask of components the system requires to act upon an entity. Each systems can also be disabled when they aren’t required.
Upon the first call to run SystemManager creates a number of Worker instances equal to the hardware concurrency. Each Worker starts a thread running its Work function. The Work function continually check if it has a system to run or if it is to shut down. If neither is the case it yields.
And that is an overview of the ECS in our Memory Leek.