Sitelet https://github.com/unoplatform/uno/pull/24982
Skip to content

feat(macos): Start render thread frames on vsync - #24982

Open
MartinZikmund wants to merge 12 commits into
dev/mazi/scroll-vsync-timestampsfrom
dev/mazi/vsync-macos
Open

MartinZikmund wants to merge 12 commits into
dev/mazi/scroll-vsync-timestampsfrom
dev/mazi/vsync-macos

Conversation

@MartinZikmund

Copy link
Copy Markdown
Member

GitHub Issue: Part of #24963

Based on #24968 (dev/mazi/scroll-vsync-timestamps). Review only this PR's commits. Completes the macOS step of #24963: frames now start on vsync, not just carry its time.

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+):
    • A request starts a frame at once if the current vsync interval has had none yet and there's still time to make it. That frame is stamped with the interval's vsync, so input never waits for a tick.
    • Otherwise the frame waits for the next vsync and uses it as its frame time. Sustained animation therefore starts every frame on vsync, at most one per interval, so the ~1 s nextDrawable stall can't come back.
  • Fallback to the timer: the timer pacing is still used when the vsync data is more than 0.5 s old (occluded window, headless agent), on macOS < 14, and with SetFrameRateAsScreenRefreshRate=false. Frames paced at a configured rate get no vsync stamp.
  • Skipping unchanged presents: a frame that would leave the window as it was is no longer presented. It's opt-in, through ISwapChain.PresentUnchanged(); other hosts are unaffected.
    • This fixes an existing bug: the first frame after input presented the old picture, and the real change landed one vsync later.
    • It never skips the first frame, or one after a resize, scale change, damage, an overlay, a failed present, a new target, or 0.5 s of idle.
  • One exact time per vsync: native code returns the display link's own timestamp. FrameClock.NextVsyncTimestamp treats 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

  • Compile: desktop SamplesApp. A CI-strict build of Uno.UI.Runtime.Skia.MacOS has no warnings in the changed files.
  • Runtime, macOS:
    • Composition, scheduler and present-skip tests: 63/63.
    • 1/20 runtime-test slice: 508/510, with the same 2 failures as the base branch.
    • The regression test for a change after idle getting its frame fails on the base branch and passes with this PR.
    • When_Host_Reports_Vsync_Then_Frame_Time_Is_The_Vsync passes on macOS, but stays disabled there: the display link doesn't tick on headless CI agents, where the timer fallback is used.
  • Latency, measured with the window frontmost, idle gaps of 100–400 ms: input to on screen went from 45.8 to 31.4 ms. Sustained animation went from occasional skipped or repeated frame times to none.
  • Not measured yet: idle gaps over 1 s with the window frontmost, and 120 Hz / ProMotion.

PR Checklist ✅

🤖 Generated with Claude Code

https://claude.ai/code/session_01JmE5zrbCJiMAv1E5yChjQp

MartinZikmund and others added 12 commits October 5, 2026 11:49
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
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
Copilot AI balanced review requested due to automatic review settings October 5, 2026 11:01
@github-actions

github-actions Bot commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @MartinZikmund's task in 7m 1s —— View job


Review complete ✅

  • Gather context (PR description, diff vs origin/dev/mazi/scroll-vsync-timestamps)
  • Review C# changes (VsyncFrameScheduler, FrameClock, CompositionTarget.*, RetainedPresentTracker)
  • Review macOS native changes (MacOSWindowHost, MacOSGraphicsContext, UNOMetalViewDelegate.{h,m}, NativeUno.cs)
  • Review test coverage (Given_VsyncFrameScheduler, Given_RetainedPresent, Given_CompositionTarget, Given_Compositor)
  • Post inline findings
  • Post summary

Verdict: ship

No correctness blockers. One low-severity suggestion posted inline (reflection access to a private field in a new runtime test, where internal + the existing InternalsVisibleTo would be simpler and less fragile).

What I checked closely

  • VsyncFrameScheduler (GetNextFrame/OnFrame/OnFrameDrawn): traced the deadline math, the "immediate vs. wait-for-next-vsync" branch, the exempt-unchanged-frame bookkeeping, and the no-grid timer fallback against all 13 cases in Given_VsyncFrameScheduler.cs. The latestVsync/vsyncPeriod invariant the scheduler relies on (vsync <= now < vsync + period) is correctly established by MacOSRenderThread.TryGetVsyncGrid and by the native 0.5s staleness guard (MaxVsyncAge), so the scheduler itself never sees a stale vsync — no scheduling drift found.
  • RetainedPresentTracker: initially read the _presentsSinceIdle >= PresentsAfterIdle || timestamp - _lastPresent > IdleGap condition in TrySkipUnchanged as inverted (looked like it'd skip presenting right when the presentedTime-after-idle macOS quirk should force a real present), but Given_RetainedPresent.When_Idle_Since_The_Last_Present_Then_Skipped confirms the intended behavior: a long-settled idle window is safe to skip, the unsafe window is only the handful of frames immediately following resumed activity, which _presentsSinceIdle correctly gates. No bug.
  • Native UNOMetalViewDelegate.m pause/resume race between onVsync (main thread) and getLastVsync (render thread) via _vsyncLinkState/_idleVsyncs atomics: traced the interleaving where a render-thread resume request races the main thread's idle-pause: the "second chance" compare-exchange in onVsync correctly recovers and un-pauses the link. No defect found, though this is inherently hard to unit-test and worth keeping an eye on if any flakiness in vsync pacing is reported later.
  • CompositionTarget.Rendering.cs/RenderScheduling.cs: _lastDrawLeftTargetUnchanged is written and read within the same render-thread call stack (Draw() then the finally in OnNativePlatformFrameRequested), so no cross-thread hazard despite not being lock-protected like the neighboring fields.
  • Conventions: braces/Allman style, #nullable enable, internal visibility, and the events/DI rules in AGENTS.md are all followed; no new warnings expected per the PR's own CI-strict build claim; commits follow Conventional Commits.

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.

Branch: dev/mazi/vsync-macos

@mergify

mergify Bot commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

Tick the box to add this pull request to the merge queue (same as @mergifyio queue).

  • Queue this pull request

@github-actions github-actions Bot added platform/macos 🍏 Categorizes an issue or PR as relevant to the macOS platform area/skia ✏️ Categorizes an issue or PR as relevant to Skia labels Oct 5, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 Medium severity · 1 Low severity

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.

Comment on lines +1155 to +1157
now = Stopwatch.GetTimestamp();
var hasGrid = TryGetVsyncGrid(now, out var latestVsync, out var period);
(var start, vsync) = _scheduler.GetNextFrame(now, _followVsync && hasGrid ? latestVsync : null, period);
Comment on lines +629 to +630
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)!;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix this →

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/skia ✏️ Categorizes an issue or PR as relevant to Skia platform/macos 🍏 Categorizes an issue or PR as relevant to the macOS platform

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants