Path: blob/main/examples/movement/physics_in_fixed_timestep.rs
6595 views
//! This example shows how to properly handle player input,1//! advance a physics simulation in a fixed timestep, and display the results.2//!3//! The classic source for how and why this is done is Glenn Fiedler's article4//! [Fix Your Timestep!](https://gafferongames.com/post/fix_your_timestep/).5//! For a more Bevy-centric source, see6//! [this cheatbook entry](https://bevy-cheatbook.github.io/fundamentals/fixed-timestep.html).7//!8//! ## Motivation9//!10//! The naive way of moving a player is to just update their position like so:11//! ```no_run12//! transform.translation += velocity;13//! ```14//! The issue here is that the player's movement speed will be tied to the frame rate.15//! Faster machines will move the player faster, and slower machines will move the player slower.16//! In fact, you can observe this today when running some old games that did it this way on modern hardware!17//! The player will move at a breakneck pace.18//!19//! The more sophisticated way is to update the player's position based on the time that has passed:20//! ```no_run21//! transform.translation += velocity * time.delta_secs();22//! ```23//! This way, velocity represents a speed in units per second, and the player will move at the same speed24//! regardless of the frame rate.25//!26//! However, this can still be problematic if the frame rate is very low or very high.27//! If the frame rate is very low, the player will move in large jumps. This may lead to28//! a player moving in such large jumps that they pass through walls or other obstacles.29//! In general, you cannot expect a physics simulation to behave nicely with *any* delta time.30//! Ideally, we want to have some stability in what kinds of delta times we feed into our physics simulation.31//!32//! The solution is using a fixed timestep. This means that we advance the physics simulation by a fixed amount33//! at a time. If the real time that passed between two frames is less than the fixed timestep, we simply34//! don't advance the physics simulation at all.35//! If it is more, we advance the physics simulation multiple times until we catch up.36//! You can read more about how Bevy implements this in the documentation for37//! [`bevy::time::Fixed`](https://docs.rs/bevy/latest/bevy/time/struct.Fixed.html).38//!39//! This leaves us with a last problem, however. If our physics simulation may advance zero or multiple times40//! per frame, there may be frames in which the player's position did not need to be updated at all,41//! and some where it is updated by a large amount that resulted from running the physics simulation multiple times.42//! This is physically correct, but visually jarring. Imagine a player moving in a straight line, but depending on the frame rate,43//! they may sometimes advance by a large amount and sometimes not at all. Visually, we want the player to move smoothly.44//! This is why we need to separate the player's position in the physics simulation from the player's position in the visual representation.45//! The visual representation can then be interpolated smoothly based on the previous and current actual player position in the physics simulation.46//!47//! This is a tradeoff: every visual frame is now slightly lagging behind the actual physical frame,48//! but in return, the player's movement will appear smooth.49//! There are other ways to compute the visual representation of the player, such as extrapolation.50//! See the [documentation of the lightyear crate](https://cbournhonesque.github.io/lightyear/book/concepts/advanced_replication/visual_interpolation.html)51//! for a nice overview of the different methods and their respective tradeoffs.52//!53//! If we decide to use a fixed timestep, our game logic should mostly go in the `FixedUpdate` schedule.54//! One notable exception is the camera. Cameras should update as often as possible, or the player will very quickly55//! 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,56//! updating its transform every frame. The question now is which schedule to use. That depends on whether the camera data is required57//! for the physics simulation to run or not.58//! For example, in 3D games, the camera rotation often determines which direction the player moves when pressing "W",59//! so we need to rotate the camera *before* the fixed timestep. In contrast, the translation of the camera depends on what the physics simulation60//! has calculated for the player's position. Therefore, we need to update the camera's translation *after* the fixed timestep. Fortunately,61//! we can get smooth movement by simply using the interpolated player translation for the camera as well.62//!63//! ## Implementation64//!65//! - The player's inputs since the last physics update are stored in the `AccumulatedInput` component.66//! - The player's velocity is stored in a `Velocity` component. This is the speed in units per second.67//! - The player's current position in the physics simulation is stored in a `PhysicalTranslation` component.68//! - The player's previous position in the physics simulation is stored in a `PreviousPhysicalTranslation` component.69//! - The player's visual representation is stored in Bevy's regular `Transform` component.70//! - Every frame, we go through the following steps:71//! - Accumulate the player's input and set the current speed in the `handle_input` system.72//! This is run in the `RunFixedMainLoop` schedule, ordered in `RunFixedMainLoopSystems::BeforeFixedMainLoop`,73//! which runs before the fixed timestep loop. This is run every frame.74//! - Rotate the camera based on the player's input. This is also run in `RunFixedMainLoopSystems::BeforeFixedMainLoop`.75//! - Advance the physics simulation by one fixed timestep in the `advance_physics` system.76//! Accumulated input is consumed here.77//! This is run in the `FixedUpdate` schedule, which runs zero or multiple times per frame.78//! - Update the player's visual representation in the `interpolate_rendered_transform` system.79//! This interpolates between the player's previous and current position in the physics simulation.80//! It is run in the `RunFixedMainLoop` schedule, ordered in `RunFixedMainLoopSystems::AfterFixedMainLoop`,81//! which runs after the fixed timestep loop. This is run every frame.82//! - Update the camera's translation to the player's interpolated translation. This is also run in `RunFixedMainLoopSystems::AfterFixedMainLoop`.83//!84//!85//! ## Controls86//!87//! | Key Binding | Action |88//! |:---------------------|:--------------|89//! | `W` | Move up |90//! | `S` | Move down |91//! | `A` | Move left |92//! | `D` | Move right |93//! | Mouse | Rotate camera |9495use std::f32::consts::FRAC_PI_2;9697use bevy::{color::palettes::tailwind, input::mouse::AccumulatedMouseMotion, prelude::*};9899fn main() {100App::new()101.add_plugins(DefaultPlugins)102.init_resource::<DidFixedTimestepRunThisFrame>()103.add_systems(Startup, (spawn_text, spawn_player, spawn_environment))104// At the beginning of each frame, clear the flag that indicates whether the fixed timestep has run this frame.105.add_systems(PreUpdate, clear_fixed_timestep_flag)106// At the beginning of each fixed timestep, set the flag that indicates whether the fixed timestep has run this frame.107.add_systems(FixedPreUpdate, set_fixed_time_step_flag)108// Advance the physics simulation using a fixed timestep.109.add_systems(FixedUpdate, advance_physics)110.add_systems(111// The `RunFixedMainLoop` schedule allows us to schedule systems to run before and after the fixed timestep loop.112RunFixedMainLoop,113(114(115// The camera needs to be rotated before the physics simulation is advanced in before the fixed timestep loop,116// so that the physics simulation can use the current rotation.117// Note that if we ran it in `Update`, it would be too late, as the physics simulation would already have been advanced.118// If we ran this in `FixedUpdate`, it would sometimes not register player input, as that schedule may run zero times per frame.119rotate_camera,120// Accumulate our input before the fixed timestep loop to tell the physics simulation what it should do during the fixed timestep.121accumulate_input,122)123.chain()124.in_set(RunFixedMainLoopSystems::BeforeFixedMainLoop),125(126// Clear our accumulated input after it was processed during the fixed timestep.127// By clearing the input *after* the fixed timestep, we can still use `AccumulatedInput` inside `FixedUpdate` if we need it.128clear_input.run_if(did_fixed_timestep_run_this_frame),129// The player's visual representation needs to be updated after the physics simulation has been advanced.130// This could be run in `Update`, but if we run it here instead, the systems in `Update`131// will be working with the `Transform` that will actually be shown on screen.132interpolate_rendered_transform,133// The camera can then use the interpolated transform to position itself correctly.134translate_camera,135)136.chain()137.in_set(RunFixedMainLoopSystems::AfterFixedMainLoop),138),139)140.run();141}142143/// A vector representing the player's input, accumulated over all frames that ran144/// since the last time the physics simulation was advanced.145#[derive(Debug, Component, Clone, Copy, PartialEq, Default, Deref, DerefMut)]146struct AccumulatedInput {147// The player's movement input (WASD).148movement: Vec2,149// Other input that could make sense would be e.g.150// boost: bool151}152153/// A vector representing the player's velocity in the physics simulation.154#[derive(Debug, Component, Clone, Copy, PartialEq, Default, Deref, DerefMut)]155struct Velocity(Vec3);156157/// The actual position of the player in the physics simulation.158/// This is separate from the `Transform`, which is merely a visual representation.159///160/// If you want to make sure that this component is always initialized161/// with the same value as the `Transform`'s translation, you can162/// use a [component lifecycle hook](https://docs.rs/bevy/0.14.0/bevy/ecs/component/struct.ComponentHooks.html)163#[derive(Debug, Component, Clone, Copy, PartialEq, Default, Deref, DerefMut)]164struct PhysicalTranslation(Vec3);165166/// The value [`PhysicalTranslation`] had in the last fixed timestep.167/// Used for interpolation in the `interpolate_rendered_transform` system.168#[derive(Debug, Component, Clone, Copy, PartialEq, Default, Deref, DerefMut)]169struct PreviousPhysicalTranslation(Vec3);170171/// Spawn the player and a 3D camera. We could also spawn the camera as a child of the player,172/// but in practice, they are usually spawned separately so that the player's rotation does not173/// influence the camera's rotation.174fn spawn_player(mut commands: Commands) {175commands.spawn((Camera3d::default(), CameraSensitivity::default()));176commands.spawn((177Name::new("Player"),178Transform::from_scale(Vec3::splat(0.3)),179AccumulatedInput::default(),180Velocity::default(),181PhysicalTranslation::default(),182PreviousPhysicalTranslation::default(),183));184}185186/// Spawn a field of floating spheres to fly around in187fn spawn_environment(188mut commands: Commands,189mut meshes: ResMut<Assets<Mesh>>,190mut materials: ResMut<Assets<StandardMaterial>>,191) {192let sphere_material = materials.add(Color::from(tailwind::SKY_200));193let sphere_mesh = meshes.add(Sphere::new(0.3));194let spheres_in_x = 6;195let spheres_in_y = 4;196let spheres_in_z = 10;197let distance = 3.0;198for x in 0..spheres_in_x {199for y in 0..spheres_in_y {200for z in 0..spheres_in_z {201let translation = Vec3::new(202x as f32 * distance - (spheres_in_x as f32 - 1.0) * distance / 2.0,203y as f32 * distance - (spheres_in_y as f32 - 1.0) * distance / 2.0,204z as f32 * distance - (spheres_in_z as f32 - 1.0) * distance / 2.0,205);206commands.spawn((207Name::new("Sphere"),208Transform::from_translation(translation),209Mesh3d(sphere_mesh.clone()),210MeshMaterial3d(sphere_material.clone()),211));212}213}214}215216commands.spawn((217DirectionalLight::default(),218Transform::default().looking_to(Vec3::new(-1.0, -3.0, 0.5), Vec3::Y),219));220}221222/// Spawn a bit of UI text to explain how to move the player.223fn spawn_text(mut commands: Commands) {224let font = TextFont {225font_size: 25.0,226..default()227};228commands.spawn((229Node {230position_type: PositionType::Absolute,231bottom: px(12),232left: px(12),233flex_direction: FlexDirection::Column,234..default()235},236children![237(Text::new("Move the player with WASD"), font.clone()),238(Text::new("Rotate the camera with the mouse"), font)239],240));241}242243fn rotate_camera(244accumulated_mouse_motion: Res<AccumulatedMouseMotion>,245player: Single<(&mut Transform, &CameraSensitivity), With<Camera>>,246) {247let (mut transform, camera_sensitivity) = player.into_inner();248249let delta = accumulated_mouse_motion.delta;250251if delta != Vec2::ZERO {252// Note that we are not multiplying by delta time here.253// The reason is that for mouse movement, we already get the full movement that happened since the last frame.254// This means that if we multiply by delta time, we will get a smaller rotation than intended by the user.255let delta_yaw = -delta.x * camera_sensitivity.x;256let delta_pitch = -delta.y * camera_sensitivity.y;257258let (yaw, pitch, roll) = transform.rotation.to_euler(EulerRot::YXZ);259let yaw = yaw + delta_yaw;260261// If the pitch was ±¹⁄₂ π, the camera would look straight up or down.262// When the user wants to move the camera back to the horizon, which way should the camera face?263// The camera has no way of knowing what direction was "forward" before landing in that extreme position,264// so the direction picked will for all intents and purposes be arbitrary.265// Another issue is that for mathematical reasons, the yaw will effectively be flipped when the pitch is at the extremes.266// To not run into these issues, we clamp the pitch to a safe range.267const PITCH_LIMIT: f32 = FRAC_PI_2 - 0.01;268let pitch = (pitch + delta_pitch).clamp(-PITCH_LIMIT, PITCH_LIMIT);269270transform.rotation = Quat::from_euler(EulerRot::YXZ, yaw, pitch, roll);271}272}273274#[derive(Debug, Component, Deref, DerefMut)]275struct CameraSensitivity(Vec2);276277impl Default for CameraSensitivity {278fn default() -> Self {279Self(280// These factors are just arbitrary mouse sensitivity values.281// It's often nicer to have a faster horizontal sensitivity than vertical.282// We use a component for them so that we can make them user-configurable at runtime283// for accessibility reasons.284// It also allows you to inspect them in an editor if you `Reflect` the component.285Vec2::new(0.003, 0.002),286)287}288}289290/// Handle keyboard input and accumulate it in the `AccumulatedInput` component.291///292/// There are many strategies for how to handle all the input that happened since the last fixed timestep.293/// This is a very simple one: we just use the last available input.294/// That strategy works fine for us since the user continuously presses the input keys in this example.295/// If we had some kind of instantaneous action like activating a boost ability, we would need to remember that that input296/// was pressed at some point since the last fixed timestep.297fn accumulate_input(298keyboard_input: Res<ButtonInput<KeyCode>>,299player: Single<(&mut AccumulatedInput, &mut Velocity)>,300camera: Single<&Transform, With<Camera>>,301) {302/// Since Bevy's 3D renderer assumes SI units, this has the unit of meters per second.303/// Note that about 1.5 is the average walking speed of a human.304const SPEED: f32 = 4.0;305let (mut input, mut velocity) = player.into_inner();306// Reset the input to zero before reading the new input. As mentioned above, we can only do this307// because this is continuously pressed by the user. Do not reset e.g. whether the user wants to boost.308input.movement = Vec2::ZERO;309if keyboard_input.pressed(KeyCode::KeyW) {310input.movement.y += 1.0;311}312if keyboard_input.pressed(KeyCode::KeyS) {313input.movement.y -= 1.0;314}315if keyboard_input.pressed(KeyCode::KeyA) {316input.movement.x -= 1.0;317}318if keyboard_input.pressed(KeyCode::KeyD) {319input.movement.x += 1.0;320}321322// Remap the 2D input to Bevy's 3D coordinate system.323// Pressing W makes `input.y` go up. Since Bevy assumes that -Z is forward, we make our new Z equal to -input.y324let input_3d = Vec3 {325x: input.movement.x,326y: 0.0,327z: -input.movement.y,328};329330// Rotate the input so that forward is aligned with the camera's forward direction.331let rotated_input = camera.rotation * input_3d;332333// We need to normalize and scale because otherwise334// diagonal movement would be faster than horizontal or vertical movement.335// We use `clamp_length_max` instead of `.normalize_or_zero()` because gamepad input336// may be smaller than 1.0 when the player is pushing the stick just a little bit.337velocity.0 = rotated_input.clamp_length_max(1.0) * SPEED;338}339340/// A simple resource that tells us whether the fixed timestep ran this frame.341#[derive(Resource, Debug, Deref, DerefMut, Default)]342pub struct DidFixedTimestepRunThisFrame(bool);343344/// Reset the flag at the start of every frame.345fn clear_fixed_timestep_flag(346mut did_fixed_timestep_run_this_frame: ResMut<DidFixedTimestepRunThisFrame>,347) {348did_fixed_timestep_run_this_frame.0 = false;349}350351/// Set the flag during each fixed timestep.352fn set_fixed_time_step_flag(353mut did_fixed_timestep_run_this_frame: ResMut<DidFixedTimestepRunThisFrame>,354) {355did_fixed_timestep_run_this_frame.0 = true;356}357358fn did_fixed_timestep_run_this_frame(359did_fixed_timestep_run_this_frame: Res<DidFixedTimestepRunThisFrame>,360) -> bool {361did_fixed_timestep_run_this_frame.0362}363364// Clear the input after it was processed in the fixed timestep.365fn clear_input(mut input: Single<&mut AccumulatedInput>) {366**input = AccumulatedInput::default();367}368369/// Advance the physics simulation by one fixed timestep. This may run zero or multiple times per frame.370///371/// Note that since this runs in `FixedUpdate`, `Res<Time>` would be `Res<Time<Fixed>>` automatically.372/// We are being explicit here for clarity.373fn advance_physics(374fixed_time: Res<Time<Fixed>>,375mut query: Query<(376&mut PhysicalTranslation,377&mut PreviousPhysicalTranslation,378&Velocity,379)>,380) {381for (mut current_physical_translation, mut previous_physical_translation, velocity) in382query.iter_mut()383{384previous_physical_translation.0 = current_physical_translation.0;385current_physical_translation.0 += velocity.0 * fixed_time.delta_secs();386}387}388389fn interpolate_rendered_transform(390fixed_time: Res<Time<Fixed>>,391mut query: Query<(392&mut Transform,393&PhysicalTranslation,394&PreviousPhysicalTranslation,395)>,396) {397for (mut transform, current_physical_translation, previous_physical_translation) in398query.iter_mut()399{400let previous = previous_physical_translation.0;401let current = current_physical_translation.0;402// The overstep fraction is a value between 0 and 1 that tells us how far we are between two fixed timesteps.403let alpha = fixed_time.overstep_fraction();404405let rendered_translation = previous.lerp(current, alpha);406transform.translation = rendered_translation;407}408}409410// Sync the camera's position with the player's interpolated position411fn translate_camera(412mut camera: Single<&mut Transform, With<Camera>>,413player: Single<&Transform, (With<AccumulatedInput>, Without<Camera>)>,414) {415camera.translation = player.translation;416}417418419