Draft Note
This is a draft because I'm looking for feedback on this api. Please let me know if you can think of improvements or gaps.
Bevy Schedule V2
Bevy's old Schedule
was simple, easy to read, and easy to use. But it also had significant limitations:
- Only one Schedule allowed
- Very static: you are limited to using the tools we gave you (stages are lists of systems, you can add stages to schedules)
- Couldn't switch between schedules at runtime
- Couldn't easily support "fixed timestep" scenarios
V2 of Bevy Schedule aims to solve these problems while still maintaining the ergonomics we all love:
Stage Trait
Stage is now a trait. You can implement your own stage types!
struct MyStage;
impl Stage for MyStage {
fn run(&mut self, world: &mut World, resources: &mut Resources);
}
There are now multiple built in Stage types:
SystemStage
// runs systems in parallel
let parallel_stage =
SystemStage::parallel()
.with_system(a)
.with_system(b);
// runs systems serially (in registration order)
let serial_stage =
SystemStage::serial()
.with_system(a)
.with_system(b);
// you can also write your own custom SystemStageExecutor
let custom_executor_stage =
SystemStage::new(MyCustomExecutor::new())
.with_system(a)
.with_system(b);
StateStage<T>
Bevy now supports states. More on this below!
Schedule
You read that right! Schedules are also stages, which means you can nest Schedules
let schedule = Schedule::default()
.with_stage("update", SystemStage::parallel()
.with_system(a)
.with_system(b)
)
.with_stage("nested",
Schedule::default()
.with_stage("nested_stage", SystemStage::serial()
.with_system(b)
)
);
// schedule stages can be downcasted
let mut update_stage = schedule.get_stage_mut::<SystemStage>("update").unwrap();
update_stage.add_system(something_new);
States
By popular demand, we now support State
s!
- Each state value has its own "enter", "update", and "exit" schedule
- You can queue up state changes from any system
- When a StateStage runs, it will dequeue all state changes and run through each state's lifecycle
- If at the end of a StateStage, new states have been queued, they will immediately be applied. This means moving between states will not be delayed across frames.
The new state.rs
example is the best illustrator of this feature. It shows how to transition between a Menu state and an InGame state. The texture_atlas.rs
example has been adapt to use states to transition between a Loading state and a Finished state.
This enables much more elaborate app models:
#[derive(Clone, PartialEq, Eq, Hash)]
enum AppState {
Loading,
InGame,
}
App::build()
// This initializes the state (adds the State<AppState> resource and adds StateStage<T> to the schedule)
// State stages are added right after UPDATE by default, but you also manually add StateStage<T> anywhere
.add_state(AppState::Loading)
// A state's "enter" schedule is run once when the state is entered
.state_enter(AppState::Loading, SystemStage::parallel()
.with_system(setup)
.with_system(load_textures)
)
// A state's "update" schedule is run once on every app update
// Note: Systems implement IntoStage, so you can do this:
.state_update(AppState::Loading, check_asset_loads)
// A state's "exit" schedule is run once when the state is exited
.state_exit(AppState::Loading, setup_world)
.state_update(AppState::InGame, SystemStage::parallel()
.with_system(movement)
.with_system(collision)
)
// You can of course still compose your schedule "normally"
.add_system(do_thing)
// add_system_to_stage assumes that the stage is a SystemStage
.add_system_to_stage(stage::POST_UPDATE, do_another_thing)
// this system checks to see if assets are loaded and transitions to the InGame state when they are finished
fn check_asset_loads(state: Res<State<AppState>>, asset_server: Res<AssetServer>) {
if assets_finished_loading(&asset_server) {
// state changes are put into a queue, which the StateStage consumes during execution
state.queue(AppState::InGame)
}
}
fn setup_world(commands: &mut Commands, state: Res<State<AppState>>, textures: Res<Assets<Textures>>) {
// This system only runs after check_asset_loads has checked that all assets have loaded
// This means we can now freely access asset data
let texture = textures.get(SOME_HANDLE).unwrap();
commands
.spawn(Camera2dBundle::default())
// spawn more things here
.spawn(SpriteBundle::default());
}
Run Criteria
Criteria driven stages (and schedules): only run stages or schedules when a certain criteria is met.
app
.add_stage_after(stage::UPDATE, "only_on_10_stage", SystemStage::parallel()
.with_run_criteria(|value: Res<usize>| if *value == 10 { ShouldRun::Yes } else { ShouldRun::No } )
.with_system(a)
)
.add_stage_after(stage::RUN_ONCE, "one_and_done", Schedule::default()
.with_run_criteria(RunOnce::default())
.with_system(a)
)
Fixed Timestep:
app.add_stage_after(stage::UPDATE, "fixed_update", SystemStage::parallel()
.with_run_criteria(FixedTimestep::steps_per_second(40.0))
.with_system(a)
)
Schedule Building
Adding stages now takes a Stage value:
App::build()
.add_stage_after(stage::UPDATE, SystemStage::parallel())
Typed stage building with nesting:
app
.stage("my_stage", |my_stage: &mut Schedule|
my_stage
.add_stage_after("substage1", "substage2", SystemStage::parallel()
.with_system(some_system)
)
.add_system_to_stage("substage_2", some_other_system)
.stage("a_2", |a_2: &mut SystemStage|
a_2.add_stage("a_2_1", StateStage::<MyState>::default())
)
)
.add_stage("b", SystemStage::serial())
)
Unified Schedule
No more separate "startup" schedule! It has been moved into the main schedule with a RunOnce criteria
startup_stage::STARTUP (and variants) have been removed in favor of this:
app
// this:
.add_startup_system(setup)
// is equivalent to this:
.stage(stage::STARTUP, |startup: &mut Schedule| {
startup.add_system_to_stage(startup_stage::STARTUP, setup)
})
// choose whichever one works for you!
this is a non-breaking change. you can continue using the AppBuilder .add_startup_system() shorthand
Discussion Topics
- General API thoughts: What do you like? What do you dislike?
- Do States behave as expected? Are they missing anything?
- Does FixedTimestep behave as expected?
- I added support for "owned builders" and "borrowed builders" for most schedule/stage building:
// borrowed (add_x and in some cases set_x)
app
.add_stage("my_stage", SystemStage::parallel())
.stage("my_stage", |my_stage: &mut Schedule|
my_stage
.add_stage("a", )
.add_system_to_stage("a", some_system)
)
// owned (with_x)
app
.add_stage("my_stage", Schedule::default()
.with_stage("a", SystemStage::parallel())
.with_system_in_stage("a", some_system)
)
)
- Does this make sense? We could remove with_x in favor of borrowed add_x in most cases. This would reduce the api surface, but it would mean slightly more cumbersome initialization. We also definitely want with_x in some cases (such as
stage.with_run_criteria()
)
Next steps:
- (Maybe) Support queuing up App changes (which will be applied right before the next update):
commands.app_change(|app: &mut App| {app.schedule = Schedule::default();})
- (Maybe) Event driven stages
app.add_stage_after(stage::UPDATE, EventStage::<SomeEvent>::default().with_system(a))
- These could easily build on top of the existing schedule features. It might be worth letting people experiment with their own implementations for a bit.
- We could also somehow try to work in "system inputs" to this. Aka when an event comes in, pass it in to each system in the schedule as input.
C-Enhancement A-ECS