docs/bevy/examples/movement/physics_in_fixed_timestep.rs 20.4 K · 417 lines · raw · history
| 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 | //! If we decide to use a fixed timestep, our game logic should mostly go in the `FixedUpdate` schedule. |
| 55 | //! One notable exception is the camera. Cameras should update as often as possible, or the player will very quickly |
| 56 | //! notice choppy movement if it's only updated at the same rate as the physics simulation. So, we use a variable timestep for the camera, |
| 57 | //! updating its transform every frame. The question now is which schedule to use. That depends on whether the camera data is required |
| 58 | //! for the physics simulation to run or not. |
| 59 | //! For example, in 3D games, the camera rotation often determines which direction the player moves when pressing "W", |
| 60 | //! so we need to rotate the camera *before* the fixed timestep. In contrast, the translation of the camera depends on what the physics simulation |
| 61 | //! has calculated for the player's position. Therefore, we need to update the camera's translation *after* the fixed timestep. Fortunately, |
| 62 | //! we can get smooth movement by simply using the interpolated player translation for the camera as well. |
| 63 | //! |
| 64 | //! ## Implementation |
| 65 | //! |
| 66 | //! - The player's inputs since the last physics update are stored in the `AccumulatedInput` component. |
| 67 | //! - The player's velocity is stored in a `Velocity` component. This is the speed in units per second. |
| 68 | //! - The player's current position in the physics simulation is stored in a `PhysicalTranslation` component. |
| 69 | //! - The player's previous position in the physics simulation is stored in a `PreviousPhysicalTranslation` component. |
| 70 | //! - The player's visual representation is stored in Bevy's regular `Transform` component. |
| 71 | //! - Every frame, we go through the following steps: |
| 72 | //! - Accumulate the player's input and set the current speed in the `handle_input` system. |
| 73 | //! This is run in the `RunFixedMainLoop` schedule, ordered in `RunFixedMainLoopSystems::BeforeFixedMainLoop`, |
| 74 | //! which runs before the fixed timestep loop. This is run every frame. |
| 75 | //! - Rotate the camera based on the player's input. This is also run in `RunFixedMainLoopSystems::BeforeFixedMainLoop`. |
| 76 | //! - Advance the physics simulation by one fixed timestep in the `advance_physics` system. |
| 77 | //! Accumulated input is consumed here. |
| 78 | //! This is run in the `FixedUpdate` schedule, which runs zero or multiple times per frame. |
| 79 | //! - Update the player's visual representation in the `interpolate_rendered_transform` system. |
| 80 | //! This interpolates between the player's previous and current position in the physics simulation. |
| 81 | //! It is run in the `RunFixedMainLoop` schedule, ordered in `RunFixedMainLoopSystems::AfterFixedMainLoop`, |
| 82 | //! which runs after the fixed timestep loop. This is run every frame. |
| 83 | //! - Update the camera's translation to the player's interpolated translation. This is also run in `RunFixedMainLoopSystems::AfterFixedMainLoop`. |
| 84 | //! |
| 85 | //! |
| 86 | //! ## Controls |
| 87 | //! |
| 88 | //! | Key Binding | Action | |
| 89 | //! |:---------------------|:--------------| |
| 90 | //! | `W` | Move up | |
| 91 | //! | `S` | Move down | |
| 92 | //! | `A` | Move left | |
| 93 | //! | `D` | Move right | |
| 94 | //! | Mouse | Rotate camera | |
| 95 | |
| 96 | use std::f32::consts::FRAC_PI_2; |
| 97 | |
| 98 | use bevy::{color::palettes::tailwind, input::mouse::AccumulatedMouseMotion, prelude::*}; |
| 99 | |
| 100 | fn main() { |
| 101 | App::new() |
| 102 | .add_plugins(DefaultPlugins) |
| 103 | .init_resource::<DidFixedTimestepRunThisFrame>() |
| 104 | .add_systems(Startup, (spawn_text, spawn_player, spawn_environment)) |
| 105 | // At the beginning of each frame, clear the flag that indicates whether the fixed timestep has run this frame. |
| 106 | .add_systems(PreUpdate, clear_fixed_timestep_flag) |
| 107 | // At the beginning of each fixed timestep, set the flag that indicates whether the fixed timestep has run this frame. |
| 108 | .add_systems(FixedPreUpdate, set_fixed_time_step_flag) |
| 109 | // Advance the physics simulation using a fixed timestep. |
| 110 | .add_systems(FixedUpdate, advance_physics) |
| 111 | .add_systems( |
| 112 | // The `RunFixedMainLoop` schedule allows us to schedule systems to run before and after the fixed timestep loop. |
| 113 | RunFixedMainLoop, |
| 114 | ( |
| 115 | ( |
| 116 | // The camera needs to be rotated before the physics simulation is advanced in before the fixed timestep loop, |
| 117 | // so that the physics simulation can use the current rotation. |
| 118 | // Note that if we ran it in `Update`, it would be too late, as the physics simulation would already have been advanced. |
| 119 | // If we ran this in `FixedUpdate`, it would sometimes not register player input, as that schedule may run zero times per frame. |
| 120 | rotate_camera, |
| 121 | // Accumulate our input before the fixed timestep loop to tell the physics simulation what it should do during the fixed timestep. |
| 122 | accumulate_input, |
| 123 | ) |
| 124 | .chain() |
| 125 | .in_set(RunFixedMainLoopSystems::BeforeFixedMainLoop), |
| 126 | ( |
| 127 | // Clear our accumulated input after it was processed during the fixed timestep. |
| 128 | // By clearing the input *after* the fixed timestep, we can still use `AccumulatedInput` inside `FixedUpdate` if we need it. |
| 129 | clear_input.run_if(did_fixed_timestep_run_this_frame), |
| 130 | // The player's visual representation needs to be updated after the physics simulation has been advanced. |
| 131 | // This could be run in `Update`, but if we run it here instead, the systems in `Update` |
| 132 | // will be working with the `Transform` that will actually be shown on screen. |
| 133 | interpolate_rendered_transform, |
| 134 | // The camera can then use the interpolated transform to position itself correctly. |
| 135 | translate_camera, |
| 136 | ) |
| 137 | .chain() |
| 138 | .in_set(RunFixedMainLoopSystems::AfterFixedMainLoop), |
| 139 | ), |
| 140 | ) |
| 141 | .run(); |
| 142 | } |
| 143 | |
| 144 | /// A vector representing the player's input, accumulated over all frames that ran |
| 145 | /// since the last time the physics simulation was advanced. |
| 146 | #[derive(Debug, Component, Clone, Copy, PartialEq, Default, Deref, DerefMut)] |
| 147 | struct AccumulatedInput { |
| 148 | // The player's movement input (WASD). |
| 149 | movement: Vec2, |
| 150 | // Other input that could make sense would be e.g. |
| 151 | // boost: bool |
| 152 | } |
| 153 | |
| 154 | /// A vector representing the player's velocity in the physics simulation. |
| 155 | #[derive(Debug, Component, Clone, Copy, PartialEq, Default, Deref, DerefMut)] |
| 156 | struct Velocity(Vec3); |
| 157 | |
| 158 | /// The actual position of the player in the physics simulation. |
| 159 | /// This is separate from the `Transform`, which is merely a visual representation. |
| 160 | /// |
| 161 | /// If you want to make sure that this component is always initialized |
| 162 | /// with the same value as the `Transform`'s translation, you can |
| 163 | /// use a [component lifecycle hook](https://docs.rs/bevy/latest/bevy/ecs/component/struct.ComponentHooks.html) |
| 164 | #[derive(Debug, Component, Clone, Copy, PartialEq, Default, Deref, DerefMut)] |
| 165 | struct PhysicalTranslation(Vec3); |
| 166 | |
| 167 | /// The value [`PhysicalTranslation`] had in the last fixed timestep. |
| 168 | /// Used for interpolation in the `interpolate_rendered_transform` system. |
| 169 | #[derive(Debug, Component, Clone, Copy, PartialEq, Default, Deref, DerefMut)] |
| 170 | struct PreviousPhysicalTranslation(Vec3); |
| 171 | |
| 172 | /// Spawn the player and a 3D camera. We could also spawn the camera as a child of the player, |
| 173 | /// but in practice, they are usually spawned separately so that the player's rotation does not |
| 174 | /// influence the camera's rotation. |
| 175 | fn spawn_player(mut commands: Commands) { |
| 176 | commands.spawn((Camera3d::default(), CameraSensitivity::default())); |
| 177 | commands.spawn(( |
| 178 | Name::new("Player"), |
| 179 | Transform::from_scale(Vec3::splat(0.3)), |
| 180 | AccumulatedInput::default(), |
| 181 | Velocity::default(), |
| 182 | PhysicalTranslation::default(), |
| 183 | PreviousPhysicalTranslation::default(), |
| 184 | )); |
| 185 | } |
| 186 | |
| 187 | /// Spawn a field of floating spheres to fly around in |
| 188 | fn spawn_environment( |
| 189 | mut commands: Commands, |
| 190 | mut meshes: ResMut<Assets<Mesh>>, |
| 191 | mut materials: ResMut<Assets<StandardMaterial>>, |
| 192 | ) { |
| 193 | let sphere_material = materials.add(Color::from(tailwind::SKY_200)); |
| 194 | let sphere_mesh = meshes.add(Sphere::new(0.3)); |
| 195 | let spheres_in_x = 6; |
| 196 | let spheres_in_y = 4; |
| 197 | let spheres_in_z = 10; |
| 198 | let distance = 3.0; |
| 199 | for x in 0..spheres_in_x { |
| 200 | for y in 0..spheres_in_y { |
| 201 | for z in 0..spheres_in_z { |
| 202 | let translation = Vec3::new( |
| 203 | x as f32 * distance - (spheres_in_x as f32 - 1.0) * distance / 2.0, |
| 204 | y as f32 * distance - (spheres_in_y as f32 - 1.0) * distance / 2.0, |
| 205 | z as f32 * distance - (spheres_in_z as f32 - 1.0) * distance / 2.0, |
| 206 | ); |
| 207 | commands.spawn(( |
| 208 | Name::new("Sphere"), |
| 209 | Transform::from_translation(translation), |
| 210 | Mesh3d(sphere_mesh.clone()), |
| 211 | MeshMaterial3d(sphere_material.clone()), |
| 212 | )); |
| 213 | } |
| 214 | } |
| 215 | } |
| 216 | |
| 217 | commands.spawn(( |
| 218 | DirectionalLight::default(), |
| 219 | Transform::default().looking_to(Vec3::new(-1.0, -3.0, 0.5), Vec3::Y), |
| 220 | )); |
| 221 | } |
| 222 | |
| 223 | /// Spawn a bit of UI text to explain how to move the player. |
| 224 | fn spawn_text(mut commands: Commands) { |
| 225 | let font = TextFont { |
| 226 | font_size: FontSize::Px(25.0), |
| 227 | ..default() |
| 228 | }; |
| 229 | commands.spawn(( |
| 230 | Node { |
| 231 | position_type: PositionType::Absolute, |
| 232 | bottom: px(12), |
| 233 | left: px(12), |
| 234 | flex_direction: FlexDirection::Column, |
| 235 | ..default() |
| 236 | }, |
| 237 | children![ |
| 238 | (Text::new("Move the player with WASD"), font.clone()), |
| 239 | (Text::new("Rotate the camera with the mouse"), font) |
| 240 | ], |
| 241 | )); |
| 242 | } |
| 243 | |
| 244 | fn rotate_camera( |
| 245 | accumulated_mouse_motion: Res<AccumulatedMouseMotion>, |
| 246 | player: Single<(&mut Transform, &CameraSensitivity), With<Camera>>, |
| 247 | ) { |
| 248 | let (mut transform, camera_sensitivity) = player.into_inner(); |
| 249 | |
| 250 | let delta = accumulated_mouse_motion.delta; |
| 251 | |
| 252 | if delta != Vec2::ZERO { |
| 253 | // Note that we are not multiplying by delta time here. |
| 254 | // The reason is that for mouse movement, we already get the full movement that happened since the last frame. |
| 255 | // This means that if we multiply by delta time, we will get a smaller rotation than intended by the user. |
| 256 | let delta_yaw = -delta.x * camera_sensitivity.x; |
| 257 | let delta_pitch = -delta.y * camera_sensitivity.y; |
| 258 | |
| 259 | let (yaw, pitch, roll) = transform.rotation.to_euler(EulerRot::YXZ); |
| 260 | let yaw = yaw + delta_yaw; |
| 261 | |
| 262 | // If the pitch was ±¹⁄₂ π, the camera would look straight up or down. |
| 263 | // When the user wants to move the camera back to the horizon, which way should the camera face? |
| 264 | // The camera has no way of knowing what direction was "forward" before landing in that extreme position, |
| 265 | // so the direction picked will for all intents and purposes be arbitrary. |
| 266 | // Another issue is that for mathematical reasons, the yaw will effectively be flipped when the pitch is at the extremes. |
| 267 | // To not run into these issues, we clamp the pitch to a safe range. |
| 268 | const PITCH_LIMIT: f32 = FRAC_PI_2 - 0.01; |
| 269 | let pitch = (pitch + delta_pitch).clamp(-PITCH_LIMIT, PITCH_LIMIT); |
| 270 | |
| 271 | transform.rotation = Quat::from_euler(EulerRot::YXZ, yaw, pitch, roll); |
| 272 | } |
| 273 | } |
| 274 | |
| 275 | #[derive(Debug, Component, Deref, DerefMut)] |
| 276 | struct CameraSensitivity(Vec2); |
| 277 | |
| 278 | impl Default for CameraSensitivity { |
| 279 | fn default() -> Self { |
| 280 | Self( |
| 281 | // These factors are just arbitrary mouse sensitivity values. |
| 282 | // It's often nicer to have a faster horizontal sensitivity than vertical. |
| 283 | // We use a component for them so that we can make them user-configurable at runtime |
| 284 | // for accessibility reasons. |
| 285 | // It also allows you to inspect them in an editor if you `Reflect` the component. |
| 286 | Vec2::new(0.003, 0.002), |
| 287 | ) |
| 288 | } |
| 289 | } |
| 290 | |
| 291 | /// Handle keyboard input and accumulate it in the `AccumulatedInput` component. |
| 292 | /// |
| 293 | /// There are many strategies for how to handle all the input that happened since the last fixed timestep. |
| 294 | /// This is a very simple one: we just use the last available input. |
| 295 | /// That strategy works fine for us since the user continuously presses the input keys in this example. |
| 296 | /// If we had some kind of instantaneous action like activating a boost ability, we would need to remember that that input |
| 297 | /// was pressed at some point since the last fixed timestep. |
| 298 | fn accumulate_input( |
| 299 | keyboard_input: Res<ButtonInput<KeyCode>>, |
| 300 | player: Single<(&mut AccumulatedInput, &mut Velocity)>, |
| 301 | camera: Single<&Transform, With<Camera>>, |
| 302 | ) { |
| 303 | /// Since Bevy's 3D renderer assumes SI units, this has the unit of meters per second. |
| 304 | /// Note that about 1.5 is the average walking speed of a human. |
| 305 | const SPEED: f32 = 4.0; |
| 306 | let (mut input, mut velocity) = player.into_inner(); |
| 307 | // Reset the input to zero before reading the new input. As mentioned above, we can only do this |
| 308 | // because this is continuously pressed by the user. Do not reset e.g. whether the user wants to boost. |
| 309 | input.movement = Vec2::ZERO; |
| 310 | if keyboard_input.pressed(KeyCode::KeyW) { |
| 311 | input.movement.y += 1.0; |
| 312 | } |
| 313 | if keyboard_input.pressed(KeyCode::KeyS) { |
| 314 | input.movement.y -= 1.0; |
| 315 | } |
| 316 | if keyboard_input.pressed(KeyCode::KeyA) { |
| 317 | input.movement.x -= 1.0; |
| 318 | } |
| 319 | if keyboard_input.pressed(KeyCode::KeyD) { |
| 320 | input.movement.x += 1.0; |
| 321 | } |
| 322 | |
| 323 | // Remap the 2D input to Bevy's 3D coordinate system. |
| 324 | // Pressing W makes `input.y` go up. Since Bevy assumes that -Z is forward, we make our new Z equal to -input.y |
| 325 | let input_3d = Vec3 { |
| 326 | x: input.movement.x, |
| 327 | y: 0.0, |
| 328 | z: -input.movement.y, |
| 329 | }; |
| 330 | |
| 331 | // Rotate the input so that forward is aligned with the camera's forward direction. |
| 332 | let rotated_input = camera.rotation * input_3d; |
| 333 | |
| 334 | // We need to normalize and scale because otherwise |
| 335 | // diagonal movement would be faster than horizontal or vertical movement. |
| 336 | // We use `clamp_length_max` instead of `.normalize_or_zero()` because gamepad input |
| 337 | // may be smaller than 1.0 when the player is pushing the stick just a little bit. |
| 338 | velocity.0 = rotated_input.clamp_length_max(1.0) * SPEED; |
| 339 | } |
| 340 | |
| 341 | /// A simple resource that tells us whether the fixed timestep ran this frame. |
| 342 | #[derive(Resource, Debug, Deref, DerefMut, Default)] |
| 343 | pub struct DidFixedTimestepRunThisFrame(bool); |
| 344 | |
| 345 | /// Reset the flag at the start of every frame. |
| 346 | fn clear_fixed_timestep_flag( |
| 347 | mut did_fixed_timestep_run_this_frame: ResMut<DidFixedTimestepRunThisFrame>, |
| 348 | ) { |
| 349 | did_fixed_timestep_run_this_frame.0 = false; |
| 350 | } |
| 351 | |
| 352 | /// Set the flag during each fixed timestep. |
| 353 | fn set_fixed_time_step_flag( |
| 354 | mut did_fixed_timestep_run_this_frame: ResMut<DidFixedTimestepRunThisFrame>, |
| 355 | ) { |
| 356 | did_fixed_timestep_run_this_frame.0 = true; |
| 357 | } |
| 358 | |
| 359 | fn did_fixed_timestep_run_this_frame( |
| 360 | did_fixed_timestep_run_this_frame: Res<DidFixedTimestepRunThisFrame>, |
| 361 | ) -> bool { |
| 362 | did_fixed_timestep_run_this_frame.0 |
| 363 | } |
| 364 | |
| 365 | // Clear the input after it was processed in the fixed timestep. |
| 366 | fn clear_input(mut input: Single<&mut AccumulatedInput>) { |
| 367 | **input = AccumulatedInput::default(); |
| 368 | } |
| 369 | |
| 370 | /// Advance the physics simulation by one fixed timestep. This may run zero or multiple times per frame. |
| 371 | /// |
| 372 | /// Note that since this runs in `FixedUpdate`, `Res<Time>` would be `Res<Time<Fixed>>` automatically. |
| 373 | /// We are being explicit here for clarity. |
| 374 | fn advance_physics( |
| 375 | fixed_time: Res<Time<Fixed>>, |
| 376 | mut query: Query<( |
| 377 | &mut PhysicalTranslation, |
| 378 | &mut PreviousPhysicalTranslation, |
| 379 | &Velocity, |
| 380 | )>, |
| 381 | ) { |
| 382 | for (mut current_physical_translation, mut previous_physical_translation, velocity) in |
| 383 | query.iter_mut() |
| 384 | { |
| 385 | previous_physical_translation.0 = current_physical_translation.0; |
| 386 | current_physical_translation.0 += velocity.0 * fixed_time.delta_secs(); |
| 387 | } |
| 388 | } |
| 389 | |
| 390 | fn interpolate_rendered_transform( |
| 391 | fixed_time: Res<Time<Fixed>>, |
| 392 | mut query: Query<( |
| 393 | &mut Transform, |
| 394 | &PhysicalTranslation, |
| 395 | &PreviousPhysicalTranslation, |
| 396 | )>, |
| 397 | ) { |
| 398 | for (mut transform, current_physical_translation, previous_physical_translation) in |
| 399 | query.iter_mut() |
| 400 | { |
| 401 | let previous = previous_physical_translation.0; |
| 402 | let current = current_physical_translation.0; |
| 403 | // The overstep fraction is a value between 0 and 1 that tells us how far we are between two fixed timesteps. |
| 404 | let alpha = fixed_time.overstep_fraction(); |
| 405 | |
| 406 | let rendered_translation = previous.lerp(current, alpha); |
| 407 | transform.translation = rendered_translation; |
| 408 | } |
| 409 | } |
| 410 | |
| 411 | // Sync the camera's position with the player's interpolated position |
| 412 | fn translate_camera( |
| 413 | mut camera: Single<&mut Transform, With<Camera>>, |
| 414 | player: Single<&Transform, (With<AccumulatedInput>, Without<Camera>)>, |
| 415 | ) { |
| 416 | camera.translation = player.translation; |
| 417 | } |