💾 Archived View for jojolepro.com › blog › 2020-08-20_event_chaining › index.gmi captured on 2023-06-16 at 16:14:24. Gemini links have been rewritten to link to archived content
⬅️ Previous capture (2023-01-29)
-=-=-=-=-=-=-
Event Chaining as a Decoupling Method in Entity-Component-System
Joël Lupien (Jojolepro, jojolepro@jojolepro.com)
In game engines, we often have a lot of dependencies between modules.
For example, the user interface often depends on the renderer, window and
input systems.This means if we are not careful, we will end up with a
user interface that works only with a single type of input.When you add a
new input method (a controller for example), then you have to also edit the
user interface functionality to be able to use this new input device. What
is surprising however, is that not only do we need to change how input works,
but also we are modifying user interface code to add a device that... probably
does the same thing we are already doing.
This situation isn't unique to user interfaces. In fact, this is very often
how dependencies are modelled.It is intuitively what we think of when we
think of logical dependencies. A depends on B, thus A refers to B.As we
have just seen however, it can cause problems of maintainability, because
we have strong coupling.
There are multiple ways to reduce coupling, depending on which paradigm
you use.Here, I will be specifically exposing a way to reduce coupling in
the context of an Entity Component System (ECS).
First of all, let's see the building pieces that we have in a ECS.I will
be using terminology from the Amethyst Game Engine, since this is what I am
most familiar with.
Entities are "units" of the game. Each player, item, user interface element,
audio source, etc.. are entities.We will not discuss much about entities
in this paper.
Components are properties of entities. Again, we will not be discussing those.
Resources are simple data that is stored inside of the ECS' context.
Systems are what drives the changes in the game. They are functions that
take data from the ECS context (resources and entity/components), perform
some computation and finally modify the ECS context.Systems can depend on
each other to sequentially perform computation. If they don't explicitly
depend on each other, they will be ordered automatically (and in parallel
when possible) in a more or less optimal ordering depending on how they use
resources or components (whether they read or write to them).If a system
writes to a resource or component, no other system can access this same
resource or component at the same time (in parallel).
Single writer/Multiple readers FIFO queues. Most data types can be inserted
through those (they only need to be thread safe).You need a registered
instance of ReaderId to read from them. ReaderId instances can be created by
getting a mutable reference to an Event Channel and calling register_reader().
Let's introduce distinct names depending on how those previous concepts are
used to improve the clarity of this paper.
A System used to "drive" the execution of another system, often through the
use of Signals.
A Signal is simply an event written in an Event Channel that has for only
purpose to drive the execution of one or multiple Systems. If we look at it
the opposite way, some System will look for Signals as a way to know if and
what kind of work they need to do.
Very similar to the concept of message passing, event chains are a way to
communicate between Systems.Simply put, you have one event that is created,
which create a second event, which create a third event, which is then consumed
(received) by a System.
In traditional message passing, you often have some System A that sends a
message to System B.We say that System A is "aware" of System B's existence.
A way to decouple this is through the use of some method of broadcasting
the message to anyone who is registered to receive it. This is how mailing
lists work.
Here however, we don't have a list of who should receive each message. Instead,
each System that wants to receive the event has a ReaderId which it can use
to read from the Event Channel. The reason for this is so that we don't have
a single place where all of the message buffers are and where every system
tries to get a mutable reference (which would heavily reduce performance).
If we continue with the example from the beginning, we can see a way to make
the user interface unaware of the input system.
ControllerPadLeft).
to a configuration structure stored as a Resource and other contextual Resources
(are we inserting text? selecting user interface elements? dragging something?)
UiEvent::Clicked(label1_entity).
Of course, we have to talk about drawbacks. Let me preface this with
the following: There is a performance cost. It is small, but it is there.
If we take the last example, we would now have one more System running (the
UI Driver) and we would have to create the Signals for the User Interface
System. In addition, in almost all cases of a Driver being present, there
is a conversion step when converting events to signals, usually a HashMap
lookup or similar.
However, we surprisingly are also getting some performance gains from this. In
the last example, instead of the User Interface System looking each frame
for the status of the input device(s), we now have this System running only
when signals are present.
We have seen how this concept of Event Chaining allows to decouple user
interfaces from input handling.Now, what else can we apply it to?Well... a
lot of things actually. Here are some examples:
Watched files are configured through a resource, which could itself
be loaded from disk and hot reloaded.
the data into a shared format. (RGBA for images, vector data for svg, vertices
for meshes, etc) A Signal is then sent (DataLoaded, DataUpdated, DataDestroyed).
example, loading or updating
a mesh into the GPU memory.
In addition to simplifying asset reloading by dividing it in steps, it now
also creates a way to notify system of data changes (a modified mesh) so that
they can update their internal states (GPU memory).This gives more power
to users, as they can now change data midway through without having to modify
the code of what depends on this data (the renderer's mesh to gpu loader).
(including audio) and
store them in a shared format.
audio data to play. For performance reasons, we can use references or indexes
to the audio data instead of copying the data into the Signal.
(AudioPlay{audio_data, from_point, speed})
a raw audio sink.
In this paper, I mentioned that Drivers can use configuration and context data
from ECS Resources.This allows for a super easy way to configure complex
behaviors. Let's take input as an example. The Input System creates raw
events. The Driver converts those into signals (or events) for other systems
to use. Let's see how we can use multiple Drivers to create complex behaviors.
UserEvent::PauseGame)
Signals. (KP_Left -> Move::Left)
FileUpdated(fun_file.jpg))
Please::SolveIt)
As you can see, lots of Drivers are possible.Now we have two choices. Either
the Drivers decide based on the context if they should create the signals
OR they always create the signals according to their configuration.In the
second case, the configurations can be changed by other Systems or by external
code, depending on your preference.Personally, I find the second option
more versatile, as it is rare that the Drivers can be made context aware in
a way that fits all use cases.In this case, it is possible to have code
that, for example, disables the UiDriver when the player is controlling
their character, saving both performance and code complexity.
In conclusion, Event Chains are a powerful and versatile tool to decouple
logical dependencies between Systems. They add a bit of complexity and
performance overhead, but they are well worth their cost in the context of
a general game engine.
For those who already worked with something similar to EventChannel and
ReaderId, you might have noticed that there are issues when a System that
consume Signals is paused. When this happens, the System stops consuming
the Signals and they stay in memory forever, causing memory leaks. The
solution for this is to store the System's ReaderId inside of a Resource
and to nullify/destroy it when the System gets paused.
Testing! You can test Systems in isolation by manually sending Signals.
Take advantage of this and test <3
Use generics! Drivers almost always do the same job: Convert one event type
into another event (or signal) type by using a table or HashMap.This means
that using generics to specify the input and output types as well as the
HashMap key and value types, creating Drivers can be done in a single line.
Document as much as possible the Systems. Since we have System and Driver
which are both Systems and Event and Signal which are both Events, it can
be quite confusing without the proper documentation to understand the role
of each piece of the puzzle.
Here are some ideas of what to document:
I released this for free/without limitations because I want to contribute
to the greater good.
If you like the work I do, please consider donating on Patreon:
Or by Bitcoin:
15NDruDUDr3KaMjt87BvUJaayEzy5c765Z
(CC0) Joël Lupien 2020-2022