Announcing a new multi-tasking library for Legion ECS

(Duncan Fairbanks) #1

Seeing that there is an effort to make Amethyst work with the Legion ECS, I figured I should take a stab at porting my specs-task crate to Legion.

TL;DR: take a look at this new library I made (https://github.com/bonsairobo/legion-task)

For background, specs-task is a fork-join multitasking crate that uses SPECS as a runtime / data model. The goal is to make it easier to write code that coordinates the effects of systems which would otherwise require writing complicated distributed state machines (I’ve written more about this here). It’s a pretty young, experimental crate, but I think it offers a very useful abstraction for programming with an ECS.

I found that porting to Legion was actually quite straightforward, and I made the tasking APIs better in the end.

One challenge to overcome was the lack of the specs::SystemData trait, which I used in specs-task to encapsulate the implementation details of the TaskManager object, which is used both by crate users as well as the TaskManagerSystem that does the magic of sequencing task execution correctly. Thankfully, legion gives every system a SubWorld object which can access any resources or components that the system has access to. So I simply changed all of the TaskManager methods to be functions that take a SubWorld.

Another challenge was making a nice interface for creating the task runner systems (TaskRunnerSystem in specs-task). Legion does system creation a bit differently than specs, by using a SystemBuilder to declare access to resources/queries instead of specs::SystemData. While this is generally a nice builder pattern, it makes it harder to implement a generic task runner system, because it uses some recursive Cons type magic to accumulate the set of queries and resources that a system can access. I just worked around this by still forcing the user to manually build their task runner systems, but I have some helper functions that fill in the complex parts. So constructing a schedule that runs your tasks looks like this:

#[derive(Clone, Debug)]
struct PushValue {
    value: usize,
}

impl<'a> TaskComponent<'a> for PushValue {
    type Data = Vec<usize>;

    fn run(&mut self, data: &mut Self::Data) -> bool {
        data.push(self.value);

        true
    }
}

fn make_task_runner_system() -> Box<dyn Schedulable> {
    SystemBuilder::new("example_task_runner")
        .write_resource::<Vec<usize>>()
        .with_query(task_runner_query::<PushValue>())
        .build(|_, mut world, value, task_query| {
            run_tasks(&mut world, &mut **value, task_query)
        })
}

fn make_schedule() -> Schedule {
    Schedule::builder()
        .add_system(build_push_value_task_runner_system())
        .add_system(build_task_manager_system("task_manager"))
        .build()
}

Maybe this could be improved with more effort / knowledge.

After getting the test suite to pass, I realized there was an opportunity to make the process of building task graphs much more ergonomic.

If you are familiar with specs-task, you know that constructing task graphs requires manually creating task or fork entities and linking them together with the TaskManager::join and TaskManager::add_prong methods. Then once the graph is complete, you have to call TaskManager::finalize on the root entity to make the graph runnable. This can be error-prone, since you’re dealing with entities of different archetypes and functions that expect the correct archetypes as input.

I realized this could all be hidden behind a better interface using macros that accumulate a TaskGraph data structure. Here are some examples demonstrating the end result:

fn make_static_task_graph(cmd: &mut CommandBuffer) {
    // Any component that implements TaskComponent can be a node in the graph.
    let task_graph = seq!(
        @TaskFoo("hello"),
        fork!(
            @TaskBar { value: 1 },
            @TaskBar { value: 2 },
            @TaskBar { value: 3 }
        ),
        @TaskZing("goodbye")
    );
    task_graph.assemble(cmd, OnCompletion::Delete);
}

fn make_dynamic_task_graph(cmd: &mut CommandBuffer) {
    let first = task!(@TaskFoo("hello"));
    let mut middle = empty_graph!();
    for i in 0..10 {
        middle = fork!(middle, @TaskBar { value: i });
    }
    let last = task!(@TaskZing("goodbye"));
    let task_graph = seq!(first, middle, last);
    task_graph.assemble(cmd, OnCompletion::Delete);
}

I’m pretty happy with these macros! But they could probably still be improved by applying macro expertise (which I do not have). For example, maybe those pesky "@"s could go away.

As for what’s next, I will bring the macro goodness back to specs-task and continue using it for my game, seeing if there are more improvements to be made. I’ll publish this on crates.io pretty soon, although I currently rely on the master branch of legion, which means I might want to wait for the next release before doing so.

I hope both specs-task and legion-task will be useful to the Rust gamedev community! Please give them a try.

EDIT: specs-task v0.2.1 was just published, and it contains the aforementioned macros and some other breaking changes that I think make the APIs better

5 Likes