Skip to content

Instantly share code, notes, and snippets.

@distransient
Last active October 17, 2019 06:29
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save distransient/1d7b0ebbff0a326c3e16ffbbc2c57eb4 to your computer and use it in GitHub Desktop.
Save distransient/1d7b0ebbff0a326c3e16ffbbc2c57eb4 to your computer and use it in GitHub Desktop.

Legion ECS Evolution

Following lengthy discussion on both Discord and the Amethyst Forum (most of which, including chat logs, can be found here), we propose with this RFC to move Amethyst from SPECS to Legion, an ECS framework building on concepts in SPECS Parallel ECS, as well as lessons learned since. This proposal stems from an improved foundational flexibility in the approach of Legion which would be untenable to affect on the current SPECS crate without forcing all users of SPECS to essentially adapt to a rewrite centered on the needs of Amethyst. The flexibility in Legion is filled with tradeoffs, generally showing benefits in performance and runtime flexibility, while generally trading off some of the ergonomics of the SPECS interface. While the benefits and the impetus for seeking them is described in the "Motivations" section, the implictions of tradeoffs following those benefits will be outlined in greater detail within the "Tradeoffs" section.

There are some core parts of Amethyst which may either need to considerably change when moving to Legion, or would otherwise just benefit from substantial changes to embrace the flexibility of Legion. Notably, systems in Legion are FnMut closures, and all systems require usage of SystemDesc to construct the closure and its associated Query structure. The dispatch technique in Legion is necessarily very different from SPECS, and the parts of the engine dealing with dispatch may also be modified in terms of Legion's dispatcher. Furthermore, the platform of Legion provides ample opportunity to improve our Transform system, with improved change detection tools at our disposal. These changes as we understand them are described below in the "Refactoring" section.

The evaluation of this large transition requires undertaking a progressive port of Amethyst to Legion with a temporary synchronization shim between SPECS and Legion. This effort exists here, utilizing the Legion fork here. Currently, this progressive fork has fully transitioned the Amethyst Renderer, one of the largest and most involved parts of the engine ECS-wise, and is capable of running that demo we're all familiar with:

Legion Rendy demo

Not only can you take a peek at what actual code transitioned directly to Legion looks like in this fork, but the refactoring work in that fork can be utilized given this RFC is accepted while actively helping to better inform where there may be shortcomings or surprises in the present.

Motivations

The forum thread outlines the deficiencies we are facing with specs in detail. This table below is a high level summary of the problems we are having with specs, and how legion solves each one.

Specs Legion
Typed storage nature prevents proper FFI All underlying legion storage is based on TypeId lookups for resources and components
hibitsetcrate has allocation/reallocation overhead, branch misses Archetypes eliminate the need of entity ID collections being used for iteration
Sparse storages causes cache incoherence Legion guarantees allocation of simliar entities into contigious, aligned chunks with all their components in linear memory
Storage fetching inherently causes many branch mispredictions See previous
Storage methodology inherently makes FlaggedStorage not thread safe. Queries in legion store filter and change state, allowing for extremely granular change detection on a Archetype, Chunk and Entity level.
Component mutation flagging limited to any mutable access Legion dispatches on a Archetype basis instead of component, allowing to parallel execute across the same component data, but just different entities. *A special case exists for sparse read/write of components where this isnt the case
Parallelization limited to component-level, no granular accesses See previous information about Archetypes
Many elided and explicit lifetimes throughout make code less ergonomic System API designed to hide the majority of these lifetimes in safety and ergonomic wrappers
ParJoin has mutation limitations See previous statements about system dispatcher and Archetypes

Immediate Benefits in a Nutshell

  • Significant performance gains
  • Scripting RFC can move forward
  • Queries open up many new optimizations for change detection such as culling, the transform system, etc.
  • More granular parallelization than we already have achieved
  • Resolves the dispatcher Order of Insertion design flaws
  • ???

Tradeoffs

These are some things I have ran into that were cumbersome changes or thoughts while porting. This is by no means comprehensive. Some of these items may not make sense until you understand legion and/or read the rest of this RFC.

  • Systems are moved to a closure, but ergonomics are given for still maintaining state, mainly in the use of FnMut[ref] for the closure, and an alternative build_disposable [ref].
  • All systems require a SystemDesc[ref] as the closure must be built
  • a Trait type cannot be used for System decleration, due to the typed nature of Queries in legion. It is far more feasible and ergonomic to use a closure for type deduction

Refactoring

This port of amethyst from legion -> specs has aimed to keep to some of the consistencies of specs and what Amethyst users would already be familiar with. Much of the implementation of Legion and the amethyst-specific components was heavily inspired/copied from the current standing implementations.

SystemBundle, System and Dispatcher refactor

This portion of the port will have the most significant impact on users, as this is where their day-to-day coding exists. The following is an example of the same system, in both specs and legion.

gist

Specs

 impl<'a> System<'a> for OrbitSystem {
    type SystemData = (
        Read<'a, Time>,
        ReadStorage<'a, Orbit>,
        WriteStorage<'a, Transform>,
        Write<'a, DebugLines>,
    );

    fn run(&mut self, (time, orbits, mut transforms, mut debug): Self::SystemData) {
        for (orbit, transform) in (&orbits, &mut transforms).join() {
            let angle = time.absolute_time_seconds() as f32 * orbit.time_scale;
            let cross = orbit.axis.cross(&Vector3::z()).normalize() * orbit.radius;
            let rot = UnitQuaternion::from_axis_angle(&orbit.axis, angle);
            let final_pos = (rot * cross) + orbit.center;
            debug.draw_line(
                orbit.center.into(),
                final_pos.into(),
                Srgba::new(0.0, 0.5, 1.0, 1.0),
            );
            transform.set_translation(final_pos);
        }
    }
}

Legion

#[derive(Default)]
pub struct OrbitSystemDesc;
impl SystemDesc for OrbitSystemDesc {
    fn build(
        self,
        world: &mut amethyst::core::legion::world::World,
    ) -> Box<dyn amethyst::core::legion::system::Schedulable> {
        SystemBuilder::<()>::new("OrbitSystem")
            .with_query(<(Write<Transform>, Read<Orbit>)>::query())
            .read_resource::<Time>()
            .write_resource::<DebugLines>()
            .build(move |commands, world, (time, debug), query| {
                query
                    .par_for_each(|(entity, (mut transform, orbit))| {
                        let angle = time.absolute_time_seconds() as f32 * orbit.time_scale;
                        let cross = orbit.axis.cross(&Vector3::z()).normalize() * orbit.radius;
                        let rot = UnitQuaternion::from_axis_angle(&orbit.axis, angle);
                        let final_pos = (rot * cross) + orbit.center;
                        debug.draw_line(
                            orbit.center.into(),
                            final_pos.into(),
                            Srgba::new(0.0, 0.5, 1.0, 1.0),
                        );
                        transform.set_translation(final_pos);
                    });
            })
    }
}

Parralelization of mutable queries

One of the major benefits of legion is its granularity with queries. Specs is not capable of performing a parralel join of Transform currently, because FlaggedStorage is not thread safe. Additionally, a mutable join such as above automatically flags all Transform components as mutated, meaning any readers will get N(entities) events.

In legion, however, we get this short syntax: query.par_for_each(|(entity, (mut transform, orbit))| { Under the hood, this code actually accomplishes more than what ParJoin may in specs. This method threads on a per-chunk basis on legion, meaning similiar data is being linearly iterated, and all components of those entities are in cache.

Transform Refactor (legion_transform)

Legion transform implementation

@AThilenius has taken on the task of refactoring the core Transform system. This system has some faults of its own, which are also exacerbated by specs. The system itself is heavily tied in with how specs operates, so a rewrite of the transform system was already in the cards for this migration.

This refactor is aimed more towards following the Unity design of Parent->Child Hierarchy and Transform storage, which has some unique changes when applied to amethyst. Mainly, these changes are:

FILL IN PLEASE

Dispatcher refactor

Lorem ipsum

Migration Story

Lorem ipsum

World Synchronization Middleware

Lorem ipsum

Brain-fart of changes needed by users

Lorem ipsum

Forecast Improvements

Publishing in StackEdit makes iasdf

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment