Flecs v4.0
A fast entity component system (ECS) for C & C++
|
Observers are a mechanism that allows applications to react to events. Events can be either user defined or builtin, where the latter communicates changes in the ECS such as adding and removing components. Observers are similar to systems, in that they are queries that are combined with a callback. The difference between systems and observers is that systems are executed periodically for all matching entities, whereas observers are executed whenever a matching event occurs.
C
C++
C#
Rust
This section goes over basic observer features.
Observers provide a flexible out of the box event delivery mechanism for applications. It is not a one-size-fits all feature however, and this section provides information that helps with making a decision on whether observers are a good fit, or not so much.
The first thing that is important to know about is that observers are primarily a mechanism for delivering events that match queries. In a way they can be considered as the reactive counterpart to systems. This means that almost all of the flexibility and features that Flecs queries provide are also available to observers, which goes far beyond what typical event implementations provide.
That flexibility does come at a cost however. For most non-trivial observers, a query has to be evaluated before the observer is invoked. This means that for simple use cases, a basic event queue is always going to outperform observers.
Observers may not provide the features that are required for an event implementation. Here are a few things that observers can't (easily) do:
Good use cases for observers are scenarios where you need to respond to a structural change in the ECS, like a component that is being added or removed to an entity. Another good use case for observers is if you need to respond to changes in a component that is always assigned through a set
operation. A typical example is a Window
component, where you can resize a window by setting the component.
Another good application for observers is when you have events that are infrequent (like a window resize) and the builtin observer API provides everything that's needed.
If you find yourself adding or removing components just to trigger observer events, that's a bad application for observers. Not only would that be an expensive solution for a simple problem, it would also be unreliable because features like command batching impact how and when events are emitted.
Another rule of thumb is that if you can solve something with a system, it should probably be solved with a system. Running something every frame may sound expensive when compared to reacting to aperiodic events, but systems are much more efficient to run, and have more predictable performance. You can also use marker tags in combination with a not operator to prevent a system from running repeatedly for the same entity.
Hooks at face value appear to provide functionality that is similar to observers. There are on_add
, on_remove
and on_set
hooks, just as there are observers that are invoked for OnAdd
, OnRemove
and OnSet
events. The intended use cases for hooks and observers are almost opposites of each other. What gives?
Hooks are part of the "interface" of a component, just like how constructors and destructors are. You could consider hooks as the counterpart to OOP methods in ECS. They define the behavior of a component, but can only be invoked through mutations on the component data. You can only configure a single on_add
, on_remove
and on_set
hook per component, just like you can only have a single constructor and destructor. Hooks also receive priority treatment: they are always invoked before observers -or in the case of a remove operation- after observers.
Observers on the other hand are a mechanism that enable other parts of the application to respond to events related to a component. There can be many observers for a single component, registered by different parts of the application.
Here's a list with the differences between hooks and observers:
Observers can subscribe to OnAdd
events to get notified whenever a component, tag or pair is added to an entity. An event only fires when the component is actually added to the entity, so not on each add
operation. An example:
C
C++
C#
Rust
An OnAdd
observer is invoked after a component constructor and on_add
hook is invoked. If an observer accesses the value of a component it will be a valid constructed object. However, if an OnAdd
observer was invoked as part of a set
operation, the value assigned to the component in the set
operation will not(!) be visible on the OnAdd
observer. This means that for components that do not have a constructor, the component value passed to the observer will be uninitialized.
Observers can subscribe to an OnSet
event to get notified whenever a component is assigned with a new value. An OnSet
event will be generated each time the set
operation is called, or when modified
is called. OnSet
observers are not invoked when a system directly modifies a component. An application will have to manually call modified
to make sure observers OnSet
are invoked. An example:
C
C++
C#
Rust
To ensure that OnSet events can be used reliably to detect component changes, events can be produced by operations that change inheritance relationships or operate on inherited from components. This is enabled by default for components with the (OnInstantiate, Inherit)
trait. To prevent this behavior, add the self
modifier to an observer term. The following inheritance scenarios produce OnSet events. All scenarios assume that the component has the (OnInstantiate, Inherit)
trait.
When an IsA pair is added to an entity, an OnSet event is generated for each newly inherited component:
C
C++
C#
Rust
If the base entity has a component that the entity already had no event is generated. Similarly, if a component from a base entity is already provided by another base entity, and the new base entity does not become the primary source for the component, no OnSet event is generated. A base entity is the primary source for a component if:
When an overridden component is removed, the inherited component is reexposed which effectively changes the value of the component for the entity. An OnSet event will be produced for the inherited component:
C
C++
C#
Rust
When an inherited component is modified, an OnSet event is propagated to all entities that inherit the component:
C
C++
C#
Rust
Observers can subscribe to OnRemove
events to get notified whenever a component, tag or pair is removed from an entity. An event only fires when the component is actually removed from the entity, so not on each remove
operation. An example:
C
C++
C#
Rust
A single observer can subscribe for multiple events:
C
C++
C#
Rust
The iterator object provided to the observer callback provides information on which event triggered. An example:
C
C++
C#
Rust
Alternatively, an observer can also use Wildcard
as event, which will create an observer that listens for any kind of event that matches the observer. Wildcard event observers do add significant overhead to ECS operations for a specific component, so they should be used sparingly. A typical use case for wildcard event observers is logging or debugging. An example:
C
C++
C#
Rust
Observers use queries to match events. This makes observers similar to systems, which are also callbacks invoked for matching entities, except that observers match their query against events. This means that observers can match multiple components, use operators, query traversal and more. A simple example:
C
C++
C#
Rust
Observers with multiple terms will only be invoked for entities that match all terms. An example:
C
C++
C#
Rust
Internally observers with multiple terms are implemented with multiple single-term observers. Whenever a single-term observer triggers, the observer query is evaluated against the source of the event. Something to consider is that single-term observers do not have the query evaluation step, which makes them more performant than multi-term observers.
A multi-term observer by default will trigger for events on any term, as long as the event source matches the observer query. In some scenarios this is not desirable, and an observer should only trigger on one or more specific terms, while applying the other terms only as a filter. This can be accomplished with filter terms. The following example shows how:
C
C++
C#
Rust
When an OnSet observer requests both components and tags, the events for the tag terms are "downgraded" to an OnAdd event. The reason this happens is because tags cannot be set, and can therefore not produce OnSet events. Downgrading the event (as opposed to failing to create the observer) allows for OnSet observers that have both components and tags. An example:
C
C++
C#
Rust
Because observers match events against queries, this also means they support all of the query operators such as and
, optional
, or
and not
. The not
operator is noteworthy, as it needs to invert the event to make sure the observer is triggered correctly. An example:
C
C++
C#
Rust
Inversion also works the other way around: a not
term will be inverted to use an OnAdd
event for an OnRemove
observer. Note that in either case, the observer will be invoked with the observer event, e.g. OnAdd
for an OnAdd
observer, and OnRemove
for an OnRemove
observer.
Inversion also applies to OnSet
events: an OnSet
event will be inverted to OnRemove
when it is used in combination with a not
term.
A monitor is an observer that fires when an entity starts and stops matching a query. Whether an entity starts or stops matching is communicated with an OnAdd
or OnRemove
event. Monitors can only specify a single Monitor
event. An example:
C
C++
C#
Rust
Monitors are implemented by evaluating the observer query twice: once on the previous archetype of the entity, and one on the current archetype of the entity. The following table shows when the monitor observer is invoked:
Previous matches | Current matches | Invoked with event |
---|---|---|
No | No | - |
No | Yes | OnAdd |
Yes | Yes | - |
Yes | No | OnRemove |
Note that because monitors have to evaluate the query twice, they are more expensive to evaluate than regular observers.
Observers can be created with the "yield existing" property, which invokes the observer with all entities that already match the observer. This can make it easier to make code order-independent, as entities created before the observer will still trigger the observer. Yield existing only works with OnAdd
, OnSet
and OnRemove
events. An example:
C
C++
C#
Rust
When yield_existing
is enabled on an OnRemove
observer, the observer will be invoked with matching entities when the observer is deleted. This makes symmetric event handling (each OnAdd
is matched by an OnRemove
) easier in scenarios where entities outlive the observer.
Applications can customize the behavior of yield_existing with the following observer flags:
Flag | Description |
---|---|
EcsObserverYieldOnCreate | Yield results on observer creation |
EcsObserverYieldOnDelete | Yield results on observer deletion |
These flags can be set on the flags_
member of ecs_observer_desc_t
. These flags should not be set at the same time as .yield_existing
. An example:
C
C++
C#
Rust
Observers can be created with fixed source terms, which are terms that are matched on a single entity. An example:
C
C++
C#
Rust
Observers may match terms on multiple different sources. However, when an observer matches components both on the $this
source (default) and on a fixed source, the fixed source terms will not match events. The reason for this is that otherwise emitting an event for a fixed source term would mean iterating all matching entities for the $this
term. If an observer only has fixed source terms, events will be matched for each of the terms.
Singletons are a special case of fixed source term, where the component is matched on itself. An example:
C
C++
C#
Rust
When an observer has a query that uses (up) relationship traversal, events are propagated along the relationship edge. For example, when an observer requests component Position
from a parent entity, setting Position
on the parent will propagate an OnSet
event along the ChildOf
edge, notifying all child entities of the parent.
Events propagate until a leaf entity is found, or an entity with the propagated component is found. For example, if an event for Position
is propagated from a parent to a child with Position
, that event will not be propagated to the child's children. This ensures that the results are consistent with up traversal, where a relationship is traversed upwards until the first entity with the component is found. An example:
C
C++
C#
Rust
Event forwarding, like event propagation, is a mechanism that propagates events along relationship edges. The difference between propagation and forwarding is that event forwarding produces events when a relationship pair is added to an entity. For example, if a (ChildOf, my_parent)
pair is added to an entity, and my_parent
has components Position
and Velocity
, an OnAdd
event is produced for each component and emitted for the child.
Event forwarding allows applications to write order independent code, where it doesn't matter whether a relationship pair was added before or after the target of that pair received new components. It is the opposite of event propagation: where event propagation "pushes" an existing event downwards, event forwarding "pulls" new events from parent entities.
Only reachable components are forwarded. If an entity has a parent and grandparent that both have Position
, only the Position
component from the parent will result in an event. Just like with event propagation, this ensures consistency with the behavior of up traversal in queries.
Both adding and removing pairs can result in event forwarding, where adding a pair results in forwarded OnAdd
events, and removing a pair results in forwarded OnRemove
events. OnSet
events can also be forwarded, but is only supported for IsA
pairs, where adding an (IsA, my_prefab)
pair will result in an OnSet
event for all reachable components of that prefab.
The following code shows an example of event forwarding:
C
C++
C#
Rust
Applications can register custom events to reuse the observer mechanism for purposes other than monitoring ECS events. Custom events, just like builtin events, require three pieces of information:
Consider adding Position
to entity my_entity
. This event would look like:
OnAdd
Position
my_entity
The difference for a custom event is that it replaces the event with a custom entity id that is created by the application. This is what a custom event could look like:
Synchronized
Position
my_entity
Just like with regular events, the entity must have the component that is emitted. This ensures that we can safely pass a reference to the component to an observer callback.
Custom events are emitted with the emit
or enqueue
functions (more later on the latter). The following example shows how to emit and listen for a custom event:
C
C++
C#
Rust
In many cases an application may want to emit an event for a specific entity without also specifying a component. This is enabled by entity observers. Entity observers are regular observers with the Any
wildcard specified as the component, essentially expressing that an observer is interested in the source and the event but not in the component.
The following code shows an example of how to use entity observers:
C
C++
C#
Rust
Events can be components, which makes it possible to add event-specific data:
C
C++
C#
Rust
Events can be emitted with either the emit
or enqueue
operation. The emit
operation invokes observers directly, whereas enqueue
will enqueue the event in the command queue if the world is in deferred mode. When the world is not in deferred mode, enqueue
defaults to the behavior of emit
.
When enqueue
adds an event to the command queue, the event data is copied in, meaning that the application does not need to keep the event data alive. This is done using the regular copy
hook that can be registered using ecs_set_hooks
. In C++ the copy assignment operator is used. Note that as a result, for an event with data to be enqueued in C++, the type has to be copyable. If no copy hook is registered, the behavior defaults to a memcpy
.
Observers are always executed when the operation that triggered the observer happens, on the thread where the operation is executed. This means that when a component is added to an entity, all OnAdd
observers will have been invoked by the time the operation is executed. When operations are deferred, because observers are always executed when the operation is executed, invoking the observer will also be delayed. In practice this often means that since most operations are deferred, most observers will also be invoked during sync points.
An example:
C
C++
C#
Rust
Just like systems, observers can be disabled which prevents them from being invoked. Additionally, when the module in which an observer is stored is disabled, all observers are disabled as well. The same happens for systems (when using the default pipeline). This makes it easy to disable all logic in a module with a single operation.
When observers are invoked, there are a few things to keep in mind when considering the order in which things happen:
When two observers match the same event, the order in which they are executed is undefined. Applications should never rely on observer order, not even if the observed order is apparently "correct" for the application logic. The order in which observers, while deterministic, depends on many different things, and it is easy to break the order.
No assumptions should be made about the order in which events are emitted for different entities. This allows the implementation to batch commands for a single entity together, which can greatly improve efficiency.
OnAdd and OnRemove observers may be triggered in an order that is different from the order in which the events were emitted, even within the same entity. This is also done to allow the implementation to batch commands.
The order in which OnSet events are delivered is the same as in which they were emitted. This allows applications to make assumptions about the (component) state of other entities in the observer code and makes it easier to write code that is agnostic to whether operations are deferred or executed immediately.
Just like for OnSet events, the ordering for custom events is maintained.
Hooks always have a well defined order with respect to events:
on_add
hooks are invoked before OnAdd
eventson_set
hooks are invoked before OnSet
eventson_remove
hooks are invoked after OnRemove
events.When a parent and its children are deleted, OnRemove
observers will be invoked for children first, under the condition that there are no cycles in the relationship graph of the deleted entities. This order is maintained for any relationship that has the (OnDeleteTarget, Delete)
trait (see the Component Traits manual for more details).
When an entity graph contains cycles, order is undefined. This includes cycles that can be formed using different relationships.