ECS

Documentation Objective

This document explains how to use Aether Engine’s Entity Component System.

Overview

Aether Engine provides an entity component system (ECS) to manage the data associated with game entities and the functionality that updates them. Entities are collections of components, which store data and are updated by the corresponding system. Internally the ECS manages the layout of data related to all components and all entities. Aether Engine does not require you to use its ECS to manage entities but it provides a convenient interface with Aether’s other functionalities.


Prerequisites

In order to begin using the ECS the project needs to be setup with simulate.hh and simulate.cc source files in which to implement the simulation code and user_definitions.hh to define common types. Steps to create a basic Aether Engine coding project can be found in the technical documentation under Simulation/General. This documentation assumes that you have data types ready to store the attributes of your components.


Walkthrough

Defining components

In user_definitions.hh the components which group together data are defined. These component types are then grouped in to a tuple. This tuple is refered as a collection. In the following example two components are defined, the first one contains data that is going to be used in all entities. The second component contains the data necessary for basic physics to be applied.

// user_definitions.hh
...
struct component_common {
uint64_t id;
colour_t colour;
};
struct component_physics {
vec3f position;
vec3f velocity;
};
AETHER_SERDE_DERIVE_TRIVIAL(component_common)
AETHER_SERDE_DERIVE_TRIVIAL(component_physics)
typedef std::tuple<
component_common,
component_physics> component_types;
...

As entity data moves across machines in Aether, it is necessary to convert components to and from a format that can be sent across machines. Components that are “trivial” (i.e. are valid to copy using memcpy) can have serialization and deserialization routines defined for them using the macro AETHER_SERDE_DERIVE_TRIVIAL.

Declaring the ECS

In simulate.hh then new ECS type is defined to be used as the state of a simulation cell. It is templated on the octree traits (see the Simulation/General under technical documentation)) and the component types tuple.

// simulate.hh
#include <aether/demo/ecs/ecs.hh>
#include "user_definitions.hh"
...
using user_cell_state = aether::ecs<octree_traits, component_types>;
...

Entities are created in simulate.cc. In the following example initialise_world function (see Simulation/General under technical documentation is used to create some entities at random positions in the world at the start of the simulation but they can be created at other points such as in receive_messages or cell_tick as part of game logic.

// simulate.cc
#include <aether/demo/ecs/ecs.hh>
#include "user_definitions.hh"
...
void user_cell_state_impl::initialise_world(const aether_state_type &aether_state) {
auto &state = get_store();
// Create an update set in which to accumulate the new entity data. This
// works as an RAII structure, committing the accumulated changes to the
// state when the update instance is destroyed.
auto update = state.create_update_set();
// Create a random number generator
std::random_device rd;
std::mt19937 gen(rd());
std::uniform_real_distribution<> rnd_position(0.0, CELL_SIDE_LENGTH);
std::uniform_real_distribution<> rnd_velocity(-1.0, 1.0);
// We'll create 10 new entities
for (int i = 0; i < 10; ++i) {
// Create a new entity
auto entity = update.new_entity_local();
// Add the common data to the entity
auto common = entity.create_component<c_common>();
common->id = generate_unique_id();
common->colour = colour(1.0, 0.0, 0.0);
// Add the physics data to the entity
auto physics = entity.create_component<c_physics>();
physics->position = {
rnd_position(),
rnd_position(),
rnd_position()
};
physics->velocity = {
rnd_velocity(),
rnd_velocity(),
rnd_velocity()
};
}
}
`...

When an entity needs to be removed from the simulation it can be removed following the below example

void user_cell_state_impl::remove_entity(uint64_t id_to_remove)
{
auto entities = state.local_entities<c_physics, c_common>();
auto update = state.create_update_set();
for (auto entity : entities) {
auto& id = entity.get<c_common>()->id;
if (id == id_to_remove){
update.drop_entity(entity);
}
}
}

Components can be added to an entity as shown in the example below

void user_cell_state_impl::add_component(uint64_t id_to_add)
{
auto entities = state.local_entities<c_common>();
for (auto entity : entities) {
auto& id = entity.get<c_common>()->id;
if (id == id_to_add){
// Add the physics data to the entity
auto physics = entity.create_component<c_physics>();
physics->position = {
rnd_position(),
rnd_position(),
rnd_position()
};
physics->velocity = {
rnd_velocity(),
rnd_velocity(),
rnd_velocity()
};
}
}
}

Defining Systems

System functionality is implemented by defining a functor that will be called to update the data in the entities on every tick. In this example the physics component is updated given a constant gravitational force.

// simulate.cc
...
struct physics_update_system {
// The accessed_components type is used to indicate which components this system uses
using accessed_components = std::tuple<c_physics>;
// A constrained_ecs is then used to provide access to just the accessed components
using ecs_type = aether::constrained_ecs<user_cell_state, accessed_components>;
// Type aliases to aid readability
using aether_state_type = aether_cell_state<octree_traits>;
// The functor takes the cell state, ecs data and a time delta for the current tick
// as parameters
void operator()(
const aether_state_type& aether_state,
ecs_type& state,
float delta_time) {
// Iterate over all the entities and initialise acceleration to zero
for (auto entity: state.local_entities<c_physics>()) {
// Fetch the physics component for this entity
auto entity_physics = entity.get<c_physics>();
// Update the velocity given a gravitational force
entity_physics->velocity += vec3f(0.0, -9.8, 0.0) * delta_time;
// Update the position based on the new velocity
entity_physics->position += entity_physics->velocity * delta_time;
}
}
};
...

Systems declare the ECS components they will access using the accessed_components member type. When the ECS calls operator(), it will provide a constrained_ecs which has a similar interface to the ECS, but only allows access to the specified components. Having systems declare the components they reference allows for implementations to run multiple systems in parallel (though Aether’s ECS does not do this currently).

Systems are registered with the ECS in the initialise_cell function.

// simulate.cc
...
void user_cell_state_impl::initialise_cell(const aether_state_type &aether_state) {
auto &state = get_store();
...
state.add_system<physics_update_system>();
...
}
...

The systems are then executed to update the state of all entities in the cell_tick function.

// simulate.cc
...
void user_cell_state_impl::cell_tick(const aether_state_type &aether_state, float delta_time) {
auto &state = get_store();
...
state.tick(aether_state, delta_time);
...
}
...

Advanced Topics

Custom Component Serialization

Using the AETHER_SERDE_DERIVE_TRIVIALmacro is useful for serializing simple components, but insufficient for more complex components that may contain dynamically allocated data structures or members that cannot be byte-copied. Aether Engine contains serialization functionality that lives in the namespace aether::serde.

For serializing user-defined types, it is possible to add a single function, serde_visit that aether::serde will call to both serialize and deserialize a value. The sd parameter may be a serializer or deserializer and the &operator is used to visit each component. serde_visit may either be defined as a member function, or outside the type:

struct component {
int a;
float b;
// Member function form
template<typename SD>
void serde_visit(SD &sd) {
sd & a & b;
}
};
// Non-member function form
template<typename SD>
void serde_visit(SD &sd, component &c) {
sd & c.a & c.b;
}

This works if the serialization and deserialization operations are inverses of each other, but insufficient if the decision about what data to send and receive is only known at run-time. In this case, the definition of serde_save for serializing and serde_load for deserializing are needed. Like serde_visit, they have both member and non-member forms. Let’s look at how to implement serialization for C++'s std::optional:

template<typename SD, typename T>
void serde_save(SD &sd, std::optional<T> &opt) {
const bool present = opt.has_value();
serde_save(sd, present);
if (present) {
serde_save(sd, opt.value());
}
}

In this case, a bool is first serialized to indicate if the value is present, then serialize the value. Similarly, for deserializing, we only attempt to deserialize a value once we know the optional was non-empty.

template<typename SD, typename T>
void serde_load(SD &sd, std::optional<T> &opt) {
bool present;
serde_load(sd, present);
if (present) {
T value;
serde_load(sd, value);
opt.emplace(std::move(value));
}
}

Fortunately, aether::serde already provides implementations for std::optional and std::vector.

Custom Stateful Serialization

For even more advanced serialization, defining a custom serialization context may be necessary. This is a struct which can hold state related to the serialization of multiple entities. For example to serialize multiple entities at once in a handover step. This is part of how the PhysX integration works, relying on PhysX' own serialization method, batching entities to be sent to the same process.