This is a small project I created to better familiarize myself with the features of c++ 17/20 and to test out some stuff I had been reading about data oriented design. An ecs library seemed like a good fit for both objectives.
More detail on what ecs is can be found here and here.
Topics with the compiler-explorer logo next to them have an example that you can play around with. Ctrl/CMD+Click the icon to open it in a new window.
The following example shows the basics of the library.
import ecs; // or #include <ecs/ecs.h> if library is not built as a module.
#include <iostream>
// The component
struct greeting {
char const* msg;
};
int main() {
ecs::runtime rt;
// The system
rt.make_system([](greeting const& g) {
std::cout << g.msg;
});
// The entities
rt.add_component({0, 2}, greeting{"alright "});
// Run it
rt.update();
}
Running this will do a Matthew McConaughey impression and print 'alright alright alright '. This is a fairly simplistic sample, but there are plenty of ways to extend it to do cooler things.
- Clone this project.
- Configure- and build it.
All dependencies will be automatically downloaded.
The CI build status for msvc, clang, and gcc is currently
The library can be built as a module that can be imported with import ecs;
. You can also use the regular #include <ecs/ecs.h>
which imports the module in the header when modules are enabled. This makes it easy to support both include- and modular builds.
Enable module building by setting the option ECS_COMPILE_AS_MODULE
to true
in your CMake script (set(ECS_COMPILE_AS_MODULE ON)
) or setting it in your CMakePresets.json file.
MSVC v14.3 (2022) or clang 17+ is required to use modules. GCC support will be added when version 14 is eventually released.
Clangs ODR detection code gives false positives when used with modules, so when including other headers, make sure they appear after the module import.
// Correct, should not induce ODR errors in clang
import ecs;
#include <vector>
// Wrong, might induce ODR errors in clang
#include <vector>
import ecs;
Entities are the scaffolding on which you build your objects. There are two classes in the library for managing entities:
ecs::entity_id
is a wrapper for an integer identifier.ecs::entity_range
is the preferred way to deal with many entities at once in a concise and efficient manner. The start- and end entity id is inclusive when passed to an entity_range, soentity_range some_range{0, 100}
will span 101 entities.
There is no such thing as creating or destroying entities in this library; all entities implicitly exists, and this library only tracks which of those entities have components attached to them.
The management of entity id's is left to user.
Components hold the data that is added to entities.
There are very few restrictions on what components can be, but they do have to obey the requirements of CopyConstructible. In the example above you could have used a std::string
instead of creating a custom component, and it would work just fine.
You can add as many different components to an entity as you need; there is no upper limit. You can not add more than one of the same type.
Adding components is done with the following functions:
Adds singular values to one-or-more entities.
rt.add_component(0, 4UL, 3.14f, 6.28); // add an unsigned long, a float, and a double to entity 0
ecs::entity_id ent{1};
rt.add_component(ent, "hello"sv); // add std::string_view to entity 1
ecs::entity_range more_ents{1,100}; // entity range of ids from 1 to (and including) 100
rt.add_component(more_ents, 3, 0.1f); // add 100 ints with value 3 and 100 floats with value 0.1f
rt.add_component({1,50}, 'A', 2.2); // add a char and a double to 50 entities
// etc..
Copies the values from a user provided span that satisfies std::ranges::contiguous_range. The entity- and component count must be equal.
std::vector<int> vec{ /* ... */ }
ecs::entity_range range{1, vec.size()};
rt.add_component_span(range, vec);
Fills the components of an entity range with the result of calling a user-supplied generator function. The function must be of the format T(ecs::entity_id)
, where T
is the component type returned by the function. The function will be called, in order, for each entity id in the range. The component type is automatically deduced from the generators return type.
In the mandelbrot example, a generator is used to create the (x,y) coordinates of the individual pixels from the entity id:
constexpr int dimension = 500;
struct pos {
int x, y;
};
// ...
rt.add_component_generator({ 0, dimension * dimension},
[](ecs::entity_id ent) -> pos {
int const x = ent % dimension;
int const y = ent / dimension;
return { x, y };
}
);
Adding and removing components from entities are deferred, and will not be processed until a call to ecs::commit_changes()
or ecs::update()
is called, where the latter function also calls the former. Changes should only be committed once per cycle.
By deferring the components changes to entities, it is possible to safely add and remove components in parallel systems, without the fear of causing data-races or doing unneeded locks.
Systems holds the logic that operates on components that are attached to entities, and are built using ecs::runtime::make_system
by passing it a lambda or a free-standing function.
#include <ecs/ecs.h>
struct component1;
struct component2;
struct component3;
void read_only_system(component1 const&) { /* logic */ }
auto read_write_system = [](component1&, component2 const&) { /* logic */ }
int main() {
ecs::runtime rt;
rt.make_system(read_only_system);
rt.make_system(read_write_system);
rt.make_system([](component2&, component3&) { // read/write to two components
/* logic */
});
}
Systems can operate on as many components as you need; there is no limit.
Accessing large components in systems should be done through references to avoid unnecessary copying of the components data.
Remember to mark components you don't intend to change in a system as const
, as this will help the sceduler by allowing the system to run concurrently with other systems that also only reads from the component. There is more information available in the automatic concurrency section.
There are a few requirements and restrictions put on the lambdas:
- No return values. Systems are not permitted to have return values, because it logically does not make any sense. Systems with return types other than
void
will result in a compile time error. - At least one component parameter. Systems operate on components, so if none is provided it will result in a compile time error.
- No duplicate components. Having the same component more than once in the parameter list is likely an error on the programmers side, so a compile time error will be raised.
Parallel systems can offer great speed-ups on multi-core machines, if the system in question has enough work to merit it. There is always some overhead associated with running code in multiple threads, and if the systems can not supply enough work for the threads you will end up losing performance instead. A good profiler can often help with this determination.
The dangers of multi-threaded code also exist in parallel systems, so take the same precautions here as you would in regular parallel code if your system accesses data outside the purview of the runtime.
Adding and removing components from entities in parallel systems is a thread-safe operation and can be done from inside systems.
To disable parallelism in a system, see opts::not_parallel
.
Whenever a system is made, it will internally be scheduled for execution concurrently with other systems, if the systems dependencies permit it. Systems are always scheduled so they don't interfere with each other, and the order in which they are made is respected. If you add systems A
and B
, then B
might run concurrently with A
, but it will not run before it.
Dependencies are determined based on the components a system operate on.
If a component is written to, any system that previously read from or wrote to that component becomes a dependency, which means that those systems will be run to completion before the new system can execute. This ensures that no data-races occur.
If a component is read from, the system that previously wrote to it becomes a dependency.
Multiple systems that read from the same component can safely run concurrently.
If you need access to the entity currently being processed by a system, make the first parameter type an ecs::entity_id
. The entity will only be passed as a value, so trying to accept it as anything else will result in a compile time error.
rt.make_system([](ecs::entity_id ent, greeting const& g) {
std::cout << "entity with id " << ent << " says: " << g.msg << '\n';
});
An additional function object can be passed along to ecs::runtime::make_system
to specify the order in which components are processed. It must adhere to the Compare requirements.
ecs::runtime rt;
rt.make_system([](int, std::string) { ... },
std::less<int>());
The components of entities processed by the system, will arrive in the order of the integers on those entities.
Note: Adding a sorting function takes up additional memory to maintain the sorted state, and it might adversely affect cache efficiency. Only use it if necessary.
Entities can be filtered by marking the component you wish to filter as a pointer argument:
rt.make_system([](int&, float*) { /* ... */ });
This system will run on all entities that has an int
component and no float
component.
More than one filter can be present; there is no limit.
Note nullptr
is always passed to filtered components, so do not try and read from them.
Hierarchies can be created by adding the special component ecs::parent
to an entity:
rt.add_component({1}, ecs::parent{0});
This alone does not create a hierarchy, but it makes it possible for systems to act on this relationship data. To access the parent component in a system, add a ecs::parent<>
parameter:
rt.make_system([](ecs::entity_id id, ecs::parent<> const& p) {
// id == 1, p.id() == 0
});
The angular brackets are needed because ecs::parent
is a templated component which allows you to specify which, if any, of the parents components you would like access to.
A parents components can be accessed by specifying them in a systems ecs::parent<>
parameter. The components can the be accessed through the get<T>
function on ecs::parent
, where T
specifies the type you want to access. If T
is not specified in the components of a systems parent parameter, an error will be raised.
If an ecs::parent
has any non-filter components the ecs::parent
must always be taken as a value or a constant reference in systems, or an error will be reported.
More than one parent component can be specified; there is no upper limit.
rt.add_component(0, short{1}, long{2});
rt.add_component(1, ecs::parent{0});
rt.make_system([](ecs::parent<short, long> p) { // runs on entity 1
assert(1 == p.get<short>());
assert(2 == p.get<long>());
);
Filters work like regular component filters and can be specified on a parents components. An ecs::parent
that only consist of filters does not need to be passed as a constant reference.
rt.make_system([](ecs::parent<short*> p) { });
This system will run on all entities whose parent does not have a short
component on them.
Marking the parent itself as a filter means that any entity with a parent component on it will be ignored. Any parent components specified are ignored.
rt.make_system([](int, ecs::parent<> *p) { });
This system will run on all entities that have an ìnt
component and no ecs::parent
.
Hierarchies are topological sorted and can be processed in parallel. An entity's parent is always processed before the entity itself.
The following options can be passed along to make_system
calls in order to change the behaviour of a system. If an option is added more than once, only the first option is used.
opts::interval
is used to specify how often a system will run. The time duration between two runs may be higher than the specified times, but it will never be lower.
opts::interval
takes a millisecond count and an optional microsecond count (defaults to zero). An opts::interval<0, 0>
is considered to have no interval and will run every cycle.
rt.make_system<ecs::opts::interval<16, 667>>([](int const&) {
std::cout << "at least 16.667 ms has passed\n";
});
Systems marked as being manually updated will not be added to the scheduler, and will thus require the user to call the system::run()
function themselves.
Calls to ecs::runtime::commit_changes()
will still cause the system to respond to changes in components.
auto& manual_sys = rt.make_system<ecs::opts::manual_update>([](int const&) { /* ... */ });
// ...
rt.add_component(0, int{});
rt.update(); // will not run 'manual_sys'
manual_sys.run(); // required to run the system
This option will prevent a system from processing components in parallel, which can be beneficial when a system does little work.
It should not be used to avoid data races when writing to a shared variable not under ecs control, such as a global variable or variables catured be reference in system lambdas. Use atomics, mutexes, or even tls::collect
in these cases, if possible.
By setting a special alias in a component, using variant_of = X;
, you can mark it as part of a variant chain. This offers functionality similar to std::variant
.
// Create variant A-B-C
struct A { };
struct B { using variant_of = A; };
struct C { using variant_of = B; };
ecs::runtime rt;
// Adds 'A'
rt.add_component(0, A{});
rt.update();
// Replaces 'A' with 'C'
rt.add_component(0, C{});
rt.update();
// Replaces 'C' with 'B'
rt.add_component(0, B{});
rt.update();
Trying to add more than one variant of the same chain at the same time, will result in a compile-time error.
rt.add_component(0, A{}, B{}); // errors at compile-time
Trying to add more than one variant of the same chain at different times during execution will result in a runtime termination, but only if ECS_ENABLE_CONTRACTS_AUDIT is set to 1. Contract audit violations are ignored otherwise, which means that improper use will put the runtime in an undefined state.
rt.add_component(0, A{});
rt.add_component(0, B{});
rt.commit_changes(); // terminates at runtime
Some interesting emergent behavior was discovered during the implementation of variants, namely that you can create variant trees instead of lists.
// A
// / \
// B C
//
// or alternatively
//
// A-B
// A-C
struct A { };
struct B { using variant_of = A; };
struct C { using variant_of = A; };
ecs::runtime rt;
// Adds 'B' and 'C', which are not considered variants of each other
rt.add_component(0, B{}, C{});
rt.update();
// Replaces 'B' and 'C' with 'A'
rt.add_component(0, A{});
rt.update();
I'm not totally sure what application this has, but it works as expected, so I'm leaving it in until it explodes spectacularly.
The behavior of components can be changed by using component flags, which can change how they are managed internally and can offer performance and memory benefits.
Marking a component as a tag is used for components that signal some kind of state, without needing to take up any memory. For instance, you could use it to tag certain entities as having some form of capability, like a 'freezable' tag to mark stuff that can be frozen.
struct freezable {
using ecs_flags = ecs::flags<ecs::tag>;
};
Using tagged components in systems has a slightly different syntax to regular components, namely that they are
always passed by value. This is to discourage the use of tags to share some kind of data, which you should use the global
flag
for instead.
rt.make_system([](greeting const& g, freezable) {
// code to operate on entities that has a greeting and are freezable
});
If tag components are marked as anything other than pass-by-value, the compiler will drop a little error message to remind you.
Marking a component as immutable (a.k.a. const) is used for components that are not to be changed by systems.
This is used for passing read-only data to systems. If a component is marked as immutable
and is used in a system without being marked const
, you will get a compile-time error reminding you to make it constant.
Marking a component as transient is used for components that only exists on entities temporarily. The runtime will remove these components from entities automatically after one cycle.
struct damage {
using ecs_flags = ecs::flags<ecs::transient>;
double value;
};
// ...
rt.add_component({0,99}, damage{9001});
rt.commit_changes(); // adds the 100 damage components
rt.commit_changes(); // removes the 100 damage components
Marking a component as global is used for components that hold data that is shared between all systems the component is added to, without the need to explicitly add the component to any entity. Adding global components to entities is not possible.
struct frame_data {
using ecs_flags = ecs::flags<ecs::global>;
double delta_time = 0.0;
};
// ...
rt.add_component({0, 100}, position{}, velocity{});
// ...
rt.make_system([](position& pos, velocity const& vel, frame_data const& fd) {
pos += vel * fd.delta_time;
});
Global components can contain std::atomic<>
types, unlike regular components, due to the fact that they are never copied.
With global components it is also possible to create global systems, which are a special kind of system that only operates on global components. Global systems are only run once per update cycle.
rt.make_system([](frame_data& fd) {
static auto clock_last = std::chrono::high_resolution_clock::now();
auto const clock_now = std::chrono::high_resolution_clock::now();
std::chrono::duration<double> const diff = clock_now - clock_last;
fd.delta_time = clock_diff.count();
});