# Ripple Reveal WebGL - Skill Reference
> A WebGL slideshow transition: one fragment shader reveals the next slide through an expanding radial mask while a Gaussian-enveloped sine ring refracts the pixels at the wave front, with an optional pointer "water" layer on top.
## What this teaches
- A single-pass GLSL fragment shader that samples two slide textures (`uPrev`, `uCurr`) and blends them with a `smoothstep` radial mask
- Gaussian leading edge plus exponential trailing wake as the envelope of a `sin()` ring, to displace UVs radially (true pixel refraction, not an overlay)
- Ken Burns zoom done in UV space (`zoomUv`) with independent clocks for the outgoing and incoming slide
- Aspect-correct distance (`uv.x * aspect`) so the wave is a circle on any container size
- `setInterval` for slide cadence plus a `requestAnimationFrame` loop for drawing, with `setParams` read every frame
- A height-field water simulation (WebGL2, ping-pong half-float framebuffers) that refracts the slideshow canvas beneath it
## Core concept
The slideshow never moves DOM nodes: it is one canvas and one program. Progress `uT` (0 to 1, eased) drives two things from the same origin: a mask circle that grows to cover the farthest corner, and a thinner ring just behind and ahead of the same front that offsets the sample UVs. Because the displacement is applied to the UV before both textures are sampled, the mask edge and the refraction stay locked together. The water layer is a separate canvas that re-captures the slideshow canvas each frame, which is why the slideshow context is created with `preserveDrawingBuffer: true`.
## Use it
### Embed (no build step)
```html
```
- Images are read from the element's `
` children (`currentSrc || src`) by `optionsFromElement`. There is no `data-images` attribute.
- Every key of `defaults` is a kebab-case `data-*` attribute: `originX` becomes `data-origin-x`, `kenBurnsDuration` becomes `data-ken-burns-duration`, `waterClick` becomes `data-water-click`.
- Numbers go through `Number(...)`. Booleans: `"false"` and `"0"` are off, anything else is on (`data-water` with no value is on).
- Images on another origin need CORS headers (`crossOrigin = 'anonymous'` is set before loading).
- The script exposes `window.RippleRevealWebGL` with `init(root)` (start any not-yet-started `[data-ripple-reveal-webgl]` under `root`, for DOM you add later), `get(el)` (the running handle: `setParams`, `dispose`), plus `mount`, `defaults`, `RippleRevealWebGL`, `WaterRipples`, `optionsFromElement`.
```js
// elements added after load
RippleRevealWebGL.init(document.querySelector('#new-section'));
// change a running instance
RippleRevealWebGL.get(el).setParams({ duration: 1.5, water: false });
```
### Bundled apps
Files come from `npx vanilla-motion add ripple-reveal-webgl` (into `./vanilla-motion/ripple-reveal-webgl/`).
```ts
import { mount } from "./vanilla-motion/ripple-reveal-webgl/ripple-reveal-webgl";
// images: option wins; if omitted, the container's
children are used
const fx = mount(container, {
images: ["/a.jpg", "/b.jpg", "/c.jpg"], // at least two, otherwise it warns and does nothing
originX: 0,
originY: 0,
delay: 4,
});
fx.setParams({ ringSpeed: 1.5, water: false }); // live; any subset of the params below
fx.dispose(); // stops the loops, removes the canvas(es), releases the WebGL context
```
The handle is `RippleRevealWebGLHandle { setParams(next: Partial): void; dispose(): void }`. Options are `Partial & { images?: string[] }`. `mount` clears the container's children and sets `overflow: hidden` (and `position: relative` if it was static), so give the container a size.
### Params
Defaults from `params.ts`; ranges are the panel's slider limits (the code accepts any number).
| Key | Default | Range | Meaning |
| --- | --- | --- | --- |
| `originX` | `0.5` | 0 to 1 | Wave origin across the width (0 left, 1 right) |
| `originY` | `0.5` | 0 to 1 | Wave origin down the height (0 top, 1 bottom) |
| `feather` | `0.15` | 0 to 0.3 | Soft edge of the reveal mask (0 hard) |
| `duration` | `1` | 0.2 to 4 | Seconds one reveal takes |
| `delay` | `3` | 0.5 to 12 | Seconds a slide stays before the next reveal |
| `kenBurnsDuration` | `10` | 1 to 30 | Seconds of the slow 1 to 1.1 zoom per slide |
| `distortScale` | `40` | 0 to 100 | Ring displacement strength (0 off) |
| `ringCount` | `8` | 1 to 20 | Number of sine rings |
| `ringWidth` | `0.05` | 0.01 to 0.3 | Gaussian band width around the front (smaller is narrower) |
| `ringLength` | `2` | 0.2 to 5 | Spatial wavelength (larger shows fewer rings) |
| `ringSpeed` | `1` | 0.2 to 3 | Ring travel speed multiplier |
| `decay` | `3` | 0 to 20 | Trailing wake length (0 is a clean leading edge) |
| `water` | `true` | on/off | Pointer ripple layer (mouse devices only) |
| `waterClick` | `true` | on/off | Stronger drop on click as well as the move trail |
| `waterResolution` | `256` | no slider; 128 soft, 256 balanced, 512 crisp | Simulation width in texels; read only when the water layer is (re)built |
| `dropRadius` | `20` | 4 to 80 | Radius of each drop in CSS px |
| `perturbance` | `0.015` | 0 to 0.08 | Refraction strength of the water |
| `persistence` | `0.65` | 0 to 1 | How long ripples keep ringing |
## Key code
### Canvas and textures
```ts
// the container becomes the stage: children cleared, one absolutely positioned canvas
container.innerHTML = ''
container.style.overflow = 'hidden'
// preserveDrawingBuffer so the water layer can read this canvas every frame
this.gl = canvas.getContext('webgl', { preserveDrawingBuffer: true })!
// 1x1 placeholder texture per slide so drawing can start before images arrive
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array([40, 40, 40, 255]))
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE) // clamp so displaced UVs never wrap
// upload: flip Y so UV (0,0) matches the CSS top-left convention
img.crossOrigin = 'anonymous'
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1)
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img)
// canvas size = CSS size x min(devicePixelRatio, 2), kept in sync by a ResizeObserver
```
A full-screen quad (two triangles, `position` in clip space) is the only geometry. `start()` awaits all image loads (a failed image resolves anyway and stays the grey placeholder), then starts the draw loop and the interval.
### Fragment shader
```glsl
vec2 origin = vec2(uOrigin.x, 1.0 - uOrigin.y); // CSS Y=0 is top, GL Y=0 is bottom
float aspect = uResolution.x / uResolution.y;
float dist = distance(vec2(uv.x * aspect, uv.y), vec2(origin.x * aspect, origin.y)); // round on any size
float radius = uT * uRingSpeed * 2.0; // ring front travels outward
float x = dist - radius; // signed distance to the front
// ahead of the front: tight Gaussian; behind it: exponential wake scaled by uDecay
float band = x > 0.0 ? exp(-pow(x / uRingWidth, 2.0))
: exp(x / max(uDecay * uRingWidth, 0.0001));
float rings = sin(x * uRingCount * 6.28318 / max(uRingLength, 0.001));
float fade = clamp(1.0 - radius / 2.0, 0.0, 1.0); // die out as the ring leaves the view
vec2 dispUv = uv + normalize(uv - origin + 0.0001) * rings * band * uAmplitude * fade; // push radially
// Ken Burns in UV space; both samples use the displaced UV so mask and refraction agree
vec4 prevColor = texture2D(uPrev, clamp(zoomUv(dispUv, uKenBurnsPrev, origin), 0.0, 1.0));
vec4 currColor = texture2D(uCurr, clamp(zoomUv(dispUv, uKenBurnsCurr, origin), 0.0, 1.0));
// radial mask: grows to the farthest corner; uFeather widens the smoothstep band
float maxDist = sqrt(aspect * aspect + 1.0) * 0.75;
float maskRadius = uT * maxDist;
float halfFeather = uFeather * maxDist * 0.5;
gl_FragColor = mix(prevColor, currColor, smoothstep(maskRadius + halfFeather, maskRadius - halfFeather, dist));
```
`uAmplitude` is `distortScale / 1000`. Uniforms are re-uploaded every frame from `this.p`, so `setParams` needs no restart. When idle, `uT = 1`, `uAmplitude = 0` and both textures are the current slide, so only the Ken Burns zoom shows.
### Timing and slide state
```ts
// the interval only flips state; drawing reads it on the next frame
private advance() {
this.kbStartPrev = this.kbStart // outgoing keeps its running zoom clock
this.current = (this.current + 1) % this.srcs.length
this.animStart = this.kbStart = performance.now() // incoming starts a fresh zoom
this.isAnimating = true
}
// in draw(): clamp to 0 first, a slightly-early frame would make pow(negative, 1.8) NaN and flash garbage
const rawT = Math.min(Math.max((now - this.animStart) / animDuration, 0), 1)
t = Math.pow(rawT, 1.8) // ease-in, approximates cubic-bezier(0.4, 0, 1, 1)
if (rawT >= 1) this.isAnimating = false
// Ken Burns: 0 to 0.1 zoom, capped, one clock per slide
const kenCurr = Math.min((now - this.kbStart) / kbDuration, 1) * 0.1
```
`setParams` re-arms the interval only when `delay` changed, and rebuilds the water layer only when one of the water keys changed (`water`, `waterClick`, `waterResolution`, `dropRadius`, `perturbance`, `persistence`).
### Water ripples
`WaterRipples` (exported too) is a WebGL2 port of the `jquery.ripples` simulation core. It overlays a `pointer-events: none` canvas on the container and refracts what is beneath.
```glsl
// update pass: r = height, g = velocity, on two ping-pong RGBA16F targets
float average = (n(-dx) + n(-dy) + n(+dx) + n(+dy)) * 0.25; // neighbours' height
info.g += (average - info.r) * 2.0; // accelerate toward the neighbour average
info.g *= uDamping; // lose energy each frame
info.r += info.g;
// render pass: normal from the height gradient bends the background sample
vec2 offset = -normalize(cross(dy, dx)).xz;
gl_FragColor = texture2D(uBackground, clamp(vUv + offset * uPerturbance, 0.0, 1.0) * uBgScale + uBgOffset) + specular;
```
```ts
// persistence 0-1 -> per-frame damping; exponential because it compounds ~60x/s
const damping = 1 - 0.1 * Math.pow(0.02, persistence) // 0 -> 0.9, 0.65 -> ~0.992, 1 -> 0.998
// pointermove drops: radius dropRadius, strength 0.005; pointerdown: radius x1.5, strength 0.06
```
- The background source is, in order, a `