This commit is contained in:
Miguel Ángel
2026-08-31 01:15:35 +07:00
committed by GitHub
4 changed files with 650 additions and 96 deletions
@@ -0,0 +1,308 @@
// @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;
let webglContextCount = 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 = `
<div data-composition-id="main" data-width="320" data-height="180" data-duration="4">
<div id="s1" class="scene"></div>
<div id="s2" class="scene"></div>
</div>`;
}
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<void> {
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<typeof vi.spyOn>;
beforeEach(() => {
drawArraysCalls = 0;
createProgramCalls = 0;
loseContextCalls = 0;
webglContextCount = 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,
) => {
if (type !== "webgl") return null;
webglContextCount += 1;
return createMockWebGl();
}) as never) as ReturnType<typeof vi.spyOn>;
});
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", async () => {
startShader([SHADER_TRANSITION]);
await settle();
expect(webglContextCount).toBe(1);
expect(loseContextCalls).toBe(0);
window.dispatchEvent(new Event("beforeunload"));
expect(loseContextCalls).toBe(1);
});
// Browsers cap concurrent WebGL contexts (~16) and silently drop the oldest,
// so init() must not take one on spec — only a shader transition that is
// actually going to draw may.
it("defers context creation until a shader transition needs it", async () => {
const { timeline } = startShader([SHADER_TRANSITION]);
expect(webglContextCount).toBe(0);
await settle();
timeline.time(1.5);
await settle();
timeline.time(1.5);
expect(webglContextCount).toBe(1);
expect(drawArraysCalls).toBeGreaterThan(0);
});
it("leaves a composition without shader transitions unaffected, and takes no context", async () => {
const { timeline, glCanvas } = startShader([CSS_TRANSITION]);
await settle();
timeline.time(1.5);
await settle();
expect(webglContextCount).toBe(0);
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);
});
});
+225 -90
View File
@@ -6,6 +6,7 @@ import {
createTexture,
uploadTextureSource,
renderShader,
manageContextLoss,
DEFAULT_WIDTH,
DEFAULT_HEIGHT,
type AccentColors,
@@ -101,7 +102,9 @@ interface CachedTransition {
duration: number;
fromId: string;
toId: string;
prog: WebGLProgram | null; // null for CSS-fallback transitions
/** The authored shader name; undefined for a CSS crossfade. */
shader: string | undefined;
prog: WebGLProgram | null; // null until the shader is compiled, and for CSS-fallback transitions
frames: CachedTransitionFrame[];
cacheKey: string;
dirty: boolean;
@@ -139,6 +142,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;
@@ -910,32 +930,6 @@ export function init(config: HyperShaderConfig): GsapTimeline {
glCanvas.style.width = `${compWidth}px`;
glCanvas.style.height = `${compHeight}px`;
const gl = createContext(glCanvas, compWidth, compHeight);
if (!gl) {
console.warn("[HyperShader] WebGL unavailable — shader transitions disabled.");
const fallback = config.timeline || gsap.timeline({ paused: true });
registerTimeline(compId, fallback, config.timeline);
return fallback;
}
const quadBuf = setupQuad(gl);
const programs = new Map<string, WebGLProgram>();
for (const t of transitions) {
// Strict undefined check — an explicit empty string from a vanilla-JS
// caller (the IIFE bundle is hand-loaded via <script> tags) should NOT
// be silently coerced into a CSS crossfade. The shader registry will
// throw a clear "unknown shader" error for it.
if (t.shader === undefined) continue;
if (!programs.has(t.shader)) {
try {
programs.set(t.shader, createProgram(gl, getFragSource(t.shader)));
} catch (e) {
console.error(`[HyperShader] Failed to compile "${t.shader}":`, e);
}
}
}
const canvasEl = glCanvas;
const previewCaptureFps = clampNumber(resolvePositiveNumber(config.previewCaptureFps, 30), 1, 60);
const previewCaptureScale = resolvePlayerCaptureScale();
@@ -943,30 +937,149 @@ export function init(config: HyperShaderConfig): GsapTimeline {
const previewTextureWidth = Math.max(1, Math.round(compWidth * previewCaptureScale));
const previewTextureHeight = Math.max(1, Math.round(compHeight * previewCaptureScale));
const cachedTransitions: CachedTransition[] = [];
const blendProg = createProgramWithVertex(
gl,
NO_FLIP_VERT_SRC,
[
"precision mediump float;",
"varying vec2 v_uv;",
"uniform sampler2D u_a;",
"uniform sampler2D u_b;",
"uniform float u_mix;",
"void main(){",
"gl_FragColor=mix(texture2D(u_a,v_uv),texture2D(u_b,v_uv),u_mix);",
"}",
].join(""),
);
const blendLoc = {
a: gl.getUniformLocation(blendProg, "u_a"),
b: gl.getUniformLocation(blendProg, "u_b"),
mix: gl.getUniformLocation(blendProg, "u_mix"),
pos: gl.getAttribLocation(blendProg, "a_pos"),
// The context and everything in it are created on demand — see ensureGl().
let gl: WebGLRenderingContext | null = null;
let glRes: GlResources | null = null;
let releaseGlContext: (() => void) | null = null;
let glUnavailable = false;
let contextLost = false;
// Every GL object below dies with the context, so they are created and
// recreated as one unit — see ensureGl() and the context-restore handler it
// installs. `programs` is a stable Map identity refilled in place because
// syncCacheProgram() reads it after every (re)build.
const programs = new Map<string, WebGLProgram>();
const createGlResources = (gl: WebGLRenderingContext): GlResources => {
programs.clear();
for (const t of transitions) {
// Strict undefined check — an explicit empty string from a vanilla-JS
// caller (the IIFE bundle is hand-loaded via <script> tags) should NOT
// be silently coerced into a CSS crossfade. The shader registry will
// throw a clear "unknown shader" error for it.
if (t.shader === undefined) continue;
if (programs.has(t.shader)) continue;
try {
programs.set(t.shader, createProgram(gl, getFragSource(t.shader)));
} catch (e) {
console.error(`[HyperShader] Failed to compile "${t.shader}":`, e);
}
}
const blendProg = createProgramWithVertex(
gl,
NO_FLIP_VERT_SRC,
[
"precision mediump float;",
"varying vec2 v_uv;",
"uniform sampler2D u_a;",
"uniform sampler2D u_b;",
"uniform float u_mix;",
"void main(){",
"gl_FragColor=mix(texture2D(u_a,v_uv),texture2D(u_b,v_uv),u_mix);",
"}",
].join(""),
);
const fromTex = createRenderTexture(gl, previewTextureWidth, previewTextureHeight);
const toTex = createRenderTexture(gl, previewTextureWidth, previewTextureHeight);
return {
quadBuf: setupQuad(gl),
blendProg,
blendLoc: {
a: gl.getUniformLocation(blendProg, "u_a"),
b: gl.getUniformLocation(blendProg, "u_b"),
mix: gl.getUniformLocation(blendProg, "u_mix"),
pos: gl.getAttribLocation(blendProg, "a_pos"),
},
fromTex,
toTex,
fromFbo: createFramebuffer(gl, fromTex),
toFbo: createFramebuffer(gl, toTex),
};
};
const interpolatedFromTex = createRenderTexture(gl, previewTextureWidth, previewTextureHeight);
const interpolatedToTex = createRenderTexture(gl, previewTextureWidth, previewTextureHeight);
const interpolatedFromFbo = createFramebuffer(gl, interpolatedFromTex);
const interpolatedToFbo = createFramebuffer(gl, interpolatedToTex);
/**
* A cache learns its program when the context comes up, and again after a
* context restore — programs die with the context. A shader that fails to
* compile degrades to the CSS crossfade path so scene progression still runs.
*/
const syncCacheProgram = (cache: CachedTransition): void => {
if (cache.shader === undefined) return;
cache.prog = programs.get(cache.shader) ?? null;
if (cache.prog) return;
console.warn(
`[HyperShader] Shader "${cache.shader}" failed to compile — falling back to CSS crossfade.`,
);
cache.fallback = true;
cache.ready = true;
cache.dirty = false;
cache.persisted = true;
};
/**
* Create the context, and everything that lives in it, the first time a
* shader transition actually needs to draw.
*
* Browsers cap concurrent WebGL contexts (~16) and silently drop the oldest,
* so a composition whose transitions are all CSS crossfades — it never
* compiles a program, uploads a texture or draws — must not hold one. Both
* callers are on a shader path: the texture upload choke point and the
* shader draw in tickShader().
*/
const ensureGl = (): WebGLRenderingContext | null => {
if (gl || glUnavailable) return gl;
const created = createContext(canvasEl, compWidth, compHeight);
if (!created) {
glUnavailable = true;
console.warn("[HyperShader] WebGL unavailable — shader transitions play as CSS crossfades.");
return null;
}
gl = created;
glRes = createGlResources(created);
releaseGlContext = manageContextLoss(canvasEl, created, {
onLost: () => {
contextLost = true;
canvasEl.style.display = "none";
// Repaint at the current playhead so the DOM crossfade takes over
// immediately instead of leaving the last shader frame frozen on screen.
tickShader();
},
onRestored: () => {
contextLost = false;
try {
rebuildGlResources();
} catch (e) {
contextLost = true;
console.warn("[HyperShader] WebGL context restore failed:", e);
return;
}
// tickShader re-requests textures for the active transition, so this both
// repaints and kicks the re-upload.
tickShader();
},
});
for (const cache of cachedTransitions) syncCacheProgram(cache);
return created;
};
const rebuildGlResources = (): void => {
const restored = gl;
if (!restored) return;
glRes = createGlResources(restored);
for (const cache of cachedTransitions) {
syncCacheProgram(cache);
// Deleting the old textures would be a no-op on a lost context, so the
// handles are just dropped; ensureTransitionTextures re-uploads them from
// the cached blobs (in memory, or re-read from IndexedDB). Bumping the
// generation makes any in-flight upload job abandon its dead textures.
cache.textureGeneration += 1;
cache.texturePromise = null;
cache.textureReady = false;
for (const frame of cache.frames) {
frame.fromTex = null;
frame.toTex = null;
}
}
};
let loadingOverlay: SnapshotLoadingOverlay | null = null;
const getLoadingOverlay = (): SnapshotLoadingOverlay | null => {
if (loadingMode !== "internal") return null;
@@ -1057,6 +1170,8 @@ export function init(config: HyperShaderConfig): GsapTimeline {
};
const renderTextureBlend = (
gl: WebGLRenderingContext,
glRes: GlResources,
target: WebGLFramebuffer,
texA: WebGLTexture,
texB: WebGLTexture,
@@ -1064,17 +1179,17 @@ export function init(config: HyperShaderConfig): GsapTimeline {
): void => {
gl.bindFramebuffer(gl.FRAMEBUFFER, target);
gl.viewport(0, 0, previewTextureWidth, previewTextureHeight);
gl.useProgram(blendProg);
gl.useProgram(glRes.blendProg);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, texA);
gl.uniform1i(blendLoc.a, 0);
gl.uniform1i(glRes.blendLoc.a, 0);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, texB);
gl.uniform1i(blendLoc.b, 1);
gl.uniform1f(blendLoc.mix, mix);
gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf);
gl.enableVertexAttribArray(blendLoc.pos);
gl.vertexAttribPointer(blendLoc.pos, 2, gl.FLOAT, false, 0, 0);
gl.uniform1i(glRes.blendLoc.b, 1);
gl.uniform1f(glRes.blendLoc.mix, mix);
gl.bindBuffer(gl.ARRAY_BUFFER, glRes.quadBuf);
gl.enableVertexAttribArray(glRes.blendLoc.pos);
gl.vertexAttribPointer(glRes.blendLoc.pos, 2, gl.FLOAT, false, 0, 0);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.viewport(0, 0, compWidth, compHeight);
@@ -1136,7 +1251,7 @@ export function init(config: HyperShaderConfig): GsapTimeline {
currentTime < cache.time + cache.duration
);
});
if (upcoming) {
if (upcoming && !contextLost) {
preloadTransitionTextures(upcoming);
}
@@ -1156,11 +1271,22 @@ export function init(config: HyperShaderConfig): GsapTimeline {
canvasEl.style.display = "none";
return;
}
// First shader draw of the composition: this is where the context (and
// with it every program) comes into existence. Guarded so an all-CSS
// composition never creates one.
if (!cache.fallback && !contextLost) ensureGl();
// CSS-only transitions (prog === null) MUST take the fallback path. The
// fallback flag is the normal signal, but we also guard on prog to keep
// the invariant even if some path momentarily resets fallback while prog
// stays null (it can't be re-created — there is no shader to compile).
if (cache.fallback || cache.prog === null) {
// stays null (a CSS transition has no shader to compile, and ensureGl
// above found no context to compile one in).
// A lost context joins them: every program, buffer and texture below is
// dead until `webglcontextrestored`, so the DOM crossfade carries the
// transition instead of the shader.
const glCtx = gl;
const res = glRes;
if (cache.fallback || cache.prog === null || contextLost || !glCtx || !res) {
state.active = true;
state.transitionIndex = activeIndex;
state.prog = null;
@@ -1189,16 +1315,16 @@ export function init(config: HyperShaderConfig): GsapTimeline {
return;
}
paintScenePairState(cache.fromId, cache.toId, "1", "1");
renderTextureBlend(interpolatedFromFbo, frame.a.fromTex, frame.b.fromTex, frame.mix);
renderTextureBlend(interpolatedToFbo, frame.a.toTex, frame.b.toTex, frame.mix);
renderTextureBlend(glCtx, res, res.fromFbo, frame.a.fromTex, frame.b.fromTex, frame.mix);
renderTextureBlend(glCtx, res, res.toFbo, frame.a.toTex, frame.b.toTex, frame.mix);
canvasEl.style.display = "block";
renderShader(
gl,
quadBuf,
glCtx,
res.quadBuf,
prog,
interpolatedFromTex,
interpolatedToTex,
res.fromTex,
res.toTex,
state.progress,
accentColors,
compWidth,
@@ -1325,19 +1451,12 @@ export function init(config: HyperShaderConfig): GsapTimeline {
const toId = scenes[i + 1];
if (!fromId || !toId) continue;
// shader omitted → CSS crossfade. shader present but program failed to
// compile (logged above) → degrade gracefully to CSS crossfade so the
// opacity timeline still runs and scene progression isn't broken. Both
// paths land in the always-ready prog=null cache.
const requestedShader = t.shader !== undefined;
const compiledProg = requestedShader ? (programs.get(t.shader!) ?? null) : null;
const isCssFallback = !requestedShader || compiledProg === null;
if (requestedShader && compiledProg === null) {
console.warn(
`[HyperShader] Shader "${t.shader}" failed to compile — falling back to CSS crossfade.`,
);
}
const prog = isCssFallback ? null : compiledProg;
// shader omitted → CSS crossfade, an always-ready prog=null cache. A cache
// that DID ask for a shader also starts at prog=null: programs are compiled
// with the context, which does not exist yet. syncCacheProgram() fills prog
// in when ensureGl() brings the context up, and degrades the cache to this
// same always-ready CSS state if the shader fails to compile.
const isCssFallback = t.shader === undefined;
const dur = t.duration ?? DEFAULT_DURATION;
const ease = t.ease ?? DEFAULT_EASE;
@@ -1349,7 +1468,8 @@ export function init(config: HyperShaderConfig): GsapTimeline {
duration: dur,
fromId,
toId,
prog,
shader: t.shader,
prog: null,
frames: [],
cacheKey: "",
dirty: !isCssFallback,
@@ -1369,11 +1489,11 @@ export function init(config: HyperShaderConfig): GsapTimeline {
const toScene = document.getElementById(toId);
if (!fromScene || !toScene) return;
state.prog = prog;
const cache = cachedTransitions[cacheIndex];
state.prog = cache?.prog ?? null;
state.transitionIndex = cacheIndex;
state.progress = 0;
state.active = true;
const cache = cachedTransitions[cacheIndex];
if (cache?.fallback) {
applyFallbackTransition(cache, 0);
return;
@@ -1483,22 +1603,27 @@ export function init(config: HyperShaderConfig): GsapTimeline {
cache.textureGeneration += 1;
for (const frame of cache.frames) {
if (frame.fromTex) {
gl.deleteTexture(frame.fromTex);
gl?.deleteTexture(frame.fromTex);
frame.fromTex = null;
}
if (frame.toTex) {
gl.deleteTexture(frame.toTex);
gl?.deleteTexture(frame.toTex);
frame.toTex = null;
}
}
cache.textureReady = false;
};
// Caches with prog === null are CSS crossfade transitions and must stay in
// the always-ready fallback state. Without this guard, disposeCachedTransition
// Transitions that play as a DOM crossfade and must stay in the always-ready
// fallback state: the author asked for no shader, or the context is up and
// the shader failed to compile. Without this guard, disposeCachedTransition
// + markScenesDirty would route them through the WebGL prewarm path and
// tickShader would eventually call renderShader(state.prog!) with a null prog.
const isCssOnlyTransition = (cache: CachedTransition): boolean => cache.prog === null;
// `prog === null` alone cannot be the test — before ensureGl() every cache
// has a null prog, which would strand real shader transitions here and skip
// the prewarm that captures their frames.
const isCssOnlyTransition = (cache: CachedTransition): boolean =>
cache.shader === undefined || (gl !== null && cache.prog === null);
const disposeCachedTransition = (cache: CachedTransition): void => {
disposeTransitionTextures(cache);
@@ -1543,7 +1668,7 @@ export function init(config: HyperShaderConfig): GsapTimeline {
getSceneSignature(cache.fromId),
cache.toId,
getSceneSignature(cache.toId),
transitions[cache.index]?.shader || "unknown",
cache.shader || "unknown",
cache.time,
cache.duration,
sampleCount,
@@ -1707,8 +1832,8 @@ export function init(config: HyperShaderConfig): GsapTimeline {
]);
if (!fromEntry || !toEntry) {
for (const frame of hydratedFrames) {
gl.deleteTexture(frame.fromTex);
gl.deleteTexture(frame.toTex);
gl?.deleteTexture(frame.fromTex);
gl?.deleteTexture(frame.toTex);
}
return false;
}
@@ -1732,12 +1857,20 @@ export function init(config: HyperShaderConfig): GsapTimeline {
};
const ensureTransitionTextures = (cache: CachedTransition): Promise<boolean> => {
// Single choke point for texture GL work — everything that would upload to
// a dead context routes through here.
if (contextLost) return Promise.resolve(false);
if (cache.fallback || cache.dirty || !cache.ready) return Promise.resolve(false);
if (cache.textureReady) {
markTextureAccess(cache);
return Promise.resolve(true);
}
if (cache.texturePromise) return cache.texturePromise;
// Being that choke point, this is also where a composition first NEEDS a
// context: the guards above have already returned for every cache that
// plays as a CSS crossfade, so nothing that reaches here is context-free.
const gl = ensureGl();
if (!gl) return Promise.resolve(false);
const generation = cache.textureGeneration;
const frames = cache.frames;
@@ -2202,6 +2335,8 @@ export function init(config: HyperShaderConfig): GsapTimeline {
for (const observer of sceneEditObservers) {
observer.disconnect();
}
// Null when no shader transition ever drew — there is no context to release.
releaseGlContext?.();
},
{ once: true },
);
@@ -0,0 +1,83 @@
// @vitest-environment happy-dom
import { describe, expect, it, vi } from "vitest";
import { createProgram, manageContextLoss } from "./webgl.js";
function createMockWebGl(): WebGLRenderingContext {
const loseContext = vi.fn();
return {
VERTEX_SHADER: 0x8b31,
FRAGMENT_SHADER: 0x8b30,
COMPILE_STATUS: 0x8b81,
LINK_STATUS: 0x8b82,
createShader: vi.fn(() => ({})),
shaderSource: vi.fn(),
compileShader: vi.fn(),
getShaderParameter: vi.fn(() => true),
getShaderInfoLog: vi.fn(() => ""),
createProgram: vi.fn(() => ({})),
attachShader: vi.fn(),
linkProgram: vi.fn(),
getProgramParameter: vi.fn(() => true),
getProgramInfoLog: vi.fn(() => ""),
getExtension: vi.fn((name: string) => (name === "WEBGL_lose_context" ? { loseContext } : null)),
} as unknown as WebGLRenderingContext;
}
describe("manageContextLoss", () => {
it("cancels the default loss action so the context can be restored", () => {
const canvas = document.createElement("canvas");
const gl = createMockWebGl();
const onLost = vi.fn();
manageContextLoss(canvas, gl, { onLost, onRestored: vi.fn() });
const lost = new Event("webglcontextlost", { cancelable: true });
canvas.dispatchEvent(lost);
expect(lost.defaultPrevented).toBe(true);
expect(onLost).toHaveBeenCalledTimes(1);
});
it("reports restoration", () => {
const canvas = document.createElement("canvas");
const onRestored = vi.fn();
manageContextLoss(canvas, createMockWebGl(), { onLost: vi.fn(), onRestored });
canvas.dispatchEvent(new Event("webglcontextrestored"));
expect(onRestored).toHaveBeenCalledTimes(1);
});
it("releases the context explicitly on teardown and stops listening", () => {
const canvas = document.createElement("canvas");
const gl = createMockWebGl();
const onLost = vi.fn();
const release = manageContextLoss(canvas, gl, { onLost, onRestored: vi.fn() });
release();
const extension = vi.mocked(gl.getExtension).mock.results[0]?.value as {
loseContext: () => void;
};
expect(extension.loseContext).toHaveBeenCalledTimes(1);
canvas.dispatchEvent(new Event("webglcontextlost", { cancelable: true }));
expect(onLost).not.toHaveBeenCalled();
});
});
describe("createProgram", () => {
// The vertex shader used to be cached in a module-level singleton, which
// outlived the context it was compiled in — after a restore every program
// linked against a dead shader.
it("compiles a fresh vertex shader per program", () => {
const gl = createMockWebGl();
createProgram(gl, "void main(){}");
createProgram(gl, "void main(){}");
const vertexCompiles = vi
.mocked(gl.createShader)
.mock.calls.filter(([type]) => type === gl.VERTEX_SHADER);
expect(vertexCompiles).toHaveLength(2);
});
});
+34 -6
View File
@@ -15,6 +15,34 @@ export function createContext(
return gl as WebGLRenderingContext;
}
/**
* Keep `gl` recoverable, and hand it back explicitly when the caller is done.
*
* Browsers cap concurrent WebGL contexts (~16) and silently drop the oldest.
* The default action of `webglcontextlost` makes that drop permanent, so
* without `preventDefault()` a transition just stops rendering with no
* diagnostic. The returned teardown releases the context instead of waiting
* for the canvas to become collectable.
*/
export function manageContextLoss(
canvas: HTMLCanvasElement,
gl: WebGLRenderingContext,
handlers: { onLost: () => void; onRestored: () => void },
): () => void {
const handleLost = (event: Event): void => {
event.preventDefault();
handlers.onLost();
};
const handleRestored = (): void => handlers.onRestored();
canvas.addEventListener("webglcontextlost", handleLost);
canvas.addEventListener("webglcontextrestored", handleRestored);
return () => {
canvas.removeEventListener("webglcontextlost", handleLost);
canvas.removeEventListener("webglcontextrestored", handleRestored);
gl.getExtension("WEBGL_lose_context")?.loseContext();
};
}
export function setupQuad(gl: WebGLRenderingContext): WebGLBuffer {
const buf = gl.createBuffer();
if (!buf) throw new Error("[HyperShader] Failed to create quad buffer");
@@ -23,8 +51,6 @@ export function setupQuad(gl: WebGLRenderingContext): WebGLBuffer {
return buf;
}
let cachedVertexShader: WebGLShader | null = null;
function compileShader(gl: WebGLRenderingContext, src: string, type: number): WebGLShader {
const s = gl.createShader(type);
if (!s) throw new Error("[HyperShader] Failed to create shader");
@@ -52,11 +78,13 @@ function linkProgram(
return p;
}
// The shared vertex shader used to be cached in a module-level singleton. That
// made it outlive both the context it was compiled in and any second context on
// the page, so every program linked after a context loss would link against a
// dead shader. It is five lines of GLSL compiled a handful of times per init —
// cheaper to recompile than to invalidate.
export function createProgram(gl: WebGLRenderingContext, fragSrc: string): WebGLProgram {
if (!cachedVertexShader) {
cachedVertexShader = compileShader(gl, vertSrc, gl.VERTEX_SHADER);
}
return linkProgram(gl, cachedVertexShader, fragSrc);
return createProgramWithVertex(gl, vertSrc, fragSrc);
}
export function createProgramWithVertex(