1use std::time::{Duration, Instant};
13
14#[derive(Clone, Copy, Debug, PartialEq, Eq)]
18pub enum TickTrigger {
19 Timer,
20 Input,
21 HostFrame,
22 Damage,
23}
24
25impl TickTrigger {
26 fn pulls_forward(self) -> bool {
28 !matches!(self, TickTrigger::Timer)
29 }
30
31 fn refills(self) -> bool {
36 matches!(self, TickTrigger::Input)
37 }
38}
39
40const TIMER_TICK_MIN_FRACTION: f64 = 0.9;
42const INPUT_TICK_MIN_FRACTION: f64 = 0.5;
45const INPUT_BORROW_REFILL: f64 = 1.0 / 32.0;
49const TIMER_GRACE_AFTER_PULL: f64 = 0.25;
54const PACED_INPUT_MIN_FRACTION: f64 = 0.75;
58const PACED_INPUT_MAX_FRACTION: f64 = 1.25;
59
60#[derive(Debug, Default)]
90pub struct FramePace {
91 pub last_tick: Option<Instant>,
93 last_paced: bool,
96 borrow_budget: Option<(Duration, Instant)>,
99 phase_budget: Option<Duration>,
102 deferred_until: Option<Instant>,
105 last_damage: Option<Instant>,
107 damage_spacing: Option<Duration>,
108}
109
110impl FramePace {
111 pub fn due(&self, trigger: TickTrigger, period: Duration, now: Instant) -> bool {
113 let Some(last) = self.last_tick else {
114 return true;
115 };
116 let elapsed = now.saturating_duration_since(last);
117 if !trigger.pulls_forward() {
118 return elapsed >= self.timer_wait(period);
119 }
120 elapsed >= period
121 || (elapsed >= period.mul_f64(INPUT_TICK_MIN_FRACTION)
122 && self.budget(trigger, period, now) >= period - elapsed)
123 }
124
125 pub fn input_paced(interval: Option<Duration>, period: Duration) -> bool {
127 interval.is_some_and(|i| {
128 i >= period.mul_f64(PACED_INPUT_MIN_FRACTION)
129 && i <= period.mul_f64(PACED_INPUT_MAX_FRACTION)
130 })
131 }
132
133 pub fn ticked(
141 &mut self,
142 trigger: TickTrigger,
143 period: Duration,
144 now: Instant,
145 input_paced: bool,
146 ) {
147 if trigger.pulls_forward()
148 && let Some(last) = self.last_tick
149 {
150 let elapsed = now.saturating_duration_since(last);
151 let budget = self.budget(trigger, period, now);
152 let left = if elapsed < period {
153 budget.saturating_sub(period - elapsed)
154 } else {
155 (budget + (elapsed - period)).min(period.mul_f64(INPUT_TICK_MIN_FRACTION))
156 };
157 if trigger.refills() {
158 self.borrow_budget = Some((left, now));
159 } else {
160 self.phase_budget = Some(left);
161 }
162 }
163 let mut at = now;
164 if trigger == TickTrigger::Timer && self.last_tick.is_some() {
165 let scheduled = self.next_due(period, now);
166 let off = if now > scheduled {
167 now - scheduled
168 } else {
169 scheduled - now
170 };
171 if off < period / 2 {
172 at = scheduled;
173 }
174 }
175 let damage_paced = trigger == TickTrigger::Damage && self.damage_paced(period, now);
176 self.last_paced = (trigger == TickTrigger::Input && input_paced) || damage_paced;
177 self.last_tick = Some(at);
178 self.deferred_until = None;
179 }
180
181 fn damage_paced(&mut self, period: Duration, now: Instant) -> bool {
184 if let Some(prev) = self.last_damage {
185 let dt = now.saturating_duration_since(prev).min(period * 4);
186 self.damage_spacing = Some(
187 self.damage_spacing
188 .map_or(dt, |avg| avg.mul_f64(0.875) + dt.mul_f64(0.125)),
189 );
190 }
191 self.last_damage = Some(now);
192 Self::input_paced(self.damage_spacing, period)
193 }
194
195 fn timer_wait(&self, period: Duration) -> Duration {
197 if self.last_paced {
198 period.mul_f64(1.0 + TIMER_GRACE_AFTER_PULL)
199 } else {
200 period.mul_f64(TIMER_TICK_MIN_FRACTION)
201 }
202 }
203
204 pub fn defer(&mut self, until: Instant) {
208 self.deferred_until = Some(until);
209 }
210
211 pub fn since_last_tick(&self, now: Instant) -> Option<Duration> {
213 self.last_tick
214 .map(|last| now.saturating_duration_since(last))
215 }
216
217 fn budget(&self, trigger: TickTrigger, period: Duration, now: Instant) -> Duration {
219 let cap = period.mul_f64(INPUT_TICK_MIN_FRACTION);
220 if !trigger.refills() {
221 return self.phase_budget.unwrap_or(cap);
222 }
223 match self.borrow_budget {
224 None => cap,
225 Some((left, at)) => (left
226 + now
227 .saturating_duration_since(at)
228 .mul_f64(INPUT_BORROW_REFILL))
229 .min(cap),
230 }
231 }
232
233 pub fn pull_at(&self, trigger: TickTrigger, period: Duration, now: Instant) -> Instant {
237 self.last_tick.map_or(now, |last| {
238 last + period.saturating_sub(self.budget(trigger, period, now))
239 })
240 }
241
242 pub fn next_due(&self, period: Duration, now: Instant) -> Instant {
245 let due = self.last_tick.map_or(now, |last| {
246 last + if self.last_paced {
247 period.mul_f64(1.0 + TIMER_GRACE_AFTER_PULL)
248 } else {
249 period
250 }
251 });
252 self.deferred_until.map_or(due, |held| held.max(due))
253 }
254}
255
256#[cfg(test)]
257mod pacing_tests {
258 use super::*;
259
260 const PERIOD: Duration = Duration::from_millis(20);
261
262 fn at(base: Instant, ms: u64) -> Instant {
263 base + Duration::from_millis(ms)
264 }
265
266 #[test]
270 fn the_earliest_pull_is_the_instant_the_gate_opens() {
271 let base = Instant::now();
272 let mut pace = FramePace::default();
273 assert_eq!(
274 pace.pull_at(TickTrigger::Damage, PERIOD, base),
275 base,
276 "a fresh capture waits for nothing"
277 );
278 pace.ticked(TickTrigger::Timer, PERIOD, base, false);
279 let earliest = pace.pull_at(TickTrigger::Damage, PERIOD, base);
280 assert_eq!(earliest, at(base, 10), "a whole budget buys half a period");
281 assert!(pace.due(TickTrigger::Damage, PERIOD, earliest));
282 assert!(!pace.due(
283 TickTrigger::Damage,
284 PERIOD,
285 earliest - Duration::from_millis(1)
286 ));
287 pace.ticked(TickTrigger::Damage, PERIOD, earliest, false);
288 assert_eq!(
289 pace.pull_at(TickTrigger::Damage, PERIOD, earliest),
290 at(base, 30),
291 "a spent budget waits the period out"
292 );
293 }
294
295 #[test]
296 fn a_fresh_capture_is_due_at_once() {
297 let pace = FramePace::default();
298 let now = Instant::now();
299 assert!(pace.due(TickTrigger::Timer, PERIOD, now));
300 assert!(pace.due(TickTrigger::Input, PERIOD, now));
301 assert_eq!(pace.next_due(PERIOD, now), now);
302 }
303
304 #[test]
305 fn the_timer_ticks_at_its_period_with_jitter_slack() {
306 let base = Instant::now();
307 let mut pace = FramePace::default();
308 pace.ticked(TickTrigger::Timer, PERIOD, base, false);
309 assert!(!pace.due(TickTrigger::Timer, PERIOD, at(base, 17)));
310 assert!(pace.due(TickTrigger::Timer, PERIOD, at(base, 18)));
311 assert_eq!(pace.next_due(PERIOD, at(base, 5)), at(base, 20));
312 }
313
314 #[test]
315 fn input_pulls_a_frame_forward_from_a_budget() {
316 let base = Instant::now();
317 let mut pace = FramePace::default();
318 pace.ticked(TickTrigger::Timer, PERIOD, base, false);
319 assert!(
320 !pace.due(TickTrigger::Input, PERIOD, at(base, 9)),
321 "under half a period waits for the timer"
322 );
323 assert!(pace.due(TickTrigger::Input, PERIOD, at(base, 10)));
324 pace.ticked(TickTrigger::Input, PERIOD, at(base, 10), false);
325 assert_eq!(
326 pace.next_due(PERIOD, at(base, 10)),
327 at(base, 30),
328 "a pull off the cadence leaves the timer its period"
329 );
330 assert!(pace.due(TickTrigger::Timer, PERIOD, at(base, 30)));
331 assert!(
332 !pace.due(TickTrigger::Input, PERIOD, at(base, 25)),
333 "the budget is spent for a second pull"
334 );
335 assert!(
336 pace.due(TickTrigger::Input, PERIOD, at(base, 30)),
337 "a whole period on, input takes the due frame"
338 );
339 pace.ticked(TickTrigger::Input, PERIOD, at(base, 30), false);
340 pace.ticked(TickTrigger::Timer, PERIOD, at(base, 120), false);
342 assert!(
343 !pace.due(TickTrigger::Input, PERIOD, at(base, 130)),
344 "a whole pull needs the whole budget back"
345 );
346 assert!(
347 pace.due(TickTrigger::Input, PERIOD, at(base, 137)),
348 "a small pull fits what refilled"
349 );
350 pace.ticked(TickTrigger::Input, PERIOD, at(base, 137), false);
351 pace.ticked(TickTrigger::Timer, PERIOD, at(base, 500), false);
352 assert!(
353 pace.due(TickTrigger::Input, PERIOD, at(base, 510)),
354 "the budget is whole again"
355 );
356 }
357
358 #[test]
359 fn input_a_hair_slower_than_the_cadence_renders_every_move() {
360 let base = Instant::now();
361 let mut pace = FramePace::default();
362 pace.ticked(TickTrigger::Timer, PERIOD, base, false);
363 let spacing = Duration::from_micros(20_400);
367 assert!(FramePace::input_paced(Some(spacing), PERIOD));
368 let mut t = 16.0;
369 pace.ticked(TickTrigger::Input, PERIOD, at(base, 16), true);
370 for _ in 0..50 {
371 t += 20.4;
372 let now = base + Duration::from_micros((t * 1000.0) as u64);
373 assert!(
374 pace.next_due(PERIOD, now) > now,
375 "no timer frame slipped in ahead of the move"
376 );
377 assert!(
378 pace.due(TickTrigger::Input, PERIOD, now),
379 "the move renders its own frame"
380 );
381 pace.ticked(TickTrigger::Input, PERIOD, now, true);
382 }
383 let stopped = base + Duration::from_micros((t * 1000.0) as u64);
384 assert_eq!(
385 pace.next_due(PERIOD, stopped),
386 stopped + Duration::from_millis(25),
387 "once the moves stop, the timer resumes after the grace"
388 );
389 }
390
391 #[test]
392 fn a_pull_that_arrives_late_pays_the_budget_back() {
393 let base = Instant::now();
394 let mut pace = FramePace::default();
395 pace.ticked(TickTrigger::Timer, PERIOD, base, false);
396 pace.ticked(TickTrigger::Input, PERIOD, at(base, 10), true);
397 assert!(
398 !pace.due(TickTrigger::Input, PERIOD, at(base, 25)),
399 "the budget is spent"
400 );
401 pace.ticked(TickTrigger::Input, PERIOD, at(base, 40), true);
402 assert!(
403 pace.due(TickTrigger::Input, PERIOD, at(base, 50)),
404 "a pull 10 ms late earned a 10 ms pull back"
405 );
406 }
407
408 #[test]
409 fn only_input_at_the_cadence_holds_the_timer_back() {
410 let base = Instant::now();
411 let mut pace = FramePace::default();
412 pace.ticked(TickTrigger::Timer, PERIOD, base, false);
413 assert!(
414 !FramePace::input_paced(Some(Duration::from_millis(7)), PERIOD),
415 "a 143 Hz client is not paced at 50 Hz"
416 );
417 assert!(
418 !FramePace::input_paced(Some(Duration::from_millis(40)), PERIOD),
419 "nor is one at half the rate"
420 );
421 assert!(!FramePace::input_paced(None, PERIOD), "nor no input at all");
422 pace.ticked(TickTrigger::Input, PERIOD, at(base, 12), false);
423 assert_eq!(
424 pace.next_due(PERIOD, at(base, 12)),
425 at(base, 32),
426 "an unpaced pull leaves the timer its period"
427 );
428 pace.ticked(TickTrigger::Input, PERIOD, at(base, 32), true);
429 assert_eq!(
430 pace.next_due(PERIOD, at(base, 32)),
431 at(base, 57),
432 "a paced one adds the grace"
433 );
434 pace.ticked(TickTrigger::HostFrame, PERIOD, at(base, 57), true);
435 assert_eq!(
436 pace.next_due(PERIOD, at(base, 57)),
437 at(base, 77),
438 "a host frame never does"
439 );
440 }
441
442 #[test]
443 fn damage_at_the_cadence_renders_every_frame_through_vblank_jitter() {
444 let base = Instant::now();
445 let mut pace = FramePace::default();
446 let steps = [21, 19, 20, 21, 21, 19, 20, 19, 21, 20];
450 let mut t = 0u64;
451 pace.ticked(TickTrigger::Damage, PERIOD, at(base, t), false);
452 for (i, step) in steps.iter().cycle().take(60).enumerate() {
453 t += step;
454 let now = at(base, t);
455 if i >= 2 {
456 assert!(
457 pace.next_due(PERIOD, now) > now,
458 "no timer frame slipped in ahead of frame {i}"
459 );
460 }
461 assert!(
462 pace.due(TickTrigger::Damage, PERIOD, now),
463 "frame {i} renders as it lands"
464 );
465 pace.ticked(TickTrigger::Damage, PERIOD, now, false);
466 }
467 assert_eq!(
468 pace.next_due(PERIOD, at(base, t)),
469 at(base, t + 25),
470 "once it stops, the timer resumes after the grace"
471 );
472 }
473
474 #[test]
475 fn damage_off_the_cadence_leaves_the_timer_its_period() {
476 let base = Instant::now();
477 let mut pace = FramePace::default();
478 for i in 0..40 {
480 pace.ticked(TickTrigger::Damage, PERIOD, at(base, 8 * i), false);
481 }
482 assert_eq!(pace.next_due(PERIOD, at(base, 312)), at(base, 332));
483 }
484
485 #[test]
486 fn a_host_frame_pulls_forward_like_input() {
487 let base = Instant::now();
488 let mut pace = FramePace::default();
489 pace.ticked(TickTrigger::Timer, PERIOD, base, false);
490 assert!(
491 !pace.due(TickTrigger::HostFrame, PERIOD, at(base, 9)),
492 "under half a period waits for the timer"
493 );
494 assert!(pace.due(TickTrigger::HostFrame, PERIOD, at(base, 10)));
495 pace.ticked(TickTrigger::HostFrame, PERIOD, at(base, 10), false);
496 assert_eq!(
497 pace.next_due(PERIOD, at(base, 10)),
498 at(base, 30),
499 "the cadence follows the host's frames"
500 );
501 assert!(
502 !pace.due(TickTrigger::Timer, PERIOD, at(base, 20)),
503 "the old phase's tick is skipped"
504 );
505 assert!(
506 !pace.due(TickTrigger::HostFrame, PERIOD, at(base, 25)),
507 "the budget is spent for a second pull"
508 );
509 assert!(
510 pace.due(TickTrigger::HostFrame, PERIOD, at(base, 30)),
511 "a whole period on, the frame is due"
512 );
513 }
514
515 #[test]
516 fn a_deferred_tick_holds_the_timer_without_counting_a_frame() {
517 let base = Instant::now();
518 let mut pace = FramePace::default();
519 pace.ticked(TickTrigger::Timer, PERIOD, base, false);
520 pace.defer(at(base, 30));
521 assert_eq!(
522 pace.next_due(PERIOD, at(base, 20)),
523 at(base, 30),
524 "the timer waits where it was held to"
525 );
526 assert!(
527 pace.due(TickTrigger::HostFrame, PERIOD, at(base, 21)),
528 "a frame a period on is still due at once"
529 );
530 pace.ticked(TickTrigger::HostFrame, PERIOD, at(base, 21), false);
531 assert_eq!(
532 pace.next_due(PERIOD, at(base, 21)),
533 at(base, 41),
534 "a rendered frame lifts the hold"
535 );
536 }
537
538 #[test]
539 fn a_timer_that_wakes_late_keeps_its_rate() {
540 let base = Instant::now();
541 let mut pace = FramePace::default();
542 let end = base + Duration::from_secs(2);
543 let (mut frames, mut t) = (0, base);
544 loop {
545 t = pace.next_due(PERIOD, t) + Duration::from_micros(700);
546 if t >= end {
547 break;
548 }
549 assert!(pace.due(TickTrigger::Timer, PERIOD, t));
550 pace.ticked(TickTrigger::Timer, PERIOD, t, false);
551 frames += 1;
552 }
553 assert!(
554 (99..=100).contains(&frames),
555 "{frames} frames in two seconds at 50 fps"
556 );
557 }
558
559 #[test]
560 fn a_stalled_timer_starts_its_cadence_again_rather_than_catch_up() {
561 let base = Instant::now();
562 let mut pace = FramePace::default();
563 pace.ticked(TickTrigger::Timer, PERIOD, base, false);
564 pace.ticked(TickTrigger::Timer, PERIOD, at(base, 75), false);
565 assert_eq!(
566 pace.next_due(PERIOD, at(base, 75)),
567 at(base, 95),
568 "a tick a period and more late counts from when it ran"
569 );
570 }
571
572 #[test]
575 fn damage_faster_than_the_period_holds_the_rate() {
576 let base = Instant::now();
577 let mut pace = FramePace::default();
578 let end = base + Duration::from_secs(2);
579 let (mut frames, mut t) = (0, base);
580 loop {
581 let due = pace.next_due(PERIOD, t);
582 t = pace.pull_at(TickTrigger::Damage, PERIOD, t).min(due).max(t);
583 if t >= end {
584 break;
585 }
586 pace.ticked(TickTrigger::Damage, PERIOD, t, false);
587 frames += 1;
588 t += Duration::from_micros(100);
589 }
590 assert!(
591 (100..=101).contains(&frames),
592 "{frames} frames in two seconds at 50 fps"
593 );
594 }
595
596 #[test]
597 fn input_faster_than_the_period_raises_the_rate_by_the_refill_at_most() {
598 let base = Instant::now();
599 let mut pace = FramePace::default();
600 let end = base + Duration::from_secs(2);
601 let (mut frames, mut next_input, mut next_timer) = (0, base, base);
602 loop {
603 let t = next_input.min(next_timer);
604 if t >= end {
605 break;
606 }
607 if t == next_timer {
608 if pace.due(TickTrigger::Timer, PERIOD, t) {
609 pace.ticked(TickTrigger::Timer, PERIOD, t, false);
610 frames += 1;
611 }
612 next_timer = pace.next_due(PERIOD, t);
613 }
614 if t == next_input {
615 if pace.due(TickTrigger::Input, PERIOD, t) {
616 pace.ticked(TickTrigger::Input, PERIOD, t, false);
617 frames += 1;
618 next_timer = pace.next_due(PERIOD, t);
619 }
620 next_input = t + Duration::from_millis(7);
621 }
622 }
623 assert!(
624 (100..=104).contains(&frames),
625 "{frames} frames in two seconds at 50 fps"
626 );
627 }
628}