Skip to content

frameclock: resolve each frame from its own present, and plan for when frames are really shown - #50

Merged
waywardmonkeys merged 1 commit into
mainfrom
frameclock/frame-feedback
Sep 17, 2026
Merged

waywardmonkeys merged 1 commit into
mainfrom
frameclock/frame-feedback

Conversation

@waywardmonkeys

Copy link
Copy Markdown
Contributor

A host whose frames reach the screen some refreshes after they are submitted — a GPU renderer, where the compositor shows a drawable once its work finishes — cannot tell frameclock what its frames did. FrameSubmission::deferred resolves a frame with the next tick's prev_actual_present, which is the refresh that just passed rather than that frame's own present, and only one frame can be waiting at a time.

Found while pacing Lightweald's Metal samples with FrameDriver over a CADisplayLink: those frames reach the screen two to four refreshes after the tick that started them, and fed the tick's timestamp the driver's missed-deadline count swung between 0 and 246 of 300 frames on one scene.

Changes

Late, per-frame reports. FrameSubmission::reported submits a frame the host will report later; FrameDriver::report_frame(frame_index, FrameReport) resolves it from its own measured present, in any order, up to REPORTED_FRAME_CAPACITY frames deep. A submission past that resolves the oldest commit-only, as a superseding deferred submission does. This should suit Wayland's wp_presentation feedback and swapchain statistics as well as Metal's presented handlers.

What a frame costs. FrameReport::work is what the submission cost beyond the host's own span: the duration of the GPU work it queued, not when that work finished. A frame queued behind others waits, and counting the wait made the scheduler choose a slower cadence, which lengthened the wait again — 120 fps decayed to 20. Build cost is now the longer of the host's span and that work, so the scheduler sees a frame that cannot hold the display's refresh.

Presentation-latency compensation. SchedulerConfig::present_latency_alpha learns how late frames are really shown and moves FramePlan::target_present, and so the time the frame is sampled for, by whole refresh intervals. Without it the only answer to a late frame is deeper pipeline lookahead, which plans fewer frames rather than better ones: with real presents reported, a 120 fps scene fell to about 12 fps. FramePlan::present_latency carries what a plan applied, so feedback corrects the estimate instead of compounding it.

A miss is a refresh, not a tick. The timestamp for the refresh a frame was planned for lands a little after the target, so a strict comparison called nearly every frame late. A frame is late when it is shown more than half a refresh past its target.

frameclock_apple exposes media_time_to_host_time, which turns the Core Animation media times Metal reports (a drawable's presentedTime, a command buffer's GPU times) into HostTime, and says plainly that a display link's timestamp is the last refresh rather than the app's own present.

Measured

Lightweald at 3456x2104 on a 120 Hz display, two frames in flight:

Frames shown late (of 300) Sampled for when it is shown
Before (commit-only) not knowable 16.7-33 ms early
After (per-frame reports) 0 1.8-2.5 ms mean, 8.4 ms worst

Frame rate and submit-to-screen latency are unchanged: 107-118 fps on one scene, 53-73 fps on a heavier one.

Still open

A scene that cannot hold 120 Hz settles around 54 fps, between the 60 and 40 that divide the refresh: frameclock picks a steady cadence, but the display keeps ticking at 120 Hz, so frames land on alternating refreshes. Asking Core Animation for the chosen cadence ratchets the rate down instead — frameclock derives display timing from the ticks it receives, so slower ticks look like a slower display, and scenes settle at 30 fps. Giving the scheduler the display's real range rather than what the last tick implied looks like the fix; happy to take that on in a follow-up.

Notes

Breaking: PresentFeedback and PresentFeedbackEvent gain fields (work, present_latency), FramePlan gains present_latency, SchedulerState gains present_latency_ticks, and the trace format records work in place of nothing. Callers in-tree are updated. The Windows examples were updated to build PresentFeedbackEvent with ::new; they are not buildable on macOS, so CI is their first check.

…n frames are really shown

A host whose frames reach the screen some refreshes after they are
submitted — a GPU renderer, where the compositor shows a drawable after
its work finishes — could not tell frameclock what its frames did.
`FrameSubmission::deferred` resolves a frame with the next tick's
`prev_actual_present`, which is the refresh that just passed, not that
frame's own present, and only one frame can be waiting at a time.

Three changes make the loop true for such hosts:

- `FrameSubmission::reported` submits a frame the host will report later.
  `FrameDriver::report_frame(frame_index, FrameReport)` resolves it from
  its own measured present, in any order, up to
  `REPORTED_FRAME_CAPACITY` frames deep; a submission past that resolves
  the oldest commit-only, as a superseding deferred submission does.
  `FrameReport::work` says what the submission cost beyond the host's own
  span — the duration of the GPU work it queued, not when that finished,
  since a frame queued behind others waits and waiting is not what the
  frame costs. Build cost is the longer of the two, so the scheduler sees
  a frame that cannot hold the display's refresh.

- `SchedulerConfig::present_latency_alpha` learns how late frames are
  really shown and moves `FramePlan::target_present`, and so what the
  frame is sampled for, by whole refresh intervals. Without it the only
  answer to a late frame is deeper pipeline lookahead, which plans fewer
  frames rather than better ones: a 120 fps scene fell to about 12 fps
  once its real presents were reported. `FramePlan::present_latency`
  carries what a plan applied so feedback corrects the estimate instead
  of compounding it.

- A frame is late when it is shown more than half a refresh past its
  target. The timestamp for the refresh a frame was planned for lands a
  little after that target, so a strict comparison called nearly every
  frame late.

`frameclock_apple` exposes `media_time_to_host_time`, which turns the
Core Animation media times that Metal reports (a drawable's
`presentedTime`, a command buffer's GPU times) into `HostTime`, and says
plainly that a display link's timestamp is the last refresh rather than
the app's own present.

Measured in Lightweald at 3456x2104 on a 120 Hz display: frames are now
sampled for 1.8-2.5 ms from when they are shown, against 16.7-33 ms
early before, with no frame shown late over 300-frame spans and no
change in frame rate or latency.
@waywardmonkeys
waywardmonkeys merged commit fd0b978 into main Sep 17, 2026
15 checks passed
@waywardmonkeys
waywardmonkeys deleted the frameclock/frame-feedback branch September 17, 2026 18:30
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.

1 participant