Skip to content

fix(core): preview recovers its WebGL scenes and effects after a lost context - #7028

Merged
miguel-heygen merged 9 commits into
mainfrom
fix/webgl-context-restore
Nov 2, 2026
Merged

miguel-heygen merged 9 commits into
mainfrom
fix/webgl-context-restore

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator

What

When a GPU process crash drops every WebGL context in the preview, the runtime now recovers what it can:

  • Every canvas in the page is allowed to restore (the runtime cancels the lost event on a capture listener; without that the browser never restores a context).
  • Once restored, the effect chains (data-vfx-chain) are rebuilt through initVfx, the one rebuild path that already existed, and repainted.
  • A paused scene is redrawn: its timeline is stepped off the current time and back, because seeking a timeline to the time it is already at calls no onUpdate, so a Three.js scene driven from onUpdate stayed blank until the next seek. A playing scene redraws on its own next tick.

Why

Windows GPU process crashes hit about 1.2% of launches and take every WebGL context with them. Before this, a paused preview showed blank 3D and blank effects until the user seeked, and effects stayed blank for the whole session.

Related work

Refs the earlier grading-only recovery in colorGrading.ts (its own canvases already rebuild; this adds the rest).

How

What goes wrong: the browser only restores a lost context if the page cancels the loss event, scene canvases belong to the composition so nothing cancelled it, effect chains were marked lost for good, and a paused timeline has no reason to call a scene's onUpdate again.

What the runtime does: one capture-phase listener on the document sees every canvas, cancels the loss, and after the restore asks for one repaint. The effect chains are rebuilt with the existing initVfx pass and repainted, and a paused timeline is stepped off its current time and back so its scene draws. A playing transport redraws on its next tick. A scene that never re-creates its own GL resources is out of reach (see the table).

Test plan

Measured on the devbox in headless Chromium with swiftshader, one page per state, a composition holding a Three.js cube, a custom WebGL triangle and a vfx host. Each context is lost and restored with WEBGL_lose_context; "lit" is the share of non-black pixels in a real screenshot of that box.

Three.js, paused Three.js, playing custom GL, either vfx, paused vfx, playing
main blank draws (its own ticker) blank blank blank
this PR draws draws blank draws draws

The custom WebGL scene stays blank: it creates its program once and never re-creates it after a restore, so only its own code (or a reload of the composition frame) can bring it back. Not done here.

Fails without the fix: with init.ts restored to main, the new entry.test.ts test "redraws a paused scene by stepping its timeline off the current time and back after a WebGL restore" fails (1 failed, 114 passed); with vfx.ts stripped of the rebuild, "paints a host again after its lost context is restored and the chains are rebuilt" fails (1 failed, 57 passed). Core src/runtime on the devbox: 113 files, 2729 tests passed at b610439. The rest of the core package has 9 files that fail to import @hyperframes/lint and @hyperframes/studio-server because those packages were not built on that tree; none touch this change.

  • Unit tests added: webglContextRecovery.test.ts, vfx.test.ts, entry.test.ts
  • Manual testing performed: the fixture above in headless Chromium, not a real Windows crash
  • Chromium only; no other browser tried
  • Comments follow CONTRIBUTING.md

@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: REQUEST CHANGES · 3 blocker · 0 important · 0 nits · head 7044c1aa

The 'fails without the fix:' line is present and names the two tests. Blockers: one rebuild-ordering bug and two CI gates this diff turns red ↓ inline.

Scope

Read all 6 files end-to-end, plus initVfx, registerVfxHost, buildPasses, releaseRegistry and applySeek on the head. No prior reviews. CI at review time: Comments ✗, Preflight format:check ✗ (which also fails Player perf and preview-regression); CI, CodeQL, Windows and Safari runs still pending.

— Genesis (Lane B)

Comment thread packages/core/src/runtime/vfx.ts Outdated

/** Rebuilds every chain when a context was lost, so a restored one paints again. */
export function rebuildLostVfx(root: HTMLElement): void {
if (registry.some((entry) => entry.contextLost)) initVfx(root, registryFps);

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.

blocker — The rebuild runs as soon as any canvas restores, even if other vfx contexts are still lost. Browsers send webglcontextrestored once per context, so a Three.js canvas or the first .hf-vfx-out can restore a frame before the rest. When that happens, initVfx → registerVfxHost sees the lost context: createShader returns null, buildPasses reports "has no usable program", and out.remove() detaches the output canvas (vfx.ts:596-599). That host is now out of the registry, and its later restore event fires on a detached canvas, which the document capture listener never sees. So the effect stays blank for the whole session, which is the bug this PR is fixing.

A host whose context is still lost but survives registration is also stuck: it gets contextLost: false because its loss event already fired, so rebuildLostVfx never runs for it again.

Fix: rebuild only once no entry is still lost (registry.every((e) => !e.gl.isContextLost()), so the last restore triggers it). Alternatively, skip and keep entries whose gl.isContextLost() is still true. Add a test where two hosts lose their contexts and only one has restored when the frame runs.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed at 1cefca8. The rebuild now waits until no effect context is still lost, so an early restore no longer drops a host. The test shows it: with the wait removed it fails, with it the rebuild runs once the last context is back. Format and comment-ratchet items are fixed too.

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.

✅ Fixed in 1cefca8

Comment thread packages/core/src/runtime/init.ts Outdated
// Seeking to the time a timeline is already at calls no onUpdate, so step off it first.
const tl = state.capturedTimeline;
const at = state.currentTime;
if (tl && typeof tl.totalTime === "function") tl.totalTime(at > 1e-3 ? at - 1e-4 : at + 1e-4, false);

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.

blocker (CI) — Preflight format:check fails on packages/core/src/runtime/init.ts because this line is longer than the formatter's width. The same Preflight failure is why Player perf and preview-regression are red. Run the formatter.

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.

✅ Fixed in 1cefca8

@@ -0,0 +1,22 @@
/** A lost context is allowed to restore; one repaint follows after the scene's own restore handlers ran. */

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.

blocker (CI) — The Comments ratchet fails: this new file is 13.0% comments (3/23 lines), and the package is 12.4%. Cutting one of the three comments, or shortening this doc line into the function name, gets it under.

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.

✅ Fixed in 1cefca8

@github-actions

github-actions Bot commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

Edit accuracy: accurate 271 (base branch 271), smooth 105 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 (0)

@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 — 3 resolved, 0 open, 0 new · 7044c1a → 1cefca8

@miguel-heygen
miguel-heygen added this pull request to the merge queue Nov 2, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Nov 2, 2026
@miguel-heygen
miguel-heygen added this pull request to the merge queue Nov 2, 2026
Merged via the queue into main with commit e8ffaa4 Nov 2, 2026
98 checks passed
@miguel-heygen
miguel-heygen deleted the fix/webgl-context-restore branch November 2, 2026 18:06
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