🌊 Interactive Metaballs Tutorial

Learn to code organic, blob-like animations from scratch

Click to add metaballs

What Are Metaballs?

Metaballs are organic-looking shapes that merge and blend together smoothly when they come close to each other. They're created using mathematical field equations and are commonly seen in lava lamps, liquid simulations, and game effects.

🧠 Core Concept

Each metaball creates an invisible "field" of influence around it. When multiple fields overlap, they add together. Where the combined field strength exceeds a threshold, we draw a pixel. This creates the smooth blending effect.

Step 1: Setting Up the Canvas

1 Create the HTML structure

Download the fullscreen HTML+JS file here: metaballs.htm or follow the steps below.

First, we need a canvas element to draw on:

<canvas id="canvas"></canvas>

<script>
    const canvas = document.getElementById('canvas');
    const ctx = canvas.getContext('2d');
    
    // Make canvas fill the viewport
    function resize() {
        canvas.width = window.innerWidth;
        canvas.height = window.innerHeight;
    }
    
    resize();
    window.addEventListener('resize', resize);
</script>

Step 2: Creating the Metaball Class

2 Define metaball properties and behavior

Each metaball needs position, velocity, and size:

class Metaball {
    constructor(x, y, vx, vy, radius) {
        this.x = x;        // X position
        this.y = y;        // Y position
        this.vx = vx;      // X velocity
        this.vy = vy;      // Y velocity
        this.radius = radius;  // Size
    }
    
    update() {
        // Move the ball
        this.x += this.vx;
        this.y += this.vy;
        
        // Bounce off walls
        if (this.x - this.radius < 0 || this.x + this.radius > canvas.width) {
            this.vx *= -1;
        }
        if (this.y - this.radius < 0 || this.y + this.radius > canvas.height) {
            this.vy *= -1;
        }
    }
}

Step 3: The Metaball Formula

3 Calculate the field strength at any point

This is the mathematical heart of metaballs. For each pixel, we calculate how much each ball influences it:

field_strength = (radius²) / (distance²)

The closer a point is to a ball's center, the stronger the influence. We sum up all influences:

function getMetaballValue(x, y) {
    let sum = 0;
    
    // Add influence from each ball
    for (const ball of balls) {
        // Calculate distance squared (faster than sqrt)
        const dx = x - ball.x;
        const dy = y - ball.y;
        const distSq = dx * dx + dy * dy;
        
        // Add this ball's influence
        sum += (ball.radius * ball.radius) / distSq;
    }
    
    return sum;
}

💡 Why Distance Squared?

We use distance squared instead of actual distance because it's faster to calculate (no square root needed) and still gives us the smooth falloff we want. The inverse square relationship creates a natural-looking field.

Step 4: Rendering the Metaballs

4 Convert field values to pixels

We check every pixel and color it based on the field strength:

function render() {
    const imageData = ctx.createImageData(canvas.width, canvas.height);
    const data = imageData.data;
    const step = 3; // Check every 3 pixels for performance
    
    for (let y = 0; y < canvas.height; y += step) {
        for (let x = 0; x < canvas.width; x += step) {
            const value = getMetaballValue(x, y);
            
            // If field is strong enough, draw a pixel
            if (value > 1) {
                // Color based on intensity
                const intensity = Math.min(value / 3, 1);
                const r = Math.floor(100 + intensity * 155);
                const g = Math.floor(50 + intensity * 100);
                const b = Math.floor(200 + intensity * 55);
                
                // Fill the step x step block
                for (let dy = 0; dy < step; dy++) {
                    for (let dx = 0; dx < step; dx++) {
                        const idx = ((y + dy) * canvas.width + (x + dx)) * 4;
                        data[idx] = r;
                        data[idx + 1] = g;
                        data[idx + 2] = b;
                        data[idx + 3] = 255; // Alpha
                    }
                }
            }
        }
    }
    
    ctx.putImageData(imageData, 0, 0);
}
Performance Tip: We use a step variable to skip pixels. Checking every pixel (step=1) looks smoother but is slower. A step of 2-4 balances quality and performance.

Step 5: Adding Interactivity

5 Track mouse/touch position

Create an invisible metaball that follows the cursor:

const mouseInfluence = {
    x: -1000,
    y: -1000,
    radius: 80,
    active: false
};

canvas.addEventListener('mousemove', (e) => {
    const rect = canvas.getBoundingClientRect();
    mouseInfluence.x = e.clientX - rect.left;
    mouseInfluence.y = e.clientY - rect.top;
    mouseInfluence.active = true;
});

canvas.addEventListener('mouseleave', () => {
    mouseInfluence.active = false;
});

Then include it in the field calculation:

function getMetaballValue(x, y) {
    let sum = 0;
    
    for (const ball of balls) {
        const dx = x - ball.x;
        const dy = y - ball.y;
        const distSq = dx * dx + dy * dy;
        sum += (ball.radius * ball.radius) / distSq;
    }
    
    // Add mouse influence
    if (mouseInfluence.active) {
        const dx = x - mouseInfluence.x;
        const dy = y - mouseInfluence.y;
        const distSq = dx * dx + dy * dy;
        sum += (mouseInfluence.radius * mouseInfluence.radius) / distSq;
    }
    
    return sum;
}

Step 6: Animation Loop

6 Bring it all together
function animate() {
    // Clear canvas
    ctx.fillStyle = '#0a0a0a';
    ctx.fillRect(0, 0, canvas.width, canvas.height);
    
    // Update all balls
    for (const ball of balls) {
        ball.update();
    }
    
    // Render metaballs
    render();
    
    // Loop
    requestAnimationFrame(animate);
}

animate();

Touch Support

Add touch event handlers for mobile devices:

canvas.addEventListener('touchmove', (e) => {
    e.preventDefault();
    const touch = e.touches[0];
    const rect = canvas.getBoundingClientRect();
    mouseInfluence.x = touch.clientX - rect.left;
    mouseInfluence.y = touch.clientY - rect.top;
    mouseInfluence.active = true;
});

canvas.addEventListener('click', (e) => {
    const rect = canvas.getBoundingClientRect();
    balls.push(new Metaball(
        e.clientX - rect.left,
        e.clientY - rect.top,
        (Math.random() - 0.5) * 3,
        (Math.random() - 0.5) * 3,
        30 + Math.random() * 40
    ));
});

Experiment and Extend

Now that you understand the basics, try these variations:

More JS tutorials

Spinning squares - visual effect (25 lines)

Oldschool fire effect (20 lines)

Fireworks (60 lines)

Animated fractal (32 lines)

Physics engine for beginners

Physics engine - interactive sandbox

Physics engine - silly contraption

Starfield (21 lines)

Yin Yang with a twist (4 circles and 20 lines)

Tile map editor (70 lines)

Sine scroller (30 lines)

Interactive animated sprites

Image transition effect (16 lines)