Flecs v4.0
A fast entity component system (ECS) for C & C++
|
Systems are queries + a function that can be ran manually or get scheduled as part of a pipeline. To use systems, applications must build Flecs with the FLECS_SYSTEM
addon (enabled by default).
An example of a simple system:
C
In C, a system can also be created with the ecs_system_init
function / ecs_system
shorthand which provides more flexibility. The same system can be created like this:
C++
C#
Rust
To manually run a system, do:
C
C++
C#
Rust
By default systems are registered for a pipeline which orders systems by their "phase" (EcsOnUpdate
). To run all systems in a pipeline, do:
C
C++
C#
Rust
To run systems as part of a pipeline, applications must build Flecs with the FLECS_PIPELINE
addon (enabled by default). To prevent a system from being registered as part of a pipeline, specify 0 as phase:
C
C++
C#
Rust
Because systems use queries, the iterating code looks similar:
C
C++
The run()
function can be invoked multiple times per frame, once for each matched table. The each
function is called once per matched entity.
Note that there is no significant performance difference between iter()
and each
, which can both be vectorized by the compiler.
C#
The Iter
function can be invoked multiple times per frame, once for each matched table. The Each
function is called once per matched entity.
Rust
The run
function can be invoked multiple times per frame, once for each matched table. The each
function is called once per matched entity.
Note that there is no significant performance difference between run
and each
, which can both be vectorized by the compiler.
Note how query iteration has an outer and an inner loop, whereas system iteration only has the inner loop. The outer loop for systems is iterated by the ecs_run
function, which invokes the system function. When running a pipeline, this means that a system callback can be invoked multiple times per frame, once for each matched table.
A system provides a delta_time
which contains the time passed since the last frame:
C
C++
C#
Rust
This is the value passed into ecs_progress
:
C
C++
C#
Rust
Passing a value for delta_time
is useful if you're running progress()
from within an existing game loop that already has time management. Providing 0 for the argument, or omitting it in the C++ API will cause progress()
to measure the time since the last call and use that as value for delta_time
:
C
C++
C#
Rust
A system may also use delta_system_time
, which is the time elapsed since the last time the system was invoked. This can be useful when a system is not invoked each frame, for example when using a timer.
A task is a system that matches no entities. Tasks are ran once per frame, and are useful for running code that is not related to entities. An example of a task system:
C
C++
C#
Rust
Tasks may query for components from a fixed source or singleton:
C
C++
C#
Rust
A pipeline is a list of systems that is executed when the ecs_progress
/world::progress
function is invoked. Which systems are part of the pipeline is determined by a pipeline query. A pipeline query is a regular ECS query, which matches system entities. Flecs has a builtin pipeline with a predefined query, in addition to offering the ability to specify a custom pipeline query.
A pipeline by default orders systems by their entity id, to ensure deterministic order. This generally means that systems will be ran in the order they are declared, as entity ids are monotonically increasing. Note that this is not guaranteed: when an application deletes entities before creating a system, the system can receive a recycled id, which means it could be lower than the last issued id. For this reason it is recommended to prevent entity deletion while registering systems. When this can't be avoided, an application can create a custom pipeline with a user-defined order_by
function (see custom pipeline).
Pipelines may utilize additional query mechanisms for ordering, such as cascade
or group_by
.
In addition to a system query, pipelines also analyze the components that systems are reading and writing to determine where to insert sync points. During a sync point enqueued commands are ran, which ensures that systems after a sync point can see all mutations from before a sync point.
The builtin pipeline matches systems that depend on a phase. A phase is any entity with the EcsPhase
/flecs::Phase
tag. To add a dependency on a phase, the DependsOn
relationship is used. This happens automatically when using the ECS_SYSTEM
macro/flecs::system::kind
method:
C
C++
C#
Rust
Systems are ordered using a topology sort on the DependsOn
relationship. Systems higher up in the topology are ran first. In the following example the order of systems is InputSystem, MoveSystem, CollisionSystem
:
Flecs has the following builtin phases, listed in topology order:
C
EcsOnStart
EcsOnLoad
EcsPostLoad
EcsPreUpdate
EcsOnUpdate
EcsOnValidate
EcsPostUpdate
EcsPreStore
EcsOnStore
C++
flecs::OnStart
flecs::OnLoad
flecs::PostLoad
flecs::PreUpdate
flecs::OnUpdate
flecs::OnValidate
flecs::PostUpdate
flecs::PreStore
flecs::OnStore
C#
Ecs.OnStart
Ecs.OnLoad
Ecs.PostLoad
Ecs.PreUpdate
Ecs.OnUpdate
Ecs.OnValidate
Ecs.PostUpdate
Ecs.PreStore
Ecs.OnStore
Rust
flecs::pipeline::OnStart
flecs::pipeline::OnLoad
flecs::pipeline::PostLoad
flecs::pipeline::PreUpdate
flecs::pipeline::OnUpdate
flecs::pipeline::OnValidate
flecs::pipeline::PostUpdate
flecs::pipeline::PreStore
flecs::pipeline::OnStore
The EcsOnStart
/flecs::OnStart
phase is a special phase that is only ran the first time progress()
is called.
An application can create custom phases, which can be (but don't need to be) branched off of existing ones:
The builtin pipeline query looks like this:
C
C++
Query DSL
C#
Rust
Applications can create their own pipelines which fully customize which systems are matched, and in which order they are executed. Custom pipelines can use phases and DependsOn
, or they may use a completely different approach. This example shows how to create a pipeline that matches all systems with the Foo
tag:
C
C++
C#
Rust
Note that ECS_SYSTEM
kind parameter/flecs::system::kind
add the provided entity both by itself as well as with a DependsOn
relationship. As a result, the above Move
system ends up with both:
Foo
(DependsOn, Foo)
This allows applications to still use the macro/builder API with custom pipelines, even if the custom pipeline does not use the DependsOn
relationship. To avoid adding the DependsOn
relationship, 0
can be passed to ECS_SYSTEM
or flecs::system::kind
followed by adding the tag manually:
C
C++
C#
Rust
When running a multithreaded application, switching pipelines can be an expensive operation. The reason for this is that it requires tearing down and recreating the worker threads with the new pipeline context. For this reason it can be more efficient to use queries that allow for enabling/disabling groups of systems vs. switching pipelines.
For example, the builtin pipeline excludes groups of systems from the schedule that:
Disabled
tagDisabled
tagDisabled
tagBecause pipelines use regular ECS queries, adding the EcsDisabled
/flecs::Disabled
tag to a system entity will exclude the system from the pipeline. An application can use the ecs_enable
function or entity::enable
/entity::disable
methods to enable/disable a system:
C
C++
C#
Rust
Additionally the EcsDisabled
/flecs::Disabled
tag can be added/removed directly:
C
C++
C#
Rust
Note that this applies both to builtin pipelines and custom pipelines, as entities with the Disabled
tag are ignored by default by queries.
Phases can also be disabled when using the builtin pipeline, which excludes all systems that depend on the phase. Note that is transitive, if PhaseB
depends on PhaseA
and PhaseA
is disabled, systems that depend on both PhaseA
and PhaseB
will be excluded from the pipeline. For this reason, the builtin phases don't directly depend on each other, so that disabling EcsOnUpdate
does not exclude systems that depend on EcsPostUpdate
.
When the parent of a system is disabled, it will also be excluded from the builtin pipeline. This makes it possible to disable all systems in a module with a single operation.
When calling progress()
the world enters a readonly state in which all ECS operations like add
, remove
, set
etc. are enqueued as commands (called "staging"). This makes sure that it is safe for systems to iterate component arrays while enqueueing operations. Without staging, component storage arrays could be reallocated to a different memory location, which could cause system code to crash. Additionally, enqueueing operations makes it safe for multiple threads to iterate the same world without taking locks as thread gets its own command queue.
In general the framework tries its best to make sure that running code inside a system doesn't have different results than running it outside of a system, but because operations are enqueued as commands, this is not always the case. For example, the following code would return true outside of a system, but false inside of a system:
Note that commands are only enqueued for ECS operations like add
, remove
, set
etc. Reading or writing a queried for component directly does not enqueue commands. As a rule of thumb, anything that does not require calling an ECS function/method does not enqueue a command.
There are a number of things applications can do to force merging of operations, or to prevent operations from being enqueued as commands. To decide which mechanism to use, an application has to decide whether it needs:
The mechanisms to accomplish this are sync points for 1), and immediate
systems for 2).
Sync points are moments during the frame where all commands are flushed to the storage. Systems that run after a sync point will be able to see all operations that happened up until the sync point. Sync points are inserted automatically by analyzing which commands could have been inserted and which components are being read by systems.
Because Flecs can't see inside the implementation of a system, pipelines can't know for which components a system could insert commands. This means that by default a pipeline assumes that systems insert no commands / that it is OK for commands to be merged at the end of the frame. To get commands to merge sooner, systems must be annotated with the components they write.
A pipeline tracks on a per-component basis whether commands could have been inserted for it, and when a component is being read. When a pipeline sees a read for a component for which commands could have been inserted, a sync point is inserted before the system that reads. This ensures that sync points are only inserted when necessary:
To make the scheduler aware that a system can enqueue commands for a component, use the out
modifier in combination with matching a component on an empty entity (0
). This tells the scheduler that even though a system is not matching with the component, it is still "writing" it:
C
C++
C#
Rust
This will cause insertion of a sync point before the next system that reads Transform
. Similarly, a system can also be annotated with reading a component that it doesn't match with, which is useful when a system calls get
:
C
C++
C#
Rust
By default systems are ran while the world is in "readonly" mode, where all ECS operations are enqueued as commands. Readonly here means that structural changes, such as changing the components of an entity are deferred. Systems can still write component values while in readonly mode.
In some cases however, operations need to be immediately visible to a system. A typical example is a system that assigns tasks to resources, like assigning plates to a waiter. A system should only assign plates to a waiter that hasn't been assigned any plates yet, but to know which waiters are free, the operation that assigns a plate to a waiter must be immediately visible.
To accomplish this, systems can be marked with the immediate
flag, which signals that a system should be ran while the world is not in readonly mode. This causes ECS operations to not get enqueued, and allows the system to directly see the results of operations. There are a few limitations to immediate
systems:
immediate
systems are always single threadedThe reason for the latter limitation is that allowing for operations on the iterated over entity would cause the system to modify the storage it is iterating, which could cause undefined behavior similar to what you'd see when changing a vector while iterating it.
The following example shows how to create a immediate
system:
C
C++
C#
Rust
This ensures the world is not in readonly mode when the system is ran. Operations are however still enqueued as commands, which ensures that the system can enqueue commands for the entity that is being iterated over. To prevent commands from being enqueued, a system needs to suspend and resume command enqueueing. This is an extra step, but makes it possible for a system to both enqueue commands for the iterated over entity, as well as do operations that are immediately visible. An example:
C
C++
C#
Rust
Note that defer_suspend
and defer_resume
may only be called from within a immediate
system.
Systems in Flecs can be multithreaded. This requires both the system to be created as a multithreaded system, as well as configuring the world to have a number of worker threads. To create worker threads, use the set_threads
function:
C
C++
C#
Rust
To create a multithreaded system, use the multi_threaded
flag:
C
C++
C#
Rust
By default systems are created as single threaded. Single threaded systems are always ran on the main thread. Multithreaded systems are ran on all worker threads. The scheduler runs each multithreaded system on all threads, and divides the number of matched entities across the threads. The way entities are allocated to threads ensures that the same entity is always processed by the same thread, until the next sync point. This means that in an ideal case, all systems in a frame can run until completion without having to synchronize.
The way the scheduler ensures that the same entities are processed by the same threads is by slicing up the entities in a table into N slices, where N is the number of threads. For a table that has 1000 entities, the first thread will process entities 0..249, thread 2 250..499, thread 3 500..749 and thread 4 entities 750..999. For more details on this behavior, see ecs_worker_iter
/flecs::iterable::worker_iter
.
Systems in Flecs can also be multithreaded using an external asynchronous task system. Instead of creating regular worker threads using set_threads
, use the set_task_threads
function and provide the OS API callbacks to create and wait for task completion using your job system. This can be helpful when using Flecs within an application which already has a job queue system to handle multithreaded tasks.
C
C++
C#
Rust
For simplicity, these task callbacks use the same format as Flecs ecs_os_api_t
thread APIs. In fact, you could provide your regular os thread api functions to create short-duration threads for multithreaded system processing. Create multithreaded systems using the multi_threaded
flag as with ecs_set_threads
above.
When ecs_progress
is called, your ecs_os_api.task_new_
callback will be called once for every task thread needed to create task threads on demand. When ecs_progress
is complete, your ecs_os_api.task_join_
function will be called to clean up each task thread. By providing callback functions which create and remove tasks for your specific asynchronous task system, you can use Flecs with any kind of async task management scheme. The only limitation is that your async task manager must be able to create and execute the number of simultaneous tasks specified in ecs_set_task_threads
and must exist for the duration of ecs_progress
.
When running a pipeline, systems are ran each time progress()
is called. The FLECS_TIMER
addon makes it possible to run systems at a specific time interval or rate.
The following example shows how to run systems at a time interval:
C
C++
C#
Rust
The following example shows how to run systems every Nth tick with a rate filter:
C
C++
C#
Rust
Instead of setting the interval or rate directly on the system, an application may also create a separate entity that holds the time or rate filter, and use that as a "tick source" for a system. This makes it easier to use the same settings across groups of systems:
C
C++
C#
Rust
Interval filters can be paused and resumed:
C
C++
C#
Rust
An additional advantage of using shared interval/rate filter between systems is that it guarantees that systems are ran at the same tick. When a system is disabled, its interval/rate filters aren't updated, which means that when the system is reenabled again it would be out of sync with other systems that had the same interval/rate specified. When using a shared tick source however the system is guaranteed to run at the same tick as other systems with the same tick source, even after the system is reenabled.
One tick source can be used as the input of another (rate) tick source. The rate tick source will run at each Nth tick of the input tick source. This can be used to create nested tick sources, like in the following example:
C
C++
C#
Rust
Systems can also act as each others tick source:
C
C++
C#
Rust