physics_in_fixed_timestep/physics_in_fixed_timestep.rs
1//! This example shows how to properly handle player input,
2//! advance a physics simulation in a fixed timestep, and display the results.
3//!
4//! The classic source for how and why this is done is Glenn Fiedler's article
5//! [Fix Your Timestep!](https://gafferongames.com/post/fix_your_timestep/).
6//! For a more Bevy-centric source, see
7//! [this cheatbook entry](https://bevy-cheatbook.github.io/fundamentals/fixed-timestep.html).
8//!
9//! ## Motivation
10//!
11//! The naive way of moving a player is to just update their position like so:
12//! ```no_run
13//! transform.translation += velocity;
14//! ```
15//! The issue here is that the player's movement speed will be tied to the frame rate.
16//! Faster machines will move the player faster, and slower machines will move the player slower.
17//! In fact, you can observe this today when running some old games that did it this way on modern hardware!
18//! The player will move at a breakneck pace.
19//!
20//! The more sophisticated way is to update the player's position based on the time that has passed:
21//! ```no_run
22//! transform.translation += velocity * time.delta_secs();
23//! ```
24//! This way, velocity represents a speed in units per second, and the player will move at the same speed
25//! regardless of the frame rate.
26//!
27//! However, this can still be problematic if the frame rate is very low or very high.
28//! If the frame rate is very low, the player will move in large jumps. This may lead to
29//! a player moving in such large jumps that they pass through walls or other obstacles.
30//! In general, you cannot expect a physics simulation to behave nicely with *any* delta time.
31//! Ideally, we want to have some stability in what kinds of delta times we feed into our physics simulation.
32//!
33//! The solution is using a fixed timestep. This means that we advance the physics simulation by a fixed amount
34//! at a time. If the real time that passed between two frames is less than the fixed timestep, we simply
35//! don't advance the physics simulation at all.
36//! If it is more, we advance the physics simulation multiple times until we catch up.
37//! You can read more about how Bevy implements this in the documentation for
38//! [`bevy::time::Fixed`](https://docs.rs/bevy/latest/bevy/time/struct.Fixed.html).
39//!
40//! This leaves us with a last problem, however. If our physics simulation may advance zero or multiple times
41//! per frame, there may be frames in which the player's position did not need to be updated at all,
42//! and some where it is updated by a large amount that resulted from running the physics simulation multiple times.
43//! This is physically correct, but visually jarring. Imagine a player moving in a straight line, but depending on the frame rate,
44//! they may sometimes advance by a large amount and sometimes not at all. Visually, we want the player to move smoothly.
45//! This is why we need to separate the player's position in the physics simulation from the player's position in the visual representation.
46//! The visual representation can then be interpolated smoothly based on the previous and current actual player position in the physics simulation.
47//!
48//! This is a tradeoff: every visual frame is now slightly lagging behind the actual physical frame,
49//! but in return, the player's movement will appear smooth.
50//! There are other ways to compute the visual representation of the player, such as extrapolation.
51//! See the [documentation of the lightyear crate](https://cbournhonesque.github.io/lightyear/book/concepts/advanced_replication/visual_interpolation.html)
52//! for a nice overview of the different methods and their respective tradeoffs.
53//!
54//! ## Implementation
55//!
56//! - The player's inputs since the last physics update are stored in the `AccumulatedInput` component.
57//! - The player's velocity is stored in a `Velocity` component. This is the speed in units per second.
58//! - The player's current position in the physics simulation is stored in a `PhysicalTranslation` component.
59//! - The player's previous position in the physics simulation is stored in a `PreviousPhysicalTranslation` component.
60//! - The player's visual representation is stored in Bevy's regular `Transform` component.
61//! - Every frame, we go through the following steps:
62//! - Accumulate the player's input and set the current speed in the `handle_input` system.
63//! This is run in the `RunFixedMainLoop` schedule, ordered in `RunFixedMainLoopSystem::BeforeFixedMainLoop`,
64//! which runs before the fixed timestep loop. This is run every frame.
65//! - Advance the physics simulation by one fixed timestep in the `advance_physics` system.
66//! Accumulated input is consumed here.
67//! This is run in the `FixedUpdate` schedule, which runs zero or multiple times per frame.
68//! - Update the player's visual representation in the `interpolate_rendered_transform` system.
69//! This interpolates between the player's previous and current position in the physics simulation.
70//! It is run in the `RunFixedMainLoop` schedule, ordered in `RunFixedMainLoopSystem::AfterFixedMainLoop`,
71//! which runs after the fixed timestep loop. This is run every frame.
72//!
73//!
74//! ## Controls
75//!
76//! | Key Binding | Action |
77//! |:---------------------|:--------------|
78//! | `W` | Move up |
79//! | `S` | Move down |
80//! | `A` | Move left |
81//! | `D` | Move right |
82
83use bevy::prelude::*;
84
85fn main() {
86 App::new()
87 .add_plugins(DefaultPlugins)
88 .add_systems(Startup, (spawn_text, spawn_player))
89 // Advance the physics simulation using a fixed timestep.
90 .add_systems(FixedUpdate, advance_physics)
91 .add_systems(
92 // The `RunFixedMainLoop` schedule allows us to schedule systems to run before and after the fixed timestep loop.
93 RunFixedMainLoop,
94 (
95 // The physics simulation needs to know the player's input, so we run this before the fixed timestep loop.
96 // Note that if we ran it in `Update`, it would be too late, as the physics simulation would already have been advanced.
97 // If we ran this in `FixedUpdate`, it would sometimes not register player input, as that schedule may run zero times per frame.
98 handle_input.in_set(RunFixedMainLoopSystem::BeforeFixedMainLoop),
99 // The player's visual representation needs to be updated after the physics simulation has been advanced.
100 // This could be run in `Update`, but if we run it here instead, the systems in `Update`
101 // will be working with the `Transform` that will actually be shown on screen.
102 interpolate_rendered_transform.in_set(RunFixedMainLoopSystem::AfterFixedMainLoop),
103 ),
104 )
105 .run();
106}
107
108/// A vector representing the player's input, accumulated over all frames that ran
109/// since the last time the physics simulation was advanced.
110#[derive(Debug, Component, Clone, Copy, PartialEq, Default, Deref, DerefMut)]
111struct AccumulatedInput(Vec2);
112
113/// A vector representing the player's velocity in the physics simulation.
114#[derive(Debug, Component, Clone, Copy, PartialEq, Default, Deref, DerefMut)]
115struct Velocity(Vec3);
116
117/// The actual position of the player in the physics simulation.
118/// This is separate from the `Transform`, which is merely a visual representation.
119///
120/// If you want to make sure that this component is always initialized
121/// with the same value as the `Transform`'s translation, you can
122/// use a [component lifecycle hook](https://docs.rs/bevy/0.14.0/bevy/ecs/component/struct.ComponentHooks.html)
123#[derive(Debug, Component, Clone, Copy, PartialEq, Default, Deref, DerefMut)]
124struct PhysicalTranslation(Vec3);
125
126/// The value [`PhysicalTranslation`] had in the last fixed timestep.
127/// Used for interpolation in the `interpolate_rendered_transform` system.
128#[derive(Debug, Component, Clone, Copy, PartialEq, Default, Deref, DerefMut)]
129struct PreviousPhysicalTranslation(Vec3);
130
131/// Spawn the player sprite and a 2D camera.
132fn spawn_player(mut commands: Commands, asset_server: Res<AssetServer>) {
133 commands.spawn(Camera2d);
134 commands.spawn((
135 Name::new("Player"),
136 Sprite::from_image(asset_server.load("branding/icon.png")),
137 Transform::from_scale(Vec3::splat(0.3)),
138 AccumulatedInput::default(),
139 Velocity::default(),
140 PhysicalTranslation::default(),
141 PreviousPhysicalTranslation::default(),
142 ));
143}
144
145/// Spawn a bit of UI text to explain how to move the player.
146fn spawn_text(mut commands: Commands) {
147 commands
148 .spawn(Node {
149 position_type: PositionType::Absolute,
150 bottom: Val::Px(12.0),
151 left: Val::Px(12.0),
152 ..default()
153 })
154 .with_child((
155 Text::new("Move the player with WASD"),
156 TextFont {
157 font_size: 25.0,
158 ..default()
159 },
160 ));
161}
162
163/// Handle keyboard input and accumulate it in the `AccumulatedInput` component.
164///
165/// There are many strategies for how to handle all the input that happened since the last fixed timestep.
166/// This is a very simple one: we just accumulate the input and average it out by normalizing it.
167fn handle_input(
168 keyboard_input: Res<ButtonInput<KeyCode>>,
169 mut query: Query<(&mut AccumulatedInput, &mut Velocity)>,
170) {
171 /// Since Bevy's default 2D camera setup is scaled such that
172 /// one unit is one pixel, you can think of this as
173 /// "How many pixels per second should the player move?"
174 const SPEED: f32 = 210.0;
175 for (mut input, mut velocity) in query.iter_mut() {
176 if keyboard_input.pressed(KeyCode::KeyW) {
177 input.y += 1.0;
178 }
179 if keyboard_input.pressed(KeyCode::KeyS) {
180 input.y -= 1.0;
181 }
182 if keyboard_input.pressed(KeyCode::KeyA) {
183 input.x -= 1.0;
184 }
185 if keyboard_input.pressed(KeyCode::KeyD) {
186 input.x += 1.0;
187 }
188
189 // Need to normalize and scale because otherwise
190 // diagonal movement would be faster than horizontal or vertical movement.
191 // This effectively averages the accumulated input.
192 velocity.0 = input.extend(0.0).normalize_or_zero() * SPEED;
193 }
194}
195
196/// Advance the physics simulation by one fixed timestep. This may run zero or multiple times per frame.
197///
198/// Note that since this runs in `FixedUpdate`, `Res<Time>` would be `Res<Time<Fixed>>` automatically.
199/// We are being explicit here for clarity.
200fn advance_physics(
201 fixed_time: Res<Time<Fixed>>,
202 mut query: Query<(
203 &mut PhysicalTranslation,
204 &mut PreviousPhysicalTranslation,
205 &mut AccumulatedInput,
206 &Velocity,
207 )>,
208) {
209 for (
210 mut current_physical_translation,
211 mut previous_physical_translation,
212 mut input,
213 velocity,
214 ) in query.iter_mut()
215 {
216 previous_physical_translation.0 = current_physical_translation.0;
217 current_physical_translation.0 += velocity.0 * fixed_time.delta_secs();
218
219 // Reset the input accumulator, as we are currently consuming all input that happened since the last fixed timestep.
220 input.0 = Vec2::ZERO;
221 }
222}
223
224fn interpolate_rendered_transform(
225 fixed_time: Res<Time<Fixed>>,
226 mut query: Query<(
227 &mut Transform,
228 &PhysicalTranslation,
229 &PreviousPhysicalTranslation,
230 )>,
231) {
232 for (mut transform, current_physical_translation, previous_physical_translation) in
233 query.iter_mut()
234 {
235 let previous = previous_physical_translation.0;
236 let current = current_physical_translation.0;
237 // The overstep fraction is a value between 0 and 1 that tells us how far we are between two fixed timesteps.
238 let alpha = fixed_time.overstep_fraction();
239
240 let rendered_translation = previous.lerp(current, alpha);
241 transform.translation = rendered_translation;
242 }
243}