From 26369672f0ac3dc3f1144d6d766f5e55656fec9c Mon Sep 17 00:00:00 2001 From: Pewnack <[email protected]> Date: Tue, 14 Apr 2026 18:46:42 +0200 Subject: [PATCH 1/4] Added FramePacer for macOS to prevent stutter in animations. --- src/native/apple/frameworks.rs | 59 +++++++++++++++++++ src/native/macos.rs | 102 ++++++++++++++++++++++++++++++++- 2 files changed, 160 insertions(+), 1 deletion(-) diff --git a/src/native/apple/frameworks.rs b/src/native/apple/frameworks.rs index d4a1c22c..c5b7985a 100644 --- a/src/native/apple/frameworks.rs +++ b/src/native/apple/frameworks.rs @@ -238,6 +238,65 @@ extern "C" { pub fn MTLCopyAllDevices() -> ObjcId; //TODO: Array } +#[cfg(target_os = "macos")] +#[repr(C)] +#[derive(Clone, Copy, Debug)] +pub struct CVTime { + pub time_value: i64, + pub time_scale: i32, + pub flags: i32, + pub epoch: i64, +} + +#[cfg(target_os = "macos")] +pub type CVDisplayLinkRef = *mut c_void; + +#[cfg(target_os = "macos")] +pub type CVOptionFlags = u64; + +#[cfg(target_os = "macos")] +#[repr(C)] +#[derive(Clone, Copy, Debug)] +pub struct CVTimeStamp { + pub version: u32, + pub video_time_scale: i32, + pub video_time: i64, + pub host_time: u64, + pub rate_scalar: f64, + pub video_refresh_period: i64, + pub smpte_time: i64, + pub flags: u64, + pub reserved: u64, +} + +#[cfg(target_os = "macos")] +pub type CVDisplayLinkOutputCallback = extern "C" fn( + display_link: CVDisplayLinkRef, + now: *const CVTimeStamp, + output_time: *const CVTimeStamp, + flags_in: CVOptionFlags, + flags_out: *mut CVOptionFlags, + display_link_context: *mut c_void, +) -> i32; + +#[cfg(target_os = "macos")] +pub const kCVTimeIsIndefinite: i32 = 1 << 0; + +#[cfg(target_os = "macos")] +#[link(name = "CoreVideo", kind = "framework")] +extern "C" { + pub fn CVDisplayLinkCreateWithActiveCGDisplays(display_link_out: *mut CVDisplayLinkRef) -> i32; + pub fn CVDisplayLinkSetOutputCallback( + display_link: CVDisplayLinkRef, + callback: CVDisplayLinkOutputCallback, + user_info: *mut c_void, + ) -> i32; + pub fn CVDisplayLinkStart(display_link: CVDisplayLinkRef) -> i32; + pub fn CVDisplayLinkStop(display_link: CVDisplayLinkRef) -> i32; + pub fn CVDisplayLinkRelease(display_link: CVDisplayLinkRef); + pub fn CVDisplayLinkGetNominalOutputVideoRefreshPeriod(display_link: CVDisplayLinkRef) -> CVTime; +} + // Foundation #[repr(C)] diff --git a/src/native/macos.rs b/src/native/macos.rs index 29b3e2ef..3d9eb06e 100644 --- a/src/native/macos.rs +++ b/src/native/macos.rs @@ -15,7 +15,10 @@ use { std::{ collections::HashMap, os::raw::c_void, - sync::mpsc::Receiver, + sync::{ + Condvar, Mutex, + mpsc::Receiver, + }, time::{Duration, Instant}, }, }; @@ -1016,6 +1019,94 @@ impl crate::native::Clipboard for MacosClipboard { fn set(&mut self, _data: &str) {} } +struct FrameSignal { + frame_ready: Mutex, + cond: Condvar, +} + +extern "C" fn display_link_frame_ready_callback( + _display_link: CVDisplayLinkRef, + _now: *const CVTimeStamp, + _output_time: *const CVTimeStamp, + _flags_in: CVOptionFlags, + _flags_out: *mut CVOptionFlags, + display_link_context: *mut c_void, +) -> i32 { + unsafe { + let frame_signal = &*(display_link_context as *const FrameSignal); + if let Ok(mut frame_ready) = frame_signal.frame_ready.lock() { + *frame_ready = true; + frame_signal.cond.notify_one(); + } + } + 0 +} + +struct FramePacer { + display_link: CVDisplayLinkRef, + frame_signal: Box, +} + +impl FramePacer { + fn new() -> Option { + unsafe { + let mut display_link: CVDisplayLinkRef = std::ptr::null_mut(); + if CVDisplayLinkCreateWithActiveCGDisplays(&mut display_link as *mut _) != 0 + || display_link.is_null() + { + return None; + } + + let frame_signal = Box::new(FrameSignal { + frame_ready: Mutex::new(false), + cond: Condvar::new(), + }); + + if CVDisplayLinkSetOutputCallback( + display_link, + display_link_frame_ready_callback, + (&*frame_signal as *const FrameSignal) as *mut c_void, + ) != 0 + { + CVDisplayLinkRelease(display_link); + return None; + } + + let _ = CVDisplayLinkStart(display_link); + + Some(Self { + display_link, + frame_signal, + }) + } + } + + fn wait_next_frame(&self) { + let mut frame_ready = match self.frame_signal.frame_ready.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + }; + while !*frame_ready { + frame_ready = match self.frame_signal.cond.wait(frame_ready) { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + }; + } + *frame_ready = false; + } +} + +impl Drop for FramePacer { + fn drop(&mut self) { + unsafe { + if !self.display_link.is_null() { + let _ = CVDisplayLinkStop(self.display_link); + CVDisplayLinkRelease(self.display_link); + } + } + } +} + unsafe extern "C" fn release_data(info: *mut c_void, _: *const c_void, _: usize) { drop(Box::from_raw(info as *mut &[u8])); } @@ -1305,6 +1396,11 @@ where // Basically reimplementing msg_send![ns_app, run] here let distant_future: ObjcId = msg_send![class!(NSDate), distantFuture]; let distant_past: ObjcId = msg_send![class!(NSDate), distantPast]; + let frame_pacer = if conf.platform.blocking_event_loop { + None + } else { + FramePacer::new() + }; let mut done = false; while !(done || crate::native_display().lock().unwrap().quit_ordered) { while let Ok(request) = display.native_requests.try_recv() { @@ -1336,5 +1432,9 @@ where if !conf.platform.blocking_event_loop || display.update_requested { perform_redraw(&mut display, conf.platform.apple_gfx_api, false); } + + if let Some(frame_pacer) = frame_pacer.as_ref() { + frame_pacer.wait_next_frame(); + } } } From d5847f619f569f40a991b7e6352acf2ccc7b80cf Mon Sep 17 00:00:00 2001 From: Pewnack <[email protected]> Date: Tue, 14 Apr 2026 22:00:01 +0200 Subject: [PATCH 2/4] Disable VSync because we are using CVDisplayLInk and we don't want to wait twice. Added timeouts to the frame pacer to prevent waiting forever for a next frame. --- src/native/macos.rs | 42 ++++++++++++++++++++++++++++++------------ 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/src/native/macos.rs b/src/native/macos.rs index 3d9eb06e..19aefaea 100644 --- a/src/native/macos.rs +++ b/src/native/macos.rs @@ -997,7 +997,9 @@ unsafe fn create_opengl_view( display.gl_context = msg_send![gl_context, initWithFormat: glpixelformat_obj shareContext: nil]; - let mut swap_interval = 1; + // Set the swap interval to 0 to disable vsync, because we're using CVDisplayLink for frame pacing + // and we don't want to have an extra vsync delay on top of that. + let mut swap_interval = 0; let () = msg_send![display.gl_context, setValues:&mut swap_interval forParameter:NSOpenGLContextParameterSwapInterval]; @@ -1072,7 +1074,10 @@ impl FramePacer { return None; } - let _ = CVDisplayLinkStart(display_link); + if CVDisplayLinkStart(display_link) != 0 { + CVDisplayLinkRelease(display_link); + return None; + } Some(Self { display_link, @@ -1081,18 +1086,26 @@ impl FramePacer { } } - fn wait_next_frame(&self) { + fn wait_next_frame(&self, timeout: Duration) -> bool { let mut frame_ready = match self.frame_signal.frame_ready.lock() { Ok(guard) => guard, Err(poisoned) => poisoned.into_inner(), }; - while !*frame_ready { - frame_ready = match self.frame_signal.cond.wait(frame_ready) { - Ok(guard) => guard, - Err(poisoned) => poisoned.into_inner(), - }; + if *frame_ready { + *frame_ready = false; + return true; + } + + let (mut frame_ready, _) = match self.frame_signal.cond.wait_timeout(frame_ready, timeout) { + Ok(pair) => pair, + Err(poisoned) => poisoned.into_inner(), + }; + + let ready = *frame_ready; + if ready { + *frame_ready = false; } - *frame_ready = false; + ready } } @@ -1403,6 +1416,14 @@ where }; let mut done = false; while !(done || crate::native_display().lock().unwrap().quit_ordered) { + + // Wait at the top for just in time rendering + if let Some(frame_pacer) = frame_pacer.as_ref() { + if !frame_pacer.wait_next_frame(Duration::from_millis(50)) { + std::thread::yield_now(); + } + } + while let Ok(request) = display.native_requests.try_recv() { display.process_request(request); } @@ -1433,8 +1454,5 @@ where perform_redraw(&mut display, conf.platform.apple_gfx_api, false); } - if let Some(frame_pacer) = frame_pacer.as_ref() { - frame_pacer.wait_next_frame(); - } } } From 6f8b5a3408be4bc93abbf200ba18ce768ecccd00 Mon Sep 17 00:00:00 2001 From: Pewnack <[email protected]> Date: Fri, 17 Apr 2026 22:00:04 +0200 Subject: [PATCH 3/4] Call to thread::yield_now is a bit overkill. It's only called after the timeout expired. Waiting for the timeout already gave time to other process/threads/ --- src/native/macos.rs | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/native/macos.rs b/src/native/macos.rs index 19aefaea..b827b9d1 100644 --- a/src/native/macos.rs +++ b/src/native/macos.rs @@ -1086,14 +1086,14 @@ impl FramePacer { } } - fn wait_next_frame(&self, timeout: Duration) -> bool { + fn wait_next_frame(&self, timeout: Duration) { let mut frame_ready = match self.frame_signal.frame_ready.lock() { Ok(guard) => guard, Err(poisoned) => poisoned.into_inner(), }; if *frame_ready { *frame_ready = false; - return true; + return; } let (mut frame_ready, _) = match self.frame_signal.cond.wait_timeout(frame_ready, timeout) { @@ -1101,11 +1101,9 @@ impl FramePacer { Err(poisoned) => poisoned.into_inner(), }; - let ready = *frame_ready; - if ready { + if *frame_ready { *frame_ready = false; } - ready } } @@ -1419,9 +1417,7 @@ where // Wait at the top for just in time rendering if let Some(frame_pacer) = frame_pacer.as_ref() { - if !frame_pacer.wait_next_frame(Duration::from_millis(50)) { - std::thread::yield_now(); - } + frame_pacer.wait_next_frame(Duration::from_millis(50)); } while let Ok(request) = display.native_requests.try_recv() { From 99ab3cf55773f6dfa5d6270c09fda8af79cd0294 Mon Sep 17 00:00:00 2001 From: Pewnack <[email protected]> Date: Sat, 18 Apr 2026 07:57:07 +0200 Subject: [PATCH 4/4] Checking for poisoned locks seems a bit overkill. If the sub thread panics let's just panic here as well. --- src/native/macos.rs | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/native/macos.rs b/src/native/macos.rs index b827b9d1..744a88b3 100644 --- a/src/native/macos.rs +++ b/src/native/macos.rs @@ -1087,19 +1087,13 @@ impl FramePacer { } fn wait_next_frame(&self, timeout: Duration) { - let mut frame_ready = match self.frame_signal.frame_ready.lock() { - Ok(guard) => guard, - Err(poisoned) => poisoned.into_inner(), - }; + let mut frame_ready = self.frame_signal.frame_ready.lock().unwrap(); if *frame_ready { *frame_ready = false; return; } - let (mut frame_ready, _) = match self.frame_signal.cond.wait_timeout(frame_ready, timeout) { - Ok(pair) => pair, - Err(poisoned) => poisoned.into_inner(), - }; + let (mut frame_ready, _) = self.frame_signal.cond.wait_timeout(frame_ready, timeout).unwrap(); if *frame_ready { *frame_ready = false;