Skip to main content

mulciber_runtime/
timing.rs

1use std::error::Error;
2use std::fmt;
3use std::time::Duration;
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    accumulator: Duration,
168    suspended: bool,
169}
170
171impl FrameClock {
172    pub(crate) const fn new(config: RuntimeConfig) -> Self {
173        Self {
174            config,
175            accumulator: Duration::ZERO,
176            suspended: false,
177        }
178    }
179
180    pub(crate) const fn suspend(&mut self) {
181        self.suspended = true;
182    }
183
184    pub(crate) const fn resume(&mut self) {
185        self.suspended = false;
186    }
187
188    pub(crate) const fn suspended(&self) -> bool {
189        self.suspended
190    }
191
192    pub(crate) fn idle_plan(&self) -> FramePlan {
193        FramePlan {
194            frame_delta: Duration::ZERO,
195            fixed_step: self.config.fixed_step,
196            fixed_steps: 0,
197            interpolation: duration_ratio(self.accumulator, self.config.fixed_step),
198            dropped_time: Duration::ZERO,
199        }
200    }
201
202    pub(crate) fn advance_by(&mut self, elapsed: Duration) -> FramePlan {
203        let frame_delta = elapsed.min(self.config.max_frame_delta);
204        let mut dropped_time = elapsed.saturating_sub(frame_delta);
205        self.accumulator += frame_delta;
206
207        let available_steps = duration_ratio_floor(self.accumulator, self.config.fixed_step);
208        let fixed_steps =
209            u32::try_from(available_steps.min(u128::from(self.config.max_fixed_steps)))
210                .expect("fixed step count is bounded by a u32 configuration value");
211        self.accumulator -= self.config.fixed_step * fixed_steps;
212
213        let excess_steps = available_steps.saturating_sub(u128::from(fixed_steps));
214        if excess_steps != 0 {
215            let remainder_nanos = self.accumulator.as_nanos() % self.config.fixed_step.as_nanos();
216            let remainder = Duration::from_nanos(
217                u64::try_from(remainder_nanos)
218                    .expect("fixed-step remainder is less than one second"),
219            );
220            let excess_duration = self
221                .accumulator
222                .checked_sub(remainder)
223                .expect("fixed-step remainder cannot exceed the accumulator");
224            self.accumulator = remainder;
225            dropped_time += excess_duration;
226        }
227
228        FramePlan {
229            frame_delta,
230            fixed_step: self.config.fixed_step,
231            fixed_steps,
232            interpolation: duration_ratio(self.accumulator, self.config.fixed_step),
233            dropped_time,
234        }
235    }
236}
237
238fn duration_ratio_floor(numerator: Duration, denominator: Duration) -> u128 {
239    numerator.as_nanos() / denominator.as_nanos()
240}
241
242#[allow(clippy::cast_precision_loss)]
243fn duration_ratio(numerator: Duration, denominator: Duration) -> f64 {
244    numerator.as_secs_f64() / denominator.as_secs_f64()
245}
246
247#[cfg(test)]
248mod tests {
249    use std::time::Duration;
250
251    use super::{FrameClock, RuntimeConfig, RuntimeConfigError};
252
253    #[test]
254    fn rejects_zero_limits() {
255        assert_eq!(
256            RuntimeConfig::fixed_hz(0),
257            Err(RuntimeConfigError::ZeroUpdateRate)
258        );
259        assert_eq!(
260            RuntimeConfig::fixed_hz(u32::MAX),
261            Err(RuntimeConfigError::UpdateRateTooHigh)
262        );
263        assert_eq!(
264            RuntimeConfig::fixed_hz(60)
265                .unwrap()
266                .with_max_frame_delta(Duration::ZERO),
267            Err(RuntimeConfigError::ZeroMaxFrameDelta)
268        );
269        assert_eq!(
270            RuntimeConfig::fixed_hz(60).unwrap().with_max_fixed_steps(0),
271            Err(RuntimeConfigError::ZeroMaxFixedSteps)
272        );
273    }
274
275    #[test]
276    fn accumulates_fixed_steps_and_render_interpolation() {
277        let config = RuntimeConfig::fixed_hz(10).unwrap();
278        let mut clock = FrameClock::new(config);
279
280        let first = clock.advance_by(Duration::from_millis(40));
281        assert_eq!(first.fixed_steps(), 0);
282        assert!((first.interpolation() - 0.4).abs() < f64::EPSILON);
283
284        let second = clock.advance_by(Duration::from_millis(85));
285        assert_eq!(second.fixed_steps(), 1);
286        assert!((second.interpolation() - 0.25).abs() < f64::EPSILON);
287    }
288
289    #[test]
290    fn clamps_long_frames_and_discards_excess_catch_up_steps() {
291        let config = RuntimeConfig::fixed_hz(100)
292            .unwrap()
293            .with_max_frame_delta(Duration::from_millis(100))
294            .unwrap()
295            .with_max_fixed_steps(3)
296            .unwrap();
297        let mut clock = FrameClock::new(config);
298
299        let plan = clock.advance_by(Duration::from_millis(250));
300        assert_eq!(plan.frame_delta(), Duration::from_millis(100));
301        assert_eq!(plan.fixed_steps(), 3);
302        assert_eq!(plan.dropped_time(), Duration::from_millis(220));
303        assert!(plan.interpolation().abs() < f64::EPSILON);
304    }
305
306    #[test]
307    fn the_idle_plan_freezes_time_and_preserves_interpolation() {
308        let config = RuntimeConfig::fixed_hz(10).unwrap();
309        let mut clock = FrameClock::new(config);
310        let before = clock.advance_by(Duration::from_millis(40));
311        assert!((before.interpolation() - 0.4).abs() < f64::EPSILON);
312
313        clock.suspend();
314        assert!(clock.suspended());
315        let suspended = clock.idle_plan();
316        assert_eq!(suspended.frame_delta(), Duration::ZERO);
317        assert_eq!(suspended.fixed_steps(), 0);
318        assert!((suspended.interpolation() - 0.4).abs() < f64::EPSILON);
319
320        clock.resume();
321        let resumed = clock.advance_by(Duration::from_millis(60));
322        assert_eq!(resumed.fixed_steps(), 1);
323        assert!(resumed.interpolation().abs() < f64::EPSILON);
324        assert_eq!(resumed.dropped_time(), Duration::ZERO);
325    }
326}