General (Simulation)

Documentation Objective

This document describes general elements of the Aether Engine based simulation.

Overview

An Aether Engine simulation consists of a number of interacting processes. Worker processes, or simply workers, are associated with regions of simulated space and are responsible for calculating updates to the state of that space. The workers are coordinated by a process known as the manager. The manager maintains a mapping between regions of space and the worker responsible for it.

The manager also acts as a timekeeper for the simulation. It owns the simulation loop, determines the size of the time steps taken and keeps workers in sync.

Walkthrough

When developing a new simulation an implementation of both the manager and worker processes must be provided. A basic setup for both of these processes can be found in the project template and example implementations can be found in the Physics Demo and Aether Hunt demo code bases.

By convention the code for the manager process is written in a file called main.cc. This file must define a main function in which the simulation is configured and the simulation loop is implemented.

We begin by setting some basic configuration arguments:

// main.cc
int main(int argc, char *argv[])
{
hadean::init();
aether::log::init("AE_Manager", hadean::pid::get());
aether::log::set_level(aether::log::level::INFO);
struct arguments arguments;
arguments.workers = 8;
arguments.tickrate = 15;
arguments.cell_level = 6;
argument_parse(argc, argv, &arguments);
auto static_args = arguments.to_octree_params<octree_traits>();
static_args.feature_flags = PHASE_BARRIERS;

These options are:

  • arguments.workers: the number of workers to be spawned initially (integer);
  • arguments.ticks: the number of ticks to run the simulation for (0 means unlimited) (integer);
  • arguments.tickrate: sets the tick rate in hertz (integer);
  • arguments.realtime: boolean value to decide if the real time is used (true) or the simulation runs as fast as it can (false);
  • arguments.cell_level: the initial level of the cells (>=0) where the side length of a cell is 2^level (integer)

Typically, the user will define a struct called octree_traits which contains static information on whether the simulation partitioning is two or three dimensional.

// For a simulation with 2-dimensional partitioning
using octree_traits = aether::octree_traits<aether::dimension_traits<2>::morton_type>;
// For a simulation with 3-dimensional partitioning
using octree_traits = aether::octree_traits<aether::dimension_traits<3>::morton_type>;

The user must also define a struct which provides compile-time information on the storage mechanism used for entities (currently only the Aether ECS is supported) and the way entities will be serialized and deserialized. Since this code is almost identical across demos, we do not cover it here. We typically name this type entity_store_traits and have it parameterised by octree_traits.

The simulation state of each worker is contained in a user-defined class which must inherit from aether::entity_based_user_state<entity_store_traits<octree_traits>>. We recommend inheriting from aether::entity_based_user_state_base<entity_store_traits<octree_traits>> instead as this provides default implementations for many functions.

A class defining the state of a simulation might be declared as follows:

// simulate.hh
class user_cell_state_impl : public aether::entity_based_user_state_base<entity_store_traits<octree_traits>> {
public:
using traits_type = entity_store_traits<octree_traits>;
using store_type = traits_type::store_type;
store_type store;
public:
user_cell_state_impl(const aether_state_type &aether_state) : store {}
{
}
store_type &get_store() override {
return store;
}
void initialise_cell(const aether_state_type &aether_state) override;
void initialise_world(const aether_state_type &aether_state) override;
void cell_tick(const aether_state_type &aether_state, float delta_time) override;
void serialize_to_client(const aether_state_type &aether_state, client_writer_type &writer) override;
float estimate_load(const aether_state_type &aether_state) override;
void receive_messages(const aether_state_type& aether_state, message_reader_type& reader) override;
std::vector<subscriber_topic_type> get_topics(const aether_state_type& aether_state) override;
aabb_type agent_aabb(const aether_state_type &aether_state, agent_reference agent) override;
morton_type agent_center(const aether_state_type &aether_state, agent_reference agent) override;
};
Aether users are responsible for implementation of all functions listed below:
  • (constructor)(const aether_state_type &aether_state)

    • the constructor of the user-defined class will be invoked with its first argument being an object passed from Aether Engine giving information about the current cell.
  • store_type &get_store()

    • used by Aether to access the entity storage mechanism. Aether Engine needs to do this so that it can transfer entities to other workers and make them aware of changes to entities in the current cell.
  • void initialise_cell(const aether_state_type &aether_state)

    • called when a new Aether Engine cell is created and can be used to initialise the state of the cell.
  • void initialise_world(const aether_state_type &aether_state)

    • called once for the whole simulation in the first cell that is created and is responsible for initialising the simulation world.
  • void cell_tick(const aether_state_type &aether_state, float delta_time)

    • called once per tick to advance the state of the simulation. The amount of time to advance the simulation by is specified by delta_time in seconds.
  • void serialize_to_client(const aether_state_type& aether_state, client_writer_type &writer)

    • called once per simulation tick and serialises the state of the cell to be sent to connected clients (via the muxer).
  • float estimate_load(const aether_state_type &aether_state)

    • called by Aether Engine to determine the amount of load the cell is under. Typically this is chosen such that 0.0 indicates no load and 1.0 being the maximum load a cell can handle. Cells under high load will be split and cells under low load merged together (if this functionality is enabled).
  • void receive_messages(const aether_state_type&, message_reader_type &reader)

    • called to process events sent to the current cell. These include both interaction events from from clients connected to the simulation, but also messages sent from other Aether Engine processes such as the Global State.
  • std::vector<subscriber_topic_type> get_topics(const aether_state_type& aether_state)

    • called by Aether Engine to determine which events the current worker is interested in. For example, if a player controls entities located in this cell, then it is possible to specify set of topics so that this cell receives all events sent by those players.
  • void deinitialise_cell(const aether_state_type &aether_state)

    • called when an Aether Engine cell is destroyed and can be used for any deinitialisation that is required.
  • aabb_type agent_aabb(const aether_state_type &aether_state, agent_reference agent)

    • called by Aether Engine to determine the area over which an entity should be broadcast. Given an agent_reference (a type specific to the entity store in use) this function returns an axis-aligned bounding box (specified by two Morton codes).
  • morton_type agent_center(const aether_state_type &aether_state, agent_reference agent)

    • called by Aether Engine to determine the centre of an agent. This is used to decide when an agent should be transferred to another worker. Given an agent_reference (a type specific to the entity store in use) this function returns the position of the entity as a Morton code.

The parameters to these functions are:

  • aether_state_type &aether_state
    • an object providing the ability to inspect information about the current cell e.g. the current cell position and size.
  • client_writer_type &writer
    • an object used to send cell and agent data from a worker to the client.
  • message_reader_type &reader
    • an object providing access to messages and client-generated events received by the current cell.

Starting a simulation consists of constructing a manager, then making it aware of all muxers) it should connect to:

// main.cc
auto manager = aether::build_entity_simulation_manager<user_cell_state_impl>(arguments.workers, static_args);
for (const auto& muxer : arguments.muxers)
{
manager.add_muxer(muxer);
}

Finally we perform the simulation loop:

// main.cc
for (uint64_t tick = 0; ; tick++)
{
auto loop_time = timer::get();
manager.manager_tick();
AETHER_LOG(INFO)(fmt::format("Hello from tick {}!", tick));
loop_time = timer::add(loop_time, static_cast<std::chrono::nanoseconds>(1s) / static_args.ticks_per_second);
timer::sleep_until(loop_time);
}
return EXIT_SUCCESS;
}

The call to manager.manager_tick() causes all workers in the simulation to simulate the next time step.