/** * Shader Transition Math Utilities * * Sampling helpers and math primitives for rgb48le shader transitions. * Functions are ported from GLSL to operate on 16-bit little-endian pixel * buffers (6 bytes per pixel: R, G, B each stored as UInt16LE). */ // ── PQ linearization ───────────────────────────────────────────────────────── // Shader transitions were ported from sRGB GLSL where pixel values distribute // linearly across the visible range. In PQ space, dark content clusters near // zero, causing UV-warping shaders to produce black artifacts. Converting to // linear light before the shader and back to PQ after gives correct results. const PQ_M1 = 0.1593017578125; const PQ_M2 = 78.84375; const PQ_C1 = 0.8359375; const PQ_C2 = 18.8515625; const PQ_C3 = 18.6875; /** PQ EOTF: decode PQ signal (0-1) → linear light (0-1, normalized to 10000 nits). */ function pqEotf(signal: number): number { const sp = Math.pow(Math.max(0, signal), 1 / PQ_M2); const num = Math.max(sp - PQ_C1, 0); const den = PQ_C2 - PQ_C3 * sp; return den > 0 ? Math.pow(num / den, 1 / PQ_M1) : 0; } /** PQ OETF: encode linear light (0-1) → PQ signal (0-1). */ function pqOetf(linear: number): number { const lp = Math.pow(Math.max(0, linear), PQ_M1); return Math.pow((PQ_C1 + PQ_C2 * lp) / (1 + PQ_C3 * lp), PQ_M2); } /** HLG OETF inverse: decode HLG signal (0-1) → linear scene light (0-1). */ function hlgEotf(signal: number): number { const a = 0.17883277; const b = 1 - 4 * a; const c = 0.5 - a * Math.log(4 * a); if (signal <= 0.5) { return (signal * signal) / 3; } return (Math.exp((signal - c) / a) + b) / 12; } /** HLG OETF: encode linear scene light (0-1) → HLG signal (0-1). */ function hlgOetf(linear: number): number { const a = 0.17883277; const b = 1 - 4 * a; const c = 0.5 - a * Math.log(4 * a); if (linear <= 1 / 12) { return Math.sqrt(3 * linear); } return a * Math.log(12 * linear - b) + c; } // ── Precomputed LUTs for fast HDR↔linear conversion ───────────────────────── // 65536-entry lookup tables eliminate per-pixel Math.pow calls. Built once on // first use, then reused for all subsequent conversions. At 4K (8.3M pixels × // 3 channels × 3 buffers), this turns ~75M Math.pow calls per transition frame // into 75M array lookups — ~100× faster. function buildLut(fn: (v: number) => number): Uint16Array { const lut = new Uint16Array(65536); for (let i = 0; i < 65536; i++) { lut[i] = Math.round(fn(i / 65535) * 65535); } return lut; } let pqToLinearLut: Uint16Array | null = null; let linearToPqLut: Uint16Array | null = null; let hlgToLinearLut: Uint16Array | null = null; let linearToHlgLut: Uint16Array | null = null; function getPqToLinearLut(): Uint16Array { if (!pqToLinearLut) pqToLinearLut = buildLut(pqEotf); return pqToLinearLut; } function getLinearToPqLut(): Uint16Array { if (!linearToPqLut) linearToPqLut = buildLut(pqOetf); return linearToPqLut; } function getHlgToLinearLut(): Uint16Array { if (!hlgToLinearLut) hlgToLinearLut = buildLut(hlgEotf); return hlgToLinearLut; } function getLinearToHlgLut(): Uint16Array { if (!linearToHlgLut) linearToHlgLut = buildLut(hlgOetf); return linearToHlgLut; } /** * Convert an rgb48le buffer from HDR signal space to linear light, in-place. * Uses precomputed 65536-entry LUT for O(1) per-sample conversion. * @param transfer "pq" or "hlg" */ export function hdrToLinear(buf: Buffer, transfer: "pq" | "hlg"): void { const lut = transfer === "pq" ? getPqToLinearLut() : getHlgToLinearLut(); const len = buf.length / 2; for (let i = 0; i < len; i++) { const off = i * 2; buf.writeUInt16LE(lut[buf.readUInt16LE(off)] ?? 0, off); } } /** * Convert an rgb48le buffer from linear light back to HDR signal space, in-place. * Uses precomputed 65536-entry LUT for O(1) per-sample conversion. * @param transfer "pq" or "hlg" */ export function linearToHdr(buf: Buffer, transfer: "pq" | "hlg"): void { const lut = transfer === "pq" ? getLinearToPqLut() : getLinearToHlgLut(); const len = buf.length / 2; for (let i = 0; i < len; i++) { const off = i * 2; buf.writeUInt16LE(lut[buf.readUInt16LE(off)] ?? 0, off); } } // ── Cross-transfer conversion (HLG↔PQ) ────────────────────────────────────── // HLG is scene-referred, PQ is display-referred. Converting between them // requires the OOTF (Optical-Optical Transfer Function) which maps scene // light to display light. Per BT.2100, the HLG OOTF for a reference // display at Lw nits is: Y_display = Lw * Y_scene^gamma, where // gamma = 1.2 * 1.111^(log2(Lw/1000)). At 1000 nits: gamma = 1.2. // // The per-channel approximation (applying gamma per-channel rather than // on luminance Y) introduces slight color shifts but avoids a full // colorimetric conversion with BT.2020 luma coefficients. const HLG_OOTF_LW = 1000; // reference display peak luminance (nits) const HLG_OOTF_GAMMA = 1.2 * Math.pow(1.111, Math.log2(HLG_OOTF_LW / 1000)); /** HLG scene light → PQ display light (per-channel, normalized to 10000 nits) */ function hlgSceneToPqDisplay(sceneLinear: number): number { const displayNits = HLG_OOTF_LW * Math.pow(Math.max(0, sceneLinear), HLG_OOTF_GAMMA); return displayNits / 10000; // PQ is normalized to 10000 nits } /** PQ display light → HLG scene light (inverse OOTF) */ function pqDisplayToHlgScene(displayNormalized: number): number { const displayNits = displayNormalized * 10000; return Math.pow(Math.max(0, displayNits / HLG_OOTF_LW), 1 / HLG_OOTF_GAMMA); } let hlgToPqLut: Uint16Array | null = null; let pqToHlgLut: Uint16Array | null = null; function getHlgToPqLut(): Uint16Array { // HLG signal → scene linear (EOTF) → display linear (OOTF) → PQ signal (OETF) if (!hlgToPqLut) hlgToPqLut = buildLut((v) => pqOetf(hlgSceneToPqDisplay(hlgEotf(v)))); return hlgToPqLut; } function getPqToHlgLut(): Uint16Array { // PQ signal → display linear (EOTF) → scene linear (inverse OOTF) → HLG signal (OETF) if (!pqToHlgLut) pqToHlgLut = buildLut((v) => hlgOetf(pqDisplayToHlgScene(pqEotf(v)))); return pqToHlgLut; } /** * Convert an rgb48le buffer between HDR transfer functions, in-place. * Uses a composite 65536-entry LUT (source EOTF → linear → target OETF) * for O(1) per-sample conversion. No-op if from === to. */ export function convertTransfer(buf: Buffer, from: "pq" | "hlg", to: "pq" | "hlg"): void { if (from === to) return; const lut = from === "hlg" ? getHlgToPqLut() : getPqToHlgLut(); const len = buf.length / 2; for (let i = 0; i < len; i++) { const off = i * 2; buf.writeUInt16LE(lut[buf.readUInt16LE(off)] ?? 0, off); } } // ── Buffer sampling ─────────────────────────────────────────────────────────── /** * Sample an rgb48le buffer at floating-point UV coordinates (0–1 range, clamped). * Uses bilinear interpolation between the 4 nearest pixels, equivalent to * GLSL `texture2D` with clamp-to-edge wrapping. * * @param buf rgb48le buffer — w * h * 6 bytes * @param u Horizontal coordinate in [0, 1] * @param v Vertical coordinate in [0, 1] * @param w Image width in pixels * @param h Image height in pixels * @returns [r, g, b] as 16-bit values (0–65535) */ export function sampleRgb48le( buf: Buffer, u: number, v: number, w: number, h: number, ): [number, number, number] { // Clamp UV to [0, 1] then map to pixel coordinates const uc = Math.max(0, Math.min(1, u)); const vc = Math.max(0, Math.min(1, v)); const sx = uc * (w - 1); const sy = vc * (h - 1); const x0 = Math.floor(sx); const y0 = Math.floor(sy); const x1 = Math.min(x0 + 1, w - 1); const y1 = Math.min(y0 + 1, h - 1); const fx = sx - x0; const fy = sy - y0; const w00 = (1 - fx) * (1 - fy); const w10 = fx * (1 - fy); const w01 = (1 - fx) * fy; const w11 = fx * fy; const off00 = (y0 * w + x0) * 6; const off10 = (y0 * w + x1) * 6; const off01 = (y1 * w + x0) * 6; const off11 = (y1 * w + x1) * 6; const r = Math.round( buf.readUInt16LE(off00) * w00 + buf.readUInt16LE(off10) * w10 + buf.readUInt16LE(off01) * w01 + buf.readUInt16LE(off11) * w11, ); const g = Math.round( buf.readUInt16LE(off00 + 2) * w00 + buf.readUInt16LE(off10 + 2) * w10 + buf.readUInt16LE(off01 + 2) * w01 + buf.readUInt16LE(off11 + 2) * w11, ); const b = Math.round( buf.readUInt16LE(off00 + 4) * w00 + buf.readUInt16LE(off10 + 4) * w10 + buf.readUInt16LE(off01 + 4) * w01 + buf.readUInt16LE(off11 + 4) * w11, ); return [r, g, b]; } // ── 16-bit math primitives ──────────────────────────────────────────────────── /** * Linear interpolate two 16-bit values. Equivalent to GLSL `mix(a, b, t)`. */ export function mix16(a: number, b: number, t: number): number { return Math.round(a * (1 - t) + b * t); } /** * Clamp a value to the 16-bit unsigned range [0, 65535]. */ export function clamp16(v: number): number { return Math.max(0, Math.min(65535, v)); } // ── GLSL math ports ─────────────────────────────────────────────────────────── /** * Hermite interpolation from GLSL `smoothstep(edge0, edge1, x)`. * Returns 0 for x ≤ edge0, 1 for x ≥ edge1, and a smooth S-curve between. */ export function smoothstep(edge0: number, edge1: number, x: number): number { const t = Math.max(0, Math.min(1, (x - edge0) / (edge1 - edge0))); return t * t * (3 - 2 * t); } /** * Deterministic pseudo-random value in [0, 1). * Port of the GLSL idiom: `fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453)`. */ export function hash(x: number, y: number): number { return (((Math.sin(x * 127.1 + y * 311.7) * 43758.5453) % 1) + 1) % 1; } /** * Value noise with C2-continuous quintic interpolation. * Samples `hash()` at the 4 surrounding integer grid corners and blends * using the quintic fade f = f³(f(6f − 15) + 10). * * Returns a value in [0, 1]. */ export function vnoise(px: number, py: number): number { const ix = Math.floor(px); const iy = Math.floor(py); // Fractional part let fx = px - ix; let fy = py - iy; // Quintic C2 interpolation weights fx = fx * fx * fx * (fx * (fx * 6 - 15) + 10); fy = fy * fy * fy * (fy * (fy * 6 - 15) + 10); const h00 = hash(ix, iy); const h10 = hash(ix + 1, iy); const h01 = hash(ix, iy + 1); const h11 = hash(ix + 1, iy + 1); // Bilinear blend return h00 * (1 - fx) * (1 - fy) + h10 * fx * (1 - fy) + h01 * (1 - fx) * fy + h11 * fx * fy; } // Rotation matrix constants from GLSL: mat2(0.8, 0.6, -0.6, 0.8) // Applies to [px, py]: px' = 0.8*px - 0.6*py, py' = 0.6*px + 0.8*py const ROT_A = 0.8; const ROT_B = 0.6; /** * Fractal Brownian motion — 5-octave accumulation of value noise. * * Each octave: accumulate `amplitude * vnoise(p)`, rotate p by 36.87°, * scale by 2.02, halve the amplitude. Matching the GLSL convention of * `mat2(0.8, 0.6, -0.6, 0.8)` for the rotation. */ export function fbm(px: number, py: number): number { let value = 0; let amplitude = 0.5; let x = px; let y = py; for (let i = 0; i < 5; i++) { value += amplitude * vnoise(x, y); // Rotate by mat2(0.8, 0.6, -0.6, 0.8) const nx = ROT_A * x - ROT_B * y; const ny = ROT_B * x + ROT_A * y; x = nx * 2.02; y = ny * 2.02; amplitude *= 0.5; } return value; } // ── Transition types and registry ───────────────────────────────────────────── /** A transition function that blends two rgb48le buffers into an output buffer. */ export type TransitionFn = ( from: Buffer, to: Buffer, output: Buffer, width: number, height: number, progress: number, ) => void; /** Registry of all available transitions by name. */ export const TRANSITIONS: Record = {}; // ── crossfade ───────────────────────────────────────────────────────────────── /** * Simple linear blend between two frames. Equivalent to GLSL `mix(from, to, progress)`. */ export const crossfade: TransitionFn = (from, to, out, w, h, p) => { const inv = 1 - p; for (let i = 0; i < w * h; i++) { const o = i * 6; out.writeUInt16LE(Math.round(from.readUInt16LE(o) * inv + to.readUInt16LE(o) * p), o); out.writeUInt16LE( Math.round(from.readUInt16LE(o + 2) * inv + to.readUInt16LE(o + 2) * p), o + 2, ); out.writeUInt16LE( Math.round(from.readUInt16LE(o + 4) * inv + to.readUInt16LE(o + 4) * p), o + 4, ); } }; TRANSITIONS["crossfade"] = crossfade; // ── flashThroughWhite ───────────────────────────────────────────────────────── /** * Flash-through-white transition: the outgoing scene brightens to white while * the incoming scene emerges from white, creating a bright flash at the midpoint. * * Port of the GLSL flash-through-white shader. */ export const flashThroughWhite: TransitionFn = (from, to, out, w, h, p) => { const toWhite = smoothstep(0, 0.45, p); // outgoing brightens toward white const fromWhite = 1 - smoothstep(0.5, 1, p); // incoming starts from white const blend = smoothstep(0.35, 0.65, p); // crossfade between the two for (let i = 0; i < w * h; i++) { const o = i * 6; const fromR = mix16(from.readUInt16LE(o), 65535, toWhite); const fromG = mix16(from.readUInt16LE(o + 2), 65535, toWhite); const fromB = mix16(from.readUInt16LE(o + 4), 65535, toWhite); const toR = mix16(to.readUInt16LE(o), 65535, fromWhite); const toG = mix16(to.readUInt16LE(o + 2), 65535, fromWhite); const toB = mix16(to.readUInt16LE(o + 4), 65535, fromWhite); out.writeUInt16LE(mix16(fromR, toR, blend), o); out.writeUInt16LE(mix16(fromG, toG, blend), o + 2); out.writeUInt16LE(mix16(fromB, toB, blend), o + 4); } }; TRANSITIONS["flash-through-white"] = flashThroughWhite; // ── chromatic-split ─────────────────────────────────────────────────────────── /** * RGB channel offset transition. Each channel is sampled at a different UV * offset, spreading apart as progress increases (outgoing) and converging * as progress approaches 1 (incoming). Port of the GLSL chromatic-split shader. */ export const chromaticSplit: TransitionFn = (from, to, out, w, h, p) => { for (let i = 0; i < w * h; i++) { const ux = (i % w) / w; const uy = Math.floor(i / w) / h; const o = i * 6; // Center-relative UV for offset direction const cx = ux - 0.5; const cy = uy - 0.5; const fromShift = p * 0.06; const fr = sampleRgb48le(from, ux + cx * fromShift, uy + cy * fromShift, w, h)[0]; const fg = sampleRgb48le(from, ux, uy, w, h)[1]; const fb = sampleRgb48le(from, ux - cx * fromShift, uy - cy * fromShift, w, h)[2]; const toShift = (1 - p) * 0.06; const tr = sampleRgb48le(to, ux - cx * toShift, uy - cy * toShift, w, h)[0]; const tg = sampleRgb48le(to, ux, uy, w, h)[1]; const tb = sampleRgb48le(to, ux + cx * toShift, uy + cy * toShift, w, h)[2]; out.writeUInt16LE(clamp16(mix16(fr, tr, p)), o); out.writeUInt16LE(clamp16(mix16(fg, tg, p)), o + 2); out.writeUInt16LE(clamp16(mix16(fb, tb, p)), o + 4); } }; TRANSITIONS["chromatic-split"] = chromaticSplit; // ── sdf-iris ────────────────────────────────────────────────────────────────── /** * Circular iris reveal. A sharp edge expands from the center while golden * glow rings ripple outward at the boundary. Port of the GLSL sdf-iris shader. */ export const sdfIris: TransitionFn = (from, to, out, w, h, p) => { // Accent colors for glow rings (16-bit scale) const accentBright = [65535, 55000, 35000] as const; for (let i = 0; i < w * h; i++) { const ux = (i % w) / w; const uy = Math.floor(i / w) / h; const o = i * 6; // Aspect-corrected distance from center const ax = (ux - 0.5) * (w / h); const ay = uy - 0.5; const d = Math.sqrt(ax * ax + ay * ay); const radius = p * 1.2; const fw = 0.003; const edge = smoothstep(radius + fw, radius - fw, d); // Three glow rings at different radii and falloff speeds const ring1 = Math.exp(-Math.abs(d - radius) * 25); const ring2 = Math.exp(-Math.abs(d - radius + 0.04) * 20) * 0.5; const ring3 = Math.exp(-Math.abs(d - radius + 0.08) * 15) * 0.25; const glow = (ring1 + ring2 + ring3) * p * (1 - p) * 4; const [fromR, fromG, fromB] = sampleRgb48le(from, ux, uy, w, h); const [toR, toG, toB] = sampleRgb48le(to, ux, uy, w, h); out.writeUInt16LE(clamp16(mix16(fromR, toR, edge) + accentBright[0] * glow * 0.6), o); out.writeUInt16LE(clamp16(mix16(fromG, toG, edge) + accentBright[1] * glow * 0.6), o + 2); out.writeUInt16LE(clamp16(mix16(fromB, toB, edge) + accentBright[2] * glow * 0.6), o + 4); } }; TRANSITIONS["sdf-iris"] = sdfIris; // ── glitch ──────────────────────────────────────────────────────────────────── /** * Deterministic PRNG matching the GLSL `rand` in the glitch shader. * Uses different constants than `hash` — do NOT substitute. */ function glitchRand(x: number, y: number): number { return (((Math.sin(x * 12.9898 + y * 78.233) * 43758.5453) % 1) + 1) % 1; } /** * Block displacement + scanlines + RGB channel split. Intensity peaks at the * midpoint (p=0.5) and decays at both ends. Port of the GLSL glitch shader. */ export const glitch: TransitionFn = (from, to, out, w, h, p) => { const intensity = p * (1 - p) * 4; for (let i = 0; i < w * h; i++) { const ux = (i % w) / w; const uy = Math.floor(i / w) / h; const o = i * 6; // Horizontal line displacement const lineY = Math.floor(uy * 60) / 60; const lineDisp = (glitchRand(lineY, Math.floor(p * 17)) - 0.5) * 0.18 * intensity; // Block displacement const blockX = Math.floor(ux * 12); const blockY = Math.floor(uy * 8); const progressStep = Math.floor(p * 11); const br = glitchRand(blockX + progressStep, blockY + progressStep); const ba = (br >= 0.83 ? 1 : 0) * intensity; const bdx = (glitchRand(blockX * 2.1, blockY * 2.1) - 0.5) * 0.35 * ba; const bdy = (glitchRand(blockX * 3.7, blockY * 3.7) - 0.5) * 0.35 * ba; const uvx = Math.max(0, Math.min(1, ux + lineDisp + bdx)); const uvy = Math.max(0, Math.min(1, uy + bdy)); // RGB channel split on displaced UV const shift = intensity * 0.035; const r = sampleRgb48le(from, uvx + shift, uvy, w, h)[0]; const g = sampleRgb48le(from, uvx, uvy, w, h)[1]; const b = sampleRgb48le(from, uvx - shift, uvy, w, h)[2]; // Normalize to 0-1 for scanline, flicker, and crush operations let cr = r / 65535; let cg = g / 65535; let cb = b / 65535; // Scanline darkening: darken rows where fract(uy * h * 0.5) > 0.5 const scanline = (((uy * h * 0.5) % 1) + 1) % 1 >= 0.5 ? 0.05 * intensity : 0; cr -= scanline; cg -= scanline; cb -= scanline; // Brightness flicker const flicker = 1 + (glitchRand(Math.floor(p * 23), 0) - 0.5) * 0.3 * intensity; cr *= flicker; cg *= flicker; cb *= flicker; // Color crush (posterize) const levels = 256 - (256 - 8) * (intensity * 0.5); cr = Math.floor(cr * levels) / levels; cg = Math.floor(cg * levels) / levels; cb = Math.floor(cb * levels) / levels; // Scale back to 16-bit and mix with `to` by progress const [toR, toG, toB] = sampleRgb48le(to, ux, uy, w, h); out.writeUInt16LE(clamp16(mix16(Math.round(cr * 65535), toR, p)), o); out.writeUInt16LE(clamp16(mix16(Math.round(cg * 65535), toG, p)), o + 2); out.writeUInt16LE(clamp16(mix16(Math.round(cb * 65535), toB, p)), o + 4); } }; TRANSITIONS["glitch"] = glitch; // ── light-leak ──────────────────────────────────────────────────────────────── /** * ACES filmic tonemap. Input and output in 0-1 normalized range. * Formula: (x * (2.51x + 0.03)) / (x * (2.43x + 0.59) + 0.14) */ function aces(x: number): number { return Math.max(0, Math.min(1, (x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14))); } /** * Warm lens-flare from the upper-right corner. The incoming scene burns through * an overexposed flash, tonemapped with ACES and crossfaded with the outgoing * scene. Port of the GLSL light-leak shader. */ export const lightLeak: TransitionFn = (from, to, out, w, h, p) => { // Normalized accent colors (0-1 range for ACES pipeline) const accent = [50000 / 65535, 25000 / 65535, 5000 / 65535] as const; const accentBright = [65535 / 65535, 55000 / 65535, 35000 / 65535] as const; // Light source position const lpx = 1.3; const lpy = -0.2; for (let i = 0; i < w * h; i++) { const ux = (i % w) / w; const uy = Math.floor(i / w) / h; const o = i * 6; const dx = ux - lpx; const dy = uy - lpy; const dist = Math.sqrt(dx * dx + dy * dy); const leak = Math.max(0, Math.min(1, Math.exp(-dist * 1.8) * p * 4)); // Warm color: mix accent with accent_bright based on distance const warmR = accent[0] + (accentBright[0] - accent[0]) * dist * 0.7; const warmG = accent[1] + (accentBright[1] - accent[1]) * dist * 0.7; const warmB = accent[2] + (accentBright[2] - accent[2]) * dist * 0.7; // Lens flare streak const flare = Math.exp(-Math.abs(uy - (-0.2 + ux * 0.3)) * 15) * leak * 0.3; const [fr, fg, fb] = sampleRgb48le(from, ux, uy, w, h); const fromR = fr / 65535; const fromG = fg / 65535; const fromB = fb / 65535; // Overexpose and tonemap const overR = aces(fromR + warmR * leak * 3 + accentBright[0] * flare); const overG = aces(fromG + warmG * leak * 3 + accentBright[1] * flare); const overB = aces(fromB + warmB * leak * 3 + accentBright[2] * flare); // Mix overexposed → to by smoothstepped progress const [toR, toG, toB] = sampleRgb48le(to, ux, uy, w, h); const blend = smoothstep(0.15, 0.85, p); out.writeUInt16LE(clamp16(mix16(Math.round(overR * 65535), toR, blend)), o); out.writeUInt16LE(clamp16(mix16(Math.round(overG * 65535), toG, blend)), o + 2); out.writeUInt16LE(clamp16(mix16(Math.round(overB * 65535), toB, blend)), o + 4); } }; TRANSITIONS["light-leak"] = lightLeak; // ── cross-warp-morph ────────────────────────────────────────────────────────── /** * FBM displacement warp. Both frames are warped in opposite directions by a * fractal noise field, then blended by a noise-threshold mask that sweeps * across the screen as progress advances. Port of the GLSL cross-warp-morph shader. */ export const crossWarpMorph: TransitionFn = (from, to, out, w, h, p) => { for (let i = 0; i < w * h; i++) { const ux = (i % w) / w; const uy = Math.floor(i / w) / h; const o = i * 6; const dispX = fbm(ux * 3, uy * 3) - 0.5; const dispY = fbm(ux * 3 + 7.3, uy * 3 + 3.7) - 0.5; const fromUx = Math.max(0, Math.min(1, ux + dispX * p * 0.5)); const fromUy = Math.max(0, Math.min(1, uy + dispY * p * 0.5)); const toUx = Math.max(0, Math.min(1, ux - dispX * (1 - p) * 0.5)); const toUy = Math.max(0, Math.min(1, uy - dispY * (1 - p) * 0.5)); const [fromR, fromG, fromB] = sampleRgb48le(from, fromUx, fromUy, w, h); const [toR, toG, toB] = sampleRgb48le(to, toUx, toUy, w, h); const n = fbm(ux * 4 + 3.1, uy * 4 + 1.7); const blend = smoothstep(0.4, 0.6, n + p * 1.2 - 0.6); out.writeUInt16LE(clamp16(mix16(fromR, toR, blend)), o); out.writeUInt16LE(clamp16(mix16(fromG, toG, blend)), o + 2); out.writeUInt16LE(clamp16(mix16(fromB, toB, blend)), o + 4); } }; TRANSITIONS["cross-warp-morph"] = crossWarpMorph; // ── whip-pan ────────────────────────────────────────────────────────────────── /** * Horizontal motion blur. The outgoing frame is sampled with offsets shifted * right (by progress*1.5) and the incoming frame is sampled with offsets shifted * left (by (1-progress)*1.5). Each direction uses 10 samples averaged together. * Port of the GLSL whip-pan shader. */ export const whipPan: TransitionFn = (from, to, out, w, h, p) => { const fromOff = p * 1.5; const toOff = (1 - p) * 1.5; for (let i = 0; i < w * h; i++) { const ux = (i % w) / w; const uy = Math.floor(i / w) / h; const o = i * 6; let fromR = 0, fromG = 0, fromB = 0; for (let s = 0; s < 10; s++) { const f = s / 10; const fuv = Math.max(0, Math.min(1, ux + fromOff + p * 0.08 * f)); const [r, g, b] = sampleRgb48le(from, fuv, uy, w, h); fromR += r; fromG += g; fromB += b; } fromR /= 10; fromG /= 10; fromB /= 10; let toR = 0, toG = 0, toB = 0; for (let s = 0; s < 10; s++) { const f = s / 10; const tuv = Math.max(0, Math.min(1, ux - toOff - (1 - p) * 0.08 * f)); const [r, g, b] = sampleRgb48le(to, tuv, uy, w, h); toR += r; toG += g; toB += b; } toR /= 10; toG /= 10; toB /= 10; out.writeUInt16LE(clamp16(mix16(Math.round(fromR), Math.round(toR), p)), o); out.writeUInt16LE(clamp16(mix16(Math.round(fromG), Math.round(toG), p)), o + 2); out.writeUInt16LE(clamp16(mix16(Math.round(fromB), Math.round(toB), p)), o + 4); } }; TRANSITIONS["whip-pan"] = whipPan; // ── cinematic-zoom ──────────────────────────────────────────────────────────── /** * Radial zoom blur with chromatic aberration. Both frames are blurred along a * radial direction from center using 12 samples. R/G/B channels use slightly * different zoom factors (1.06, 1.0, 0.94) for chromatic aberration. The * outgoing frame zooms inward while the incoming zooms outward. * Port of the GLSL cinematic-zoom shader. */ export const cinematicZoom: TransitionFn = (from, to, out, w, h, p) => { const fromS = p * 0.08; const toS = (1 - p) * 0.06; for (let i = 0; i < w * h; i++) { const ux = (i % w) / w; const uy = Math.floor(i / w) / h; const o = i * 6; const dx = ux - 0.5; const dy = uy - 0.5; let fr = 0, fg = 0, fb = 0; for (let s = 0; s < 12; s++) { const f = s / 12; const rr = sampleRgb48le( from, ux - dx * fromS * 1.06 * f, uy - dy * fromS * 1.06 * f, w, h, )[0]; const gg = sampleRgb48le(from, ux - dx * fromS * f, uy - dy * fromS * f, w, h)[1]; const bb = sampleRgb48le( from, ux - dx * fromS * 0.94 * f, uy - dy * fromS * 0.94 * f, w, h, )[2]; fr += rr; fg += gg; fb += bb; } fr /= 12; fg /= 12; fb /= 12; let tr = 0, tg = 0, tb = 0; for (let s = 0; s < 12; s++) { const f = s / 12; const rr = sampleRgb48le(to, ux + dx * toS * 1.06 * f, uy + dy * toS * 1.06 * f, w, h)[0]; const gg = sampleRgb48le(to, ux + dx * toS * f, uy + dy * toS * f, w, h)[1]; const bb = sampleRgb48le(to, ux + dx * toS * 0.94 * f, uy + dy * toS * 0.94 * f, w, h)[2]; tr += rr; tg += gg; tb += bb; } tr /= 12; tg /= 12; tb /= 12; out.writeUInt16LE(clamp16(mix16(Math.round(fr), Math.round(tr), p)), o); out.writeUInt16LE(clamp16(mix16(Math.round(fg), Math.round(tg), p)), o + 2); out.writeUInt16LE(clamp16(mix16(Math.round(fb), Math.round(tb), p)), o + 4); } }; TRANSITIONS["cinematic-zoom"] = cinematicZoom; // ── gravitational-lens ──────────────────────────────────────────────────────── /** * Radial warp toward center simulating a gravitational lens effect. The * outgoing frame is warped with chromatic separation, masked by a horizon * that depends on distance from center. Mixed to the incoming frame using * smoothstep(0.3, 0.9, progress). Port of the GLSL gravitational-lens shader. */ export const gravitationalLens: TransitionFn = (from, to, out, w, h, p) => { for (let i = 0; i < w * h; i++) { const ux = (i % w) / w; const uy = Math.floor(i / w) / h; const o = i * 6; const uvx = ux - 0.5; const uvy = uy - 0.5; const dist = Math.sqrt(uvx * uvx + uvy * uvy); const pull = p * 2; const warpStr = (pull * 0.3) / (dist + 0.1); const warpedX = Math.max(0, Math.min(1, ux - uvx * warpStr)); const warpedY = Math.max(0, Math.min(1, uy - uvy * warpStr)); const [, ag] = sampleRgb48le(from, warpedX, warpedY, w, h); const horizon = smoothstep(0, 0.3, dist / (1 - p * 0.85 + 0.001)); const shift = (pull * 0.02) / (dist + 0.2); const rSampX = Math.max(0, Math.min(1, ux - uvx * (warpStr + shift))); const rSampY = Math.max(0, Math.min(1, uy - uvy * (warpStr + shift))); const bSampX = Math.max(0, Math.min(1, ux - uvx * (warpStr - shift))); const bSampY = Math.max(0, Math.min(1, uy - uvy * (warpStr - shift))); const ar = sampleRgb48le(from, rSampX, rSampY, w, h)[0]; const ab = sampleRgb48le(from, bSampX, bSampY, w, h)[2]; const lensedR = Math.round(ar * horizon); const lensedG = Math.round(ag * horizon); const lensedB = Math.round(ab * horizon); const [toR, toG, toB] = sampleRgb48le(to, ux, uy, w, h); const blend = smoothstep(0.3, 0.9, p); out.writeUInt16LE(clamp16(mix16(lensedR, toR, blend)), o); out.writeUInt16LE(clamp16(mix16(lensedG, toG, blend)), o + 2); out.writeUInt16LE(clamp16(mix16(lensedB, toB, blend)), o + 4); } }; TRANSITIONS["gravitational-lens"] = gravitationalLens; // ── ripple-waves ────────────────────────────────────────────────────────────── /** * Concentric wave distortion. Exponential wave functions create rings radiating * outward from center. Both frames are distorted — the outgoing with progress- * scaled amplitude, the incoming with (1-progress)-scaled amplitude. A warm * accent tint highlights wave peaks. Port of the GLSL ripple-waves shader. */ export const rippleWaves: TransitionFn = (from, to, out, w, h, p) => { // Accent bright color (16-bit) const accentBright = [65535, 55000, 35000] as const; for (let i = 0; i < w * h; i++) { const ux = (i % w) / w; const uy = Math.floor(i / w) / h; const o = i * 6; const uvx = ux - 0.5; const uvy = uy - 0.5; const dist = Math.sqrt(uvx * uvx + uvy * uvy); // Normalize direction from center, offset by small amount to avoid div-by-zero const nux = uvx + 0.001; const nuy = uvy + 0.001; const nlen = Math.sqrt(nux * nux + nuy * nuy); const dirx = nux / nlen; const diry = nuy / nlen; // From frame: waves moving outward (positive phase) const fromAmp = p * 0.04; const fw1 = Math.exp(Math.sin(dist * 25 - p * 12) - 1); const fw2 = Math.exp(Math.sin(dist * 50 - p * 18) - 1) * 0.5; const fromUx = Math.max(0, Math.min(1, ux + dirx * (fw1 + fw2) * fromAmp)); const fromUy = Math.max(0, Math.min(1, uy + diry * (fw1 + fw2) * fromAmp)); // To frame: waves moving inward (reversed phase) const toAmp = (1 - p) * 0.04; const tw1 = Math.exp(Math.sin(dist * 25 + p * 12) - 1); const tw2 = Math.exp(Math.sin(dist * 50 + p * 18) - 1) * 0.5; const toUx = Math.max(0, Math.min(1, ux - dirx * (tw1 + tw2) * toAmp)); const toUy = Math.max(0, Math.min(1, uy - diry * (tw1 + tw2) * toAmp)); const [fromR, fromG, fromB] = sampleRgb48le(from, fromUx, fromUy, w, h); const [toR, toG, toB] = sampleRgb48le(to, toUx, toUy, w, h); const peak = fw1 * p; const tintR = accentBright[0] * peak * 0.1; const tintG = accentBright[1] * peak * 0.1; const tintB = accentBright[2] * peak * 0.1; out.writeUInt16LE(clamp16(mix16(Math.round(fromR + tintR), toR, p)), o); out.writeUInt16LE(clamp16(mix16(Math.round(fromG + tintG), toG, p)), o + 2); out.writeUInt16LE(clamp16(mix16(Math.round(fromB + tintB), toB, p)), o + 4); } }; TRANSITIONS["ripple-waves"] = rippleWaves; // ── swirl-vortex ────────────────────────────────────────────────────────────── /** * Rotational UV warp. The outgoing frame is rotated clockwise and the incoming * counter-clockwise. Rotation angle depends on distance from center and FBM * warp noise. Port of the GLSL swirl-vortex shader. */ export const swirlVortex: TransitionFn = (from, to, out, w, h, p) => { for (let i = 0; i < w * h; i++) { const ux = (i % w) / w; const uy = Math.floor(i / w) / h; const o = i * 6; const uvx = ux - 0.5; const uvy = uy - 0.5; const dist = Math.sqrt(uvx * uvx + uvy * uvy); const warp = fbm(ux * 4, uy * 4) * 0.5; const fromAng = p * (1 - dist) * 10 + warp * p * 3; const fs = Math.sin(fromAng); const fc = Math.cos(fromAng); const fromUx = Math.max(0, Math.min(1, uvx * fc - uvy * fs + 0.5)); const fromUy = Math.max(0, Math.min(1, uvx * fs + uvy * fc + 0.5)); const toAng = -(1 - p) * (1 - dist) * 10 - warp * (1 - p) * 3; const ts = Math.sin(toAng); const tc = Math.cos(toAng); const toUx = Math.max(0, Math.min(1, uvx * tc - uvy * ts + 0.5)); const toUy = Math.max(0, Math.min(1, uvx * ts + uvy * tc + 0.5)); const [fromR, fromG, fromB] = sampleRgb48le(from, fromUx, fromUy, w, h); const [toR, toG, toB] = sampleRgb48le(to, toUx, toUy, w, h); out.writeUInt16LE(clamp16(mix16(fromR, toR, p)), o); out.writeUInt16LE(clamp16(mix16(fromG, toG, p)), o + 2); out.writeUInt16LE(clamp16(mix16(fromB, toB, p)), o + 4); } }; TRANSITIONS["swirl-vortex"] = swirlVortex; // ── thermal-distortion ──────────────────────────────────────────────────────── /** * Heat shimmer effect. Horizontal displacement based on a sin wave modulated by * FBM noise, fading toward the top of the screen. Both frames are displaced * independently, and a warm haze overlay fades as progress advances. * Port of the GLSL thermal-distortion shader. */ export const thermalDistortion: TransitionFn = (from, to, out, w, h, p) => { // Accent bright color (16-bit) const accentBright = [65535, 55000, 35000] as const; for (let i = 0; i < w * h; i++) { const ux = (i % w) / w; const uy = Math.floor(i / w) / h; const o = i * 6; const heat = p * 1.5; const yFade = smoothstep(1, 0, uy); // From frame shimmer: fbm(uv*6) modulates sin wave const shimmer = Math.sin(uy * 40 + fbm(ux * 6, uy * 6) * 8) * fbm(ux * 3 + 0, uy * 3 + p * 2); const dispX = shimmer * heat * 0.03 * yFade; const fromUx = Math.max(0, Math.min(1, ux + dispX)); const [fromR, fromG, fromB] = sampleRgb48le(from, fromUx, uy, w, h); // To frame shimmer: different FBM seed (offset by 3) const invShimmer = Math.sin(uy * 40 + fbm(ux * 6 + 3, uy * 6 + 3) * 8) * fbm(ux * 3 + 3, uy * 3 + p * 2); const dispX2 = invShimmer * (1 - p) * 0.03 * yFade; const toUx = Math.max(0, Math.min(1, ux + dispX2)); const [toR, toG, toB] = sampleRgb48le(to, toUx, uy, w, h); const haze = heat * yFade * 0.15 * (1 - p); out.writeUInt16LE(clamp16(mix16(fromR, toR, p) + Math.round(accentBright[0] * haze)), o); out.writeUInt16LE(clamp16(mix16(fromG, toG, p) + Math.round(accentBright[1] * haze)), o + 2); out.writeUInt16LE(clamp16(mix16(fromB, toB, p) + Math.round(accentBright[2] * haze)), o + 4); } }; TRANSITIONS["thermal-distortion"] = thermalDistortion; // ── domain-warp ─────────────────────────────────────────────────────────────── /** * FBM-driven UV warp with edge glow. Computes two layers of FBM (q and r) to * derive a warp direction. Both frames are displaced in opposite directions. * An edge-detection glow appears at the transition boundary. * Port of the GLSL domain-warp shader. Note: mix(B, A, e) ordering — e=1 shows A (from). */ export const domainWarp: TransitionFn = (from, to, out, w, h, p) => { // Accent colors (16-bit) const accentDark = [25000, 8000, 2000] as const; const accentBright = [65535, 55000, 35000] as const; for (let i = 0; i < w * h; i++) { const ux = (i % w) / w; const uy = Math.floor(i / w) / h; const o = i * 6; // Two-layer domain warp: q, then r const qx = fbm(ux * 3, uy * 3); const qy = fbm(ux * 3 + 5.2, uy * 3 + 1.3); const rx = fbm(ux * 3 + qx * 4 + 1.7, uy * 3 + qy * 4 + 9.2); const ry = fbm(ux * 3 + qx * 4 + 8.3, uy * 3 + qy * 4 + 2.8); const n = fbm(ux * 3 + rx * 2, uy * 3 + ry * 2); const warpDirX = (qx - 0.5) * 0.4; const warpDirY = (qy - 0.5) * 0.4; const aUx = Math.max(0, Math.min(1, ux + warpDirX * p)); const aUy = Math.max(0, Math.min(1, uy + warpDirY * p)); const bUx = Math.max(0, Math.min(1, ux - warpDirX * (1 - p))); const bUy = Math.max(0, Math.min(1, uy - warpDirY * (1 - p))); const [aR, aG, aB] = sampleRgb48le(from, aUx, aUy, w, h); const [bR, bG, bB] = sampleRgb48le(to, bUx, bUy, w, h); // e=1 → show A (from), e=0 → show B (to): mix(B, A, e) const e = smoothstep(p - 0.08, p + 0.08, n); const ed = Math.abs(n - p); // step(1, p) = p >= 1 ? 1 : 0 → suppress glow at p=1 const pStep = p >= 1 ? 1 : 0; const em = smoothstep(0.1, 0, ed) * (1 - pStep); // Edge color: mix accent_dark → accent_bright based on edge proximity const ecBlend = smoothstep(0, 0.1, ed); const ecR = accentDark[0] + (accentBright[0] - accentDark[0]) * (1 - ecBlend); const ecG = accentDark[1] + (accentBright[1] - accentDark[1]) * (1 - ecBlend); const ecB = accentDark[2] + (accentBright[2] - accentDark[2]) * (1 - ecBlend); // mix(B, A, e) + edge glow out.writeUInt16LE(clamp16(mix16(bR, aR, e) + Math.round(ecR * em * 2)), o); out.writeUInt16LE(clamp16(mix16(bG, aG, e) + Math.round(ecG * em * 2)), o + 2); out.writeUInt16LE(clamp16(mix16(bB, aB, e) + Math.round(ecB * em * 2)), o + 4); } }; TRANSITIONS["domain-warp"] = domainWarp; // ── ridged-burn ─────────────────────────────────────────────────────────────── /** * Ridged noise threshold transition with heat glow and sparks. Uses a custom * ridged noise function (5 octaves of abs(vnoise*2 - 1)) to create a * burning-paper effect. Accent colors glow at the burn boundary. Sparks * appear from high-frequency noise. * Port of the GLSL ridged-burn shader. Note: mix(B, A, e) ordering — e=1 shows A (from). */ /** * Ridged noise: 5 octaves of abs(vnoise*2 - 1) with the same rotation and * scaling as fbm. Returns values in [0, ~0.97]. */ function ridged(px: number, py: number): number { let value = 0; let amplitude = 0.5; let x = px; let y = py; for (let i = 0; i < 5; i++) { value += amplitude * Math.abs(vnoise(x, y) * 2 - 1); const nx = ROT_A * x - ROT_B * y; const ny = ROT_B * x + ROT_A * y; x = nx * 2.02; y = ny * 2.02; amplitude *= 0.5; } return value; } export const ridgedBurn: TransitionFn = (from, to, out, w, h, p) => { // Accent colors (16-bit) const accent = [50000, 25000, 5000] as const; const accentDark = [25000, 8000, 2000] as const; const accentBright = [65535, 55000, 35000] as const; for (let i = 0; i < w * h; i++) { const ux = (i % w) / w; const uy = Math.floor(i / w) / h; const o = i * 6; const [aR, aG, aB] = sampleRgb48le(from, ux, uy, w, h); const [bR, bG, bB] = sampleRgb48le(to, ux, uy, w, h); const n = ridged(ux * 4, uy * 4); // e=1 → show A (from), e=0 → show B (to): mix(B, A, e) const e = smoothstep(p - 0.04, p + 0.04, n); const heat = smoothstep(0.12, 0, Math.abs(n - p)); // step(1, p) = p >= 1 ? 1 : 0 → suppress glow at p=1 const pStep = p >= 1 ? 1 : 0; const heatMasked = heat * (1 - pStep); // Burn color gradient: dark → accent → accent_bright → white let burnR = accentDark[0] + (accent[0] - accentDark[0]) * smoothstep(0, 0.25, heatMasked); let burnG = accentDark[1] + (accent[1] - accentDark[1]) * smoothstep(0, 0.25, heatMasked); let burnB = accentDark[2] + (accent[2] - accentDark[2]) * smoothstep(0, 0.25, heatMasked); const blend2 = smoothstep(0.25, 0.5, heatMasked); burnR = burnR + (accentBright[0] - burnR) * blend2; burnG = burnG + (accentBright[1] - burnG) * blend2; burnB = burnB + (accentBright[2] - burnB) * blend2; const blend3 = smoothstep(0.5, 1, heatMasked); burnR = burnR + (65535 - burnR) * blend3; burnG = burnG + (65535 - burnG) * blend3; burnB = burnB + (65535 - burnB) * blend3; // Sparks: high-frequency noise above threshold const sparks = (vnoise(ux * 80, uy * 80) >= 0.92 ? 1 : 0) * heatMasked * 3; out.writeUInt16LE( clamp16( mix16(bR, aR, e) + Math.round(burnR * heatMasked * 3.5) + Math.round(accentBright[0] * sparks), ), o, ); out.writeUInt16LE( clamp16( mix16(bG, aG, e) + Math.round(burnG * heatMasked * 3.5) + Math.round(accentBright[1] * sparks), ), o + 2, ); out.writeUInt16LE( clamp16( mix16(bB, aB, e) + Math.round(burnB * heatMasked * 3.5) + Math.round(accentBright[2] * sparks), ), o + 4, ); } }; TRANSITIONS["ridged-burn"] = ridgedBurn;