You can find a fullscreen demo at /projects/gooey/, or a smaller version if you skip to the end of this post. For the full code, see the JS code and vertex and fragment shaders.

The options panel in the top left of this page lets you change the variables in the animations.

Libraries

twgl.js is a WebGL helper library that's great for simply loading shaders without having to deal with the verbose WebGL API. It's also super small, compared to other WebGL libraries, because it has a more focused feature set. For context, here are some other WebGL libraries:

LibraryMinified size (KB)
twgl.js50
regl85
zen-3d220
pixi.js435
three.js650
babylon.js3400

Vertex shader

The vertex shader processes individual vertices by mapping input vertices to output vertices. Our shader is just a rectangle, so we don't need to do any fancing vertex processing.

We use the most basic vertex shader, simply returning the input:

attribute vec4 position;
void main() {
  gl_Position = position;
}

Fragment shader

This is where the fun begins. The fragment shader takes in a pixel position and returns the color of that pixel.

Uniforms

First, let's define some uniforms, which are global variables that we can use as configurable options. Uniforms are the bridge between JavaScript code and shader code. The JavaScript code will pass in values for the uniforms to the fragment shader.

At the top of the fragment shader:

precision mediump float;

uniform float time;
uniform vec2 resolution;
uniform vec2 mouse;
uniform float noise_speed;
uniform float metaball;
uniform float discard_threshold;
uniform float antialias_threshold;
uniform float noise_height;
uniform float noise_scale;

Coordinates

glFragCoord.xy represents the (x,y) coordinates of the fragment on the screen. We want the screen to be responsive, so we normalize pos.x and pos.y to the range (0,1) and then restore the aspect ratio. Given a 1920x1080 resolution, 0 < pos.x < 1 and 0 < pos.y < 1080/1920.

Note that gl_FragCoord.xy places the origin at the lower left of the screen. However, the coordinates we get from onmousemove are based on an upper left origin. So, we convert our mouse coordinates to the fragment shader's coordinate system.

void main() {
  float ar = resolution.x / resolution.y;
  vec2 pos = gl_FragCoord.xy / resolution;  // normalize coordinates
  pos.y /= ar;  // keep aspect ratio same

  vec2 mouse = mouse / resolution;
  mouse.y = (1. - mouse.y) / ar;  // reflect y and maintain aspect ratio

  // color a small circle around the mouse
  float b = step(distance(mouse, pos) * 10., 0.5);
  gl_FragColor = vec4(pos.x, pos.y, b, 1.);
}

The last two lines visualize pos.x and pos.y as a red-green gradient and color a blue circle around the mouse. The fragment shader so far:

3D Perlin noise

The noise function (snoise) is from stegu/webgl-noise, an implementation of 3D simplex noise.

Why do we need three dimensional noise when our screen is only two dimensional? The extra dimension is time, because we want a smooth animation. We pass in the position (scaled by noise_scale) and the time (scaled by noise_speed) to the snoise function and get back a float in the (-1,1) range. Then we transform it to be in the (0, noise_height) range.

float noise = snoise(vec3(pos * noise_scale, time * noise_speed)); // (-1, 1)
noise = (noise + 1.) / 2.; // (-1, 1) to (0, 1)
float val = noise * noise_height; // (0, noise_height)

Setting the fragment color to val (with gl_FragColor = vec4(vec3(val), 1.)), we get:

Observe that this is psuedorandom noise; it's much less random than JavaScript's Math.random. If you increase the noise_scale uniform, you can see a diagonal striation pattern.

Increasing noise_speed will make the animation faster. Increasing noise_height increases the heights of the "peaks," which appear as lighter areas.

Mouse metaball

I wrote the code for the metaball mostly by trial and error, tweaking functions and coefficients until I got something that looked good.

First, we calculate the distance d between the mouse and the fragment and scale it using the adjustable metaball uniform.

Finally, we clamp the mouseMetaball value to the (0,1) range to ignore extremes.

float d = distance(mouse, pos); // (0=near, 1=far)
float u = d / (metaball + 0.00001);  // avoid division by 0

float mouseMetaball = u * max(5., 10. - 25. * u);
mouseMetaball = clamp(1. - mouseMetaball, 0., 1.);
val += mouseMetaball;

You can see the unclamped mouseMetaball (in green) and the clamped mouseMetaball (in black) as a function of d (x-axis):

(Click edit graph on desmos to see the effect of metaball parameter m on the graph.)

Now, visualizing val again, we see an animated white blob around the mouse:

Setting noise_height to 0, you can see the metaball by itself, a spotlight centered around the mouse. Increasing the metaball uniform increases the radius of the spotlight.

Antialiasing

Now, we use discard_threshold as a cutoff to determine whether or not to color a pixel. We color the pixel (i.e. set alpha=1) iff val >= threshold and otherwise leave it transparent (alpha=0).

But the hard cutoff has a problem: the edges are jagged. We can see the individual pixels on the border because there's no antialiasing. To smooth the edges, we have to add antialiasing manually. If val is between discard_threshold and discard_threshold + alias_threshold, we set alpha to an intermediate value. This works well for small values of alias_threshold, but if it's too high, the edges become too blurry.

Finding the sweet spot for alias_threshold depends on screen resolution, screen size, and DPR. Compare these values of antialias thresholds:

Screenshot of antialiased blob with threshold 0. The blob has jagged edges.
Jagged edges with antialias threshold 0.
Screenshot of antialiased blob with threshold 0.002. The blob has smooth edges.
Smooth edges with antialias threshold 0.002.
Screenshot of antialiased blob with threshold 0.01. The blob has 'feathery' edges and looks blurred.
Blurred edges with antialias threshold 0.01.
// no antialiasing
float alpha = step(discard_threshold, val);
// antialiasing
float alpha = (val - discard_threshold) / alias_threshold;
alpha = clamp(alpha, 0., 1.);

After setting the pixel color, we finally have an animated blob!

vec3 blurple = vec3(63., 30., 223.) / 255.;  // base color
gl_FragColor = vec4(blurple, alpha);

Gradient coloring

Finally, we have to decide what to color the pixel. I want a smooth rainbow-like effect, so the best colorspace to use is HSB, where we can vary the hue and keep saturation and brightness constant. Our fragment shader needs to output an RGBA value, but luckily we can convert HSB to RGB:

//  Function from Iñigo Quiles
//  https://www.shadertoy.com/view/MsS3Wc
vec3 hsb2rgb(in vec3 c) {
    vec3 rgb = clamp(abs(mod(c.x*6.0+vec3(0.0,4.0,2.0),6.0)-3.0)-1.0,0.0, 1.0);
    rgb = rgb*rgb*(3.0-2.0*rgb);
    return c.z * mix(vec3(1.0), rgb, c.y);
}

Let's very the hue by time and add a bit of noise to make it interesting:

float hue = (pos.x + pos.y) / 2. + u_time / 10. + val / 10.;
vec3 color = hsb2rgb(vec3(hue, 0.8, 0.8));

gl_FragColor = vec4(vec3(color), alpha);

If we ignore the discard threshold and show every pixel, we see how the gradient changes:

Putting it together, we're finished!