From 366ce1dfe4d6405bc780ab87a104645d6db77937 Mon Sep 17 00:00:00 2001 From: Alice Cecile Date: Mon, 19 Sep 2022 19:07:08 -0400 Subject: [PATCH] Stageless: a complete scheduling overhaul (#45) --- rfcs/45-stageless.md | 1131 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1131 insertions(+) create mode 100644 rfcs/45-stageless.md diff --git a/rfcs/45-stageless.md b/rfcs/45-stageless.md new file mode 100644 index 00000000..579b27fd --- /dev/null +++ b/rfcs/45-stageless.md @@ -0,0 +1,1131 @@ +# Feature Name: `stageless` + +## Summary + +Users often have a hard time working with Bevy's system scheduling API. +Core building blocks — stages, run criteria, and states — are presented as independent but actually have tons of hidden internal coupling. + +This coupling frequently comes to bite users in the form of surprising limitations, unexpected side effects, and indecipherable errors. + +This RFC proposes a holistic redesign that neatly fixes these problems, with clear boundaries between system configuration, storage, execution, and flow control. + +In summary: + +- Store schedules in a resource. +- Make system sets (sub)graph nodes instead of containers and include them in the descriptor API. +- Make exclusive systems "normal" and use them for high-level flow control. (command application, state transitions, fixed timestep, turn queues, etc.) +- Replace run criteria with immutable, `bool`-returning conditions. +- Remove stages. + +## Motivation + +There are [many standing issues](https://github.com/bevyengine/bevy/discussions/2801#discussioncomment-1304027) with the current stage-centered scheduling model. +Some highlights are, in no particular order: + +- Users often have a hard time just deciding what stages to put systems in. + - Can't just think about when commands should be applied, you also have to consider run criteria and if the stage loops. +- Plugins often export systems wrapped in stages, but users can't control where those imported stages go. +- Run criteria can't be composed in any appreciable way except "piping". + - A system can't have multiple run criteria. + - A system can't have a single run criteria if it belongs to a `SystemSet` that has one. + - Users can't add a state-related `SystemSet` to multiple stages because its run criteria driver will think it's finished after the first one. + - Users can't (really) mix a fixed timestep and state transitions (unless they involve a nested schedule, but more on that later). +- Users can't identify clear patterns for implementing "turn-based" game logic. +- Users don't (or can't) really take advantage of the capabilities that the extra complexity of our stack-based state model enables. +- There's just too much API. (e.g. "`.add_system_set_to_startup_stage`") + +Unfortunately, these issues remain deeply ingrained and intertwined, despite our best efforts to surface and untangle them. + +To give you an idea of the challenge. +If we removed stages, all places to evaluate run criteria and apply commands would be lost, except the beginning and end of each schedule execution. +If we required immutable access and `bool` output from run criteria to enable basic composition, states would break because their implementation relies on run criteria that mutate a resource. +Likewise, if we just took away stages and `ShouldRun::*AndCheckAgain`, there could be no inner frame loops (e.g. fixed timestep). + +Addressing even one problem involves major breaking changes. +Ideally, we update everything in one go, as trying to spread the breaking changes over a longer period of time brings risk of adding even more technical debt. + +## User-facing explanation + +### Scheduling terminology and overview + +Let's define some terms. + +- **system**: stateful instance of a function that can access data stored in a world +- **system set**: logical group (subgraph) of systems (can include other system sets) +- **condition**: function that must evaluate to `true` for a system (or systems in a set) to run +- **dependency**: system (set) that must complete before another system (set) can run +- **schedule** (noun): an executable system graph +- **schedule** (verb): specify when and under what conditions systems run +- **executor**: runs the systems in a schedule on a world +- **"ready"**: when a system is no longer waiting for dependencies to complete + +To write a Bevy app, you have to specify when your systems run. +By default, systems have neither strict execution order nor any conditions for execution. +**Scheduling** is the process of supplying those properties. +To make things more ergonomic, systems can be grouped under **system sets**, which can be ordered and conditioned in the same manner as individual systems. +This allows you to easily refer to many systems and (indirectly) give properties to many systems. +Furthermore, systems and system sets can be ordered together and even grouped together *within larger sets*, meaning you can layer those properties. + +In short, for any system or system set, you can define: + +- its execution order relative to other systems or sets (e.g. "this system runs before A") +- any conditions that must be true for it to run (e.g. "this system only runs if a player has full health") +- which set(s) it belongs to (which define properties that affect all systems underneath) + - if left unspecified, the system or set will be added under a default one (this is configurable) + +These properties are all additive, and properties can be added to existing sets. +Adding another does not replace an existing one, and they cannot be removed. +If incompatible properties are added, the schedule will panic at startup. + +### Sample + +```rust +use bevy::prelude::*; + +#[derive(State)] +enum GameState { + Running, + Paused +} + +#[derive(SystemSet)] +enum MySystems { + Update, + Menu, + SubMenu, +} + +fn main() { + App::new() + /* ... */ + // If a set does not exist when `configure_set` is called, + // it will be implicitly created. + .configure_set( + // Use the same builder API for scheduling systems and system sets. + MySystems::Menu + // Put sets in other sets. + .in_set(MySystems::Update) + // Attach conditions to system sets. + // (If this fails, all systems in the set will be skipped.) + .run_if(state_equals(GameState::Paused)) + ) + // Bulk-add systems + .add_systems(( + some_system + .in_set(MySystems::Menu) + // Attach multiple conditions to this system, and they won't conflict with Menu's conditions. + // (All of these must return true or this system will be skipped.) + .run_if(some_condition_system) + .run_if(|value: Res| value > 9000), + // nesting tuples of systems is allowed, enabling grouped configuration of systems + ( + some_system, + // Choose when to process commands with instances of this dedicated system. + apply_system_buffers, + ) + .chain() + // Configure these together. + .in_set(MySystems::Menu) + .after(some_other_system), + + )) + /* ... */ + .run(); +} +``` + +### Deciding when systems run with dependencies + +The main way you can configure systems is to say *when* they should run, using the `.before`, `.after`, and `.in_set` methods. +These properties, called *dependencies*, determine execution order relative to other systems and system sets. +These dependencies are collected and assembled to produce dependency graphs, which, along with the signature of each system, tells the executor which systems can run in parallel. +Dependencies involving system sets are later flattened into dependencies between individual pairs of systems. + +If a combination of constraints cannot be satisfied (e.g. you say A has to come both before and after B), a dependency graph will be found to be **unsolvable** and return an error. However, that error should clearly explain how to fix whatever problem was detected. + +An `add_systems` method is provided as another means to add properties in bulk, which accepts a collection of configured systems. +For example, tuples of systems like `(a, b, c)` can be added, and using `(a, b, c, ...).chain()` will create dependencies between the successive elements. + +*Note: This is different from the "system chaining" in previous versions of Bevy. That has been renamed to "system piping" to avoid overlap.* + +Bevy's `MinimalPlugins` and `DefaultPlugins` plugin groups include several built-in system sets. + +```rust +#[derive(SystemSet)] +enum Physics { + ComputeForces, + DetectCollisions, + HandleCollisions, +} + +/// "Logical" system set for sharing fixed-after-input config +#[derive(SystemSet)] +struct FixedAfterInput; + +impl Plugin for PhysicsPlugin { + fn build(app: &mut App){ + app + .configure_set( + FixedAfterInput + .in_set(CoreSystems::FixedUpdate) + .after(InputSystems::ReadInputHandling)) + .configure_set( + Physics::ComputeForces + .in_set(FixedAfterInput) + .before(Physics::DetectCollisions)) + .configure_set( + Physics::DetectCollisions + .in_set(FixedAfterInput) + .before(Physics::HandleCollisions)) + .configure_set(Physics::HandleCollisions.in_set(FixedAfterInput)) + .add_systems(( + gravity.in_set(Physics::ComputeForces), + (broad_pass, narrow_pass, solve_constraints) + .chain() + .in_set(Physics::DetectCollisions), + collision_damage.in_set(Physics::HandleCollisions) + )); + } +} +``` + +### Deciding if systems run with conditions + +While dependencies determine *when* systems run, **conditions** determine *if* they run at all. + +Functions with compatible signatures (immutable `World` data access and `bool` output) can be attached to systems and system sets as conditions. +A system or system set will only run if all of its conditions return `true`. +If one of its conditions returns `false`, the system (or members of the system set) will be skipped. + +To be clear, systems can have multiple conditions, and those conditions are not shared with others. +Each condition instance is unique and will be evaluated *at most once* per run of a schedule. +Conditions are evaluated right before their guarded system (or the first system in their guarded system set that's able to run) would be run, so their results are guaranteed to be up-to-date. +The data read by conditions will not change before the guarded system starts. + +```rust +// This is just an ordinary system: timers need to be ticked! +fn tick_construction_timer(timer: ResMut, time: Res