loam_time/
fixed_timestep.rs1use std::ops::Range;
2use std::time::Duration;
3use web_time::Instant;
5
6pub 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 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 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 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 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}