1pub use ferravex_ecs as ecs;
12pub use ferravex_ecs::{Entity, World};
13pub mod collision;
14pub mod telemetry;
15
16#[derive(Debug, Clone, Copy)]
18pub struct FrameStep {
19 pub steps: u32,
20 pub dt: f32,
21 pub alpha: f32,
22 pub dropped_seconds: f64,
24}
25
26#[derive(Debug, Clone)]
31pub struct FixedClock {
32 step: f64,
33 accumulator: f64,
34 max_steps: u32,
35 tick: u64,
36}
37
38impl FixedClock {
39 pub fn new(hz: f64, max_steps: u32) -> Self {
40 assert!(
41 hz.is_finite() && (1.0..=1000.0).contains(&hz),
42 "tick rate must be in 1..=1000 Hz"
43 );
44 assert!(max_steps > 0, "catch-up budget must be nonzero");
45 Self {
46 step: 1.0 / hz,
47 accumulator: 0.0,
48 max_steps,
49 tick: 0,
50 }
51 }
52
53 pub fn advance(&mut self, frame_seconds: f64) -> FrameStep {
54 let elapsed = if frame_seconds.is_finite() {
56 frame_seconds.clamp(0.0, 3600.0)
57 } else {
58 0.0
59 };
60 self.accumulator += elapsed;
61 let available = ((self.accumulator + self.step * 1e-9) / self.step).floor() as u64;
62 let steps = available.min(self.max_steps as u64) as u32;
63 self.accumulator = (self.accumulator - available as f64 * self.step).max(0.0);
64 self.tick = self.tick.wrapping_add(steps as u64);
65 FrameStep {
66 steps,
67 dt: self.step as f32,
68 alpha: (self.accumulator / self.step).clamp(0.0, 1.0) as f32,
69 dropped_seconds: (available - steps as u64) as f64 * self.step,
70 }
71 }
72
73 pub fn tick(&self) -> u64 {
74 self.tick
75 }
76 pub fn dt(&self) -> f32 {
77 self.step as f32
78 }
79 pub fn reset(&mut self) {
80 self.accumulator = 0.0;
81 self.tick = 0;
82 }
83}
84
85pub struct FrameStats<const N: usize = 120> {
87 samples: [f32; N],
88 cursor: usize,
89 len: usize,
90}
91impl<const N: usize> Default for FrameStats<N> {
92 fn default() -> Self {
93 assert!(N > 0);
94 Self {
95 samples: [0.0; N],
96 cursor: 0,
97 len: 0,
98 }
99 }
100}
101impl<const N: usize> FrameStats<N> {
102 pub fn record(&mut self, seconds: f32) {
103 if !seconds.is_finite() || seconds < 0.0 {
104 return;
105 }
106 self.samples[self.cursor] = seconds * 1000.0;
107 self.cursor = (self.cursor + 1) % N;
108 self.len = (self.len + 1).min(N);
109 }
110 pub fn mean_ms(&self) -> f32 {
111 if self.len == 0 {
112 return 0.0;
113 }
114 self.samples[..self.len].iter().sum::<f32>() / self.len as f32
115 }
116 pub fn max_ms(&self) -> f32 {
117 self.samples[..self.len].iter().copied().fold(0.0, f32::max)
118 }
119}
120
121#[cfg(test)]
122mod tests {
123 use super::*;
124 #[test]
125 fn frame_rate_independent() {
126 for fps in [30, 60, 144, 240] {
127 let mut clock = FixedClock::new(64.0, 8);
128 for _ in 0..fps * 10 {
129 clock.advance(1.0 / fps as f64);
130 }
131 assert_eq!(clock.tick(), 640);
132 }
133 }
134 #[test]
135 fn catchup_is_bounded_and_fraction_retained() {
136 let mut clock = FixedClock::new(64.0, 4);
137 let f = clock.advance(10.5 / 64.0);
138 assert_eq!(f.steps, 4);
139 assert!((f.alpha - 0.5).abs() < 0.0001);
140 assert!((f.dropped_seconds - 6.0 / 64.0).abs() < 0.0001);
141 assert_eq!(clock.advance(0.5 / 64.0).steps, 1);
142 }
143 #[test]
144 fn invalid_durations_do_not_poison_clock() {
145 let mut c = FixedClock::new(64.0, 8);
146 for dt in [f64::NAN, f64::INFINITY, -5.0] {
147 assert_eq!(c.advance(dt).steps, 0);
148 }
149 assert_eq!(c.advance(1.0 / 64.0).steps, 1);
150 }
151 #[test]
152 fn stats_wrap_and_ignore_nan() {
153 let mut s = FrameStats::<2>::default();
154 for dt in [0.001, 0.003, 0.005, f32::NAN] {
155 s.record(dt);
156 }
157 assert!((s.mean_ms() - 4.0).abs() < 0.001);
158 assert_eq!(s.max_ms(), 5.0);
159 }
160}