Skip to content

Complete easing for transform changes outside fixed time step #10

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,8 +140,7 @@ If extrapolation is used:

- In `FixedLast`, `start` is set to the current `Transform`, and `end` is set to the `Transform` predicted based on velocity.

At the start of the `FixedFirst` schedule, the states are reset to `None`. If the `Transform` is detected to have changed
since the last easing run but *outside* of the fixed timestep schedules, the easing is also reset to `None` to prevent overwriting the change.
At the start of the `FixedFirst` schedule, the states are reset to `None`.

The actual easing is performed in `RunFixedMainLoop`, right after `FixedMain`, before `Update`.
By default, linear interpolation (`lerp`) is used for translation and scale, and spherical linear interpolation (`slerp`)
Expand Down
42 changes: 42 additions & 0 deletions src/commands.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
//! Helper commands for operations on interpolated or extrapolated entities.

use bevy::{
ecs::{entity::Entity, system::Command, world::World},
reflect::prelude::*,
};

use crate::{RotationEasingState, ScaleEasingState, TranslationEasingState};

/// A [`Command`] that resets the easing states of an entity.
///
/// This disables easing for the remainder of the current fixed time step,
/// allowing you to freely set the [`Transform`](bevy::transform::components::Transform)
/// of the entity without any easing being applied.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Reflect)]
#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serialize", reflect(Serialize, Deserialize))]
#[reflect(Debug, PartialEq)]
pub struct ResetEasing(pub Entity);

impl Command for ResetEasing {
fn apply(self, world: &mut World) {
let Ok(mut entity_mut) = world.get_entity_mut(self.0) else {
return;
};

if let Some(mut translation_easing) = entity_mut.get_mut::<TranslationEasingState>() {
translation_easing.start = None;
translation_easing.end = None;
}

if let Some(mut rotation_easing) = entity_mut.get_mut::<RotationEasingState>() {
rotation_easing.start = None;
rotation_easing.end = None;
}

if let Some(mut scale_easing) = entity_mut.get_mut::<ScaleEasingState>() {
scale_easing.start = None;
scale_easing.end = None;
}
}
}
26 changes: 23 additions & 3 deletions src/extrapolation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -200,9 +200,6 @@ use bevy::prelude::*;
/// When 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`] and [`NoRotationEasing`] 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 extrapolation for the entity for the remainder of that fixed timestep.
///
/// [`QueryData`]: bevy::ecs::query::QueryData
/// [`TransformExtrapolationPlugin::extrapolate_all()`]: TransformExtrapolationPlugin::extrapolate_all
/// [`extrapolate_translation_all`]: TransformExtrapolationPlugin::extrapolate_translation_all
Expand All @@ -211,6 +208,29 @@ use bevy::prelude::*;
/// [`NoTranslationEasing`]: crate::NoTranslationEasing
/// [`NoRotationEasing`]: crate::NoRotationEasing
///
/// ## Changing [`Transform`] Outside of Fixed Timesteps
///
/// Changing the [`Transform`] of an extrapolated entity in any schedule that *doesn't* use
/// a fixed timestep is also supported, but comes with some special behavior.
///
/// [`Transform`] changes made outside of the fixed time step are applied immediately,
/// effectively teleporting the entity to the new position. However, the easing is not interrupted,
/// meaning that the remaining extrapolation will still be applied, but relative to the new transform.
///
/// To better visualize this, consider a classic trick in games where an infinite world is simulated
/// by teleporting the player to the other side of the game area when they reach the edge of the world.
/// This teleportation is done in the [`Update`] schedule as soon as the [`Transform`] reaches the edge.
///
/// To make the effect smooth, we want to set the visual [`Transform`] to the new position immediately,
/// but to still complete the remainder of the extrapolation to prevent any stuttering.
/// In `bevy_transform_interpolation`, this works *by default*. Just set the [`Transform`],
/// and the entity will be teleported without interrupting the extrapolation.
///
/// In other instances, it may be desirable to instead interrupt the extrapolation and teleport the entity
/// without any easing. This can be done using the [`ResetEasing`] command and then setting the [`Transform`].
///
/// [`ResetEasing`]: crate::commands::ResetEasing
///
/// # Alternatives
///
/// For many applications, the stutter caused by mispredictions in extrapolation may be undesirable.
Expand Down
26 changes: 23 additions & 3 deletions src/interpolation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,13 +90,33 @@ use bevy::prelude::*;
/// 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.
///
/// [`interpolate_translation_all`]: TransformInterpolationPlugin::interpolate_translation_all
/// [`interpolate_rotation_all`]: TransformInterpolationPlugin::interpolate_rotation_all
/// [`interpolate_scale_all`]: TransformInterpolationPlugin::interpolate_scale_all
///
/// ## Changing [`Transform`] Outside of Fixed Timesteps
///
/// Changing the [`Transform`] of an interpolated entity in any schedule that *doesn't* use
/// a fixed timestep is also supported, but comes with some special behavior.
///
/// [`Transform`] changes made outside of the fixed time step are applied immediately,
/// effectively teleporting the entity to the new position. However, the easing is not interrupted,
/// meaning that the remaining interpolation will still be applied, but relative to the new transform.
///
/// To better visualize this, consider a classic trick in games where an infinite world is simulated
/// by teleporting the player to the other side of the game area when they reach the edge of the world.
/// This teleportation is done in the [`Update`] schedule as soon as the [`Transform`] reaches the edge.
///
/// To make the effect smooth, we want to set the visual [`Transform`] to the new position immediately,
/// but to still complete the remainder of the interpolation to prevent any stuttering.
/// In `bevy_transform_interpolation`, this works *by default*. Just set the [`Transform`],
/// and the entity will be teleported without interrupting the interpolation.
///
/// In other instances, it may be desirable to instead interrupt the interpolation and teleport the entity
/// without any easing. This can be done using the [`ResetEasing`] command and then setting the [`Transform`].
///
/// [`ResetEasing`]: crate::commands::ResetEasing
///
/// # Alternatives
///
/// For games where low latency is crucial for gameplay, such as in some first-person shooters
Expand Down
43 changes: 31 additions & 12 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,7 @@
//!
//! - In [`FixedLast`], `start` is set to the current [`Transform`], and `end` is set to the [`Transform`] predicted based on velocity.
//!
//! At the start of the [`FixedFirst`] schedule, the states are reset to `None`. If the [`Transform`] is detected to have changed
//! since the last easing run but *outside* of the fixed timestep schedules, the easing is also reset to `None` to prevent overwriting the change.
//! At the start of the [`FixedFirst`] schedule, the states are reset to `None`.
//!
//! The actual easing is performed in [`RunFixedMainLoop`], right after [`FixedMain`](bevy::app::FixedMain), before [`Update`].
//! By default, linear interpolation (`lerp`) is used for translation and scale, and spherical linear interpolation (`slerp`)
Expand All @@ -135,12 +134,16 @@ pub mod interpolation;
// TODO: Catmull-Rom (like Hermite interpolation, but velocity is estimated from four points)
pub mod hermite;

// Helper commands
pub mod commands;

/// The prelude.
///
/// This includes the most common types in this crate, re-exported for your convenience.
pub mod prelude {
#[doc(inline)]
pub use crate::{
commands::ResetEasing,
extrapolation::*,
hermite::{
RotationHermiteEasing, TransformHermiteEasing, TransformHermiteEasingPlugin,
Expand Down Expand Up @@ -224,7 +227,8 @@ impl Plugin for TransformEasingPlugin {

app.add_systems(
RunFixedMainLoop,
reset_easing_states_on_transform_change.before(TransformEasingSet::Ease),
update_easing_states_on_transform_change
.in_set(RunFixedMainLoopSystem::BeforeFixedMainLoop),
);

// Perform easing.
Expand Down Expand Up @@ -473,10 +477,14 @@ fn update_last_easing_tick(
*last_easing_tick = LastEasingTick(system_change_tick.this_run());
}

/// Resets the easing states to `None` when [`Transform`] is modified outside of the fixed timestep schedules
/// or interpolation logic. This makes it possible to "teleport" entities in schedules like [`Update`].
/// Updates easing states when [`Transform`] is modified outside of the fixed timestep schedules
/// or interpolation logic.
///
/// The `start` and `end` states are updated such that the current interpolated transform
/// matches the new transform. This makes it possible to "teleport" entities in schedules
/// such as [`Update`] without interrupting the easing.
#[allow(clippy::type_complexity, private_interfaces)]
pub fn reset_easing_states_on_transform_change(
pub fn update_easing_states_on_transform_change(
mut query: Query<
(
Ref<Transform>,
Expand All @@ -495,8 +503,10 @@ pub fn reset_easing_states_on_transform_change(
>,
last_easing_tick: Res<LastEasingTick>,
system_change_tick: SystemChangeTick,
time: Res<Time<Fixed>>,
) {
let this_run = system_change_tick.this_run();
let overstep = time.overstep_fraction();

query.par_iter_mut().for_each(
|(transform, translation_easing, rotation_easing, scale_easing)| {
Expand All @@ -507,28 +517,37 @@ pub fn reset_easing_states_on_transform_change(
return;
}

// Transform the `start` and `end` states of each transform property
// such that the current eased transform matches `transform`.
if let Some(mut translation_easing) = translation_easing {
if let (Some(start), Some(end)) = (translation_easing.start, translation_easing.end)
{
if transform.translation != start && transform.translation != end {
translation_easing.start = None;
translation_easing.end = None;
let old = start.lerp(end, overstep);
let difference = transform.translation - old;
translation_easing.start = Some(start + difference);
translation_easing.end = Some(end + difference);
}
}
}
if let Some(mut rotation_easing) = rotation_easing {
if let (Some(start), Some(end)) = (rotation_easing.start, rotation_easing.end) {
if transform.rotation != start && transform.rotation != end {
rotation_easing.start = None;
rotation_easing.end = None;
// TODO: Do we need to consider alternative easing modes?
let old = start.slerp(end, overstep);
let difference = old.inverse() * transform.rotation;
rotation_easing.start = Some((difference * start).normalize());
rotation_easing.end = Some((difference * end).normalize());
}
}
}
if let Some(mut scale_easing) = scale_easing {
if let (Some(start), Some(end)) = (scale_easing.start, scale_easing.end) {
if transform.scale != start && transform.scale != end {
scale_easing.start = None;
scale_easing.end = None;
let old = start.lerp(end, overstep);
let difference = transform.scale - old;
scale_easing.start = Some(start + difference);
scale_easing.end = Some(end + difference);
}
}
}
Expand Down