Skip to main content

mulciber_runtime/
timing.rs

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/// Configuration for fixed simulation and variable-rate rendering.
9#[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    /// Creates a fixed-rate configuration with bounded catch-up defaults.
18    ///
19    /// The default maximum accepted frame delta is 250 milliseconds and at most eight fixed steps
20    /// may run in one frame. Whole fixed steps beyond that budget are discarded to avoid a spiral
21    /// of death, and the discarded duration is reported by [`FramePlan::dropped_time`].
22    ///
23    /// # Errors
24    ///
25    /// Returns [`RuntimeConfigError::ZeroUpdateRate`] when `updates_per_second` is zero, or
26    /// [`RuntimeConfigError::UpdateRateTooHigh`] when its period is below `Duration` resolution.
27    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    /// Replaces the largest wall-clock delta accepted for one frame.
43    ///
44    /// # Errors
45    ///
46    /// Returns [`RuntimeConfigError::ZeroMaxFrameDelta`] when the duration is zero.
47    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    /// Replaces the maximum number of fixed updates allowed in one frame.
59    ///
60    /// # Errors
61    ///
62    /// Returns [`RuntimeConfigError::ZeroMaxFixedSteps`] when the limit is zero.
63    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    /// Returns the duration of one fixed simulation update.
75    #[must_use]
76    pub const fn fixed_step(self) -> Duration {
77        self.fixed_step
78    }
79
80    /// Returns the largest wall-clock delta accepted for one frame.
81    #[must_use]
82    pub const fn max_frame_delta(self) -> Duration {
83        self.max_frame_delta
84    }
85
86    /// Returns the maximum number of fixed updates allowed in one frame.
87    #[must_use]
88    pub const fn max_fixed_steps(self) -> u32 {
89        self.max_fixed_steps
90    }
91}
92
93/// An invalid runtime timing configuration.
94#[derive(Clone, Copy, Debug, Eq, PartialEq)]
95pub enum RuntimeConfigError {
96    /// The requested fixed update rate was zero.
97    ZeroUpdateRate,
98    /// The requested update rate cannot be represented by a nonzero [`Duration`].
99    UpdateRateTooHigh,
100    /// The maximum accepted frame delta was zero.
101    ZeroMaxFrameDelta,
102    /// The maximum number of fixed updates per frame was zero.
103    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/// Work scheduled for one rendered frame.
120#[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    /// Returns the clamped variable-rate frame delta.
131    #[must_use]
132    pub const fn frame_delta(self) -> Duration {
133        self.frame_delta
134    }
135
136    /// Returns the duration to use for every fixed simulation update.
137    #[must_use]
138    pub const fn fixed_step(self) -> Duration {
139        self.fixed_step
140    }
141
142    /// Returns how many fixed simulation updates should run before rendering.
143    #[must_use]
144    pub const fn fixed_steps(self) -> u32 {
145        self.fixed_steps
146    }
147
148    /// Returns the fractional position between the previous and current simulation states.
149    ///
150    /// After running [`Self::fixed_steps`] updates, render state as
151    /// `previous.lerp(current, interpolation)` to remain smooth at arbitrary presentation rates.
152    #[must_use]
153    pub const fn interpolation(self) -> f64 {
154        self.interpolation
155    }
156
157    /// Returns wall-clock time discarded by frame clamping or the catch-up step limit.
158    #[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}