fix(windows): make a resize deliver frames at the new size - #323
Open
jhodges10 wants to merge 4 commits into
Open
fix(windows): make a resize deliver frames at the new size#323jhodges10 wants to merge 4 commits into
jhodges10 wants to merge 4 commits into
Conversation
|
@jhodges10 is attempting to deploy a commit to the Vercel Labs Team on Vercel. A member of the Team first needs to authorize it. |
Windows synthesizes WM_TIMER only when the message queue is empty. The one-shot emit timer therefore starves under exactly the load that needs frames most, and vercel-labs#313's post-dispatch drain cannot reach it during a user-driven move/size drag: DefWindowProc runs its own modal message pump there, so the run loop — and both of the wake paths it owns, the waitable timer and the drain — stay parked for the whole drag. Schedule each deadline on a timer queue whose callback only posts kGpuEmitMessage. A posted message is an ordinary queued message that every pump delivers, including the modal one, and no queue-empty rule gates it. The callback does nothing else, so all app and runtime work stays on the UI thread, and a generation stamp lets a re-arm discard the message a superseded deadline already posted. Alongside that, the pieces the new cadence needs to be real: - Hold 1 ms system timer resolution for the loop's lifetime. Every pacing primitive here quantizes to the system timer, and the default ~15.6 ms granularity caps a 240 Hz grid near 64 Hz. Needs winmm. - Derive the frame interval from the monitor carrying the surface instead of a hardcoded 16.67 ms, memoized against its HMONITOR. - Coalesce pointer motion (latest-wins) and wheel deltas (accumulated) to one flush per frame, so an input storm cannot outrun the grid. - Set WS_CLIPCHILDREN on top-level windows and gpu-surface containers. Without it a parent repaint paints COLOR_WINDOW straight over child HWNDs, which reads as white strobing over a canvas mid-drag. - Coalesce WM_MOVE across the modal loop. A pure move changes no client size, but each one drove a full shell relayout AND a synchronous window-state file rewrite, hundreds of times a second during a drag. The settled frame emits once on WM_EXITSIZEMOVE. Measured on a 165 Hz Windows desktop, retained path, dragging a canvas window: 1.82 ms/frame at 1037x775 and 2.15 ms/frame at 3053x1175 — 4.5x the pixels for 18% more cost, worst single frame 2.7 ms. This overlaps vercel-labs#313 deliberately rather than replacing it. That change fixed the same starvation for the ordinary loop, where draining after each dispatch is sufficient; it cannot fix the modal loop, which never returns to the loop that drains. The drain still runs and still earns its keep — the two wakes are complementary. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two source-pinning tests still asserted the WM_TIMER scheduling this change replaces, so they fail on it: one looked for `KillTimer(hwnd, kGpuEmitTimerId);` in the drain helper, the other for `if (wparam == kGpuEmitTimerId)` as the emit entry point. Neither string survives arming emissions with CreateTimerQueueTimer and delivering them as kGpuEmitMessage. Re-point both at the shape that actually ships. The drain helper retires a deadline with one call, cancelGpuSurfaceFrameEmission, which also bumps the generation and clears the scheduled flag, so the old kill-then-clear ordering pair collapses into retire-before-emit. The emit handler gains the assertion that matters for a threaded timer: it fences on the generation before touching anything, because a callback that raced a cancellation carries a stale one and must drop. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The top-level WM_SIZE handler re-arms every child surface's pending emission. That exists for restore-from-minimize, where a heartbeat-paced deadline can be parked up to a second out and superseding it returns full cadence without dropping a beat. But WM_SIZE also arrives on every step of a live resize drag, where the pending emission is already grid-paced and already nearly due. Re-arming it there discards a frame that was about to fire and restarts its wait, so a drag whose steps outpace the frame interval keeps resetting the deadline just before it lands. Measured on a 165 Hz desktop, dragging a canvas window for ~10 s: 2,639 deadlines were armed and only 445 fired — 17%. The window painted 2,250 times against 215 presents, so roughly nine of every ten frames on screen were the previous frame stretched to the new size rather than content laid out at that size. It looks plausible, because scaling the last good frame is a convincing stand-in, which is why this hides. Record the pacing interval a deadline was scheduled against and supersede only a parked heartbeat one. The reveal path this was written for still works; a drag now lets its due frames fire. The show/policy-hidden reveal at the other re-arm site is left alone: it runs once on a real occlusion transition, not per message. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
jhodges10
force-pushed
the
perf/windows-modal-resize-frames
branch
from
August 12, 2026 17:22
6d373a1 to
2976afb
Compare
A geometry change was not a reason to draw. Emissions are scheduled by input, by animation, and by explicit frame requests; the top-level WM_SIZE handler re-arms child surfaces, but only ones that ALREADY had an emission pending. A panel that happened to be idle when the window or a dock divider moved therefore never received a frame event carrying its new size: it kept presenting the packet it had rendered for the old bounds, and repaired itself only when the pointer wandered in and woke it for unrelated reasons. That is the split a user sees as "some panels resize live, others wait for the mouse" — which panels depends on nothing but whether each happened to be animating at the moment of the drag. Schedule a frame from the surface's own WM_SIZE, gated on syncGpuSurfaceGeometry reporting a real change so an unchanged message cannot hold a frame loop open. The repaint has to be forced: the runtime plans an idle frame for an unchanged scene, and a resize does not change the scene, only the viewport it is laid out against. AppKit already forces one across its view-frame and backing-scale transitions; this is the Win32 half of the same contract. Cost is bounded by the frame grid rather than the message rate — a drag delivers WM_SIZE per mouse step and all of them fold into the single in-flight emission. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
jhodges10
marked this pull request as draft
August 13, 2026 00:09
jhodges10
marked this pull request as ready for review
August 13, 2026 06:12
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Four commits that make a Windows canvas actually receive frames while its bounds are changing. Host-side only —
webview2_host.cpp, its source pins, and four lines ofbuild/app.zig; no renderer files — so it can land independently of the flip-model migration in #330, which it was previously sitting behind.Absorbs #324 (now closed): that fix's correct shape depends on whether the timer queue is present, so keeping it as a separate PR meant maintaining two conflicting versions of it.
What was wrong
A geometry change was not a reason to draw. Emissions are scheduled by input, by animation, and by explicit frame requests. The top-level
WM_SIZEhandler re-arms child surfaces, but only ones that already had an emission pending — so a panel that happened to be idle when the window (or a dock divider) moved never got a frame event carrying its new size. It kept presenting the packet it rendered for the old bounds until the pointer wandered in and woke it for something unrelated. On a 20-surface app, of the seven panels whose bounds moved in a divider drag, one re-rendered and six waited. Which panels fall on which side is pure accident of what happened to be animating.That same re-arm cancels frames during a drag. It exists for restore-from-minimize, where a heartbeat-paced deadline can be parked up to a second out and superseding it returns full cadence. But
WM_SIZEalso arrives on every step of a live resize, where the pending emission is already grid-paced and already nearly due — re-arming there discards a frame that was about to fire and restarts its wait. Measured on a 165 Hz desktop over a ~10 s drag: 2,639 deadlines armed, 445 fired (17%). The window painted 2,250 times against 215 presents, so roughly nine of every ten frames on screen were the previous frame stretched to the new size. It hides well, because a scaled last-good frame is a convincing stand-in for a laid-out one.And the deadline itself was coarse. A nominal 16.67 ms
SetTimeris commonly rounded to the legacy timer grid, andWM_TIMERis only generated after higher-priority input has drained — so a high-rate trackpad or wheel could hold a due frame pending for hundreds of milliseconds.The commits
672f682eCreateTimerQueueTimerand deliver them as a postedkGpuEmitMessage, with a process-owned high-resolution waitable timer following the earliest deadline. A generation counter fences a re-arm against a callback already in flight.2976afbbKillTimer(hwnd, kGpuEmitTimerId);andif (wparam == kGpuEmitTimerId), neither of which survives the change.bf4da8c243f4d06fWM_SIZE, with a forced full repaint — the runtime plans an idle frame for an unchanged scene, and a resize changes the viewport, not the scene. Gated on the geometry actually changing, so a stream ofWM_SIZEcannot hold a full-rate loop open.Validation
zig build test-desktop-platform— 210 pass, 1 skip, 0 fail on this branch standalone.Worth stating plainly: this is not observable on the SDK's own examples. They are single-surface and light enough that the old scheduling kept up; the numbers above come from a 20-surface application.
tools/gpu-image-fixture(added in #330) does not help here either — it prices texture-cache cost, not frame scheduling.🤖 Generated with Claude Code