120Hz: early waker thread + render freshness guard (vsync-edge pacing)
The Choreographer vsync callback reaches the UI thread only 2.5-6 ms AFTER the vsync edge (measured on the phone). With a 2.5-5 ms Skia render plus present, that left no phase margin inside the 8.3 ms slot: 51% of frames landed one slot late (judder) and ~4% two slots late (hitches), on every build tested so far. Attempted fixes, all measured and retired (see commit history): - render-ahead hold (present previous frame at next render start): the held present lands after the NEXT wake's 3.2-6 ms Choreographer latency -> 52% one-slot late. - present-at-end + present GATE (sleep on the UI thread to quantize the present to the vsync grid): the gate sleep (2.5-4 ms) added to the slot budget (wake 5.3 + core 3 + gate 3 + present 1 = 12.3 ms > 8.3 ms) -> cadence collapsed to 60 Hz. Working fix (this commit): - vsync.rs: an 'early waker' thread learns the vsync grid from the callback's frame_time_nanos (vkprobe::note_vsync_edge) and wakes the main looper ~0.8 ms BEFORE each edge while animating. Render start moves from edge+2.5..6 to edge-0.8..+0.5, so the present lands at a natural ~edge+3..5 ms phase with no UI-thread sleep. - lib.rs: a render freshness guard (min gap = period - 1.5 ms) makes the FIRST wake of each slot (early or the late Choreographer fallback) render and rejects the second (double render = 60 Hz collapse). Threshold sits between late-after-early (5.8 ms, reject) and early-after-late (7.5 ms, accept). - lib.rs: locked renders no longer consume pending_redraw (it carried the motion invalidations; consuming it starved the follow-up unlocked iteration -> 60 Hz collapse). - stg.rs: IT lines now log the render decision (locked/saw_wake/ pr_before/do_render); vkprobe logs gate sleep (GATE_US, now 0). Measured (phone, 8580-file grid, slow 4 s drags): in-burst cadence: 8.29-8.44 ms median, p90 8.6-10.2 ms (was 8.36 median / 10.2 p90 with 3.7-20% two-slot gaps) in-burst >16.7 ms gaps: 0-2 per burst (was 3-13) present-to-present: 27% exact one slot, 33% one-slot-late steady state (imvisible), 3% f-jumps (Skia core 2.5-5 ms variance). Residual judder lever: Skia core cost variance (next step). Test hygiene reminders (HANDOFF 8.10): verify ResumedActivity, keep the task clean (sz=1), record the perf-log byte offset before each run (the file accumulates across process launches).
This commit is contained in:
parent
99d155e859
commit
7c2d0f354e
|
|
@ -268,22 +268,91 @@ impl i_slint_core::platform::Platform for AndroidPlatform {
|
||||||
// to one slot of extra input latency.
|
// to one slot of extra input latency.
|
||||||
let do_render = if locked {
|
let do_render = if locked {
|
||||||
// Present EVERY vsync while animating (not only on content
|
// Present EVERY vsync while animating (not only on content
|
||||||
// change): the regular 120 Hz present stream keeps the Mali
|
// change): the regular 120 Hz present stream locks the
|
||||||
// present path warm (PRE ≈ 0.4 ms) and locks the display
|
// display cadence. Re-rendering unchanged content is cheap
|
||||||
// cadence. Re-rendering unchanged content is cheap with
|
// with Skia's incremental pipeline.
|
||||||
// Skia's incremental pipeline.
|
//
|
||||||
self.window.pending_redraw.take();
|
// IMPORTANT: do NOT `pending_redraw.take()` here. The render
|
||||||
saw_wake
|
// is driven by the vsync wake, but the pending flag also
|
||||||
|
// carries the motion events' invalidations. Consuming it
|
||||||
|
// here starves the follow-up unlocked iteration (which runs
|
||||||
|
// 0.4 ms later, before the next motion event arrives) of
|
||||||
|
// anything to render, collapsing the cadence to 60 Hz
|
||||||
|
// (one render per two vsyncs). Letting the flag survive
|
||||||
|
// means the next iteration renders too; if the content is
|
||||||
|
// unchanged the extra render is invisible.
|
||||||
|
//
|
||||||
|
// FRESHNESS GUARD: with the early waker (vsync.rs), TWO
|
||||||
|
// wakes can arrive per slot — the early one (~0.8 ms BEFORE
|
||||||
|
// the vsync edge) and the Choreographer's late fallback
|
||||||
|
// (~5 ms after it). The first one of each slot renders;
|
||||||
|
// the second must NOT (double render = 2× the slot budget =
|
||||||
|
// 60 Hz collapse). The intervals (measured):
|
||||||
|
// late-after-early = ~5.8 ms → must be REJECTED
|
||||||
|
// early-after-late = ~7.5 ms → must be ACCEPTED
|
||||||
|
// same-kind = ~8.33 ms → ACCEPTED
|
||||||
|
// Threshold `period − 1.5 ms` (≈6.8 ms) sits between 5.8
|
||||||
|
// and 7.5 with ~0.7-1.0 ms of jitter margin on both sides.
|
||||||
|
// (A 0.7-period threshold rejects early-after-late and a
|
||||||
|
// 1.0-period threshold locks the cadence onto the late
|
||||||
|
// wakes — both measured to fragment the burst.)
|
||||||
|
let __now_ns = i_slint_renderer_skia::wgpu_30_surface::vkprobe::monotonic_ns();
|
||||||
|
let __period_ns = i_slint_renderer_skia::wgpu_30_surface::vkprobe::VSYNC_PERIOD_NS
|
||||||
|
.load(std::sync::atomic::Ordering::Relaxed);
|
||||||
|
let __last = i_slint_renderer_skia::wgpu_30_surface::vkprobe::LAST_RENDER_START_NS
|
||||||
|
.load(std::sync::atomic::Ordering::Relaxed);
|
||||||
|
let __min_gap = __period_ns.saturating_sub(1_500_000);
|
||||||
|
let __fresh = __period_ns == 0
|
||||||
|
|| __now_ns.saturating_sub(__last) >= __min_gap;
|
||||||
|
if __fresh {
|
||||||
|
i_slint_renderer_skia::wgpu_30_surface::vkprobe::LAST_RENDER_START_NS
|
||||||
|
.store(__now_ns, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
saw_wake && __fresh
|
||||||
} else {
|
} else {
|
||||||
self.window.pending_redraw.get()
|
self.window.pending_redraw.get()
|
||||||
};
|
};
|
||||||
if do_render {
|
if do_render {
|
||||||
self.window.pending_redraw.take();
|
// Locked (vsync-wake) renders leave the flag set so the
|
||||||
// RENDER_AHEAD: activate the hold-without-present pipeline
|
// follow-up unlocked iteration renders too (60 Hz collapse,
|
||||||
// only for locked (animating) renders; an unlocked render
|
// see the do_render note); unlocked renders consume it.
|
||||||
// presents immediately (see RENDER_AHEAD_ACTIVE).
|
if !locked {
|
||||||
|
self.window.pending_redraw.take();
|
||||||
|
}
|
||||||
|
// RENDER_AHEAD (hold-without-present) is DISABLED: the held
|
||||||
|
// frame is presented at the start of the NEXT render, i.e.
|
||||||
|
// after the next vsync wake's Choreographer latency
|
||||||
|
// (measured 3.2–6 ms on this device) plus the present cost —
|
||||||
|
// landing at vsync+12.4–15.2 ms against a 13.8 ms latch
|
||||||
|
// deadline: a 1.4 ms margin that missed 52% of frames
|
||||||
|
// (one slot late) during real slow scrolls. Presenting at
|
||||||
|
// the END of the render instead still left 51% one slot
|
||||||
|
// late (the wake-arrival latency 2.4–6 ms + render 3–9 ms
|
||||||
|
// make the present phase random). The actual fix is the
|
||||||
|
// PRESENT GATE: the render still runs on the vsync wake
|
||||||
|
// (locked pacing kept), but `Queue::present` sleeps to a
|
||||||
|
// fixed vsync-grid phase (next edge + 1.5 ms, from the
|
||||||
|
// Choreographer frameTimeNanos), so cadence is exactly one
|
||||||
|
// period. See `vkprobe::present_gate` and
|
||||||
|
// docs/BUGS/scroll-jank-120hz.md §9.
|
||||||
i_slint_renderer_skia::wgpu_30_surface::vkprobe::RENDER_AHEAD_ACTIVE
|
i_slint_renderer_skia::wgpu_30_surface::vkprobe::RENDER_AHEAD_ACTIVE
|
||||||
.store(locked, std::sync::atomic::Ordering::Relaxed);
|
.store(false, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
// PRESENT GATE: quantize the present to the vsync grid while
|
||||||
|
// animating (drag/fling) — including the follow-up UNLOCKED
|
||||||
|
// renders (see the do_render note above), so every present
|
||||||
|
// in the burst lands on the same grid phase. One-off
|
||||||
|
// redraws (taps, model updates) stay ungated for minimum
|
||||||
|
// latency.
|
||||||
|
// PRESENT GATE: DISABLED. Sleeping on the UI thread to
|
||||||
|
// quantize the present proved counterproductive — every ms
|
||||||
|
// of gate sleep adds to the slot budget (wake 5.3 + core 3
|
||||||
|
// + gate 3 + present 1 = 12.3 ms > 8.3 ms slot → 60 Hz
|
||||||
|
// collapse, measured). The early waker (vsync.rs) instead
|
||||||
|
// moves the RENDER START to the vsync edge, so the present
|
||||||
|
// lands at a constant natural phase (~edge+3 ms) with no
|
||||||
|
// sleep at all. The gate code stays for reference.
|
||||||
|
i_slint_renderer_skia::wgpu_30_surface::vkprobe::PRESENT_GATE_ON
|
||||||
|
.store(false, std::sync::atomic::Ordering::Relaxed);
|
||||||
stg::S_RENDER_START.store(t2, std::sync::atomic::Ordering::Relaxed);
|
stg::S_RENDER_START.store(t2, std::sync::atomic::Ordering::Relaxed);
|
||||||
self.window.do_render()?;
|
self.window.do_render()?;
|
||||||
let t3 = stg::now_us();
|
let t3 = stg::now_us();
|
||||||
|
|
@ -320,6 +389,7 @@ impl i_slint_core::platform::Platform for AndroidPlatform {
|
||||||
stg::input_snapshot(),
|
stg::input_snapshot(),
|
||||||
stg::timeout_snapshot(),
|
stg::timeout_snapshot(),
|
||||||
stg::wake_snapshot(),
|
stg::wake_snapshot(),
|
||||||
|
(locked, saw_wake, pr_before, do_render),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -241,6 +241,7 @@ pub fn log_iter(
|
||||||
inp: (u32, u64, u64),
|
inp: (u32, u64, u64),
|
||||||
tocls: (u64, u64, u64),
|
tocls: (u64, u64, u64),
|
||||||
wk: (u64, u64, u64),
|
wk: (u64, u64, u64),
|
||||||
|
dec: (bool, bool, bool, bool),
|
||||||
) {
|
) {
|
||||||
// Jank-hunt fix: emit through the log facade (nblog) instead of a private
|
// Jank-hunt fix: emit through the log facade (nblog) instead of a private
|
||||||
// BufWriter — two independent buffers appending to the same file made IT
|
// BufWriter — two independent buffers appending to the same file made IT
|
||||||
|
|
@ -249,9 +250,10 @@ pub fn log_iter(
|
||||||
let live_rows = i_slint_core::model::repeater::LV_LIVE_ROWS
|
let live_rows = i_slint_core::model::repeater::LV_LIVE_ROWS
|
||||||
.load(std::sync::atomic::Ordering::Relaxed);
|
.load(std::sync::atomic::Ordering::Relaxed);
|
||||||
log::info!(
|
log::info!(
|
||||||
"IT {} poll={}us nev={} fe={}us timers={}us ev={}us render={}us anim={} to={}ms lv1={}/{} e={}/{}/{} ent={}/{} lv2={}/{} flk={}/{} inb={}/{} toc={}/{}/{} wk={}/{}/{} live={}",
|
"IT {} poll={}us nev={} fe={}us timers={}us ev={}us render={}us anim={} to={}ms lv1={}/{} e={}/{}/{} ent={}/{} lv2={}/{} flk={}/{} inb={}/{} toc={}/{}/{} wk={}/{}/{} live={} dec={}/{}/{}/{}",
|
||||||
start_us, poll_us, nev, first_ev_us, timer_us, event_us, render_us, animating as u8, timeout_ms,
|
start_us, poll_us, nev, first_ev_us, timer_us, event_us, render_us, animating as u8, timeout_ms,
|
||||||
lv1.0, lv1.1, lv1.2, lv1.3, lv1.4, lv1.5, lv1.6, lv2.0, lv2.1, flk.0, flk.1, inp.0, inp.2, tocls.0, tocls.1, tocls.2, wk.0, wk.1, wk.2, live_rows
|
lv1.0, lv1.1, lv1.2, lv1.3, lv1.4, lv1.5, lv1.6, lv2.0, lv2.1, flk.0, flk.1, inp.0, inp.2, tocls.0, tocls.1, tocls.2, wk.0, wk.1, wk.2, live_rows,
|
||||||
|
dec.0 as u8, dec.1 as u8, dec.2 as u8, dec.3 as u8
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ struct State {
|
||||||
pub struct VsyncDriver {
|
pub struct VsyncDriver {
|
||||||
shared: Arc<Shared>,
|
shared: Arc<Shared>,
|
||||||
handle: Option<JoinHandle<()>>,
|
handle: Option<JoinHandle<()>>,
|
||||||
|
waker_handle: Option<JoinHandle<()>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl VsyncDriver {
|
impl VsyncDriver {
|
||||||
|
|
@ -63,7 +64,7 @@ impl VsyncDriver {
|
||||||
cond: Condvar::new(),
|
cond: Condvar::new(),
|
||||||
driving: AtomicBool::new(false),
|
driving: AtomicBool::new(false),
|
||||||
looper: Mutex::new(None),
|
looper: Mutex::new(None),
|
||||||
main_waker,
|
main_waker: main_waker.clone(),
|
||||||
choreographer: std::ptr::null_mut(),
|
choreographer: std::ptr::null_mut(),
|
||||||
});
|
});
|
||||||
let handle = std::thread::Builder::new()
|
let handle = std::thread::Builder::new()
|
||||||
|
|
@ -73,7 +74,28 @@ impl VsyncDriver {
|
||||||
move || vsync_thread(shared)
|
move || vsync_thread(shared)
|
||||||
})
|
})
|
||||||
.ok();
|
.ok();
|
||||||
Self { shared, handle }
|
// EARLY WAKER (the real 120Hz fix, part 2): the Choreographer
|
||||||
|
// callback only reaches the UI thread ~2.5-6 ms AFTER the vsync
|
||||||
|
// edge — with a 2.5-5 ms Skia render plus present, that leaves
|
||||||
|
// no phase margin inside the 8.3 ms slot (measured: 51% of frames
|
||||||
|
// landed one slot late). This thread learns the vsync grid from
|
||||||
|
// the callback's `frame_time_nanos` (vkprobe::note_vsync_edge) and
|
||||||
|
// wakes the main loop slightly BEFORE each edge, so render start ≈
|
||||||
|
// the edge and present lands at a constant ~edge+3 ms phase.
|
||||||
|
// The Choreographer's late wake remains as fallback (the render
|
||||||
|
// freshness guard in the event loop prevents double renders).
|
||||||
|
let waker_handle = std::thread::Builder::new()
|
||||||
|
.name("slint early-waker".into())
|
||||||
|
.spawn({
|
||||||
|
let shared = shared.clone();
|
||||||
|
move || early_waker_thread(shared)
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
Self {
|
||||||
|
shared,
|
||||||
|
handle,
|
||||||
|
waker_handle,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_driving(&self) -> bool {
|
pub fn is_driving(&self) -> bool {
|
||||||
|
|
@ -112,6 +134,62 @@ impl Drop for VsyncDriver {
|
||||||
if let Some(handle) = self.handle.take() {
|
if let Some(handle) = self.handle.take() {
|
||||||
let _ = handle.join();
|
let _ = handle.join();
|
||||||
}
|
}
|
||||||
|
if let Some(handle) = self.waker_handle.take() {
|
||||||
|
let _ = handle.join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wakes the main loop a few hundred microseconds before each vsync edge
|
||||||
|
/// (see the `early_waker_handle` docs in `VsyncDriver::new`). Runs at 120 Hz
|
||||||
|
/// only while animating; otherwise parks in short sleeps.
|
||||||
|
fn early_waker_thread(shared: Arc<Shared>) {
|
||||||
|
let waker = shared.main_waker.clone();
|
||||||
|
// Wake slightly BEFORE the edge so poll-returns + render-start lands on
|
||||||
|
// the edge; with a 2.5-5 ms Skia render the present then sits at a
|
||||||
|
// constant ~edge+3 ms (middle of the slot, ~2.5 ms of deadline margin
|
||||||
|
// both ways).
|
||||||
|
const LEAD_NS: u64 = 800_000;
|
||||||
|
loop {
|
||||||
|
{
|
||||||
|
let state = shared.state.lock().unwrap();
|
||||||
|
if state.quit {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if !state.animating {
|
||||||
|
drop(state);
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let edge = i_slint_renderer_skia::wgpu_30_surface::vkprobe::VSYNC_EDGE_NS
|
||||||
|
.load(Ordering::Relaxed);
|
||||||
|
let period = i_slint_renderer_skia::wgpu_30_surface::vkprobe::VSYNC_PERIOD_NS
|
||||||
|
.load(Ordering::Relaxed);
|
||||||
|
let now = i_slint_renderer_skia::wgpu_30_surface::vkprobe::monotonic_ns();
|
||||||
|
// Grid unknown or stale (no callbacks for ~100 ms, e.g. the app
|
||||||
|
// went to the background): park briefly and re-check.
|
||||||
|
if edge == 0 || period == 0 || now == 0 || now > edge + 100_000_000 {
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Next edge at/after (now + LEAD).
|
||||||
|
let mut target = edge + period;
|
||||||
|
while target < now.saturating_add(LEAD_NS) {
|
||||||
|
target += period;
|
||||||
|
}
|
||||||
|
let delay = target.saturating_sub(now + LEAD_NS);
|
||||||
|
if delay > 0 {
|
||||||
|
std::thread::sleep(std::time::Duration::from_nanos(delay));
|
||||||
|
}
|
||||||
|
let landed = i_slint_renderer_skia::wgpu_30_surface::vkprobe::monotonic_ns()
|
||||||
|
.saturating_sub(target);
|
||||||
|
// Wake only if we landed within ~1 ms of the edge. If we overshot
|
||||||
|
// (CPU starvation), the Choreographer's late wake (~5 ms after the
|
||||||
|
// edge) covers the frame instead.
|
||||||
|
if landed <= 1_000_000 {
|
||||||
|
waker.wake();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -150,6 +228,12 @@ unsafe extern "C" fn frame_callback(
|
||||||
// before the wake would push the main loop's render start (and thus the
|
// before the wake would push the main loop's render start (and thus the
|
||||||
// present phase) later in the slot.
|
// present phase) later in the slot.
|
||||||
shared.main_waker.wake();
|
shared.main_waker.wake();
|
||||||
|
// PRESENT GATE: publish the exact vsync edge (CLOCK_MONOTONIC ns — the
|
||||||
|
// same epoch as `mon_ns()` above) so the renderer can quantize its
|
||||||
|
// `Queue::present` to the vsync grid (wgpu_30_surface::vkprobe).
|
||||||
|
i_slint_renderer_skia::wgpu_30_surface::vkprobe::note_vsync_edge(
|
||||||
|
frame_time_nanos as u64,
|
||||||
|
);
|
||||||
let mon = unsafe { mon_ns() };
|
let mon = unsafe { mon_ns() };
|
||||||
if mon > 0 {
|
if mon > 0 {
|
||||||
log::info!("VSYNC ftn={} mon={}", frame_time_nanos, mon);
|
log::info!("VSYNC ftn={} mon={}", frame_time_nanos, mon);
|
||||||
|
|
|
||||||
94
vendor/i-slint-renderer-skia/wgpu_30_surface.rs
vendored
94
vendor/i-slint-renderer-skia/wgpu_30_surface.rs
vendored
|
|
@ -61,6 +61,88 @@ pub mod vkprobe {
|
||||||
/// renders coming) and never reach the screen.
|
/// renders coming) and never reach the screen.
|
||||||
pub static RENDER_AHEAD_ACTIVE: std::sync::atomic::AtomicBool =
|
pub static RENDER_AHEAD_ACTIVE: std::sync::atomic::AtomicBool =
|
||||||
std::sync::atomic::AtomicBool::new(false);
|
std::sync::atomic::AtomicBool::new(false);
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// PRESENT GATE (vsync-grid quantization, the real 120Hz fix).
|
||||||
|
//
|
||||||
|
// Measured on the phone: the Choreographer vsync callback reaches the
|
||||||
|
// UI thread 2.4–6 ms (median 3.3 ms) after the vsync edge, and the
|
||||||
|
// render takes 3–9 ms — so each frame's present lands at a RANDOM
|
||||||
|
// phase 8–14 ms after the edge against a 13.8 ms latch deadline.
|
||||||
|
// 51% of frames land one slot late (judder) and ~4% two slots late
|
||||||
|
// (visible hitch), independent of where in the render() call the
|
||||||
|
// present happens.
|
||||||
|
//
|
||||||
|
// Fix: quantize the PRESENT to the vsync grid. The vsync driver hands
|
||||||
|
// us the exact edge (Choreographer `frame_time_nanos`, CLOCK_MONOTONIC)
|
||||||
|
// via `note_vsync_edge`; before `Queue::present` we sleep (≤ ~2 ms) so
|
||||||
|
// the present lands at (next edge + 1.5 ms) — constant phase, ~4 ms of
|
||||||
|
// deadline margin, frame-to-frame gaps of exactly one period. See
|
||||||
|
// docs/BUGS/scroll-jank-120hz.md §9.
|
||||||
|
/// Latest Choreographer `frame_time_nanos` (CLOCK_MONOTONIC ns).
|
||||||
|
pub static VSYNC_EDGE_NS: AtomicU64 = AtomicU64::new(0);
|
||||||
|
/// Detected vsync period in ns (from consecutive callback deltas).
|
||||||
|
pub static VSYNC_PERIOD_NS: AtomicU64 = AtomicU64::new(0);
|
||||||
|
/// Gate enabled for the current render (set per-frame by the event
|
||||||
|
/// loop; true while animating/locked, false for one-off redraws so
|
||||||
|
/// taps and model updates keep minimum latency).
|
||||||
|
pub static PRESENT_GATE_ON: std::sync::atomic::AtomicBool =
|
||||||
|
std::sync::atomic::AtomicBool::new(false);
|
||||||
|
/// Microseconds the most recent `present_gate()` slept (0 = no sleep).
|
||||||
|
pub static GATE_US: AtomicU64 = AtomicU64::new(0);
|
||||||
|
/// CLOCK_MONOTONIC ns when the last vsync-wake (locked) render STARTED.
|
||||||
|
/// The event loop uses it to skip a second wake (e.g. the Choreographer's
|
||||||
|
/// late fallback wake arriving ~5 ms after the early-waker's edge wake
|
||||||
|
/// already triggered a render) instead of double-rendering the slot.
|
||||||
|
pub static LAST_RENDER_START_NS: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
|
/// Called from the vsync driver on every Choreographer callback.
|
||||||
|
pub fn note_vsync_edge(ftn_ns: u64) {
|
||||||
|
let prev = VSYNC_EDGE_NS.swap(ftn_ns, Ordering::Relaxed);
|
||||||
|
if prev > 0 {
|
||||||
|
let d = ftn_ns.saturating_sub(prev);
|
||||||
|
if (4_000_000..20_000_000).contains(&d) {
|
||||||
|
VSYNC_PERIOD_NS.store(d, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sleep so the `Queue::present` that follows lands at (latest vsync
|
||||||
|
/// edge + one period + 1.5 ms). No-op when disabled, when the grid is
|
||||||
|
/// unknown/stale (no recent callbacks), or when already past target.
|
||||||
|
pub fn present_gate() {
|
||||||
|
if !PRESENT_GATE_ON.load(Ordering::Acquire) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let edge = VSYNC_EDGE_NS.load(Ordering::Relaxed);
|
||||||
|
let period = VSYNC_PERIOD_NS.load(Ordering::Relaxed);
|
||||||
|
if edge == 0 || period == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let now = monotonic_ns();
|
||||||
|
if now == 0 || now > edge + 4 * period {
|
||||||
|
return; // stale grid (e.g. after resume) — don't gate
|
||||||
|
}
|
||||||
|
// Earliest grid phase (a vsync edge + 1.5 ms) strictly after now:
|
||||||
|
// T = edge + m*period + 1.5 ms with m = floor((now-edge-1.5ms)
|
||||||
|
// / period) + 1.
|
||||||
|
// NOTE: a Choreographer callback can fire MID-RENDER (the next
|
||||||
|
// edge's callback arrives at edge+~11.6 ms while a slow render is
|
||||||
|
// still running), which advances `edge` by one period — the
|
||||||
|
// smallest-phase rule above then just yields the same absolute T, so
|
||||||
|
// the mid-render callback can never add an extra slot of delay.
|
||||||
|
const PHASE_NS: u64 = 1_500_000;
|
||||||
|
let rel = now.saturating_sub(edge).saturating_sub(PHASE_NS) / period;
|
||||||
|
let target = edge + (rel + 1) * period + PHASE_NS;
|
||||||
|
let wait = target.saturating_sub(now);
|
||||||
|
// Only sleep for close phases; if the next grid phase is >4 ms out
|
||||||
|
// this frame is already running late for it — present immediately
|
||||||
|
// (the next frame re-syncs to the grid).
|
||||||
|
let slept = if wait > 200_000 && wait <= 4_000_000 { wait } else { 0 };
|
||||||
|
GATE_US.store(slept / 1000, Ordering::Relaxed);
|
||||||
|
if slept > 0 {
|
||||||
|
std::thread::sleep(std::time::Duration::from_nanos(slept));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn scn_reset() {
|
pub fn scn_reset() {
|
||||||
crate::scn::reset();
|
crate::scn::reset();
|
||||||
|
|
@ -638,6 +720,10 @@ impl crate::Surface for WGPUSurface {
|
||||||
} else {
|
} else {
|
||||||
// Unlocked render: present immediately. (Any frame left in the
|
// Unlocked render: present immediately. (Any frame left in the
|
||||||
// held slot was already presented at the top of this call.)
|
// held slot was already presented at the top of this call.)
|
||||||
|
// PRESENT GATE: while animating, land this present at a fixed
|
||||||
|
// vsync-grid phase (next edge + 1.5 ms) so frame-to-frame
|
||||||
|
// cadence is exactly one period (see `present_gate`).
|
||||||
|
vkprobe::present_gate();
|
||||||
self.wgpu.queue.present(frame);
|
self.wgpu.queue.present(frame);
|
||||||
vkprobe::now_us().saturating_sub(__t_pre)
|
vkprobe::now_us().saturating_sub(__t_pre)
|
||||||
};
|
};
|
||||||
|
|
@ -649,7 +735,13 @@ impl crate::Surface for WGPUSurface {
|
||||||
// against VSYNC mon= stamps: phase = pres_mon - previous_vsync_mon.
|
// against VSYNC mon= stamps: phase = pres_mon - previous_vsync_mon.
|
||||||
let __mon = vkprobe::monotonic_ns();
|
let __mon = vkprobe::monotonic_ns();
|
||||||
if __mon > 0 {
|
if __mon > 0 {
|
||||||
log::info!("PRES f={} mon={} pre_us={}", __f, __mon, __pre_us);
|
log::info!(
|
||||||
|
"PRES f={} mon={} pre_us={} gate_us={}",
|
||||||
|
__f,
|
||||||
|
__mon,
|
||||||
|
__pre_us,
|
||||||
|
vkprobe::GATE_US.load(std::sync::atomic::Ordering::Relaxed)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user