1#[must_use]
26pub const fn dom_timestamp_to_seconds(timestamp_ms: f64) -> f64 {
27 timestamp_ms / 1000.0
28}
29
30#[must_use]
40pub const fn seconds_to_dom_timestamp(seconds: f64) -> f64 {
41 seconds * 1000.0
42}
43
44#[must_use]
66pub const fn calculate_delta_time(current_ms: f64, previous_ms: f64) -> f64 {
67 (current_ms - previous_ms) / 1000.0
68}
69
70#[must_use]
84#[allow(clippy::missing_const_for_fn)] pub fn clamp_delta_time(dt: f64, max_dt: f64) -> f64 {
86 dt.min(max_dt).max(0.0)
87}
88
89pub const DEFAULT_MAX_DELTA_TIME: f64 = 0.1;
94
95pub const TARGET_DT_60FPS: f64 = 1.0 / 60.0;
97
98pub const TARGET_DT_30FPS: f64 = 1.0 / 30.0;
100
101pub const TARGET_DT_120FPS: f64 = 1.0 / 120.0;
103
104#[derive(Debug, Clone)]
106pub struct FrameTimer {
107 last_timestamp_ms: Option<f64>,
109 accumulator: f64,
111 fixed_dt: f64,
113 max_dt: f64,
115 total_time: f64,
117 frame_count: u64,
119}
120
121impl Default for FrameTimer {
122 fn default() -> Self {
123 Self::new()
124 }
125}
126
127impl FrameTimer {
128 #[must_use]
130 pub const fn new() -> Self {
131 Self {
132 last_timestamp_ms: None,
133 accumulator: 0.0,
134 fixed_dt: TARGET_DT_60FPS,
135 max_dt: DEFAULT_MAX_DELTA_TIME,
136 total_time: 0.0,
137 frame_count: 0,
138 }
139 }
140
141 #[must_use]
143 pub const fn with_fixed_dt(fixed_dt: f64) -> Self {
144 Self {
145 last_timestamp_ms: None,
146 accumulator: 0.0,
147 fixed_dt,
148 max_dt: DEFAULT_MAX_DELTA_TIME,
149 total_time: 0.0,
150 frame_count: 0,
151 }
152 }
153
154 #[allow(clippy::missing_const_for_fn)] pub fn set_max_dt(&mut self, max_dt: f64) {
157 self.max_dt = max_dt;
158 }
159
160 #[allow(clippy::missing_const_for_fn)] pub fn set_fixed_dt(&mut self, fixed_dt: f64) {
163 self.fixed_dt = fixed_dt;
164 }
165
166 pub fn update(&mut self, timestamp_ms: f64) -> f64 {
176 let dt = self
177 .last_timestamp_ms
178 .map_or(0.0, |last| calculate_delta_time(timestamp_ms, last));
179
180 self.last_timestamp_ms = Some(timestamp_ms);
181 let clamped_dt = clamp_delta_time(dt, self.max_dt);
182 self.total_time += clamped_dt;
183 self.frame_count += 1;
184 self.accumulator += clamped_dt;
185
186 clamped_dt
187 }
188
189 pub fn consume_fixed_step(&mut self) -> Option<f64> {
203 if self.accumulator >= self.fixed_dt {
204 self.accumulator -= self.fixed_dt;
205 Some(self.fixed_dt)
206 } else {
207 None
208 }
209 }
210
211 #[must_use]
216 pub fn interpolation_alpha(&self) -> f64 {
217 if self.fixed_dt > 0.0 {
218 self.accumulator / self.fixed_dt
219 } else {
220 0.0
221 }
222 }
223
224 #[must_use]
226 pub const fn total_time(&self) -> f64 {
227 self.total_time
228 }
229
230 #[must_use]
232 pub const fn frame_count(&self) -> u64 {
233 self.frame_count
234 }
235
236 #[must_use]
238 pub fn average_fps(&self) -> f64 {
239 if self.total_time > 0.0 {
240 self.frame_count as f64 / self.total_time
241 } else {
242 0.0
243 }
244 }
245
246 #[must_use]
248 pub const fn accumulator(&self) -> f64 {
249 self.accumulator
250 }
251
252 #[must_use]
254 pub const fn fixed_dt(&self) -> f64 {
255 self.fixed_dt
256 }
257
258 #[allow(clippy::missing_const_for_fn)] pub fn reset(&mut self) {
261 self.last_timestamp_ms = None;
262 self.accumulator = 0.0;
263 self.total_time = 0.0;
264 self.frame_count = 0;
265 }
266}
267
268#[cfg(test)]
269#[allow(
270 clippy::unwrap_used,
271 clippy::expect_used,
272 clippy::cast_lossless,
273 clippy::manual_range_contains
274)]
275mod tests {
276 use super::*;
277
278 #[test]
279 fn test_dom_timestamp_to_seconds() {
280 assert!((dom_timestamp_to_seconds(0.0) - 0.0).abs() < f64::EPSILON);
281 assert!((dom_timestamp_to_seconds(1000.0) - 1.0).abs() < f64::EPSILON);
282 assert!((dom_timestamp_to_seconds(16.667) - 0.016_667).abs() < 1e-9);
283 assert!((dom_timestamp_to_seconds(100_000.0) - 100.0).abs() < f64::EPSILON);
284 }
285
286 #[test]
287 fn test_seconds_to_dom_timestamp() {
288 assert!((seconds_to_dom_timestamp(0.0) - 0.0).abs() < f64::EPSILON);
289 assert!((seconds_to_dom_timestamp(1.0) - 1000.0).abs() < f64::EPSILON);
290 assert!((seconds_to_dom_timestamp(0.016_667) - 16.667).abs() < 1e-6);
291 }
292
293 #[test]
294 fn test_roundtrip_conversion() {
295 let original = 12345.678;
296 let seconds = dom_timestamp_to_seconds(original);
297 let back = seconds_to_dom_timestamp(seconds);
298 assert!((back - original).abs() < 1e-9);
299 }
300
301 #[test]
302 fn test_calculate_delta_time() {
303 let dt = calculate_delta_time(1016.667, 1000.0);
305 assert!((dt - 0.016_667).abs() < 1e-6);
306
307 let dt = calculate_delta_time(1033.333, 1000.0);
309 assert!((dt - 0.033_333).abs() < 1e-6);
310
311 let dt = calculate_delta_time(1000.0, 1000.0);
313 assert!(dt.abs() < f64::EPSILON);
314 }
315
316 #[test]
317 fn test_clamp_delta_time() {
318 assert!((clamp_delta_time(0.016_667, 0.1) - 0.016_667).abs() < 1e-9);
320
321 assert!((clamp_delta_time(0.5, 0.1) - 0.1).abs() < f64::EPSILON);
323
324 assert!((clamp_delta_time(-0.1, 0.1) - 0.0).abs() < f64::EPSILON);
326
327 assert!(clamp_delta_time(0.0, 0.1).abs() < f64::EPSILON);
329 }
330
331 #[test]
332 fn test_constants() {
333 assert!((DEFAULT_MAX_DELTA_TIME - 0.1).abs() < f64::EPSILON);
334 assert!((TARGET_DT_60FPS - 1.0 / 60.0).abs() < 1e-9);
335 assert!((TARGET_DT_30FPS - 1.0 / 30.0).abs() < 1e-9);
336 assert!((TARGET_DT_120FPS - 1.0 / 120.0).abs() < 1e-9);
337 }
338
339 #[test]
340 fn test_frame_timer_new() {
341 let timer = FrameTimer::new();
342 assert!(timer.last_timestamp_ms.is_none());
343 assert!(timer.accumulator.abs() < f64::EPSILON);
344 assert!((timer.fixed_dt - TARGET_DT_60FPS).abs() < f64::EPSILON);
345 assert!((timer.max_dt - DEFAULT_MAX_DELTA_TIME).abs() < f64::EPSILON);
346 assert!(timer.total_time.abs() < f64::EPSILON);
347 assert_eq!(timer.frame_count, 0);
348 }
349
350 #[test]
351 fn test_frame_timer_default() {
352 let timer = FrameTimer::default();
353 assert!(timer.last_timestamp_ms.is_none());
354 }
355
356 #[test]
357 fn test_frame_timer_with_fixed_dt() {
358 let timer = FrameTimer::with_fixed_dt(TARGET_DT_30FPS);
359 assert!((timer.fixed_dt - TARGET_DT_30FPS).abs() < f64::EPSILON);
360 }
361
362 #[test]
363 fn test_frame_timer_set_max_dt() {
364 let mut timer = FrameTimer::new();
365 timer.set_max_dt(0.05);
366 assert!((timer.max_dt - 0.05).abs() < f64::EPSILON);
367 }
368
369 #[test]
370 fn test_frame_timer_set_fixed_dt() {
371 let mut timer = FrameTimer::new();
372 timer.set_fixed_dt(TARGET_DT_30FPS);
373 assert!((timer.fixed_dt - TARGET_DT_30FPS).abs() < f64::EPSILON);
374 }
375
376 #[test]
377 fn test_frame_timer_first_update() {
378 let mut timer = FrameTimer::new();
379 let dt = timer.update(1000.0);
380
381 assert!(dt.abs() < f64::EPSILON);
383 assert_eq!(timer.frame_count, 1);
384 assert!(timer.last_timestamp_ms.is_some());
385 }
386
387 #[test]
388 fn test_frame_timer_normal_updates() {
389 let mut timer = FrameTimer::new();
390
391 let _ = timer.update(0.0);
393
394 let dt1 = timer.update(16.667);
396 assert!((dt1 - 0.016_667).abs() < 1e-6);
397
398 let dt2 = timer.update(33.333);
399 assert!((dt2 - 0.016_666).abs() < 1e-5);
400
401 assert_eq!(timer.frame_count, 3);
402 assert!(timer.total_time > 0.03);
403 }
404
405 #[test]
406 fn test_frame_timer_clamps_large_dt() {
407 let mut timer = FrameTimer::new();
408 let _ = timer.update(0.0);
409
410 let dt = timer.update(500.0);
412
413 assert!((dt - DEFAULT_MAX_DELTA_TIME).abs() < f64::EPSILON);
415 }
416
417 #[test]
418 fn test_frame_timer_total_time() {
419 let mut timer = FrameTimer::new();
420 let _ = timer.update(0.0);
421 let _ = timer.update(1000.0); assert!((timer.total_time() - 0.1).abs() < f64::EPSILON); }
425
426 #[test]
427 fn test_frame_timer_consume_fixed_step() {
428 let mut timer = FrameTimer::with_fixed_dt(TARGET_DT_60FPS);
429 let _ = timer.update(0.0);
430
431 let _ = timer.update(34.0);
433
434 let step1 = timer.consume_fixed_step();
436 assert!(step1.is_some());
437 assert!((step1.unwrap() - TARGET_DT_60FPS).abs() < f64::EPSILON);
438
439 let step2 = timer.consume_fixed_step();
440 assert!(step2.is_some());
441
442 let step3 = timer.consume_fixed_step();
444 assert!(step3.is_none());
445 }
446
447 #[test]
448 fn test_frame_timer_interpolation_alpha() {
449 let mut timer = FrameTimer::with_fixed_dt(TARGET_DT_60FPS);
450 let _ = timer.update(0.0);
451
452 timer.accumulator = TARGET_DT_60FPS / 2.0;
454
455 let alpha = timer.interpolation_alpha();
456 assert!((alpha - 0.5).abs() < 1e-6);
457 }
458
459 #[test]
460 fn test_frame_timer_interpolation_alpha_zero_fixed_dt() {
461 let mut timer = FrameTimer::new();
462 timer.fixed_dt = 0.0;
463
464 let alpha = timer.interpolation_alpha();
465 assert!(alpha.abs() < f64::EPSILON);
466 }
467
468 #[test]
469 fn test_frame_timer_average_fps() {
470 let mut timer = FrameTimer::new();
471 let _ = timer.update(0.0);
472
473 for i in 1..=60 {
475 let _ = timer.update((i as f64) * 16.667);
476 }
477
478 let fps = timer.average_fps();
479 assert!(fps > 50.0 && fps < 70.0);
481 }
482
483 #[test]
484 fn test_frame_timer_average_fps_no_time() {
485 let timer = FrameTimer::new();
486 let fps = timer.average_fps();
487 assert!(fps.abs() < f64::EPSILON);
488 }
489
490 #[test]
491 fn test_frame_timer_accumulator() {
492 let mut timer = FrameTimer::new();
493 let _ = timer.update(0.0);
494 let _ = timer.update(10.0); assert!((timer.accumulator() - 0.01).abs() < 1e-6);
497 }
498
499 #[test]
500 fn test_frame_timer_fixed_dt_getter() {
501 let timer = FrameTimer::with_fixed_dt(0.02);
502 assert!((timer.fixed_dt() - 0.02).abs() < f64::EPSILON);
503 }
504
505 #[test]
506 fn test_frame_timer_reset() {
507 let mut timer = FrameTimer::new();
508 let _ = timer.update(0.0);
509 let _ = timer.update(1000.0);
510
511 timer.reset();
512
513 assert!(timer.last_timestamp_ms.is_none());
514 assert!(timer.accumulator.abs() < f64::EPSILON);
515 assert!(timer.total_time.abs() < f64::EPSILON);
516 assert_eq!(timer.frame_count, 0);
517 assert!((timer.fixed_dt - TARGET_DT_60FPS).abs() < f64::EPSILON);
519 }
520
521 #[test]
522 fn test_frame_timer_game_loop_simulation() {
523 let mut timer = FrameTimer::with_fixed_dt(TARGET_DT_60FPS);
524 let mut physics_steps = 0;
525
526 let timestamps: [f64; 5] = [0.0, 16.0, 32.0, 48.0, 64.0];
528
529 for &ts in ×tamps {
530 let _dt = timer.update(ts);
531
532 while timer.consume_fixed_step().is_some() {
534 physics_steps += 1;
535 }
536 }
537
538 assert!(physics_steps >= 3 && physics_steps <= 5);
540 }
541}
542
543#[cfg(test)]
544#[allow(clippy::manual_range_contains, clippy::unwrap_used)]
545mod property_tests {
546 use super::*;
547 use proptest::prelude::*;
548
549 proptest! {
550 #[test]
552 fn property_timestamp_roundtrip(ms in 0.0f64..1_000_000.0) {
553 let seconds = dom_timestamp_to_seconds(ms);
554 let back = seconds_to_dom_timestamp(seconds);
555 prop_assert!((ms - back).abs() < 1e-9, "Roundtrip failed: {} -> {} -> {}", ms, seconds, back);
556 }
557
558 #[test]
560 fn property_clamped_dt_non_negative(dt in -10.0f64..10.0) {
561 let clamped = clamp_delta_time(dt, DEFAULT_MAX_DELTA_TIME);
562 prop_assert!(clamped >= 0.0, "Clamped dt should be non-negative: {}", clamped);
563 }
564
565 #[test]
567 fn property_clamped_dt_bounded(dt in 0.0f64..1.0, max_dt in 0.001f64..0.5) {
568 let clamped = clamp_delta_time(dt, max_dt);
569 prop_assert!(clamped <= max_dt, "Clamped {} should be <= {}", clamped, max_dt);
570 }
571
572 #[test]
574 fn property_total_time_monotonic(timestamps in proptest::collection::vec(0.0f64..10000.0, 2..20)) {
575 let mut timer = FrameTimer::new();
576 let mut prev_total = 0.0;
577
578 let mut sorted = timestamps;
580 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
581
582 for ts in sorted {
583 let _ = timer.update(ts);
584 prop_assert!(timer.total_time >= prev_total,
585 "Total time should be monotonic: {} -> {}", prev_total, timer.total_time);
586 prev_total = timer.total_time;
587 }
588 }
589
590 #[test]
592 fn property_frame_count_increments(n in 1usize..100) {
593 let mut timer = FrameTimer::new();
594
595 for i in 0..n {
596 let _ = timer.update(i as f64 * 16.667);
597 }
598
599 prop_assert_eq!(timer.frame_count, n as u64);
600 }
601
602 #[test]
604 fn property_interpolation_alpha_bounded(fixed_dt in 0.001f64..0.1) {
605 let mut timer = FrameTimer::new();
606 timer.accumulator = fixed_dt * 0.5;
608 timer.fixed_dt = fixed_dt;
609
610 let alpha = timer.interpolation_alpha();
611 prop_assert!(alpha >= 0.0 && alpha <= 1.0,
612 "Alpha {} should be in [0, 1] for acc={}, fixed_dt={}", alpha, timer.accumulator, fixed_dt);
613 }
614
615 #[test]
617 fn property_interpolation_alpha_non_negative(accumulator in 0.0f64..1.0, fixed_dt in 0.001f64..0.1) {
618 let mut timer = FrameTimer::new();
619 timer.accumulator = accumulator;
620 timer.fixed_dt = fixed_dt;
621
622 let alpha = timer.interpolation_alpha();
623 prop_assert!(alpha >= 0.0, "Alpha {} should be non-negative", alpha);
624 }
625 }
626}