Allow ordering variable timesteps around fixed timesteps (#14881)

# 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 <git@tau.garden>
This commit is contained in:
Jan Hohenheim 2024-08-23 18:19:42 +02:00 committed by GitHub
parent f1f07bec09
commit c92ee31779
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 185 additions and 30 deletions

View file

@ -34,7 +34,8 @@ pub mod prelude {
app::{App, AppExit}, app::{App, AppExit},
main_schedule::{ main_schedule::{
First, FixedFirst, FixedLast, FixedPostUpdate, FixedPreUpdate, FixedUpdate, Last, Main, 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, sub_app::SubApp,
Plugin, PluginGroup, Plugin, PluginGroup,

View file

@ -1,6 +1,9 @@
use crate::{App, Plugin}; use crate::{App, Plugin};
use bevy_ecs::{ use bevy_ecs::{
schedule::{ExecutorKind, InternedScheduleLabel, Schedule, ScheduleLabel}, schedule::{
ExecutorKind, InternedScheduleLabel, IntoSystemSetConfigs, Schedule, ScheduleLabel,
SystemSet,
},
system::{Local, Resource}, system::{Local, Resource},
world::{Mut, World}, world::{Mut, World},
}; };
@ -75,6 +78,11 @@ pub struct First;
pub struct PreUpdate; pub struct PreUpdate;
/// Runs the [`FixedMain`] schedule in a loop according until all relevant elapsed time has been "consumed". /// 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. /// See the [`Main`] schedule for some details about how schedules are run.
#[derive(ScheduleLabel, Clone, Debug, PartialEq, Eq, Hash)] #[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 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. If you need to order your variable timestep systems
/// This is run by the [`RunFixedMainLoop`] schedule. /// before or after the fixed update logic, use the [`RunFixedMainLoopSystem`] system set.
/// ///
/// Frequency of execution is configured by inserting `Time<Fixed>` resource, 64 Hz by default. /// Frequency of execution is configured by inserting `Time<Fixed>` resource, 64 Hz by default.
/// See [this example](https://github.com/bevyengine/bevy/blob/latest/examples/time/time.rs). /// See [this example](https://github.com/bevyengine/bevy/blob/latest/examples/time/time.rs).
@ -288,7 +296,16 @@ impl Plugin for MainSchedulePlugin {
.init_resource::<MainScheduleOrder>() .init_resource::<MainScheduleOrder>()
.init_resource::<FixedMainScheduleOrder>() .init_resource::<FixedMainScheduleOrder>()
.add_systems(Main, Main::run_main) .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")] #[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<Virtual>`] 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<Virtual>`]: 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,
}

View file

@ -246,13 +246,15 @@ impl AppGizmoBuilder for App {
.init_resource::<GizmoStorage<Config, Swap<Fixed>>>() .init_resource::<GizmoStorage<Config, Swap<Fixed>>>()
.add_systems( .add_systems(
RunFixedMainLoop, RunFixedMainLoop,
start_gizmo_context::<Config, Fixed>.before(bevy_time::run_fixed_main_schedule), start_gizmo_context::<Config, Fixed>
.in_set(bevy_app::RunFixedMainLoopSystem::BeforeFixedMainLoop),
) )
.add_systems(FixedFirst, clear_gizmo_context::<Config, Fixed>) .add_systems(FixedFirst, clear_gizmo_context::<Config, Fixed>)
.add_systems(FixedLast, collect_requested_gizmos::<Config, Fixed>) .add_systems(FixedLast, collect_requested_gizmos::<Config, Fixed>)
.add_systems( .add_systems(
RunFixedMainLoop, RunFixedMainLoop,
end_gizmo_context::<Config, Fixed>.after(bevy_time::run_fixed_main_schedule), end_gizmo_context::<Config, Fixed>
.in_set(bevy_app::RunFixedMainLoopSystem::AfterFixedMainLoop),
) )
.add_systems( .add_systems(
Last, Last,

View file

@ -233,8 +233,10 @@ impl Default for Fixed {
} }
/// Runs [`FixedMain`] zero or more times based on delta of /// Runs [`FixedMain`] zero or more times based on delta of
/// [`Time<Virtual>`](Virtual) and [`Time::overstep`] /// [`Time<Virtual>`](Virtual) and [`Time::overstep`].
pub fn run_fixed_main_schedule(world: &mut World) { /// 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::<Time<Virtual>>().delta(); let delta = world.resource::<Time<Virtual>>().delta();
world.resource_mut::<Time<Fixed>>().accumulate(delta); world.resource_mut::<Time<Fixed>>().accumulate(delta);

View file

@ -70,7 +70,10 @@ impl Plugin for TimePlugin {
.in_set(TimeSystem) .in_set(TimeSystem)
.ambiguous_with(event_update_system), .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 // Ensure the events are not dropped until `FixedMain` systems can observe them
app.add_systems(FixedPostUpdate, signal_event_update_system); app.add_systems(FixedPostUpdate, signal_event_update_system);

View file

@ -53,16 +53,22 @@
//! //!
//! ## Implementation //! ## 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 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 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 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. //! - The player's visual representation is stored in Bevy's regular `Transform` component.
//! - Every frame, we go through the following steps: //! - 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. //! - 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. //! Accumulated input is consumed here.
//! - Update the player's visual representation in the `update_rendered_transform` system. //! 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. //! 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 //! ## Controls
@ -80,13 +86,31 @@ fn main() {
App::new() App::new()
.add_plugins(DefaultPlugins) .add_plugins(DefaultPlugins)
.add_systems(Startup, (spawn_text, spawn_player)) .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(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(); .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)] #[derive(Debug, Component, Clone, Copy, PartialEq, Default, Deref, DerefMut)]
struct Velocity(Vec3); struct Velocity(Vec3);
@ -100,7 +124,7 @@ struct Velocity(Vec3);
struct PhysicalTranslation(Vec3); struct PhysicalTranslation(Vec3);
/// The value [`PhysicalTranslation`] had in the last fixed timestep. /// 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)] #[derive(Debug, Component, Clone, Copy, PartialEq, Default, Deref, DerefMut)]
struct PreviousPhysicalTranslation(Vec3); struct PreviousPhysicalTranslation(Vec3);
@ -114,6 +138,7 @@ fn spawn_player(mut commands: Commands, asset_server: Res<AssetServer>) {
transform: Transform::from_scale(Vec3::splat(0.3)), transform: Transform::from_scale(Vec3::splat(0.3)),
..default() ..default()
}, },
AccumulatedInput::default(),
Velocity::default(), Velocity::default(),
PhysicalTranslation::default(), PhysicalTranslation::default(),
PreviousPhysicalTranslation::default(), PreviousPhysicalTranslation::default(),
@ -143,31 +168,35 @@ fn spawn_text(mut commands: Commands) {
}); });
} }
/// Handle keyboard input to move the player. /// Handle keyboard input and accumulate it in the `AccumulatedInput` component.
fn handle_input(keyboard_input: Res<ButtonInput<KeyCode>>, mut query: Query<&mut Velocity>) { /// 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<ButtonInput<KeyCode>>,
mut query: Query<(&mut AccumulatedInput, &mut Velocity)>,
) {
/// Since Bevy's default 2D camera setup is scaled such that /// Since Bevy's default 2D camera setup is scaled such that
/// one unit is one pixel, you can think of this as /// one unit is one pixel, you can think of this as
/// "How many pixels per second should the player move?" /// "How many pixels per second should the player move?"
const SPEED: f32 = 210.0; const SPEED: f32 = 210.0;
for mut velocity in query.iter_mut() { for (mut input, mut velocity) in query.iter_mut() {
velocity.0 = Vec3::ZERO;
if keyboard_input.pressed(KeyCode::KeyW) { if keyboard_input.pressed(KeyCode::KeyW) {
velocity.y += 1.0; input.y += 1.0;
} }
if keyboard_input.pressed(KeyCode::KeyS) { if keyboard_input.pressed(KeyCode::KeyS) {
velocity.y -= 1.0; input.y -= 1.0;
} }
if keyboard_input.pressed(KeyCode::KeyA) { if keyboard_input.pressed(KeyCode::KeyA) {
velocity.x -= 1.0; input.x -= 1.0;
} }
if keyboard_input.pressed(KeyCode::KeyD) { if keyboard_input.pressed(KeyCode::KeyD) {
velocity.x += 1.0; input.x += 1.0;
} }
// Need to normalize and scale because otherwise // Need to normalize and scale because otherwise
// diagonal movement would be faster than horizontal or vertical movement. // 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 query: Query<(
&mut PhysicalTranslation, &mut PhysicalTranslation,
&mut PreviousPhysicalTranslation, &mut PreviousPhysicalTranslation,
&mut AccumulatedInput,
&Velocity, &Velocity,
)>, )>,
) { ) {
for (mut current_physical_translation, mut previous_physical_translation, velocity) in for (
query.iter_mut() mut current_physical_translation,
mut previous_physical_translation,
mut input,
velocity,
) in query.iter_mut()
{ {
previous_physical_translation.0 = current_physical_translation.0; previous_physical_translation.0 = current_physical_translation.0;
current_physical_translation.0 += velocity.0 * fixed_time.delta_seconds(); 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<Time<Fixed>>, fixed_time: Res<Time<Fixed>>,
mut query: Query<( mut query: Query<(
&mut Transform, &mut Transform,