From f64e256641ff206e64a0769f8e13f2c1c1a04448 Mon Sep 17 00:00:00 2001 From: Alula Date: Mon, 4 May 2026 05:45:31 +0200 Subject: [PATCH] Improve frame pacing implementation --- src/framework/frame_pacer.rs | 233 +++++++++++++++++++++++++++++++++++ src/framework/mod.rs | 1 + src/game/mod.rs | 141 +++++++++------------ 3 files changed, 290 insertions(+), 85 deletions(-) create mode 100644 src/framework/frame_pacer.rs diff --git a/src/framework/frame_pacer.rs b/src/framework/frame_pacer.rs new file mode 100644 index 0000000..f1b44b6 --- /dev/null +++ b/src/framework/frame_pacer.rs @@ -0,0 +1,233 @@ +//! Backend-agnostic frame pacing. +//! +//! Maintains an anchored tick clock (sim-side) and an anchored present clock (display-side), +//! both expressed as `anchor + period * index` so deadlines stay drift-free regardless of how +//! many frames have run. Re-anchors on speed changes, large clock drift (suspend/resume, +//! NTP step-back, non-monotonic `Instant`), and on a sub-tick high-water index. +//! +//! Also tracks an EMA of swap+present latency so interpolation can predict the actual +//! display moment, and so VRR pacing can lead the deadline by the measured latency. +use std::time::{Duration, Instant}; + +/// Maximum ticks of clock lag we'll absorb via catch-up before re-anchoring. +const MAX_LAG_TICKS: u32 = 4; +/// If a deadline is more than this far in the future relative to `now`, treat it +/// as a clock regression / suspend-resume artifact and re-anchor. +const MAX_LEAD: Duration = Duration::from_secs(1); +/// Sub-tick re-anchor floor — re-anchor when index reaches this, to keep +/// `index * period` from growing without bound. ~52 days at 60 Hz. +const REANCHOR_INDEX: u32 = 1 << 25; +/// Slack subtracted from the present deadline before sleeping; covers OS scheduler wake-up jitter. +const SCHEDULER_MARGIN: Duration = Duration::from_micros(500); +/// Hard cap on the spin-wait window that bridges the residual sleep error. +const MAX_SPIN: Duration = Duration::from_millis(1); +/// Maximum sim ticks to run per `advance_ticks` call (death-spiral guard). +pub const MAX_CATCHUP: u32 = 10; + +pub struct FramePacer { + tick_anchor: Instant, + tick_index: u32, + tick_delta: Duration, + last_tick_instant: Instant, + present_anchor: Instant, + present_index: u32, + present_period: Duration, + swap_latency_ema: Duration, +} + +impl FramePacer { + pub fn new() -> Self { + let now = Instant::now(); + let default_delta = Duration::from_nanos(1_000_000_000 / 60); + Self { + tick_anchor: now, + tick_index: 0, + tick_delta: default_delta, + last_tick_instant: now, + present_anchor: now, + present_index: 0, + present_period: default_delta, + swap_latency_ema: Duration::ZERO, + } + } + + /// Reset all anchors to `now`. Call on focus gain, scene change, or after a known stall. + pub fn reset(&mut self) { + let now = Instant::now(); + self.tick_anchor = now; + self.tick_index = 0; + self.last_tick_instant = now; + self.present_anchor = now; + self.present_index = 0; + } + + pub fn tick_delta(&self) -> Duration { + self.tick_delta + } + + pub fn last_tick_instant(&self) -> Instant { + self.last_tick_instant + } + + pub fn swap_latency(&self) -> Duration { + self.swap_latency_ema + } + + /// Update the sim tick period. Phase-preserving re-anchor if the period changed. + pub fn set_tick_delta(&mut self, delta: Duration) { + if delta != self.tick_delta && !delta.is_zero() { + self.tick_delta = delta; + self.reanchor_ticks_phased(self.last_tick_instant); + } + } + + /// Update the VRR present period. Phase-preserving re-anchor if it changed. + pub fn set_present_period(&mut self, period: Duration) { + if period != self.present_period && !period.is_zero() { + self.present_period = period; + self.reanchor_present_phased(Instant::now()); + } + } + + /// Compute how many sim ticks should run this frame. Advances internal counters + /// and `last_tick_instant`. Caller is expected to invoke its tick callback this + /// many times. Returns 0..=MAX_CATCHUP. + pub fn advance_ticks(&mut self) -> u32 { + if self.tick_delta.is_zero() { + return 0; + } + let now = Instant::now(); + let mut deadline = self.tick_deadline(1); + + let lag_cap = self.tick_delta.checked_mul(MAX_LAG_TICKS).unwrap_or(MAX_LEAD); + if now.saturating_duration_since(deadline) > lag_cap + || deadline.saturating_duration_since(now) > MAX_LEAD + { + self.tick_anchor = now; + self.tick_index = 0; + self.last_tick_instant = now; + deadline = self.tick_deadline(1); + } + + let mut loops = 0u32; + while now >= deadline && loops < MAX_CATCHUP { + self.tick_index = self.tick_index.saturating_add(1); + self.last_tick_instant = deadline; + loops += 1; + deadline = self.tick_deadline(1); + } + + if self.tick_index >= REANCHOR_INDEX { + self.reanchor_ticks_phased(self.last_tick_instant); + } + + loops + } + + /// Block until just before the next present deadline. Uses recorded swap latency + /// to lead the deadline so the frame appears at the intended instant. + pub fn wait_for_present(&mut self) { + if self.present_period.is_zero() { + return; + } + let now = Instant::now(); + let mut deadline = self.present_deadline(1); + + let lag_cap = self.present_period.checked_mul(MAX_LAG_TICKS).unwrap_or(MAX_LEAD); + if now.saturating_duration_since(deadline) > lag_cap + || deadline.saturating_duration_since(now) > MAX_LEAD + { + self.present_anchor = now; + self.present_index = 0; + deadline = self.present_deadline(1); + } + + let lead = self.swap_latency_ema.min(self.present_period / 2); + let target = deadline.checked_sub(lead).unwrap_or(deadline); + let target_minus_margin = target.checked_sub(SCHEDULER_MARGIN).unwrap_or(target); + + let now2 = Instant::now(); + if target_minus_margin > now2 { + std::thread::sleep(target_minus_margin - now2); + } + let spin_until = { + let cap = Instant::now() + MAX_SPIN; + if target < cap { target } else { cap } + }; + while Instant::now() < spin_until { + std::hint::spin_loop(); + } + + self.present_index = self.present_index.saturating_add(1); + if self.present_index >= REANCHOR_INDEX { + self.reanchor_present_phased(Instant::now()); + } + } + + /// Record measured swap+present latency. Call after the swap-buffers / finalize call. + pub fn record_swap_latency(&mut self, measured: Duration) { + // Cap at one tick so a single stall can't poison the lead. + let cap = if self.tick_delta.is_zero() { Duration::from_millis(50) } else { self.tick_delta }; + let measured = measured.min(cap); + self.swap_latency_ema = (self.swap_latency_ema * 7 + measured) / 8; + } + + /// Predicted instant the next presented frame will appear on the display. + pub fn predicted_present_instant(&self) -> Instant { + Instant::now() + self.swap_latency_ema + } + + /// Interpolation alpha in [0, 1] — fraction of the current tick at the predicted + /// present instant. Returns 0 if no tick has run yet or `tick_delta` is zero. + pub fn interpolation_alpha(&self) -> f64 { + if self.tick_delta.is_zero() { + return 0.0; + } + let predicted = self.predicted_present_instant(); + let since = predicted.saturating_duration_since(self.last_tick_instant); + let alpha = (since.as_nanos() as f64) / (self.tick_delta.as_nanos() as f64); + alpha.clamp(0.0, 1.0) + } + + fn tick_deadline(&self, idx_offset: u32) -> Instant { + let idx = self.tick_index.saturating_add(idx_offset); + self.tick_delta + .checked_mul(idx) + .and_then(|d| self.tick_anchor.checked_add(d)) + .unwrap_or(self.tick_anchor) + } + + fn present_deadline(&self, idx_offset: u32) -> Instant { + let idx = self.present_index.saturating_add(idx_offset); + self.present_period + .checked_mul(idx) + .and_then(|d| self.present_anchor.checked_add(d)) + .unwrap_or(self.present_anchor) + } + + fn reanchor_ticks_phased(&mut self, now: Instant) { + let phase_ns = if self.tick_delta.is_zero() { + 0 + } else { + now.saturating_duration_since(self.tick_anchor).as_nanos() % self.tick_delta.as_nanos() + }; + self.tick_anchor = now.checked_sub(Duration::from_nanos(phase_ns as u64)).unwrap_or(now); + self.tick_index = 0; + } + + fn reanchor_present_phased(&mut self, now: Instant) { + let phase_ns = if self.present_period.is_zero() { + 0 + } else { + now.saturating_duration_since(self.present_anchor).as_nanos() % self.present_period.as_nanos() + }; + self.present_anchor = now.checked_sub(Duration::from_nanos(phase_ns as u64)).unwrap_or(now); + self.present_index = 0; + } +} + +impl Default for FramePacer { + fn default() -> Self { + Self::new() + } +} diff --git a/src/framework/mod.rs b/src/framework/mod.rs index ccd92d3..cb9c3ac 100644 --- a/src/framework/mod.rs +++ b/src/framework/mod.rs @@ -12,6 +12,7 @@ pub mod clipboard; pub mod context; pub mod error; pub mod filesystem; +pub mod frame_pacer; pub mod gamepad; pub mod graphics; pub mod input; diff --git a/src/game/mod.rs b/src/game/mod.rs index d324210..6852cb2 100644 --- a/src/game/mod.rs +++ b/src/game/mod.rs @@ -12,6 +12,7 @@ use scripting::tsc::text_script::ScriptMode; use crate::framework::backend::{BackendCallbacks, WindowParams}; use crate::framework::context::Context; use crate::framework::error::GameResult; +use crate::framework::frame_pacer::FramePacer; use crate::framework::graphics::{self, SwapMode}; use crate::framework::keyboard; use crate::framework::ui::UI; @@ -116,11 +117,8 @@ pub struct Game { pub(crate) state: RefCell, ui: UI, start_time: Instant, - last_tick: u128, - next_tick: u128, + pacer: FramePacer, pub(crate) loops: u32, - next_tick_draw: u128, - present: bool, fps: Fps, } @@ -131,11 +129,8 @@ impl Game { ui: UI::new(ctx)?, state: RefCell::new(SharedGameState::new(ctx)?), start_time: Instant::now(), - last_tick: 0, - next_tick: 0, + pacer: FramePacer::new(), loops: 0, - next_tick_draw: 0, - present: true, fps: Fps::new(), }; @@ -143,63 +138,58 @@ impl Game { } pub(crate) fn update(&mut self, ctx: &mut Context) -> GameResult { - let state_ref = self.state.get_mut(); + // Snapshot config we need for the timing math while not holding any borrows on `self`. + let (timing_mode, speed) = { + let s = self.state.get_mut(); + let speed_mul = + if s.textscript_vm.mode == ScriptMode::Map && s.textscript_vm.flags.cutscene_skip() { 4.0 } else { 1.0 }; + (s.settings.timing_mode, speed_mul * s.settings.speed) + }; - if let Some(scene) = self.scene.get_mut() { - let speed = - if state_ref.textscript_vm.mode == ScriptMode::Map && state_ref.textscript_vm.flags.cutscene_skip() { - 4.0 + match timing_mode { + TimingMode::_50Hz | TimingMode::_60Hz => { + let base_delta_ns = timing_mode.get_delta() as u64; + let effective_delta_ns = if (speed - 1.0).abs() < 0.01 { + base_delta_ns + } else if speed > 0.0 { + ((base_delta_ns as f64) / speed).max(1.0) as u64 } else { - 1.0 - } * state_ref.settings.speed; + base_delta_ns + }; + self.pacer.set_tick_delta(Duration::from_nanos(effective_delta_ns)); - match state_ref.settings.timing_mode { - TimingMode::_50Hz | TimingMode::_60Hz => { - let last_tick = self.next_tick; - - while self.start_time.elapsed().as_nanos() >= self.next_tick && self.loops < 10 { - let delta = state_ref.settings.timing_mode.get_delta(); - - if (speed - 1.0).abs() < 0.01 { - self.next_tick += delta as u128; - } else { - self.next_tick += (delta as f64 / speed) as u128; - } - self.loops += 1; - } - - if self.loops == 10 { - let delta = state_ref.settings.timing_mode.get_delta(); - - log::warn!("Frame skip is way too high, a long system lag occurred?"); - self.last_tick = self.start_time.elapsed().as_nanos(); - self.next_tick = self.last_tick + (delta as f64 / speed) as u128; - self.loops = 0; - } - - if self.loops != 0 { + let loops = self.pacer.advance_ticks(); + self.loops = loops; + if loops != 0 { + if let Some(scene) = self.scene.get_mut() { + let state_ref = self.state.get_mut(); scene.draw_tick(state_ref)?; - self.last_tick = last_tick; + for _ in 0..loops { + scene.tick(state_ref, ctx)?; + } } - - for _ in 0..self.loops { - scene.tick(state_ref, ctx)?; - } - self.fps.tick_count = self.fps.tick_count.saturating_add(self.loops as u32); + self.fps.tick_count = self.fps.tick_count.saturating_add(loops); } - TimingMode::FrameSynchronized => { + } + TimingMode::FrameSynchronized => { + if let Some(scene) = self.scene.get_mut() { + let state_ref = self.state.get_mut(); scene.tick(state_ref, ctx)?; } } } - let next_scene = std::mem::take(&mut state_ref.next_scene); + let next_scene = std::mem::take(&mut self.state.get_mut().next_scene); if let Some(mut next_scene) = next_scene { - next_scene.init(state_ref, ctx)?; + { + let state_ref = self.state.get_mut(); + next_scene.init(state_ref, ctx)?; + state_ref.frame_time = 0.0; + } *self.scene.get_mut() = Some(next_scene); self.loops = 0; - state_ref.frame_time = 0.0; + self.pacer.reset(); } Ok(()) @@ -207,45 +197,32 @@ impl Game { pub(crate) fn draw(&mut self, ctx: &mut Context) -> GameResult { let vsync_mode = self.state.get_mut().settings.vsync_mode; + + // Set swap mode + (for VRR) deadline-pace before rendering. match vsync_mode { VSyncMode::Uncapped => { graphics::set_swap_mode(ctx, SwapMode::Immediate)?; - self.present = true; } VSyncMode::VSync => { graphics::set_swap_mode(ctx, SwapMode::VSync)?; - self.present = true; } - _ => unsafe { + _ => { graphics::set_swap_mode(ctx, SwapMode::Adaptive)?; - self.present = false; - let divisor = match vsync_mode { + let divisor: u32 = match vsync_mode { VSyncMode::VRRTickSync1x => 1, VSyncMode::VRRTickSync2x => 2, VSyncMode::VRRTickSync3x => 3, - _ => std::hint::unreachable_unchecked(), + _ => 1, }; - let delta = self.state.get_mut().settings.timing_mode.get_delta(); - let delta = (delta / divisor) as u64; - - let now = self.start_time.elapsed().as_nanos(); - if now > self.next_tick_draw + delta as u128 * 4 { - self.next_tick_draw = now; + let base_delta_ns = self.state.get_mut().settings.timing_mode.get_delta() as u64; + if base_delta_ns > 0 { + let period = Duration::from_nanos((base_delta_ns / divisor as u64).max(1)); + self.pacer.set_present_period(period); + self.pacer.wait_for_present(); } - - while self.start_time.elapsed().as_nanos() >= self.next_tick_draw { - self.next_tick_draw += delta as u128; - self.present = true; - } - }, - } - - if !self.present { - std::thread::sleep(Duration::from_millis(2)); - self.loops = 0; - return Ok(()); + } } if ctx.headless { @@ -255,18 +232,9 @@ impl Game { } if self.state.get_mut().settings.timing_mode != TimingMode::FrameSynchronized { - let mut elapsed = self.start_time.elapsed().as_nanos(); - - // Even with the non-monotonic Instant mitigation at the start of the event loop, there's still a chance of it not working. - // This check here should trigger if that happens and makes sure there's no panic from an underflow. - if elapsed < self.last_tick { - elapsed = self.last_tick; - } - - let n1 = (elapsed - self.last_tick) as f64; - let n2 = (self.next_tick - self.last_tick) as f64; + let alpha = self.pacer.interpolation_alpha(); self.state.get_mut().frame_time = - if self.state.get_mut().settings.motion_interpolation { n1 / n2 } else { 1.0 }; + if self.state.get_mut().settings.motion_interpolation { alpha } else { 1.0 }; } unsafe { G_MAG = if self.state.get_mut().settings.subpixel_coords { self.state.get_mut().scale } else { 1.0 }; @@ -301,7 +269,9 @@ impl Game { self.ui.draw(state_ref, ctx, scene)?; } + let pre_finalize = Instant::now(); graphics::finalize_frame(ctx)?; + self.pacer.record_swap_latency(Instant::now().saturating_duration_since(pre_finalize)); Ok(()) } @@ -319,6 +289,7 @@ impl BackendCallbacks for Game { ctx.suspended = false; state_ref.sound_manager.resume(); self.loops = 0; + self.pacer.reset(); } Ok(()) }