Skip to content

macOS: pace the window's frame to the display, and never wait for a drawable on the main thread - #10

Merged
mtdowling merged 1 commit into
tecs-dev:mainfrom
albanmgicquel:macos-frame-loop
Oct 16, 2026
Merged

mtdowling merged 1 commit into
tecs-dev:mainfrom
albanmgicquel:macos-frame-loop

Conversation

@albanmgicquel

@albanmgicquel albanmgicquel commented Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator

On macOS the window host polled (ControlFlow::Poll, the frame in about_to_wait). Under FIFO presentation the frame spent most of its time in get_current_texture, which is nextDrawable waiting for vsync, on the main thread: 90% of the run in a game we measured.

Window managers with a click-filtering event tap, such as Magnet or BetterTouchTool's snap areas, hold each mouse-down while they ask the clicked app about its window over Accessibility. That request is served by the main run loop, one per turn, and each turn was a blocked frame. The result: every click, and the start of every title-bar drag, waited about 0.5 s at 60 Hz (less at 120 Hz), while the host's own timings looked perfect. This is rust-windowing/winit#1737; Godot ships a workaround that detects Magnet by its bundle id.

This change, on macOS:

  • The event loop sleeps in WaitUntil until the next frame is due, one monitor refresh after the last. The frame is requested with request_redraw and runs in RedrawRequested.
  • A worker thread acquires each drawable as soon as the previous frame is presented. A due frame whose drawable isn't ready yet is put off and checked again every 1 ms, at most 100 ms, then runs and waits as before.
  • Between frames the run loop sleeps, so Accessibility requests are answered right away.
  • It uses only wgpu's public API (Surface and SurfaceTexture are Send + Sync). A reconfigure first settles the acquisition in flight.

Measured with an equivalent diagnostic build, with Magnet running:

Before After
Main thread blocked in acquire 90% 0%
Mouse-down age on arrival ~500 ms max 16 ms (the tap's own cost)

Alternatives tried and rejected:

  • Timer pacing without the gate: still blocked up to a frame and held clicks up to ~130 ms.
  • A CADisplayLink: no better than the timer once gated (and blocking without the gate).
  • One frame of latency: halves the frame rate.
  • Counting presented drawables: needs a wgpu-hal patch.

Other platforms and the benchmark's interactive view keep the polled loop. One caveat, documented: wgpu-hal's occlusionState read now runs on the acquisition worker thread.

Tests: pacing.rs unit tests (deadline, gate, its bound, no catch-up after a late frame, parked retry, the acquisition worker asking rather than waiting, settling, shutdown). cargo fmt, cargo clippy -p tecs-winit-host --all-targets -D warnings, cargo test -p tecs-winit-host (110) and nupp test pass. Verified by hand on macOS with Magnet running. Documented in a README design record and docs/gfx/benchmarks.md.

Not run here: nupp task docs-check (this toolchain lacks the lunamark rock).

…and never waits for a drawable on the main thread (a window manager's click tap no longer holds clicks for half a second)

The polled loop spent most of each frame in nextDrawable on the main thread,
which serves a window manager's Accessibility requests (Magnet, BetterTouchTool)
one per frame while their event tap holds the click: winit#1737. The frame now
runs in RedrawRequested at a WaitUntil deadline one refresh apart, and a worker
thread acquires each drawable, so a due frame without one is put off and
re-checked (at most 100 ms) instead of waited for. Other platforms and the
benchmark view keep polling.
@mtdowling
mtdowling merged commit 1f5cfa0 into tecs-dev:main Oct 16, 2026
4 of 8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants