#async-task #bevy-plugin #async #bevy #tasks #async-api #task

bevy_mod_async

Ergonomic async tasks plugin for the Bevy game engine

8 releases (breaking)

0.7.0 Jul 5, 2024
0.6.0 Jun 10, 2024
0.5.0 May 3, 2024
0.4.0 May 3, 2024
0.1.0 Jan 26, 2024

#281 in Game dev

MIT/Apache

7MB
460 lines

bevy_mod_async

bevy_mod_async is my attempt at a more ergonomic API for async tasks in Bevy. It's built on bevy_tasks's executor, so there's no need to pull in another async runtime (although bevy_mod_async does use some tokio types for its async interface -- which could be replaced in the future).

Usage

Adding AsyncTasksPlugin ensures all async tasks will be properly updated, and adds the resources TaskContext needs to spawn in tasks:

use bevy_mod_async::prelude::*;
...
app.add_plugins(AsyncTasksPlugin);

After that, bevy_mod_async has two primary APIs: commands.spawn_task() (taking an async closure with a single argument of type TaskContext):

commands.spawn_task(|cx| async move {
    ...
});

and TaskContext::with_world, which is used to exclusively, asynchronously access the world:

cx.with_world(|world| {
    // do anything with `world` here as in an exclusive system
    let e = world.spawn(()).id();
    world.despawn(e);
    world.resource_mut::<Counter>().0 += 1;
}).await;

Several convenience methods are provided as extensions on top of this API:

let e = cx.spawn(()).await;
cx.despawn(e).await;
let a = cx.load_asset::<Mesh>("model.glb#Mesh0").await.unwrap();

Many of these APIs return WithWorld, a Future that, when awaited, returns the result of executing the command on the World. This means that after .awaiting the future, any modifications to the world will have taken effect. Due to the fact that WithWorld futures are (by default) only advanced once per frame, it also means that each .await will typically delay execution by one frame. If this is undesirable, the task can be detached as well:

cx.spawn(()).detach();

This will still push the task onto Bevy's executor, but it will not suspend execution (which also obviously means the world won't have been modified either).

Motivation

What's wrong with vanilla bevy_tasks? Well, Bevy's primary API for kicking off async tasks uses AsyncTaskPool:

let task = AsyncComputeTaskPool::get().spawn(my_task);

Once you've spawned a new task, the executor takes care of polling the resulting future to completion. But how do you access the result? You've got to do your own polling, usually using a Bevy system:

struct MyTask(Task<..>);
fn handle_task_completion(mut tasks: Query<&mut MyTask>, mut commands: Commands) {
    for mut task in &mut tasks {
        if task.is_finished() {
            let result = block_on(poll_once(&mut task.0));
            // handle result
        }
    }
}

If you're spawning lots of similar async tasks, that's not too bad. For a one-shot async task like reacting to an asset loading, that's a lot of boilerplate. Instead, you could handle the async task finishing inside an async block:

AsyncComputeTaskPool::get().spawn(async move {
    let result = my_task.await;
    println!("{result}");
}).detach();

Of course, this approach only works if you can comfortably handle your result without access to the ECS, since the task has no way to access the World by itself. Another approach if you wanted to avoid making a task-specific system, you could instead use channels to send tasks to a task handler system and return their results. This is the approach bevy_mod_tasks uses. There's an exclusive system that runs queued tasks, a resource that accumulates them, and channels that pass tasks and results back and forth between this exclusive system and your tasks.

This comes with obvious limitations: all your World accesses have to wait until the next time this exclusive system runs to get their results back, and none of the accesses are parallelizable. On the other hand, if you're doing something as simple as loading an asset, waiting until it's finished loading, then spawning it into the scene, bevy_mod_async gives you a simple API to write this logic in an easy-to-read, linear way. The performance impact in this case is negligible: the task is only running once, and it's running at load time, rather than in a hot game loop.

Examples

hello_world

Demonstrates some of TaskContext's async APIs, and tries to simply explain how it works internally. Shows how to subscribe to an asynchronous event stream and basic UI reactivity.

async_asset

Demonstrates loading assets asynchronously. Spawns a loading screen, then despawns it when the scene is ready to be loaded.

timers

Demonstrates the sleep APIs, as well as spawning async tasks with Bevy running headless.

Bevy Version

bevy bevy_mod_async
0.12 0.1-0.2
0.13 0.3-0.6
0.14 0.7

Contribution

PRs welcome. I'll admit I'm not the world's leading expert in asynchronous Rust programming, nor do I have an incredible grasp of Bevy's ECS internals. If anyone has performance, ergonomics, or other improvements in mind, I'm open to contributions.

Dependencies

~13–50MB
~801K SLoC