diff --git a/.gitignore b/.gitignore index 909cc324..3f0a1495 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target /frames/*.png +*.DS_Store diff --git a/crates/bevy_motiongfx/README.md b/crates/bevy_motiongfx/README.md index 5367b33d..5f65a70c 100644 --- a/crates/bevy_motiongfx/README.md +++ b/crates/bevy_motiongfx/README.md @@ -53,7 +53,7 @@ fn build_timeline( .act(entity, path!(::translation::x), |x| { x + 6.0 }) - .play(1.0) + .play(s(1)) .compile(); b.add_tracks(track); @@ -90,7 +90,7 @@ fn build_timeline( path!(::base_color), |_| Srgba::RED.into(), ) - .play(1.0) + .play(s(1)) .compile(); b.add_tracks(track); diff --git a/crates/bevy_motiongfx/src/controller.rs b/crates/bevy_motiongfx/src/controller.rs index 28c71976..c14c57bf 100644 --- a/crates/bevy_motiongfx/src/controller.rs +++ b/crates/bevy_motiongfx/src/controller.rs @@ -1,3 +1,5 @@ +use core::time::Duration; + use bevy_app::prelude::*; use bevy_ecs::prelude::*; use bevy_time::prelude::*; @@ -30,10 +32,14 @@ fn realtime_player_update( q_timelines.iter().filter(|(_, p)| p.is_playing) { if let Some(timeline) = motiongfx.get_timeline_mut(id) { - let target_time = timeline.target_time() - + player.time_scale * time.delta_secs(); + // Magnitude sets the step, sign picks the direction. + let delta = time.delta().mul_f64(player.time_scale.abs()); - timeline.set_target_time(target_time); + if player.time_scale > 0.0 { + timeline.advance_time(delta); + } else if player.time_scale < 0.0 { + timeline.rewind_time(delta); + } } } } @@ -47,11 +53,8 @@ fn fixed_rate_player_update( { if let Some(timeline) = motiongfx.get_timeline_mut(id) { // Each frame we update the timeline according to the fps. - let target_time = - timeline.target_time() + player.delta_secs(); - - timeline.set_target_time(target_time); player.curr_frame += 1; + timeline.set_target_time(player.frame_time()); } } } @@ -83,7 +86,7 @@ pub struct RealtimePlayer { pub is_playing: bool, /// The time scale of the player. Set this to negative /// to play backwards. - pub time_scale: f32, + pub time_scale: f64, } impl RealtimePlayer { @@ -107,7 +110,7 @@ impl RealtimePlayer { /// Builder method for setting [`Self::time_scale`]. #[inline] #[must_use] - pub const fn with_time_scale(mut self, time_scale: f32) -> Self { + pub const fn with_time_scale(mut self, time_scale: f64) -> Self { self.time_scale = time_scale; self } @@ -123,7 +126,7 @@ impl RealtimePlayer { #[inline] pub const fn set_time_scale( &mut self, - time_scale: f32, + time_scale: f64, ) -> &mut Self { self.time_scale = time_scale; self @@ -182,6 +185,22 @@ impl FixedRatePlayer { 1.0 / self.fps as f32 } + /// The exact timestamp of [`Self::curr_frame`]. + /// + /// A given frame index always maps to the same instant no matter + /// how long the recording runs. + /// + /// Returns [`Duration::ZERO`] when [`Self::fps`] is zero. + #[inline] + #[must_use] + pub fn frame_time(&self) -> Duration { + if self.fps == 0 { + return Duration::ZERO; + } + + Duration::from_secs(self.curr_frame) / self.fps as u32 + } + /// Setter method for setting [`Self::is_playing`]. #[inline] pub const fn set_playing( @@ -192,24 +211,30 @@ impl FixedRatePlayer { self } } + #[derive(Default, Component)] pub struct PassivePlayer { - time: f32, + time: Duration, track_index: usize, } impl PassivePlayer { #[inline] - pub fn set_time(&mut self, time: f32) { + pub fn set_time(&mut self, time: Duration) -> &mut Self { self.time = time; + self } #[inline] - pub fn set_track_index(&mut self, track_index: usize) { + pub fn set_track_index( + &mut self, + track_index: usize, + ) -> &mut Self { self.track_index = track_index; + self } - pub fn get_time(&self) -> f32 { + pub fn get_time(&self) -> Duration { self.time } @@ -217,3 +242,58 @@ impl PassivePlayer { self.track_index } } + +#[cfg(test)] +mod tests { + use motiongfx::time::{cs, ns, s}; + + use super::*; + + fn at(fps: u16, frame: u64) -> Duration { + FixedRatePlayer { + fps, + curr_frame: frame, + is_playing: false, + } + .frame_time() + } + + #[test] + fn frame_time_is_exact_where_the_rate_divides() { + assert_eq!(at(30, 0), Duration::ZERO); + assert_eq!(at(30, 30), s(1)); + assert_eq!(at(30, 3), cs(10)); + assert_eq!(at(25, 1), cs(4)); + assert_eq!(at(60, 90), cs(150)); + } + + /// The whole point of deriving from the counter: no accumulated + /// error, so a long render lands on exact second boundaries. + #[test] + fn frame_time_does_not_drift_over_a_long_render() { + // 30 minutes at 30fps. + assert_eq!(at(30, 54_000), s(1800)); + // Well past where the old nanosecond-based form overflowed. + assert_eq!(at(30, 30_000_000_000), s(1_000_000_000)); + } + + /// 1/3s is not representable in nanoseconds, so consecutive + /// frames differ by a nanosecond. The error must not compound. + #[test] + fn frame_time_stays_within_a_nanosecond_of_ideal() { + for frame in [1u64, 2, 1_000, 999_999] { + let ideal = + ns((frame as u128 * 1_000_000_000 / 3) as u64); + + assert_eq!(at(3, frame).abs_diff(ideal), Duration::ZERO); + } + } + + /// A rate of zero frames per second advances no time, rather than + /// dividing by zero or standing in some invented rate. + #[test] + fn zero_fps_yields_zero() { + assert_eq!(at(0, 0), Duration::ZERO); + assert_eq!(at(0, 5), Duration::ZERO); + } +} diff --git a/crates/motiongfx/README.md b/crates/motiongfx/README.md index e173f3bc..16a9e838 100644 --- a/crates/motiongfx/README.md +++ b/crates/motiongfx/README.md @@ -60,7 +60,7 @@ let id = 0; let action = b.act(id, path!(), |x| x + 10.0); // "Play" the action into a `TrackFragment` with a duration. -let frag = action.play(1.0); +let frag = action.play(s(1)); // Compile into a `Track` // See "Track Ordering" section for composing fragments. @@ -73,7 +73,7 @@ let mut timeline = b.compile(); timeline.bake_actions(®istry, &world); // Sample at t = 0.5, world.0[0] should now be 5.0. -timeline.set_target_time(0.5); +timeline.set_target_time(cs(50)); timeline.queue_actions(); timeline.sample_queued_actions(®istry, &mut world); @@ -147,7 +147,7 @@ let mut b = registry.create_builder::(); Animations are built up in layers: 1. An **action** says what to animate and how to transform it. -2. A **track fragment** gives the action a duration by calling `.play(seconds)`. +2. A **track fragment** gives the action a duration by calling `.play(duration)`. 3. A **track** is one or more fragments compiled together. You can order fragments before compiling (see [Track Ordering](#track-ordering)). 4. A **timeline** combines all your tracks into one playable sequence. @@ -165,7 +165,7 @@ let action = b .with_ease(ease::cubic::ease_in_out); // Play: turn the action into a fragment with a 1-second duration. -let frag = action.play(1.0); +let frag = action.play(s(1)); // Compile the fragment into a Track. let track = frag.compile(); @@ -196,7 +196,7 @@ Use `set_target_track` to jump between them. timeline.bake_actions(®istry, &subjects); // Set target time, queue, then sample. -timeline.set_target_time(0.5); +timeline.set_target_time(cs(50)); timeline.queue_actions(); timeline.sample_queued_actions(®istry, &mut subjects); @@ -261,7 +261,7 @@ use motiongfx::prelude::*; let f0 = TrackFragment::new(); let f1 = TrackFragment::new(); -let f = [f0, f1].ord_flow(0.5); +let f = [f0, f1].ord_flow(cs(50)); ``` `f1` starts 0.5 seconds after `f0` begins, regardless of how long diff --git a/crates/motiongfx/benches/action_storage.rs b/crates/motiongfx/benches/action_storage.rs index 33d74f48..7c0ca2bd 100644 --- a/crates/motiongfx/benches/action_storage.rs +++ b/crates/motiongfx/benches/action_storage.rs @@ -81,7 +81,7 @@ fn build_timeline( builder .act_builder(Id(i), path!(::x), |x| x + 72.0) .with_interp(linear_f32) - .play(1.0) + .play(s(1)) }) .collect::>(); @@ -156,8 +156,7 @@ fn bench_scrub(c: &mut Criterion) { // the start so successive iterations are identical. b.iter(|| { for frame in 0..=FRAMES { - let t = - duration * frame as f32 / FRAMES as f32; + let t = duration * frame / FRAMES; timeline.set_target_time(black_box(t)); timeline.queue_actions(); timeline.sample_queued_actions( @@ -165,7 +164,7 @@ fn bench_scrub(c: &mut Criterion) { ); } // Rewind to t=0 for the next iteration. - timeline.set_target_time(0.0); + timeline.set_target_time(s(0)); timeline.queue_actions(); timeline .sample_queued_actions(®istry, &mut world); @@ -278,7 +277,7 @@ fn build_mixed( v + 1.0 }) .with_interp(linear_f32) - .play(1.0), + .play(s(1)), builder .act_builder( Id(i), @@ -289,7 +288,7 @@ fn build_mixed( }, ) .with_interp(lerp2) - .play(1.0), + .play(s(1)), builder .act_builder( Id(i), @@ -301,7 +300,7 @@ fn build_mixed( }, ) .with_interp(lerp3) - .play(1.0), + .play(s(1)), builder .act_builder( Id(i), @@ -314,7 +313,7 @@ fn build_mixed( }, ) .with_interp(lerp4) - .play(1.0), + .play(s(1)), ] }) .collect::>(); @@ -383,15 +382,14 @@ fn bench_mixed_scrub(c: &mut Criterion) { b.iter(|| { for frame in 0..=FRAMES { - let t = - duration * frame as f32 / FRAMES as f32; + let t = duration * frame / FRAMES; timeline.set_target_time(black_box(t)); timeline.queue_actions(); timeline.sample_queued_actions( ®istry, &mut world, ); } - timeline.set_target_time(0.0); + timeline.set_target_time(s(0)); timeline.queue_actions(); timeline .sample_queued_actions(®istry, &mut world); diff --git a/crates/motiongfx/docs/world.rs b/crates/motiongfx/docs/world.rs index e97fb0e0..30b5c68a 100644 --- a/crates/motiongfx/docs/world.rs +++ b/crates/motiongfx/docs/world.rs @@ -22,7 +22,7 @@ pub fn timeline() -> (Registry, Timeline) { let track = b .act_builder(0usize, path!(), |x| x + 10.0) .with_interp(|a: &f32, b: &f32, t| a + (b - a) * t) - .play(1.0) + .play(s(1)) .compile(); b.add_tracks(track); let timeline = b.compile(); diff --git a/crates/motiongfx/examples/custom_world.rs b/crates/motiongfx/examples/custom_world.rs index fec2eb21..67a580ae 100644 --- a/crates/motiongfx/examples/custom_world.rs +++ b/crates/motiongfx/examples/custom_world.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::time::Duration; use motiongfx::prelude::*; @@ -68,21 +69,21 @@ fn main() { builder .act_builder(Id(0), path!(::x), |x| x + 72.0) .with_interp(linear_f32) - .play(1.0), + .play(s(1)), [ builder .act_builder(Id(1), path!(::p0::y), |y| { y + 42.0 }) .with_interp(linear_f32) - .play(2.0), + .play(s(2)), builder .act_builder(Id(1), path!(::p1), |_| Point { x: 6.0, y: 6.0, }) .with_interp(linear_point) - .play(2.0), + .play(s(2)), ] .ord_all(), ] @@ -96,7 +97,7 @@ fn main() { timeline.bake_actions(&world.registry, &world.subject_world); // Change the target time. - timeline.set_target_time(1.5); + timeline.set_target_time(cs(150)); // Check the values before sampling: println!("Before: {:?}", world.subject_world); @@ -111,19 +112,22 @@ fn main() { // Check the values after sampling: println!("After: {:?}\n", world.subject_world); - let new_target_time = 7.0; + let new_target_time = Duration::from_secs(7); // Set target time to after total track duration timeline.set_target_time(new_target_time); - println!("timeline target time set to: {}s", new_target_time); + println!("timeline target time set to: {:?}", new_target_time); println!( - "# Before sampling \ncurrent time: {}s,\ntarget time: {}s", + "# Before sampling \ncurrent time: {:?},\ntarget time: {:?}", timeline.curr_time(), - timeline.target_time() + timeline.target_time(), + ); + println!( + "target time clamped to current track's duration ({:?})\n", + timeline.curr_track().duration() ); - println!("target time clamped to timeline duration (3s)\n"); // Queue and sample the actions. timeline.queue_actions(); @@ -133,9 +137,9 @@ fn main() { ); println!( - "# After sampling: \ncurrent time: {}s,\ntarget time: {}s\n", + "# After sampling: \ncurrent time: {:?},\ntarget time: {:?}\n", timeline.curr_time(), - timeline.target_time() + timeline.target_time(), ); } diff --git a/crates/motiongfx/src/action.rs b/crates/motiongfx/src/action.rs index 6e045fd5..3c6a37a5 100644 --- a/crates/motiongfx/src/action.rs +++ b/crates/motiongfx/src/action.rs @@ -1,4 +1,5 @@ use core::any::TypeId; +use core::time::Duration; use alloc::boxed::Box; use field_path::field::UntypedField; @@ -125,25 +126,43 @@ pub type EaseFn = fn(t: f32) -> f32; #[derive(Debug, Clone, Copy)] pub struct EaseStorage(pub EaseFn); -#[derive(Debug, Clone, Copy, PartialEq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ActionClip { pub id: ActionId, - pub start: f32, - pub duration: f32, + pub start: Duration, + pub duration: Duration, } impl ActionClip { - pub const fn new(id: ActionId, duration: f32) -> Self { + pub const fn new(id: ActionId, duration: Duration) -> Self { Self { id, - start: 0.0, + start: Duration::ZERO, duration, } } + /// Saturating, so an absurd authored duration degrades to a clamped + /// timeline rather than a panic deep in playback. #[inline] - pub fn end(&self) -> f32 { - self.start + self.duration + pub fn end(&self) -> Duration { + self.start.saturating_add(self.duration) + } + + /// Normalized progress of `time` through this clip, in + /// \[0.0..=1.0\]. + /// + /// Zero-duration spacer clips report `1.0` rather than `NaN`. + #[inline] + pub fn progress(&self, time: Duration) -> f32 { + let span = self.duration.as_secs_f64(); + + if span == 0.0 { + return 1.0; + } + + let elapsed = time.saturating_sub(self.start).as_secs_f64(); + (elapsed / span).clamp(0.0, 1.0) as f32 } } @@ -167,3 +186,57 @@ pub enum SampleMode { End, Interp(f32), } + +#[cfg(test)] +mod tests { + use crate::time::{cs, s}; + + use super::*; + + const fn clip(start: Duration, duration: Duration) -> ActionClip { + ActionClip { + id: ActionId::PLACEHOLDER, + start, + duration, + } + } + + #[test] + fn progress_spans_the_clip() { + let clip = clip(cs(100), cs(200)); + + assert_eq!(clip.progress(cs(100)), 0.0); + assert_eq!(clip.progress(cs(200)), 0.5); + assert_eq!(clip.progress(cs(300)), 1.0); + } + + /// `end()` runs on every queue pass, so a saturated duration must + /// not panic there. + #[test] + fn end_saturates_instead_of_overflowing() { + let clip = ActionClip { + id: ActionId::PLACEHOLDER, + start: Duration::MAX, + duration: Duration::MAX, + }; + + assert_eq!(clip.end(), Duration::MAX); + } + + #[test] + fn progress_clamps_outside_the_clip() { + let clip = clip(cs(100), cs(200)); + + assert_eq!(clip.progress(Duration::ZERO), 0.0); + assert_eq!(clip.progress(s(60)), 1.0); + } + + /// A `NaN` here would poison the interpolated value. + #[test] + fn zero_duration_clip_reports_completion() { + let t = clip(cs(100), Duration::ZERO).progress(cs(100)); + + assert!(!t.is_nan()); + assert_eq!(t, 1.0); + } +} diff --git a/crates/motiongfx/src/action/table.rs b/crates/motiongfx/src/action/table.rs index a987caae..e7896cb9 100644 --- a/crates/motiongfx/src/action/table.rs +++ b/crates/motiongfx/src/action/table.rs @@ -1,5 +1,6 @@ use core::any::TypeId; use core::marker::PhantomData; +use core::time::Duration; use field_path::field::UntypedField; use typarena::ColumnId; @@ -232,7 +233,7 @@ impl InterpActionBuilder<'_, T> { /// Confirms the configuration of the action and creates a /// [`TrackFragment`]. - pub fn play(self, duration: f32) -> TrackFragment { + pub fn play(self, duration: Duration) -> TrackFragment { TrackFragment::single( self.inner.key, ActionClip::new(self.id(), duration), diff --git a/crates/motiongfx/src/lib.rs b/crates/motiongfx/src/lib.rs index 709c74e6..a6637995 100644 --- a/crates/motiongfx/src/lib.rs +++ b/crates/motiongfx/src/lib.rs @@ -11,6 +11,7 @@ pub mod registry; mod resources; pub mod sequence; pub mod subject; +pub mod time; pub mod timeline; pub mod track; pub mod world; @@ -33,6 +34,7 @@ pub mod prelude { pub use crate::registry::{ AccessorRegistry, PipelineRegistry, Registry, }; + pub use crate::time::{cs, ms, ns, s}; pub use crate::timeline::{Timeline, TimelineBuilder}; pub use crate::track::{Track, TrackFragment, TrackOrdering}; pub use crate::world::SubjectSource; diff --git a/crates/motiongfx/src/pipeline.rs b/crates/motiongfx/src/pipeline.rs index 8d77ef11..cf25ee58 100644 --- a/crates/motiongfx/src/pipeline.rs +++ b/crates/motiongfx/src/pipeline.rs @@ -2,6 +2,7 @@ pub mod func_pointers; use core::any::TypeId; use core::marker::PhantomData; +use core::time::Duration; use func_pointers::{BakeFnPtr, SampleFnPtr}; @@ -305,10 +306,10 @@ where } } -#[derive(Default, Debug, PartialEq, Clone, Copy)] +#[derive(Default, Debug, PartialEq, Eq, Clone, Copy)] pub struct Range { - pub start: f32, - pub end: f32, + pub start: Duration, + pub end: Duration, } impl Range { @@ -321,6 +322,7 @@ impl Range { #[cfg(test)] mod tests { use crate::interpolation::Interpolation; + use crate::time::s; use super::*; @@ -442,20 +444,20 @@ mod tests { #[test] fn range_overlap_behavior() { let a = Range { - start: 0.0, - end: 5.0, + start: s(0), + end: s(5), }; let b = Range { - start: 3.0, - end: 8.0, + start: s(3), + end: s(8), }; let c = Range { - start: 6.0, - end: 10.0, + start: s(6), + end: s(10), }; let d = Range { - start: 5.0, - end: 5.0, + start: s(5), + end: s(5), }; // touching boundary assert!( diff --git a/crates/motiongfx/src/sequence.rs b/crates/motiongfx/src/sequence.rs index d7df28a8..659c5ea1 100644 --- a/crates/motiongfx/src/sequence.rs +++ b/crates/motiongfx/src/sequence.rs @@ -1,3 +1,5 @@ +use core::time::Duration; + use nonempty::NonEmpty; use crate::action::ActionClip; @@ -23,25 +25,25 @@ impl Sequence { /// Get the start time of the sequence. #[inline] - pub fn start(&self) -> f32 { + pub fn start(&self) -> Duration { self.clips.first().start } /// Get the end time of the sequence. #[inline] - pub fn end(&self) -> f32 { + pub fn end(&self) -> Duration { self.clips.last().end() } /// Get the duration of the sequence. #[inline] - pub fn duration(&self) -> f32 { - self.end() - self.start() + pub fn duration(&self) -> Duration { + self.end().saturating_sub(self.start()) } - pub(crate) fn delay(&mut self, duration: f32) { + pub(crate) fn delay(&mut self, duration: Duration) { for clip in self.clips.iter_mut() { - clip.start += duration; + clip.start = clip.start.saturating_add(duration); } } } @@ -51,7 +53,7 @@ impl Sequence { pub fn push(&mut self, span: ActionClip) { debug_assert!( span.start >= self.end(), - "({} >= {}) `ActionClip`s shouldn't overlap!", + "({:?} >= {:?}) `ActionClip`s shouldn't overlap!", span.start, self.end(), ); @@ -73,7 +75,7 @@ impl Extend for Sequence { iter.into_iter().inspect(|clip| { debug_assert!( clip.start >= end, - "({} >= {}) `ActionClip`s shouldn't overlap!", + "({:?} >= {:?}) `ActionClip`s shouldn't overlap!", clip.start, end, ); diff --git a/crates/motiongfx/src/time.rs b/crates/motiongfx/src/time.rs new file mode 100644 index 00000000..30adb153 --- /dev/null +++ b/crates/motiongfx/src/time.rs @@ -0,0 +1,52 @@ +//! Time conversion helpers. +//! +//! Timing is stored as [`Duration`] so that the durations accumulated by +//! the track combinators and the clip offsets accumulated when delaying a +//! [`Sequence`] can never disagree. Float seconds are not associative, so +//! the two used to drift apart by a few ULPs, tripping the non-overlap +//! assertion and leaving the playhead clamp short of the final clip's end. +//! +//! [`Sequence`]: crate::sequence::Sequence + +use core::time::Duration; + +/// Whole seconds as a [`Duration`]. +#[inline] +#[must_use] +pub const fn s(secs: u64) -> Duration { + Duration::from_secs(secs) +} + +/// Whole centiseconds (hundredths of a second) as a [`Duration`]. +#[inline] +#[must_use] +pub const fn cs(centis: u64) -> Duration { + Duration::from_millis(centis.saturating_mul(10)) +} + +/// Whole milliseconds as a [`Duration`]. +#[inline] +#[must_use] +pub const fn ms(millis: u64) -> Duration { + Duration::from_millis(millis) +} + +/// Whole nanoseconds as a [`Duration`]. +#[inline] +#[must_use] +pub const fn ns(nanos: u64) -> Duration { + Duration::from_nanos(nanos) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unit_helpers_agree_with_duration_constructors() { + assert_eq!(s(2), ms(2_000)); + assert_eq!(cs(150), ms(1_500)); + assert_eq!(ms(1), ns(1_000_000)); + assert_eq!(cs(u64::MAX), Duration::from_millis(u64::MAX)); + } +} diff --git a/crates/motiongfx/src/timeline.rs b/crates/motiongfx/src/timeline.rs index 59f84f20..1c268bd3 100644 --- a/crates/motiongfx/src/timeline.rs +++ b/crates/motiongfx/src/timeline.rs @@ -1,5 +1,6 @@ use core::cmp::Ordering; use core::marker::PhantomData; +use core::time::Duration; use alloc::boxed::Box; use alloc::vec::Vec; @@ -35,9 +36,9 @@ pub struct Timeline { /// actions of each type, with no per-action column lookup. sample_queue: HashMap>, /// The current time of the current track. - curr_time: f32, + curr_time: Duration, /// The target time of the target track. - target_time: f32, + target_time: Duration, /// The index of the current track. curr_index: usize, /// The index of the target track. @@ -93,7 +94,7 @@ impl Timeline { > self.curr_index() { // From the start. - curr_time = 0.0; + curr_time = Duration::ZERO; ( SampleMode::End, self.curr_index()..self.target_index(), @@ -179,13 +180,12 @@ impl Timeline { Ok(index) => { let clip = &clips[index]; - let t = (self.target_time - clip.start) - / (clip.end() - clip.start); - self.queue_cache.cache( *key, clip.id, - SampleMode::Interp(t), + SampleMode::Interp( + clip.progress(self.target_time), + ), ); } // `target_time` is out of bounds. @@ -274,13 +274,13 @@ impl Timeline { /// Returns the current playback time. #[inline] - pub fn curr_time(&self) -> f32 { + pub fn curr_time(&self) -> Duration { self.curr_time } /// Returns the target playback time. #[inline] - pub fn target_time(&self) -> f32 { + pub fn target_time(&self) -> Duration { self.target_time } @@ -342,13 +342,32 @@ impl Timeline { impl Timeline { /// Set the target time of the current track, clamping the value /// within \[0.0..=track.duration\] - pub fn set_target_time(&mut self, target_time: f32) -> &mut Self { + pub fn set_target_time( + &mut self, + target_time: Duration, + ) -> &mut Self { let duration = self.tracks[self.target_index].duration(); - self.target_time = target_time.clamp(0.0, duration); + self.target_time = target_time.min(duration); self } + /// Steps forward, clamping at the track's end. + pub fn advance_time(&mut self, time: Duration) -> &mut Self { + let target_time = self.target_time.saturating_add(time); + + self.set_target_time(target_time) + } + + /// Steps backward, saturating at [`Duration::ZERO`]. + /// + /// [`Duration`] carries no sign, hence a separate method. + pub fn rewind_time(&mut self, time: Duration) -> &mut Self { + let target_time = self.target_time.saturating_sub(time); + + self.set_target_time(target_time) + } + /// Set the target track index, clamping the value within /// \[0..=track_count - 1\]. pub fn set_target_track( @@ -565,8 +584,8 @@ impl<'a, W: 'static> TimelineBuilder<'a, W> { tracks: self.tracks.into_boxed_slice(), queue_cache: QueueCache::new(), sample_queue: HashMap::new(), - curr_time: 0.0, - target_time: 0.0, + curr_time: Duration::ZERO, + target_time: Duration::ZERO, curr_index: 0, target_index: 0, _marker: PhantomData, @@ -580,5 +599,6 @@ impl<'a, W: 'static> TimelineBuilder<'a, W> { } } +// TODO: Write some unit tests. #[cfg(test)] mod tests {} diff --git a/crates/motiongfx/src/track.rs b/crates/motiongfx/src/track.rs index d671731f..d5dffe68 100644 --- a/crates/motiongfx/src/track.rs +++ b/crates/motiongfx/src/track.rs @@ -1,3 +1,5 @@ +use core::time::Duration; + use alloc::boxed::Box; use alloc::vec::Vec; use field_path::field::UntypedField; @@ -11,7 +13,7 @@ pub trait TrackOrdering { fn ord_chain(self) -> TrackFragment; fn ord_all(self) -> TrackFragment; fn ord_any(self) -> TrackFragment; - fn ord_flow(self, delay: f32) -> TrackFragment; + fn ord_flow(self, delay: Duration) -> TrackFragment; } impl TrackOrdering for T @@ -30,7 +32,7 @@ where any(self) } - fn ord_flow(self, delay: f32) -> TrackFragment { + fn ord_flow(self, delay: Duration) -> TrackFragment { flow(delay, self) } } @@ -52,7 +54,8 @@ pub fn chain( track = track.upsert_sequence(key, other_sequence); } - chain_duration += other_track.duration; + chain_duration = + chain_duration.saturating_add(other_track.duration); } track.duration = chain_duration; @@ -106,19 +109,20 @@ pub fn any( /// Run one [`Track`] after another with a fixed delay time. #[must_use = "This function consumes all the given tracks and returns a modified one."] pub fn flow( - delay: f32, + delay: Duration, tracks: impl IntoIterator, ) -> TrackFragment { let mut tracks_iter = tracks.into_iter(); let mut track = tracks_iter.next().unwrap_or_default(); - let mut flow_delay = 0.0; + let mut flow_delay = Duration::ZERO; let mut final_duration = track.duration; for other_track in tracks_iter { - flow_delay += delay; - final_duration = - (flow_delay + other_track.duration).max(final_duration); + flow_delay = flow_delay.saturating_add(delay); + final_duration = flow_delay + .saturating_add(other_track.duration) + .max(final_duration); for (key, mut sequence) in other_track.sequences { sequence.delay(flow_delay); @@ -132,7 +136,10 @@ pub fn flow( /// Run a [`Track`] after a fixed delay time. #[must_use = "This function consumes the given track and returns a modified one."] -pub fn delay(delay: f32, mut track: TrackFragment) -> TrackFragment { +pub fn delay( + delay: Duration, + mut track: TrackFragment, +) -> TrackFragment { for sequence in track.sequences.values_mut() { sequence.delay(delay); } @@ -142,14 +149,14 @@ pub fn delay(delay: f32, mut track: TrackFragment) -> TrackFragment { pub struct TrackFragment { sequences: HashMap, - duration: f32, + duration: Duration, } impl TrackFragment { pub fn new() -> Self { Self { sequences: HashMap::new(), - duration: 0.0, + duration: Duration::ZERO, } } @@ -194,8 +201,29 @@ impl TrackFragment { pub fn compile(self) -> Track { let mut sequences = self.sequences.into_iter().collect::>(); + + if sequences.is_empty() { + return Track { + field_lookups: Box::new([]), + sequence_spans: Box::new([]), + clip_arena: Box::new([]), + duration: self.duration, + }; + } + sequences.sort_by_key(|(key, _)| *key.field()); + // The combinators accumulate `duration` independently of the + // clip offsets, so pin them together here: the clamp in + // `Timeline::set_target_time` must be able to reach the last + // clip's end. + let duration = sequences + .iter() + .map(|(_, seq)| seq.end()) + .max() + .unwrap_or(Duration::ZERO) + .max(self.duration); + let mut seq_offset = 0; let mut sequence_spans = Vec::with_capacity(sequences.len()); @@ -248,7 +276,7 @@ impl TrackFragment { field_lookups: field_lookups.into_boxed_slice(), sequence_spans: sequence_spans.into_boxed_slice(), clip_arena, - duration: self.duration, + duration, } } } @@ -284,8 +312,11 @@ pub struct Track { /// Contiguous storage of all action clips. clip_arena: Box<[ActionClip]>, - /// Total duration of the track in seconds. - duration: f32, + /// Total duration of the track. + /// + /// Guaranteed to be `>=` the end of every clip in `clip_arena`. + /// See [`TrackFragment::compile`]. + duration: Duration, } impl Track { @@ -321,7 +352,7 @@ impl Track { } #[inline] - pub fn duration(&self) -> f32 { + pub fn duration(&self) -> Duration { self.duration } } @@ -345,6 +376,7 @@ pub struct Span { #[cfg(test)] mod tests { use crate::action::{ActionId, IdRegistry, UntypedSubjectId}; + use crate::time::{cs, ms, s}; use super::*; @@ -360,14 +392,14 @@ mod tests { ) } - const fn clip(duration: f32) -> ActionClip { - ActionClip::new(ActionId::PLACEHOLDER, duration) + const fn clip(centis: u64) -> ActionClip { + ActionClip::new(ActionId::PLACEHOLDER, cs(centis)) } #[test] fn track_key_uniqueness() { // Sequence with 0 duration to prevent overlaps. - const DUMMY_SEQ: Sequence = Sequence::new(clip(0.0)); + const DUMMY_SEQ: Sequence = Sequence::new(clip(0)); let entity1 = DummyId(1); let entity2 = DummyId(2); @@ -403,57 +435,132 @@ mod tests { #[test] fn chain_duration_and_delay() { - let track1 = TrackFragment::single(key("a"), clip(1.0)); - let track2 = TrackFragment::single(key("b"), clip(2.0)); + let track1 = TrackFragment::single(key("a"), clip(100)); + let track2 = TrackFragment::single(key("b"), clip(200)); let track = [track1, track2].ord_chain(); - assert_eq!(track.duration, 3.0); + assert_eq!(track.duration, cs(300)); let seq_b = &track.sequences[&key("b")]; // `seq_b` should be delayed by 1.0 (duration of `track1`). - assert_eq!(seq_b.start(), 1.0); + assert_eq!(seq_b.start(), cs(100)); } #[test] fn all_duration_max() { - let track1 = TrackFragment::single(key("a"), clip(1.0)); - let track2 = TrackFragment::single(key("b"), clip(3.0)); + let track1 = TrackFragment::single(key("a"), clip(100)); + let track2 = TrackFragment::single(key("b"), clip(300)); let track = [track1, track2].ord_all(); - assert_eq!(track.duration, 3.0); + assert_eq!(track.duration, cs(300)); } #[test] fn any_duration_min() { - let track1 = TrackFragment::single(key("a"), clip(1.0)); - let track2 = TrackFragment::single(key("b"), clip(3.0)); + let track1 = TrackFragment::single(key("a"), clip(100)); + let track2 = TrackFragment::single(key("b"), clip(300)); let track = [track1, track2].ord_any(); - assert_eq!(track.duration, 1.0); + assert_eq!(track.duration, cs(100)); } #[test] fn flow_with_delay() { - let track1 = TrackFragment::single(key("a"), clip(1.0)); - let track2 = TrackFragment::single(key("b"), clip(1.0)); + let track1 = TrackFragment::single(key("a"), clip(100)); + let track2 = TrackFragment::single(key("b"), clip(100)); - let track = [track1, track2].ord_flow(0.5); + let track = [track1, track2].ord_flow(cs(50)); - assert_eq!(track.duration, 1.5); // 0.5 delay + 1.0 duration + // 0.5 delay + 1.0 duration + assert_eq!(track.duration, cs(150)); let seq_b = &track.sequences[&key("b")]; // `seq_b` should be delayed by 0.5 - assert_eq!(seq_b.start(), 0.5); + assert_eq!(seq_b.start(), cs(50)); } #[test] fn delay_applies_offset() { - let track = TrackFragment::single(key("a"), clip(2.0)); + let track = TrackFragment::single(key("a"), clip(200)); - let track = delay(1.5, track); + let track = delay(cs(150), track); let seq_a = &track.sequences[&key("a")]; - assert_eq!(seq_a.start(), 1.5); - assert_eq!(seq_a.end(), 3.5); - assert_eq!(track.duration, 2.0); + assert_eq!(seq_a.start(), cs(150)); + assert_eq!(seq_a.end(), cs(350)); + assert_eq!(track.duration, cs(200)); + } + + /// Chaining durations that have no exact `f32` representation used + /// to leave `TrackFragment::duration` and the clip offsets on + /// different values, because the two are accumulated separately. + /// The mismatch tripped the non-overlap assert in `Sequence::push` + /// and put `Track::duration` out of reach of the last clip's end. + #[test] + fn chain_accumulation_matches_clip_offsets() { + // 0.1s is not representable in binary floating point. + let tracks: Vec<_> = (0..10) + .map(|_| TrackFragment::single(key("a"), clip(10))) + .collect(); + + let track = tracks.ord_chain(); + + assert_eq!(track.duration, cs(100)); + assert_eq!(track.sequences[&key("a")].end(), cs(100)); + } + + /// `Track::duration` must always be reachable by the playhead, so + /// that the final clip can resolve to `SampleMode::End`. + #[test] + fn compile_duration_covers_last_clip_end() { + let mut fragment = TrackFragment::single(key("a"), clip(100)); + // Understate the duration the way a combinator would if the + // two accumulations ever diverged again. + fragment.duration = ms(999); + + let track = fragment.compile(); + + assert_eq!(track.duration(), cs(100)); + } + + /// Combinator arithmetic has to saturate, or a `Duration::MAX` + /// duration panics inside `chain`, `flow`, or `ActionClip::end`. + /// + /// Distinct keys per fragment: saturated clips really do overlap, + /// and the non-overlap assert is right to say so. Only the duration + /// arithmetic is under test. + #[test] + fn saturated_durations_do_not_overflow_the_combinators() { + let huge = |path: &'static str| { + TrackFragment::single( + key(path), + ActionClip::new(ActionId::PLACEHOLDER, Duration::MAX), + ) + }; + + assert_eq!(huge("a").duration, Duration::MAX); + assert_eq!( + [huge("a"), huge("b")].ord_chain().duration, + Duration::MAX + ); + assert_eq!( + [huge("a"), huge("b")].ord_flow(s(1)).duration, + Duration::MAX + ); + assert_eq!( + [huge("a"), huge("b")].ord_all().duration, + Duration::MAX + ); + assert_eq!( + delay(Duration::MAX, huge("a")).duration, + Duration::MAX + ); + } + + #[test] + fn compile_empty_fragment_is_not_a_panic() { + let track = TrackFragment::new().compile(); + + assert_eq!(track.duration(), Duration::ZERO); + assert!(track.sequences_spans().is_empty()); } } diff --git a/crates/motiongfx_editor/src/lib.rs b/crates/motiongfx_editor/src/lib.rs index 9f833914..1fbd713a 100644 --- a/crates/motiongfx_editor/src/lib.rs +++ b/crates/motiongfx_editor/src/lib.rs @@ -17,6 +17,8 @@ mod ui; +use core::time::Duration; + use bevy::camera::{ClearColorConfig, RenderTarget, Viewport}; use bevy::feathers::dark_theme::create_dark_theme; use bevy::feathers::theme::{ThemeBackgroundColor, UiTheme}; @@ -320,18 +322,16 @@ fn build_timeline_view( .iter() .map(|(k, s)| (k, s)) .collect(); - rows.sort_by(|a, b| { - let start = |span: &Span| { - track - .clips(*span) - .first() - .map(|c| c.start) - .unwrap_or(f32::INFINITY) - }; - start(a.1).total_cmp(&start(b.1)) + rows.sort_by_key(|(_, span)| { + track + .clips(**span) + .first() + .map(|c| c.start) + .unwrap_or(Duration::MAX) }); - let duration = track.duration(); + // The layout below is pixel math, so drop to seconds here. + let duration = track.duration().as_secs_f32(); let content_width = (duration * PIXELS_PER_SECOND).max(1.0); let content_height = TRACK_TOP_PADDING * 2.0 + rows.len() as f32 * ROW_STRIDE; @@ -358,8 +358,10 @@ fn build_timeline_view( .insert(ChildOf(name_panel)); for clip in track.clips(**span) { - let left = clip.start * PIXELS_PER_SECOND; - let width = (clip.duration * PIXELS_PER_SECOND).max(2.0); + let left = clip.start.as_secs_f32() * PIXELS_PER_SECOND; + let width = (clip.duration.as_secs_f32() + * PIXELS_PER_SECOND) + .max(2.0); commands .spawn_scene(action_box( @@ -400,7 +402,8 @@ fn on_scrub( if let Some(timeline) = manager.get_timeline_mut(&timeline_id) { timeline.set_target_track(0); - timeline.set_target_time(change.value); + timeline + .set_target_time(Duration::from_secs_f32(change.value)); } } @@ -425,10 +428,10 @@ fn on_play_pause( if let Some(timeline_id) = state.timeline && q_players.iter().any(|p| p.is_playing) && let Some(timeline) = manager.get_timeline_mut(&timeline_id) - && timeline.target_time() >= state.duration + && timeline.target_time().as_secs_f32() >= state.duration { timeline.set_target_track(0); - timeline.set_target_time(0.0); + timeline.set_target_time(Duration::ZERO); } } @@ -448,7 +451,7 @@ fn update_playhead( let Some(timeline) = manager.get_timeline(&timeline_id) else { return; }; - let time = timeline.target_time(); + let time = timeline.target_time().as_secs_f32(); for mut node in &mut q_playhead { node.left = Val::Px(time * PIXELS_PER_SECOND); @@ -494,9 +497,9 @@ fn stop_at_track_end( continue; } let at_end = if player.time_scale >= 0.0 { - timeline.target_time() >= state.duration + timeline.target_time().as_secs_f32() >= state.duration } else { - timeline.target_time() <= 0.0 + timeline.target_time() == Duration::ZERO }; if at_end { player.is_playing = false; diff --git a/examples/bevy_examples/examples/custom_ease.rs b/examples/bevy_examples/examples/custom_ease.rs index 3d2c97bb..6d809def 100644 --- a/examples/bevy_examples/examples/custom_ease.rs +++ b/examples/bevy_examples/examples/custom_ease.rs @@ -40,7 +40,7 @@ fn spawn_timeline( }) // A custom 10 step easing. .with_ease(|t| ((t * 10.0) as u32) as f32 / 10.0) - .play(1.0) + .play(s(1)) .compile(); b.add_tracks(track); diff --git a/examples/bevy_examples/examples/custom_interp.rs b/examples/bevy_examples/examples/custom_interp.rs index 57bddcaf..a22eea93 100644 --- a/examples/bevy_examples/examples/custom_interp.rs +++ b/examples/bevy_examples/examples/custom_interp.rs @@ -42,7 +42,7 @@ fn spawn_timeline( }) .with_interp(|start, end, t| arc_lerp_3d(*start, *end, t)) .with_ease(ease::cubic::ease_in_out) - .play(1.0) + .play(s(1)) .compile(); b.add_tracks(track); diff --git a/examples/bevy_examples/examples/easings.rs b/examples/bevy_examples/examples/easings.rs index 19fabdc1..bd1b1235 100644 --- a/examples/bevy_examples/examples/easings.rs +++ b/examples/bevy_examples/examples/easings.rs @@ -85,14 +85,14 @@ fn spawn_timeline( |x| x + 10.0, ) .with_ease(ease_fn) - .play(1.0), + .play(s(1)), b.act( sphere_mat_ids[i], path!(::emissive), move |_| red, ) .with_ease(ease_fn) - .play(1.0), + .play(s(1)), ] .ord_all() }) diff --git a/examples/bevy_examples/examples/editor.rs b/examples/bevy_examples/examples/editor.rs index c21d6e3a..c09ba716 100644 --- a/examples/bevy_examples/examples/editor.rs +++ b/examples/bevy_examples/examples/editor.rs @@ -59,10 +59,10 @@ fn spawn_timeline( .map(|&cube| { b.act(cube, path!(::scale), |_| Vec3::ONE) .with_ease(ease::back::ease_out) - .play(0.6) + .play(cs(60)) }) .collect::>() - .ord_flow(0.15); + .ord_flow(cs(15)); let spin = cubes .iter() @@ -71,10 +71,10 @@ fn spawn_timeline( Quat::from_rotation_y(std::f32::consts::PI) }) .with_ease(ease::cubic::ease_in_out) - .play(1.0) + .play(s(1)) }) .collect::>() - .ord_flow(0.1); + .ord_flow(cs(10)); let track = [grow, spin].ord_chain().compile(); b.add_tracks(track); diff --git a/examples/bevy_examples/examples/hello_world.rs b/examples/bevy_examples/examples/hello_world.rs index 3d8dd622..98c11691 100644 --- a/examples/bevy_examples/examples/hello_world.rs +++ b/examples/bevy_examples/examples/hello_world.rs @@ -67,14 +67,14 @@ fn spawn_timeline( Vec3::splat(0.9) }) .with_ease(circ_ease) - .play(1.0), + .play(s(1)), b.act( cube_id, path!(::translation::x), |x| x + 1.0, ) .with_ease(circ_ease) - .play(1.0), + .play(s(1)), b.act(cube_id, path!(::rotation), |_| { Quat::from_euler( EulerRot::XYZ, @@ -84,7 +84,7 @@ fn spawn_timeline( ) }) .with_ease(circ_ease) - .play(1.0), + .play(s(1)), ] .ord_all(); @@ -92,7 +92,7 @@ fn spawn_timeline( } } - let track = cube_tracks.ord_flow(0.01).compile(); + let track = cube_tracks.ord_flow(cs(1)).compile(); b.add_tracks(track); let timeline = b.compile(); diff --git a/examples/bevy_examples/examples/minimal.rs b/examples/bevy_examples/examples/minimal.rs index e0ccb2e7..785ae67c 100644 --- a/examples/bevy_examples/examples/minimal.rs +++ b/examples/bevy_examples/examples/minimal.rs @@ -54,13 +54,13 @@ fn build_timeline( b.act(cube_id, path!(::translation::x), |x| { x + 6.0 }) - .play(1.0), + .play(s(1)), b.act( cube_mat_id, path!(::base_color), |_| Srgba::RED.into(), ) - .play(1.0), + .play(s(1)), ] .ord_all() .compile(); diff --git a/examples/bevy_examples/examples/recording.rs b/examples/bevy_examples/examples/recording.rs index d3c7e77e..c302b7ba 100644 --- a/examples/bevy_examples/examples/recording.rs +++ b/examples/bevy_examples/examples/recording.rs @@ -112,7 +112,7 @@ fn spawn_timeline( x + Vec3::ZERO.with_x(10.0).with_z(1.0) }) .with_interp(|start, end, t| arc_lerp_3d(*start, *end, t)) - .play(1.0) + .play(s(1)) .compile(); b.add_tracks(track); diff --git a/examples/bevy_examples/examples/slide_basic.rs b/examples/bevy_examples/examples/slide_basic.rs index ce5ce4ea..eb8a202b 100644 --- a/examples/bevy_examples/examples/slide_basic.rs +++ b/examples/bevy_examples/examples/slide_basic.rs @@ -1,3 +1,5 @@ +use std::time::Duration; + use bevy::camera::Hdr; use bevy::color::palettes; use bevy::prelude::*; @@ -53,7 +55,7 @@ fn spawn_timeline( let slide0 = b .act(cube, path!(::scale), |_| Vec3::ONE) .with_ease(ease::cubic::ease_out) - .play(1.0) + .play(s(1)) .compile(); let slide1 = [ @@ -64,21 +66,21 @@ fn spawn_timeline( move |_| -X_OFFSET, ) .with_ease(ease::cubic::ease_out) - .play(1.0), + .play(s(1)), b.act( cube_mat_id, path!(::base_color), move |_| palettes::tailwind::ZINC_700.into(), ) .with_ease(ease::cubic::ease_out) - .play(1.0), + .play(s(1)), ] .ord_all(), b.act(sphere, path!(::scale), |_| Vec3::ONE) .with_ease(ease::cubic::ease_out) - .play(1.0), + .play(s(1)), ] - .ord_flow(0.1) + .ord_flow(cs(10)) .compile(); b.add_tracks([slide0, slide1]); @@ -119,7 +121,7 @@ fn slide_movement( // Move to the start of the next track. let target_index = timeline.curr_index() + 1; timeline.set_target_track(target_index); - timeline.set_target_time(0.0); + timeline.set_target_time(s(0)); player.set_playing(false); } @@ -129,7 +131,7 @@ fn slide_movement( let target_index = timeline.curr_index().saturating_sub(1); timeline.set_target_track(target_index); - timeline.set_target_time(0.0); + timeline.set_target_time(s(0)); player.set_playing(false); } @@ -141,14 +143,15 @@ fn slide_movement( ]) { player.set_playing(true).set_time_scale(-1.0); - if timeline.curr_time() <= 0.0 + if timeline.curr_time() == Duration::ZERO && timeline.curr_index() > 0 { // Move to the end of the previous track. let target_index = timeline.curr_index().saturating_sub(1); timeline.set_target_track(target_index); - timeline.set_target_time(f32::MAX); + // Clamped to the new track's duration. + timeline.set_target_time(Duration::MAX); } } else { player.set_playing(true).set_time_scale(1.0); @@ -159,7 +162,7 @@ fn slide_movement( // Move to the start of the next track. let target_index = timeline.curr_index() + 1; timeline.set_target_track(target_index); - timeline.set_target_time(0.0); + timeline.set_target_time(s(0)); } } } diff --git a/examples/bevy_examples/src/lib.rs b/examples/bevy_examples/src/lib.rs index 191c33fb..8730ab3b 100644 --- a/examples/bevy_examples/src/lib.rs +++ b/examples/bevy_examples/src/lib.rs @@ -12,16 +12,12 @@ pub fn timeline_movement( if keys.any_pressed([KeyCode::KeyD, KeyCode::ArrowRight]) { player.set_playing(false); - let target_time = - timeline.target_time() + time.delta_secs(); - timeline.set_target_time(target_time); + timeline.advance_time(time.delta()); } if keys.any_pressed([KeyCode::KeyA, KeyCode::ArrowLeft]) { player.set_playing(false); - let target_time = - timeline.target_time() - time.delta_secs(); - timeline.set_target_time(target_time); + timeline.rewind_time(time.delta()); } if keys.just_pressed(KeyCode::Space) { diff --git a/examples/vello_winit_example/examples/lissajous.rs b/examples/vello_winit_example/examples/lissajous.rs index 11173e12..38395f67 100644 --- a/examples/vello_winit_example/examples/lissajous.rs +++ b/examples/vello_winit_example/examples/lissajous.rs @@ -1,5 +1,5 @@ use core::f64; -use std::time::Instant; +use std::time::{Duration, Instant}; use kurbo::{Affine, BezPath, Vec2}; use motiongfx::prelude::*; @@ -16,9 +16,9 @@ const CELL_H: f64 = 113.0; const CURVE_R: f64 = 42.0; const REF_R: f64 = 36.0; const DELTA: f64 = f64::consts::PI / 4.0; -const DRAW_DUR: f32 = 1.5; -const HOLD_DUR: f32 = 0.8; -const STAGGER: f32 = 0.08; // per diagonal +const DRAW_DUR: Duration = cs(150); +const HOLD_DUR: Duration = cs(80); +const STAGGER: Duration = cs(8); // per diagonal fn curve_color(c: usize) -> Color { let t = (c - 1) as f64 / (N_X - 1) as f64; @@ -90,8 +90,8 @@ struct LissajousTableDemo { world: TableWorld, start: Instant, timeline: Timeline, - grid_duration: f32, - curve_duration: f32, + grid_duration: Duration, + curve_duration: Duration, window_size: kurbo::Size, } @@ -169,22 +169,22 @@ impl LissajousTableDemo { let v = vert_entries.get(i).map(|&(id, p1)| { b.act(id, path!(::line::p1), move |_| p1) .with_ease(ease::cubic::ease_in_out) - .play(0.6) + .play(cs(60)) }); let h = horiz_entries.get(i).map(|&(id, p1)| { b.act(id, path!(::line::p1), move |_| p1) .with_ease(ease::cubic::ease_in_out) - .play(0.6) + .play(cs(60)) }); pair_tracks.push(match (v, h) { - (Some(v), Some(h)) => [v, h].ord_flow(0.025), + (Some(v), Some(h)) => [v, h].ord_flow(ms(25)), (Some(v), None) => v, (None, Some(h)) => h, (None, None) => unreachable!(), }); } let grid_track = - pair_tracks.into_iter().ord_flow(0.05).compile(); + pair_tracks.into_iter().ord_flow(cs(5)).compile(); b.add_tracks(grid_track); // Track 1: curves draw in and out, looped by the caller. @@ -263,17 +263,21 @@ impl VelloDemo for LissajousTableDemo { scene: &mut vello::Scene, scale_factor: f64, ) { - let elapsed = self.start.elapsed().as_secs_f32(); + let elapsed = self.start.elapsed(); // Track 0 plays once; once done we lock to track 1 and loop it. if elapsed < self.grid_duration { self.timeline.set_target_track(0); self.timeline.set_target_time(elapsed); } else { + // `Duration` has no `Rem`, so wrap through nanoseconds. + let into_loop = (elapsed - self.grid_duration).as_nanos(); + let period = self.curve_duration.as_nanos().max(1); + self.timeline.set_target_track(1); - self.timeline.set_target_time( - (elapsed - self.grid_duration) % self.curve_duration, - ); + self.timeline.set_target_time(Duration::from_nanos( + (into_loop % period) as u64, + )); } self.timeline.queue_actions(); self.timeline