diff --git a/README.md b/README.md index 9d11d45..6615323 100644 --- a/README.md +++ b/README.md @@ -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`) diff --git a/src/commands.rs b/src/commands.rs new file mode 100644 index 0000000..fb3d014 --- /dev/null +++ b/src/commands.rs @@ -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::() { + translation_easing.start = None; + translation_easing.end = None; + } + + if let Some(mut rotation_easing) = entity_mut.get_mut::() { + rotation_easing.start = None; + rotation_easing.end = None; + } + + if let Some(mut scale_easing) = entity_mut.get_mut::() { + scale_easing.start = None; + scale_easing.end = None; + } + } +} diff --git a/src/extrapolation.rs b/src/extrapolation.rs index 2b74173..1a698ad 100644 --- a/src/extrapolation.rs +++ b/src/extrapolation.rs @@ -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 @@ -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. diff --git a/src/interpolation.rs b/src/interpolation.rs index 27bc0d5..5fd43bf 100644 --- a/src/interpolation.rs +++ b/src/interpolation.rs @@ -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 diff --git a/src/lib.rs b/src/lib.rs index 1827ab8..249a6f8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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`) @@ -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, @@ -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. @@ -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, @@ -495,8 +503,10 @@ pub fn reset_easing_states_on_transform_change( >, last_easing_tick: Res, system_change_tick: SystemChangeTick, + time: Res>, ) { 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)| { @@ -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); } } }