From 17b4c165ee202b669ad477b626240d7315bb1f44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benoi=CC=82t=20Rouleau?= Date: Mon, 19 Oct 2026 11:31:54 -0400 Subject: [PATCH] macos: honour swap_interval: Some(0) macOS ignored `swap_interval`. Each frame waited for the next tick of a `CVDisplayLink`, Metal presented each drawable on a vertical blank, and an occluded window slept between frames to save power. So frames could never come faster than the display refreshes, as a tool drawing frames off-screen to save them needs, or a benchmark. With `swap_interval: Some(0)`, a frame now starts as soon as the last one is done: there's no frame pacer, the Metal layer doesn't sync to the display, and a hidden window isn't slowed down, as on Windows and Linux, where `Some(0)` turns vsync off and nothing slows a hidden window. Any other value keeps to the display's refresh rate, as before. Co-Authored-By: Claude Opus 5.5 Co-authored-by: multica-agent --- src/conf.rs | 4 ++++ src/native/macos.rs | 14 +++++++++++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/conf.rs b/src/conf.rs index 13cdc7541..e0c3bb47c 100644 --- a/src/conf.rs +++ b/src/conf.rs @@ -138,6 +138,10 @@ pub struct Platform { /// There is no guarantee the FPS will match the specified `swap_interval`. /// In other words, `swap_interval` is only a hint to the GPU driver and /// not a reliable way to limit the game's FPS. + /// + /// On macOS, `Some(0)` draws each frame as soon as the last one is done, + /// even while the window is hidden, and any other value keeps to the + /// display's refresh rate. pub swap_interval: Option, /// If `true`, the event loop will block until [`schedule_update`] is called. diff --git a/src/native/macos.rs b/src/native/macos.rs index b8d01fdef..74df9b03b 100644 --- a/src/native/macos.rs +++ b/src/native/macos.rs @@ -29,6 +29,9 @@ pub struct MacosDisplay { gl_context: ObjcId, fullscreen: bool, occluded: bool, + /// Whether frames keep to the display's refresh rate, as they do + /// unless `swap_interval` is `Some(0)`. + vsync: bool, // [NSCursor hide]/unhide calls should be balanced // hide/hide/unhide will keep cursor hidden // so need to keep internal cursor state to avoid problems from @@ -992,7 +995,7 @@ fn get_window_payload(this: &Object) -> &mut MacosDisplay { } } -unsafe fn create_metal_view(_: &mut MacosDisplay, sample_count: i32, _: bool) -> ObjcId { +unsafe fn create_metal_view(display: &mut MacosDisplay, sample_count: i32, _: bool) -> ObjcId { let mtl_device_obj = MTLCreateSystemDefaultDevice(); let view_class = define_metal_view_class(); let view: ObjcId = msg_send![view_class, alloc]; @@ -1006,6 +1009,10 @@ unsafe fn create_metal_view(_: &mut MacosDisplay, sample_count: i32, _: bool) -> ]; let () = msg_send![view, setSampleCount: sample_count]; let () = msg_send![view, setPaused: true]; + if !display.vsync { + let layer: ObjcId = msg_send![view, layer]; + let () = msg_send![layer, setDisplaySyncEnabled: NO]; + } view } @@ -1280,7 +1287,7 @@ unsafe fn perform_redraw( } unsafe { // reduce CPU usage hen window is in background - if display.occluded { + if display.vsync && display.occluded { let now = Instant::now(); let framerate = 60.0; let period = (1.0 / framerate * 1000.) as u64; @@ -1326,6 +1333,7 @@ where gl_context: std::ptr::null_mut(), fullscreen: false, occluded: false, + vsync: conf.platform.swap_interval != Some(0), cursor_shown: true, current_cursor: CursorIcon::Default, cursor_grabbed: false, @@ -1461,7 +1469,7 @@ 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 { + let frame_pacer = if conf.platform.blocking_event_loop || !display.vsync { None } else { FramePacer::new()