mulciber_runtime/
timing.rs1use 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#[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 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}