Skip to content

Commit

Permalink
Physics Interpolation and Extrapolation (#566)
Browse files Browse the repository at this point in the history
# Objective

Closes #444.

To produce frame rate independent behavior and deterministic results, Avian runs at a fixed timestep in `FixedPostUpdate` by default. However, this can often lead to visual stutter when the fixed timestep does not match the display refresh rate, especially at low physics tick rates.

Avian should support `Transform` interpolation to visually smooth out movement in between fixed timesteps.

## Solution

Add a `PhysicsInterpolationPlugin` powered by my new crate [`bevy_transform_interpolation`](https://github.com/Jondolf/bevy_transform_interpolation)! It supports:

- Transform interpolation and extrapolation
- Granularly interpolating only specific transform properties
- Optional Hermite interpolation to produce more accurate easing and fix visual interpolation artifacts caused by very large angular velocities (ex: for car wheels or fan blades)
- Custom easing backends

A new `interpolation` example has been added to demonstrate the new interpolation and extrapolation functionality.

https://github.com/user-attachments/assets/0eac03ac-f8b3-4b82-b828-d36c0976a7cc

Note: You can see that restitution doesn't work as well for low tick rates; this is expected.

### Overview

To enable interpolation/extrapolation functionality, add the `PhysicsInterpolationPlugin`:

```rust
fn main() {
    App::new()
        .add_plugins((
            DefaultPlugins,
            PhysicsPlugins::default(),
            PhysicsInterpolationPlugin::default(),
        ))
        // ...other plugins, resources, and systems
        .run();
}
```

Interpolation and extrapolation can be enabled for individual entities using the `TransformInterpolation` and `TransformExtrapolation` components respectively:

```rust
fn setup(mut commands: Commands) {
    // Enable interpolation for this rigid body.
    commands.spawn((
        RigidBody::Dynamic,
        Transform::default(),
        TransformInterpolation,
    ));

    // Enable extrapolation for this rigid body.
    commands.spawn((
        RigidBody::Dynamic,
        Transform::default(),
        TransformExtrapolation,
    ));
}
```

Now, any changes made to the `Transform` of the entity in `FixedPreUpdate`, `FixedUpdate`, or `FixedPostUpdate` will automatically be smoothed in between fixed timesteps.

Transform properties can also be interpolated individually by adding the `TranslationInterpolation`, `RotationInterpolation`, and `ScaleInterpolation` components, and similarly for extrapolation.

```rust
fn setup(mut commands: Commands) {
    // Only interpolate translation.
    commands.spawn((Transform::default(), TranslationInterpolation));
    
    // Only interpolate rotation.
    commands.spawn((Transform::default(), RotationInterpolation));
    
    // Only interpolate scale.
    commands.spawn((Transform::default(), ScaleInterpolation));
    
    // Mix and match!
    // Extrapolate translation and interpolate rotation.
    commands.spawn((
        Transform::default(),
        TranslationExtrapolation,
        RotationInterpolation,
    ));
}
```

If you want *all* rigid bodies to be interpolated or extrapolated by default, you can use `PhysicsInterpolationPlugin::interpolate_all()` or `PhysicsInterpolationPlugin::extrapolate_all()`:

```rust
fn main() {
    App::build()
        .add_plugins(PhysicsInterpolationPlugin::interpolate_all())
        // ...
        .run();
}
```

When interpolation or extrapolation is enabled for all entities by default, you can still opt out of it for individual entities by adding the `NoTransformEasing` component, or the individual `NoTranslationEasing`, `NoRotationEasing`, and `NoScaleEasing` components.

Note that changing `Transform` manually in any schedule that *doesn't* use a fixed timestep is also supported, but it is equivalent to teleporting, and disables interpolation for the entity for the remainder of that fixed timestep.

## Caveats

- [`big_space`](https://github.com/aevyrie/big_space) should sort of work with `bevy_transform_interpolation`, but transitions between grid cells aren't eased correctly. Avian itself doesn't support `big_space` yet either, but it's something to keep in mind. This should be fixable on the `bevy_transform_interpolation` side.
- `bevy_transform_interpolation` technically stores duplicate position data, since we could use the existing `Position` and `Rotation` components for the current "gameplay transform". However, these physics components are in global space while `Transform` isn't, which could complicate hierarchies. For now, I chose to accept this small amount of duplication; if it is an issue, we could make `bevy_transform_interpolation` accept arbitrary "position sources" similar to the "velocity sources" it already has.
- There are instances where you want to "teleport" entities without interpolation. For now, I chose to make transform changes in non-fixed schedules teleport, but we might want to consider alternative approaches too, like calling some command or adding a marker component to skip interpolation for one fixed tick.
- The extrapolation currently doesn't integrate velocity for the prediction, so it won't account for gravity or external forces.
  • Loading branch information
Jondolf authored Dec 7, 2024
1 parent cb4ff2e commit aeeb856
Show file tree
Hide file tree
Showing 5 changed files with 658 additions and 27 deletions.
11 changes: 6 additions & 5 deletions crates/avian2d/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ bevy_picking = ["bevy/bevy_picking"]
serialize = [
"dep:serde",
"bevy/serialize",
"bevy_transform_interpolation/serialize",
"parry2d?/serde-serialize",
"parry2d-f64?/serde-serialize",
"bitflags/serde",
Expand All @@ -63,6 +64,7 @@ avian_derive = { path = "../avian_derive", version = "0.1" }
bevy = { version = "0.15", default-features = false }
bevy_math = { version = "0.15" }
bevy_heavy = { version = "0.1" }
bevy_transform_interpolation = { version = "0.1" }
libm = { version = "0.2", optional = true }
parry2d = { version = "0.17", optional = true }
parry2d-f64 = { version = "0.17", optional = true }
Expand Down Expand Up @@ -117,6 +119,10 @@ required-features = ["2d", "default-collider", "enhanced-determinism"]
name = "fixed_joint_2d"
required-features = ["2d", "default-collider"]

[[example]]
name = "interpolation"
required-features = ["2d"]

[[example]]
name = "move_marbles"
required-features = ["2d", "default-collider"]
Expand All @@ -136,8 +142,3 @@ required-features = ["2d", "default-collider"]
[[example]]
name = "debugdump_2d"
required-features = ["2d"]

[[bench]]
name = "pyramid"
required-features = ["2d", "default-collider"]
harness = false
231 changes: 231 additions & 0 deletions crates/avian2d/examples/interpolation.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
//! This example showcases how `Transform` interpolation or extrapolation can be used
//! to make movement appear smooth at fixed timesteps.
//!
//! To produce consistent, frame rate independent behavior, physics by default runs
//! in the `FixedPostUpdate` schedule with a fixed timestep, meaning that the time between
//! physics ticks remains constant. On some frames, physics can either not run at all or run
//! more than once to catch up to real time. This can lead to visible stutter for movement.
//!
//! `Transform` interpolation resolves this issue by updating `Transform` at every frame in between
//! physics ticks to smooth out the visual result. The interpolation is done from the previous position
//! to the current physics position, which keeps movement smooth, but has the downside of making movement
//! feel slightly delayed as the rendered result lags slightly behind the true positions.
//!
//! `Transform` extrapolation works similarly, but instead of using the previous positions, it predicts
//! the next positions based on velocity. This makes movement feel much more responsive, but can cause
//! jumpy results when the prediction is wrong, such as when the velocity of an object is suddenly altered.
use avian2d::{math::*, prelude::*};
use bevy::{
color::palettes::{
css::WHITE,
tailwind::{CYAN_400, LIME_400, RED_400},
},
input::common_conditions::input_pressed,
prelude::*,
};

fn main() {
let mut app = App::new();

// Add the `PhysicsInterpolationPlugin` to enable interpolation and extrapolation functionality.
//
// By default, interpolation and extrapolation must be enabled for each entity manually.
// Use `PhysicsInterpolationPlugin::interpolate_all()` to enable interpolation for all rigid bodies.
app.add_plugins((
DefaultPlugins,
PhysicsPlugins::default().with_length_unit(50.0),
PhysicsInterpolationPlugin::default(),
));

// Set gravity.
app.insert_resource(Gravity(Vector::NEG_Y * 900.0));

// Set the fixed timestep to just 10 Hz for demonstration purposes.
app.insert_resource(Time::from_hz(10.0));

// Setup the scene and UI, and update text in `Update`.
app.add_systems(Startup, (setup_scene, setup_balls, setup_text))
.add_systems(
Update,
(
change_timestep,
update_timestep_text,
// Reset the scene when the 'R' key is pressed.
reset_balls.run_if(input_pressed(KeyCode::KeyR)),
),
);

// Run the app.
app.run();
}

#[derive(Component)]
struct Ball;

fn setup_scene(
mut commands: Commands,
mut materials: ResMut<Assets<ColorMaterial>>,
mut meshes: ResMut<Assets<Mesh>>,
) {
// Spawn a camera.
commands.spawn(Camera2d);

// Spawn the ground.
commands.spawn((
Name::new("Ground"),
RigidBody::Static,
Collider::rectangle(500.0, 20.0),
Restitution::new(0.99).with_combine_rule(CoefficientCombine::Max),
Transform::from_xyz(0.0, -300.0, 0.0),
Mesh2d(meshes.add(Rectangle::new(500.0, 20.0))),
MeshMaterial2d(materials.add(Color::from(WHITE))),
));
}

fn setup_balls(
mut commands: Commands,
mut materials: ResMut<Assets<ColorMaterial>>,
mut meshes: ResMut<Assets<Mesh>>,
) {
let circle = Circle::new(30.0);
let mesh = meshes.add(circle);

// This entity uses transform interpolation.
commands.spawn((
Name::new("Interpolation"),
Ball,
RigidBody::Dynamic,
Collider::from(circle),
TransformInterpolation,
Transform::from_xyz(-100.0, 300.0, 0.0),
Mesh2d(mesh.clone()),
MeshMaterial2d(materials.add(Color::from(CYAN_400)).clone()),
));

// This entity uses transform extrapolation.
commands.spawn((
Name::new("Extrapolation"),
Ball,
RigidBody::Dynamic,
Collider::from(circle),
TransformExtrapolation,
Transform::from_xyz(0.0, 300.0, 0.0),
Mesh2d(mesh.clone()),
MeshMaterial2d(materials.add(Color::from(LIME_400)).clone()),
));

// This entity is simulated in `FixedUpdate` without any smoothing.
commands.spawn((
Name::new("No Interpolation"),
Ball,
RigidBody::Dynamic,
Collider::from(circle),
Transform::from_xyz(100.0, 300.0, 0.0),
Mesh2d(mesh.clone()),
MeshMaterial2d(materials.add(Color::from(RED_400)).clone()),
));
}

/// Despawns all balls and respawns them.
fn reset_balls(mut commands: Commands, query: Query<Entity, With<Ball>>) {
for entity in &query {
commands.entity(entity).despawn();
}

commands.run_system_cached(setup_balls);
}

#[derive(Component)]
struct TimestepText;

fn setup_text(mut commands: Commands) {
let font = TextFont {
font_size: 20.0,
..default()
};

commands
.spawn((
Text::new("Fixed Hz: "),
TextColor::from(WHITE),
font.clone(),
Node {
position_type: PositionType::Absolute,
top: Val::Px(10.0),
left: Val::Px(10.0),
..default()
},
))
.with_child((TimestepText, TextSpan::default()));

commands.spawn((
Text::new("Change Timestep With Up/Down Arrow\nPress R to reset"),
TextColor::from(WHITE),
TextLayout::new_with_justify(JustifyText::Right),
font.clone(),
Node {
position_type: PositionType::Absolute,
top: Val::Px(10.0),
right: Val::Px(10.0),
..default()
},
));

commands.spawn((
Text::new("Interpolation"),
TextColor::from(CYAN_400),
font.clone(),
Node {
position_type: PositionType::Absolute,
top: Val::Px(50.0),
left: Val::Px(10.0),
..default()
},
));

commands.spawn((
Text::new("Extrapolation"),
TextColor::from(LIME_400),
font.clone(),
Node {
position_type: PositionType::Absolute,
top: Val::Px(75.0),
left: Val::Px(10.0),
..default()
},
));

commands.spawn((
Text::new("No Interpolation"),
TextColor::from(RED_400),
font.clone(),
Node {
position_type: PositionType::Absolute,
top: Val::Px(100.0),
left: Val::Px(10.0),
..default()
},
));
}

/// Changes the timestep of the simulation when the up or down arrow keys are pressed.
fn change_timestep(mut time: ResMut<Time<Fixed>>, keyboard_input: Res<ButtonInput<KeyCode>>) {
if keyboard_input.pressed(KeyCode::ArrowUp) {
let new_timestep = (time.delta_secs_f64() * 0.975).max(1.0 / 255.0);
time.set_timestep_seconds(new_timestep);
}
if keyboard_input.pressed(KeyCode::ArrowDown) {
let new_timestep = (time.delta_secs_f64() * 1.025).min(1.0 / 5.0);
time.set_timestep_seconds(new_timestep);
}
}

/// Updates the text with the current timestep.
fn update_timestep_text(
mut text: Single<&mut TextSpan, With<TimestepText>>,
time: Res<Time<Fixed>>,
) {
let timestep = time.timestep().as_secs_f32().recip();
text.0 = format!("{timestep:.2}");
}
2 changes: 2 additions & 0 deletions crates/avian3d/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ serialize = [
"dep:serde",
"bevy/serialize",
"bevy_heavy/serialize",
"bevy_transform_interpolation/serialize",
"parry3d?/serde-serialize",
"parry3d-f64?/serde-serialize",
"bitflags/serde",
Expand All @@ -66,6 +67,7 @@ avian_derive = { path = "../avian_derive", version = "0.1" }
bevy = { version = "0.15", default-features = false }
bevy_math = { version = "0.15" }
bevy_heavy = { version = "0.1" }
bevy_transform_interpolation = { version = "0.1" }
libm = { version = "0.2", optional = true }
parry3d = { version = "0.17", optional = true }
parry3d-f64 = { version = "0.17", optional = true }
Expand Down
Loading

0 comments on commit aeeb856

Please sign in to comment.