From 79698f5c2507fe8b15151cf6977f4e39cda973dc Mon Sep 17 00:00:00 2001 From: Thierry Berger Date: Tue, 24 Dec 2024 10:49:04 +0100 Subject: [PATCH 1/8] background simulation task + interpolation (wip wip) --- Cargo.toml | 16 +- examples/extrapolation.rs | 2 +- examples/interpolate_custom_schedule.rs | 285 +++++++ examples/interpolate_custom_schedule_retry.rs | 722 ++++++++++++++++++ examples/interpolation.rs | 8 - src/background_fixed_schedule.rs | 423 ++++++++++ src/interpolation.rs | 37 +- src/lib.rs | 64 +- 8 files changed, 1520 insertions(+), 37 deletions(-) create mode 100644 examples/interpolate_custom_schedule.rs create mode 100644 examples/interpolate_custom_schedule_retry.rs create mode 100644 src/background_fixed_schedule.rs diff --git a/Cargo.toml b/Cargo.toml index d35dd9f..f3342c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,15 +11,27 @@ readme = "README.md" keywords = ["gamedev", "easing", "bevy"] categories = ["game-development"] +[features] +default = ["x11"] +x11 = ["bevy/x11"] + [dependencies] -bevy = { version = "0.15.0-rc", default-features = false } +bevy = { version = "0.15", default-features = false } +crossbeam-channel = "0.5" +profiling = "1.0" +rand = "0.8" [dev-dependencies] -bevy = { version = "0.15.0-rc", default-features = false, features = [ +bevy = { version = "0.15", default-features = false, features = [ "bevy_asset", "bevy_render", + "bevy_window", "bevy_text", "bevy_ui", "bevy_winit", "default_font", + "bevy_gizmos", ] } +crossbeam-channel = "0.5" +profiling = "1.0" +rand = "0.8" diff --git a/examples/extrapolation.rs b/examples/extrapolation.rs index 2fc61b2..852f1e1 100644 --- a/examples/extrapolation.rs +++ b/examples/extrapolation.rs @@ -83,7 +83,7 @@ impl Plugin for TransformExtrapolationPlugin { // Add the `TransformEasingPlugin` if it hasn't been added yet. // It performs the actual easing based on the start and end states set by the extrapolation. if !app.is_plugin_added::() { - app.add_plugins(TransformEasingPlugin); + app.add_plugins(TransformEasingPlugin::default()); } } } diff --git a/examples/interpolate_custom_schedule.rs b/examples/interpolate_custom_schedule.rs new file mode 100644 index 0000000..d50f20f --- /dev/null +++ b/examples/interpolate_custom_schedule.rs @@ -0,0 +1,285 @@ +//! This example showcases how `Transform` interpolation can be used to make movement +//! appear smooth at fixed timesteps. +//! +//! `Transform` interpolation updates `Transform` at every frame in between +//! fixed ticks to smooth out the visual result. The interpolation is done +//! from the previous positions to the current positions, which keeps movement smooth, +//! but has the downside of making movement feel slightly delayed as the rendered +//! result lags slightly behind the true positions. +//! +//! For an example of how transform extrapolation could be implemented instead, +//! see `examples/extrapolation.rs`. + +use bevy::{ + color::palettes::{ + css::{ORANGE, RED, WHITE}, + tailwind::{CYAN_400, RED_400}, + }, + ecs::schedule::ScheduleLabel, + prelude::*, +}; +use bevy_transform_interpolation::{ + background_fixed_schedule::{ + AngularVelocity, BackgroundFixedUpdatePlugin, LinearVelocity, PostWriteBack, PreWriteBack, + TaskResults, TaskToRenderTime, Timestep, ToMove, + }, + prelude::*, + RotationEasingState, ScaleEasingState, TransformEasingSet, TranslationEasingState, +}; + +use std::time::Duration; + +const MOVEMENT_SPEED: f32 = 250.0; +const ROTATION_SPEED: f32 = 2.0; + +fn main() { + let mut app = App::new(); + + let easing_plugin = TransformEasingPlugin { + schedule_fixed_first: PreWriteBack.intern(), + schedule_fixed_last: PostWriteBack.intern(), + schedule_fixed_loop: bevy::app::prelude::RunFixedMainLoop.intern(), + after_fixed_main_loop: RunFixedMainLoopSystem::AfterFixedMainLoop.intern(), + update_easing_values: false, + }; + let interpolation_plugin = TransformInterpolationPlugin { + schedule_fixed_first: PreWriteBack.intern(), + schedule_fixed_last: PostWriteBack.intern(), + interpolate_translation_all: false, + interpolate_rotation_all: false, + interpolate_scale_all: false, + }; + + // Add the `TransformInterpolationPlugin` to the app to enable transform interpolation. + app.add_plugins(( + DefaultPlugins, + BackgroundFixedUpdatePlugin, + easing_plugin, + interpolation_plugin, + )); + + // Set the fixed timestep to just 5 Hz for demonstration purposes. + + // Setup the scene and UI, and update text in `Update`. + app.add_systems(Startup, (setup, setup_text)).add_systems( + bevy::app::prelude::RunFixedMainLoop, + ( + change_timestep, + update_timestep_text, + update_diff_to_render_text, + ), + ); + + // This runs every frame to poll if our task was done. + + app.add_systems( + bevy::app::prelude::RunFixedMainLoop, + (ease_translation_lerp, ease_rotation_slerp, ease_scale_lerp) + .in_set(TransformEasingSet::Ease), + ); + + // Run the app. + app.run(); +} +/// Eases the translations of entities with linear interpolation. +fn ease_translation_lerp( + mut query: Query<(&mut Transform, &TranslationEasingState)>, + time: Query<(&TaskToRenderTime, &Timestep)>, +) { + let Ok((time, timestep)) = time.get_single() else { + return; + }; + let overstep = (time.diff.max(0.0) / timestep.timestep.as_secs_f64()).min(1.0) as f32; + query.iter_mut().for_each(|(mut transform, interpolation)| { + if let (Some(start), Some(end)) = (interpolation.start, interpolation.end) { + transform.translation = start.lerp(end, overstep); + } + }); +} + +/// Eases the rotations of entities with spherical linear interpolation. +fn ease_rotation_slerp( + mut query: Query<(&mut Transform, &RotationEasingState)>, + time: Query<(&TaskToRenderTime, &Timestep)>, +) { + let Ok((time, timestep)) = time.get_single() else { + return; + }; + let overstep = (time.diff.max(0.0) / timestep.timestep.as_secs_f64()).min(1.0) as f32; + + query + .par_iter_mut() + .for_each(|(mut transform, interpolation)| { + if let (Some(start), Some(end)) = (interpolation.start, interpolation.end) { + // Note: `slerp` will always take the shortest path, but when the two rotations are more than + // 180 degrees apart, this can cause visual artifacts as the rotation "flips" to the other side. + transform.rotation = start.slerp(end, overstep); + } + }); +} + +/// Eases the scales of entities with linear interpolation. +fn ease_scale_lerp( + mut query: Query<(&mut Transform, &ScaleEasingState)>, + time: Query<(&TaskToRenderTime, &Timestep)>, +) { + let Ok((time, timestep)) = time.get_single() else { + return; + }; + let overstep = (time.diff.max(0.0) / timestep.timestep.as_secs_f64()).min(1.0) as f32; + + query.iter_mut().for_each(|(mut transform, interpolation)| { + if let (Some(start), Some(end)) = (interpolation.start, interpolation.end) { + transform.scale = start.lerp(end, overstep); + } + }); +} + +fn setup( + mut commands: Commands, + mut materials: ResMut>, + mut meshes: ResMut>, +) { + // Spawn a camera. + commands.spawn(Camera2d); + + let mesh = meshes.add(Rectangle::from_length(60.0)); + + commands.spawn(( + TaskToRenderTime::default(), + Timestep { + timestep: Duration::from_secs_f32(0.5), + }, + TaskResults::default(), + )); + + // This entity uses transform interpolation. + commands.spawn(( + Name::new("Interpolation"), + Mesh2d(mesh.clone()), + MeshMaterial2d(materials.add(Color::from(CYAN_400)).clone()), + Transform::from_xyz(-500.0, 60.0, 0.0), + TransformInterpolation, + LinearVelocity(Vec2::new(MOVEMENT_SPEED, 0.0)), + AngularVelocity(ROTATION_SPEED), + ToMove, + )); + + // This entity is simulated in `FixedUpdate` without any smoothing. + commands.spawn(( + Name::new("No Interpolation"), + Mesh2d(mesh.clone()), + MeshMaterial2d(materials.add(Color::from(RED_400)).clone()), + Transform::from_xyz(-500.0, -60.0, 0.0), + LinearVelocity(Vec2::new(MOVEMENT_SPEED, 0.0)), + AngularVelocity(ROTATION_SPEED), + ToMove, + )); +} + +/// Changes the timestep of the simulation when the up or down arrow keys are pressed. +fn change_timestep(mut time: Query<&mut Timestep>, keyboard_input: Res>) { + let mut time = time.single_mut(); + if keyboard_input.pressed(KeyCode::ArrowUp) { + let new_timestep = (time.timestep.as_secs_f64() * 0.9).max(1.0 / 255.0); + time.timestep = Duration::from_secs_f64(new_timestep); + } + if keyboard_input.pressed(KeyCode::ArrowDown) { + let new_timestep = (time.timestep.as_secs_f64() * 1.1) + .min(1.0) + .max(1.0 / 255.0); + time.timestep = Duration::from_secs_f64(new_timestep); + } +} + +#[derive(Component)] +struct TimestepText; + +#[derive(Component)] +struct TaskToRenderTimeText; + +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"), + TextColor::from(WHITE), + 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("No Interpolation"), + TextColor::from(RED_400), + font.clone(), + Node { + position_type: PositionType::Absolute, + top: Val::Px(75.0), + left: Val::Px(10.0), + ..default() + }, + )); + + commands + .spawn(( + Text::new("Diff to render time: "), + TextColor::from(WHITE), + font.clone(), + Node { + position_type: PositionType::Absolute, + top: Val::Px(100.0), + left: Val::Px(10.0), + ..default() + }, + )) + .with_child((TaskToRenderTimeText, TextSpan::default())); +} + +fn update_timestep_text( + mut text: Single<&mut TextSpan, With>, + time: Query<&Timestep>, +) { + let timestep = time.single().timestep.as_secs_f32().recip(); + text.0 = format!("{timestep:.2}"); +} + +fn update_diff_to_render_text( + mut text: Single<&mut TextSpan, With>, + task_to_render: Single<&TaskToRenderTime>, +) { + text.0 = format!("{:.2}", task_to_render.diff); +} diff --git a/examples/interpolate_custom_schedule_retry.rs b/examples/interpolate_custom_schedule_retry.rs new file mode 100644 index 0000000..b4bd70e --- /dev/null +++ b/examples/interpolate_custom_schedule_retry.rs @@ -0,0 +1,722 @@ +//! This example showcases how `Transform` interpolation can be used to make movement +//! appear smooth at fixed timesteps. +//! +//! `Transform` interpolation updates `Transform` at every frame in between +//! fixed ticks to smooth out the visual result. The interpolation is done +//! from the previous positions to the current positions, which keeps movement smooth, +//! but has the downside of making movement feel slightly delayed as the rendered +//! result lags slightly behind the true positions. +//! +//! For an example of how transform extrapolation could be implemented instead, +//! see `examples/extrapolation.rs`. + +use bevy::{ + color::palettes::{ + css::WHITE, + tailwind::{CYAN_400, RED_400}, + }, + ecs::schedule::{LogLevel, ScheduleBuildSettings, ScheduleLabel}, + prelude::*, + tasks::AsyncComputeTaskPool, +}; +use bevy_transform_interpolation::{ + prelude::*, RotationEasingState, ScaleEasingState, TransformEasingSet, TranslationEasingState, +}; +use crossbeam_channel::Receiver; +use rand::{thread_rng, Rng}; +use std::{collections::VecDeque, slice::IterMut, time::Duration}; + +const MOVEMENT_SPEED: f32 = 250.0; +const ROTATION_SPEED: f32 = 2.0; + +// TODO: update this time to use it correctly. +// See https://github.com/bevyengine/bevy/blob/d4b07a51149c4cc69899f7424df473ff817fe324/crates/bevy_time/src/fixed.rs#L241 + +fn main() { + let mut app = App::new(); + + // Add the `TransformInterpolationPlugin` to the app to enable transform interpolation. + app.add_plugins(DefaultPlugins); + + // Set the fixed timestep to just 5 Hz for demonstration purposes. + + // Setup the scene and UI, and update text in `Update`. + app.add_systems(Startup, (setup, setup_text)).add_systems( + bevy::app::prelude::RunFixedMainLoop, + ( + change_timestep, + update_timestep_text, + update_diff_to_render_text, + ), + ); + + // This runs every frame to poll if our task was done. + app.add_systems( + bevy::app::prelude::RunFixedMainLoop, // TODO: use a specific schedule for this, à la bevy's FixedMainLoop + task_schedule::FixedMain::run_schedule, + ); + + // this handles checking for task completion, firing writeback schedules and spawning a new task. + app.edit_schedule(task_schedule::FixedMain, |schedule| { + schedule + .add_systems(task_schedule::HandleTask::run_schedule) + .set_build_settings(ScheduleBuildSettings { + ambiguity_detection: LogLevel::Error, + ..default() + }); + }); + + // those schedules are part of FixedMain + app.init_schedule(task_schedule::PreWriteBack); + app.edit_schedule(task_schedule::WriteBack, |schedule| { + schedule + .add_systems((handle_task,)) + .set_build_settings(ScheduleBuildSettings { + ambiguity_detection: LogLevel::Error, + ..default() + }); + }); + app.edit_schedule(task_schedule::PostWriteBack, |schedule| { + schedule.set_build_settings(ScheduleBuildSettings { + ambiguity_detection: LogLevel::Error, + ..default() + }); + }); + + app.add_systems( + bevy::app::prelude::RunFixedMainLoop, + (ease_translation_lerp, ease_rotation_slerp, ease_scale_lerp) + .in_set(TransformEasingSet::Ease), + ); + // this will spawn a new task if needed. + app.edit_schedule(task_schedule::MaybeSpawnTask, |schedule| { + schedule + .add_systems(spawn_task) + .set_build_settings(ScheduleBuildSettings { + ambiguity_detection: LogLevel::Error, + ..default() + }); + }); + + // Run the app. + app.run(); +} +/// Eases the translations of entities with linear interpolation. +fn ease_translation_lerp( + mut query: Query<(&mut Transform, &TranslationEasingState)>, + time: Query<(&TaskToRenderTime, &Timestep, &LastTaskTimings)>, +) { + let Ok((time, timestep, last_task_timing)) = time.get_single() else { + return; + }; + let overstep = (time.diff.max(0.0) + / (timestep.timestep - last_task_timing.render_time_elapsed_during_the_simulation) + .as_secs_f64()) + .min(1.0) as f32; + query.iter_mut().for_each(|(mut transform, interpolation)| { + if let (Some(start), Some(end)) = (interpolation.start, interpolation.end) { + transform.translation = start.lerp(end, overstep); + } + }); +} + +/// Eases the rotations of entities with spherical linear interpolation. +fn ease_rotation_slerp( + mut query: Query<(&mut Transform, &RotationEasingState)>, + time: Query<(&TaskToRenderTime, &Timestep)>, +) { + let Ok((time, timestep)) = time.get_single() else { + return; + }; + let overstep = (time.diff.max(0.0) / timestep.timestep.as_secs_f64()).min(1.0) as f32; + + query + .par_iter_mut() + .for_each(|(mut transform, interpolation)| { + if let (Some(start), Some(end)) = (interpolation.start, interpolation.end) { + // Note: `slerp` will always take the shortest path, but when the two rotations are more than + // 180 degrees apart, this can cause visual artifacts as the rotation "flips" to the other side. + transform.rotation = start.slerp(end, overstep); + } + }); +} + +/// Eases the scales of entities with linear interpolation. +fn ease_scale_lerp( + mut query: Query<(&mut Transform, &ScaleEasingState)>, + time: Query<(&TaskToRenderTime, &Timestep)>, +) { + let Ok((time, timestep)) = time.get_single() else { + return; + }; + let overstep = (time.diff.max(0.0) / timestep.timestep.as_secs_f64()).min(1.0) as f32; + + query.iter_mut().for_each(|(mut transform, interpolation)| { + if let (Some(start), Some(end)) = (interpolation.start, interpolation.end) { + transform.scale = start.lerp(end, overstep); + } + }); +} + +/// The linear velocity of an entity indicating its movement speed and direction. +#[derive(Component, Debug, Deref, DerefMut, Clone)] +pub struct LinearVelocity(Vec2); + +/// The angular velocity of an entity indicating its rotation speed. +#[derive(Component, Debug, Deref, DerefMut, Clone)] +pub struct AngularVelocity(f32); + +#[derive(Component, Debug, Clone)] +struct ToMove; + +fn setup( + mut commands: Commands, + mut materials: ResMut>, + mut meshes: ResMut>, +) { + // Spawn a camera. + commands.spawn(Camera2d); + + let mesh = meshes.add(Rectangle::from_length(60.0)); + + commands.spawn(( + TaskToRenderTime::default(), + Timestep { + timestep: Duration::from_secs_f32(0.5), + }, + TaskResults::default(), + )); + + // This entity uses transform interpolation. + commands.spawn(( + Name::new("Interpolation"), + Mesh2d(mesh.clone()), + MeshMaterial2d(materials.add(Color::from(CYAN_400)).clone()), + Transform::from_xyz(-500.0, 60.0, 0.0), + TransformInterpolation, + LinearVelocity(Vec2::new(MOVEMENT_SPEED, 0.0)), + AngularVelocity(ROTATION_SPEED), + ToMove, + )); + + // This entity is simulated in `FixedUpdate` without any smoothing. + commands.spawn(( + Name::new("No Interpolation"), + Mesh2d(mesh.clone()), + MeshMaterial2d(materials.add(Color::from(RED_400)).clone()), + Transform::from_xyz(-500.0, -60.0, 0.0), + LinearVelocity(Vec2::new(MOVEMENT_SPEED, 0.0)), + AngularVelocity(ROTATION_SPEED), + ToMove, + )); +} + +/// Changes the timestep of the simulation when the up or down arrow keys are pressed. +fn change_timestep(mut time: Query<&mut Timestep>, keyboard_input: Res>) { + let mut time = time.single_mut(); + if keyboard_input.pressed(KeyCode::ArrowUp) { + let new_timestep = (time.timestep.as_secs_f64() * 0.9).max(1.0 / 255.0); + time.timestep = Duration::from_secs_f64(new_timestep); + } + if keyboard_input.pressed(KeyCode::ArrowDown) { + let new_timestep = (time.timestep.as_secs_f64() * 1.1) + .min(1.0) + .max(1.0 / 255.0); + time.timestep = Duration::from_secs_f64(new_timestep); + } +} + +/// Flips the movement directions of objects when they reach the left or right side of the screen. +fn flip_movement_direction(query: IterMut<(&mut Transform, &mut LinearVelocity)>) { + for (transform, lin_vel) in query { + if transform.translation.x > 500.0 && lin_vel.0.x > 0.0 { + lin_vel.0 = Vec2::new(-MOVEMENT_SPEED, 0.0); + } else if transform.translation.x < -500.0 && lin_vel.0.x < 0.0 { + lin_vel.0 = Vec2::new(MOVEMENT_SPEED, 0.0); + } + } +} + +/// Moves entities based on their `LinearVelocity`. +fn movement(query: IterMut<(&mut Transform, &mut LinearVelocity)>, delta: Duration) { + let delta_secs = delta.as_secs_f32(); + for (transform, lin_vel) in query { + transform.translation += lin_vel.extend(0.0) * delta_secs; + } +} + +/// Rotates entities based on their `AngularVelocity`. +fn rotate(query: IterMut<(&mut Transform, &mut AngularVelocity)>, delta: Duration) { + let delta_secs = delta.as_secs_f32(); + for (transform, ang_vel) in query { + transform.rotate_local_z(ang_vel.0 * delta_secs); + } +} + +#[derive(Component)] +struct TimestepText; + +#[derive(Component)] +struct TaskToRenderTimeText; + +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"), + TextColor::from(WHITE), + 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("No Interpolation"), + TextColor::from(RED_400), + font.clone(), + Node { + position_type: PositionType::Absolute, + top: Val::Px(75.0), + left: Val::Px(10.0), + ..default() + }, + )); + + commands + .spawn(( + Text::new("Diff to render time: "), + TextColor::from(WHITE), + font.clone(), + Node { + position_type: PositionType::Absolute, + top: Val::Px(100.0), + left: Val::Px(10.0), + ..default() + }, + )) + .with_child((TaskToRenderTimeText, TextSpan::default())); +} + +fn update_timestep_text( + mut text: Single<&mut TextSpan, With>, + time: Query<&Timestep>, +) { + let timestep = time.single().timestep.as_secs_f32().recip(); + text.0 = format!("{timestep:.2}"); +} + +fn update_diff_to_render_text( + mut text: Single<&mut TextSpan, With>, + task_to_render: Single<&TaskToRenderTime>, +) { + text.0 = format!("{:.2}", task_to_render.diff); +} + +pub mod task_schedule { + + use bevy::{ + ecs::schedule::ScheduleLabel, + log::{info, trace}, + prelude::{SystemSet, World}, + time::Time, + }; + + use crate::TaskToRenderTime; + + #[derive(SystemSet, Clone, Copy, Debug, PartialEq, Eq, Hash)] + pub enum FixedMainLoop { + Before, + During, + After, + } + + /// Executes before the task result is propagated to the ECS. + #[derive(ScheduleLabel, Clone, Copy, Debug, PartialEq, Eq, Hash)] + pub struct PreWriteBack; + + /// Propagates the task result to the ECS. + #[derive(ScheduleLabel, Clone, Copy, Debug, PartialEq, Eq, Hash)] + pub struct WriteBack; + + /// Called after the propagation of the task result to the ECS. + #[derive(ScheduleLabel, Clone, Copy, Debug, PartialEq, Eq, Hash)] + pub struct PostWriteBack; + + /// Called once to start a task, then after receiving each task result. + #[derive(ScheduleLabel, Clone, Copy, Debug, PartialEq, Eq, Hash)] + pub struct MaybeSpawnTask; + + /// Schedule running [`PreWriteBack`], [`WriteBack`] and [`PostWriteBack`] + /// only if it received its data from the [`super::WorkTask`] present in the single Entity containing it. + /// + /// This Schedule overrides [`Res