From 36fb83fa42c05c53f70817172924e4e33c6940b1 Mon Sep 17 00:00:00 2001 From: Jan Hohenheim Date: Mon, 14 Jul 2025 23:20:19 +0200 Subject: [PATCH] Port the physics in fixed timestep example to 3D (#20089) # Objective Since I originally wrote this example, many people on Discord have asked me specifically how to handle camera movement in and around fixed timesteps. I had to write that information up maaaany times, so I believe this is an area where the example falls short of. ## Solution Let's port the example to 3D, where we can better showcase how to handle the camera. The knowledge is trivially transferable to 2D :) Also, we don't need to average out continuous input. Just using the last one is fine in practice. Still, we need to keep around the `AccumulatedInput` resource for things like jumping. ## Testing https://github.com/user-attachments/assets/c1306d36-1f94-43b6-b8f6-af1cbb622698 ## Notes - The current implementation is extremely faithful to how it will look like in practice when writing a 3D game using e.g. Avian. With the exception that Avian does the part with the actual physics of course - I'd love to showcase how to map sequences of inputs to fixed updates, but winit does not export timestamps - I'd also like to showcase instantaneous inputs like activating a boost or shooting a laser, but that would make the example even bigger - Not locking the cursor because doing so correctly on Wasm in the current Bevy version is not trivial at all --------- Co-authored-by: Joona Aalto --- .../movement/physics_in_fixed_timestep.rs | 294 ++++++++++++++---- 1 file changed, 234 insertions(+), 60 deletions(-) diff --git a/examples/movement/physics_in_fixed_timestep.rs b/examples/movement/physics_in_fixed_timestep.rs index d10f7af785..c2e9735c2a 100644 --- a/examples/movement/physics_in_fixed_timestep.rs +++ b/examples/movement/physics_in_fixed_timestep.rs @@ -51,6 +51,16 @@ //! See the [documentation of the lightyear crate](https://cbournhonesque.github.io/lightyear/book/concepts/advanced_replication/visual_interpolation.html) //! for a nice overview of the different methods and their respective tradeoffs. //! +//! If we decide to use a fixed timestep, our game logic should mostly go in the `FixedUpdate` schedule. +//! One notable exception is the camera. Cameras should update as often as possible, or the player will very quickly +//! 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, +//! updating its transform every frame. The question now is which schedule to use. That depends on whether the camera data is required +//! for the physics simulation to run or not. +//! For example, in 3D games, the camera rotation often determines which direction the player moves when pressing "W", +//! so we need to rotate the camera *before* the fixed timestep. In contrast, the translation of the camera depends on what the physics simulation +//! has calculated for the player's position. Therefore, we need to update the camera's translation *after* the fixed timestep. Fortunately, +//! we can get smooth movement by simply using the interpolated player translation for the camera as well. +//! //! ## Implementation //! //! - The player's inputs since the last physics update are stored in the `AccumulatedInput` component. @@ -62,6 +72,7 @@ //! - Accumulate the player's input and set the current speed in the `handle_input` system. //! This is run in the `RunFixedMainLoop` schedule, ordered in `RunFixedMainLoopSystems::BeforeFixedMainLoop`, //! which runs before the fixed timestep loop. This is run every frame. +//! - Rotate the camera based on the player's input. This is also run in `RunFixedMainLoopSystems::BeforeFixedMainLoop`. //! - Advance the physics simulation by one fixed timestep in the `advance_physics` system. //! Accumulated input is consumed here. //! This is run in the `FixedUpdate` schedule, which runs zero or multiple times per frame. @@ -69,6 +80,7 @@ //! This interpolates between the player's previous and current position in the physics simulation. //! It is run in the `RunFixedMainLoop` schedule, ordered in `RunFixedMainLoopSystems::AfterFixedMainLoop`, //! which runs after the fixed timestep loop. This is run every frame. +//! - Update the camera's translation to the player's interpolated translation. This is also run in `RunFixedMainLoopSystems::AfterFixedMainLoop`. //! //! //! ## Controls @@ -79,27 +91,51 @@ //! | `S` | Move down | //! | `A` | Move left | //! | `D` | Move right | +//! | Mouse | Rotate camera | -use bevy::prelude::*; +use std::f32::consts::FRAC_PI_2; + +use bevy::{color::palettes::tailwind, input::mouse::AccumulatedMouseMotion, prelude::*}; fn main() { App::new() .add_plugins(DefaultPlugins) - .add_systems(Startup, (spawn_text, spawn_player)) + .init_resource::() + .add_systems(Startup, (spawn_text, spawn_player, spawn_environment)) + // At the beginning of each frame, clear the flag that indicates whether the fixed timestep has run this frame. + .add_systems(PreUpdate, clear_fixed_timestep_flag) + // At the beginning of each fixed timestep, set the flag that indicates whether the fixed timestep has run this frame. + .add_systems(FixedPreUpdate, set_fixed_time_step_flag) // Advance the physics simulation using a fixed timestep. .add_systems(FixedUpdate, advance_physics) .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(RunFixedMainLoopSystems::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(RunFixedMainLoopSystems::AfterFixedMainLoop), + ( + // The camera needs to be rotated before the physics simulation is advanced in before the fixed timestep loop, + // so that the physics simulation can use the current rotation. + // 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. + rotate_camera, + // Accumulate our input before the fixed timestep loop to tell the physics simulation what it should do during the fixed timestep. + accumulate_input, + ) + .chain() + .in_set(RunFixedMainLoopSystems::BeforeFixedMainLoop), + ( + // Clear our accumulated input after it was processed during the fixed timestep. + // By clearing the input *after* the fixed timestep, we can still use `AccumulatedInput` inside `FixedUpdate` if we need it. + clear_input.run_if(did_fixed_timestep_run_this_frame), + // 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, + // The camera can then use the interpolated transform to position itself correctly. + translate_camera, + ) + .chain() + .in_set(RunFixedMainLoopSystems::AfterFixedMainLoop), ), ) .run(); @@ -108,7 +144,12 @@ fn main() { /// 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); +struct AccumulatedInput { + // The player's movement input (WASD). + movement: Vec2, + // Other input that could make sense would be e.g. + // boost: bool +} /// A vector representing the player's velocity in the physics simulation. #[derive(Debug, Component, Clone, Copy, PartialEq, Default, Deref, DerefMut)] @@ -128,12 +169,13 @@ struct PhysicalTranslation(Vec3); #[derive(Debug, Component, Clone, Copy, PartialEq, Default, Deref, DerefMut)] struct PreviousPhysicalTranslation(Vec3); -/// Spawn the player sprite and a 2D camera. -fn spawn_player(mut commands: Commands, asset_server: Res) { - commands.spawn(Camera2d); +/// Spawn the player and a 3D camera. We could also spawn the camera as a child of the player, +/// but in practice, they are usually spawned separately so that the player's rotation does not +/// influence the camera's rotation. +fn spawn_player(mut commands: Commands) { + commands.spawn((Camera3d::default(), CameraSensitivity::default())); commands.spawn(( Name::new("Player"), - Sprite::from_image(asset_server.load("branding/icon.png")), Transform::from_scale(Vec3::splat(0.3)), AccumulatedInput::default(), Velocity::default(), @@ -142,55 +184,187 @@ fn spawn_player(mut commands: Commands, asset_server: Res) { )); } +/// Spawn a field of floating spheres to fly around in +fn spawn_environment( + mut commands: Commands, + mut meshes: ResMut>, + mut materials: ResMut>, +) { + let sphere_material = materials.add(Color::from(tailwind::SKY_200)); + let sphere_mesh = meshes.add(Sphere::new(0.3)); + let spheres_in_x = 6; + let spheres_in_y = 4; + let spheres_in_z = 10; + let distance = 3.0; + for x in 0..spheres_in_x { + for y in 0..spheres_in_y { + for z in 0..spheres_in_z { + let translation = Vec3::new( + x as f32 * distance - (spheres_in_x as f32 - 1.0) * distance / 2.0, + y as f32 * distance - (spheres_in_y as f32 - 1.0) * distance / 2.0, + z as f32 * distance - (spheres_in_z as f32 - 1.0) * distance / 2.0, + ); + commands.spawn(( + Name::new("Sphere"), + Transform::from_translation(translation), + Mesh3d(sphere_mesh.clone()), + MeshMaterial3d(sphere_material.clone()), + )); + } + } + } + + commands.spawn(( + DirectionalLight::default(), + Transform::default().looking_to(Vec3::new(-1.0, -3.0, 0.5), Vec3::Y), + )); +} + /// Spawn a bit of UI text to explain how to move the player. fn spawn_text(mut commands: Commands) { - commands - .spawn(Node { + let font = TextFont { + font_size: 25.0, + ..default() + }; + commands.spawn(( + Node { position_type: PositionType::Absolute, bottom: Val::Px(12.0), left: Val::Px(12.0), + flex_direction: FlexDirection::Column, ..default() - }) - .with_child(( - Text::new("Move the player with WASD"), - TextFont { - font_size: 25.0, - ..default() - }, - )); + }, + children![ + (Text::new("Move the player with WASD"), font.clone()), + (Text::new("Rotate the camera with the mouse"), font) + ], + )); +} + +fn rotate_camera( + accumulated_mouse_motion: Res, + player: Single<(&mut Transform, &CameraSensitivity), With>, +) { + let (mut transform, camera_sensitivity) = player.into_inner(); + + let delta = accumulated_mouse_motion.delta; + + if delta != Vec2::ZERO { + // Note that we are not multiplying by delta time here. + // The reason is that for mouse movement, we already get the full movement that happened since the last frame. + // This means that if we multiply by delta time, we will get a smaller rotation than intended by the user. + let delta_yaw = -delta.x * camera_sensitivity.x; + let delta_pitch = -delta.y * camera_sensitivity.y; + + let (yaw, pitch, roll) = transform.rotation.to_euler(EulerRot::YXZ); + let yaw = yaw + delta_yaw; + + // If the pitch was ±¹⁄₂ π, the camera would look straight up or down. + // When the user wants to move the camera back to the horizon, which way should the camera face? + // The camera has no way of knowing what direction was "forward" before landing in that extreme position, + // so the direction picked will for all intents and purposes be arbitrary. + // Another issue is that for mathematical reasons, the yaw will effectively be flipped when the pitch is at the extremes. + // To not run into these issues, we clamp the pitch to a safe range. + const PITCH_LIMIT: f32 = FRAC_PI_2 - 0.01; + let pitch = (pitch + delta_pitch).clamp(-PITCH_LIMIT, PITCH_LIMIT); + + transform.rotation = Quat::from_euler(EulerRot::YXZ, yaw, pitch, roll); + } +} + +#[derive(Debug, Component, Deref, DerefMut)] +struct CameraSensitivity(Vec2); + +impl Default for CameraSensitivity { + fn default() -> Self { + Self( + // These factors are just arbitrary mouse sensitivity values. + // It's often nicer to have a faster horizontal sensitivity than vertical. + // We use a component for them so that we can make them user-configurable at runtime + // for accessibility reasons. + // It also allows you to inspect them in an editor if you `Reflect` the component. + Vec2::new(0.003, 0.002), + ) + } } /// 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( +/// This is a very simple one: we just use the last available input. +/// That strategy works fine for us since the user continuously presses the input keys in this example. +/// If we had some kind of instantaneous action like activating a boost ability, we would need to remember that that input +/// was pressed at some point since the last fixed timestep. +fn accumulate_input( keyboard_input: Res>, - mut query: Query<(&mut AccumulatedInput, &mut Velocity)>, + player: Single<(&mut AccumulatedInput, &mut Velocity)>, + camera: Single<&Transform, With>, ) { - /// 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 input, mut velocity) in query.iter_mut() { - if keyboard_input.pressed(KeyCode::KeyW) { - input.y += 1.0; - } - if keyboard_input.pressed(KeyCode::KeyS) { - input.y -= 1.0; - } - if keyboard_input.pressed(KeyCode::KeyA) { - input.x -= 1.0; - } - if keyboard_input.pressed(KeyCode::KeyD) { - input.x += 1.0; - } - - // Need to normalize and scale because otherwise - // diagonal movement would be faster than horizontal or vertical movement. - // This effectively averages the accumulated input. - velocity.0 = input.extend(0.0).normalize_or_zero() * SPEED; + /// Since Bevy's 3D renderer assumes SI units, this has the unit of meters per second. + /// Note that about 1.5 is the average walking speed of a human. + const SPEED: f32 = 4.0; + let (mut input, mut velocity) = player.into_inner(); + // Reset the input to zero before reading the new input. As mentioned above, we can only do this + // because this is continuously pressed by the user. Do not reset e.g. whether the user wants to boost. + input.movement = Vec2::ZERO; + if keyboard_input.pressed(KeyCode::KeyW) { + input.movement.y += 1.0; } + if keyboard_input.pressed(KeyCode::KeyS) { + input.movement.y -= 1.0; + } + if keyboard_input.pressed(KeyCode::KeyA) { + input.movement.x -= 1.0; + } + if keyboard_input.pressed(KeyCode::KeyD) { + input.movement.x += 1.0; + } + + // Remap the 2D input to Bevy's 3D coordinate system. + // Pressing W makes `input.y` go up. Since Bevy assumes that -Z is forward, we make our new Z equal to -input.y + let input_3d = Vec3 { + x: input.movement.x, + y: 0.0, + z: -input.movement.y, + }; + + // Rotate the input so that forward is aligned with the camera's forward direction. + let rotated_input = camera.rotation * input_3d; + + // We need to normalize and scale because otherwise + // diagonal movement would be faster than horizontal or vertical movement. + // We use `clamp_length_max` instead of `.normalize_or_zero()` because gamepad input + // may be smaller than 1.0 when the player is pushing the stick just a little bit. + velocity.0 = rotated_input.clamp_length_max(1.0) * SPEED; +} + +/// A simple resource that tells us whether the fixed timestep ran this frame. +#[derive(Resource, Debug, Deref, DerefMut, Default)] +pub struct DidFixedTimestepRunThisFrame(bool); + +/// Reset the flag at the start of every frame. +fn clear_fixed_timestep_flag( + mut did_fixed_timestep_run_this_frame: ResMut, +) { + did_fixed_timestep_run_this_frame.0 = false; +} + +/// Set the flag during each fixed timestep. +fn set_fixed_time_step_flag( + mut did_fixed_timestep_run_this_frame: ResMut, +) { + did_fixed_timestep_run_this_frame.0 = true; +} + +fn did_fixed_timestep_run_this_frame( + did_fixed_timestep_run_this_frame: Res, +) -> bool { + did_fixed_timestep_run_this_frame.0 +} + +// Clear the input after it was processed in the fixed timestep. +fn clear_input(mut input: Single<&mut AccumulatedInput>) { + **input = AccumulatedInput::default(); } /// Advance the physics simulation by one fixed timestep. This may run zero or multiple times per frame. @@ -202,22 +376,14 @@ fn advance_physics( mut query: Query<( &mut PhysicalTranslation, &mut PreviousPhysicalTranslation, - &mut AccumulatedInput, &Velocity, )>, ) { - for ( - mut current_physical_translation, - mut previous_physical_translation, - mut input, - velocity, - ) in query.iter_mut() + for (mut current_physical_translation, mut previous_physical_translation, velocity) in + query.iter_mut() { previous_physical_translation.0 = current_physical_translation.0; current_physical_translation.0 += velocity.0 * fixed_time.delta_secs(); - - // Reset the input accumulator, as we are currently consuming all input that happened since the last fixed timestep. - input.0 = Vec2::ZERO; } } @@ -241,3 +407,11 @@ fn interpolate_rendered_transform( transform.translation = rendered_translation; } } + +// Sync the camera's position with the player's interpolated position +fn translate_camera( + mut camera: Single<&mut Transform, With>, + player: Single<&Transform, (With, Without)>, +) { + camera.translation = player.translation; +}