use std::collections::VecDeque;
use std::fmt;
use std::time::{Duration, Instant};
const DEFAULT_INTERVAL_WINDOW: usize = 240;
const MIN_ESTIMATION_INTERVALS: usize = 10;
const MISSED_INTERVAL_FACTOR: f64 = 1.5;
#[derive(Debug)]
pub struct PacingDiagnostics {
intervals: VecDeque<Duration>,
window: usize,
last_presented_at: Option<Instant>,
presented_frames: u64,
untimed_frames: u64,
missed_intervals: u64,
}
impl Default for PacingDiagnostics {
fn default() -> Self {
Self::new()
}
}
impl PacingDiagnostics {
#[must_use]
pub fn new() -> Self {
Self::with_window(DEFAULT_INTERVAL_WINDOW)
}
#[must_use]
pub fn with_window(window: usize) -> Self {
Self {
intervals: VecDeque::new(),
window: window.max(1),
last_presented_at: None,
presented_frames: 0,
untimed_frames: 0,
missed_intervals: 0,
}
}
pub fn record_presented(&mut self, presented_at: Instant) {
self.presented_frames += 1;
if let Some(previous) = self.last_presented_at
&& let Some(interval) = presented_at.checked_duration_since(previous)
&& !interval.is_zero()
{
if let Some(cadence) = self.estimated_cadence()
&& interval.as_secs_f64() > cadence.as_secs_f64() * MISSED_INTERVAL_FACTOR
{
self.missed_intervals += 1;
}
if self.intervals.len() >= self.window {
self.intervals.pop_front();
}
self.intervals.push_back(interval);
}
self.last_presented_at = Some(presented_at);
}
pub fn record_untimed_presented(&mut self) {
self.presented_frames += 1;
self.untimed_frames += 1;
}
#[must_use]
pub fn estimated_cadence(&self) -> Option<Duration> {
if self.intervals.len() < MIN_ESTIMATION_INTERVALS {
return None;
}
let mut sorted: Vec<Duration> = self.intervals.iter().copied().collect();
sorted.sort_unstable();
Some(sorted[(sorted.len() - 1) / 2])
}
#[must_use]
pub fn report(&self) -> PacingReport {
let mut sorted: Vec<Duration> = self.intervals.iter().copied().collect();
sorted.sort_unstable();
let recent_intervals = (!sorted.is_empty()).then(|| IntervalSummary {
samples: sorted.len(),
min: sorted[0],
median: sorted[(sorted.len() - 1) / 2],
p95: sorted[percentile_rank(sorted.len(), 95)],
max: sorted[sorted.len() - 1],
});
PacingReport {
presented_frames: self.presented_frames,
untimed_frames: self.untimed_frames,
estimated_cadence: self.estimated_cadence(),
recent_intervals,
missed_intervals: self.missed_intervals,
}
}
}
fn percentile_rank(length: usize, percent: usize) -> usize {
(percent * length).div_ceil(100).max(1) - 1
}
const PACING_STALENESS_LIMIT: Duration = Duration::from_millis(250);
#[derive(Debug)]
pub struct FramePacer {
diagnostics: PacingDiagnostics,
last_presented: Option<Instant>,
last_schedule_at: Option<Instant>,
}
impl Default for FramePacer {
fn default() -> Self {
Self::new()
}
}
impl FramePacer {
#[must_use]
pub fn new() -> Self {
Self {
diagnostics: PacingDiagnostics::new(),
last_presented: None,
last_schedule_at: None,
}
}
pub fn record_presented(&mut self, presented_at: Instant) {
self.diagnostics.record_presented(presented_at);
self.last_presented = Some(match self.last_presented {
Some(previous) => previous.max(presented_at),
None => presented_at,
});
}
pub fn record_untimed_presented(&mut self) {
self.diagnostics.record_untimed_presented();
}
#[must_use]
pub fn report(&self) -> PacingReport {
self.diagnostics.report()
}
pub fn schedule(&mut self, now: Instant) -> FrameSchedule {
let elapsed = self.last_schedule_at.map_or(Duration::ZERO, |previous| {
now.saturating_duration_since(previous)
});
self.last_schedule_at = Some(now);
match self.display_intervals(now, elapsed) {
Some(frame_delta) => FrameSchedule {
frame_delta,
paced: true,
},
None => FrameSchedule {
frame_delta: elapsed,
paced: false,
},
}
}
fn display_intervals(&self, now: Instant, elapsed: Duration) -> Option<Duration> {
let cadence = self.diagnostics.estimated_cadence()?;
let presented = self.last_presented?;
if now.saturating_duration_since(presented) > PACING_STALENESS_LIMIT {
return None;
}
let slack = cadence / 4;
let intervals = (elapsed + slack).as_nanos() / cadence.as_nanos();
u32::try_from(intervals.max(1))
.ok()
.map(|intervals| cadence * intervals)
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[must_use = "a schedule carries the frame delta the simulation should consume"]
pub struct FrameSchedule {
frame_delta: Duration,
paced: bool,
}
impl FrameSchedule {
#[must_use]
pub const fn frame_delta(self) -> Duration {
self.frame_delta
}
#[must_use]
pub const fn paced(self) -> bool {
self.paced
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct IntervalSummary {
pub samples: usize,
pub min: Duration,
pub median: Duration,
pub p95: Duration,
pub max: Duration,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct PacingReport {
pub presented_frames: u64,
pub untimed_frames: u64,
pub estimated_cadence: Option<Duration>,
pub recent_intervals: Option<IntervalSummary>,
pub missed_intervals: u64,
}
impl fmt::Display for PacingReport {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
formatter,
"{} presented frames ({} without a display time)",
self.presented_frames, self.untimed_frames
)?;
if let Some(cadence) = self.estimated_cadence {
write!(
formatter,
", estimated cadence {:.3} ms",
cadence.as_secs_f64() * 1_000.0
)?;
}
if let Some(intervals) = self.recent_intervals {
write!(
formatter,
", recent intervals (n={}) min {:.3} ms, median {:.3} ms, p95 {:.3} ms, max {:.3} ms",
intervals.samples,
intervals.min.as_secs_f64() * 1_000.0,
intervals.median.as_secs_f64() * 1_000.0,
intervals.p95.as_secs_f64() * 1_000.0,
intervals.max.as_secs_f64() * 1_000.0,
)?;
}
write!(formatter, ", {} missed intervals", self.missed_intervals)
}
}
#[cfg(test)]
mod tests {
use std::time::{Duration, Instant};
use super::{FramePacer, MIN_ESTIMATION_INTERVALS, PACING_STALENESS_LIMIT, PacingDiagnostics};
const STEP: Duration = Duration::from_micros(16_667);
fn pacer_after_steady_presents(count: u32) -> (FramePacer, Instant) {
let mut pacer = FramePacer::new();
let base = Instant::now();
let mut at = base;
for _ in 1..count {
pacer.record_presented(at);
at += STEP;
}
pacer.record_presented(at);
(pacer, at)
}
fn diagnostics_after_steady_frames(count: usize) -> (PacingDiagnostics, Instant) {
let mut diagnostics = PacingDiagnostics::new();
let base = Instant::now();
let mut at = base;
for _ in 0..count {
diagnostics.record_presented(at);
at += STEP;
}
(diagnostics, at)
}
#[test]
fn steady_frames_estimate_their_cadence() {
let (diagnostics, _) = diagnostics_after_steady_frames(60);
let report = diagnostics.report();
assert_eq!(report.presented_frames, 60);
assert_eq!(report.untimed_frames, 0);
assert_eq!(report.estimated_cadence, Some(STEP));
assert_eq!(report.missed_intervals, 0);
let intervals = report.recent_intervals.unwrap();
assert_eq!(intervals.samples, 59);
assert_eq!(intervals.min, STEP);
assert_eq!(intervals.max, STEP);
}
#[test]
fn a_skipped_vsync_counts_as_missed_once_estimation_exists() {
let (mut diagnostics, mut at) = diagnostics_after_steady_frames(30);
at += STEP;
diagnostics.record_presented(at);
assert_eq!(diagnostics.report().missed_intervals, 1);
assert_eq!(diagnostics.report().estimated_cadence, Some(STEP));
}
#[test]
fn estimation_requires_enough_intervals() {
let (diagnostics, _) = diagnostics_after_steady_frames(MIN_ESTIMATION_INTERVALS);
assert_eq!(diagnostics.report().estimated_cadence, None);
let (diagnostics, _) = diagnostics_after_steady_frames(MIN_ESTIMATION_INTERVALS + 1);
assert!(diagnostics.report().estimated_cadence.is_some());
}
#[test]
fn untimed_and_non_monotonic_frames_count_without_intervals() {
let mut diagnostics = PacingDiagnostics::new();
let base = Instant::now();
diagnostics.record_presented(base);
diagnostics.record_presented(base);
diagnostics.record_untimed_presented();
let report = diagnostics.report();
assert_eq!(report.presented_frames, 3);
assert_eq!(report.untimed_frames, 1);
assert!(report.recent_intervals.is_none());
}
#[test]
fn jittered_schedule_gaps_still_advance_one_display_interval() {
let (mut pacer, mut presented) = pacer_after_steady_presents(30);
let mut now = presented + Duration::from_millis(1);
for jitter_ms in [3_u64, 18, 2, 17, 4, 16] {
let schedule = pacer.schedule(now);
assert!(schedule.paced());
assert_eq!(schedule.frame_delta(), STEP, "jitter {jitter_ms} ms");
presented += STEP;
pacer.record_presented(presented);
now += Duration::from_millis(jitter_ms);
}
}
#[test]
fn a_late_schedule_advances_by_whole_missed_intervals() {
let (mut pacer, last_presented) = pacer_after_steady_presents(30);
let first_at = last_presented + Duration::from_millis(1);
let _ = pacer.schedule(first_at);
let second = pacer.schedule(first_at + STEP * 2 + Duration::from_millis(2));
assert!(second.paced());
assert_eq!(second.frame_delta(), STEP * 2);
}
#[test]
fn a_build_start_spike_short_of_two_intervals_stays_one_interval() {
let (mut pacer, last_presented) = pacer_after_steady_presents(30);
let first_at = last_presented + Duration::from_millis(1);
let _ = pacer.schedule(first_at);
let spike = pacer.schedule(first_at + STEP + STEP * 7 / 10);
assert!(spike.paced());
assert_eq!(spike.frame_delta(), STEP);
}
#[test]
fn back_to_back_schedules_keep_the_one_interval_floor() {
let (mut pacer, last_presented) = pacer_after_steady_presents(30);
let now = last_presented + Duration::from_millis(1);
let first = pacer.schedule(now);
let second = pacer.schedule(now + Duration::from_millis(1));
assert_eq!(first.frame_delta(), STEP);
assert_eq!(second.frame_delta(), STEP);
}
#[test]
fn missing_and_stale_feedback_fall_back_to_wall_clock() {
let mut pacer = FramePacer::new();
let base = Instant::now();
let unestimated = pacer.schedule(base);
assert!(!unestimated.paced());
assert_eq!(unestimated.frame_delta(), Duration::ZERO);
let next = pacer.schedule(base + Duration::from_millis(7));
assert!(!next.paced());
assert_eq!(next.frame_delta(), Duration::from_millis(7));
let (mut pacer, last_presented) = pacer_after_steady_presents(30);
let _ = pacer.schedule(last_presented + Duration::from_millis(1));
let stale_at = last_presented + Duration::from_millis(1) + PACING_STALENESS_LIMIT + STEP;
let stale = pacer.schedule(stale_at);
assert!(!stale.paced());
assert_eq!(stale.frame_delta(), PACING_STALENESS_LIMIT + STEP);
}
#[test]
fn the_interval_window_is_bounded() {
let mut diagnostics = PacingDiagnostics::with_window(4);
let mut at = Instant::now();
for _ in 0..20 {
diagnostics.record_presented(at);
at += STEP;
}
assert_eq!(diagnostics.report().recent_intervals.unwrap().samples, 4);
}
}