Legion ECS - v0.3

Published on Sep 7th 2020 by Tom Gillen

Legion 0.3

After months in development, legion v0.3.0 has finally been released to crates.io. This is a huge release amounting to a near total rewrite of the library. The repo has also been moved to the Amethyst org to reflect its close collaboration with the Amethyst community.

Legion is among Rust's fastest and most powerful ECS libraries. The new 0.3 release includes many performance and usability improvements and is a major step towards a stable 1.0 release.

Hello World

use legion::*;

// components are normal Rust structs
struct Person {
    name: &'static str
}

struct Age(u8);

// systems are normal Rust fns, annotated with #[system]
#[system(for_each)]
fn say_hello(person: &Person) {
    println!("hello, {}!", person.name);
}

#[system]
fn introduce_people(commands: &mut CommandBuffer, #[resource] names: &Vec<&'static str>) 
    // entities are inserted as tuples of components
    for name in names {
        commands.push((Person { name }, Age(35));
    }
}

fn main() {
    // the world is a container of entities
    let mut world = World::default();
    
    // resources can be shared between systems
    let mut resources = Resources::default();
    resources.insert(vec!["Jane Doe", "John Smith"]);
    
    // schedule will automatically execute systems in parallel
    let mut schedule = Schedule::builder()
        .add_system(introduce_people_system())
        .add_system(say_hello_system())
        .build();
    
    // run our schedule
    schedule.execute(&mut world, &mut resources);
}

Packed Archetypes

The biggest under the hood change in legion 0.3 is a new storage architecture.

There are many advantages to the archetype model for ECS datastructures. However, one of the archetecture's major weaknesses is what is known as "archetype fragmentation".

Archetypal ECS's group entities together based on their "archetype" - the unique set of component types attached to the entity. All entities with a given set of components will be stored together in the same archetype. Each archetype therefore contains a slice of components for each component type. This allows very fast entity search and filtering, and gives us contiguous runs of components which can be indexed together to access each entity.

However, as each archetype's components are stored separately, queries which must access multiple archetypes will hop to a new memory location every time they cross to a new archetype. This effectively fragments our memory model and causes expensive cache misses.

This is a problem which is difficult to demonstrate in a microbenchmark, as it only shows up in applications which have a very large number of component layout combinations. However, a full scale game can easily contain hundreds of components, and thousands of archetypes.

Legion 0.3 introduces an evolution of archetypes called "packed archetypes". Effectively, legion is capable of incrementally defragmenting its internal memory to try and minimise the impact of archetype fragmentation. It does this without giving up any of the iteration speed advantages of an archetypal architecture.

Queries

At the heart of legion is the Query. Queries can be used to iterate, filter and index into the entities stored in your world. Legion's query API provides greater power and flexibility than alternative ECS libraries.

Read and Write Components

You can iterate through all entities which have both Position and Velocity components:

let mut query = <(&Velocity, &mut Position)>::query();
for (velocity, position) in query.iter_mut(world) {
    *position += *velocity * time;
}

Option Components

You can ask for components which may or may not be attached to an entity:

let mut query = <(&mut Velocity, &mut Position, Option<&Acceleration>)>::query();
for (velocity, position, acceleration) in query.iter_mut(world) {
    if Some(acceleration) = acceleration {
      *velocity += *acceleration * time;
    }
    
    *position += *velocity * time;
}

Boolean Filter Expressions

Additional filters can be attached to a query, with support for boolean combinators:

let mut query = <(&Velocity, &mut Position)>::query()
    .filter(!component::<Static>() | component::<Dynamic>());

for (velocity, position) in query.iter_mut(world) {
    *position += *velocity * time;
}

One of the filters available is maybe_changed, which can reject entities where a specified component is known to have not changed since the last time the query was run, with cheap course-grained change tracking:

let mut query = <(&Transform, &mut RenderMatrix)>::query()
    .filter(!component::<Static>() | maybe_changed::<Transform>());

for (transform, matrix) in query.iter_mut(world) {
    transform.update(matrix);
}

Archetype Iteration

Queries allow iteration over each archetype in the result set. An archetype is a set of entities with the same component types. Each archetype contains slices of components which can be indexed together:

let mut query = <&mat4x4>::query();
for chunk in query.iter_chunks(world) {
    let transforms: &[mat4x4] = chunk.into_components();
    renderer.upload_transforms(transforms);
}

Systems

Systems allow work to be automatically scheduled in parallel, according to resource and component access dependencies. A system is a function annotated with #[system].

#[system]
fn hello_world() {
    println!("hello world");
}

Systems can be added to a Schedule for later execution against any World:

let mut schedule = Schedule::builder()
    .add_system(hello_world_system())
    .build();

schedule.execute(&mut world, &mut resources);

Command Buffers

Systems can request a command buffer, to create or remove entities, or to add or remove components from an existing entity:

#[system]
fn create_entity(commands: &mut CommandBuffer) {
    commands.push((Position(0.5), Speed(1.2));
}

Resources

Systems can request resources:

#[system]
fn render_clock(#[resource] clock: &Time, #[resource] renderer: &mut Renderer) {
    renderer.draw(clock);
}

World Access

Systems can request access to a sub-world, allowing limited access to any entity and construction of arbitrary queries. The system must declare which components it will access:

#[system]
#[read_component(usize)]
#[write_component(bool)]
fn run_query(world: &mut SubWorld) {
    let mut query = <(&usize, &mut bool)>::query();
    for (a, b) in query.iter_mut(world) {
        println!("{} {}", a, b);
    }
}

For-each

for_each and par_for_each system types can be used to implement the query for you. You can request components with & and &mut references (or wrapped in an Option), and the entity ID with &Entity:

#[system(for_each)]
fn update_positions(entity: &Entity, pos: &mut Position, vel: &Velocity, #[resource] time: &Time) {
    pos.x += vel.x * time.seconds;
}

for_each and par_for_each systems can also request attitional filters for their query via the #[filter] attribute:

#[system(for_each)]
#[filter(maybe_changed::<Position>())]
fn update_positions(pos: &mut Position, vel: &Velocity, #[resource] time: &Time) {
    pos.x += vel.x * time.seconds;
}

par_for_each systems will use a rayon parallel iterator to loop through all entities in parallel.

We can also ask for optional components. The following system will iterate through all entities with A and, for those entities which also have it, component B:

#[system(for_each)]
fn optional_components(a: &A, b: Option<&B>) {
    // ...
}

Private State

Systems can contain their own state. Add a reference arguement marked with the #[state] attribute to your function. This state will be initialized when you construct the system.

#[system]
fn stateful(#[state] counter: &mut usize) {
    *counter += 1;
    println!("state: {}", counter);
}

// initialize state when you construct the system
Schedule::builder()
    .add_system(stateful_system(5_usize))
    .build();

Generics

All of the above can be combined with generic parameters, as you would expect from a Rust function:

#[system(for_each)]
fn print_component<T: Component + Display>(component: &T, #[state] counter: &mut usize) {
  println!("component: {}", component);
  
  *counter += 1;
  println!("I have printed {} components", counter);
}

Schedule::builder()
    .add_system(print_component_system::<Position>(0))
    .add_system(print_component_system::<Velocity>(0))
    .build();

Why a proc-macro?

Other ECS libraries use plain functions as systems. This arguably produces prettier code, but there are advantages to using a proc-macro. Beyond the significantly improved flexbility of a proc-macro, consider the following code:

#[system(for_each)]
fn for_each(a: Option<Position>, b: Option<&mut Size>) {
    println!("{:?} {:?}", a, b);
}

The above code is wrong.. but can you spot how?

The system is requesting an optional Position component, but it is requesting ownership of the component rather than a reference to the component.

In most ECS's, this kind of mistake would result in an obscure error when you try to create the system, complaining about your function not implementing some trait. Legion, however, produces the following compile error:

error: option arguments must contain a component reference, consider `Option<&Position>`
  --> tests\for_each.rs:74:20
   |
74 |     fn for_each(a: Option<Position>, b: Option<&mut Size>) {
   |                    ^^^^^^

Proc-macros allow us to bake significantly improved diagnostics and error messages right into the Rust compiler.

Scheduling

Legion's Schedule will automatically parallelise your systems according to their component and resource dependencies. You simply add your systems to the schedule in the order in which you expect side-effects (such as a write to a component) to be observed, and let Schedule do the rest.

!Send and !Sync Resources

Some systems shouldn't be run on the thread pool, but need to be tied to the main thread. You can schedule such systems by using .add_thread_local when constructing your schedule.

Usually, a system is thread-local because it wants to access resources which are tied to the main thread. For example, the graphics context.

Legion's Resources collection can store types which are !Send and/or !Sync. A system which tries to access such a resource will itself be marked as !Send and/or !Sync as appropriate, and Schedule will not allow you to add such a system as anything but a thead-local system.

Inner Data Parallelism

With consoles now feauring 8 core (16 thread) CPUs, and consumer 16 core (32 thread) CPUs hitting the market, system level parallelism is insufficient to utilise the compute power available to us. Rather than leave CPU cycles on the table, legion provides custom optimised rayon ParallelIterator implementations to allow for query-level data parallelism.

let mut query = <(&Transform, &mut RenderMatrix)>::query()
    .filter(!component::<Static>() | maybe_changed::<Transform>());

query.par_for_each_mut(world, |(transform, matrix)| {
    transform.update(matrix);
});

Or as a system:

#[system(par_for_each)]
#[filter(!component::<Static>() | maybe_changed::<Transform>())]
fn compute_render_matrix(transform: &Transform, matrix: &mut RenderMatrix) {
    transform.update(matrix);
}

Serialization

Legion 0.3 introduces a new out-of-box serialization API.

The Registry can be used to tell legion how it should serialize a world:

let mut registry = Registry::<String>::default();
registry.register::<usize>("usize".to_string());
registry.register::<bool>("bool".to_string());
registry.register::<isize>("isize".to_string());

let json = serde_json::to_value(&world.as_serializable(any(), &registry)).unwrap();
println!("{:#}", json);

Alternatively, the companion legion_typeuuid crate can be used to automatically gather component type registrations.

Format

Legion's text serialization format is optimised for human readability and stability. It is designed to minimise the impact of changes to reduce the probably of merge conflicts when multiple team members are working on the same scene files, and to allow easy manual conflict resoluton:

{
  "entities": {
    "0d427e60-6ab6-40f2-85d9-e33ef89f7948": {
      "bool": false,
      "isize": 2,
      "usize": 2
    },
    "7c6e8b51-de98-4bf9-96d8-a05751659168": {
      "bool": false,
      "isize": 4,
      "usize": 4
    },
    "80cff44f-0c3f-4412-9cef-42f79ed8f067": {
      "entity": "80e2f82e-39e2-4d65-8251-a3b4d5df8e08",
      "isize": 5,
      "usize": 5
    },
    "80e2f82e-39e2-4d65-8251-a3b4d5df8e08": {
      "bool": false,
      "isize": 1,
      "usize": 1
    },
    "8a435ddb-8003-4ef3-9362-cd74bb514958": {
      "entity": "80e2f82e-39e2-4d65-8251-a3b4d5df8e08",
      "isize": 7,
      "usize": 7
    },
    "92c49919-5dc9-43f9-9597-846e4a1497cc": {
      "bool": false,
      "isize": 3,
      "usize": 3
    },
    "c4451f98-f4c6-4081-b3ac-42739c483379": {
      "entity": "80e2f82e-39e2-4d65-8251-a3b4d5df8e08",
      "isize": 6,
      "usize": 6
    },
    "ca2bfbed-f76f-4a52-a80e-9bba81dd4324": {
      "entity": "80e2f82e-39e2-4d65-8251-a3b4d5df8e08",
      "isize": 8,
      "usize": 8
    }
  }
}

Performance

In addition to its text format, legion will automatically switch to a far more efficient and compact data layout when serializing into a binary format.

This gives legion's serialization the best of both worlds: readability and usability at edit-time, and blazing performance in built applications.

IDs

At runtime, legion uses a simple u64 as the unique ID for each entity. However, when entities are serialized they are assigned their own UUID.

The mapping between runtime entities and UUIDs is stable and will round-trip through a serialize-deserialize-serialize cycle.

Additionally, legion is capable of automatically wiring up Entity references. If one of your components contains a reference to another entity in the scene via a runtime Entity ID, that reference will also round-trip through serialization and still point to the correct entity on the other end.

Prefabs and Streaming

Legion provides powerful features to enable prefab and scene streaming scenarios.

Move

World::move_from allows near instant transfer of entities from one world to another. With move, we can asynchronously deserialize and run an initialization schedule over a new world on a background thread - entirely decoupled from the main loop - and then when ready move all of the new entities into the main world without causing any frame time stutters.

Clone

World::clone_from allows entities to be cloned from one world to another.

Clone allows for powerful prefab workflows, with component type conversions and automatic Entity reference adjustments.

A prefab world containing a collection of entities can be cloned into the main world. During the clone, component types can be converted (e.g. a physics rigid body definition converted to a runtime physics engine handle) and any Entity references between the prefab entities can be automatically hooked up to their corresponding cloned entity rather than back into the original prefab.