All articles
28 min read

Metaballs: How to Create Mesmerizing Blob Art With Code

metaballsmetaballblob artcreative codingJavaScriptgenerative artcanvasmarching squares

Metaballs are one of the most visually satisfying effects in computer graphics. Invented by Jim Blinn in 1982 for visualizing molecular electron density clouds, they create organic, blobby shapes that merge and split smoothly as they move near each other. Think lava lamps, mercury droplets, or amoebas under a microscope—metaballs capture that same liquid, alive quality.

The math is surprisingly simple. Each metaball is a point in space with a radius. At any position, you sum the “influence” of every ball using an inverse-square falloff function. Where the total influence exceeds a threshold, you’re inside the blob. Where two balls get close, their influence fields overlap and the boundary smoothly merges them together.

This guide builds eight metaball systems from scratch. Every example runs in a single HTML file, no libraries, no dependencies. Each one produces those hypnotic, organic shapes that are impossible to stop watching.

1. Basic 2D metaballs — pixel-by-pixel rendering

The simplest approach: for every pixel on screen, sum the influence of all balls. If the sum exceeds a threshold, color the pixel. The influence function for each ball is r² / ((x - bx)² + (y - by)²) where r is the ball’s radius and (bx, by) is its center.

const canvas = document.createElement('canvas');
canvas.width = 400; canvas.height = 400;
canvas.style.background = '#000';
document.body.appendChild(canvas);
document.body.style.margin = '0';
document.body.style.background = '#000';
document.body.style.display = 'flex';
document.body.style.justifyContent = 'center';
document.body.style.alignItems = 'center';
document.body.style.height = '100vh';
const ctx = canvas.getContext('2d');
const W = 400, H = 400;

const balls = [];
for (let i = 0; i < 6; i++) {
  balls.push({
    x: Math.random() * W,
    y: Math.random() * H,
    r: 40 + Math.random() * 30,
    vx: (Math.random() - 0.5) * 2,
    vy: (Math.random() - 0.5) * 2
  });
}

const img = ctx.createImageData(W, H);

function render() {
  for (let i = 0; i < balls.length; i++) {
    const b = balls[i];
    b.x += b.vx; b.y += b.vy;
    if (b.x < 0 || b.x > W) b.vx *= -1;
    if (b.y < 0 || b.y > H) b.vy *= -1;
  }
  for (let y = 0; y < H; y++) {
    for (let x = 0; x < W; x++) {
      let sum = 0;
      for (let i = 0; i < balls.length; i++) {
        const b = balls[i];
        const dx = x - b.x, dy = y - b.y;
        sum += (b.r * b.r) / (dx * dx + dy * dy + 1);
      }
      const idx = (y * W + x) * 4;
      if (sum > 1) {
        img.data[idx] = 60;
        img.data[idx + 1] = 180;
        img.data[idx + 2] = 255;
        img.data[idx + 3] = 255;
      } else {
        img.data[idx] = 0;
        img.data[idx + 1] = 0;
        img.data[idx + 2] = 0;
        img.data[idx + 3] = 255;
      }
    }
  }
  ctx.putImageData(img, 0, 0);
  requestAnimationFrame(render);
}
render();

This brute-force approach is O(pixels × balls). For a 400×400 canvas with 6 balls, that’s about 960,000 distance calculations per frame—but modern CPUs handle it fine at 60fps. The blobs drift, bounce off walls, and merge smoothly when they approach each other. No special merge logic needed—it emerges naturally from summing the fields.

2. Marching squares metaballs — smooth vector outlines

Instead of coloring every pixel, marching squares traces the contour where the field equals the threshold. This gives you clean vector outlines that you can stroke or fill, and it’s much faster than pixel-by-pixel rendering because you only evaluate the field on a grid.

const canvas = document.createElement('canvas');
canvas.width = 600; canvas.height = 600;
canvas.style.background = '#0a0a1a';
document.body.appendChild(canvas);
document.body.style.margin = '0';
document.body.style.background = '#0a0a1a';
document.body.style.display = 'flex';
document.body.style.justifyContent = 'center';
document.body.style.alignItems = 'center';
document.body.style.height = '100vh';
const ctx = canvas.getContext('2d');
const W = 600, H = 600;
const RES = 5; // grid resolution
const cols = Math.floor(W / RES) + 1;
const rows = Math.floor(H / RES) + 1;

const balls = [];
for (let i = 0; i < 8; i++) {
  balls.push({
    x: Math.random() * W, y: Math.random() * H,
    r: 50 + Math.random() * 40,
    vx: (Math.random() - 0.5) * 3,
    vy: (Math.random() - 0.5) * 3
  });
}

function field(x, y) {
  let sum = 0;
  for (const b of balls) {
    const dx = x - b.x, dy = y - b.y;
    sum += (b.r * b.r) / (dx * dx + dy * dy + 1);
  }
  return sum;
}

function lerp(a, b, t) { return a + (b - a) * t; }

function render() {
  for (const b of balls) {
    b.x += b.vx; b.y += b.vy;
    if (b.x < 0 || b.x > W) b.vx *= -1;
    if (b.y < 0 || b.y > H) b.vy *= -1;
  }

  // Sample field on grid
  const grid = new Float32Array(cols * rows);
  for (let j = 0; j < rows; j++) {
    for (let i = 0; i < cols; i++) {
      grid[j * cols + i] = field(i * RES, j * RES);
    }
  }

  ctx.clearRect(0, 0, W, H);
  ctx.strokeStyle = '#00e5ff';
  ctx.lineWidth = 2;
  ctx.fillStyle = 'rgba(0, 229, 255, 0.15)';
  const threshold = 1;

  // Marching squares
  ctx.beginPath();
  for (let j = 0; j < rows - 1; j++) {
    for (let i = 0; i < cols - 1; i++) {
      const x = i * RES, y = j * RES;
      const a = grid[j * cols + i];
      const b = grid[j * cols + i + 1];
      const c = grid[(j + 1) * cols + i + 1];
      const d = grid[(j + 1) * cols + i];
      let state = 0;
      if (a >= threshold) state |= 1;
      if (b >= threshold) state |= 2;
      if (c >= threshold) state |= 4;
      if (d >= threshold) state |= 8;
      if (state === 0 || state === 15) continue;

      const top = lerp(x, x + RES, (threshold - a) / (b - a));
      const right = lerp(y, y + RES, (threshold - b) / (c - b));
      const bottom = lerp(x, x + RES, (threshold - d) / (c - d));
      const left = lerp(y, y + RES, (threshold - a) / (d - a));

      switch (state) {
        case 1: case 14: ctx.moveTo(x, left); ctx.lineTo(top, y); break;
        case 2: case 13: ctx.moveTo(top, y); ctx.lineTo(x + RES, right); break;
        case 3: case 12: ctx.moveTo(x, left); ctx.lineTo(x + RES, right); break;
        case 4: case 11: ctx.moveTo(x + RES, right); ctx.lineTo(bottom, y + RES); break;
        case 5: ctx.moveTo(x, left); ctx.lineTo(top, y); ctx.moveTo(x + RES, right); ctx.lineTo(bottom, y + RES); break;
        case 6: case 9: ctx.moveTo(top, y); ctx.lineTo(bottom, y + RES); break;
        case 7: case 8: ctx.moveTo(x, left); ctx.lineTo(bottom, y + RES); break;
        case 10: ctx.moveTo(x, left); ctx.lineTo(bottom, y + RES); ctx.moveTo(top, y); ctx.lineTo(x + RES, right); break;
      }
    }
  }
  ctx.stroke();
  requestAnimationFrame(render);
}
render();

Marching squares evaluates the field on a grid (here 120×120 = 14,400 points instead of 360,000 pixels), then interpolates the contour between grid points. The result is smooth, anti-aliased outlines that look much cleaner than the pixel approach. Decrease RES for higher fidelity, increase it for better performance.

3. Colorful organic metaballs — gradient field rendering

Instead of a binary inside/outside test, map the field value to a color gradient. This reveals the full influence field—showing how the balls interact even at a distance. The result looks like thermal imaging or a topographic map of an alien landscape.

const canvas = document.createElement('canvas');
canvas.width = 500; canvas.height = 500;
document.body.appendChild(canvas);
document.body.style.margin = '0';
document.body.style.background = '#000';
document.body.style.display = 'flex';
document.body.style.justifyContent = 'center';
document.body.style.alignItems = 'center';
document.body.style.height = '100vh';
const ctx = canvas.getContext('2d');
const W = 500, H = 500;

const balls = [];
for (let i = 0; i < 7; i++) {
  balls.push({
    x: W / 2 + Math.cos(i * 0.9) * 120,
    y: H / 2 + Math.sin(i * 0.9) * 120,
    r: 35 + Math.random() * 25,
    hue: (i * 51) % 360,
    phase: Math.random() * Math.PI * 2,
    orbitR: 80 + Math.random() * 80,
    speed: 0.3 + Math.random() * 0.4
  });
}

const img = ctx.createImageData(W, H);
const scale = 2;
const sw = Math.ceil(W / scale), sh = Math.ceil(H / scale);

function render(t) {
  for (let i = 0; i < balls.length; i++) {
    const b = balls[i];
    const angle = t * 0.001 * b.speed + b.phase;
    b.x = W / 2 + Math.cos(angle) * b.orbitR;
    b.y = H / 2 + Math.sin(angle * 0.7 + i) * b.orbitR * 0.8;
  }

  for (let py = 0; py < sh; py++) {
    for (let px = 0; px < sw; px++) {
      const x = px * scale, y = py * scale;
      let sum = 0;
      let hr = 0, hg = 0, hb = 0, totalW = 0;
      for (const b of balls) {
        const dx = x - b.x, dy = y - b.y;
        const influence = (b.r * b.r) / (dx * dx + dy * dy + 1);
        sum += influence;
        // Weight color by influence
        const h = b.hue * Math.PI / 180;
        hr += Math.cos(h) * influence;
        hg += Math.cos(h - 2.094) * influence;
        hb += Math.cos(h + 2.094) * influence;
        totalW += influence;
      }

      for (let dy2 = 0; dy2 < scale && py * scale + dy2 < H; dy2++) {
        for (let dx2 = 0; dx2 < scale && px * scale + dx2 < W; dx2++) {
          const idx = ((py * scale + dy2) * W + px * scale + dx2) * 4;
          if (sum > 0.8) {
            const brightness = Math.min(1, (sum - 0.8) * 2);
            const saturation = 0.8;
            hr /= totalW; hg /= totalW; hb /= totalW;
            img.data[idx] = Math.max(0, Math.min(255, (hr * saturation + 1 - saturation) * brightness * 255));
            img.data[idx + 1] = Math.max(0, Math.min(255, (hg * saturation + 1 - saturation) * brightness * 255));
            img.data[idx + 2] = Math.max(0, Math.min(255, (hb * saturation + 1 - saturation) * brightness * 255));
            img.data[idx + 3] = 255;
          } else {
            const glow = sum / 0.8;
            img.data[idx] = glow * 15;
            img.data[idx + 1] = glow * 8;
            img.data[idx + 2] = glow * 30;
            img.data[idx + 3] = 255;
          }
        }
      }
    }
  }
  ctx.putImageData(img, 0, 0);
  requestAnimationFrame(render);
}
requestAnimationFrame(render);

Each ball carries its own hue. When fields overlap, the colors blend proportionally to each ball’s contribution at that point. The 2x downsampling keeps it fast on mobile—rendering at 250×250 and upscaling is visually identical at normal viewing distances.

4. Interactive mouse metaballs — follow the cursor

Add a metaball that follows the mouse. The other balls orbit lazily, but whenever the mouse-ball approaches them, they merge organically. Click to spawn new balls at the cursor position.

const canvas = document.createElement('canvas');
canvas.width = 600; canvas.height = 600;
canvas.style.background = '#0a0a0a'; canvas.style.cursor = 'crosshair';
document.body.appendChild(canvas);
document.body.style.margin = '0';
document.body.style.background = '#0a0a0a';
document.body.style.display = 'flex';
document.body.style.justifyContent = 'center';
document.body.style.alignItems = 'center';
document.body.style.height = '100vh';
const ctx = canvas.getContext('2d');
const W = 600, H = 600;
const RES = 4;
const cols = Math.floor(W / RES) + 1;
const rows = Math.floor(H / RES) + 1;

let mx = W / 2, my = H / 2;
canvas.addEventListener('mousemove', e => {
  const r = canvas.getBoundingClientRect();
  mx = e.clientX - r.left; my = e.clientY - r.top;
});
canvas.addEventListener('click', () => {
  balls.push({
    x: mx, y: my, r: 30 + Math.random() * 20,
    vx: (Math.random() - 0.5) * 4, vy: (Math.random() - 0.5) * 4,
    hue: Math.random() * 360, isMouse: false
  });
});

const balls = [{ x: W / 2, y: H / 2, r: 60, vx: 0, vy: 0, hue: 180, isMouse: true }];
for (let i = 0; i < 5; i++) {
  balls.push({
    x: Math.random() * W, y: Math.random() * H,
    r: 40 + Math.random() * 25,
    vx: (Math.random() - 0.5) * 2, vy: (Math.random() - 0.5) * 2,
    hue: i * 72, isMouse: false
  });
}

function field(x, y) {
  let sum = 0;
  for (const b of balls) {
    const dx = x - b.x, dy = y - b.y;
    sum += (b.r * b.r) / (dx * dx + dy * dy + 1);
  }
  return sum;
}

function lerp(a, b, t) { return a + (b - a) * t; }

function render(t) {
  for (const b of balls) {
    if (b.isMouse) {
      b.x += (mx - b.x) * 0.15;
      b.y += (my - b.y) * 0.15;
    } else {
      b.x += b.vx; b.y += b.vy;
      if (b.x < 0 || b.x > W) b.vx *= -1;
      if (b.y < 0 || b.y > H) b.vy *= -1;
    }
  }

  const grid = new Float32Array(cols * rows);
  for (let j = 0; j < rows; j++)
    for (let i = 0; i < cols; i++)
      grid[j * cols + i] = field(i * RES, j * RES);

  ctx.clearRect(0, 0, W, H);

  // Fill blobs
  const img = ctx.createImageData(W, H);
  for (let y = 0; y < H; y += 2) {
    for (let x = 0; x < W; x += 2) {
      let sum = 0, hr = 0, hg = 0, hb = 0;
      for (const b of balls) {
        const dx = x - b.x, dy = y - b.y;
        const inf = (b.r * b.r) / (dx * dx + dy * dy + 1);
        sum += inf;
        const h = b.hue * Math.PI / 180;
        hr += (0.5 + 0.5 * Math.cos(h)) * inf;
        hg += (0.5 + 0.5 * Math.cos(h - 2.094)) * inf;
        hb += (0.5 + 0.5 * Math.cos(h + 2.094)) * inf;
      }
      if (sum > 1) {
        const bright = Math.min(1, sum * 0.6);
        const r = Math.min(255, hr / sum * bright * 400);
        const g = Math.min(255, hg / sum * bright * 400);
        const bl = Math.min(255, hb / sum * bright * 400);
        for (let dy2 = 0; dy2 < 2 && y + dy2 < H; dy2++) {
          for (let dx2 = 0; dx2 < 2 && x + dx2 < W; dx2++) {
            const idx = ((y + dy2) * W + x + dx2) * 4;
            img.data[idx] = r; img.data[idx+1] = g;
            img.data[idx+2] = bl; img.data[idx+3] = 255;
          }
        }
      }
    }
  }
  ctx.putImageData(img, 0, 0);

  // Contour outline
  ctx.strokeStyle = 'rgba(255,255,255,0.5)';
  ctx.lineWidth = 1.5;
  ctx.beginPath();
  for (let j = 0; j < rows - 1; j++) {
    for (let i = 0; i < cols - 1; i++) {
      const x = i * RES, y = j * RES;
      const a = grid[j*cols+i], b = grid[j*cols+i+1];
      const c = grid[(j+1)*cols+i+1], d = grid[(j+1)*cols+i];
      let s = 0;
      if (a >= 1) s |= 1; if (b >= 1) s |= 2;
      if (c >= 1) s |= 4; if (d >= 1) s |= 8;
      if (s === 0 || s === 15) continue;
      const tp = lerp(x, x+RES, (1-a)/(b-a));
      const rt = lerp(y, y+RES, (1-b)/(c-b));
      const bt = lerp(x, x+RES, (1-d)/(c-d));
      const lt = lerp(y, y+RES, (1-a)/(d-a));
      switch(s) {
        case 1: case 14: ctx.moveTo(x,lt); ctx.lineTo(tp,y); break;
        case 2: case 13: ctx.moveTo(tp,y); ctx.lineTo(x+RES,rt); break;
        case 3: case 12: ctx.moveTo(x,lt); ctx.lineTo(x+RES,rt); break;
        case 4: case 11: ctx.moveTo(x+RES,rt); ctx.lineTo(bt,y+RES); break;
        case 5: ctx.moveTo(x,lt); ctx.lineTo(tp,y); ctx.moveTo(x+RES,rt); ctx.lineTo(bt,y+RES); break;
        case 6: case 9: ctx.moveTo(tp,y); ctx.lineTo(bt,y+RES); break;
        case 7: case 8: ctx.moveTo(x,lt); ctx.lineTo(bt,y+RES); break;
        case 10: ctx.moveTo(x,lt); ctx.lineTo(bt,y+RES); ctx.moveTo(tp,y); ctx.lineTo(x+RES,rt); break;
      }
    }
  }
  ctx.stroke();

  ctx.fillStyle = 'rgba(255,255,255,0.5)';
  ctx.font = '12px monospace';
  ctx.fillText(balls.length + ' balls — click to add', 10, 20);
  requestAnimationFrame(render);
}
render();

The mouse ball uses smooth interpolation (0.15 blend factor) so it follows the cursor with a satisfying lag rather than snapping instantly. Each click spawns a new ball with a random velocity and hue, so you can build up complex blob ecosystems.

5. Lava lamp — heated blobs rising and falling

The classic lava lamp: warm blobs rise from the bottom, cool at the top, and sink back down. The physics is simplified but convincing—each blob has a temperature that controls buoyancy, and it heats when near the bottom, cools when near the top.

const canvas = document.createElement('canvas');
canvas.width = 300; canvas.height = 600;
canvas.style.borderRadius = '150px 150px 30px 30px';
document.body.appendChild(canvas);
document.body.style.margin = '0';
document.body.style.background = '#1a0a2e';
document.body.style.display = 'flex';
document.body.style.justifyContent = 'center';
document.body.style.alignItems = 'center';
document.body.style.height = '100vh';
const ctx = canvas.getContext('2d');
const W = 300, H = 600;

const balls = [];
for (let i = 0; i < 10; i++) {
  balls.push({
    x: W/2 + (Math.random()-0.5) * 100,
    y: H * 0.5 + Math.random() * H * 0.4,
    r: 25 + Math.random() * 30,
    vx: 0, vy: 0,
    temp: Math.random()
  });
}

const img = ctx.createImageData(W, H);
const sc = 3;
const sw = Math.ceil(W / sc), sh = Math.ceil(H / sc);

function render(t) {
  // Physics
  for (const b of balls) {
    // Heat near bottom, cool near top
    const normalY = b.y / H;
    if (normalY > 0.8) b.temp += 0.01;
    else if (normalY < 0.2) b.temp -= 0.01;
    b.temp = Math.max(0, Math.min(1, b.temp));

    // Buoyancy: hot rises, cold sinks
    const buoyancy = (b.temp - 0.5) * -0.08;
    b.vy += buoyancy;
    b.vy *= 0.98; // drag
    b.vx += (Math.sin(t * 0.001 + b.y * 0.01) * 0.02); // gentle sway
    b.vx *= 0.95;

    b.x += b.vx; b.y += b.vy;

    // Contain within lamp shape
    const margin = 30;
    if (b.x < margin) { b.x = margin; b.vx *= -0.5; }
    if (b.x > W - margin) { b.x = W - margin; b.vx *= -0.5; }
    if (b.y < margin) { b.y = margin; b.vy *= -0.5; }
    if (b.y > H - margin) { b.y = H - margin; b.vy *= -0.5; }
  }

  // Render
  for (let py = 0; py < sh; py++) {
    for (let px = 0; px < sw; px++) {
      const x = px * sc, y = py * sc;
      let sum = 0, tempSum = 0;
      for (const b of balls) {
        const dx = x - b.x, dy = y - b.y;
        const inf = (b.r * b.r) / (dx * dx + dy * dy + 1);
        sum += inf;
        tempSum += b.temp * inf;
      }

      for (let dy2 = 0; dy2 < sc && py*sc+dy2 < H; dy2++) {
        for (let dx2 = 0; dx2 < sc && px*sc+dx2 < W; dx2++) {
          const idx = ((py*sc+dy2) * W + px*sc+dx2) * 4;
          if (sum > 1) {
            const avgTemp = tempSum / sum;
            // Hot = orange/yellow, cold = red/purple
            const r2 = 200 + avgTemp * 55;
            const g = avgTemp * 180;
            const b2 = (1 - avgTemp) * 100 + 30;
            img.data[idx] = r2;
            img.data[idx+1] = g;
            img.data[idx+2] = b2;
            img.data[idx+3] = 255;
          } else {
            // Background: dark purple with subtle glow
            const glow = Math.max(0, sum * 0.5);
            img.data[idx] = 15 + glow * 40;
            img.data[idx+1] = 5 + glow * 10;
            img.data[idx+2] = 30 + glow * 30;
            img.data[idx+3] = 255;
          }
        }
      }
    }
  }
  ctx.putImageData(img, 0, 0);

  // Lamp glass overlay
  const grad = ctx.createLinearGradient(0, 0, W, 0);
  grad.addColorStop(0, 'rgba(255,255,255,0.08)');
  grad.addColorStop(0.3, 'rgba(255,255,255,0.02)');
  grad.addColorStop(1, 'rgba(0,0,0,0.1)');
  ctx.fillStyle = grad;
  ctx.fillRect(0, 0, W, H);

  requestAnimationFrame(render);
}
render();

The temperature-based coloring gives visual feedback about the physics: you can see blobs turn yellow as they heat up near the base, then gradually shift to red/purple as they cool at the top. The borderRadius CSS rounds the canvas into a lamp shape without any masking code.

6. 3D raymarched metaballs — sphere tracing in a fragment shader

Moving to 3D, we use WebGL and a fragment shader to raytrace metaballs. The SDF for a single sphere is length(p) - r. For metaballs, we use smooth minimum (smin) to blend multiple spheres. The result is glossy, lit 3D blobs that merge seamlessly.

const canvas = document.createElement('canvas');
canvas.width = 500; canvas.height = 500;
document.body.appendChild(canvas);
document.body.style.margin = '0';
document.body.style.background = '#000';
document.body.style.display = 'flex';
document.body.style.justifyContent = 'center';
document.body.style.alignItems = 'center';
document.body.style.height = '100vh';
const gl = canvas.getContext('webgl');

const vs = `attribute vec2 p; void main(){gl_Position=vec4(p,0,1);}`;
const fs = `
precision highp float;
uniform float t;
uniform vec2 r;

float smin(float a, float b, float k) {
  float h = clamp(0.5 + 0.5*(b-a)/k, 0.0, 1.0);
  return mix(b, a, h) - k*h*(1.0-h);
}

float map(vec3 p) {
  float d = 1e9;
  for (int i = 0; i < 5; i++) {
    float fi = float(i);
    vec3 c = vec3(
      sin(t*0.7 + fi*1.3) * 1.5,
      cos(t*0.5 + fi*0.9) * 1.2,
      sin(t*0.3 + fi*2.1) * 1.0
    );
    float radius = 0.6 + 0.15 * sin(t + fi * 1.7);
    d = smin(d, length(p - c) - radius, 0.8);
  }
  return d;
}

vec3 normal(vec3 p) {
  vec2 e = vec2(0.001, 0);
  return normalize(vec3(
    map(p+e.xyy)-map(p-e.xyy),
    map(p+e.yxy)-map(p-e.yxy),
    map(p+e.yyx)-map(p-e.yyx)
  ));
}

void main() {
  vec2 uv = (gl_FragCoord.xy - r * 0.5) / r.y;
  vec3 ro = vec3(0, 0, 5);
  vec3 rd = normalize(vec3(uv, -1.5));

  float d = 0.0;
  for (int i = 0; i < 80; i++) {
    vec3 p = ro + rd * d;
    float h = map(p);
    if (h < 0.001) {
      vec3 n = normal(p);
      vec3 light = normalize(vec3(2, 3, 4));
      float diff = max(dot(n, light), 0.0);
      float spec = pow(max(dot(reflect(-light, n), -rd), 0.0), 32.0);
      float fresnel = pow(1.0 - max(dot(n, -rd), 0.0), 3.0);

      vec3 col = vec3(0.1, 0.5, 0.9) * diff +
                 vec3(1.0) * spec * 0.8 +
                 vec3(0.2, 0.6, 1.0) * fresnel * 0.5;
      col += vec3(0.02, 0.05, 0.1); // ambient
      gl_FragColor = vec4(pow(col, vec3(0.45)), 1);
      return;
    }
    d += h;
    if (d > 20.0) break;
  }
  // Background gradient
  float bg = 0.03 + uv.y * 0.02;
  gl_FragColor = vec4(vec3(bg, bg * 0.8, bg * 1.5), 1);
}
`;

function compile(src, type) {
  const s = gl.createShader(type);
  gl.shaderSource(s, src);
  gl.compileShader(s);
  return s;
}
const prog = gl.createProgram();
gl.attachShader(prog, compile(vs, gl.VERTEX_SHADER));
gl.attachShader(prog, compile(fs, gl.FRAGMENT_SHADER));
gl.linkProgram(prog);
gl.useProgram(prog);

const buf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1,1,-1,-1,1,1,1]), gl.STATIC_DRAW);
const pLoc = gl.getAttribLocation(prog, 'p');
gl.enableVertexAttribArray(pLoc);
gl.vertexAttribPointer(pLoc, 2, gl.FLOAT, false, 0, 0);

const tLoc = gl.getUniformLocation(prog, 't');
const rLoc = gl.getUniformLocation(prog, 'r');

function render(time) {
  gl.uniform1f(tLoc, time * 0.001);
  gl.uniform2f(rLoc, 500, 500);
  gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
  requestAnimationFrame(render);
}
requestAnimationFrame(render);

The smin function is the secret sauce for 3D metaballs. Regular min gives a hard union between shapes; smin smoothly blends the distance fields with a parameter k that controls the merge radius. Higher k = blobier merges. The Fresnel term adds that characteristic bright rim when viewing a blob at a grazing angle.

7. Metaball text — liquid letters

Place metaballs along the path of text characters. As they settle into position, the text becomes readable. Disturb them and the letters dissolve into blobs before reforming.

const canvas = document.createElement('canvas');
canvas.width = 700; canvas.height = 300;
canvas.style.background = '#0a0a1a';
document.body.appendChild(canvas);
document.body.style.margin = '0';
document.body.style.background = '#0a0a1a';
document.body.style.display = 'flex';
document.body.style.justifyContent = 'center';
document.body.style.alignItems = 'center';
document.body.style.height = '100vh';
const ctx = canvas.getContext('2d');
const W = 700, H = 300;

// Sample text to get target positions
const tmp = document.createElement('canvas');
tmp.width = W; tmp.height = H;
const tctx = tmp.getContext('2d');
tctx.fillStyle = '#fff';
tctx.font = 'bold 120px Arial';
tctx.textAlign = 'center';
tctx.textBaseline = 'middle';
tctx.fillText('METABALL', W/2, H/2);
const textData = tctx.getImageData(0, 0, W, H).data;

// Extract points from text
const targets = [];
const step = 12;
for (let y = 0; y < H; y += step) {
  for (let x = 0; x < W; x += step) {
    if (textData[(y * W + x) * 4 + 3] > 128) {
      targets.push({ x, y });
    }
  }
}

const balls = targets.map(t => ({
  x: Math.random() * W, y: Math.random() * H,
  tx: t.x, ty: t.y,
  r: step * 0.6,
  vx: 0, vy: 0
}));

let disturb = false;
canvas.addEventListener('mousedown', () => { disturb = true; });
canvas.addEventListener('mouseup', () => { disturb = false; });
canvas.addEventListener('touchstart', () => { disturb = true; });
canvas.addEventListener('touchend', () => { disturb = false; });

const img = ctx.createImageData(W, H);
const sc = 2;

function render(t) {
  for (const b of balls) {
    if (disturb) {
      b.vx += (Math.random() - 0.5) * 3;
      b.vy += (Math.random() - 0.5) * 3;
    } else {
      // Spring toward target
      b.vx += (b.tx - b.x) * 0.03;
      b.vy += (b.ty - b.y) * 0.03;
    }
    b.vx *= 0.92; b.vy *= 0.92;
    b.x += b.vx; b.y += b.vy;
  }

  // Render metaball field
  for (let py = 0; py < H; py += sc) {
    for (let px = 0; px < W; px += sc) {
      let sum = 0;
      for (const b of balls) {
        const dx = px - b.x, dy = py - b.y;
        const dist2 = dx * dx + dy * dy;
        if (dist2 < 10000) { // skip distant balls for perf
          sum += (b.r * b.r) / (dist2 + 1);
        }
      }
      const r2 = sum > 0.7 ? Math.min(255, (sum - 0.5) * 200) : 0;
      const g = sum > 0.7 ? Math.min(255, (sum - 0.5) * 350) : sum * 20;
      const bl = sum > 0.7 ? Math.min(255, (sum - 0.5) * 500) : sum * 50;
      for (let dy2 = 0; dy2 < sc && py+dy2 < H; dy2++) {
        for (let dx2 = 0; dx2 < sc && px+dx2 < W; dx2++) {
          const idx = ((py+dy2) * W + px+dx2) * 4;
          img.data[idx] = r2;
          img.data[idx+1] = g;
          img.data[idx+2] = bl;
          img.data[idx+3] = 255;
        }
      }
    }
  }
  ctx.putImageData(img, 0, 0);

  ctx.fillStyle = 'rgba(255,255,255,0.4)';
  ctx.font = '11px monospace';
  ctx.fillText('hold mouse to disturb', 10, 20);
  requestAnimationFrame(render);
}
render();

The distance-cutoff optimization (dist2 < 10000) is crucial here. With 200+ balls, checking every ball for every pixel would be too slow. Since each small ball only influences a ~100px radius, we skip the calculation for distant balls. This takes it from unusable to smooth 60fps.

8. Generative metaball art — evolving abstract composition

The final piece combines everything: multiple layers of metaballs at different scales, each with its own color palette and motion pattern. Balls split, merge, and pulse in a never-ending organic composition. Screen-blend layering creates depth and luminosity.

const canvas = document.createElement('canvas');
canvas.width = 600; canvas.height = 600;
document.body.appendChild(canvas);
document.body.style.margin = '0';
document.body.style.background = '#000';
document.body.style.display = 'flex';
document.body.style.justifyContent = 'center';
document.body.style.alignItems = 'center';
document.body.style.height = '100vh';
const ctx = canvas.getContext('2d');
const W = 600, H = 600;

// Create layers
const layers = [
  { balls: [], hue: 210, count: 5, rMin: 60, rMax: 90, speed: 0.4, threshold: 1 },
  { balls: [], hue: 330, count: 7, rMin: 35, rMax: 55, speed: 0.7, threshold: 1 },
  { balls: [], hue: 60, count: 10, rMin: 20, rMax: 35, speed: 1.2, threshold: 1 },
];

for (const layer of layers) {
  for (let i = 0; i < layer.count; i++) {
    layer.balls.push({
      x: Math.random() * W, y: Math.random() * H,
      r: layer.rMin + Math.random() * (layer.rMax - layer.rMin),
      phase: Math.random() * Math.PI * 2,
      orbitR: 100 + Math.random() * 150,
      orbitX: W * 0.2 + Math.random() * W * 0.6,
      orbitY: H * 0.2 + Math.random() * H * 0.6,
      freqX: 0.3 + Math.random() * 0.5,
      freqY: 0.2 + Math.random() * 0.4
    });
  }
}

const offscreen = document.createElement('canvas');
offscreen.width = W; offscreen.height = H;
const octx = offscreen.getContext('2d');

function renderLayer(layer, t) {
  const sc = 3;
  const img = octx.createImageData(W, H);

  for (const b of layer.balls) {
    const speed = layer.speed;
    b.x = b.orbitX + Math.sin(t * 0.001 * speed * b.freqX + b.phase) * b.orbitR;
    b.y = b.orbitY + Math.cos(t * 0.001 * speed * b.freqY + b.phase * 1.3) * b.orbitR * 0.7;
    // Pulse radius
    b.r = b.r + Math.sin(t * 0.002 + b.phase) * 3;
  }

  for (let py = 0; py < H; py += sc) {
    for (let px = 0; px < W; px += sc) {
      let sum = 0;
      for (const b of layer.balls) {
        const dx = px - b.x, dy = py - b.y;
        sum += (b.r * b.r) / (dx * dx + dy * dy + 1);
      }
      if (sum > layer.threshold * 0.5) {
        const intensity = Math.min(1, (sum - layer.threshold * 0.5) / (layer.threshold * 1.5));
        const h = layer.hue;
        const angle = h * Math.PI / 180;
        const r2 = (0.5 + 0.5 * Math.cos(angle)) * intensity * 255;
        const g = (0.5 + 0.5 * Math.cos(angle - 2.094)) * intensity * 255;
        const bl = (0.5 + 0.5 * Math.cos(angle + 2.094)) * intensity * 255;
        for (let dy2 = 0; dy2 < sc && py+dy2 < H; dy2++) {
          for (let dx2 = 0; dx2 < sc && px+dx2 < W; dx2++) {
            const idx = ((py+dy2) * W + px+dx2) * 4;
            img.data[idx] = r2;
            img.data[idx+1] = g;
            img.data[idx+2] = bl;
            img.data[idx+3] = 255;
          }
        }
      }
    }
  }
  octx.putImageData(img, 0, 0);
}

function render(t) {
  ctx.fillStyle = '#050510';
  ctx.fillRect(0, 0, W, H);

  ctx.globalCompositeOperation = 'screen';
  for (const layer of layers) {
    octx.clearRect(0, 0, W, H);
    renderLayer(layer, t);
    ctx.drawImage(offscreen, 0, 0);
  }
  ctx.globalCompositeOperation = 'source-over';

  // Vignette
  const grad = ctx.createRadialGradient(W/2, H/2, W*0.3, W/2, H/2, W*0.7);
  grad.addColorStop(0, 'rgba(0,0,0,0)');
  grad.addColorStop(1, 'rgba(0,0,0,0.6)');
  ctx.fillStyle = grad;
  ctx.fillRect(0, 0, W, H);

  requestAnimationFrame(render);
}
requestAnimationFrame(render);

Three layers, each at a different scale, speed, and color. Blue deep-layer blobs move slowly like deep-sea creatures. Pink mid-layer blobs dance at medium speed. Yellow surface-layer blobs dart quickly like fireflies. Screen blending lets the colors add together where they overlap, creating white-hot spots where all three layers converge.

The math behind metaballs

Jim Blinn’s original 1982 formulation used an exponential falloff: f(r) = e^(-a·r²) where r is the distance from the ball center and a controls the falloff rate. The isosurface at Σf(r) = threshold gives the blobby shape.

In practice, most implementations (including all the examples above) use the simpler inverse-square falloff: f(r) = R² / (r² + ε). This is cheaper to compute (no exponential) and produces visually similar results. The ε (usually 1) prevents division by zero at the center.

For 3D rendering with raymarching, the smooth minimum function is the standard approach. There are several variants:

  • Polynomial smooth min: smin(a,b,k) = min(a,b) - h²*k*0.25 where h = max(k-|a-b|, 0)/k. Fast, but the blend radius varies.
  • Exponential smooth min: smin(a,b,k) = -log(e^(-k*a) + e^(-k*b)) / k. Mathematically elegant but expensive.
  • Power smooth min: Used in the shader example above. Best balance of quality and speed.

A brief history of metaballs

Jim Blinn introduced “blobby molecules” in his 1982 paper “A Generalization of Algebraic Surface Drawing.” He needed to visualize electron density clouds for a chemistry documentary, and existing polygon-based rendering couldn’t capture the soft, merging quality of molecular orbitals.

The technique quickly escaped chemistry. In 1986, the Wyvill brothers at the University of Calgary refined the approach with “soft objects”—using a sixth-degree polynomial falloff that’s faster to compute than Blinn’s exponential. In Japan, the technique became known as “metaballs” through the work of Nishimura et al., and that name stuck worldwide.

Metaballs became a staple of the demoscene in the 1990s. Groups like Future Crew (Second Reality, 1993) and Orange (Copperbars, various) used real-time 2D metaballs as eye-candy in their demos. The technique was so popular that “yet another metaball demo” became a meme in demo circles.

Today, metaballs appear in game engines (Unity and Unreal both have SDF-based smooth unions), scientific visualization (molecular dynamics, fluid surfaces), medical imaging (organ boundary visualization), and generative art. The core math hasn’t changed since 1982—what’s changed is that GPUs can now render them in real time at 4K resolution.

Performance tips

  • Downscale the pixel-by-pixel approach. Render at half or third resolution and upscale. Metaballs are smooth by nature, so the visual difference is negligible.
  • Use marching squares for outlines. Grid resolution of 4-6 pixels gives clean contours at a fraction of the cost of full pixel evaluation.
  • Spatial partitioning for many balls. With 50+ balls, use a grid to skip balls that are too far away. A ball with radius r only influences pixels within about 3r distance.
  • WebGL for 3D. The raymarching shader approach scales perfectly on GPU. Adding more balls just means more distance evaluations in the loop—the GPU parallelizes across pixels automatically.
  • Smooth min parameter tuning. In 3D, the k parameter in smin controls the merge radius. Start with k = 0.5 and increase for blobier merges. Too high and the shapes lose definition; too low and they don’t merge at all.

Explore more generative art on Lumitree, where every branch is a unique micro-world built from code. For related topics, see the fluid simulation guide, the Perlin noise tutorial, or the procedural generation deep-dive.

Related articles