Skip to main content

loam_time/
fixed_timestep.rs

1use std::ops::Range;
2use std::time::Duration;
3// `std::time::Instant::now` panics on wasm32.
4use web_time::Instant;
5
6/// Per-frame catch-up cap; excess ticks are dropped.
7pub const DEFAULT_MAX_CATCH_UP: u32 = 10;
8
9#[derive(Debug, Clone)]
10pub struct FixedTimestep {
11    dt: Duration,
12    accumulator: Duration,
13    last_instant: Option<Instant>,
14    tick: u64,
15    max_catch_up: u32,
16}
17
18impl FixedTimestep {
19    /// Panics outside `1..=1_000_000_000` Hz, the range supported by nanosecond ticks.
20    pub fn new(hz: u32) -> Self {
21        assert!(
22            (1..=1_000_000_000).contains(&hz),
23            "tick rate must be between 1 and 1000000000 Hz"
24        );
25        Self {
26            dt: Duration::from_nanos(1_000_000_000 / u64::from(hz)),
27            accumulator: Duration::ZERO,
28            last_instant: None,
29            tick: 0,
30            max_catch_up: DEFAULT_MAX_CATCH_UP,
31        }
32    }
33
34    pub fn with_max_catch_up(mut self, n: u32) -> Self {
35        self.max_catch_up = n;
36        self
37    }
38
39    /// Discards elapsed and fractional time while preserving the next tick index.
40    pub fn reset_clock(&mut self, now: Instant) {
41        self.last_instant = Some(now);
42        self.accumulator = Duration::ZERO;
43    }
44
45    pub fn tick(&self) -> u64 {
46        self.tick
47    }
48
49    pub fn dt(&self) -> Duration {
50        self.dt
51    }
52
53    pub fn dt_seconds(&self) -> f32 {
54        self.dt.as_secs_f32()
55    }
56
57    /// Fraction of the pending tick elapsed, rounded to `[0, 1]`.
58    pub fn alpha(&self) -> f32 {
59        let a = self.accumulator.as_secs_f64() / self.dt.as_secs_f64();
60        (a as f32).clamp(0.0, 1.0)
61    }
62
63    /// The first call only primes the clock; ticks past `max_catch_up` are dropped.
64    pub fn advance(&mut self, now: Instant) -> Range<u64> {
65        let last = match self.last_instant.replace(now) {
66            Some(t) => t,
67            None => return self.tick..self.tick,
68        };
69
70        self.accumulator += now.saturating_duration_since(last);
71
72        let start = self.tick;
73        let pending = self.accumulator.as_nanos() / self.dt.as_nanos();
74        self.tick += pending.min(u128::from(self.max_catch_up)) as u64;
75
76        self.accumulator =
77            Duration::from_nanos((self.accumulator.as_nanos() % self.dt.as_nanos()) as u64);
78
79        start..self.tick
80    }
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86
87    #[test]
88    fn reset_clock_drops_pause_time_without_rewinding_ticks() {
89        let mut clock = FixedTimestep::new(100);
90        let start = Instant::now();
91        clock.advance(start);
92        clock.advance(start + Duration::from_millis(15));
93        let resumed = start + Duration::from_secs(60);
94        clock.reset_clock(resumed);
95        assert_eq!(clock.advance(resumed), 1..1);
96        assert_eq!(clock.advance(resumed + clock.dt()), 1..2);
97    }
98
99    #[test]
100    fn tick_boundaries_carry_fractional_time() {
101        let mut clock = FixedTimestep::new(100);
102        let start = Instant::now();
103        let dt = clock.dt();
104        assert_eq!(clock.advance(start), 0..0);
105        assert_eq!(clock.advance(start + dt / 2), 0..0);
106        assert_eq!(clock.alpha(), 0.5);
107        assert_eq!(clock.advance(start + dt), 0..1);
108        assert_eq!(clock.alpha(), 0.0);
109        assert_eq!(clock.advance(start + dt * 3 + dt / 2), 1..3);
110        assert_eq!(clock.alpha(), 0.5);
111        assert_eq!(clock.tick(), 3);
112    }
113
114    #[test]
115    fn catch_up_drops_backlog_but_keeps_remainder() {
116        let mut clock = FixedTimestep::new(100).with_max_catch_up(5);
117        let start = Instant::now();
118        clock.advance(start);
119        let resumed = start + Duration::from_secs(86_400) + clock.dt() / 2;
120        assert_eq!(clock.advance(resumed), 0..5);
121        assert_eq!(clock.alpha(), 0.5);
122        assert_eq!(clock.advance(resumed), 5..5);
123        assert_eq!(clock.advance(resumed + clock.dt() / 2), 5..6);
124    }
125
126    #[test]
127    fn zero_catch_up_discards_whole_ticks() {
128        let mut clock = FixedTimestep::new(100).with_max_catch_up(0);
129        let start = Instant::now();
130        clock.advance(start);
131        assert_eq!(clock.advance(start + Duration::from_millis(15)), 0..0);
132        assert_eq!(clock.alpha(), 0.5);
133    }
134
135    #[test]
136    fn nanosecond_ticks_advance() {
137        let mut clock = FixedTimestep::new(1_000_000_000);
138        let start = Instant::now();
139        clock.advance(start);
140        assert_eq!(clock.advance(start + Duration::from_nanos(1)), 0..1);
141    }
142
143    #[test]
144    #[should_panic(expected = "tick rate must be between")]
145    fn zero_tick_rate_is_rejected() {
146        FixedTimestep::new(0);
147    }
148
149    #[test]
150    #[should_panic(expected = "tick rate must be between")]
151    fn subnanosecond_ticks_are_rejected() {
152        FixedTimestep::new(1_000_000_001);
153    }
154}