Dissecting a Wavy Shader: Sine, Refraction, and Serendipity

0
4


Every creative studio has its weird internal rituals.

Ours? A challenge we call “12 Pens in 12 Months” — one experiment each month, no rules, no clients, just play.

Somewhere between caffeine and chaos, one of our devs made this — a wavy, hypnotic, jelly-like motion effect that instantly became the team’s favorite. We didn’t plan to make anything “serious”, but people started asking how it works, and here we are — writing about it on Codrops!

The Concept

The idea started simple:

What if we could make motion that feels organic — not mechanical, not linear, but something that flows?

We wanted that “liquid between two worlds” feel — a motion that seems to breathe, stretch, and relax.

At its core, it’s a mix of:

  • fragment shaders (to generate geometric wave-like cells)
  • math-driven distortion (sine waves, ripples, and refraction)
  • and requestAnimationFrame for smooth, continuous GPU updates.
function draw(tms) {
  const t = tms * 0.001;

  // organic motion: a sine wave that gently distorts the grid size over time
  const wave = Math.sin(p.x * 0.01 + p.y * 0.015 + t) * 0.25;
  const localCell = cell * (1.0 + wave * 0.2);

  // ripple effect triggered by user interaction (click)
  float ripple = sin(R * 0.06 - dt * 6.0) * env;

  // blending the glass-like refraction with the base image
  vec3 col = mix(base, glass, inside);

  gl_FragColor = vec4(col, 1.0);
}

requestAnimationFrame(draw);

How It Works

The magic trick here is in how we recalculate the wave path every frame.

Instead of animating DOM or CSS properties, we rebuild every pixel dynamically in the shader — frame by frame.

Each cell behaves like a living surface — pulsing, refracting, and rippling with time.

Imagine drawing a line with a rubber band — each anchor point follows the one before it, with a bit of delay and overshoot. That delay gives us that deliciously gooey, alive movement.

For performance reasons, we use:

  • requestAnimationFrame for smooth updates.
  • GPU-driven math — sine, refraction, ripple computed directly in the shader.
  • requestAnimationFrame to sync GPU frames with the browser’s refresh rate.
// find the nearest cell center based on the selected shape
void nearestCenter(int shape, vec2 p, float cell, out vec2 c, out vec2 lp) {
  if (shape == 0) {
    vec2 qr  = hex_pixel_to_axial(p, cell);
    vec2 qrr = hex_axial_round(qr);
    c = hex_axial_to_pixel(qrr, cell);
    lp = p - c;
  } else {
    vec2 g = floor(p / cell + 0.5);
    c = g * cell;
    lp = p - c;
  }
}

Presets & Variations

Once the core system was running, we couldn’t stop tweaking it.

Instead of fixed presets, we built a simple control panel — four sliders that control the shader parameters directly:

  • Cell size (uCell) — density of the grid
  • Amplitude (uAmp) — strength of refraction
  • Chromatic shift (uChrom) — amount of color separation
  • Speed (uSpeed) — how fast the wave evolves

Changing just one of these instantly transforms the mood of the motion — from soft and fluid to sharp and energetic.

Every frame, the app reads the live values from the sliders and sends them straight into the shader —

so any tiny change instantly ripples through the entire surface.

function draw(tms) {
  const t = tms * 0.001;

  // live parameters from the UI → shader
  gl.uniform1f(uCell,   parseFloat(cellInp.value));
  gl.uniform1f(uAmp,    parseFloat(ampInp.value));
  gl.uniform1f(uChrom,  parseFloat(chromInp.value));
  gl.uniform1f(uSpeed,  parseFloat(speedInp.value));
  gl.uniform1f(uTime,   t);

  // update the video texture (if active)
  if (videoReady) {
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, videoEl);
  }

  // render + next frame
  gl.drawArrays(gl.TRIANGLES, 0, 6);
  requestAnimationFrame(draw);
}

Challenges Along the Way

Of course, it wasn’t all smooth sailing (pun intended).

At one point the wave went full chaos — spikes, flickers, everything breaking apart.

Turns out, our smoothing logic was off by one pixel (literally).

Another funny bug: when testing on high-refresh monitors, the motion looked too smooth — like it lost its texture. So we had to add a touch of imperfection to bring back the “handmade” vibe.

Try It Yourself

The whole setup is open and easy to remix — just fork it and start playing with the parameters.

Try changing:

  • the number of points
  • the easing curves
  • the color gradients

We’d love to see what kind of waves you make — tag us or send your remix!

Final Thoughts

Sometimes the best ideas come from not trying too hard.

This one was supposed to be a quick internal experiment — but it turned into something oddly satisfying and visually rich.

We hope you enjoy it as much as we enjoyed breaking (and fixing) it.

Made with ❤️ by Blacklead Studio as part of our 12 Pens in 12 Months creative challenge.