From c81482b3fcfee19dd93b9e79420f73a85550b4fb Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 1 Aug 2026 13:39:17 +0200 Subject: [PATCH 1/2] fix(shader-transitions): survive WebGL context loss instead of relying on GC Browsers cap concurrent WebGL contexts (~16) and silently drop the oldest. HyperShader had no `webglcontextlost` listener, so a dropped context was permanent and surfaced as a transition that stopped rendering with no diagnostic; the context itself was only reclaimed when the canvas became collectable. Generalise the pattern already used by the runtime colour grader: - `manageContextLoss()` in webgl.ts cancels the default (unrecoverable) loss action, reports loss/restore, and returns a teardown that calls `WEBGL_lose_context.loseContext()` explicitly. - While the context is lost, HyperShader routes active transitions through the existing DOM crossfade and skips all texture uploads, so the composition keeps playing and no GL call touches a dead context. - On restore, programs, the quad buffer, the blend program and both interpolation render targets are rebuilt as a unit and the cached snapshot textures are re-uploaded from their blobs. - The shared vertex shader is no longer cached in a module-level singleton: it outlived its context, so every program linked after a restore would have linked against a dead shader. Verified in headless Chrome with real WebGL (SwiftShader): a shader transition under a forced `loseContext()` drops to 0 GL draws and crossfades in the DOM, then resumes real rendering after `restoreContext()`. --- .../src/hyper-shader.test.ts | 283 ++++++++++++++++++ .../shader-transitions/src/hyper-shader.ts | 189 ++++++++---- packages/shader-transitions/src/webgl.test.ts | 83 +++++ packages/shader-transitions/src/webgl.ts | 40 ++- 4 files changed, 534 insertions(+), 61 deletions(-) create mode 100644 packages/shader-transitions/src/hyper-shader.test.ts create mode 100644 packages/shader-transitions/src/webgl.test.ts diff --git a/packages/shader-transitions/src/hyper-shader.test.ts b/packages/shader-transitions/src/hyper-shader.test.ts new file mode 100644 index 000000000..484caca73 --- /dev/null +++ b/packages/shader-transitions/src/hyper-shader.test.ts @@ -0,0 +1,283 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// html2canvas needs a real browser. The snapshot pipeline only cares that it +// gets back a canvas it can encode, so stand in a stub and keep the test on the +// context-loss behaviour. +vi.mock("./capture.js", () => ({ + initCapture: () => undefined, + isHtmlInCanvasCaptureSupported: () => false, + captureScene: () => + Promise.resolve({ + width: 8, + height: 8, + toBlob: (cb: (blob: Blob) => void) => cb(new Blob(["x"])), + } as unknown as HTMLCanvasElement), +})); + +import { init, type TransitionConfig } from "./hyper-shader.js"; + +let drawArraysCalls = 0; +let createProgramCalls = 0; +let loseContextCalls = 0; + +function createMockWebGl(): WebGLRenderingContext { + return { + VERTEX_SHADER: 0x8b31, + FRAGMENT_SHADER: 0x8b30, + COMPILE_STATUS: 0x8b81, + LINK_STATUS: 0x8b82, + TEXTURE_2D: 0x0de1, + TEXTURE_WRAP_S: 0x2802, + TEXTURE_WRAP_T: 0x2803, + TEXTURE_MIN_FILTER: 0x2801, + TEXTURE_MAG_FILTER: 0x2800, + CLAMP_TO_EDGE: 0x812f, + LINEAR: 0x2601, + RGBA: 0x1908, + UNSIGNED_BYTE: 0x1401, + ARRAY_BUFFER: 0x8892, + STATIC_DRAW: 0x88e4, + TEXTURE0: 0x84c0, + TEXTURE1: 0x84c1, + FLOAT: 0x1406, + TRIANGLE_STRIP: 0x0005, + UNPACK_FLIP_Y_WEBGL: 0x9240, + FRAMEBUFFER: 0x8d40, + COLOR_ATTACHMENT0: 0x8ce0, + createShader: () => ({}), + shaderSource: () => undefined, + compileShader: () => undefined, + getShaderParameter: () => true, + getShaderInfoLog: () => "", + createProgram: () => { + createProgramCalls += 1; + return {}; + }, + attachShader: () => undefined, + linkProgram: () => undefined, + getProgramParameter: () => true, + getProgramInfoLog: () => "", + createTexture: () => ({}), + bindTexture: () => undefined, + texParameteri: () => undefined, + texImage2D: () => undefined, + createFramebuffer: () => ({}), + bindFramebuffer: () => undefined, + framebufferTexture2D: () => undefined, + createBuffer: () => ({}), + bindBuffer: () => undefined, + bufferData: () => undefined, + getAttribLocation: () => 0, + getUniformLocation: (_p: WebGLProgram, name: string) => name, + viewport: () => undefined, + useProgram: () => undefined, + activeTexture: () => undefined, + pixelStorei: () => undefined, + uniform1i: () => undefined, + uniform1f: () => undefined, + uniform2f: () => undefined, + uniform3f: () => undefined, + enableVertexAttribArray: () => undefined, + vertexAttribPointer: () => undefined, + drawArrays: () => { + drawArraysCalls += 1; + }, + deleteTexture: () => undefined, + getExtension: (name: string) => + name === "WEBGL_lose_context" + ? { + loseContext: () => { + loseContextCalls += 1; + }, + } + : null, + } as unknown as WebGLRenderingContext; +} + +interface FakeTimeline { + paused: () => boolean; + play: (from?: number) => FakeTimeline; + pause: (at?: number) => FakeTimeline; + time: (value?: number) => FakeTimeline | number; + call: () => FakeTimeline; + to: () => FakeTimeline; + set: () => FakeTimeline; + from: () => FakeTimeline; + fromTo: () => FakeTimeline; + [key: string]: unknown; +} + +function makeTimeline(): FakeTimeline { + let position = 0; + let paused = true; + const tl: FakeTimeline = { + paused: () => paused, + play: (from?: number) => { + if (typeof from === "number") position = from; + paused = false; + return tl; + }, + pause: (at?: number) => { + if (typeof at === "number") position = at; + paused = true; + return tl; + }, + time: (value?: number) => { + if (value === undefined) return position; + position = value; + return tl; + }, + call: () => tl, + to: () => tl, + set: () => tl, + from: () => tl, + fromTo: () => tl, + }; + return tl; +} + +function setupDom(): void { + document.body.innerHTML = ` +
+
+
+
`; +} + +function startShader(transitions: TransitionConfig[]): { + timeline: { time: (value: number) => void }; + glCanvas: HTMLCanvasElement; +} { + const timeline = makeTimeline(); + init({ + bgColor: "#000", + scenes: ["s1", "s2"], + transitions, + timeline: timeline as never, + compositionId: "main", + previewCaptureFps: 1, + }); + const glCanvas = document.getElementById("gl-canvas"); + if (!(glCanvas instanceof HTMLCanvasElement)) throw new Error("gl canvas missing"); + return { + timeline: { + time: (value: number) => { + (timeline.time as (v: number) => unknown)(value); + }, + }, + glCanvas, + }; +} + +/** Wait for the prewarm + texture-upload promise chains to settle. */ +async function settle(): Promise { + for (let i = 0; i < 40; i += 1) { + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } +} + +function sceneOpacity(id: string): string { + return document.getElementById(id)?.style.opacity ?? ""; +} + +const SHADER_TRANSITION: TransitionConfig = { time: 1, duration: 1, shader: "glitch" }; +const CSS_TRANSITION: TransitionConfig = { time: 1, duration: 1 }; + +describe("HyperShader WebGL context loss", () => { + let getContextSpy: ReturnType; + + beforeEach(() => { + drawArraysCalls = 0; + createProgramCalls = 0; + loseContextCalls = 0; + setupDom(); + // No IndexedDB: the snapshot cache degrades to in-memory blobs, which is + // all this test needs. + vi.stubGlobal("indexedDB", undefined); + vi.stubGlobal("createImageBitmap", () => Promise.resolve({})); + getContextSpy = vi + .spyOn(HTMLCanvasElement.prototype, "getContext") + .mockImplementation(((type: string) => + type === "webgl" ? createMockWebGl() : null) as never) as ReturnType; + }); + + afterEach(() => { + // Drain this test's beforeunload teardown (registered `{ once: true }`) so + // it cannot fire during the next test on the shared window. + window.dispatchEvent(new Event("beforeunload")); + getContextSpy.mockRestore(); + vi.unstubAllGlobals(); + document.body.innerHTML = ""; + }); + + it("stops shader work without throwing when the context is lost", async () => { + const { timeline, glCanvas } = startShader([SHADER_TRANSITION]); + await settle(); + timeline.time(1.5); + await settle(); + timeline.time(1.5); + expect(drawArraysCalls).toBeGreaterThan(0); + + const lost = new Event("webglcontextlost", { cancelable: true }); + glCanvas.dispatchEvent(lost); + + expect(lost.defaultPrevented).toBe(true); + expect(glCanvas.style.display).toBe("none"); + + drawArraysCalls = 0; + expect(() => timeline.time(1.5)).not.toThrow(); + await settle(); + expect(drawArraysCalls).toBe(0); + expect(glCanvas.style.display).toBe("none"); + // The transition still plays — as a DOM crossfade. + expect(Number(sceneOpacity("s1"))).toBeCloseTo(0.5, 5); + expect(Number(sceneOpacity("s2"))).toBeCloseTo(0.5, 5); + }); + + it("rebuilds and resumes rendering when the context is restored", async () => { + const { timeline, glCanvas } = startShader([SHADER_TRANSITION]); + await settle(); + timeline.time(1.5); + await settle(); + + glCanvas.dispatchEvent(new Event("webglcontextlost", { cancelable: true })); + const programsBeforeRestore = createProgramCalls; + drawArraysCalls = 0; + + glCanvas.dispatchEvent(new Event("webglcontextrestored")); + + // Programs, buffers and render targets are rebuilt, not reused. + expect(createProgramCalls).toBeGreaterThan(programsBeforeRestore); + + await settle(); + timeline.time(1.5); + expect(drawArraysCalls).toBeGreaterThan(0); + expect(glCanvas.style.display).toBe("block"); + }); + + it("releases the context on teardown instead of leaving it to GC", () => { + startShader([SHADER_TRANSITION]); + expect(loseContextCalls).toBe(0); + + window.dispatchEvent(new Event("beforeunload")); + + expect(loseContextCalls).toBe(1); + }); + + it("leaves a composition without shader transitions unaffected", async () => { + const { timeline, glCanvas } = startShader([CSS_TRANSITION]); + await settle(); + timeline.time(1.5); + expect(drawArraysCalls).toBe(0); + expect(Number(sceneOpacity("s1"))).toBeCloseTo(0.5, 5); + + glCanvas.dispatchEvent(new Event("webglcontextlost", { cancelable: true })); + + expect(() => timeline.time(1.5)).not.toThrow(); + expect(Number(sceneOpacity("s1"))).toBeCloseTo(0.5, 5); + expect(Number(sceneOpacity("s2"))).toBeCloseTo(0.5, 5); + expect(drawArraysCalls).toBe(0); + }); +}); diff --git a/packages/shader-transitions/src/hyper-shader.ts b/packages/shader-transitions/src/hyper-shader.ts index 3223edb74..d56cb0743 100644 --- a/packages/shader-transitions/src/hyper-shader.ts +++ b/packages/shader-transitions/src/hyper-shader.ts @@ -6,6 +6,7 @@ import { createTexture, uploadTextureSource, renderShader, + manageContextLoss, DEFAULT_WIDTH, DEFAULT_HEIGHT, type AccentColors, @@ -139,6 +140,23 @@ interface SnapshotCacheEntry { updatedAt: number; } +/** Every GL object owned by one init() call — recreated as a unit on restore. */ +interface GlResources { + quadBuf: WebGLBuffer; + blendProg: WebGLProgram; + blendLoc: { + a: WebGLUniformLocation | null; + b: WebGLUniformLocation | null; + mix: WebGLUniformLocation | null; + pos: number; + }; + /** Render targets holding the two motion-interpolated transition frames. */ + fromTex: WebGLTexture; + toTex: WebGLTexture; + fromFbo: WebGLFramebuffer; + toFbo: WebGLFramebuffer; +} + interface SceneStyleState { scene: HTMLElement | null; opacity: string; @@ -918,24 +936,6 @@ export function init(config: HyperShaderConfig): GsapTimeline { return fallback; } - const quadBuf = setupQuad(gl); - - const programs = new Map(); - for (const t of transitions) { - // Strict undefined check — an explicit empty string from a vanilla-JS - // caller (the IIFE bundle is hand-loaded via