feat(macos): Start render thread frames on vsync - #24982
MartinZikmund wants to merge 12 commits into
Conversation
Decides when a host's render thread starts its next frame and which vsync it belongs to: right away when the current vsync interval has no frame yet and there is still time to make it (Chromium's missed BeginFrame), else on the next vsync, and paced by an interval when no vsync grid is known. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JmE5zrbCJiMAv1E5yChjQp
Input after an idle period asks for a frame before the UI thread has recorded its change, so that frame re-presented the old picture and the one with the change queued a vsync behind it. A swapchain can now skip a present that would leave the window as it is, and the macOS Metal one does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JmE5zrbCJiMAv1E5yChjQp
The render thread was paced by a timer that drifted against the display, repeating or skipping a vsync now and then. It now schedules frames on the vsync grid the window's display link reports: a request after idle starts at once, stamped with the current vsync, and sustained frames start on each vsync. Waits are on the thread's own clock, so a link that stops ticking (occluded window, headless agent) falls back to interval pacing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JmE5zrbCJiMAv1E5yChjQp
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JmE5zrbCJiMAv1E5yChjQp
The vsync was rebuilt from its age, sampled against two clocks, so one vsync came out a few microseconds apart from frame to frame. The display link's own timestamp is now passed as is (it shares the mach clock with Stopwatch). Frames paced at a configured rate no longer take a vsync stamp, and a request racing the link's idle pause can no longer leave it paused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JmE5zrbCJiMAv1E5yChjQp
Two stamps of the same vsync a few ticks apart were sampled as an interval, which collapses the median until every real interval reads as an idle gap. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JmE5zrbCJiMAv1E5yChjQp
After a second of idle the display link is paused and the grid stale, so input took the interval path, where the frame that showed nothing new still used up the interval. It no longer does, and a frame paced off the grid now counts for the vsync interval it started in once the grid returns. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JmE5zrbCJiMAv1E5yChjQp
macOS reports the first presents after about a second of idle as never shown, so the frames right after one present again instead of being skipped, as they did before. A reallocated target is presented too. The skip decision moves into small helpers so it can be tested. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JmE5zrbCJiMAv1E5yChjQp
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JmE5zrbCJiMAv1E5yChjQp
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JmE5zrbCJiMAv1E5yChjQp
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JmE5zrbCJiMAv1E5yChjQp
Fails before the vsync scheduling (the change waits ~16ms for its frame), and before the idle fix for changes after a second of idle (~19ms). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JmE5zrbCJiMAv1E5yChjQp
|
Claude finished @MartinZikmund's task in 7m 1s —— View job Review complete ✅
Verdict: shipNo correctness blockers. One low-severity suggestion posted inline (reflection access to a private field in a new runtime test, where What I checked closely
Not independently re-verified (would need macOS hardware/CI)Real vsync pacing behavior, the reported latency numbers, and the ObjC pause/resume race under actual concurrent load — I validated these by code tracing and the PR's own test suite, not by running on macOS. |
|
Tick the box to add this pull request to the merge queue (same as
|
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Fixed-rate mode unnecessarily activates the display link, and the new regression test lacks required cleanup and issue metadata.
Review effort: Balanced
Findings: 2
Open (3)
What changed in this PR
Adds macOS vsync-aligned frame scheduling and avoids redundant presentation of unchanged frames.
Changes:
- Introduces vsync-aware scheduling with timer fallback.
- Adds retained-present tracking and frame-clock jitter tolerance.
- Adds scheduler, presentation, and macOS latency tests.
Review: 3 unresolved issues covering fixed-rate overhead and runtime-test conventions.
| File | Description |
|---|---|
CompositionTarget.RenderScheduling.cs |
Selects normal or unchanged presentation. |
CompositionTarget.Rendering.cs |
Detects unchanged render targets. |
Given_RetainedPresent.cs |
Tests retained presentation rules. |
Given_CompositionTarget.cs |
Adds macOS idle-input regression test. |
Given_VsyncFrameScheduler.cs |
Tests scheduler timing behavior. |
Given_Compositor.cs |
Tests near-identical vsync timestamps. |
UNOMetalViewDelegate.m |
Exposes native vsync timing. |
UNOMetalViewDelegate.h |
Declares the native vsync API. |
MacOSWindowHost.cs |
Integrates vsync scheduling. |
MacOSGraphicsContext.cs |
Implements unchanged-present skipping. |
NativeUno.cs |
Adds managed native interop. |
VsyncFrameScheduler.skia.cs |
Implements frame scheduling policy. |
FrameClock.skia.cs |
Deduplicates jittered vsync timestamps. |
RetainedPresentTracker.cs |
Tracks safe presentation skips. |
IGraphicsContext.cs |
Adds optional unchanged presentation. |
💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.
| now = Stopwatch.GetTimestamp(); | ||
| var hasGrid = TryGetVsyncGrid(now, out var latestVsync, out var period); | ||
| (var start, vsync) = _scheduler.GetNextFrame(now, _followVsync && hasGrid ? latestVsync : null, period); |
| var border = new Border { Width = 100, Height = 100, Background = new SolidColorBrush(Colors.Red) }; | ||
| await UITestHelper.Load(border); |
| /// </summary> | ||
| [TestMethod] | ||
| [RunsOnUIThread] | ||
| [PlatformCondition(ConditionMode.Include, RuntimeTestPlatforms.SkiaMacOS)] |
| var border = new Border { Width = 100, Height = 100, Background = new SolidColorBrush(Colors.Red) }; | ||
| await UITestHelper.Load(border); | ||
| var target = (CompositionTarget)border.Visual.CompositionTarget!; | ||
| var lastNativeFrame = typeof(CompositionTarget).GetField("_lastNativeFrameTimestamp", BindingFlags.Instance | BindingFlags.NonPublic)!; |
There was a problem hiding this comment.
Minor: this reaches CompositionTarget's private _lastNativeFrameTimestamp via reflection. Uno.UI already has InternalsVisibleTo for Uno.UI.RuntimeTests (used elsewhere in this test class and file), so making the field internal in CompositionTarget.RenderScheduling.cs:79 and reading it directly would be simpler and not silently break (with a confusing NRE from GetField(...)!) if the field is ever renamed.


GitHub Issue: Part of #24963
PR Type:
✨ Feature
What changed? 🚀
Before
The macOS render thread was paced by a timer (
FramePacer). #24968 stamped each frame with the time of the latest vsync, but frames still started on the timer. The timer and the display drifted apart, so now and then two frames fell in the same vsync, or one was skipped. An earlier prototype that paced frames with a display link fixed that, but added 4–9 ms before the first response to input, because every request waited for the next tick.After
Frames are scheduled the way Chromium does it.
VsyncFrameScheduler(Uno.UI.Composition) decides when each frame starts, from the window display link's vsync times (macOS 14+):nextDrawablestall can't come back.SetFrameRateAsScreenRefreshRate=false. Frames paced at a configured rate get no vsync stamp.ISwapChain.PresentUnchanged(); other hosts are unaffected.FrameClock.NextVsyncTimestamptreats stamps less than 1 ms apart as the same vsync. Before, the same vsync could come out up to 90 µs apart between calls, and those tiny gaps would slowly pull the frame interval estimate down.Validation
Uno.UI.Runtime.Skia.MacOShas no warnings in the changed files.When_Host_Reports_Vsync_Then_Frame_Time_Is_The_Vsyncpasses on macOS, but stays disabled there: the display link doesn't tick on headless CI agents, where the timer fallback is used.PR Checklist ✅
Screenshots Compare Test Runresults.🤖 Generated with Claude Code
https://claude.ai/code/session_01JmE5zrbCJiMAv1E5yChjQp