use std::time::{Duration, Instant};
const DEFAULT_FRAME_BUDGET: Duration = Duration::from_micros(16_667);
type FrameCallback = Box<dyn FnMut(FrameInfo) -> bool>;
#[derive(Debug, Clone, Copy)]
pub struct FrameInfo {
pub timestamp: Duration,
pub frame_budget: Duration,
pub frame_start: Instant,
pub frame_number: u64,
pub fps: f64,
pub prev_timing: FrameTiming,
}
impl FrameInfo {
#[inline]
#[must_use]
pub fn remaining_budget(&self) -> Duration {
self.frame_budget.saturating_sub(self.frame_start.elapsed())
}
}
const FPS_WINDOW: Duration = Duration::from_millis(500);
pub struct FrameScheduler {
epoch: Instant,
frame_budget: Duration,
last_frame_start: Option<Instant>,
last_frame_end: Option<Instant>,
frame_number: u64,
needs_frame: bool,
callbacks: Vec<FrameCallback>,
pending_callbacks: Vec<FrameCallback>,
in_frame: bool,
fps_frame_count: u32,
fps_window_start: Instant,
fps_value: f64,
prev_timing: FrameTiming,
}
pub use kozan_primitives::timing::FrameTiming;
impl FrameScheduler {
#[must_use]
pub fn new() -> Self {
Self::with_budget(DEFAULT_FRAME_BUDGET)
}
#[must_use]
pub fn with_budget(frame_budget: Duration) -> Self {
let now = Instant::now();
Self {
epoch: now,
frame_budget,
last_frame_start: None,
last_frame_end: None,
frame_number: 0,
needs_frame: false,
callbacks: Vec::new(),
pending_callbacks: Vec::new(),
in_frame: false,
fps_frame_count: 0,
fps_window_start: now,
fps_value: 0.0,
prev_timing: FrameTiming::default(),
}
}
#[inline]
pub fn set_needs_frame(&mut self) {
self.needs_frame = true;
}
pub fn request_frame(&mut self, callback: impl FnMut(FrameInfo) -> bool + 'static) {
if self.in_frame {
self.pending_callbacks.push(Box::new(callback));
} else {
self.callbacks.push(Box::new(callback));
self.needs_frame = true;
}
}
#[must_use]
pub fn should_produce_frame(&self) -> bool {
if !self.needs_frame && self.callbacks.is_empty() {
return false;
}
match self.last_frame_start {
None => true,
Some(last) => last.elapsed() >= self.frame_budget,
}
}
#[must_use]
pub fn time_until_next_frame(&self) -> Option<Duration> {
if !self.needs_frame && self.callbacks.is_empty() {
return None;
}
match self.last_frame_start {
None => Some(Duration::ZERO),
Some(last) => {
let elapsed = last.elapsed();
if elapsed >= self.frame_budget {
Some(Duration::ZERO)
} else {
Some(self.frame_budget - elapsed)
}
}
}
}
pub fn begin_frame(&mut self) -> FrameInfo {
let now = Instant::now();
self.fps_frame_count += 1;
let window_elapsed = now.duration_since(self.fps_window_start);
if window_elapsed >= FPS_WINDOW {
self.fps_value = self.fps_frame_count as f64 / window_elapsed.as_secs_f64();
self.fps_frame_count = 0;
self.fps_window_start = now;
}
self.in_frame = true;
self.frame_number += 1;
self.last_frame_start = Some(now);
self.needs_frame = false;
FrameInfo {
timestamp: now.duration_since(self.epoch),
frame_budget: self.frame_budget,
frame_start: now,
frame_number: self.frame_number,
fps: self.fps_value,
prev_timing: self.prev_timing,
}
}
pub fn run_callbacks(&mut self, info: FrameInfo) -> usize {
debug_assert!(
self.in_frame,
"run_callbacks called outside of begin_frame/end_frame"
);
let mut callbacks = std::mem::take(&mut self.callbacks);
let count = callbacks.len();
callbacks.retain_mut(|cb| cb(info));
self.callbacks = callbacks;
count
}
pub fn end_frame(&mut self) {
debug_assert!(self.in_frame, "end_frame called without begin_frame");
self.last_frame_end = Some(Instant::now());
self.in_frame = false;
self.callbacks.append(&mut self.pending_callbacks);
}
#[must_use]
pub fn last_frame_time(&self) -> Option<Duration> {
match (self.last_frame_start, self.last_frame_end) {
(Some(start), Some(end)) => Some(end.duration_since(start)),
_ => None,
}
}
#[must_use]
pub fn last_frame_janked(&self) -> bool {
self.last_frame_time()
.is_some_and(|t| t > self.frame_budget)
}
#[inline]
#[must_use]
pub fn frame_number(&self) -> u64 {
self.frame_number
}
#[inline]
#[must_use]
pub fn frame_budget(&self) -> Duration {
self.frame_budget
}
#[inline]
pub fn set_frame_budget(&mut self, budget: Duration) {
self.frame_budget = budget;
}
#[inline]
pub fn set_frame_timing(&mut self, timing: FrameTiming) {
self.prev_timing = timing;
}
#[must_use]
pub fn remaining_budget(&self) -> Duration {
match self.last_frame_start {
Some(start) if self.in_frame => {
let elapsed = start.elapsed();
self.frame_budget.saturating_sub(elapsed)
}
_ => Duration::ZERO,
}
}
#[inline]
#[must_use]
pub fn pending_callback_count(&self) -> usize {
self.callbacks.len() + self.pending_callbacks.len()
}
}
impl Default for FrameScheduler {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::Cell;
use std::rc::Rc;
#[test]
fn no_frame_when_clean() {
let scheduler = FrameScheduler::new();
assert!(!scheduler.should_produce_frame());
assert!(scheduler.time_until_next_frame().is_none());
}
#[test]
fn frame_needed_after_set_needs_frame() {
let mut scheduler = FrameScheduler::new();
scheduler.set_needs_frame();
assert!(scheduler.should_produce_frame());
}
#[test]
fn frame_needed_after_request_frame() {
let mut scheduler = FrameScheduler::new();
scheduler.request_frame(|_| false);
assert!(scheduler.should_produce_frame());
}
#[test]
fn begin_end_frame_lifecycle() {
let mut scheduler = FrameScheduler::new();
scheduler.set_needs_frame();
let info = scheduler.begin_frame();
assert_eq!(info.frame_number, 1);
assert!(info.frame_budget > Duration::ZERO);
assert!(info.remaining_budget() > Duration::ZERO);
scheduler.end_frame();
assert_eq!(scheduler.frame_number(), 1);
assert!(!scheduler.should_produce_frame()); }
#[test]
fn callbacks_executed_during_frame() {
let mut scheduler = FrameScheduler::new();
let called = Rc::new(Cell::new(false));
let c = called.clone();
scheduler.request_frame(move |_| {
c.set(true);
false
});
let info = scheduler.begin_frame();
let count = scheduler.run_callbacks(info);
scheduler.end_frame();
assert!(called.get());
assert_eq!(count, 1);
}
#[test]
fn callback_during_frame_goes_to_next() {
let mut scheduler = FrameScheduler::new();
let log = Rc::new(std::cell::RefCell::new(Vec::new()));
let l = log.clone();
scheduler.request_frame(move |_| {
l.borrow_mut().push("frame1");
false
});
let info = scheduler.begin_frame();
let l = log.clone();
scheduler.request_frame(move |_| {
l.borrow_mut().push("frame2");
false
});
scheduler.run_callbacks(info);
scheduler.end_frame();
assert_eq!(*log.borrow(), vec!["frame1"]);
assert_eq!(scheduler.pending_callback_count(), 1);
scheduler.set_needs_frame();
scheduler.last_frame_start = Some(Instant::now() - Duration::from_millis(20));
let info = scheduler.begin_frame();
scheduler.run_callbacks(info);
scheduler.end_frame();
assert_eq!(*log.borrow(), vec!["frame1", "frame2"]);
}
#[test]
fn frame_info_timestamp() {
let mut scheduler = FrameScheduler::new();
scheduler.set_needs_frame();
let info = scheduler.begin_frame();
assert!(info.timestamp >= Duration::ZERO);
scheduler.end_frame();
}
#[test]
fn frame_budget_default_60hz() {
let scheduler = FrameScheduler::new();
assert_eq!(scheduler.frame_budget(), Duration::from_micros(16_667));
}
#[test]
fn custom_frame_budget() {
let scheduler = FrameScheduler::with_budget(Duration::from_micros(8_333));
assert_eq!(scheduler.frame_budget(), Duration::from_micros(8_333));
}
#[test]
fn set_frame_budget() {
let mut scheduler = FrameScheduler::new();
scheduler.set_frame_budget(Duration::from_micros(8_333));
assert_eq!(scheduler.frame_budget(), Duration::from_micros(8_333));
}
#[test]
fn remaining_budget_outside_frame() {
let scheduler = FrameScheduler::new();
assert_eq!(scheduler.remaining_budget(), Duration::ZERO);
}
#[test]
fn last_frame_time_none_initially() {
let scheduler = FrameScheduler::new();
assert!(scheduler.last_frame_time().is_none());
assert!(!scheduler.last_frame_janked());
}
#[test]
fn vsync_throttle() {
let mut scheduler = FrameScheduler::new();
scheduler.set_needs_frame();
assert!(scheduler.should_produce_frame());
scheduler.begin_frame();
scheduler.end_frame();
scheduler.set_needs_frame();
assert!(!scheduler.should_produce_frame());
scheduler.last_frame_start = Some(Instant::now() - Duration::from_millis(20));
assert!(scheduler.should_produce_frame());
}
}