Files
hyperframes/skills/hyperframes/references/transitions/shader-setup.md
T
Vance Ingalls cb3d94c2a5 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)
2026-04-13 18:44:00 -07:00

9.4 KiB

Shader Transition Setup

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

<canvas
  id="gl-canvas"
  width="1920"
  height="1080"
  style="position:absolute;top:0;left:0;width:1920px;height:1080px;z-index:100;pointer-events:none;display:none;"
>
</canvas>

WebGL Init

var sceneTextures = {};
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);

Shader Compilation + Shared Constants

var 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);}";

var quadBuf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW);

function compileShader(src, type) {
  var s = gl.createShader(type);
  gl.shaderSource(s, src);
  gl.compileShader(s);
  if (!gl.getShaderParameter(s, gl.COMPILE_STATUS))
    console.error("Shader:", gl.getShaderInfoLog(s));
  return s;
}

function mkProg(fragSrc) {
  var p = gl.createProgram();
  gl.attachShader(p, compileShader(vertSrc, gl.VERTEX_SHADER));
  gl.attachShader(p, compileShader(fragSrc, gl.FRAGMENT_SHADER));
  gl.linkProgram(p);
  if (!gl.getProgramParameter(p, gl.LINK_STATUS)) console.error("Link:", gl.getProgramInfoLog(p));
  return p;
}

// Shared uniform header — every fragment shader starts with this
var H =
  "precision mediump float;" +
  "varying vec2 v_uv;" +
  "uniform sampler2D u_from, u_to;" +
  "uniform float u_progress;" +
  "uniform vec2 u_resolution;\n";

Noise Libraries

Include only what each shader needs. Do NOT include multiple libraries that redefine hash() in the same shader.

// Quintic C2 noise + inter-octave rotation FBM
var 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.);" + // quintic interpolation — C2 continuous
  "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);" + // inter-octave rotation (~37deg)
  "for(int i=0;i<5;i++){v+=a*vnoise(p);p=R*p*2.02;a*=.5;}return v;}";

// Noise with analytical derivatives (quintic) + erosion FBM
// Use for transitions that need gradient-based edge lighting
var ND =
  "float hash(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);}" +
  "vec3 noised(vec2 p){vec2 i=floor(p),f=fract(p);" +
  "vec2 u=f*f*f*(f*(f*6.-15.)+10.),du=30.*f*f*(f*(f-2.)+1.);" +
  "float a=hash(i),b=hash(i+vec2(1,0)),c=hash(i+vec2(0,1)),d=hash(i+vec2(1,1));" +
  "return vec3(a+(b-a)*u.x+(c-a)*u.y+(a-b-c+d)*u.x*u.y," +
  "du*vec2(b-a+(a-b-c+d)*u.y,c-a+(a-b-c+d)*u.x));}" +
  "float erosionFBM(vec2 p){float v=0.,a=.5;vec2 d=vec2(0);mat2 R=mat2(.8,.6,-.6,.8);" +
  "for(int i=0;i<6;i++){vec3 n=noised(p);d+=n.yz;v+=a*n.x/(1.+dot(d,d));p=R*p*2.02;a*=.5;}return v;}";

// Cosine palette: a + b*cos(2pi(c*t + d))
var CP = "vec3 palette(float t,vec3 a,vec3 b,vec3 c,vec3 d){" + "return a+b*cos(6.2832*(c*t+d));}";

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:

<script src="https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js"></script>
// 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);
  gl.bindTexture(gl.TEXTURE_2D, texFrom);
  gl.uniform1i(gl.getUniformLocation(prog, "u_from"), 0);
  gl.activeTexture(gl.TEXTURE1);
  gl.bindTexture(gl.TEXTURE_2D, texTo);
  gl.uniform1i(gl.getUniformLocation(prog, "u_to"), 1);
  gl.uniform1f(gl.getUniformLocation(prog, "u_progress"), progress);
  gl.uniform2f(gl.getUniformLocation(prog, "u_resolution"), 1920, 1080);
  var pos = gl.getAttribLocation(prog, "a_pos");
  gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf);
  gl.enableVertexAttribArray(pos);
  gl.vertexAttribPointer(pos, 2, gl.FLOAT, false, 0, 0);
  gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
}

var trans = {
  active: false,
  prog: null,
  fromId: null,
  toId: null,
  progress: 0,
};

function beginTrans(prog, fromId, toId) {
  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 || !gl) return;
  renderShader(trans.prog, sceneTextures[trans.fromId], sceneTextures[trans.toId], trans.progress);
}

function endTrans(showId) {
  trans.active = false;
  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.

// Canvas starts hidden — DOM scene 1 is visible
glCanvas.style.display = "none";

var tl = gsap.timeline({
  paused: true,
  onUpdate: function () {
    updateTrans();
  },
});

// Scene 1 entrance animations go here (normal GSAP on DOM)...

// 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;