Skip to main content

metal_live_resize/
lib.rs

1//! Glitch-free macOS `CAMetalLayer` window live resize.
2//!
3//! Provides the minimum configuration primitives to prevent content
4//! distortion during window resize on macOS Metal apps.
5//!
6//! # The problem
7//!
8//! During live window resize on macOS, the compositor lags behind the
9//! app's rendering by one or more frames. By default, the compositor
10//! stretches the previous drawable contents to fill the new window
11//! bounds (`contentsGravity = kCAGravityResize`), which shows up as
12//! visible wobble or distortion until the next frame is presented.
13//!
14//! # The fix
15//!
16//! Two one-time configuration calls on the `CAMetalLayer`:
17//!
18//! 1. **`contentsGravity = kCAGravityTopLeft`** — pins stale frames to
19//!    the top-left corner instead of scaling them, so the "wrong" area
20//!    is clipped rather than stretched.
21//! 2. **`contentsScale = NSWindow.backingScaleFactor`** — ensures
22//!    drawable pixels map 1:1 to screen pixels on Retina displays,
23//!    without which topLeft gravity puts content at the wrong position.
24//!
25//! And one per-frame discipline:
26//!
27//! 3. **Read drawable texture dimensions at render time**, never cached
28//!    layer `width`/`height`. During resize, the drawable's texture
29//!    may not yet match the cached size and rendering at the wrong
30//!    size causes visible distortion.
31//!
32//! # What NOT to do
33//!
34//! The `presentsWithTransaction = true` + `commandBuffer.waitUntilScheduled()`
35//! approach is sometimes suggested as an alternative. It has been tested
36//! and **breaks frame delivery** — AppKit events fire (hit tests pass,
37//! state updates) but nothing renders to the screen. The
38//! `contentsGravity + contentsScale` approach alone is sufficient and
39//! does not block the event loop.
40//!
41//! # Reference
42//!
43//! Pattern first documented by Tristan Hume, 2019:
44//! <https://thume.ca/2019/06/19/glitchless-metal-window-resizing/>
45//!
46//! # Example
47//!
48//! ```ignore
49//! // macOS-only; the crate exposes no items on other platforms.
50//! use metal_live_resize::configure_for_live_resize;
51//! use core::ffi::c_void;
52//!
53//! unsafe fn example(layer: *mut c_void, view: *mut c_void) {
54//!     // after attaching your CAMetalLayer to an NSView:
55//!     unsafe { configure_for_live_resize(layer, view) };
56//!
57//!     // per-frame, read the actual drawable size rather than cached w/h:
58//!     if let Some((w, h)) = unsafe { metal_live_resize::drawable_texture_size(layer) } {
59//!         let _ = (w, h); // render at (w, h)
60//!     }
61//! }
62//! ```
63
64#![cfg(target_os = "macos")]
65#![deny(missing_docs)]
66
67use core::ffi::{CStr, c_void};
68use objc2::runtime::AnyObject;
69use objc2::{class, msg_send};
70
71/// Apply both live-resize fixes: sets `contentsGravity = kCAGravityTopLeft`
72/// and `contentsScale = view.window.backingScaleFactor` on the layer.
73///
74/// Convenience wrapper around [`set_contents_gravity_top_left`] and
75/// [`set_contents_scale`] with scale read via [`view_backing_scale`].
76/// If the view has no attached window yet, only the gravity is set.
77///
78/// Call once after attaching the `CAMetalLayer` to the `NSView`.
79///
80/// # Safety
81/// - `layer` must be a valid pointer to a `CAMetalLayer`.
82/// - `view` must be a valid pointer to an `NSView` that owns the layer.
83/// - Must be called on the main thread (AppKit requirement).
84pub unsafe fn configure_for_live_resize(layer: *mut c_void, view: *mut c_void) {
85    // SAFETY: invariants delegated to the individual calls; see function docs.
86    unsafe {
87        set_contents_gravity_top_left(layer);
88        if let Some(scale) = view_backing_scale(view) {
89            set_contents_scale(layer, scale);
90        }
91    }
92}
93
94/// Sets `contentsGravity = kCAGravityTopLeft` on the layer.
95///
96/// Prevents the compositor from scaling old drawable contents during
97/// live resize. Without this, each resize tick visibly stretches the
98/// previous frame until the next `nextDrawable` completes.
99///
100/// # Safety
101/// - `layer` must be a valid pointer to a `CALayer` (or subclass such
102///   as `CAMetalLayer`).
103/// - Must be called on the main thread.
104pub unsafe fn set_contents_gravity_top_left(layer: *mut c_void) {
105    // SAFETY: caller guarantees `layer` is a valid CALayer pointer and
106    // is accessed from the main thread. NSString creation via
107    // `stringWithUTF8String:` returns an autoreleased NSString; the
108    // layer retains its `contentsGravity` value internally.
109    unsafe {
110        let layer_obj = layer as *mut AnyObject;
111        let s: *const CStr = c"topLeft";
112        let nsstring: *const AnyObject =
113            msg_send![class!(NSString), stringWithUTF8String: s.cast::<core::ffi::c_char>()];
114        let _: () = msg_send![layer_obj, setContentsGravity: nsstring];
115    }
116}
117
118/// Sets `contentsScale` on the layer.
119///
120/// Pass the window's `backingScaleFactor` (2.0 on standard Retina
121/// displays, 1.0 on 1x displays, 3.0 on iPhone-class displays).
122/// Without this, drawable pixels are mismatched from screen pixels
123/// and top-left gravity puts content at a scaled position.
124///
125/// # Safety
126/// - `layer` must be a valid pointer to a `CALayer` (or subclass).
127/// - Must be called on the main thread.
128pub unsafe fn set_contents_scale(layer: *mut c_void, scale: f64) {
129    // SAFETY: caller guarantees `layer` is a valid CALayer pointer.
130    // `setContentsScale:` takes a CGFloat (f64 on 64-bit macOS).
131    unsafe {
132        let layer_obj = layer as *mut AnyObject;
133        let _: () = msg_send![layer_obj, setContentsScale: scale];
134    }
135}
136
137/// Reads the `backingScaleFactor` from an `NSView`'s window.
138///
139/// Returns `None` if the view is detached from any window (i.e.
140/// `[view window]` returns `nil`). In that case fall back to 1.0 or
141/// query the main screen directly.
142///
143/// # Safety
144/// - `view` must be a valid pointer to an `NSView`.
145/// - Must be called on the main thread.
146pub unsafe fn view_backing_scale(view: *mut c_void) -> Option<f64> {
147    // SAFETY: caller guarantees `view` is a valid NSView pointer.
148    // `[view window]` returns nullable; we null-check before messaging.
149    unsafe {
150        let view_obj = view as *mut AnyObject;
151        let window: *mut AnyObject = msg_send![view_obj, window];
152        if window.is_null() {
153            return None;
154        }
155        let scale: f64 = msg_send![window, backingScaleFactor];
156        Some(scale)
157    }
158}
159
160/// Gets the actual pixel dimensions of the drawable's current texture.
161///
162/// Obtains `nextDrawable` from the layer and reads the `width`/`height`
163/// of its bound texture. During live resize, the drawable may not yet
164/// match the window's reported size, and rendering at the cached size
165/// causes visible distortion.
166///
167/// Returns `None` if `nextDrawable` returns `nil` (drawable pool
168/// exhausted — caller should skip the frame).
169///
170/// **This consumes the next drawable from the layer's pool.** If you
171/// need the drawable itself for rendering, obtain it via `nextDrawable`
172/// yourself and read `drawable.texture.width/height` directly.
173///
174/// # Safety
175/// - `layer` must be a valid pointer to a `CAMetalLayer` with a Metal
176///   device attached.
177/// - Must be called on the main thread.
178pub unsafe fn drawable_texture_size(layer: *mut c_void) -> Option<(u32, u32)> {
179    // SAFETY: caller guarantees `layer` is a valid CAMetalLayer.
180    // `nextDrawable` returns an autoreleased `id<CAMetalDrawable>`; we
181    // null-check before accessing its texture.
182    unsafe {
183        let layer_obj = layer as *mut AnyObject;
184        let drawable: *mut AnyObject = msg_send![layer_obj, nextDrawable];
185        if drawable.is_null() {
186            return None;
187        }
188        let texture: *mut AnyObject = msg_send![drawable, texture];
189        if texture.is_null() {
190            return None;
191        }
192        let w: u64 = msg_send![texture, width];
193        let h: u64 = msg_send![texture, height];
194        Some((w as u32, h as u32))
195    }
196}