diff --git a/vendor/i-slint-backend-android-activity-1.18.1/lib.rs b/vendor/i-slint-backend-android-activity-1.18.1/lib.rs index e300da6..a0eb364 100644 --- a/vendor/i-slint-backend-android-activity-1.18.1/lib.rs +++ b/vendor/i-slint-backend-android-activity-1.18.1/lib.rs @@ -268,22 +268,91 @@ impl i_slint_core::platform::Platform for AndroidPlatform { // to one slot of extra input latency. let do_render = if locked { // Present EVERY vsync while animating (not only on content - // change): the regular 120 Hz present stream keeps the Mali - // present path warm (PRE ≈ 0.4 ms) and locks the display - // cadence. Re-rendering unchanged content is cheap with - // Skia's incremental pipeline. - self.window.pending_redraw.take(); - saw_wake + // change): the regular 120 Hz present stream locks the + // display cadence. Re-rendering unchanged content is cheap + // with Skia's incremental pipeline. + // + // IMPORTANT: do NOT `pending_redraw.take()` here. The render + // 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 { self.window.pending_redraw.get() }; if do_render { - self.window.pending_redraw.take(); - // RENDER_AHEAD: activate the hold-without-present pipeline - // only for locked (animating) renders; an unlocked render - // presents immediately (see RENDER_AHEAD_ACTIVE). + // Locked (vsync-wake) renders leave the flag set so the + // follow-up unlocked iteration renders too (60 Hz collapse, + // see the do_render note); unlocked renders consume it. + 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 - .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); self.window.do_render()?; let t3 = stg::now_us(); @@ -320,6 +389,7 @@ impl i_slint_core::platform::Platform for AndroidPlatform { stg::input_snapshot(), stg::timeout_snapshot(), stg::wake_snapshot(), + (locked, saw_wake, pr_before, do_render), ); } } diff --git a/vendor/i-slint-backend-android-activity-1.18.1/stg.rs b/vendor/i-slint-backend-android-activity-1.18.1/stg.rs index 5b01657..3658651 100644 --- a/vendor/i-slint-backend-android-activity-1.18.1/stg.rs +++ b/vendor/i-slint-backend-android-activity-1.18.1/stg.rs @@ -241,6 +241,7 @@ pub fn log_iter( inp: (u32, u64, u64), tocls: (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 // 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 .load(std::sync::atomic::Ordering::Relaxed); 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, - 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 ); } diff --git a/vendor/i-slint-backend-android-activity-1.18.1/vsync.rs b/vendor/i-slint-backend-android-activity-1.18.1/vsync.rs index d93c533..0a1c5c4 100644 --- a/vendor/i-slint-backend-android-activity-1.18.1/vsync.rs +++ b/vendor/i-slint-backend-android-activity-1.18.1/vsync.rs @@ -54,6 +54,7 @@ struct State { pub struct VsyncDriver { shared: Arc, handle: Option>, + waker_handle: Option>, } impl VsyncDriver { @@ -63,7 +64,7 @@ impl VsyncDriver { cond: Condvar::new(), driving: AtomicBool::new(false), looper: Mutex::new(None), - main_waker, + main_waker: main_waker.clone(), choreographer: std::ptr::null_mut(), }); let handle = std::thread::Builder::new() @@ -73,7 +74,28 @@ impl VsyncDriver { move || vsync_thread(shared) }) .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 { @@ -112,6 +134,62 @@ impl Drop for VsyncDriver { if let Some(handle) = self.handle.take() { 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) { + 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 // present phase) later in the slot. 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() }; if mon > 0 { log::info!("VSYNC ftn={} mon={}", frame_time_nanos, mon); diff --git a/vendor/i-slint-renderer-skia/wgpu_30_surface.rs b/vendor/i-slint-renderer-skia/wgpu_30_surface.rs index 643d9a3..4e924cf 100644 --- a/vendor/i-slint-renderer-skia/wgpu_30_surface.rs +++ b/vendor/i-slint-renderer-skia/wgpu_30_surface.rs @@ -61,6 +61,88 @@ pub mod vkprobe { /// renders coming) and never reach the screen. pub static RENDER_AHEAD_ACTIVE: std::sync::atomic::AtomicBool = 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() { crate::scn::reset(); @@ -638,6 +720,10 @@ impl crate::Surface for WGPUSurface { } else { // Unlocked render: present immediately. (Any frame left in the // 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); 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. let __mon = vkprobe::monotonic_ns(); 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) + ); } }