// Copyright © SixtyFPS GmbH // SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 //! Drives animation frames from the display's vsync using the Android Choreographer. //! Optimized for ultra-low latency: self-re-entrant Choreographer callbacks wake //! the main loop directly on vsync without thread round-trip serialization delay. use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Condvar, Mutex}; use std::thread::JoinHandle; use crate::android_activity::AndroidAppWaker; use crate::ndk::looper::{ForeignLooper, ThreadLooper}; mod ffi { use core::ffi::{c_long, c_void}; #[repr(C)] pub struct AChoreographer { _unused: [u8; 0], } pub type FrameCallback = unsafe extern "C" fn(frame_time_nanos: c_long, data: *mut c_void); #[link(name = "android")] unsafe extern "C" { pub fn AChoreographer_getInstance() -> *mut AChoreographer; pub fn AChoreographer_postFrameCallback( choreographer: *mut AChoreographer, callback: FrameCallback, data: *mut c_void, ); } } struct Shared { state: Mutex, cond: Condvar, driving: AtomicBool, looper: Mutex>, main_waker: AndroidAppWaker, choreographer: *mut ffi::AChoreographer, } unsafe impl Send for Shared {} unsafe impl Sync for Shared {} #[derive(Default)] struct State { animating: bool, quit: bool, callback_posted: bool, } pub struct VsyncDriver { shared: Arc, handle: Option>, } impl VsyncDriver { pub fn new(main_waker: AndroidAppWaker) -> Self { let shared = Arc::new(Shared { state: Mutex::new(State::default()), cond: Condvar::new(), driving: AtomicBool::new(false), looper: Mutex::new(None), main_waker, choreographer: std::ptr::null_mut(), }); let handle = std::thread::Builder::new() .name("slint vsync".into()) .spawn({ let shared = shared.clone(); move || vsync_thread(shared) }) .ok(); Self { shared, handle } } pub fn is_driving(&self) -> bool { self.shared.driving.load(Ordering::Acquire) } pub fn set_animating(&self, animating: bool) { let mut state = self.shared.state.lock().unwrap(); if state.animating != animating { state.animating = animating; drop(state); if animating { // Wake the vsync thread: it may be parked in `poll_once` (the // Choreographer queue drained once the previous animation's // self-sustaining callback chain ended). Waking the looper is // what makes it re-check `state.animating` and post the first // frame callback for this new animation. if let Some(looper) = self.shared.looper.lock().unwrap().as_ref() { looper.wake(); } } self.shared.cond.notify_one(); } } } impl Drop for VsyncDriver { fn drop(&mut self) { let mut state = self.shared.state.lock().unwrap(); state.quit = true; drop(state); self.shared.cond.notify_one(); if let Some(looper) = self.shared.looper.lock().unwrap().as_ref() { looper.wake(); } if let Some(handle) = self.handle.take() { let _ = handle.join(); } } } // Jank-hunt: frame-alignment probes. `frame_time_nanos` is the Choreographer // vsync timestamp (System.nanoTime epoch = CLOCK_MONOTONIC on Android); we // ALSO stamp CLOCK_MONOTONIC here so VSYNC and PRES lines share one clock and // we can measure, per presented frame, how far past the vsync edge it landed // (phase). If presents systematically land in the back half of the 8.3 ms // window the compositor shows them one vsync late → effective 60 Hz + judder // even though our frame-to-frame cadence looks like 120 Hz. unsafe fn mon_ns() -> u64 { #[repr(C)] struct Ts { tv_sec: i64, tv_nsec: i64, } unsafe extern "C" { fn clock_gettime(clkid: i32, tp: *mut Ts) -> i32; } const CLOCK_MONOTONIC: i32 = 1; let mut ts = Ts { tv_sec: 0, tv_nsec: 0 }; if unsafe { clock_gettime(CLOCK_MONOTONIC, &mut ts) } == 0 { ts.tv_sec as u64 * 1_000_000_000 + ts.tv_nsec as u64 } else { 0 } } unsafe extern "C" fn frame_callback( frame_time_nanos: core::ffi::c_long, data: *mut core::ffi::c_void, ) { let shared = &*(data as *const Shared); // CRITICAL PATH: wake the main loop FIRST. The VSYNC log line below is // a file write (50-200us typical, worse under I/O pressure) — doing it // before the wake would push the main loop's render start (and thus the // present phase) later in the slot. shared.main_waker.wake(); let mon = unsafe { mon_ns() }; if mon > 0 { log::info!("VSYNC ftn={} mon={}", frame_time_nanos, mon); } let mut state = shared.state.lock().unwrap(); if state.animating && !state.quit { unsafe { ffi::AChoreographer_postFrameCallback( shared.choreographer, frame_callback, data, ); } } else { state.callback_posted = false; } } fn vsync_thread(shared: Arc) { let looper = ThreadLooper::prepare(); *shared.looper.lock().unwrap() = Some(looper.as_foreign().clone()); let choreographer = unsafe { ffi::AChoreographer_getInstance() }; if choreographer.is_null() { return; } let ptr = Arc::as_ptr(&shared) as *mut Shared; unsafe { (*ptr).choreographer = choreographer; } shared.driving.store(true, Ordering::Release); let raw_shared = Arc::into_raw(shared); loop { { let mut state = unsafe { &(*raw_shared).state }.lock().unwrap(); if state.quit { break; } // Post the FIRST frame callback ONLY when we're animating AND no // callback is already pending. While animating, `frame_callback` // re-posts itself on every vsync (self-sustaining chain), so a // callback is always pending — we must NOT post another here, or // the pending-callback count grows by one every vsync (1, 2, 3, // 4…) and each vsync fires N callbacks → N main-loop wakes with N // unbounded. That was the ~1600 iters/s busy-spin. See // docs/BUGS/scroll-jank-120hz.md. if state.animating && !state.callback_posted { state.callback_posted = true; unsafe { ffi::AChoreographer_postFrameCallback( choreographer, frame_callback, raw_shared as *mut core::ffi::c_void, ); } } } // Block until the next Choreographer message (a vsync fires the frame // callback) or a wake (set_animating/quit). While animating the // self-sustaining chain keeps delivering one message per vsync; when // the animation stops the chain ends and this blocks until the next // animation's set_animating(true) wakes the looper. let _ = looper.poll_once(); } let _ = unsafe { Arc::from_raw(raw_shared) }; }