Improve frame pacing implementation

This commit is contained in:
Alula
2026-05-04 05:45:31 +02:00
parent 7984f304be
commit f64e256641
3 changed files with 290 additions and 85 deletions
+233
View File
@@ -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()
}
}
+1
View File
@@ -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;
+56 -85
View File
@@ -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<SharedGameState>,
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(())
}