mirror of
https://github.com/doukutsu-rs/doukutsu-rs.git
synced 2026-08-24 13:02:49 -07:00
Improve frame pacing implementation
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user