{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ripple-reveal-webgl",
  "type": "registry:lib",
  "title": "Ripple Reveal WebGL",
  "description": "Slideshow transition using WebGL with radial mask crossfade and Gaussian sine ring distortion for true pixel-level wave refraction at the reveal edge",
  "dependencies": [],
  "files": [
    {
      "path": "registry/ripple-reveal-webgl/params.ts",
      "type": "registry:file",
      "target": "lib/vanilla-motion/ripple-reveal-webgl/params.ts",
      "content": "// The one definition of every Ripple Reveal value. The class merges options over `defaults`,\n// ripple-reveal-webgl-controls.ts builds the Toolcraft panel from the same keys, and the embed\n// reads the same keys as kebab-case data attributes (data-origin-x, data-ring-count, data-water=\"false\"…).\n// v2 dropped the v1 CSS custom properties (--origin-x, --ring-count…) and the data-wr-* attributes.\n\nexport type RippleRevealWebGLParams = {\n  // reveal\n  /** Wave origin, 0 = left, 1 = right. */\n  originX: number;\n  /** Wave origin, 0 = top, 1 = bottom. */\n  originY: number;\n  /** Soft edge of the reveal mask, as a fraction of the reveal radius (0 hard, 0.3 soft). */\n  feather: number;\n  /** Seconds one reveal takes. */\n  duration: number;\n  /** Seconds a slide stays before the next reveal starts. */\n  delay: number;\n  /** Seconds of the slow Ken Burns zoom (1 to 1.1 scale). */\n  kenBurnsDuration: number;\n  // distortion ring\n  /** Displacement strength at the ring (0 off, 40 default, 80 strong). */\n  distortScale: number;\n  ringCount: number;\n  /** Gaussian falloff of the ring band: smaller is a narrower band. */\n  ringWidth: number;\n  /** Spatial wavelength: larger shows fewer rings. */\n  ringLength: number;\n  /** Ring expansion speed multiplier. */\n  ringSpeed: number;\n  /** Length of the trailing wake (0 none, 5 medium, 20 long tail). */\n  decay: number;\n  // water layer (the pointer ripples over the slideshow; desktop with a mouse only)\n  water: boolean;\n  /** Pointer ripples on click as well as on move. */\n  waterClick: boolean;\n  /** Simulation width in texels: 128 soft, 256 balanced, 512 crisp. Read when the layer starts. */\n  waterResolution: number;\n  /** Radius of each drop in CSS pixels. */\n  dropRadius: number;\n  /** Refraction strength of the water. */\n  perturbance: number;\n  /** How long ripples ring, 0 to 1. */\n  persistence: number;\n};\n\nexport const defaults: RippleRevealWebGLParams = {\n  originX: 0.5, originY: 0.5, feather: 0.15, duration: 1, delay: 3, kenBurnsDuration: 10,\n  distortScale: 40, ringCount: 8, ringWidth: 0.05, ringLength: 2, ringSpeed: 1, decay: 3,\n  water: true, waterClick: true, waterResolution: 256, dropRadius: 20, perturbance: 0.015, persistence: 0.65,\n};\n"
    },
    {
      "path": "registry/ripple-reveal-webgl/ripple-reveal-webgl.ts",
      "type": "registry:file",
      "target": "lib/vanilla-motion/ripple-reveal-webgl/ripple-reveal-webgl.ts",
      "content": "import { defaults, type RippleRevealWebGLParams } from './params'\nimport { WaterRipples } from './water-ripples'\n\nexport type { RippleRevealWebGLParams } from './params'\nexport { defaults } from './params'\nexport { WaterRipples, type WaterRipplesOptions } from './water-ripples'\n\nexport type RippleRevealWebGLOptions = Partial<RippleRevealWebGLParams> & {\n  /**\n   * Slide image URLs. When omitted, the slideshow reads the <img> elements inside the\n   * container (the markup way), then replaces them with its canvas.\n   */\n  images?: string[]\n}\n\n/** The public surface wrappers use (Toolcraft canvas, embed). */\nexport interface RippleRevealWebGLHandle {\n  setParams(next: Partial<RippleRevealWebGLParams>): void\n  dispose(): void\n}\n\n/**\n * Fill an element with the slideshow: a canvas sized to the element, slides taken from `images`\n * or from the element's <img> children. Needs at least two images, otherwise it does nothing.\n */\nexport function mount(container: HTMLElement, options: RippleRevealWebGLOptions = {}): RippleRevealWebGLHandle {\n  const { images, ...params } = options\n  const srcs = images ?? Array.from(container.querySelectorAll<HTMLImageElement>('img')).map(img => img.src)\n  if (srcs.length < 2) {\n    console.warn('[RippleRevealWebGL] needs at least two images')\n    return { setParams() {}, dispose() {} }\n  }\n  const fx = new RippleRevealWebGL(container, srcs, params)\n  fx.start().catch(err => console.warn('[RippleRevealWebGL] start failed', err))\n  return { setParams: next => fx.setParams(next), dispose: () => fx.dispose() }\n}\n\n/** Read the slides from the element's own <img> children; data-* params are read by the embed. */\nexport function optionsFromElement(el: HTMLElement): RippleRevealWebGLOptions {\n  const images = Array.from(el.querySelectorAll<HTMLImageElement>('img')).map(img => img.currentSrc || img.src)\n  return images.length ? { images } : {}\n}\n\n// ── Shaders ───────────────────────────────────────────────────────────────────\n\nconst VS = /* glsl */`\n  attribute vec2 position;\n  varying vec2 vUv;\n  void main() {\n    vUv = position * 0.5 + 0.5;\n    gl_Position = vec4(position, 0.0, 1.0);\n  }\n`\n\nconst FS = /* glsl */`\n  precision mediump float;\n\n  uniform sampler2D uPrev;       // outgoing slide texture\n  uniform sampler2D uCurr;       // incoming slide texture\n  uniform vec2      uOrigin;     // wave origin 0-1 (CSS convention: Y=0 top)\n  uniform vec2      uResolution; // canvas physical pixels\n  uniform float     uT;          // 0→1 progress over animation duration\n  uniform float     uFeather;    // mask soft edge (fraction of reveal radius)\n  uniform float     uAmplitude;  // distortion strength in UV space\n  uniform float     uRingCount;  // number of sine rings\n  uniform float     uRingWidth;  // Gaussian falloff width\n  uniform float     uRingLength; // spatial wavelength (larger = fewer visible rings)\n  uniform float     uRingSpeed;  // ring expansion speed multiplier\n  uniform float     uDecay;      // trailing wake length (0=none, higher=longer tail)\n  uniform float     uKenBurnsCurr; // 0→0.1 zoom progress for incoming slide\n  uniform float     uKenBurnsPrev; // fixed 0.1 (outgoing already zoomed)\n\n  varying vec2 vUv;\n\n  vec2 zoomUv(vec2 uv, float zoom, vec2 origin) {\n    return (uv - origin) / (1.0 + zoom) + origin;\n  }\n\n  void main() {\n    vec2 uv = vUv;\n\n    // aspect-correct distance so circle looks round on any viewport\n    float aspect = uResolution.x / uResolution.y;\n    // flip origin Y: CSS convention Y=0 is top, WebGL UV Y=0 is bottom\n    vec2 origin = vec2(uOrigin.x, 1.0 - uOrigin.y);\n    vec2 aUv = vec2(uv.x * aspect, uv.y);\n    vec2 aOr = vec2(origin.x * aspect, origin.y);\n    float dist = distance(aUv, aOr);\n\n    // wave front expands outward over time\n    float radius = uT * uRingSpeed * 2.0;\n    float x = dist - radius; // signed dist from wave front\n\n    // Gaussian on leading edge, exponential decay on trailing wake\n    float band = x > 0.0\n      ? exp(-pow(x / uRingWidth, 2.0))\n      : exp(x / max(uDecay * uRingWidth, 0.0001));\n    float freq  = uRingCount * 6.28318 / max(uRingLength, 0.001);\n    float rings = sin(x * freq);\n    float fade  = clamp(1.0 - radius / 2.0, 0.0, 1.0); // fade as ring exits viewport\n    float disp  = rings * band * uAmplitude * fade;\n\n    // displace radially outward from origin\n    vec2 dir     = normalize(uv - origin + 0.0001);\n    vec2 dispUv  = uv + dir * disp;\n\n    // Ken Burns slow zoom per slide\n    vec2 uvPrev = zoomUv(dispUv, uKenBurnsPrev, origin);\n    vec2 uvCurr = zoomUv(dispUv, uKenBurnsCurr, origin);\n    uvPrev = clamp(uvPrev, 0.0, 1.0);\n    uvCurr = clamp(uvCurr, 0.0, 1.0);\n\n    vec4 prevColor = texture2D(uPrev, uvPrev);\n    vec4 currColor = texture2D(uCurr, uvCurr);\n\n    // radial mask: reveals incoming slide from origin outward\n    float maxDist    = sqrt(aspect * aspect + 1.0) * 0.75; // covers farthest corner\n    float maskRadius = uT * maxDist;\n    float halfFeather = uFeather * maxDist * 0.5;\n    float maskAlpha  = smoothstep(maskRadius + halfFeather, maskRadius - halfFeather, dist);\n\n    gl_FragColor = mix(prevColor, currColor, maskAlpha);\n  }\n`\n\n// ── Helpers ───────────────────────────────────────────────────────────────────\n\nfunction easeIn(t: number): number {\n  // approximates cubic-bezier(0.4, 0, 1, 1) used in the CSS version\n  return Math.pow(t, 1.8)\n}\n\nconst WATER_KEYS: (keyof RippleRevealWebGLParams)[] = ['water', 'waterClick', 'waterResolution', 'dropRadius', 'perturbance', 'persistence']\n\n// ── RippleRevealWebGL class ───────────────────────────────────────────────────\n\nexport class RippleRevealWebGL implements RippleRevealWebGLHandle {\n  /** Same as meta.json \"version\"; bump both when the class or params change. */\n  static readonly version = '2.0.0'\n\n  private gl: WebGLRenderingContext\n  private canvas: HTMLCanvasElement\n  private container: HTMLElement\n  private textures: WebGLTexture[]\n  private srcs: string[]\n  private U: Record<string, WebGLUniformLocation | null>\n  private p: RippleRevealWebGLParams\n\n  private current  = 0\n  private animStart = -Infinity\n  private isAnimating = false\n  private kbStart = -Infinity\n  private kbStartPrev = -Infinity\n  private raf = 0\n  private timer = 0\n  private ro: ResizeObserver\n  private water: WaterRipples | null = null\n  private disposed = false\n\n  constructor(container: HTMLElement, srcs: string[], params: Partial<RippleRevealWebGLParams> = {}) {\n    this.container = container\n    this.srcs = srcs\n    this.p = { ...defaults, ...params }\n\n    container.innerHTML = ''\n    container.style.overflow = 'hidden'\n    if (getComputedStyle(container).position === 'static') container.style.position = 'relative'\n\n    this.canvas = document.createElement('canvas')\n    this.canvas.style.cssText = 'position:absolute;inset:0;width:100%;height:100%'\n    container.appendChild(this.canvas)\n\n    // preserveDrawingBuffer: the water layer captures this canvas every frame\n    this.gl = this.canvas.getContext('webgl', { preserveDrawingBuffer: true })!\n\n    const prog = this.createProgram()\n    this.gl.useProgram(prog)\n    this.setupQuad(prog)\n    this.U = this.collectUniforms(prog)\n    this.textures = this.srcs.map(() => this.createTexture())\n\n    this.ro = new ResizeObserver(() => this.resize())\n    this.ro.observe(container)\n    this.resize()\n    this.syncWater()\n  }\n\n  // ── Public API ──────────────────────────────────────────────────────────────\n\n  setParams(next: Partial<RippleRevealWebGLParams>) {\n    const prev = this.p\n    this.p = { ...prev, ...next }\n    if (this.p.delay !== prev.delay && this.timer) this.schedule()\n    if (WATER_KEYS.some(k => this.p[k] !== prev[k])) this.syncWater()\n  }\n\n  dispose() {\n    this.disposed = true\n    cancelAnimationFrame(this.raf)\n    clearInterval(this.timer)\n    this.ro.disconnect()\n    this.water?.destroy()\n    this.water = null\n    this.canvas.remove()\n    this.gl.getExtension('WEBGL_lose_context')?.loseContext()\n  }\n\n  async start() {\n    await Promise.all(this.srcs.map((src, i) => this.loadImage(this.textures[i], src)))\n    if (this.disposed) return\n    this.kbStart = this.kbStartPrev = performance.now()\n    this.raf = requestAnimationFrame(t => this.draw(t))\n    this.schedule()\n  }\n\n  private schedule() {\n    clearInterval(this.timer)\n    this.timer = window.setInterval(() => this.advance(), Math.max(this.p.delay, 0.05) * 1000)\n  }\n\n  /** (Re)build the water layer to match the params. It lives on top of this canvas. */\n  private syncWater() {\n    this.water?.destroy()\n    this.water = null\n    if (!this.p.water) return\n    this.water = new WaterRipples(this.container, {\n      resolution: this.p.waterResolution, dropRadius: this.p.dropRadius,\n      perturbance: this.p.perturbance, persistence: this.p.persistence, click: this.p.waterClick,\n    })\n  }\n\n  // ── WebGL setup ─────────────────────────────────────────────────────────────\n\n  private compileShader(type: number, src: string): WebGLShader {\n    const shader = this.gl.createShader(type)!\n    this.gl.shaderSource(shader, src)\n    this.gl.compileShader(shader)\n    if (!this.gl.getShaderParameter(shader, this.gl.COMPILE_STATUS))\n      throw new Error(this.gl.getShaderInfoLog(shader) ?? 'shader compile error')\n    return shader\n  }\n\n  private createProgram(): WebGLProgram {\n    const prog = this.gl.createProgram()!\n    this.gl.attachShader(prog, this.compileShader(this.gl.VERTEX_SHADER, VS))\n    this.gl.attachShader(prog, this.compileShader(this.gl.FRAGMENT_SHADER, FS))\n    this.gl.linkProgram(prog)\n    if (!this.gl.getProgramParameter(prog, this.gl.LINK_STATUS))\n      throw new Error(this.gl.getProgramInfoLog(prog) ?? 'program link error')\n    return prog\n  }\n\n  private setupQuad(prog: WebGLProgram) {\n    const quad = new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1])\n    const buf  = this.gl.createBuffer()!\n    this.gl.bindBuffer(this.gl.ARRAY_BUFFER, buf)\n    this.gl.bufferData(this.gl.ARRAY_BUFFER, quad, this.gl.STATIC_DRAW)\n    const loc = this.gl.getAttribLocation(prog, 'position')\n    this.gl.enableVertexAttribArray(loc)\n    this.gl.vertexAttribPointer(loc, 2, this.gl.FLOAT, false, 0, 0)\n  }\n\n  private collectUniforms(prog: WebGLProgram): Record<string, WebGLUniformLocation | null> {\n    const names = ['uPrev','uCurr','uOrigin','uResolution','uT','uFeather',\n                   'uAmplitude','uRingCount','uRingWidth','uRingLength',\n                   'uRingSpeed','uDecay','uKenBurnsCurr','uKenBurnsPrev']\n    return Object.fromEntries(names.map(n => [n, this.gl.getUniformLocation(prog, n)]))\n  }\n\n  private createTexture(): WebGLTexture {\n    const gl  = this.gl\n    const tex = gl.createTexture()!\n    gl.bindTexture(gl.TEXTURE_2D, tex)\n    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array([40, 40, 40, 255]))\n    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)\n    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)\n    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)\n    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)\n    return tex\n  }\n\n  private loadImage(tex: WebGLTexture, src: string): Promise<void> {\n    return new Promise(resolve => {\n      const img = new Image()\n      img.crossOrigin = 'anonymous'\n      img.onload = () => {\n        const gl = this.gl\n        gl.bindTexture(gl.TEXTURE_2D, tex)\n        gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1) // WebGL Y=0 is bottom; flip to match CSS origin convention\n        gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img)\n        gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 0)\n        resolve()\n      }\n      img.onerror = () => resolve()\n      img.src = src\n    })\n  }\n\n  private resize() {\n    const dpr = Math.min(devicePixelRatio, 2)\n    this.canvas.width  = this.container.clientWidth  * dpr\n    this.canvas.height = this.container.clientHeight * dpr\n    this.gl.viewport(0, 0, this.canvas.width, this.canvas.height)\n  }\n\n  private draw(now: number) {\n    const gl = this.gl\n    const { originX, originY, feather, ringCount, ringWidth, ringLength, ringSpeed, decay } = this.p\n    const amplitude = this.p.distortScale / 1000\n    const animDuration = this.p.duration * 1000\n    const kbDuration = Math.max(this.p.kenBurnsDuration, 0.001) * 1000\n\n    let t = 1\n    if (this.isAnimating) {\n      // clamp to 0: a fresh animStart (set via performance.now() on a separate\n      // setInterval) can land a hair after the rAF timestamp of the very next\n      // frame on some browsers/GPUs, going negative — Math.pow(negative, 1.8)\n      // is NaN, which poisons uT and flashes garbage for one frame\n      const rawT = Math.min(Math.max((now - this.animStart) / Math.max(animDuration, 1), 0), 1)\n      t = easeIn(rawT)\n      if (rawT >= 1) this.isAnimating = false\n    }\n\n    const n    = this.srcs.length\n    const curr = this.current                        // incoming / now-displayed\n    const prev = (this.current - 1 + n) % n         // outgoing\n\n    // Ken Burns runs continuously per slide, both incoming and outgoing\n    const kbElapsed     = Math.max((now - this.kbStart)     / kbDuration, 0)\n    const kbElapsedPrev = Math.max((now - this.kbStartPrev) / kbDuration, 0)\n    const kenCurr = Math.min(kbElapsed,     1) * 0.1\n    const kenPrev = Math.min(kbElapsedPrev, 1) * 0.1\n\n    // uPrev = outgoing, uCurr = incoming (or same when idle)\n    // idle: uT=1, both textures = curr → full curr shown with kenCurr zoom\n    gl.activeTexture(gl.TEXTURE0)\n    gl.bindTexture(gl.TEXTURE_2D, this.textures[this.isAnimating ? prev : curr])\n    gl.uniform1i(this.U.uPrev, 0)\n\n    gl.activeTexture(gl.TEXTURE1)\n    gl.bindTexture(gl.TEXTURE_2D, this.textures[curr])\n    gl.uniform1i(this.U.uCurr, 1)\n\n    gl.uniform2f(this.U.uOrigin,     originX, originY)\n    gl.uniform2f(this.U.uResolution, this.canvas.width, this.canvas.height)\n    gl.uniform1f(this.U.uT,             this.isAnimating ? t : 1)\n    gl.uniform1f(this.U.uFeather,       feather)\n    gl.uniform1f(this.U.uAmplitude,     this.isAnimating ? amplitude : 0)\n    gl.uniform1f(this.U.uRingCount,     ringCount)\n    gl.uniform1f(this.U.uRingWidth,     ringWidth)\n    gl.uniform1f(this.U.uRingLength,    ringLength)\n    gl.uniform1f(this.U.uRingSpeed,     ringSpeed)\n    gl.uniform1f(this.U.uDecay,         decay)\n    gl.uniform1f(this.U.uKenBurnsCurr,  kenCurr)\n    gl.uniform1f(this.U.uKenBurnsPrev,  kenPrev)\n\n    gl.drawArrays(gl.TRIANGLES, 0, 6)\n\n    this.raf = requestAnimationFrame(t => this.draw(t))\n  }\n\n  private advance() {\n    this.kbStartPrev = this.kbStart        // outgoing keeps its running KB clock\n    this.current     = (this.current + 1) % this.srcs.length\n    this.animStart   = performance.now()\n    this.kbStart     = performance.now()   // incoming starts fresh\n    this.isAnimating = true\n  }\n}\n"
    },
    {
      "path": "registry/ripple-reveal-webgl/water-ripples.ts",
      "type": "registry:file",
      "target": "lib/vanilla-motion/ripple-reveal-webgl/water-ripples.ts",
      "content": "/**\n * water-ripples.ts: interactive \"tap on water\" cursor layer, used by the slideshow.\n *\n * Vanilla WebGL2 port of the simulation core of sirxemic/jquery.ripples (MIT).\n * Overlays its own canvas on an element and refracts whatever renders beneath:\n *   - a <canvas> inside the element: re-captured every frame (live sources)\n *   - an <img> inside the element: uploaded once\n *   - a CSS background-image: uploaded once, cover-fitted\n *\n * Desktop-only by design: mounts nothing on touch-only devices, with\n * prefers-reduced-motion, or without WebGL2 float render targets.\n */\n\n// ── Shaders ───────────────────────────────────────────────────────────────────\n\nconst VS = /* glsl */`\n  attribute vec2 position;\n  varying vec2 vUv;\n  void main() {\n    vUv = position * 0.5 + 0.5;\n    gl_Position = vec4(position, 0.0, 1.0);\n  }\n`\n\nconst UPDATE_FS = /* glsl */`\n  precision highp float;\n\n  uniform sampler2D uSim;    // r = height, g = velocity\n  uniform vec2      uDelta;  // 1 / sim resolution\n  uniform float     uDamping;\n\n  varying vec2 vUv;\n\n  void main() {\n    vec4 info = texture2D(uSim, vUv);\n\n    vec2 dx = vec2(uDelta.x, 0.0);\n    vec2 dy = vec2(0.0, uDelta.y);\n    float average = (\n      texture2D(uSim, vUv - dx).r +\n      texture2D(uSim, vUv - dy).r +\n      texture2D(uSim, vUv + dx).r +\n      texture2D(uSim, vUv + dy).r\n    ) * 0.25;\n\n    info.g += (average - info.r) * 2.0; // accelerate toward neighbor average\n    info.g *= uDamping;                 // lose energy each frame\n    info.r += info.g;\n\n    gl_FragColor = info;\n  }\n`\n\nconst DROP_FS = /* glsl */`\n  precision highp float;\n\n  const float PI = 3.141592653589793;\n\n  uniform sampler2D uSim;\n  uniform vec2  uCenter;   // drop position in sim UV\n  uniform float uRadius;   // width-relative units\n  uniform float uStrength;\n  uniform float uAspect;   // container height / width — keeps the drop round\n\n  varying vec2 vUv;\n\n  void main() {\n    vec4 info = texture2D(uSim, vUv);\n    float dist = length((uCenter - vUv) * vec2(1.0, uAspect));\n    float drop = max(0.0, 1.0 - dist / uRadius);\n    drop = 0.5 - cos(drop * PI) * 0.5; // smooth cosine bump\n    info.r += drop * uStrength;\n    gl_FragColor = info;\n  }\n`\n\nconst RENDER_FS = /* glsl */`\n  precision highp float;\n\n  uniform sampler2D uBackground;\n  uniform sampler2D uSim;\n  uniform vec2  uDelta;       // 1 / sim resolution\n  uniform float uPerturbance; // refraction strength\n  uniform vec2  uBgScale;     // cover-fit transform (1,1 for canvas sources)\n  uniform vec2  uBgOffset;\n\n  varying vec2 vUv;\n\n  void main() {\n    float height  = texture2D(uSim, vUv).r;\n    float heightX = texture2D(uSim, vec2(vUv.x + uDelta.x, vUv.y)).r;\n    float heightY = texture2D(uSim, vec2(vUv.x, vUv.y + uDelta.y)).r;\n\n    // surface normal from the height gradient → refraction offset + light glint\n    vec3 dx = vec3(uDelta.x, heightX - height, 0.0);\n    vec3 dy = vec3(0.0, heightY - height, uDelta.y);\n    vec2 offset = -normalize(cross(dy, dx)).xz;\n    float specular = pow(max(0.0, dot(offset, normalize(vec2(-0.6, 1.0)))), 4.0) * 0.4;\n\n    vec2 bgUv = clamp(vUv + offset * uPerturbance, 0.0, 1.0) * uBgScale + uBgOffset;\n    gl_FragColor = texture2D(uBackground, bgUv) + specular;\n  }\n`\n\n// ── Types ─────────────────────────────────────────────────────────────────────\n\nexport interface WaterRipplesOptions {\n  /**\n   * Simulation texture width in texels; height follows the container's aspect\n   * ratio. Higher = finer, sharper ripples at more GPU cost.\n   * `128` = coarse/soft, `256` = balanced, `512` = crisp.\n   */\n  resolution?: number\n\n  /**\n   * Radius of each injected drop, in CSS pixels. Smaller = fine rain-like\n   * pricks, larger = broad soft swells.\n   */\n  dropRadius?: number\n\n  /**\n   * Refraction strength — how far (in UV units) wave slopes bend the image\n   * underneath. `0` = invisible, `0.01–0.02` = subtle, `0.03+` = strong,\n   * clearly liquid.\n   */\n  perturbance?: number\n\n  /**\n   * How long ripples ring before the surface settles, normalized `0–1`:\n   * `0` = dies almost instantly, `0.5` = calm water, `1` = long slosh.\n   * (Mapped internally onto the exponential per-frame damping factor.)\n   */\n  persistence?: number\n\n  /**\n   * Listen for pointer events on the container. Set `false` for a still\n   * surface driven only by programmatic {@link WaterRipples.drop} calls.\n   */\n  interactive?: boolean\n\n  /**\n   * Splash a stronger drop on click/pointerdown. Set `false` to keep only\n   * the gentle cursor-move trail. (No effect when `interactive` is `false`.)\n   */\n  click?: boolean\n}\n\ninterface ProgramInfo {\n  prog: WebGLProgram\n  U: Record<string, WebGLUniformLocation | null>\n}\n\ninterface PendingDrop { x: number; y: number; r: number; s: number }\n\n// ── WaterRipples class ────────────────────────────────────────────────────────\n\nexport class WaterRipples {\n  private container: HTMLElement\n  private opts: Required<WaterRipplesOptions>\n  private enabled = false\n\n  private canvas?: HTMLCanvasElement\n  private gl!: WebGL2RenderingContext\n  private progs!: { update: ProgramInfo; drop: ProgramInfo; render: ProgramInfo }\n\n  private simTex: WebGLTexture[] = []\n  private simFbo: WebGLFramebuffer[] = []\n  private simRead = 0\n  private simW = 0\n  private simH = 0\n  private fboOk = false\n\n  private bgTex!: WebGLTexture\n  private sourceCanvas: HTMLCanvasElement | null = null\n  private bgReady = false\n  private imgSize: [number, number] | null = null\n  private bgScale: [number, number] = [1, 1]\n  private bgOffset: [number, number] = [0, 0]\n\n  private pendingDrops: PendingDrop[] = []\n  private raf = 0\n  private ro?: ResizeObserver\n\n  constructor(container: HTMLElement, options: WaterRipplesOptions = {}) {\n    this.container = container\n    this.opts = {\n      resolution: 256, dropRadius: 20, perturbance: 0.015,\n      persistence: 0.65, interactive: true, click: true, ...options,\n    }\n\n    if (!matchMedia('(hover: hover) and (pointer: fine)').matches) return\n    if (matchMedia('(prefers-reduced-motion: reduce)').matches) return\n\n    const canvas = document.createElement('canvas')\n    const gl = canvas.getContext('webgl2', { antialias: false })\n    if (!gl) return\n    if (!gl.getExtension('EXT_color_buffer_float') &&\n        !gl.getExtension('EXT_color_buffer_half_float')) return\n\n    this.canvas = canvas\n    this.gl = gl\n    canvas.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:2'\n    if (getComputedStyle(container).position === 'static') container.style.position = 'relative'\n\n    this.progs = {\n      update: this.createProgram(UPDATE_FS),\n      drop:   this.createProgram(DROP_FS),\n      render: this.createProgram(RENDER_FS),\n    }\n    this.setupQuad()\n    this.bgTex = this.createTexture()\n\n    this.findSource() // before appending our canvas, so it never matches itself\n    this.resize()\n    if (!this.fboOk) {\n      gl.getExtension('WEBGL_lose_context')?.loseContext()\n      return\n    }\n\n    container.appendChild(canvas)\n    if (this.opts.interactive) {\n      container.addEventListener('pointermove', this.onPointerMove)\n      if (this.opts.click) container.addEventListener('pointerdown', this.onPointerDown)\n    }\n    this.ro = new ResizeObserver(() => this.resize())\n    this.ro.observe(container)\n\n    this.enabled = true\n    this.raf = requestAnimationFrame(this.frame)\n  }\n\n  // ── Public API ──────────────────────────────────────────────────────────────\n\n  /** Inject a drop at CSS-pixel coords relative to the container. */\n  drop(xPx: number, yPx: number, radiusPx: number, strength: number) {\n    if (!this.enabled) return\n    const w = this.container.clientWidth\n    const h = this.container.clientHeight\n    if (!w || !h) return\n    this.pendingDrops.push({\n      x: xPx / w,\n      y: 1 - yPx / h, // CSS y-down → GL v-up\n      r: Math.max(radiusPx / w, 0.001),\n      s: strength,\n    })\n  }\n\n  destroy() {\n    cancelAnimationFrame(this.raf)\n    this.ro?.disconnect()\n    this.container.removeEventListener('pointermove', this.onPointerMove)\n    this.container.removeEventListener('pointerdown', this.onPointerDown)\n    this.canvas?.remove()\n    this.gl?.getExtension('WEBGL_lose_context')?.loseContext()\n    this.enabled = false\n  }\n\n  // ── WebGL setup ─────────────────────────────────────────────────────────────\n\n  private compileShader(type: number, src: string): WebGLShader {\n    const shader = this.gl.createShader(type)!\n    this.gl.shaderSource(shader, src)\n    this.gl.compileShader(shader)\n    if (!this.gl.getShaderParameter(shader, this.gl.COMPILE_STATUS))\n      throw new Error(this.gl.getShaderInfoLog(shader) ?? 'shader compile error')\n    return shader\n  }\n\n  private createProgram(fs: string): ProgramInfo {\n    const gl = this.gl\n    const prog = gl.createProgram()!\n    gl.attachShader(prog, this.compileShader(gl.VERTEX_SHADER, VS))\n    gl.attachShader(prog, this.compileShader(gl.FRAGMENT_SHADER, fs))\n    gl.bindAttribLocation(prog, 0, 'position') // all programs share one VAO\n    gl.linkProgram(prog)\n    if (!gl.getProgramParameter(prog, gl.LINK_STATUS))\n      throw new Error(gl.getProgramInfoLog(prog) ?? 'program link error')\n\n    const U: Record<string, WebGLUniformLocation | null> = {}\n    const count = gl.getProgramParameter(prog, gl.ACTIVE_UNIFORMS)\n    for (let i = 0; i < count; i++) {\n      const name = gl.getActiveUniform(prog, i)!.name\n      U[name] = gl.getUniformLocation(prog, name)\n    }\n    return { prog, U }\n  }\n\n  private setupQuad() {\n    const gl = this.gl\n    gl.bindVertexArray(gl.createVertexArray())\n    gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer())\n    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]), gl.STATIC_DRAW)\n    gl.enableVertexAttribArray(0)\n    gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0)\n  }\n\n  private createTexture(): WebGLTexture {\n    const gl = this.gl\n    const tex = gl.createTexture()!\n    gl.bindTexture(gl.TEXTURE_2D, tex)\n    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)\n    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)\n    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)\n    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)\n    return tex\n  }\n\n  private createSimTargets() {\n    const gl = this.gl\n    this.simTex.forEach(t => gl.deleteTexture(t))\n    this.simFbo.forEach(f => gl.deleteFramebuffer(f))\n    this.simTex = []\n    this.simFbo = []\n    for (let i = 0; i < 2; i++) {\n      const tex = this.createTexture()\n      gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA16F, this.simW, this.simH, 0, gl.RGBA, gl.HALF_FLOAT, null)\n      const fbo = gl.createFramebuffer()!\n      gl.bindFramebuffer(gl.FRAMEBUFFER, fbo)\n      gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0)\n      this.simTex.push(tex)\n      this.simFbo.push(fbo)\n    }\n    this.fboOk = gl.checkFramebufferStatus(gl.FRAMEBUFFER) === gl.FRAMEBUFFER_COMPLETE\n    gl.bindFramebuffer(gl.FRAMEBUFFER, null)\n    this.simRead = 0\n  }\n\n  // ── Background source ───────────────────────────────────────────────────────\n\n  private findSource() {\n    const canvas = this.container.querySelector('canvas')\n    if (canvas) {\n      this.sourceCanvas = canvas // live source, captured every frame\n      this.bgReady = true\n      return\n    }\n    const img = this.container.querySelector('img')\n    if (img) {\n      this.loadStaticImage(img.currentSrc || img.src)\n      return\n    }\n    const bg = getComputedStyle(this.container).backgroundImage\n    const url = bg === 'none' ? null : bg.match(/url\\([\"']?([^\"')]+)[\"']?\\)/)\n    if (url) this.loadStaticImage(url[1])\n  }\n\n  private loadStaticImage(src: string) {\n    const img = new Image()\n    img.crossOrigin = 'anonymous'\n    img.onload = () => {\n      const gl = this.gl\n      gl.activeTexture(gl.TEXTURE0)\n      gl.bindTexture(gl.TEXTURE_2D, this.bgTex)\n      gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1)\n      gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img)\n      gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 0)\n      this.imgSize = [img.naturalWidth, img.naturalHeight]\n      this.updateBgTransform()\n      this.bgReady = true\n    }\n    img.src = src\n  }\n\n  private captureBackground() {\n    const gl = this.gl\n    gl.activeTexture(gl.TEXTURE0)\n    gl.bindTexture(gl.TEXTURE_2D, this.bgTex)\n    gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1)\n    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, this.sourceCanvas!)\n    gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 0)\n  }\n\n  private updateBgTransform() {\n    if (!this.imgSize) return // canvas source fills the container 1:1\n    const [iw, ih] = this.imgSize\n    const cw = this.container.clientWidth\n    const ch = this.container.clientHeight\n    const scale = Math.max(cw / iw, ch / ih) // CSS background-size: cover\n    const fx = cw / (iw * scale)\n    const fy = ch / (ih * scale)\n    this.bgScale = [fx, fy]\n    this.bgOffset = [(1 - fx) / 2, (1 - fy) / 2]\n  }\n\n  // ── Sizing ──────────────────────────────────────────────────────────────────\n\n  private resize() {\n    const w = this.container.clientWidth\n    const h = this.container.clientHeight\n    if (!w || !h || !this.canvas) return\n    const dpr = Math.min(devicePixelRatio, 2)\n    this.canvas.width = w * dpr\n    this.canvas.height = h * dpr\n\n    // sim texel density matches the canvas aspect so waves travel isotropically\n    const simW = Math.round(this.opts.resolution)\n    const simH = Math.max(16, Math.round(simW * h / w))\n    if (simW !== this.simW || simH !== this.simH) {\n      this.simW = simW\n      this.simH = simH\n      this.createSimTargets()\n    }\n    this.updateBgTransform()\n  }\n\n  // ── Pointer ─────────────────────────────────────────────────────────────────\n\n  private onPointerMove = (e: PointerEvent) => this.dropAtPointer(e, this.opts.dropRadius, 0.005)\n  private onPointerDown = (e: PointerEvent) => this.dropAtPointer(e, this.opts.dropRadius * 1.5, 0.06)\n\n  private dropAtPointer(e: PointerEvent, radiusPx: number, strength: number) {\n    const rect = this.container.getBoundingClientRect()\n    this.drop(e.clientX - rect.left, e.clientY - rect.top, radiusPx, strength)\n  }\n\n  // ── Frame loop ──────────────────────────────────────────────────────────────\n\n  private frame = () => {\n    this.raf = requestAnimationFrame(this.frame)\n    if (!this.bgReady || !this.fboOk) return\n    if (this.sourceCanvas && this.sourceCanvas.width > 0) this.captureBackground()\n    this.step()\n    this.render()\n  }\n\n  private simPass(p: ProgramInfo, setUniforms: (U: ProgramInfo['U']) => void) {\n    const gl = this.gl\n    const write = 1 - this.simRead\n    gl.bindFramebuffer(gl.FRAMEBUFFER, this.simFbo[write])\n    gl.useProgram(p.prog)\n    gl.activeTexture(gl.TEXTURE0)\n    gl.bindTexture(gl.TEXTURE_2D, this.simTex[this.simRead])\n    gl.uniform1i(p.U.uSim, 0)\n    setUniforms(p.U)\n    gl.drawArrays(gl.TRIANGLES, 0, 6)\n    this.simRead = write\n  }\n\n  private step() {\n    const gl = this.gl\n    gl.viewport(0, 0, this.simW, this.simH)\n\n    const aspect = this.container.clientHeight / this.container.clientWidth\n    for (const d of this.pendingDrops) {\n      this.simPass(this.progs.drop, U => {\n        gl.uniform2f(U.uCenter, d.x, d.y)\n        gl.uniform1f(U.uRadius, d.r)\n        gl.uniform1f(U.uStrength, d.s)\n        gl.uniform1f(U.uAspect, aspect)\n      })\n    }\n    this.pendingDrops.length = 0\n\n    // persistence 0–1 → per-frame damping factor, mapped exponentially:\n    // decay compounds ~60×/s, so equal perceptual steps need exponential spacing.\n    // p=0 → 0.9 (dies in ~0.1s), p=0.65 → 0.992 (calm water), p=1 → 0.998 (long slosh)\n    const p = Math.min(Math.max(this.opts.persistence, 0), 1)\n    const damping = 1 - 0.1 * Math.pow(0.02, p)\n\n    this.simPass(this.progs.update, U => {\n      gl.uniform2f(U.uDelta, 1 / this.simW, 1 / this.simH)\n      gl.uniform1f(U.uDamping, damping)\n    })\n  }\n\n  private render() {\n    const gl = this.gl\n    const p = this.progs.render\n    gl.bindFramebuffer(gl.FRAMEBUFFER, null)\n    gl.viewport(0, 0, this.canvas!.width, this.canvas!.height)\n    gl.useProgram(p.prog)\n\n    gl.activeTexture(gl.TEXTURE0)\n    gl.bindTexture(gl.TEXTURE_2D, this.bgTex)\n    gl.uniform1i(p.U.uBackground, 0)\n\n    gl.activeTexture(gl.TEXTURE1)\n    gl.bindTexture(gl.TEXTURE_2D, this.simTex[this.simRead])\n    gl.uniform1i(p.U.uSim, 1)\n\n    gl.uniform2f(p.U.uDelta, 1 / this.simW, 1 / this.simH)\n    gl.uniform1f(p.U.uPerturbance, this.opts.perturbance)\n    gl.uniform2f(p.U.uBgScale, this.bgScale[0], this.bgScale[1])\n    gl.uniform2f(p.U.uBgOffset, this.bgOffset[0], this.bgOffset[1])\n\n    gl.drawArrays(gl.TRIANGLES, 0, 6)\n  }\n}\n\n"
    }
  ],
  "meta": {
    "version": "2.0.0",
    "entry": "ripple-reveal-webgl.ts",
    "docs": "https://vanilla-motion.com/ripple-reveal-webgl/skill.md"
  }
}
