Legion ECS with Godot and Rust 15 Mar 2020

Intro

This is my take on using Legion ECS with Godot and Rust.

The core premise of this is one Godot node that holds the schedules, and executes them in _process and _physics_process respectively.

For information on how to use GDNative with Rust and Godot see the previous post: Up and running with Rust and Godot: A basic setup.

Setup the GameWorld

Start by adding a Godot node, and name it “GameWorld”, and attach a NativeScript called “GameWorld”.

This is all that has to be done in Godot for now (additional nodes are added later).

Create a new file: src/gameworld.rs and add the following code.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
#[derive(NativeClass)]
#[inherit(Node2D)]
pub struct GameWorld {
}

#[methods]
impl GameWorld {
    pub fn new(_owner: &Node2D) -> Self {
        Self { }
    }

    #[export]
    pub fn _process(&self, owner: &Node2D, delta: f64) {
    }

    // Skipping _physics_process for now
}

(and don’t forget to add the struct to the init function)

1
2
3
fn init(handle: InitHandle) {
    handle.add_class::<gameworld::GameWorld>();
}

Adding a world

Without a World it’s not possible to execute the systems.

I have opted to place the world in a Mutex so it can be accessed from other threads if needed. For this post I ignore the Universe but the same method of adding the World could be applied to the Universe:

A utility function for easily accessing the world is added as well. This way it’s easy to work with the world with_world(|world| /* use world */);.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
use std::sync::Mutex;
use lazy_static::lazy_static;
use legion::prelude::*;

lazy_static! {
    static ref WORLD: Mutex<World> = Mutex::new(Universe::new().create_world());
}

pub fn with_world<F>(mut f: F)
where
    F: FnMut(&mut World),
{
    let _result = WORLD.try_lock().map(|mut world| f(&mut world));
}

Adding a resource first

Before creating the schedules, create the Delta resource. This is one way of passing the delta time value from _process to all the systems.

1
pub struct Delta(pub f32);

Creating schedules

With access to a World it is now possible to add resources and components, however to run systems a Schedule is recommended.

There are two types of schedules in this setup: Process and Physics (ignoring physics for now).

The Process will handle all systems that run on _process and Physics will run all systems that will execute on _physics_process.

Create a new struct in src/gameworld.rs and call it Process.

1
2
3
4
struct Process {
    resources: Resources,
    schedule: Schedule,
}

The Process struct holds all the resources that will be made available to the systems that are registered with schedule.

Finally the execute function is called on _process, providing the delta resource with a new value and executing all the systems.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
impl Process {
    fn new() -> Self {
        let mut resources = Resources::default();
        resources.insert(Delta(0.));

        let schedule = Schedule::builder()
            //.add_system(dont_touch_scene_tree())
            //.add_thread_local(can_touch_scene_tree())
            .build();

        Self {
            resources,
            schedule,
        }
    }

    fn execute(&mut self, delta: f64) {
        self.resources
            .get_mut::<Delta>()
            .map(|mut d| d.0 = delta as f32);

        with_world(|mut world| {
            self.schedule.execute(&mut world, &mut self.resources);
        })
    }
}

Note that it’s safe to touch the scene tree when adding a thread local system. Therefore all systems that manipulate Godot nodes should be added with add_thread_local. Components that are wrapping Godot nodes requires unsafe impl of Send and Sync.

Add schedules to the world

Attach the Process struct to the GameWorld

1
2
3
4
5
#[derive(NativeClass)]
#[inherit(Node2D)]
pub struct GameWorld {
    process: Process, // add this line
}

Then instantiate Process on _init:

1
2
3
4
5
pub fn _init(_owner: &Node2D) -> Self {
    Self {
        process: Process::new(), // and this line
    }
}

Finally it’s possible to call the execute function on the process in _process. Update the GameWorld’s _process function:

1
2
3
4
#[export]
pub fn _process(&mut self, owner: &Node2D, delta: f64) {
    self.process.execute(delta);
}
Note that &self changed to &mut self in the above code snippet.

At this point all the systems in Process will execute every time _process is called.

Adding a system

There are two types of systems that can be added in Legion (three if you consider thread_local_fn).

A Runnable (thread local) and a Schedulable (not thread local).

To keep things simple I consider anything that touches the scene tree to be part of a Runnable and everything that is just plain data to be Schedulable.

Create a system that moves a node across the screen:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
use gdnative::{Node2D, Vector2};

pub struct NodeComponent(Node2D);

unsafe impl Send for NodeComponent {}
unsafe impl Sync for NodeComponent {}

fn move_node() -> Box<dyn Runnable> {
    SystemBuilder::new("move nodes")
        .read_resource::<Delta>()
        .with_query(<Write<NodeComponent>>::query())
        .build_thread_local(|cmd, world, delta, query| {

            for mut node in query.iter_mut(world) {
                let speed = 100f32;
                let vel = Vector2::new(1.0, 0.0) * speed * delta.0;
                unsafe {
                    node.0.global_translate(vel);
                }
            }

        })
}

Finally update the new function inside Process, to add the new system:

1
2
3
4
5
6
7
8
// Process
fn new() -> Self {
    // ..
    let schedule = Schedule::builder()
        add_thread_local(move_node())
        .build();
    // ..
}

Adding a component

Open up Godot, load the GameWorld.tscn scene and add a Node2D with the name: “TheNode”.

To be able to see anything, add either a Sprite (or in my case I added a ColorRect) to the node.

Finally add the component to the world in the _ready function of the GameWorld:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
#[methods]
impl GameWorld {
    // ..

    #[export]
    pub fn _ready(&self, owner: &Node2D) {
        unsafe {
            let node = owner
                .get_node("TheNode".into())
                .and_then(|node| node.cast::<Node2D>())
                .unwrap();

            with_world(|world| {
                world.insert(
                    (), // No tags
                    vec![(NodeComponent(node), )]
                );
            });
        }
    }
}

Compile (and copy the lib into Godot) and run the Godot project. The node should now move across the screen from left to right.

Final notes

An example with this code is available on Github: rust-godot-legion-setup

The code in the project has been run and tested on Linux only, for Windows and MacOS remember to update the .gdnlib file

I have opted to keep my physics and process schedules separate, however they are very much identical, which is why the physics struct is omitted from this post. This means that resources aren’t shared between them. This can be solved by having two schedules in Process instead (one additional for physics) with an additional execute_physics function for _physics_process.