bevy/examples/camera/2d_top_down_camera.rs
Jan Hohenheim d0e606b87c
Add an example for doing movement in fixed timesteps (#14223)
_copy-pasted from my doc comment in the code_

# Objective

This example shows how to properly handle player input, advance a
physics simulation in a fixed timestep, and display the results.

The classic source for how and why this is done is Glenn Fiedler's
article [Fix Your
Timestep!](https://gafferongames.com/post/fix_your_timestep/).

## Motivation

The naive way of moving a player is to just update their position like
so:
```rust
transform.translation += velocity;
```
The issue here is that the player's movement speed will be tied to the
frame rate.
Faster machines will move the player faster, and slower machines will
move the player slower.
In fact, you can observe this today when running some old games that did
it this way on modern hardware!
The player will move at a breakneck pace.

The more sophisticated way is to update the player's position based on
the time that has passed:
```rust
transform.translation += velocity * time.delta_seconds();
```
This way, velocity represents a speed in units per second, and the
player will move at the same speed regardless of the frame rate.

However, this can still be problematic if the frame rate is very low or
very high. If the frame rate is very low, the player will move in large
jumps. This may lead to a player moving in such large jumps that they
pass through walls or other obstacles. In general, you cannot expect a
physics simulation to behave nicely with *any* delta time. Ideally, we
want to have some stability in what kinds of delta times we feed into
our physics simulation.

The solution is using a fixed timestep. This means that we advance the
physics simulation by a fixed amount at a time. If the real time that
passed between two frames is less than the fixed timestep, we simply
don't advance the physics simulation at all.
If it is more, we advance the physics simulation multiple times until we
catch up. You can read more about how Bevy implements this in the
documentation for
[`bevy::time::Fixed`](https://docs.rs/bevy/latest/bevy/time/struct.Fixed.html).

This leaves us with a last problem, however. If our physics simulation
may advance zero or multiple times per frame, there may be frames in
which the player's position did not need to be updated at all, and some
where it is updated by a large amount that resulted from running the
physics simulation multiple times. This is physically correct, but
visually jarring. Imagine a player moving in a straight line, but
depending on the frame rate, they may sometimes advance by a large
amount and sometimes not at all. Visually, we want the player to move
smoothly. This is why we need to separate the player's position in the
physics simulation from the player's position in the visual
representation. The visual representation can then be interpolated
smoothly based on the last and current actual player position in the
physics simulation.

This is a tradeoff: every visual frame is now slightly lagging behind
the actual physical frame, but in return, the player's movement will
appear smooth. There are other ways to compute the visual representation
of the player, such as extrapolation. 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 tradeoffs.

## Implementation

- 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 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.
- Every frame, we go through the following steps:
- 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.
- Update the player's visual representation in the
`update_displayed_transform` system.
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.

## Relevant Issues

Related to #1259.
I'm also fairly sure I've seen an issue somewhere made by
@alice-i-cecile about showing how to move a character correctly in a
fixed timestep, but I cannot find it.
2024-07-09 14:23:10 +00:00

150 lines
4.4 KiB
Rust

//! This example showcases a 2D top-down camera with smooth player tracking.
//!
//! ## Controls
//!
//! | Key Binding | Action |
//! |:---------------------|:--------------|
//! | `W` | Move up |
//! | `S` | Move down |
//! | `A` | Move left |
//! | `D` | Move right |
use bevy::core_pipeline::bloom::BloomSettings;
use bevy::math::vec3;
use bevy::prelude::*;
use bevy::sprite::{MaterialMesh2dBundle, Mesh2dHandle};
/// Player movement speed factor.
const PLAYER_SPEED: f32 = 100.;
/// How quickly should the camera snap to the desired location.
const CAMERA_DECAY_RATE: f32 = 2.;
#[derive(Component)]
struct Player;
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_systems(Startup, (setup_scene, setup_instructions, setup_camera))
.add_systems(Update, (move_player, update_camera).chain())
.run();
}
fn setup_scene(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<ColorMaterial>>,
) {
// World where we move the player
commands.spawn(MaterialMesh2dBundle {
mesh: Mesh2dHandle(meshes.add(Rectangle::new(1000., 700.))),
material: materials.add(Color::srgb(0.2, 0.2, 0.3)),
..default()
});
// Player
commands.spawn((
Player,
MaterialMesh2dBundle {
mesh: meshes.add(Circle::new(25.)).into(),
material: materials.add(Color::srgb(6.25, 9.4, 9.1)), // RGB values exceed 1 to achieve a bright color for the bloom effect
transform: Transform {
translation: vec3(0., 0., 2.),
..default()
},
..default()
},
));
}
fn setup_instructions(mut commands: Commands) {
commands.spawn(
TextBundle::from_section(
"Move the light with WASD.\nThe camera will smoothly track the light.",
TextStyle::default(),
)
.with_style(Style {
position_type: PositionType::Absolute,
bottom: Val::Px(12.0),
left: Val::Px(12.0),
..default()
}),
);
}
fn setup_camera(mut commands: Commands) {
commands.spawn((
Camera2dBundle {
camera: Camera {
hdr: true, // HDR is required for the bloom effect
..default()
},
..default()
},
BloomSettings::NATURAL,
));
}
/// Update the camera position by tracking the player.
fn update_camera(
mut camera: Query<&mut Transform, (With<Camera2d>, Without<Player>)>,
player: Query<&Transform, (With<Player>, Without<Camera2d>)>,
time: Res<Time>,
) {
let Ok(mut camera) = camera.get_single_mut() else {
return;
};
let Ok(player) = player.get_single() else {
return;
};
let Vec3 { x, y, .. } = player.translation;
let direction = Vec3::new(x, y, camera.translation.z);
// Applies a smooth effect to camera movement using stable interpolation
// between the camera position and the player position on the x and y axes.
camera
.translation
.smooth_nudge(&direction, CAMERA_DECAY_RATE, time.delta_seconds());
}
/// Update the player position with keyboard inputs.
/// Note that the approach used here is for demonstration purposes only,
/// as the point of this example is to showcase the camera tracking feature.
///
/// A more robust solution for player movement can be found in `examples/movement/physics_in_fixed_timestep.rs`.
fn move_player(
mut player: Query<&mut Transform, With<Player>>,
time: Res<Time>,
kb_input: Res<ButtonInput<KeyCode>>,
) {
let Ok(mut player) = player.get_single_mut() else {
return;
};
let mut direction = Vec2::ZERO;
if kb_input.pressed(KeyCode::KeyW) {
direction.y += 1.;
}
if kb_input.pressed(KeyCode::KeyS) {
direction.y -= 1.;
}
if kb_input.pressed(KeyCode::KeyA) {
direction.x -= 1.;
}
if kb_input.pressed(KeyCode::KeyD) {
direction.x += 1.;
}
// Progressively update the player's position over time. Normalize the
// direction vector to prevent it from exceeding a magnitude of 1 when
// moving diagonally.
let move_delta = direction.normalize_or_zero() * PLAYER_SPEED * time.delta_seconds();
player.translation += move_delta.extend(0.);
}