Skip to content

perf(core): a render frame with no mover to blur skips capture blur - #7038

Closed
vanceingalls wants to merge 2 commits into
mainfrom
perf/motion-blur-plain-still-frames
Closed

vanceingalls wants to merge 2 commits into
mainfrom
perf/motion-blur-plain-still-frames

Conversation

@vanceingalls

@vanceingalls vanceingalls commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator

What

With capture blur on, every render frame goes through the engine's composite handshake (a forced paint and two page calls), a full drawElementImage of the root and the pose reads, even when no mover would be blurred on that frame. Now a render frame whose pose reads keep no mover is drawn as the plain page: the root is unwrapped right after the reads, so the engine's composite step is never armed and no capture runs. Preview, the filter tier and frames that keep a mover are unchanged.

How

start() keeps main's order: wrap(), then the reads, then compositor.plan(). Right after the plan, if (noMoverToBlur(kept, engineMode)) { unwrap(); return Promise.resolve(); }. The test is the old path's own kept, so a frame is skipped exactly when the old path would have drawn it sharp. Nothing is predicted from the scan, so there is no new way to draw a blurred frame sharp.

wrap() has to stay before the reads. A trial that moved it after them failed three existing tests, because the root's silent resync seek then lands after holdFrame's restore. Only unwrap() follows the reads, and its resync is a same-time silent seek. Two new real-GSAP tests pin that this extra seek neither re-fires a call nor reorders same-time sets.

Chrome drops the scrollTop/scrollLeft of scrolled containers when the root moves in or out of the layoutsubtree canvas. On main the root stays wrapped, but a root that now alternates would lose them, and GSAP's same-time resync cannot put them back. So discover() collects the scroll containers once per scan and moveRootThenResync saves and restores their offsets around the move.

What this replaces on this branch

An earlier revision decided before the reads, from a hash of every changing element's box at each scan step (stillAt). It skipped the reads too, but any move that leaves and returns to its box between two scan steps rendered sharp: a 0.06 s shake in a 20 s film lost 3 frames (smear up to 43 px), a 0.3 s shake in a 120 s film 10 frames. No frame of the 12 capture-tier launch films hit it (0 of 12,990), but nothing prevented it. Deciding after the reads removes the blind spot and is also faster: it saves the capture on every frame without a mover to blur, not only on frames where no box moves.

Results

Mac, Chrome 156, hardware GPU, 4 workers, draft quality, main build against this branch, two alternated pairs. Wall seconds, old to new:

  • 20 s film, one 0.06 s shake: 18.5 / 18.5 to 4.0 / 3.9
  • 120 s film, one 0.3 s shake: 106.6 / 106.5 to 15.3 / 20.8
  • 10 s film with calls and an onUpdate on frames drawn plain: 9.8 / 9.8 to 5.2 / 5.1
  • 10 s film that scrolls a list by scrollTop and holds: 9.9 / 9.8 to 4.1 / 4.4
  • k3-promo: 18.4 / 18.4 to 15.0 / 15.2
  • spacex-launch: 38.5 / 37.5 to 25.3 / 24.6
  • website-to-hyperframes: 43.5 / 39.9 to 22.1 / 24.8
  • figma-launch: 65.8 / 62.5 to 45.6 / 47.1

failedFrames=0 in every render. Compared per frame with the main output, no frame of the constructed films is below 56 dB (the calls film is above 84 dB on the frames with calls), and on real films the new output is closer to a blur-off render than main's on 114 (k3-promo), 319 (spacex-launch), 257 (website) and 797 (figma) frames, and further by more than 1 dB on at most 15, all at noise level or identical frames (101 to 99 dB). main draws the frames it calls sharp through the capture, which is slightly less faithful than the plain page.

Linux container (Chrome 156, software GPU), same protocol, before the scroll fix: the 20 s shake film 16.4 / 16.4 to 4.5 / 4.5, the calls film 8.9 / 8.8 to 5.0 / 5.0, but k3-promo 41.6 / 41.1 to 39.3 / 39.1 and spacex-launch 62.2 / 62.2 to 63.1 / 64.4.

Limits

  • With a software GPU there is no gain on films that always keep a mover. There is no GPU readback to skip, so the saving is large on mostly still films and about nothing on k3-promo and spacex-launch. Hosted renders are software-GPU renders.
  • A frame drawn plain still pays wrap(), the 8 pose reads and unwrap(), with two silent same-time seeks and two moves of the root.
  • Scroll containers are found once per scan. A container that becomes scrollable later still loses its offset on a move. CSS keyframe animations are recreated by the move and re-seeked by the resync.
  • report().frames and meanMs now count only frames that went through capture. Nothing in the engine reads them for decisions.
  • On the 20 s shake film, 111 frames differ from a blur-off render by 57 to 66 dB (glyph-edge anti-aliasing). A held stretch of that render changes 2 times frame to frame, against about 149 for blur off and main, which vary per worker.
  • Windows was not measured. The website and figma timings and the Linux numbers were taken before the scroll fix, which adds one read per scroll container on a root move.

Tests

The blur folder and the GSAP step tests pass (287 tests). New: a shake that falls between two scan steps stays blurred; a mover moving under a pixel across the shutter draws plain; a frame with no mover skips the capture (with a positive control that a moving frame draws); a frame left in flight is settled and its movers revealed; preview is unchanged; scroll offsets survive a wrap, an unwrap and a wrap again; and two real-GSAP tests for the same-time silent seek. The engine pixel test and the producer motionBlurVideo integration test run in CI.

🤖 Generated with Claude Code

Base automatically changed from fix/motion-blur-worker-start-state to main November 2, 2026 19:40
@vanceingalls
vanceingalls force-pushed the perf/motion-blur-plain-still-frames branch from 5a7dfe7 to 251823b Compare November 2, 2026 19:41
@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Edit accuracy: accurate 267 (base branch 267), smooth 89 of those

The gate passes.
Smoothness is reported in the artifact, not gated. A case fails only if it fails 2 of 3 runs.

Quarantined, measured but not gated (8)

Unstable (1)

  • seqplainkeys-none-px-r0-root-z100: tracking 0.06, pressJump 0, drop 0, reload 0, render 0.03, renderKey -, undo true, teleport true / tracking 0.06, pressJump 0, drop 0, reload 0, render 0.03, renderKey -, undo true, teleport true / tracking 0.06, pressJump 0, drop 0, reload 0, render 0.03, renderKey -, undo true, teleport true

vanceingalls and others added 2 commits November 2, 2026 13:51
A render frame decides from its own pose reads: when the compositor keeps
no mover for the frame, the root is unwrapped and the capture is skipped.
The decision follows the reads (the same `kept` test the sharp phase used),
so a shake between two scan steps still blurs and a sub-pixel mover draws
plain. wrap() stays before the reads.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
@vanceingalls
vanceingalls force-pushed the perf/motion-blur-plain-still-frames branch from 251823b to 4eaa255 Compare November 2, 2026 21:58
@vanceingalls vanceingalls changed the title perf(core): a render frame with nothing moving skips capture blur perf(core): a render frame with no mover to blur skips capture blur Nov 2, 2026

@heygengenesis heygengenesis Bot 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.

Verdict: APPROVE · 0 blocker · 0 important · 1 nit · head 4eaa255

The skip really is exact. It is the old path's own kept from compositor.plan(), and with nothing kept the old sharp phase was drawImage(capture) with no trails. The skip runs before the composite is armed, so the engine never gets a handshake.

  • nit (PR body): Limits says software GPU sees "no gain". But the Linux spacex-launch pairs went 62.2 / 62.2 → 63.1 / 64.4, about 1.5–3.5 % slower, and that was before the scroll fix added reads. Hosted renders use software GPU, so please call it a small loss on mixed films there, not neutral.
Scope

Read index.ts end to end (start/unwrap/finishJob/arm/finish, paintCaptureMotionBlur, armPageComposite) plus all three test diffs.

Checked:

  • A job in flight is revealed and settled by unwrap(). A queued preview request is released instead of restarted, which is better than the old finishJob re-entry.
  • Iframes are already the iframe-in-root defect. CSS transitions killed by the extra moves are already the css_transition_used lint warning. The CSS adapter re-reads getAnimations() on the resync.
  • report().frames and meanMs have no engine or CLI reader.

Locally, the blur and GSAP step suites pass (287 tests). All CI on this head is green.

— Claude (Genesis)

@vanceingalls

Copy link
Copy Markdown
Collaborator Author

Correction to the Results section (added after the review request). The timings above use draft quality and 4 workers. I then ran the Desktop's export protocol (standard quality, 8 workers, one warm-up, median of 3 rotated rounds, the main build against this branch, Chrome 156, hardware GPU) under heavy peer load (load average 40-140):

  • k3-promo, Subtle: 22.0 s on main, 20.8 s here (1.65x and 1.56x blur off).
  • spacex-launch, Subtle: 33.1 s on main, 36.0 s here (1.24x and 1.34x), tight across the three rounds.
  • codex-five-hour-limit-replica, Subtle: 26.8 s on main, 29.8 s here.

So under that protocol this change is neutral to roughly 10% slower on the capture-tier films, not 20-50% faster. I am isolating which setting flips it (quality, worker count, or the load) and will update this PR before it merges. The correctness results are unaffected. Please do not merge on the speedup numbers above.

@vanceingalls

Copy link
Copy Markdown
Collaborator Author

Follow-up on the correction above: the speed claim is unresolved, not disproved. I isolated quality and worker count with old and new interleaved on spacex-launch (Subtle): in all 8 paired runs across draft/4, standard/8, draft/8 and standard/4 workers the branch was faster (0.52-0.93 of the main time). A tighter counterbalanced run (old, new, new, old, old, new, new, old, 4 pairs) then favoured main (spacex-launch median 81.8 s here vs 63.7 s, codex-five-hour-limit-replica 41.2 s vs 36.5 s). The load average on the machine ranged from 20 to 212 across these runs because of other jobs, and the same build varied 2x between consecutive renders, so none of the three runs can settle it. The draft/4-worker numbers in the description were taken at a load average of about 7.

What I am confident of: correctness (no failed frames, no frame further from a blur-off render than main beyond noise, the shake, callback and scroll films match) and the 3-4x gain on mostly still films. What needs a quiet machine: the size of the gain on films that keep a mover on most frames, under the Desktop's export settings. I will rerun the benchmark when the machine is quiet and update this PR; until then please do not merge it on speed.

@vanceingalls

Copy link
Copy Markdown
Collaborator Author

Quiet-machine rerun: no speedup under the Desktop's export settings. Please do not merge as it stands.

Protocol: standard quality, 8 workers, Chrome 156, hardware GPU, main build against this branch, Subtle, counterbalanced order (old, new, new, old, old, new, new, old), one warm-up per film, and each render waited for a one-minute load below 10 (the load before every run is recorded; runs that started above 10 are excluded). failedFrames=0 in every render.

Medians, Subtle as a multiple of blur off:

  • k3-promo: 1.52x on main, 1.57x here (17.5 s, 18.1 s; Off 11.5 s)
  • spacex-launch: 1.28x on main, 1.33x here (30.6 s, 31.9 s; Off 24.0 s)
  • codex-five-hour-limit-replica (4K): 1.02x on main, 1.21x here (26.6 s, 31.5 s; Off 26.0 s). Every one of the four runs here (28.9-40.2 s) was slower than every run on main (25.1-27.8 s).

Strong shows the same shape (k3-promo 1.64x to 1.87x, codex 1.02x to 1.41x). So at the settings the Desktop uses, this change is about 3-4% slower on k3-promo and spacex-launch and about 20% slower on the 4K film. The draft-quality, 4-worker gains in the description do not carry over.

Likely cause (not yet proven): a frame drawn plain pays wrap(), the 8 pose reads and unwrap() every time, two moves of the root, which costs more than the capture it saves once the machine is quiet. Reading first and wrapping only on a still-to-moving transition would avoid that churn; the two GSAP tests on this branch pin that a same-time silent seek after the reads is safe. Correctness results (shake, callbacks and scroll films, no failed frames) are unaffected.

Moving this to draft until the design is reworked or the PR is closed.

@vanceingalls

Copy link
Copy Markdown
Collaborator Author

Closing. At Desktop export settings (standard quality, 8 workers) on a quiet machine this gave no speedup, and the scroll-offset part shipped as #7190. The skip-capture-on-plain-frames idea can come back if a measurement on a real film shows a gain.

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