From c92ee3177999e246c84eb146fafd83182cd15ac7 Mon Sep 17 00:00:00 2001 From: Jan Hohenheim Date: Fri, 23 Aug 2024 18:19:42 +0200 Subject: [PATCH] Allow ordering variable timesteps around fixed timesteps (#14881) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Objective - Fixes #14873, see that issue for a whole lot of context ## Solution - Add a blessed system set for this stuff. See [this Discord discussion](https://discord.com/channels/691052431525675048/749335865876021248/1276262931327094908). Note that the gizmo systems, [LWIM](https://github.com/Leafwing-Studios/leafwing-input-manager/pull/522/files#diff-9b59ee4899ad0a5d008889ea89a124a7291316532e42f9f3d6ae842b906fb095R154) and now a new plugin I'm working on are all already ordering against `run_fixed_main_schedule`, so having a dedicated system set should be more robust and hopefully also more discoverable. --- ## ~~Showcase~~ ~~I can add a little video of a smooth camera later if this gets merged :)~~ Apparently a release note is not needed, so I'll leave it out. See the changes in the fixed timestep example for usage showcase and the video in #14873 for a more or less accurate video of the effect (it does not use the same solution though, so it is not quite the same) ## Migration Guide [run_fixed_main_schedule](https://docs.rs/bevy/latest/bevy/time/fn.run_fixed_main_schedule.html) is no longer public. If you used to order against it, use the new dedicated `RunFixedMainLoopSystem` system set instead. You can replace your usage of `run_fixed_main_schedule` one for one by `RunFixedMainLoopSystem::FixedMainLoop`, but it is now more idiomatic to place your systems in either `RunFixedMainLoopSystem::BeforeFixedMainLoop` or `RunFixedMainLoopSystem::AfterFixedMainLoop` Old: ```rust app.add_systems( RunFixedMainLoop, some_system.before(run_fixed_main_schedule) ); ``` New: ```rust app.add_systems( RunFixedMainLoop, some_system.in_set(RunFixedMainLoopSystem::BeforeFixedMainLoop) ); ``` --------- Co-authored-by: Tau Gärtli --- crates/bevy_app/src/lib.rs | 3 +- crates/bevy_app/src/main_schedule.rs | 118 +++++++++++++++++- crates/bevy_gizmos/src/lib.rs | 6 +- crates/bevy_time/src/fixed.rs | 6 +- crates/bevy_time/src/lib.rs | 5 +- .../movement/physics_in_fixed_timestep.rs | 77 +++++++++--- 6 files changed, 185 insertions(+), 30 deletions(-) diff --git a/crates/bevy_app/src/lib.rs b/crates/bevy_app/src/lib.rs index d22669a634..b389395d3d 100644 --- a/crates/bevy_app/src/lib.rs +++ b/crates/bevy_app/src/lib.rs @@ -34,7 +34,8 @@ pub mod prelude { app::{App, AppExit}, main_schedule::{ First, FixedFirst, FixedLast, FixedPostUpdate, FixedPreUpdate, FixedUpdate, Last, Main, - PostStartup, PostUpdate, PreStartup, PreUpdate, SpawnScene, Startup, Update, + PostStartup, PostUpdate, PreStartup, PreUpdate, RunFixedMainLoop, + RunFixedMainLoopSystem, SpawnScene, Startup, Update, }, sub_app::SubApp, Plugin, PluginGroup, diff --git a/crates/bevy_app/src/main_schedule.rs b/crates/bevy_app/src/main_schedule.rs index 00531c9071..3ee0a07a74 100644 --- a/crates/bevy_app/src/main_schedule.rs +++ b/crates/bevy_app/src/main_schedule.rs @@ -1,6 +1,9 @@ use crate::{App, Plugin}; use bevy_ecs::{ - schedule::{ExecutorKind, InternedScheduleLabel, Schedule, ScheduleLabel}, + schedule::{ + ExecutorKind, InternedScheduleLabel, IntoSystemSetConfigs, Schedule, ScheduleLabel, + SystemSet, + }, system::{Local, Resource}, world::{Mut, World}, }; @@ -75,6 +78,11 @@ pub struct First; pub struct PreUpdate; /// Runs the [`FixedMain`] schedule in a loop according until all relevant elapsed time has been "consumed". +/// If you need to order your variable timestep systems +/// before or after the fixed update logic, use the [`RunFixedMainLoopSystem`] system set. +/// +/// Note that in contrast to most other Bevy schedules, systems added directly to +/// [`RunFixedMainLoop`] will *not* be parallelized between each other. /// /// See the [`Main`] schedule for some details about how schedules are run. #[derive(ScheduleLabel, Clone, Debug, PartialEq, Eq, Hash)] @@ -126,8 +134,8 @@ pub struct FixedLast; /// The schedule that contains systems which only run after a fixed period of time has elapsed. /// -/// The exclusive `run_fixed_main_schedule` system runs this schedule. -/// This is run by the [`RunFixedMainLoop`] schedule. +/// This is run by the [`RunFixedMainLoop`] schedule. If you need to order your variable timestep systems +/// before or after the fixed update logic, use the [`RunFixedMainLoopSystem`] system set. /// /// Frequency of execution is configured by inserting `Time` resource, 64 Hz by default. /// See [this example](https://github.com/bevyengine/bevy/blob/latest/examples/time/time.rs). @@ -288,7 +296,16 @@ impl Plugin for MainSchedulePlugin { .init_resource::() .init_resource::() .add_systems(Main, Main::run_main) - .add_systems(FixedMain, FixedMain::run_fixed_main); + .add_systems(FixedMain, FixedMain::run_fixed_main) + .configure_sets( + RunFixedMainLoop, + ( + RunFixedMainLoopSystem::BeforeFixedMainLoop, + RunFixedMainLoopSystem::FixedMainLoop, + RunFixedMainLoopSystem::AfterFixedMainLoop, + ) + .chain(), + ); #[cfg(feature = "bevy_debug_stepping")] { @@ -352,3 +369,96 @@ impl FixedMain { }); } } + +/// Set enum for the systems that want to run inside [`RunFixedMainLoop`], +/// but before or after the fixed update logic. Systems in this set +/// will run exactly once per frame, regardless of the number of fixed updates. +/// They will also run under a variable timestep. +/// +/// This is useful for handling things that need to run every frame, but +/// also need to be read by the fixed update logic. See the individual variants +/// for examples of what kind of systems should be placed in each. +/// +/// Note that in contrast to most other Bevy schedules, systems added directly to +/// [`RunFixedMainLoop`] will *not* be parallelized between each other. +#[derive(Debug, Hash, PartialEq, Eq, Copy, Clone, SystemSet)] +pub enum RunFixedMainLoopSystem { + /// Runs before the fixed update logic. + /// + /// A good example of a system that fits here + /// is camera movement, which needs to be updated in a variable timestep, + /// as you want the camera to move with as much precision and updates as + /// the frame rate allows. A physics system that needs to read the camera + /// position and orientation, however, should run in the fixed update logic, + /// as it needs to be deterministic and run at a fixed rate for better stability. + /// Note that we are not placing the camera movement system in `Update`, as that + /// would mean that the physics system already ran at that point. + /// + /// # Example + /// ``` + /// # use bevy_app::prelude::*; + /// # use bevy_ecs::prelude::*; + /// App::new() + /// .add_systems( + /// RunFixedMainLoop, + /// update_camera_rotation.in_set(RunFixedMainLoopSystem::BeforeFixedMainLoop)) + /// .add_systems(FixedUpdate, update_physics); + /// + /// # fn update_camera_rotation() {} + /// # fn update_physics() {} + /// ``` + BeforeFixedMainLoop, + /// Contains the fixed update logic. + /// Runs [`FixedMain`] zero or more times based on delta of + /// [`Time`] and [`Time::overstep`]. + /// + /// Don't place systems here, use [`FixedUpdate`] and friends instead. + /// Use this system instead to order your systems to run specifically inbetween the fixed update logic and all + /// other systems that run in [`RunFixedMainLoopSystem::BeforeFixedMainLoop`] or [`RunFixedMainLoopSystem::AfterFixedMainLoop`]. + /// + /// [`Time`]: https://docs.rs/bevy/latest/bevy/prelude/struct.Virtual.html + /// [`Time::overstep`]: https://docs.rs/bevy/latest/bevy/time/struct.Time.html#method.overstep + /// # Example + /// ``` + /// # use bevy_app::prelude::*; + /// # use bevy_ecs::prelude::*; + /// App::new() + /// .add_systems(FixedUpdate, update_physics) + /// .add_systems( + /// RunFixedMainLoop, + /// ( + /// // This system will be called before all interpolation systems + /// // that third-party plugins might add. + /// prepare_for_interpolation + /// .after(RunFixedMainLoopSystem::FixedMainLoop) + /// .before(RunFixedMainLoopSystem::AfterFixedMainLoop), + /// ) + /// ); + /// + /// # fn prepare_for_interpolation() {} + /// # fn update_physics() {} + /// ``` + FixedMainLoop, + /// Runs after the fixed update logic. + /// + /// A good example of a system that fits here + /// is a system that interpolates the transform of an entity between the last and current fixed update. + /// See the [fixed timestep example] for more details. + /// + /// [fixed timestep example]: https://github.com/bevyengine/bevy/blob/main/examples/movement/physics_in_fixed_timestep.rs + /// + /// # Example + /// ``` + /// # use bevy_app::prelude::*; + /// # use bevy_ecs::prelude::*; + /// App::new() + /// .add_systems(FixedUpdate, update_physics) + /// .add_systems( + /// RunFixedMainLoop, + /// interpolate_transforms.in_set(RunFixedMainLoopSystem::AfterFixedMainLoop)); + /// + /// # fn interpolate_transforms() {} + /// # fn update_physics() {} + /// ``` + AfterFixedMainLoop, +} diff --git a/crates/bevy_gizmos/src/lib.rs b/crates/bevy_gizmos/src/lib.rs index d5a20bc5c5..1d42c58947 100644 --- a/crates/bevy_gizmos/src/lib.rs +++ b/crates/bevy_gizmos/src/lib.rs @@ -246,13 +246,15 @@ impl AppGizmoBuilder for App { .init_resource::>>() .add_systems( RunFixedMainLoop, - start_gizmo_context::.before(bevy_time::run_fixed_main_schedule), + start_gizmo_context:: + .in_set(bevy_app::RunFixedMainLoopSystem::BeforeFixedMainLoop), ) .add_systems(FixedFirst, clear_gizmo_context::) .add_systems(FixedLast, collect_requested_gizmos::) .add_systems( RunFixedMainLoop, - end_gizmo_context::.after(bevy_time::run_fixed_main_schedule), + end_gizmo_context:: + .in_set(bevy_app::RunFixedMainLoopSystem::AfterFixedMainLoop), ) .add_systems( Last, diff --git a/crates/bevy_time/src/fixed.rs b/crates/bevy_time/src/fixed.rs index a497639055..4b60461e34 100644 --- a/crates/bevy_time/src/fixed.rs +++ b/crates/bevy_time/src/fixed.rs @@ -233,8 +233,10 @@ impl Default for Fixed { } /// Runs [`FixedMain`] zero or more times based on delta of -/// [`Time`](Virtual) and [`Time::overstep`] -pub fn run_fixed_main_schedule(world: &mut World) { +/// [`Time`](Virtual) and [`Time::overstep`]. +/// You can order your systems relative to this by using +/// [`RunFixedMainLoopSystem`](bevy_app::prelude::RunFixedMainLoopSystem). +pub(super) fn run_fixed_main_schedule(world: &mut World) { let delta = world.resource::>().delta(); world.resource_mut::>().accumulate(delta); diff --git a/crates/bevy_time/src/lib.rs b/crates/bevy_time/src/lib.rs index 906d8df1c5..5181bc4335 100644 --- a/crates/bevy_time/src/lib.rs +++ b/crates/bevy_time/src/lib.rs @@ -70,7 +70,10 @@ impl Plugin for TimePlugin { .in_set(TimeSystem) .ambiguous_with(event_update_system), ) - .add_systems(RunFixedMainLoop, run_fixed_main_schedule); + .add_systems( + RunFixedMainLoop, + run_fixed_main_schedule.in_set(RunFixedMainLoopSystem::FixedMainLoop), + ); // Ensure the events are not dropped until `FixedMain` systems can observe them app.add_systems(FixedPostUpdate, signal_event_update_system); diff --git a/examples/movement/physics_in_fixed_timestep.rs b/examples/movement/physics_in_fixed_timestep.rs index 0179288e83..1a922dd7bb 100644 --- a/examples/movement/physics_in_fixed_timestep.rs +++ b/examples/movement/physics_in_fixed_timestep.rs @@ -53,16 +53,22 @@ //! //! ## Implementation //! +//! - The player's inputs since the last physics update are stored in the `AccumulatedInput` component. //! - The player's velocity is stored in a `Velocity` component. This is the speed in units per second. //! - The player's current position in the physics simulation is stored in a `PhysicalTranslation` component. //! - The player's previous position in the physics simulation is stored in a `PreviousPhysicalTranslation` component. //! - The player's visual representation is stored in Bevy's regular `Transform` component. //! - Every frame, we go through the following steps: +//! - Accumulate the player's input and set the current speed in the `handle_input` system. +//! This is run in the `RunFixedMainLoop` schedule, ordered in `RunFixedMainLoopSystem::BeforeFixedMainLoop`, +//! which runs before the fixed timestep loop. This is run every frame. //! - Advance the physics simulation by one fixed timestep in the `advance_physics` system. -//! This is run in the `FixedUpdate` schedule, which runs before the `Update` schedule. -//! - Update the player's visual representation in the `update_rendered_transform` system. +//! Accumulated input is consumed here. +//! This is run in the `FixedUpdate` schedule, which runs zero or multiple times per frame. +//! - Update the player's visual representation in the `interpolate_rendered_transform` system. //! This interpolates between the player's previous and current position in the physics simulation. -//! - Update the player's velocity based on the player's input in the `handle_input` system. +//! It is run in the `RunFixedMainLoop` schedule, ordered in `RunFixedMainLoopSystem::AfterFixedMainLoop`, +//! which runs after the fixed timestep loop. This is run every frame. //! //! //! ## Controls @@ -80,13 +86,31 @@ fn main() { App::new() .add_plugins(DefaultPlugins) .add_systems(Startup, (spawn_text, spawn_player)) - // `FixedUpdate` runs before `Update`, so the physics simulation is advanced before the player's visual representation is updated. + // Advance the physics simulation using a fixed timestep. .add_systems(FixedUpdate, advance_physics) - .add_systems(Update, (update_rendered_transform, handle_input).chain()) + .add_systems( + // The `RunFixedMainLoop` schedule allows us to schedule systems to run before and after the fixed timestep loop. + RunFixedMainLoop, + ( + // The physics simulation needs to know the player's input, so we run this before the fixed timestep loop. + // Note that if we ran it in `Update`, it would be too late, as the physics simulation would already have been advanced. + // If we ran this in `FixedUpdate`, it would sometimes not register player input, as that schedule may run zero times per frame. + handle_input.in_set(RunFixedMainLoopSystem::BeforeFixedMainLoop), + // The player's visual representation needs to be updated after the physics simulation has been advanced. + // This could be run in `Update`, but if we run it here instead, the systems in `Update` + // will be working with the `Transform` that will actually be shown on screen. + interpolate_rendered_transform.in_set(RunFixedMainLoopSystem::AfterFixedMainLoop), + ), + ) .run(); } -/// How many units per second the player should move. +/// A vector representing the player's input, accumulated over all frames that ran +/// since the last time the physics simulation was advanced. +#[derive(Debug, Component, Clone, Copy, PartialEq, Default, Deref, DerefMut)] +struct AccumulatedInput(Vec2); + +/// A vector representing the player's velocity in the physics simulation. #[derive(Debug, Component, Clone, Copy, PartialEq, Default, Deref, DerefMut)] struct Velocity(Vec3); @@ -100,7 +124,7 @@ struct Velocity(Vec3); struct PhysicalTranslation(Vec3); /// The value [`PhysicalTranslation`] had in the last fixed timestep. -/// Used for interpolation in the `update_rendered_transform` system. +/// Used for interpolation in the `interpolate_rendered_transform` system. #[derive(Debug, Component, Clone, Copy, PartialEq, Default, Deref, DerefMut)] struct PreviousPhysicalTranslation(Vec3); @@ -114,6 +138,7 @@ fn spawn_player(mut commands: Commands, asset_server: Res) { transform: Transform::from_scale(Vec3::splat(0.3)), ..default() }, + AccumulatedInput::default(), Velocity::default(), PhysicalTranslation::default(), PreviousPhysicalTranslation::default(), @@ -143,31 +168,35 @@ fn spawn_text(mut commands: Commands) { }); } -/// Handle keyboard input to move the player. -fn handle_input(keyboard_input: Res>, mut query: Query<&mut Velocity>) { +/// Handle keyboard input and accumulate it in the `AccumulatedInput` component. +/// There are many strategies for how to handle all the input that happened since the last fixed timestep. +/// This is a very simple one: we just accumulate the input and average it out by normalizing it. +fn handle_input( + keyboard_input: Res>, + mut query: Query<(&mut AccumulatedInput, &mut Velocity)>, +) { /// Since Bevy's default 2D camera setup is scaled such that /// one unit is one pixel, you can think of this as /// "How many pixels per second should the player move?" const SPEED: f32 = 210.0; - for mut velocity in query.iter_mut() { - velocity.0 = Vec3::ZERO; - + for (mut input, mut velocity) in query.iter_mut() { if keyboard_input.pressed(KeyCode::KeyW) { - velocity.y += 1.0; + input.y += 1.0; } if keyboard_input.pressed(KeyCode::KeyS) { - velocity.y -= 1.0; + input.y -= 1.0; } if keyboard_input.pressed(KeyCode::KeyA) { - velocity.x -= 1.0; + input.x -= 1.0; } if keyboard_input.pressed(KeyCode::KeyD) { - velocity.x += 1.0; + input.x += 1.0; } // Need to normalize and scale because otherwise // diagonal movement would be faster than horizontal or vertical movement. - velocity.0 = velocity.normalize_or_zero() * SPEED; + // This effectively averages the accumulated input. + velocity.0 = input.extend(0.0).normalize_or_zero() * SPEED; } } @@ -180,18 +209,26 @@ fn advance_physics( mut query: Query<( &mut PhysicalTranslation, &mut PreviousPhysicalTranslation, + &mut AccumulatedInput, &Velocity, )>, ) { - for (mut current_physical_translation, mut previous_physical_translation, velocity) in - query.iter_mut() + for ( + mut current_physical_translation, + mut previous_physical_translation, + mut input, + velocity, + ) in query.iter_mut() { previous_physical_translation.0 = current_physical_translation.0; current_physical_translation.0 += velocity.0 * fixed_time.delta_seconds(); + + // Reset the input accumulator, as we are currently consuming all input that happened since the last fixed timestep. + input.0 = Vec2::ZERO; } } -fn update_rendered_transform( +fn interpolate_rendered_transform( fixed_time: Res>, mut query: Query<( &mut Transform,