mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
feat: add @hyperframes/shader-transitions package (#251)
## Summary
New `@hyperframes/shader-transitions` package that encapsulates WebGL shader transitions into a single `HyperShader.init()` call. Replaces ~200 lines of per-composition boilerplate that LLMs failed to wire correctly 60% of the time.
### API
```js
var tl = HyperShader.init({
bgColor: "#0a0a1a",
accentColor: "#6366f1",
scenes: ["scene1", "scene2", "scene3", "scene4", "scene5"],
transitions: [
{ time: 7.2, shader: "cross-warp-morph", duration: 0.7 },
{ time: 15.2, shader: "domain-warp", duration: 0.7 },
]
});
tl.from("#s1-title", { y: 50, opacity: 0, duration: 0.7 }, 0.3);
```
### What the library handles
- **13 shader programs**: domain-warp, ridged-burn, whip-pan, sdf-iris, ripple-waves, gravitational-lens, cinematic-zoom, chromatic-split, glitch, swirl-vortex, thermal-distortion, cross-warp-morph, light-leak
- **html2canvas** bundled as dependency (not CDN) — single script tag for CLI users
- **DOM-during-holds**: canvas hidden between transitions, GSAP animations play on live DOM
- **Async capture with pause/resume**: timeline pauses during capture, resumes after textures uploaded — prevents progress tween from running ahead
- **Accent color theming**: `accentColor` derives dark/mid/bright uniforms. Burns, glows, leaks match the composition palette
- **Graceful degradation**: falls back silently when WebGL unavailable
### Code quality (from 3 review agents)
- No `!` non-null assertions — all WebGL creation calls throw on failure
- Vertex shader compiled once, cached across all programs
- Uniform/attribute locations cached per program via WeakMap (not looked up every frame)
- Captured canvases freed after texture upload (8MB each)
- Single timeline creation (was creating two, discarding one)
- Shared `tickShader()` render callback (was copy-pasted)
- `.finally()` for DOM restore in capture (was duplicated in `.then`/`.catch`)
- `parseHex` validates input (was silently producing NaN on invalid hex)
- Dead `ND`/`CP` shader library exports removed
### Shader-compatible CSS rules (transitions.md)
6 rules for compositions using shader transitions:
1. No `transparent` in gradients (canvas interpolates through black)
2. No gradient backgrounds on elements < 4px
3. No CSS variables on captured elements
4. `data-no-capture` for uncapturable decoratives
5. No gradient opacity < 0.15
6. Every `.scene` must have explicit `background-color` matching `bgColor`
### Build output
- IIFE (~214KB with html2canvas bundled, ~65KB gzipped) — `window.HyperShader`
- ESM + CJS + TypeScript declarations
- tsup build following `@hyperframes/player` conventions
## Test plan
- [ ] `bun run build` succeeds (includes shader-transitions)
- [ ] `bunx oxlint packages/shader-transitions/src/` — 0 errors
- [ ] Create a composition using `HyperShader.init()` — verify transitions fire, DOM animations play, accent colors match
- [ ] Test graceful degradation: composition works without WebGL (no transitions, no crash)
- [ ] Verify pause/resume: scrub to transition boundary — no jump in progress
🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
@@ -102,7 +102,7 @@
|
||||
},
|
||||
"packages/player": {
|
||||
"name": "@hyperframes/player",
|
||||
"version": "0.2.2",
|
||||
"version": "0.2.4",
|
||||
"devDependencies": {
|
||||
"tsup": "^8.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
@@ -147,6 +147,18 @@
|
||||
"typescript": "^5.7.2",
|
||||
},
|
||||
},
|
||||
"packages/shader-transitions": {
|
||||
"name": "@hyperframes/shader-transitions",
|
||||
"version": "0.2.4",
|
||||
"dependencies": {
|
||||
"html2canvas": "^1.4.1",
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsup": "^8.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vitest": "^3.2.4",
|
||||
},
|
||||
},
|
||||
"packages/studio": {
|
||||
"name": "@hyperframes/studio",
|
||||
"version": "0.2.4",
|
||||
@@ -162,6 +174,7 @@
|
||||
"@codemirror/theme-one-dark": "^6.1.2",
|
||||
"@codemirror/view": "^6.40.0",
|
||||
"@hyperframes/core": "workspace:*",
|
||||
"@hyperframes/player": "workspace:*",
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"codemirror": "^6.0.1",
|
||||
"motion": "^12.38.0",
|
||||
@@ -426,6 +439,8 @@
|
||||
|
||||
"@hyperframes/producer": ["@hyperframes/producer@workspace:packages/producer"],
|
||||
|
||||
"@hyperframes/shader-transitions": ["@hyperframes/shader-transitions@workspace:packages/shader-transitions"],
|
||||
|
||||
"@hyperframes/studio": ["@hyperframes/studio@workspace:packages/studio"],
|
||||
|
||||
"@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "5.1.2", "string-width-cjs": "npm:string-width@4.2.3", "strip-ansi": "7.2.0", "strip-ansi-cjs": "npm:strip-ansi@6.0.1", "wrap-ansi": "8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
|
||||
@@ -782,6 +797,8 @@
|
||||
|
||||
"bare-url": ["bare-url@2.4.0", "", { "dependencies": { "bare-path": "3.0.0" } }, "sha512-NSTU5WN+fy/L0DDenfE8SXQna4voXuW0FHM7wH8i3/q9khUSchfPbPezO4zSFMnDGIf9YE+mt/RWhZgNRKRIXA=="],
|
||||
|
||||
"base64-arraybuffer": ["base64-arraybuffer@1.0.2", "", {}, "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.9", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-OZd0e2mU11ClX8+IdXe3r0dbqMEznRiT4TfbhYIbcRPZkqJ7Qwer8ij3GZAmLsRKa+II9V1v5czCkvmHH3XZBg=="],
|
||||
|
||||
"basic-ftp": ["basic-ftp@5.2.0", "", {}, "sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw=="],
|
||||
@@ -860,6 +877,8 @@
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "3.1.1", "shebang-command": "2.0.0", "which": "2.0.2" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"css-line-break": ["css-line-break@2.1.0", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w=="],
|
||||
|
||||
"css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "1.0.0", "css-what": "6.2.2", "domhandler": "5.0.3", "domutils": "3.2.2", "nth-check": "2.1.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="],
|
||||
|
||||
"css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="],
|
||||
@@ -1006,6 +1025,8 @@
|
||||
|
||||
"html-escaper": ["html-escaper@3.0.3", "", {}, "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ=="],
|
||||
|
||||
"html2canvas": ["html2canvas@1.4.1", "", { "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" } }, "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA=="],
|
||||
|
||||
"htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "2.3.0", "domhandler": "5.0.3", "domutils": "3.2.2", "entities": "7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="],
|
||||
|
||||
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "7.1.4", "debug": "4.4.3" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
|
||||
@@ -1358,6 +1379,8 @@
|
||||
|
||||
"text-decoder": ["text-decoder@1.2.7", "", { "dependencies": { "b4a": "1.8.0" } }, "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ=="],
|
||||
|
||||
"text-segmentation": ["text-segmentation@1.0.3", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw=="],
|
||||
|
||||
"thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "1.3.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
|
||||
|
||||
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": "3.3.1" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
|
||||
@@ -1412,6 +1435,8 @@
|
||||
|
||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
|
||||
"utrie": ["utrie@1.0.2", "", { "dependencies": { "base64-arraybuffer": "^1.0.2" } }, "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw=="],
|
||||
|
||||
"vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="],
|
||||
|
||||
"vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "6.7.14", "debug": "4.4.3", "es-module-lexer": "1.7.0", "pathe": "2.0.3", "vite": "5.4.21" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="],
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "@hyperframes/shader-transitions",
|
||||
"version": "0.2.4",
|
||||
"description": "WebGL shader transitions for HyperFrames compositions",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/heygen-com/hyperframes",
|
||||
"directory": "packages/shader-transitions"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"script": "./dist/index.global.js",
|
||||
"import": "./src/index.ts",
|
||||
"require": "./dist/index.cjs"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"html2canvas": "^1.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsup": "^8.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import html2canvas from "html2canvas";
|
||||
|
||||
let patched = false;
|
||||
|
||||
function patchCreatePattern(): void {
|
||||
if (patched) return;
|
||||
patched = true;
|
||||
const orig = CanvasRenderingContext2D.prototype.createPattern;
|
||||
CanvasRenderingContext2D.prototype.createPattern = function (
|
||||
image: CanvasImageSource,
|
||||
repetition: string | null,
|
||||
): CanvasPattern | null {
|
||||
if (
|
||||
image &&
|
||||
"width" in image &&
|
||||
"height" in image &&
|
||||
((image as HTMLCanvasElement).width === 0 || (image as HTMLCanvasElement).height === 0)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return orig.call(this, image, repetition);
|
||||
};
|
||||
}
|
||||
|
||||
export function initCapture(): void {
|
||||
patchCreatePattern();
|
||||
}
|
||||
|
||||
export function captureScene(sceneEl: HTMLElement, bgColor: string): Promise<HTMLCanvasElement> {
|
||||
return html2canvas(sceneEl, {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
scale: 1,
|
||||
backgroundColor: bgColor,
|
||||
logging: false,
|
||||
ignoreElements: (el: Element) => el.tagName === "CANVAS" || el.hasAttribute("data-no-capture"),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture the incoming scene with .scene-content hidden (background + decoratives only).
|
||||
* Shows the scene behind the outgoing scene via z-index, waits 2 rAFs for font rendering,
|
||||
* captures, then restores.
|
||||
*/
|
||||
export function captureIncomingScene(
|
||||
toScene: HTMLElement,
|
||||
bgColor: string,
|
||||
): Promise<HTMLCanvasElement> {
|
||||
return new Promise<HTMLCanvasElement>((resolve, reject) => {
|
||||
const origZ = toScene.style.zIndex;
|
||||
const origOpacity = toScene.style.opacity;
|
||||
toScene.style.zIndex = "-1";
|
||||
toScene.style.opacity = "1";
|
||||
|
||||
const contentEl = toScene.querySelector<HTMLElement>(".scene-content");
|
||||
if (contentEl) contentEl.style.visibility = "hidden";
|
||||
|
||||
const restore = () => {
|
||||
if (contentEl) contentEl.style.visibility = "";
|
||||
toScene.style.opacity = origOpacity;
|
||||
toScene.style.zIndex = origZ;
|
||||
};
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
captureScene(toScene, bgColor).then(resolve, reject).finally(restore);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import {
|
||||
createContext,
|
||||
setupQuad,
|
||||
createProgram,
|
||||
createTexture,
|
||||
uploadTexture,
|
||||
renderShader,
|
||||
WIDTH,
|
||||
HEIGHT,
|
||||
type AccentColors,
|
||||
} from "./webgl.js";
|
||||
import { getFragSource, type ShaderName } from "./shaders/registry.js";
|
||||
import { initCapture, captureScene, captureIncomingScene } from "./capture.js";
|
||||
|
||||
declare const gsap: {
|
||||
timeline: (opts: Record<string, unknown>) => GsapTimeline;
|
||||
};
|
||||
|
||||
interface GsapTimeline {
|
||||
paused: () => boolean;
|
||||
play: () => GsapTimeline;
|
||||
pause: () => GsapTimeline;
|
||||
call: (fn: () => void, args: null, position: number) => GsapTimeline;
|
||||
to: (
|
||||
target: Record<string, unknown>,
|
||||
vars: Record<string, unknown>,
|
||||
position: number,
|
||||
) => GsapTimeline;
|
||||
set: (target: string, vars: Record<string, unknown>, position?: number) => GsapTimeline;
|
||||
from: (target: string, vars: Record<string, unknown>, position?: number) => GsapTimeline;
|
||||
fromTo: (
|
||||
target: string,
|
||||
from: Record<string, unknown>,
|
||||
to: Record<string, unknown>,
|
||||
position?: number,
|
||||
) => GsapTimeline;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface TransitionConfig {
|
||||
time: number;
|
||||
shader: ShaderName;
|
||||
duration?: number;
|
||||
ease?: string;
|
||||
}
|
||||
|
||||
export interface HyperShaderConfig {
|
||||
bgColor: string;
|
||||
accentColor?: string;
|
||||
scenes: string[];
|
||||
transitions: TransitionConfig[];
|
||||
timeline?: GsapTimeline;
|
||||
compositionId?: string;
|
||||
}
|
||||
|
||||
interface TransState {
|
||||
active: boolean;
|
||||
prog: WebGLProgram | null;
|
||||
fromId: string;
|
||||
toId: string;
|
||||
progress: number;
|
||||
}
|
||||
|
||||
function parseHex(hex: string): [number, number, number] {
|
||||
const h = hex.replace("#", "");
|
||||
if (h.length < 6) return [0.5, 0.5, 0.5];
|
||||
const r = parseInt(h.slice(0, 2), 16) / 255;
|
||||
const g = parseInt(h.slice(2, 4), 16) / 255;
|
||||
const b = parseInt(h.slice(4, 6), 16) / 255;
|
||||
if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return [0.5, 0.5, 0.5];
|
||||
return [r, g, b];
|
||||
}
|
||||
|
||||
function deriveAccentColors(hex: string): AccentColors {
|
||||
const [r, g, b] = parseHex(hex);
|
||||
return {
|
||||
accent: [r, g, b],
|
||||
dark: [r * 0.35, g * 0.35, b * 0.35],
|
||||
bright: [Math.min(1, r * 1.5 + 0.2), Math.min(1, g * 1.5 + 0.2), Math.min(1, b * 1.5 + 0.2)],
|
||||
};
|
||||
}
|
||||
|
||||
export function init(config: HyperShaderConfig): GsapTimeline {
|
||||
const { bgColor, scenes, transitions } = config;
|
||||
|
||||
const accentColors: AccentColors = config.accentColor
|
||||
? deriveAccentColors(config.accentColor)
|
||||
: { accent: [1, 0.6, 0.2], dark: [0.4, 0.15, 0], bright: [1, 0.85, 0.5] };
|
||||
|
||||
const root = document.querySelector<HTMLElement>("[data-composition-id]");
|
||||
const compId = config.compositionId || root?.getAttribute("data-composition-id") || "main";
|
||||
|
||||
const state: TransState = {
|
||||
active: false,
|
||||
prog: null,
|
||||
fromId: "",
|
||||
toId: "",
|
||||
progress: 0,
|
||||
};
|
||||
|
||||
let glCanvas = document.getElementById("gl-canvas") as HTMLCanvasElement | null;
|
||||
if (!glCanvas) {
|
||||
glCanvas = document.createElement("canvas");
|
||||
glCanvas.id = "gl-canvas";
|
||||
glCanvas.width = WIDTH;
|
||||
glCanvas.height = HEIGHT;
|
||||
glCanvas.style.cssText = `position:absolute;top:0;left:0;width:${WIDTH}px;height:${HEIGHT}px;z-index:100;pointer-events:none;display:none;`;
|
||||
(root || document.body).appendChild(glCanvas);
|
||||
}
|
||||
|
||||
const gl = createContext(glCanvas);
|
||||
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) {
|
||||
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 textures = new Map<string, WebGLTexture>();
|
||||
for (const id of scenes) {
|
||||
textures.set(id, createTexture(gl));
|
||||
}
|
||||
|
||||
const tickShader = () => {
|
||||
if (state.active && state.prog) {
|
||||
const fromTex = textures.get(state.fromId);
|
||||
const toTex = textures.get(state.toId);
|
||||
if (fromTex && toTex) {
|
||||
renderShader(gl, quadBuf, state.prog, fromTex, toTex, state.progress, accentColors);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let tl: GsapTimeline;
|
||||
if (config.timeline) {
|
||||
tl = config.timeline;
|
||||
const duration = Number(root?.getAttribute("data-duration") || "40");
|
||||
tl.to({ t: 0 }, { t: 1, duration, ease: "none", onUpdate: tickShader }, 0);
|
||||
} else {
|
||||
tl = gsap.timeline({ paused: true, onUpdate: tickShader });
|
||||
}
|
||||
|
||||
initCapture();
|
||||
glCanvas.style.display = "none";
|
||||
|
||||
const canvasEl = glCanvas;
|
||||
|
||||
for (let i = 0; i < transitions.length; i++) {
|
||||
const t = transitions[i];
|
||||
const fromId = scenes[i];
|
||||
const toId = scenes[i + 1];
|
||||
if (!fromId || !toId) continue;
|
||||
|
||||
const prog = programs.get(t.shader);
|
||||
if (!prog) continue;
|
||||
|
||||
const dur = t.duration ?? 0.7;
|
||||
const ease = t.ease ?? "power2.inOut";
|
||||
const T = t.time;
|
||||
|
||||
// Pause timeline during async capture to prevent the progress tween
|
||||
// from running ahead. Resume once textures are uploaded.
|
||||
tl.call(
|
||||
() => {
|
||||
const fromScene = document.getElementById(fromId);
|
||||
const toScene = document.getElementById(toId);
|
||||
if (!fromScene || !toScene) return;
|
||||
|
||||
const wasPlaying = !tl.paused();
|
||||
if (wasPlaying) tl.pause();
|
||||
|
||||
captureScene(fromScene, bgColor)
|
||||
.then((fromCanvas) => {
|
||||
const fromTex = textures.get(fromId);
|
||||
if (fromTex) uploadTexture(gl, fromTex, fromCanvas);
|
||||
return captureIncomingScene(toScene, bgColor);
|
||||
})
|
||||
.then((toCanvas) => {
|
||||
const toTex = textures.get(toId);
|
||||
if (toTex) uploadTexture(gl, toTex, toCanvas);
|
||||
|
||||
document.querySelectorAll<HTMLElement>(".scene").forEach((s) => {
|
||||
s.style.opacity = "0";
|
||||
});
|
||||
canvasEl.style.display = "block";
|
||||
state.prog = prog;
|
||||
state.fromId = fromId;
|
||||
state.toId = toId;
|
||||
state.progress = 0;
|
||||
state.active = true;
|
||||
|
||||
if (wasPlaying) tl.play();
|
||||
})
|
||||
.catch((e) => {
|
||||
console.warn("[HyperShader] Capture failed, falling back to hard cut:", e);
|
||||
document.querySelectorAll<HTMLElement>(".scene").forEach((s) => {
|
||||
s.style.opacity = "0";
|
||||
});
|
||||
const scene = document.getElementById(toId);
|
||||
if (scene) scene.style.opacity = "1";
|
||||
if (wasPlaying) tl.play();
|
||||
});
|
||||
},
|
||||
null,
|
||||
T,
|
||||
);
|
||||
|
||||
const proxy = { p: 0 };
|
||||
tl.to(
|
||||
proxy,
|
||||
{
|
||||
p: 1,
|
||||
duration: dur,
|
||||
ease,
|
||||
onUpdate: () => {
|
||||
state.progress = proxy.p;
|
||||
},
|
||||
},
|
||||
T,
|
||||
);
|
||||
|
||||
tl.call(
|
||||
() => {
|
||||
state.active = false;
|
||||
canvasEl.style.display = "none";
|
||||
const scene = document.getElementById(toId);
|
||||
if (scene) scene.style.opacity = "1";
|
||||
},
|
||||
null,
|
||||
T + dur,
|
||||
);
|
||||
}
|
||||
|
||||
registerTimeline(compId, tl, config.timeline);
|
||||
return tl;
|
||||
}
|
||||
|
||||
function registerTimeline(
|
||||
compId: string,
|
||||
tl: GsapTimeline,
|
||||
provided: GsapTimeline | undefined,
|
||||
): void {
|
||||
if (!provided) {
|
||||
const w = window as unknown as { __timelines: Record<string, unknown> };
|
||||
w.__timelines = w.__timelines || {};
|
||||
w.__timelines[compId] = tl;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { init, type HyperShaderConfig, type TransitionConfig } from "./hyper-shader.js";
|
||||
export { SHADER_NAMES, type ShaderName } from "./shaders/registry.js";
|
||||
@@ -0,0 +1,26 @@
|
||||
/** Vertex shader — flips Y for WebGL coordinate system */
|
||||
export const vertSrc =
|
||||
"attribute vec2 a_pos; varying vec2 v_uv; void main(){" +
|
||||
"v_uv=a_pos*0.5+0.5; v_uv.y=1.0-v_uv.y; gl_Position=vec4(a_pos,0,1);}";
|
||||
|
||||
/** Shared uniform header — every fragment shader starts with this */
|
||||
export const H =
|
||||
"precision mediump float;" +
|
||||
"varying vec2 v_uv;" +
|
||||
"uniform sampler2D u_from, u_to;" +
|
||||
"uniform float u_progress;" +
|
||||
"uniform vec2 u_resolution;" +
|
||||
"uniform vec3 u_accent;" +
|
||||
"uniform vec3 u_accent_dark;" +
|
||||
"uniform vec3 u_accent_bright;\n";
|
||||
|
||||
/** Quintic C2 noise + inter-octave rotation FBM */
|
||||
export const NQ =
|
||||
"float hash(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);}" +
|
||||
"float vnoise(vec2 p){vec2 i=floor(p),f=fract(p);" +
|
||||
"f=f*f*f*(f*(f*6.-15.)+10.);" +
|
||||
"return mix(mix(hash(i),hash(i+vec2(1,0)),f.x)," +
|
||||
"mix(hash(i+vec2(0,1)),hash(i+vec2(1,1)),f.x),f.y);}" +
|
||||
"float fbm(vec2 p){float v=0.,a=.5;" +
|
||||
"mat2 R=mat2(.8,.6,-.6,.8);" +
|
||||
"for(int i=0;i<5;i++){v+=a*vnoise(p);p=R*p*2.02;a*=.5;}return v;}";
|
||||
@@ -0,0 +1,249 @@
|
||||
import { H, NQ } from "./common.js";
|
||||
|
||||
interface ShaderDef {
|
||||
frag: string;
|
||||
}
|
||||
|
||||
const shaders: Record<string, ShaderDef> = {
|
||||
"domain-warp": {
|
||||
frag:
|
||||
H +
|
||||
NQ +
|
||||
"void main(){" +
|
||||
"vec2 q=vec2(fbm(v_uv*3.),fbm(v_uv*3.+vec2(5.2,1.3)));" +
|
||||
"vec2 r=vec2(fbm(v_uv*3.+q*4.+vec2(1.7,9.2)),fbm(v_uv*3.+q*4.+vec2(8.3,2.8)));" +
|
||||
"float n=fbm(v_uv*3.+r*2.);" +
|
||||
"vec2 warpDir=(q-.5)*.4;" +
|
||||
"vec4 A=texture2D(u_from,clamp(v_uv+warpDir*u_progress,0.,1.));" +
|
||||
"vec4 B=texture2D(u_to,clamp(v_uv-warpDir*(1.-u_progress),0.,1.));" +
|
||||
"float e=smoothstep(u_progress-.08,u_progress+.08,n);" +
|
||||
"float ed=abs(n-u_progress);" +
|
||||
"float em=smoothstep(.1,0.,ed)*(1.-step(1.,u_progress));" +
|
||||
"vec3 ec=mix(u_accent_dark,u_accent_bright,smoothstep(0.,.1,ed));" +
|
||||
"gl_FragColor=vec4(mix(B,A,e).rgb+ec*em*2.,1.);}",
|
||||
},
|
||||
|
||||
"ridged-burn": {
|
||||
frag:
|
||||
H +
|
||||
NQ +
|
||||
"float ridged(vec2 p){float v=0.,a=.5;mat2 R=mat2(.8,.6,-.6,.8);" +
|
||||
"for(int i=0;i<5;i++){v+=a*abs(vnoise(p)*2.-1.);p=R*p*2.02;a*=.5;}return v;}" +
|
||||
"void main(){vec4 A=texture2D(u_from,v_uv),B=texture2D(u_to,v_uv);" +
|
||||
"float n=ridged(v_uv*4.);" +
|
||||
"float e=smoothstep(u_progress-.04,u_progress+.04,n);" +
|
||||
"float heat=smoothstep(.12,0.,abs(n-u_progress))*(1.-step(1.,u_progress));" +
|
||||
"vec3 burn=mix(u_accent_dark,u_accent,smoothstep(0.,.25,heat));" +
|
||||
"burn=mix(burn,u_accent_bright,smoothstep(.25,.5,heat));" +
|
||||
"burn=mix(burn,vec3(1),smoothstep(.5,1.,heat));" +
|
||||
"float sparks=step(.92,vnoise(v_uv*80.))*heat*3.;" +
|
||||
"gl_FragColor=vec4(mix(B,A,e).rgb+burn*heat*3.5+u_accent_bright*sparks,1.);}",
|
||||
},
|
||||
|
||||
"whip-pan": {
|
||||
frag:
|
||||
H +
|
||||
"void main(){" +
|
||||
"float fromOff=u_progress*1.5;vec3 fromC=vec3(0.);" +
|
||||
"for(int i=0;i<10;i++){float f=float(i)/10.;" +
|
||||
"vec2 fuv=vec2(v_uv.x+fromOff+u_progress*.08*f,v_uv.y);" +
|
||||
"fromC+=texture2D(u_from,clamp(fuv,0.,1.)).rgb;}fromC/=10.;" +
|
||||
"float toOff=(1.-u_progress)*1.5;vec3 toC=vec3(0.);" +
|
||||
"for(int i=0;i<10;i++){float f=float(i)/10.;" +
|
||||
"vec2 tuv=vec2(v_uv.x-toOff-(1.-u_progress)*.08*f,v_uv.y);" +
|
||||
"toC+=texture2D(u_to,clamp(tuv,0.,1.)).rgb;}toC/=10.;" +
|
||||
"gl_FragColor=vec4(mix(fromC,toC,u_progress),1.);}",
|
||||
},
|
||||
|
||||
"sdf-iris": {
|
||||
frag:
|
||||
H +
|
||||
"void main(){vec4 A=texture2D(u_from,v_uv),B=texture2D(u_to,v_uv);" +
|
||||
"vec2 uv=(v_uv-.5)*vec2(u_resolution.x/u_resolution.y,1.);" +
|
||||
"float d=length(uv);float radius=u_progress*1.2;float fw=.003;" +
|
||||
"float edge=smoothstep(radius+fw,radius-fw,d);" +
|
||||
"float ring1=exp(-abs(d-radius)*25.);" +
|
||||
"float ring2=exp(-abs(d-radius+.04)*20.)*.5;" +
|
||||
"float ring3=exp(-abs(d-radius+.08)*15.)*.25;" +
|
||||
"float glow=(ring1+ring2+ring3)*u_progress*(1.-u_progress)*4.;" +
|
||||
"gl_FragColor=vec4(mix(A,B,edge).rgb+u_accent_bright*glow*.6,1.);}",
|
||||
},
|
||||
|
||||
"ripple-waves": {
|
||||
frag:
|
||||
H +
|
||||
"void main(){vec2 uv=v_uv-.5;float dist=length(uv);vec2 dir=normalize(uv+.001);" +
|
||||
"float fromAmp=u_progress*.04;" +
|
||||
"float fw1=exp(sin(dist*25.-u_progress*12.)-1.);" +
|
||||
"float fw2=exp(sin(dist*50.-u_progress*18.)-1.)*.5;" +
|
||||
"vec2 fromUv=clamp(v_uv+dir*(fw1+fw2)*fromAmp,0.,1.);" +
|
||||
"float toAmp=(1.-u_progress)*.04;" +
|
||||
"float tw1=exp(sin(dist*25.+u_progress*12.)-1.);" +
|
||||
"float tw2=exp(sin(dist*50.+u_progress*18.)-1.)*.5;" +
|
||||
"vec2 toUv=clamp(v_uv-dir*(tw1+tw2)*toAmp,0.,1.);" +
|
||||
"vec4 A=texture2D(u_from,fromUv);vec4 B=texture2D(u_to,toUv);" +
|
||||
"float peak=fw1*u_progress;vec3 tint=u_accent_bright*peak*.1;" +
|
||||
"gl_FragColor=vec4(mix(A.rgb+tint,B.rgb,u_progress),1.);}",
|
||||
},
|
||||
|
||||
"gravitational-lens": {
|
||||
frag:
|
||||
H +
|
||||
"void main(){vec4 B=texture2D(u_to,v_uv);" +
|
||||
"vec2 uv=v_uv-.5;float dist=length(uv);float pull=u_progress*2.;" +
|
||||
"float warpStr=pull*.3/(dist+.1);" +
|
||||
"vec2 warped=clamp(v_uv-uv*warpStr,0.,1.);" +
|
||||
"vec4 A=texture2D(u_from,warped);" +
|
||||
"float horizon=smoothstep(0.,.3,dist/(1.-u_progress*.85+.001));" +
|
||||
"float shift=pull*.02/(dist+.2);" +
|
||||
"float r=texture2D(u_from,clamp(v_uv-uv*(warpStr+shift),0.,1.)).r;" +
|
||||
"float b=texture2D(u_from,clamp(v_uv-uv*(warpStr-shift),0.,1.)).b;" +
|
||||
"vec3 lensed=vec3(r,A.g,b)*horizon;" +
|
||||
"gl_FragColor=vec4(mix(lensed,B.rgb,smoothstep(.3,.9,u_progress)),1.);}",
|
||||
},
|
||||
|
||||
"cinematic-zoom": {
|
||||
frag:
|
||||
H +
|
||||
"void main(){vec2 d=v_uv-vec2(.5);" +
|
||||
"float fromS=u_progress*.08;float toS=(1.-u_progress)*.06;" +
|
||||
"float fr=0.,fg=0.,fb=0.;" +
|
||||
"for(int i=0;i<12;i++){float f=float(i)/12.;" +
|
||||
"fr+=texture2D(u_from,v_uv-d*(fromS*1.06)*f).r;" +
|
||||
"fg+=texture2D(u_from,v_uv-d*fromS*f).g;" +
|
||||
"fb+=texture2D(u_from,v_uv-d*(fromS*.94)*f).b;}" +
|
||||
"vec3 fromBl=vec3(fr,fg,fb)/12.;" +
|
||||
"float tr=0.,tg=0.,tb=0.;" +
|
||||
"for(int i=0;i<12;i++){float f=float(i)/12.;" +
|
||||
"tr+=texture2D(u_to,v_uv+d*(toS*1.06)*f).r;" +
|
||||
"tg+=texture2D(u_to,v_uv+d*toS*f).g;" +
|
||||
"tb+=texture2D(u_to,v_uv+d*(toS*.94)*f).b;}" +
|
||||
"vec3 toBl=vec3(tr,tg,tb)/12.;" +
|
||||
"gl_FragColor=vec4(mix(fromBl,toBl,u_progress),1.);}",
|
||||
},
|
||||
|
||||
"chromatic-split": {
|
||||
frag:
|
||||
H +
|
||||
"void main(){vec2 c=v_uv-.5;" +
|
||||
"float fromShift=u_progress*.06;" +
|
||||
"float fr=texture2D(u_from,clamp(v_uv+c*fromShift,0.,1.)).r;" +
|
||||
"float fg=texture2D(u_from,v_uv).g;" +
|
||||
"float fb=texture2D(u_from,clamp(v_uv-c*fromShift,0.,1.)).b;" +
|
||||
"vec3 fromSplit=vec3(fr,fg,fb);" +
|
||||
"float toShift=(1.-u_progress)*.06;" +
|
||||
"float tr=texture2D(u_to,clamp(v_uv-c*toShift,0.,1.)).r;" +
|
||||
"float tg=texture2D(u_to,v_uv).g;" +
|
||||
"float tb=texture2D(u_to,clamp(v_uv+c*toShift,0.,1.)).b;" +
|
||||
"vec3 toSplit=vec3(tr,tg,tb);" +
|
||||
"gl_FragColor=vec4(mix(fromSplit,toSplit,u_progress),1.);}",
|
||||
},
|
||||
|
||||
glitch: {
|
||||
frag:
|
||||
H +
|
||||
"float rand(vec2 co){return fract(sin(dot(co,vec2(12.9898,78.233)))*43758.5453);}" +
|
||||
"void main(){float inten=u_progress*(1.-u_progress)*4.;" +
|
||||
"float lineY=floor(v_uv.y*60.)/60.;" +
|
||||
"float lineDisp=(rand(vec2(lineY,floor(u_progress*17.)))-.5)*.18*inten;" +
|
||||
"vec2 block=floor(v_uv*vec2(12.,8.));" +
|
||||
"float br=rand(block+vec2(floor(u_progress*11.)));" +
|
||||
"float ba=step(.83,br)*inten;" +
|
||||
"vec2 bd=(vec2(rand(block*2.1),rand(block*3.7))-.5)*.35*ba;" +
|
||||
"vec2 uv=clamp(v_uv+vec2(lineDisp,0.)+bd,0.,1.);" +
|
||||
"float shift=inten*.035;" +
|
||||
"float r=texture2D(u_from,uv+vec2(shift,0.)).r;" +
|
||||
"float g=texture2D(u_from,uv).g;" +
|
||||
"float b=texture2D(u_from,uv-vec2(shift,0.)).b;" +
|
||||
"vec3 col=vec3(r,g,b);" +
|
||||
"col-=step(.5,fract(v_uv.y*u_resolution.y*.5))*.05*inten;" +
|
||||
"col*=1.+(rand(vec2(floor(u_progress*23.)))-.5)*.3*inten;" +
|
||||
"float levels=mix(256.,8.,inten*.5);" +
|
||||
"col=floor(col*levels)/levels;" +
|
||||
"gl_FragColor=mix(vec4(col,1.),texture2D(u_to,v_uv),u_progress);}",
|
||||
},
|
||||
|
||||
"swirl-vortex": {
|
||||
frag:
|
||||
H +
|
||||
NQ +
|
||||
"void main(){vec2 uv=v_uv-.5;float dist=length(uv);" +
|
||||
"float warp=fbm(v_uv*4.)*.5;" +
|
||||
"float fromAng=u_progress*(1.-dist)*10.+warp*u_progress*3.;" +
|
||||
"float fs=sin(fromAng),fc=cos(fromAng);" +
|
||||
"vec2 fromUv=clamp(vec2(uv.x*fc-uv.y*fs,uv.x*fs+uv.y*fc)+.5,0.,1.);" +
|
||||
"float toAng=-(1.-u_progress)*(1.-dist)*10.-warp*(1.-u_progress)*3.;" +
|
||||
"float ts=sin(toAng),tc=cos(toAng);" +
|
||||
"vec2 toUv=clamp(vec2(uv.x*tc-uv.y*ts,uv.x*ts+uv.y*tc)+.5,0.,1.);" +
|
||||
"vec4 A=texture2D(u_from,fromUv);vec4 B=texture2D(u_to,toUv);" +
|
||||
"gl_FragColor=mix(A,B,u_progress);}",
|
||||
},
|
||||
|
||||
"thermal-distortion": {
|
||||
frag:
|
||||
H +
|
||||
NQ +
|
||||
"void main(){float heat=u_progress*1.5;" +
|
||||
"float yFade=smoothstep(1.,0.,v_uv.y);" +
|
||||
"float shimmer=sin(v_uv.y*40.+fbm(v_uv*6.)*8.)*fbm(v_uv*3.+vec2(0.,u_progress*2.));" +
|
||||
"float dispX=shimmer*heat*.03*yFade;" +
|
||||
"vec2 fromUv=clamp(v_uv+vec2(dispX,0.),0.,1.);" +
|
||||
"vec4 A=texture2D(u_from,fromUv);" +
|
||||
"float invShimmer=sin(v_uv.y*40.+fbm(v_uv*6.+3.)*8.)*fbm(v_uv*3.+vec2(3.,u_progress*2.));" +
|
||||
"float dispX2=invShimmer*(1.-u_progress)*.03*yFade;" +
|
||||
"vec2 toUv=clamp(v_uv+vec2(dispX2,0.),0.,1.);" +
|
||||
"vec4 B=texture2D(u_to,toUv);" +
|
||||
"float haze=heat*yFade*.15*(1.-u_progress);" +
|
||||
"gl_FragColor=vec4(mix(A.rgb,B.rgb,u_progress)+u_accent_bright*haze,1.);}",
|
||||
},
|
||||
|
||||
"flash-through-white": {
|
||||
frag:
|
||||
H +
|
||||
"void main(){vec4 A=texture2D(u_from,v_uv),B=texture2D(u_to,v_uv);" +
|
||||
"float toWhite=smoothstep(0.,.45,u_progress);" +
|
||||
"vec3 fromC=mix(A.rgb,vec3(1.),toWhite);" +
|
||||
"float fromWhite=1.-smoothstep(.5,1.,u_progress);" +
|
||||
"vec3 toC=mix(B.rgb,vec3(1.),fromWhite);" +
|
||||
"gl_FragColor=vec4(mix(fromC,toC,smoothstep(.35,.65,u_progress)),1.);}",
|
||||
},
|
||||
|
||||
"cross-warp-morph": {
|
||||
frag:
|
||||
H +
|
||||
NQ +
|
||||
"void main(){vec2 disp=vec2(fbm(v_uv*3.),fbm(v_uv*3.+vec2(7.3,3.7)))-.5;" +
|
||||
"vec2 fromUv=clamp(v_uv+disp*u_progress*.5,0.,1.);" +
|
||||
"vec2 toUv=clamp(v_uv-disp*(1.-u_progress)*.5,0.,1.);" +
|
||||
"vec4 A=texture2D(u_from,fromUv);vec4 B=texture2D(u_to,toUv);" +
|
||||
"float n=fbm(v_uv*4.+vec2(3.1,1.7));" +
|
||||
"float blend=smoothstep(.4,.6,n+u_progress*1.2-.6);" +
|
||||
"gl_FragColor=mix(A,B,blend);}",
|
||||
},
|
||||
|
||||
"light-leak": {
|
||||
frag:
|
||||
H +
|
||||
"vec3 aces(vec3 x){return clamp((x*(2.51*x+.03))/(x*(2.43*x+.59)+.14),0.,1.);}" +
|
||||
"void main(){vec4 A=texture2D(u_from,v_uv),B=texture2D(u_to,v_uv);" +
|
||||
"vec2 lp=vec2(1.3,-.2);float dist=length(v_uv-lp);" +
|
||||
"float leak=clamp(exp(-dist*1.8)*u_progress*4.,0.,1.);" +
|
||||
"vec3 warmColor=mix(u_accent,u_accent_bright,dist*.7);" +
|
||||
"float flare=exp(-abs(v_uv.y-(-.2+v_uv.x*.3))*15.)*leak*.3;" +
|
||||
"vec3 overexposed=A.rgb+warmColor*leak*3.+u_accent_bright*flare;" +
|
||||
"overexposed=aces(overexposed);" +
|
||||
"gl_FragColor=vec4(mix(overexposed,B.rgb,smoothstep(.15,.85,u_progress)),1.);}",
|
||||
},
|
||||
};
|
||||
|
||||
export type ShaderName = keyof typeof shaders;
|
||||
export const SHADER_NAMES = Object.keys(shaders) as ShaderName[];
|
||||
|
||||
export function getFragSource(name: string): string {
|
||||
const def = shaders[name];
|
||||
if (!def)
|
||||
throw new Error(
|
||||
`[HyperShader] Unknown shader: "${name}". Available: ${SHADER_NAMES.join(", ")}`,
|
||||
);
|
||||
return def.frag;
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { vertSrc } from "./shaders/common.js";
|
||||
|
||||
export const WIDTH = 1920;
|
||||
export const HEIGHT = 1080;
|
||||
|
||||
export function createContext(canvas: HTMLCanvasElement): WebGLRenderingContext | null {
|
||||
const gl = canvas.getContext("webgl", { preserveDrawingBuffer: true });
|
||||
if (!gl) return null;
|
||||
gl.viewport(0, 0, WIDTH, HEIGHT);
|
||||
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
|
||||
return gl as WebGLRenderingContext;
|
||||
}
|
||||
|
||||
export function setupQuad(gl: WebGLRenderingContext): WebGLBuffer {
|
||||
const buf = gl.createBuffer();
|
||||
if (!buf) throw new Error("[HyperShader] Failed to create quad buffer");
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW);
|
||||
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");
|
||||
gl.shaderSource(s, src);
|
||||
gl.compileShader(s);
|
||||
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
|
||||
throw new Error(`[HyperShader] Shader compile: ${gl.getShaderInfoLog(s) || "unknown"}`);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
export function createProgram(gl: WebGLRenderingContext, fragSrc: string): WebGLProgram {
|
||||
if (!cachedVertexShader) {
|
||||
cachedVertexShader = compileShader(gl, vertSrc, gl.VERTEX_SHADER);
|
||||
}
|
||||
const p = gl.createProgram();
|
||||
if (!p) throw new Error("[HyperShader] Failed to create program");
|
||||
gl.attachShader(p, cachedVertexShader);
|
||||
gl.attachShader(p, compileShader(gl, fragSrc, gl.FRAGMENT_SHADER));
|
||||
gl.linkProgram(p);
|
||||
if (!gl.getProgramParameter(p, gl.LINK_STATUS)) {
|
||||
throw new Error(`[HyperShader] Program link: ${gl.getProgramInfoLog(p) || "unknown"}`);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
export interface AccentColors {
|
||||
accent: [number, number, number];
|
||||
dark: [number, number, number];
|
||||
bright: [number, number, number];
|
||||
}
|
||||
|
||||
interface ProgramLocations {
|
||||
from: WebGLUniformLocation | null;
|
||||
to: WebGLUniformLocation | null;
|
||||
progress: WebGLUniformLocation | null;
|
||||
resolution: WebGLUniformLocation | null;
|
||||
accent: WebGLUniformLocation | null;
|
||||
accentDark: WebGLUniformLocation | null;
|
||||
accentBright: WebGLUniformLocation | null;
|
||||
aPos: number;
|
||||
}
|
||||
|
||||
const locationsCache = new WeakMap<WebGLProgram, ProgramLocations>();
|
||||
|
||||
function getLocations(gl: WebGLRenderingContext, prog: WebGLProgram): ProgramLocations {
|
||||
let loc = locationsCache.get(prog);
|
||||
if (loc) return loc;
|
||||
loc = {
|
||||
from: gl.getUniformLocation(prog, "u_from"),
|
||||
to: gl.getUniformLocation(prog, "u_to"),
|
||||
progress: gl.getUniformLocation(prog, "u_progress"),
|
||||
resolution: gl.getUniformLocation(prog, "u_resolution"),
|
||||
accent: gl.getUniformLocation(prog, "u_accent"),
|
||||
accentDark: gl.getUniformLocation(prog, "u_accent_dark"),
|
||||
accentBright: gl.getUniformLocation(prog, "u_accent_bright"),
|
||||
aPos: gl.getAttribLocation(prog, "a_pos"),
|
||||
};
|
||||
locationsCache.set(prog, loc);
|
||||
return loc;
|
||||
}
|
||||
|
||||
export function renderShader(
|
||||
gl: WebGLRenderingContext,
|
||||
quadBuf: WebGLBuffer,
|
||||
prog: WebGLProgram,
|
||||
texFrom: WebGLTexture,
|
||||
texTo: WebGLTexture,
|
||||
progress: number,
|
||||
colors?: AccentColors,
|
||||
): void {
|
||||
const loc = getLocations(gl, prog);
|
||||
gl.useProgram(prog);
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, texFrom);
|
||||
gl.uniform1i(loc.from, 0);
|
||||
gl.activeTexture(gl.TEXTURE1);
|
||||
gl.bindTexture(gl.TEXTURE_2D, texTo);
|
||||
gl.uniform1i(loc.to, 1);
|
||||
gl.uniform1f(loc.progress, progress);
|
||||
gl.uniform2f(loc.resolution, WIDTH, HEIGHT);
|
||||
if (colors) {
|
||||
gl.uniform3f(loc.accent, ...colors.accent);
|
||||
gl.uniform3f(loc.accentDark, ...colors.dark);
|
||||
gl.uniform3f(loc.accentBright, ...colors.bright);
|
||||
}
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf);
|
||||
gl.enableVertexAttribArray(loc.aPos);
|
||||
gl.vertexAttribPointer(loc.aPos, 2, gl.FLOAT, false, 0, 0);
|
||||
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
||||
}
|
||||
|
||||
export function createTexture(gl: WebGLRenderingContext): WebGLTexture {
|
||||
const tex = gl.createTexture();
|
||||
if (!tex) throw new Error("[HyperShader] Failed to create texture");
|
||||
gl.bindTexture(gl.TEXTURE_2D, tex);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
|
||||
return tex;
|
||||
}
|
||||
|
||||
export function uploadTexture(
|
||||
gl: WebGLRenderingContext,
|
||||
tex: WebGLTexture,
|
||||
canvas: HTMLCanvasElement,
|
||||
): void {
|
||||
gl.bindTexture(gl.TEXTURE_2D, tex);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, canvas);
|
||||
canvas.width = 0;
|
||||
canvas.height = 0;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from "tsup";
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/index.ts"],
|
||||
format: ["esm", "cjs", "iife"],
|
||||
globalName: "HyperShader",
|
||||
noExternal: ["html2canvas"],
|
||||
dts: true,
|
||||
clean: true,
|
||||
minify: true,
|
||||
sourcemap: true,
|
||||
});
|
||||
@@ -1,14 +1,23 @@
|
||||
# Scene Transitions
|
||||
|
||||
A transition tells the viewer how two scenes relate. A crossfade says "this continues." A push slide says "next point." A flash cut says "wake up." A blur crossfade says "drift with me." Choose transitions that match what the content is doing emotionally, not just technically.
|
||||
A transition tells the viewer how two scenes relate. A crossfade says "this continues." A push slide says "next point." A blur crossfade says "drift with me." Choose transitions that match what the content is doing emotionally, not just technically.
|
||||
|
||||
## Animation Rules for Multi-Scene Compositions
|
||||
|
||||
These are non-negotiable for every multi-scene composition:
|
||||
|
||||
1. **Every composition uses transitions.** No exceptions. Scenes without transitions feel like jump cuts.
|
||||
2. **Every scene uses entrance animations.** Elements animate IN via `gsap.from()` — opacity, position, scale, etc. No scene should pop fully-formed onto screen.
|
||||
3. **Exit animations are BANNED** except on the final scene. Do NOT use `gsap.to()` to animate elements out before a transition fires. The transition IS the exit. Outgoing scene content must be fully visible when the transition starts — the transition handles the visual handoff.
|
||||
4. **Final scene exception:** The last scene MAY fade elements out (e.g., fade to black at the end of the composition). This is the only scene where exit animations are allowed.
|
||||
|
||||
## Energy → Primary Transition
|
||||
|
||||
| Energy | Primary | Accent for key moments | Duration | Easing |
|
||||
| ---------------------------------------- | ---------------------------- | ------------------------------ | --------- | ---------------------- |
|
||||
| **Calm** (wellness, brand story, luxury) | Blur crossfade, focus pull | Light leak, circle iris | 0.5-0.8s | `sine.inOut`, `power1` |
|
||||
| **Medium** (corporate, SaaS, explainer) | Push slide, staggered blocks | Squeeze, vertical push | 0.3-0.5s | `power2`, `power3` |
|
||||
| **High** (promos, sports, music, launch) | Flash cut, zoom through | Staggered blocks, gravity drop | 0.15-0.3s | `power4`, `expo` |
|
||||
| Energy | CSS Primary | Shader Primary | Accent | Duration | Easing |
|
||||
| ---------------------------------------- | ---------------------------- | ------------------------------------ | ------------------------------ | --------- | ---------------------- |
|
||||
| **Calm** (wellness, brand story, luxury) | Blur crossfade, focus pull | Cross-warp morph, thermal distortion | Light leak, circle iris | 0.5-0.8s | `sine.inOut`, `power1` |
|
||||
| **Medium** (corporate, SaaS, explainer) | Push slide, staggered blocks | Whip pan, cinematic zoom | Squeeze, vertical push | 0.3-0.5s | `power2`, `power3` |
|
||||
| **High** (promos, sports, music, launch) | Zoom through, overexposure | Ridged burn, glitch, chromatic split | Staggered blocks, gravity drop | 0.15-0.3s | `power4`, `expo` |
|
||||
|
||||
Pick ONE primary (60-70% of scene changes) + 1-2 accents. Never use a different transition for every scene.
|
||||
|
||||
@@ -16,17 +25,17 @@ Pick ONE primary (60-70% of scene changes) + 1-2 accents. Never use a different
|
||||
|
||||
Think about what the transition _communicates_, not just what it looks like.
|
||||
|
||||
| Mood | Transitions | Why it works |
|
||||
| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Warm / inviting** | Light leak, blur crossfade, focus pull, film burn, light leak (shader), thermal distortion | Soft edges, warm color washes. Nothing sharp or mechanical. The transition feels like sunlight. |
|
||||
| **Cold / clinical** | Squeeze, zoom out, blinds, shutter, grid dissolve, gravitational lens | Content transforms mechanically — compressed, shrunk, sliced, gridded. Zoom out creates clinical distance. No softness. |
|
||||
| **Editorial / magazine** | Push slide, vertical push, diagonal split, shutter, whip pan | Like turning a page or slicing a layout. Clean directional movement. Whip pan is a fast editorial camera move. |
|
||||
| **Tech / futuristic** | Grid dissolve, staggered blocks, blinds, chromatic aberration, glitch (shader), chromatic split (shader) | Grid dissolve is the core "data" transition. Shader glitch adds posterization + scan lines. |
|
||||
| **Tense / edgy** | Glitch, VHS, chromatic aberration, flash cut, ripple, ridged burn, glitch (shader) | Instability, distortion, digital breakdown. Ridged burn adds sharp lightning-crack edges. |
|
||||
| **Playful / fun** | Elastic push, 3D flip, circle iris, morph circle, clock wipe, ripple waves, swirl vortex | Overshoot, bounce, rotation, expansion. Swirl vortex adds organic spiral distortion. |
|
||||
| **Dramatic / cinematic** | Zoom through, zoom out, gravity drop, overexposure, diagonal split, color dip to black, cinematic zoom (shader), gravitational lens, domain warp, flash through white | Scale, weight, light extremes. Shader transitions add per-pixel depth. |
|
||||
| **Premium / luxury** | Focus pull, blur crossfade, color dip to black, slow crossfade, cross-warp morph, thermal distortion | Restraint. Cross-warp morph flows both scenes into each other organically. |
|
||||
| **Retro / analog** | Film burn, light leak, VHS, clock wipe | Organic imperfection. Warm color bleeds, scan line displacement. Clock wipe evokes broadcast TV. |
|
||||
| Mood | Transitions | Why it works |
|
||||
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------- |
|
||||
| **Warm / inviting** | Light leak, blur crossfade, focus pull, film burn · **Shader:** thermal distortion, light leak, cross-warp morph | Soft edges, warm color washes. Nothing sharp or mechanical. |
|
||||
| **Cold / clinical** | Squeeze, zoom out, blinds, shutter, grid dissolve · **Shader:** gravitational lens | Content transforms mechanically — compressed, shrunk, sliced, gridded. |
|
||||
| **Editorial / magazine** | Push slide, vertical push, diagonal split, shutter · **Shader:** whip pan | Like turning a page or slicing a layout. Clean directional movement. |
|
||||
| **Tech / futuristic** | Grid dissolve, staggered blocks, blinds, chromatic aberration · **Shader:** glitch, chromatic split | Grid dissolve is the core "data" transition. Shader glitch adds posterization + scan lines. |
|
||||
| **Tense / edgy** | Glitch, VHS, chromatic aberration, ripple · **Shader:** ridged burn, glitch, domain warp | Instability, distortion, digital breakdown. Ridged burn adds sharp lightning-crack edges. |
|
||||
| **Playful / fun** | Elastic push, 3D flip, circle iris, morph circle, clock wipe · **Shader:** ripple waves, swirl vortex | Overshoot, bounce, rotation, expansion. Swirl vortex adds organic spiral distortion. |
|
||||
| **Dramatic / cinematic** | Zoom through, zoom out, gravity drop, overexposure, color dip to black · **Shader:** cinematic zoom, gravitational lens, domain warp | Scale, weight, light extremes. Shader transitions add per-pixel depth. |
|
||||
| **Premium / luxury** | Focus pull, blur crossfade, color dip to black · **Shader:** cross-warp morph, thermal distortion | Restraint. Cross-warp morph flows both scenes into each other organically. |
|
||||
| **Retro / analog** | Film burn, light leak, VHS, clock wipe · **Shader:** light leak | Organic imperfection. Warm color bleeds, scan line displacement. |
|
||||
|
||||
## Narrative Position
|
||||
|
||||
@@ -62,34 +71,41 @@ Think about what the transition _communicates_, not just what it looks like.
|
||||
|
||||
Read [transitions/catalog.md](transitions/catalog.md) for GSAP code and hard rules for every transition type.
|
||||
|
||||
| Category | Transitions |
|
||||
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Content-transforming | Push slide, vertical push, elastic push, squeeze, zoom through, zoom out, gravity drop, 3D flip |
|
||||
| Reveal/mask | Circle iris, diamond iris, diagonal split, clock wipe, shutter |
|
||||
| Dissolve | Crossfade, blur crossfade, focus pull, color dip |
|
||||
| Cover | Staggered blocks, horizontal blinds, vertical blinds |
|
||||
| Light | Light leak, overexposure burn, film burn |
|
||||
| Distortion | Glitch, chromatic aberration, ripple, VHS tape |
|
||||
| Pattern | Grid dissolve |
|
||||
| Instant | Flash cut, morph circle |
|
||||
| Shader (WebGL) | Domain warp, ridged burn, whip pan, SDF iris, ripple waves, gravitational lens, cinematic zoom, chromatic split, glitch, swirl vortex, thermal distortion, flash through white, cross-warp morph, light leak (shader) |
|
||||
| Category | CSS | Shader (WebGL) |
|
||||
| ----------- | -------------------------------------------------------------- | ------------------------------------------------------------------------- |
|
||||
| Push/slide | Push slide, vertical push, elastic push, squeeze | Whip pan |
|
||||
| Scale/zoom | Zoom through, zoom out, gravity drop, 3D flip | Cinematic zoom, gravitational lens |
|
||||
| Reveal/mask | Circle iris, diamond iris, diagonal split, clock wipe, shutter | SDF iris |
|
||||
| Dissolve | Crossfade, blur crossfade, focus pull, color dip | Cross-warp morph, domain warp |
|
||||
| Cover | Staggered blocks, horizontal blinds, vertical blinds | — |
|
||||
| Light | Light leak, overexposure burn, film burn | Light leak (shader), thermal distortion |
|
||||
| Distortion | Glitch, chromatic aberration, ripple, VHS tape | Glitch (shader), chromatic split, ridged burn, ripple waves, swirl vortex |
|
||||
| Pattern | Grid dissolve, morph circle | — |
|
||||
|
||||
## Transitions That Don't Work in CSS
|
||||
|
||||
Avoid: star iris, tilt-shift, lens flare, hinge/door. See catalog.md for why.
|
||||
|
||||
## CSS vs Shader: When to Use Which
|
||||
## CSS vs Shader
|
||||
|
||||
Most compositions should use **CSS/GSAP transitions** (the other categories above). They're simpler, lighter, and handle most needs. Use **shader transitions** only when you need an effect that CSS can't achieve:
|
||||
CSS transitions animate scene containers with opacity, transforms, clip-path, and filters. Shader transitions composite both scene textures per-pixel on a WebGL canvas — they can warp, dissolve, and morph in ways CSS cannot.
|
||||
|
||||
| Use CSS/GSAP when | Use Shader when |
|
||||
| ---------------------------------------------- | ----------------------------------------------- |
|
||||
| Opacity, transform, clip-path, filter effects | Per-pixel noise dissolves, domain warping |
|
||||
| Simple crossfades, wipes, slides | Both scenes actively morph into each other |
|
||||
| No images/video in scenes (text + shapes only) | Live video needs to play through the transition |
|
||||
| Quick to set up, no boilerplate | Willing to add WebGL setup layer (~200 lines) |
|
||||
**Both are first-class options.** Shaders require setup boilerplate (~200 lines, copied from [transitions/shader-setup.md](transitions/shader-setup.md)) but produce richer, more cinematic effects. CSS transitions are simpler to set up. Choose based on the effect you want, not based on which is easier.
|
||||
|
||||
Shader transitions require setup boilerplate (canvas, scene capture, WebGL init). Read [transitions/shader-setup.md](transitions/shader-setup.md) for the complete code. The fragment shaders themselves are in the Shader section of [transitions/catalog.md](transitions/catalog.md).
|
||||
When a composition uses shader transitions, ALL transitions in that composition should be shader-based (the WebGL canvas replaces DOM-based scene switching). Don't mix CSS and shader transitions in the same composition.
|
||||
|
||||
## Shader-Compatible CSS Rules
|
||||
|
||||
Shader transitions capture DOM scenes to WebGL textures via html2canvas. The canvas 2D rendering pipeline doesn't match CSS exactly. Follow these rules to avoid visible artifacts at transition boundaries:
|
||||
|
||||
1. **No `transparent` keyword in gradients.** Canvas interpolates `transparent` as `rgba(0,0,0,0)` (black at zero alpha), creating dark fringes. Always use the target color at zero alpha: `rgba(200,117,51,0)` not `transparent`.
|
||||
2. **No gradient backgrounds on elements thinner than 4px.** Canvas can't match CSS gradient rendering on 1-2px elements. Use solid `background-color` on thin accent lines.
|
||||
3. **No CSS variables (`var()`) on elements visible during capture.** html2canvas doesn't reliably resolve custom properties. Use literal color values in inline styles.
|
||||
4. **Mark uncapturable decorative elements with `data-no-capture`.** The capture function skips these. They're present on the live DOM but absent from the shader texture. Use for elements that can't follow the rules above.
|
||||
5. **No gradient opacity below 0.15.** Gradient elements below 10% opacity render differently in canvas vs CSS. Increase to 0.15+ or use a solid color at equivalent brightness.
|
||||
6. **Every `.scene` div must have explicit `background-color`, AND set `BG_COLOR` in the shader setup to the same value.** html2canvas captures the scene element, not the body. Both the CSS `background-color` on `.scene` and the `backgroundColor` option in `html2canvas()` must be set to the composition's background color. Without either, the texture renders as black.
|
||||
|
||||
These rules only apply to shader transition compositions. CSS-only compositions have no restrictions.
|
||||
|
||||
## Visual Pattern Warning
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ Read [shader-setup.md](./shader-setup.md) for the full setup code these rules ap
|
||||
|
||||
**WebGL setup:** `gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false)` — NOT true. Vertex shader flips Y: `v_uv.y = 1.0 - v_uv.y`. `preserveDrawingBuffer: true` required for HyperFrames capture. No `fwidth()` without extension — use constant `0.003`.
|
||||
|
||||
**Rendering model:** GL canvas stays visible entire composition — DOM scenes remain `opacity: 0`. Passthrough shader for holds. Scene capture = static snapshots at load; animated GSAP tweens on elements won't appear.
|
||||
**Rendering model:** DOM scenes play normally with GSAP animations during holds — canvas is hidden (`display:none`). When a transition starts: capture outgoing scene with full content, capture incoming scene with `.scene-content` hidden (background + decoratives only), show canvas, run shader. When transition ends: hide canvas, show next DOM scene. GSAP entrance animations play on the live DOM. The incoming scene's content is never visible in the shader — it only shows the background layer, preventing un-animated elements from flashing.
|
||||
|
||||
**Scene capture:** Canvas `fillText` doesn't match CSS fonts exactly (known, not a bug). No CSS gradients or SVGs. Images and videos ARE supported via `ctx.drawImage()`. Video scenes re-capture every frame during transitions via `recaptureVideoScene()`.
|
||||
|
||||
@@ -118,7 +118,7 @@ All code examples use `old` for the outgoing scene-inner selector and `new` for
|
||||
| Distortion | Glitch, chromatic aberration, ripple, VHS tape | [css-distortion.md](./css-distortion.md) |
|
||||
| Mechanical | Shutter, clock wipe | [css-mechanical.md](./css-mechanical.md) |
|
||||
| Grid | Grid dissolve | [css-grid.md](./css-grid.md) |
|
||||
| Other | Flash cut, gravity drop, morph circle | [css-other.md](./css-other.md) |
|
||||
| Other | Gravity drop, morph circle | [css-other.md](./css-other.md) |
|
||||
| Blur | Blur through, directional blur | [css-blur.md](./css-blur.md) |
|
||||
| Destruction | Page burn | [css-destruction.md](./css-destruction.md) |
|
||||
|
||||
@@ -126,7 +126,7 @@ All code examples use `old` for the outgoing scene-inner selector and `new` for
|
||||
|
||||
WebGL fragment shaders that composite between scene textures per-pixel. Require setup boilerplate.
|
||||
|
||||
| What | Reference |
|
||||
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ |
|
||||
| Setup (canvas, capture, WebGL init, render loop, GSAP integration) | [shader-setup.md](./shader-setup.md) |
|
||||
| Fragment shaders (14 transitions: domain warp, ridged burn, whip pan, SDF iris, ripple waves, gravitational lens, cinematic zoom, chromatic split, glitch, swirl vortex, thermal distortion, flash through white, cross-warp morph, light leak) | [shader-transitions.md](./shader-transitions.md) |
|
||||
| What | Reference |
|
||||
| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ |
|
||||
| Setup (canvas, capture, WebGL init, render loop, GSAP integration) | [shader-setup.md](./shader-setup.md) |
|
||||
| Fragment shaders (13 transitions: domain warp, ridged burn, whip pan, SDF iris, ripple waves, gravitational lens, cinematic zoom, chromatic split, glitch, swirl vortex, thermal distortion, cross-warp morph, light leak) | [shader-transitions.md](./shader-transitions.md) |
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
# Shader Transition Setup
|
||||
|
||||
Complete boilerplate for WebGL shader transitions in HyperFrames. Read this when implementing a shader transition — copy the setup code, then plug in the fragment shader from the catalog.
|
||||
Complete boilerplate for WebGL shader transitions in HyperFrames. Copy the setup code, then plug in the fragment shader from the catalog.
|
||||
|
||||
**Rendering model:** DOM scenes play normally with GSAP animations. The WebGL canvas is hidden (`display:none`) between transitions. When a transition starts, `beginTrans` uses html2canvas to capture the outgoing scene with full content, and the incoming scene with `.scene-content` hidden (background + decorative elements only). This prevents un-animated content from flashing during the transition. When the transition ends, `endTrans` hides the canvas and reveals the incoming DOM scene — GSAP entrance animations then play on live elements.
|
||||
|
||||
**Shader-compatible CSS:** Compositions using shader transitions must follow the rules in transitions.md § "Shader-Compatible CSS Rules" — no `transparent` in gradients, no gradient backgrounds on sub-4px elements, no `var()` on captured elements, `data-no-capture` on uncapturable decoratives.
|
||||
|
||||
## HTML
|
||||
|
||||
@@ -14,270 +18,14 @@ Complete boilerplate for WebGL shader transitions in HyperFrames. Read this when
|
||||
</canvas>
|
||||
```
|
||||
|
||||
## WebGL Init + Scene Capture
|
||||
|
||||
Handles images, video, shapes, and text. Supports `object-fit: cover` on images and live video re-upload during transitions.
|
||||
## WebGL Init
|
||||
|
||||
```js
|
||||
var sceneTextures = {};
|
||||
var sceneHasVideo = {}; // tracks which scenes have live video
|
||||
var glCanvas = document.getElementById("gl-canvas");
|
||||
var gl = glCanvas.getContext("webgl", { preserveDrawingBuffer: true });
|
||||
gl.viewport(0, 0, 1920, 1080);
|
||||
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
|
||||
|
||||
// Wait for all media to load before capturing
|
||||
function waitForMedia() {
|
||||
return new Promise(function (resolve) {
|
||||
var promises = [];
|
||||
document.querySelectorAll("img").forEach(function (img) {
|
||||
if (!img.complete)
|
||||
promises.push(
|
||||
new Promise(function (r) {
|
||||
img.onload = r;
|
||||
img.onerror = r;
|
||||
}),
|
||||
);
|
||||
});
|
||||
document.querySelectorAll("video").forEach(function (vid) {
|
||||
if (vid.readyState < 2)
|
||||
promises.push(
|
||||
new Promise(function (r) {
|
||||
vid.addEventListener("loadeddata", r, { once: true });
|
||||
}),
|
||||
);
|
||||
});
|
||||
Promise.all(promises).then(resolve);
|
||||
});
|
||||
}
|
||||
|
||||
function captureScene(sceneId) {
|
||||
return new Promise(function (resolve) {
|
||||
var scene = document.getElementById(sceneId);
|
||||
var origOpacity = scene.style.opacity;
|
||||
var origZ = scene.style.zIndex;
|
||||
scene.style.opacity = "1";
|
||||
scene.style.zIndex = "999";
|
||||
|
||||
if (scene.querySelector("video")) sceneHasVideo[sceneId] = scene.querySelector("video");
|
||||
|
||||
requestAnimationFrame(function () {
|
||||
requestAnimationFrame(function () {
|
||||
var c = document.createElement("canvas");
|
||||
c.width = 1920;
|
||||
c.height = 1080;
|
||||
var ctx = c.getContext("2d");
|
||||
|
||||
ctx.fillStyle = window.getComputedStyle(scene).backgroundColor;
|
||||
ctx.fillRect(0, 0, 1920, 1080);
|
||||
|
||||
var sr = scene.getBoundingClientRect();
|
||||
var els = scene.querySelectorAll("*");
|
||||
for (var i = 0; i < els.length; i++) {
|
||||
var el = els[i];
|
||||
var cs = window.getComputedStyle(el);
|
||||
if (cs.display === "none" || cs.visibility === "hidden") continue;
|
||||
var r = el.getBoundingClientRect();
|
||||
if (r.width < 1 || r.height < 1) continue;
|
||||
var x = r.left - sr.left,
|
||||
y = r.top - sr.top,
|
||||
w = r.width,
|
||||
h = r.height;
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = parseFloat(cs.opacity) || 1;
|
||||
|
||||
// <img> elements (with object-fit: cover support)
|
||||
if (el.tagName === "IMG" && el.complete && el.naturalWidth > 0) {
|
||||
try {
|
||||
if (cs.objectFit === "cover") {
|
||||
var iR = el.naturalWidth / el.naturalHeight,
|
||||
bR = w / h;
|
||||
var sx = 0,
|
||||
sy = 0,
|
||||
sw = el.naturalWidth,
|
||||
sh = el.naturalHeight;
|
||||
if (iR > bR) {
|
||||
sw = sh * bR;
|
||||
sx = (el.naturalWidth - sw) / 2;
|
||||
} else {
|
||||
sh = sw / bR;
|
||||
sy = (el.naturalHeight - sh) / 2;
|
||||
}
|
||||
ctx.drawImage(el, sx, sy, sw, sh, x, y, w, h);
|
||||
} else {
|
||||
ctx.drawImage(el, x, y, w, h);
|
||||
}
|
||||
} catch (e) {}
|
||||
ctx.restore();
|
||||
continue;
|
||||
}
|
||||
|
||||
// <video> elements (grabs current frame)
|
||||
if (el.tagName === "VIDEO" && el.readyState >= 2) {
|
||||
try {
|
||||
var vR = el.videoWidth / el.videoHeight,
|
||||
bR2 = w / h;
|
||||
var vx = 0,
|
||||
vy = 0,
|
||||
vw = el.videoWidth,
|
||||
vh = el.videoHeight;
|
||||
if (vR > bR2) {
|
||||
vw = vh * bR2;
|
||||
vx = (el.videoWidth - vw) / 2;
|
||||
} else {
|
||||
vh = vw / bR2;
|
||||
vy = (el.videoHeight - vh) / 2;
|
||||
}
|
||||
ctx.drawImage(el, vx, vy, vw, vh, x, y, w, h);
|
||||
} catch (e) {}
|
||||
ctx.restore();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Background color
|
||||
var bg = cs.backgroundColor;
|
||||
if (bg && bg !== "rgba(0, 0, 0, 0)" && bg !== "transparent") {
|
||||
ctx.fillStyle = bg;
|
||||
var br = parseInt(cs.borderRadius) || 0;
|
||||
if (br >= Math.min(w, h) / 2 - 1 && Math.abs(w - h) < 4) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(x + w / 2, y + h / 2, Math.min(w, h) / 2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
} else if (br > 0) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + br, y);
|
||||
ctx.arcTo(x + w, y, x + w, y + h, br);
|
||||
ctx.arcTo(x + w, y + h, x, y + h, br);
|
||||
ctx.arcTo(x, y + h, x, y, br);
|
||||
ctx.arcTo(x, y, x + w, y, br);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
} else {
|
||||
ctx.fillRect(x, y, w, h);
|
||||
}
|
||||
}
|
||||
|
||||
// Text (leaf nodes only, with text-shadow)
|
||||
var hasChildEls = el.querySelector("div, span, img, video");
|
||||
var text = "";
|
||||
for (var j = 0; j < el.childNodes.length; j++)
|
||||
if (el.childNodes[j].nodeType === 3) text += el.childNodes[j].textContent;
|
||||
text = text.trim();
|
||||
if (text && !hasChildEls) {
|
||||
ctx.font = cs.fontWeight + " " + cs.fontSize + " " + cs.fontFamily;
|
||||
ctx.fillStyle = cs.color;
|
||||
if (cs.letterSpacing && cs.letterSpacing !== "normal")
|
||||
ctx.letterSpacing = cs.letterSpacing;
|
||||
var shadow = cs.textShadow;
|
||||
if (shadow && shadow !== "none") {
|
||||
var sp = shadow.match(/rgba?\([^)]+\)\s+(-?\d+)px\s+(-?\d+)px\s+(-?\d+)px/);
|
||||
if (sp) {
|
||||
ctx.shadowColor = shadow.match(/rgba?\([^)]+\)/)[0];
|
||||
ctx.shadowOffsetX = parseFloat(sp[1]);
|
||||
ctx.shadowOffsetY = parseFloat(sp[2]);
|
||||
ctx.shadowBlur = parseFloat(sp[3]);
|
||||
}
|
||||
}
|
||||
if (cs.textAlign === "center" || w > 1800) {
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(text, x + w / 2, y + h / 2);
|
||||
} else {
|
||||
ctx.textAlign = "left";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(text, x, y + h / 2);
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
scene.style.opacity = origOpacity;
|
||||
scene.style.zIndex = origZ;
|
||||
|
||||
var tex = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, tex);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, c);
|
||||
sceneTextures[sceneId] = tex;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Re-capture video scenes every frame (call in updateTrans and during holds)
|
||||
function recaptureVideoScene(sceneId) {
|
||||
var video = sceneHasVideo[sceneId];
|
||||
if (!video || video.readyState < 2) return;
|
||||
var scene = document.getElementById(sceneId);
|
||||
var c = document.createElement("canvas");
|
||||
c.width = 1920;
|
||||
c.height = 1080;
|
||||
var ctx = c.getContext("2d");
|
||||
ctx.fillStyle = window.getComputedStyle(scene).backgroundColor;
|
||||
ctx.fillRect(0, 0, 1920, 1080);
|
||||
var sr = scene.getBoundingClientRect();
|
||||
var els = scene.querySelectorAll("*");
|
||||
for (var i = 0; i < els.length; i++) {
|
||||
var el = els[i],
|
||||
cs = window.getComputedStyle(el);
|
||||
if (cs.display === "none") continue;
|
||||
var r = el.getBoundingClientRect();
|
||||
if (r.width < 1) continue;
|
||||
var x = r.left - sr.left,
|
||||
y = r.top - sr.top,
|
||||
w = r.width,
|
||||
h = r.height;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = parseFloat(cs.opacity) || 1;
|
||||
if (el.tagName === "VIDEO" && el.readyState >= 2) {
|
||||
try {
|
||||
var vR = el.videoWidth / el.videoHeight,
|
||||
bR = w / h;
|
||||
var sx = 0,
|
||||
sy = 0,
|
||||
sw = el.videoWidth,
|
||||
sh = el.videoHeight;
|
||||
if (vR > bR) {
|
||||
sw = sh * bR;
|
||||
sx = (el.videoWidth - sw) / 2;
|
||||
} else {
|
||||
sh = sw / bR;
|
||||
sy = (el.videoHeight - sh) / 2;
|
||||
}
|
||||
ctx.drawImage(el, sx, sy, sw, sh, x, y, w, h);
|
||||
} catch (e) {}
|
||||
} else if (el.tagName === "IMG" && el.complete) {
|
||||
try {
|
||||
ctx.drawImage(el, x, y, w, h);
|
||||
} catch (e) {}
|
||||
} else {
|
||||
var bg = cs.backgroundColor;
|
||||
if (bg && bg !== "rgba(0, 0, 0, 0)") {
|
||||
ctx.fillStyle = bg;
|
||||
ctx.fillRect(x, y, w, h);
|
||||
}
|
||||
var txt = "";
|
||||
for (var j = 0; j < el.childNodes.length; j++)
|
||||
if (el.childNodes[j].nodeType === 3) txt += el.childNodes[j].textContent;
|
||||
txt = txt.trim();
|
||||
if (txt && !el.querySelector("div,span,img,video")) {
|
||||
ctx.font = cs.fontWeight + " " + cs.fontSize + " " + cs.fontFamily;
|
||||
ctx.fillStyle = cs.color;
|
||||
ctx.textAlign = "left";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(txt, x, y + h / 2);
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
gl.bindTexture(gl.TEXTURE_2D, sceneTextures[sceneId]);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, c);
|
||||
}
|
||||
```
|
||||
|
||||
## Shader Compilation + Shared Constants
|
||||
@@ -352,7 +100,54 @@ var CP = "vec3 palette(float t,vec3 a,vec3 b,vec3 c,vec3 d){" + "return a+b*cos(
|
||||
|
||||
## Render + State Machine
|
||||
|
||||
DOM scenes play normally with GSAP animations during holds. The canvas is only visible during shader transitions — hidden the rest of the time. Capture uses html2canvas (loaded from CDN alongside GSAP).
|
||||
|
||||
Add this script tag alongside GSAP:
|
||||
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js"></script>
|
||||
```
|
||||
|
||||
```js
|
||||
// Patch createPattern for html2canvas bug with 0-dimension elements
|
||||
var _origCP = CanvasRenderingContext2D.prototype.createPattern;
|
||||
CanvasRenderingContext2D.prototype.createPattern = function (img, rep) {
|
||||
if (img && (img.width === 0 || img.height === 0)) return null;
|
||||
return _origCP.call(this, img, rep);
|
||||
};
|
||||
|
||||
function uploadTexture(sceneId, canvas) {
|
||||
if (!sceneTextures[sceneId]) {
|
||||
var tex = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, tex);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
||||
sceneTextures[sceneId] = tex;
|
||||
}
|
||||
gl.bindTexture(gl.TEXTURE_2D, sceneTextures[sceneId]);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, canvas);
|
||||
}
|
||||
|
||||
// BG_COLOR must match your composition's background color (e.g. "#0a0a1a").
|
||||
// html2canvas backgroundColor: null means transparent, which renders as black
|
||||
// in WebGL textures. Always pass the explicit color.
|
||||
var BG_COLOR = "#000"; // ← set to your composition's background
|
||||
|
||||
function captureScene(sceneEl) {
|
||||
return html2canvas(sceneEl, {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
scale: 1,
|
||||
backgroundColor: BG_COLOR,
|
||||
logging: false,
|
||||
ignoreElements: function (el) {
|
||||
return el.tagName === "CANVAS" || el.hasAttribute("data-no-capture");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function renderShader(prog, texFrom, texTo, progress) {
|
||||
gl.useProgram(prog);
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
@@ -370,8 +165,6 @@ function renderShader(prog, texFrom, texTo, progress) {
|
||||
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
||||
}
|
||||
|
||||
var progPass = mkProg(H + "void main(){gl_FragColor=texture2D(u_from,v_uv);}");
|
||||
|
||||
var trans = {
|
||||
active: false,
|
||||
prog: null,
|
||||
@@ -381,83 +174,109 @@ var trans = {
|
||||
};
|
||||
|
||||
function beginTrans(prog, fromId, toId) {
|
||||
trans.prog = prog;
|
||||
trans.fromId = fromId;
|
||||
trans.toId = toId;
|
||||
trans.progress = 0;
|
||||
trans.active = true;
|
||||
if (!gl) return;
|
||||
var fromScene = document.getElementById(fromId);
|
||||
var toScene = document.getElementById(toId);
|
||||
|
||||
// Capture outgoing scene (DOM stays visible during async capture)
|
||||
captureScene(fromScene)
|
||||
.then(function (fromCanvas) {
|
||||
uploadTexture(fromId, fromCanvas);
|
||||
|
||||
// Show incoming scene BEHIND outgoing (z-index -1) for capture
|
||||
toScene.style.zIndex = "-1";
|
||||
toScene.style.opacity = "1";
|
||||
var contentEl = toScene.querySelector(".scene-content");
|
||||
if (contentEl) contentEl.style.visibility = "hidden";
|
||||
|
||||
// Wait 2 rAFs for browser to render with correct fonts
|
||||
return new Promise(function (resolve) {
|
||||
requestAnimationFrame(function () {
|
||||
requestAnimationFrame(function () {
|
||||
captureScene(toScene).then(function (toCanvas) {
|
||||
if (contentEl) contentEl.style.visibility = "";
|
||||
toScene.style.opacity = "0";
|
||||
toScene.style.zIndex = "";
|
||||
uploadTexture(toId, toCanvas);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
})
|
||||
.then(function () {
|
||||
// Both textures ready — swap DOM for canvas
|
||||
document.querySelectorAll(".scene").forEach(function (s) {
|
||||
s.style.opacity = "0";
|
||||
});
|
||||
glCanvas.style.display = "block";
|
||||
trans.prog = prog;
|
||||
trans.fromId = fromId;
|
||||
trans.toId = toId;
|
||||
trans.progress = 0;
|
||||
trans.active = true;
|
||||
});
|
||||
}
|
||||
|
||||
function updateTrans() {
|
||||
if (!trans.active) return;
|
||||
// Re-capture video scenes every frame during transition
|
||||
if (sceneHasVideo[trans.fromId]) recaptureVideoScene(trans.fromId);
|
||||
if (sceneHasVideo[trans.toId]) recaptureVideoScene(trans.toId);
|
||||
if (!trans.active || !gl) return;
|
||||
renderShader(trans.prog, sceneTextures[trans.fromId], sceneTextures[trans.toId], trans.progress);
|
||||
}
|
||||
|
||||
function endTrans(showId) {
|
||||
trans.active = false;
|
||||
renderShader(progPass, sceneTextures[showId], sceneTextures[showId], 0);
|
||||
glCanvas.style.display = "none";
|
||||
document.getElementById(showId).style.opacity = "1";
|
||||
}
|
||||
```
|
||||
|
||||
## GSAP Timeline Integration
|
||||
|
||||
Scene 1 starts visible on the DOM. GSAP animates elements normally. The canvas is hidden until a transition begins. After each transition, the canvas hides and the next scene's DOM takes over.
|
||||
|
||||
```js
|
||||
// Wait for media, start videos, capture all scenes, then build timeline
|
||||
var sceneIds = ["scene1", "scene2" /* ... */];
|
||||
waitForMedia()
|
||||
.then(function () {
|
||||
// Start any background videos (muted)
|
||||
document.querySelectorAll("video").forEach(function (v) {
|
||||
v.play();
|
||||
});
|
||||
return Promise.all(sceneIds.map(captureScene));
|
||||
})
|
||||
.then(function () {
|
||||
glCanvas.style.display = "block";
|
||||
renderShader(progPass, sceneTextures["scene1"], sceneTextures["scene1"], 0);
|
||||
document.querySelectorAll(".scene").forEach(function (s) {
|
||||
s.style.opacity = "0";
|
||||
});
|
||||
// Canvas starts hidden — DOM scene 1 is visible
|
||||
glCanvas.style.display = "none";
|
||||
|
||||
var tl = gsap.timeline({
|
||||
paused: true,
|
||||
onUpdate: function () {
|
||||
updateTrans();
|
||||
},
|
||||
});
|
||||
var tl = gsap.timeline({
|
||||
paused: true,
|
||||
onUpdate: function () {
|
||||
updateTrans();
|
||||
},
|
||||
});
|
||||
|
||||
// For each transition:
|
||||
tl.call(
|
||||
function () {
|
||||
beginTrans(myShaderProg, "scene1", "scene2");
|
||||
},
|
||||
null,
|
||||
T,
|
||||
);
|
||||
var tw = { p: 0 };
|
||||
tl.to(
|
||||
tw,
|
||||
{
|
||||
p: 1,
|
||||
duration: DUR,
|
||||
ease: "power2.inOut",
|
||||
onUpdate: function () {
|
||||
trans.progress = tw.p;
|
||||
},
|
||||
},
|
||||
T,
|
||||
);
|
||||
tl.call(
|
||||
function () {
|
||||
endTrans("scene2");
|
||||
},
|
||||
null,
|
||||
T + DUR,
|
||||
);
|
||||
// Scene 1 entrance animations go here (normal GSAP on DOM)...
|
||||
|
||||
window.__timelines["main"] = tl;
|
||||
});
|
||||
// Transition 1→2:
|
||||
tl.call(
|
||||
function () {
|
||||
beginTrans(myShaderProg, "scene1", "scene2");
|
||||
},
|
||||
null,
|
||||
T,
|
||||
);
|
||||
var tw1 = { p: 0 };
|
||||
tl.to(
|
||||
tw1,
|
||||
{
|
||||
p: 1,
|
||||
duration: DUR,
|
||||
ease: "power2.inOut",
|
||||
onUpdate: function () {
|
||||
trans.progress = tw1.p;
|
||||
},
|
||||
},
|
||||
T,
|
||||
);
|
||||
tl.call(
|
||||
function () {
|
||||
endTrans("scene2");
|
||||
},
|
||||
null,
|
||||
T + DUR,
|
||||
);
|
||||
|
||||
// Scene 2 entrance animations go here (normal GSAP on DOM)...
|
||||
|
||||
window.__timelines["main"] = tl;
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user