1use std::error::Error;
2use std::fmt;
3use std::time::{Duration, Instant};
4
5const DEFAULT_MAX_FRAME_DELTA: Duration = Duration::from_millis(250);
6const DEFAULT_MAX_FIXED_STEPS: u32 = 8;
7
8#[derive(Clone, Copy, Debug, Eq, PartialEq)]
10pub struct RuntimeConfig {
11 fixed_step: Duration,
12 max_frame_delta: Duration,
13 max_fixed_steps: u32,
14}
15
16impl RuntimeConfig {
17 pub fn fixed_hz(updates_per_second: u32) -> Result<Self, RuntimeConfigError> {
28 if updates_per_second == 0 {
29 return Err(RuntimeConfigError::ZeroUpdateRate);
30 }
31 let fixed_step = Duration::from_secs_f64(1.0 / f64::from(updates_per_second));
32 if fixed_step.is_zero() {
33 return Err(RuntimeConfigError::UpdateRateTooHigh);
34 }
35 Ok(Self {
36 fixed_step,
37 max_frame_delta: DEFAULT_MAX_FRAME_DELTA,
38 max_fixed_steps: DEFAULT_MAX_FIXED_STEPS,
39 })
40 }
41
42 pub fn with_max_frame_delta(
48 mut self,
49 max_frame_delta: Duration,
50 ) -> Result<Self, RuntimeConfigError> {
51 if max_frame_delta.is_zero() {
52 return Err(RuntimeConfigError::ZeroMaxFrameDelta);
53 }
54 self.max_frame_delta = max_frame_delta;
55 Ok(self)
56 }
57
58 pub fn with_max_fixed_steps(
64 mut self,
65 max_fixed_steps: u32,
66 ) -> Result<Self, RuntimeConfigError> {
67 if max_fixed_steps == 0 {
68 return Err(RuntimeConfigError::ZeroMaxFixedSteps);
69 }
70 self.max_fixed_steps = max_fixed_steps;
71 Ok(self)
72 }
73
74 #[must_use]
76 pub const fn fixed_step(self) -> Duration {
77 self.fixed_step
78 }
79
80 #[must_use]
82 pub const fn max_frame_delta(self) -> Duration {
83 self.max_frame_delta
84 }
85
86 #[must_use]
88 pub const fn max_fixed_steps(self) -> u32 {
89 self.max_fixed_steps
90 }
91}
92
93#[derive(Clone, Copy, Debug, Eq, PartialEq)]
95pub enum RuntimeConfigError {
96 ZeroUpdateRate,
98 UpdateRateTooHigh,
100 ZeroMaxFrameDelta,
102 ZeroMaxFixedSteps,
104}
105
106impl fmt::Display for RuntimeConfigError {
107 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
108 formatter.write_str(match self {
109 Self::ZeroUpdateRate => "fixed update rate must be greater than zero",
110 Self::UpdateRateTooHigh => "fixed update rate is too high to represent",
111 Self::ZeroMaxFrameDelta => "maximum frame delta must be greater than zero",
112 Self::ZeroMaxFixedSteps => "maximum fixed steps must be greater than zero",
113 })
114 }
115}
116
117impl Error for RuntimeConfigError {}
118
119#[derive(Clone, Copy, Debug, PartialEq)]
121pub struct FramePlan {
122 frame_delta: Duration,
123 fixed_step: Duration,
124 fixed_steps: u32,
125 interpolation: f64,
126 dropped_time: Duration,
127}
128
129impl FramePlan {
130 #[must_use]
132 pub const fn frame_delta(self) -> Duration {
133 self.frame_delta
134 }
135
136 #[must_use]
138 pub const fn fixed_step(self) -> Duration {
139 self.fixed_step
140 }
141
142 #[must_use]
144 pub const fn fixed_steps(self) -> u32 {
145 self.fixed_steps
146 }
147
148 #[must_use]
153 pub const fn interpolation(self) -> f64 {
154 self.interpolation
155 }
156
157 #[must_use]
159 pub const fn dropped_time(self) -> Duration {
160 self.dropped_time
161 }
162}
163
164#[derive(Debug)]
165pub(crate) struct FrameClock {
166 config: RuntimeConfig,
167 previous: Instant,
168 accumulator: Duration,
169 suspended: bool,
170}
171
172impl FrameClock {
173 pub(crate) const fn new(config: RuntimeConfig, started_at: Instant) -> Self {
174 Self {
175 config,
176 previous: started_at,
177 accumulator: Duration::ZERO,
178 suspended: false,
179 }
180 }
181
182 pub(crate) fn advance(&mut self, now: Instant) -> FramePlan {
183 if self.suspended {
184 return self.idle_plan();
185 }
186 let elapsed = now.saturating_duration_since(self.previous);
187 self.previous = now;
188 self.advance_by(elapsed)
189 }
190
191 pub(crate) const fn suspend(&mut self) {
192 self.suspended = true;
193 }
194
195 pub(crate) const fn resume(&mut self, now: Instant) {
196 self.previous = now;
197 self.suspended = false;
198 }
199
200 pub(crate) const fn suspended(&self) -> bool {
201 self.suspended
202 }
203
204 fn idle_plan(&self) -> FramePlan {
205 FramePlan {
206 frame_delta: Duration::ZERO,
207 fixed_step: self.config.fixed_step,
208 fixed_steps: 0,
209 interpolation: duration_ratio(self.accumulator, self.config.fixed_step),
210 dropped_time: Duration::ZERO,
211 }
212 }
213
214 fn advance_by(&mut self, elapsed: Duration) -> FramePlan {
215 let frame_delta = elapsed.min(self.config.max_frame_delta);
216 let mut dropped_time = elapsed.saturating_sub(frame_delta);
217 self.accumulator += frame_delta;
218
219 let available_steps = duration_ratio_floor(self.accumulator, self.config.fixed_step);
220 let fixed_steps =
221 u32::try_from(available_steps.min(u128::from(self.config.max_fixed_steps)))
222 .expect("fixed step count is bounded by a u32 configuration value");
223 self.accumulator -= self.config.fixed_step * fixed_steps;
224
225 let excess_steps = available_steps.saturating_sub(u128::from(fixed_steps));
226 if excess_steps != 0 {
227 let remainder_nanos = self.accumulator.as_nanos() % self.config.fixed_step.as_nanos();
228 let remainder = Duration::from_nanos(
229 u64::try_from(remainder_nanos)
230 .expect("fixed-step remainder is less than one second"),
231 );
232 let excess_duration = self
233 .accumulator
234 .checked_sub(remainder)
235 .expect("fixed-step remainder cannot exceed the accumulator");
236 self.accumulator = remainder;
237 dropped_time += excess_duration;
238 }
239
240 FramePlan {
241 frame_delta,
242 fixed_step: self.config.fixed_step,
243 fixed_steps,
244 interpolation: duration_ratio(self.accumulator, self.config.fixed_step),
245 dropped_time,
246 }
247 }
248}
249
250fn duration_ratio_floor(numerator: Duration, denominator: Duration) -> u128 {
251 numerator.as_nanos() / denominator.as_nanos()
252}
253
254#[allow(clippy::cast_precision_loss)]
255fn duration_ratio(numerator: Duration, denominator: Duration) -> f64 {
256 numerator.as_secs_f64() / denominator.as_secs_f64()
257}
258
259#[cfg(test)]
260mod tests {
261 use std::time::{Duration, Instant};
262
263 use super::{FrameClock, RuntimeConfig, RuntimeConfigError};
264
265 #[test]
266 fn rejects_zero_limits() {
267 assert_eq!(
268 RuntimeConfig::fixed_hz(0),
269 Err(RuntimeConfigError::ZeroUpdateRate)
270 );
271 assert_eq!(
272 RuntimeConfig::fixed_hz(u32::MAX),
273 Err(RuntimeConfigError::UpdateRateTooHigh)
274 );
275 assert_eq!(
276 RuntimeConfig::fixed_hz(60)
277 .unwrap()
278 .with_max_frame_delta(Duration::ZERO),
279 Err(RuntimeConfigError::ZeroMaxFrameDelta)
280 );
281 assert_eq!(
282 RuntimeConfig::fixed_hz(60).unwrap().with_max_fixed_steps(0),
283 Err(RuntimeConfigError::ZeroMaxFixedSteps)
284 );
285 }
286
287 #[test]
288 fn accumulates_fixed_steps_and_render_interpolation() {
289 let start = Instant::now();
290 let config = RuntimeConfig::fixed_hz(10).unwrap();
291 let mut clock = FrameClock::new(config, start);
292
293 let first = clock.advance(start + Duration::from_millis(40));
294 assert_eq!(first.fixed_steps(), 0);
295 assert!((first.interpolation() - 0.4).abs() < f64::EPSILON);
296
297 let second = clock.advance(start + Duration::from_millis(125));
298 assert_eq!(second.fixed_steps(), 1);
299 assert!((second.interpolation() - 0.25).abs() < f64::EPSILON);
300 }
301
302 #[test]
303 fn clamps_long_frames_and_discards_excess_catch_up_steps() {
304 let start = Instant::now();
305 let config = RuntimeConfig::fixed_hz(100)
306 .unwrap()
307 .with_max_frame_delta(Duration::from_millis(100))
308 .unwrap()
309 .with_max_fixed_steps(3)
310 .unwrap();
311 let mut clock = FrameClock::new(config, start);
312
313 let plan = clock.advance(start + Duration::from_millis(250));
314 assert_eq!(plan.frame_delta(), Duration::from_millis(100));
315 assert_eq!(plan.fixed_steps(), 3);
316 assert_eq!(plan.dropped_time(), Duration::from_millis(220));
317 assert!(plan.interpolation().abs() < f64::EPSILON);
318 }
319
320 #[test]
321 fn suspension_freezes_time_and_preserves_interpolation() {
322 let start = Instant::now();
323 let config = RuntimeConfig::fixed_hz(10).unwrap();
324 let mut clock = FrameClock::new(config, start);
325 let before = clock.advance(start + Duration::from_millis(40));
326 assert!((before.interpolation() - 0.4).abs() < f64::EPSILON);
327
328 clock.suspend();
329 let suspended = clock.advance(start + Duration::from_mins(1));
330 assert_eq!(suspended.frame_delta(), Duration::ZERO);
331 assert_eq!(suspended.fixed_steps(), 0);
332 assert!((suspended.interpolation() - 0.4).abs() < f64::EPSILON);
333
334 let resumed_at = start + Duration::from_mins(2);
335 clock.resume(resumed_at);
336 let resumed = clock.advance(resumed_at + Duration::from_millis(60));
337 assert_eq!(resumed.fixed_steps(), 1);
338 assert!(resumed.interpolation().abs() < f64::EPSILON);
339 assert_eq!(resumed.dropped_time(), Duration::ZERO);
340 }
341}