AI Metaball Lava Lamp - Hypnotic Blob Fusion Watch colorful blobs float and merge like a real lava

This sketch renders a hypnotic lava-lamp effect where 6 softly glowing blobs drift around the screen and smoothly melt into one another whenever they get close. It uses a classic 'metaball' field algorithm - summing the influence of each blob at every pixel - to decide where blob surfaces appear and how their orange-to-magenta color blends.

🧪 Try This!

Experiment with the code by making these changes:

  1. Change the color palette — startColor and endColor control the gradient every blob uses - swap them for a cool blue-to-green lava lamp instead of orange-magenta.
  2. Brighten the background — bgColor is the near-black color painted outside every blob - lightening it changes the whole mood of the scene.
  3. Add more blobs — NUM_METABALLS controls how many independent blobs are created and animated - raising it makes the lava lamp feel busier and more crowded.
Prefer the full editor? Open it there →

📖 About This Sketch

This sketch creates a mesmerizing lava-lamp animation: six softly glowing blobs float around an offscreen buffer, orbiting in slow sine-and-cosine loops, and whenever two blobs get close their edges dissolve into each other instead of simply overlapping. The melting effect isn't done with shapes or blending modes - it's calculated pixel by pixel using a classic 'metaball' scalar field, then colored with a smooth orange-to-magenta gradient and a soft, glowing edge.

The code is split into small, focused functions: setup() prepares the canvas, initField()/initMetaballs() build a low-resolution offscreen graphics buffer and randomize each blob's size, orbit radius and speed, draw() clears the screen every frame, and drawMetaballs() does the real work - computing the field, writing raw pixel colors, and scaling the tiny buffer up to fill the window. Studying it teaches you how to manipulate pixels directly with loadPixels()/updatePixels(), how trigonometric functions create smooth looping motion, and how a simple physics-inspired formula (inverse-square falloff) can produce organic, liquid-looking shapes.

⚙️ How It Works

  1. When the sketch loads, setup() creates a full-window canvas and calls initField(), which builds a much smaller offscreen graphics buffer (about half the window's resolution) for fast pixel math.
  2. initMetaballs() then gives each of the 6 blobs a random radius, orbit size (amplitude), orbit speed, and starting phase so every blob moves independently.
  3. Every frame, draw() paints the dark background and calls drawMetaballs(), which first calculates each blob's current center position using sin() and cos() driven by frameCount, so the blobs continuously drift in looping paths.
  4. For every pixel in the low-res buffer, the code sums up each blob's 'field strength' (radius squared divided by distance squared) - the closer a pixel is to a blob's center, the stronger that blob's pull on the total.
  5. If the summed field is below a threshold (isoLevel) the pixel stays background-dark; if it's above, the pixel is colored using a gradient from orange to magenta based on how strong the field is, with a soft alpha fade at the boundary so edges glow instead of looking jagged.
  6. Finally the tiny buffer is stretched up to fill the whole canvas with image(), which blurs the low-res pixels into the smooth, organic look, and windowResized() rebuilds everything if the browser window changes size.

🎓 Concepts You'll Learn

Offscreen graphics buffer (createGraphics)Direct pixel manipulation (loadPixels/updatePixels)Metaball scalar field algorithmColor interpolation between two hex/RGB colorsTrigonometric motion (sin/cos for orbiting)Responsive canvas resizing (windowResized)

📝 Code Breakdown

setup()

setup() runs once when the sketch starts. Here it wires together the canvas and the two initialization helpers, keeping the startup logic short and readable.

function setup() {
  createCanvas(windowWidth, windowHeight);
  pixelDensity(1); // Keep pixel math simple & fast
  initField();
}
Line-by-line explanation (3 lines)
createCanvas(windowWidth, windowHeight);
Creates a canvas that fills the entire browser window, using the browser's current width and height.
pixelDensity(1);
Forces the canvas to use exactly one pixel per CSS pixel (ignoring Retina/HiDPI scaling), which keeps the pixel-array math in drawMetaballs() simple and predictable.
initField();
Calls the setup helper that builds the offscreen low-resolution buffer and creates the metaball objects.

initField()

This function demonstrates the 'render small, scale up' technique: doing expensive per-pixel work on a small offscreen buffer and stretching the result with image() is a common way to fake high resolution effects without the full performance cost.

function initField() {
  const gw = max(160, floor(width  / 2)); // lower res = faster, still smooth
  const gh = max(120, floor(height / 2));

  fieldGraphics = createGraphics(gw, gh); // https://p5js.org/reference/#/p5/createGraphics
  fieldGraphics.pixelDensity(1);

  initMetaballs();
}
Line-by-line explanation (5 lines)
const gw = max(160, floor(width / 2));
Calculates the offscreen buffer's width as half the canvas width, but never smaller than 160 pixels - this keeps performance good even on tiny windows.
const gh = max(120, floor(height / 2));
Same idea for height, with a 120 pixel minimum.
fieldGraphics = createGraphics(gw, gh);
Creates a separate, smaller drawing surface (an offscreen p5.Graphics object) that we compute the metaball field on, instead of the full-size canvas - this is what keeps the per-pixel math fast.
fieldGraphics.pixelDensity(1);
Makes sure this offscreen buffer also uses a 1:1 pixel density, matching the main canvas's setting.
initMetaballs();
Now that the buffer's size is known, this creates the blob objects sized relative to that buffer.

initMetaballs()

This function shows a common pattern in generative art: give every object randomized parameters within a controlled range, so the overall composition feels alive and non-repetitive even though the underlying logic is identical for each item.

🔬 This loop randomizes each blob's radius and orbit range. What happens if you shrink the amplitude ranges (e.g. random(0.05, 0.10)) so blobs barely move from the center?

  for (let i = 0; i < NUM_METABALLS; i++) {
    const radius     = random(0.18, 0.28) * base;
    const amplitudeX = random(0.20, 0.45) * gw;
    const amplitudeY = random(0.20, 0.45) * gh;
function initMetaballs() {
  metaballs = [];
  const gw = fieldGraphics.width;
  const gh = fieldGraphics.height;
  const base = min(gw, gh);

  for (let i = 0; i < NUM_METABALLS; i++) {
    const radius     = random(0.18, 0.28) * base;
    const amplitudeX = random(0.20, 0.45) * gw;
    const amplitudeY = random(0.20, 0.45) * gh;
    const speedX     = random(0.15, 0.40); // different speeds per axis
    const speedY     = random(0.15, 0.40);
    const phase      = random(TWO_PI);

    metaballs.push({
      radius,
      amplitudeX,
      amplitudeY,
      speedX,
      speedY,
      phase
    });
  }
}
Line-by-line explanation (7 lines)

🔧 Subcomponents:

for-loop Blob Creation Loop for (let i = 0; i < NUM_METABALLS; i++) {

Runs once per blob, giving each one randomized size, orbit dimensions, speed, and starting phase before pushing it into the metaballs array.

metaballs = [];
Empties any previous blob data so we start fresh (important when the window is resized and this function runs again).
const base = min(gw, gh);
Uses the smaller of the buffer's width/height as a reference size, so blob radii scale sensibly whether the window is wide or tall.
const radius = random(0.18, 0.28) * base;
Picks a random radius between 18% and 28% of the base size - this controls how big each blob's influence is.
const amplitudeX = random(0.20, 0.45) * gw;
Sets how far this blob can drift horizontally from the center, as a random fraction of the buffer width.
const speedX = random(0.15, 0.40); // different speeds per axis
Gives the blob a random horizontal orbit speed, different from its vertical speed, so its path looks organic rather than a perfect circle.
const phase = random(TWO_PI);
Offsets where in its sine/cosine cycle this blob starts, so all 6 blobs don't move in sync.
metaballs.push({ radius, amplitudeX, amplitudeY, speedX, speedY, phase });
Stores all of this blob's random properties as one object in the metaballs array, using ES6 shorthand (radius means radius: radius).

draw()

draw() is intentionally tiny here - it delegates all the real work to drawMetaballs(), which keeps the animation loop easy to read at a glance.

function draw() {
  background(bgColor[0], bgColor[1], bgColor[2]);
  drawMetaballs();
}
Line-by-line explanation (2 lines)
background(bgColor[0], bgColor[1], bgColor[2]);
Clears the canvas each frame using the dark bgColor array's red, green, and blue values.
drawMetaballs();
Calls the main function that computes the metaball field and draws all the blobs for this frame.

drawMetaballs()

This function is the mathematical core of the whole sketch: the metaball field formula (sum of radius²/distance²) is a well-known technique from computer graphics for creating organic, liquid-like shapes that merge smoothly. Combined with direct pixel manipulation via loadPixels()/updatePixels(), it shows how you can build effects that would be very hard to achieve with p5's regular shape-drawing functions.

🔬 This check decides whether a pixel is inside or outside a blob. What happens visually if you lower isoLevel near the top of the function to something like 0.3? Try predicting whether blobs get bigger or smaller before you test it.

      if (field <= isoLevel) {
        // Outside blobs: dark background
        pixels[idx + 0] = bgColor[0];
        pixels[idx + 1] = bgColor[1];
        pixels[idx + 2] = bgColor[2];
        pixels[idx + 3] = 255;
      } else {

🔬 This is the heart of the metaball formula - field strength falls off with the square of distance. What do you think happens if you change distSq to just dx*dx+dy*dy without squaring again (i.e. use distance instead of distance-squared)? Try dividing by an extra sqrt(distSq) to see softer, wider blobs.

        const dx = x - centersX[i];
        const dy = y - centersY[i];
        let distSq = dx * dx + dy * dy;
        if (distSq < 0.0001) distSq = 0.0001; // avoid division by zero
        field += strengths[i] / distSq;
function drawMetaballs() {
  const gw = fieldGraphics.width;
  const gh = fieldGraphics.height;

  const isoLevel = 1.0;  // threshold where the blob "surface" appears
  const falloff  = 3.0;  // how quickly color moves from orange to magenta

  const n = metaballs.length;
  const centersX = new Array(n);
  const centersY = new Array(n);
  const strengths = new Array(n);

  const t = frameCount * 0.02; // time for smooth oscillation

  // Precompute metaball centers and strengths this frame
  for (let i = 0; i < n; i++) {
    const m = metaballs[i];

    centersX[i] = gw / 2 + m.amplitudeX * sin(t * m.speedX + m.phase);
    centersY[i] = gh / 2 + m.amplitudeY * cos(t * m.speedY + m.phase * 1.37);

    // Classic metaball field: strength = radius^2
    strengths[i] = m.radius * m.radius;
  }

  fieldGraphics.loadPixels(); // https://p5js.org/reference/#/p5/loadPixels
  const pixels = fieldGraphics.pixels;

  for (let y = 0; y < gh; y++) {
    for (let x = 0; x < gw; x++) {
      let field = 0;

      // Accumulate influence from each metaball: Σ (r^2 / d^2)
      for (let i = 0; i < n; i++) {
        const dx = x - centersX[i];
        const dy = y - centersY[i];
        let distSq = dx * dx + dy * dy;
        if (distSq < 0.0001) distSq = 0.0001; // avoid division by zero
        field += strengths[i] / distSq;
      }

      const idx = 4 * (y * gw + x);

      if (field <= isoLevel) {
        // Outside blobs: dark background
        pixels[idx + 0] = bgColor[0];
        pixels[idx + 1] = bgColor[1];
        pixels[idx + 2] = bgColor[2];
        pixels[idx + 3] = 255;
      } else {
        // Inside blob: smooth gradient + soft edge
        // u controls color along orange -> magenta
        const u = constrain((field - isoLevel) / falloff, 0, 1);

        // alpha controls the softness of the blob edge
        const edgeWidth = 0.8;
        const alpha = constrain((field - isoLevel) / edgeWidth, 0, 1);

        const r = startColor[0] + (endColor[0] - startColor[0]) * u;
        const g = startColor[1] + (endColor[1] - startColor[1]) * u;
        const b = startColor[2] + (endColor[2] - startColor[2]) * u;

        pixels[idx + 0] = r;
        pixels[idx + 1] = g;
        pixels[idx + 2] = b;
        pixels[idx + 3] = 255 * alpha;
      }
    }
  }

  fieldGraphics.updatePixels(); // https://p5js.org/reference/#/p5/updatePixels

  // Scale the low-res field up to full canvas for a smooth, hypnotic look
  image(fieldGraphics, 0, 0, width, height); // https://p5js.org/reference/#/p5/image
}
Line-by-line explanation (14 lines)

🔧 Subcomponents:

for-loop Precompute Blob Centers for (let i = 0; i < n; i++) {

Calculates every blob's current x/y position (using sin/cos oscillation) and its field strength once per frame, before the expensive pixel loop starts.

for-loop Pixel Row Loop for (let y = 0; y < gh; y++) {

Iterates over every row of the offscreen buffer.

for-loop Pixel Column Loop for (let x = 0; x < gw; x++) {

Iterates over every column within the current row, so together with the row loop it visits every single pixel in the buffer.

for-loop Metaball Field Accumulation for (let i = 0; i < n; i++) {

For the current pixel, sums up how strongly each of the 6 blobs 'pulls' on it based on inverse-square distance, producing the total metaball field value.

conditional Inside/Outside Blob Check if (field <= isoLevel) {

Decides whether this pixel is outside any blob (paint background) or inside a blob (paint the orange-to-magenta gradient with a soft edge).

const t = frameCount * 0.02; // time for smooth oscillation
Converts the ever-increasing frameCount into a slow-moving 'time' value used to drive the sine/cosine motion - a bigger multiplier speeds up the animation.
centersX[i] = gw / 2 + m.amplitudeX * sin(t * m.speedX + m.phase);
Places this blob's horizontal center around the buffer's midpoint, oscillating left/right using a sine wave whose speed and phase are unique to this blob.
strengths[i] = m.radius * m.radius;
Pre-squares each blob's radius so the pixel loop can reuse this value instead of recalculating it for every single pixel - a small but meaningful performance optimization.
fieldGraphics.loadPixels(); // https://p5js.org/reference/#/p5/loadPixels
Loads the buffer's raw pixel color data into a JavaScript array (pixels) so we can read and write individual pixel colors directly, which is much faster than calling point() thousands of times.
let distSq = dx * dx + dy * dy;
Calculates the squared distance from this pixel to the blob's center (skipping the square root, since we only need relative comparisons, which is faster).
if (distSq < 0.0001) distSq = 0.0001; // avoid division by zero
Prevents a divide-by-zero error (which would produce Infinity) if a pixel happens to sit exactly on a blob's center.
field += strengths[i] / distSq;
Adds this blob's contribution to the total field - the formula (radius² / distance²) means influence fades off quickly with distance, which is what creates the smooth merging effect when two blobs get close.
const idx = 4 * (y * gw + x);
Calculates the starting index for this pixel's color data in the flat pixels array, since each pixel takes up 4 consecutive slots (red, green, blue, alpha).
if (field <= isoLevel) {
If the accumulated field is weak (below the threshold), this pixel is considered outside all blobs, so it gets painted as plain background.
const u = constrain((field - isoLevel) / falloff, 0, 1);
Converts the field strength into a 0-to-1 value representing 'how deep inside a blob' this pixel is, used to blend from orange (0) to magenta (1).
const alpha = constrain((field - isoLevel) / edgeWidth, 0, 1);
Creates a second, narrower 0-to-1 ramp just for transparency, so pixels right at a blob's boundary fade in softly instead of appearing with a hard edge.
const r = startColor[0] + (endColor[0] - startColor[0]) * u;
Linearly interpolates the red channel between the orange startColor and magenta endColor based on u - this is manual color lerping done channel by channel.
fieldGraphics.updatePixels(); // https://p5js.org/reference/#/p5/updatePixels
Pushes all the pixel changes we made in the array back onto the actual offscreen graphics buffer, making them visible.
image(fieldGraphics, 0, 0, width, height); // https://p5js.org/reference/#/p5/image
Draws the small offscreen buffer onto the main canvas, stretching it to fill the full window width/height - this scaling is what gives the low-res field its soft, blurry lava-lamp look.

windowResized()

windowResized() is a special p5.js function that's automatically called whenever the browser window changes size, letting you keep responsive sketches looking correct at any screen size.

function windowResized() {
  resizeCanvas(windowWidth, windowHeight);
  initField(); // rebuild buffer & metaballs for new size
}
Line-by-line explanation (2 lines)
resizeCanvas(windowWidth, windowHeight);
Resizes the main canvas to match the browser window's new dimensions whenever it changes (e.g. the user resizes their browser or rotates their device).
initField(); // rebuild buffer & metaballs for new size
Rebuilds the offscreen buffer at the new appropriate size and re-randomizes all the metaballs so they fit proportionally within the new canvas.

📦 Key Variables

fieldGraphics object

Holds the offscreen p5.Graphics buffer where the metaball field is computed at a lower resolution before being scaled up to the full canvas.

let fieldGraphics;
NUM_METABALLS number

Constant that sets how many blobs exist in the scene.

const NUM_METABALLS = 6;
metaballs array

Array of blob objects, each storing a radius, orbit amplitude, orbit speed, and phase used to animate its position every frame.

let metaballs = [];
bgColor array

RGB values for the dark background color painted outside all blobs.

const bgColor = [4, 2, 20];
startColor array

RGB values for the orange color used at a blob's outer edge (low field strength).

const startColor = [255, 170, 0];
endColor array

RGB values for the magenta color used deep inside a blob (high field strength).

const endColor = [255, 0, 200];

🔧 Potential Improvements (4)

Here are some ways this code could be enhanced:

BUG windowResized()

Calling initField() on every resize fully re-randomizes every metaball's radius, orbit, speed, and phase, so blobs abruptly jump to new sizes and positions instead of smoothly adapting to the new canvas size.

💡 Store each metaball's parameters as fractions of the buffer's width/height (already partially done via amplitudeX/amplitudeY) and only rebuild fieldGraphics on resize, reusing the existing metaballs array scaled to the new dimensions instead of calling initMetaballs() again.

PERFORMANCE drawMetaballs()

The triple-nested loop (rows × columns × metaballs) recomputes dx/dy/distSq for every pixel/blob pair every single frame, which is the main performance bottleneck, especially if NUM_METABALLS or the buffer resolution is increased.

💡 Consider skipping pixels far outside a blob's bounding box (early-exit checks), or moving this computation to a GLSL shader using WEBGL mode, which can run the same math per-pixel on the GPU far faster than JavaScript's CPU loop.

STYLE initMetaballs()

The random ranges for radius, amplitudeX/Y, and speedX/Y are magic numbers written directly inline (e.g. 0.18, 0.28, 0.20, 0.45), making them harder to find and tune later.

💡 Extract them into named constants near the top of the file, such as const RADIUS_MIN = 0.18, RADIUS_MAX = 0.28, so future tweaks don't require hunting through function bodies.

FEATURE drawMetaballs() / mousePressed

The blobs currently move on a fixed, purely automatic path with no way for a viewer to interact with them.

💡 Add a mousePressed() or mouseMoved() handler that temporarily adds an extra 'repulsive' or 'attractive' metaball at the mouse position, letting users poke and reshape the lava in real time.

🔄 Code Flow

Code flow showing setup, initfield, initmetaballs, draw, drawmetaballs, windowresized

💡 Click on function names in the diagram to jump to their code

graph TD start[Start] --> setup[setup] setup --> initfield[initfield] setup --> initmetaballs[initmetaballs] setup --> draw[draw loop] draw --> drawmetaballs[drawmetaballs] drawmetaballs --> precompute[Precompute Centers Loop] precompute --> metaballloop[Blob Creation Loop] metaballloop --> pixelrow[Pixel Row Loop] pixelrow --> pixelcolumn[Pixel Column Loop] pixelcolumn --> fieldaccumulation[Field Accumulation Loop] fieldaccumulation --> insidecheck[Inside/Outside Blob Check] insidecheck --> draw draw --> windowresized[windowResized] click setup href "#fn-setup" click initfield href "#fn-initfield" click initmetaballs href "#fn-initmetaballs" click draw href "#fn-draw" click drawmetaballs href "#fn-drawmetaballs" click precompute href "#sub-precompute-centers-loop" click metaballloop href "#sub-metaball-creation-loop" click pixelrow href "#sub-pixel-row-loop" click pixelcolumn href "#sub-pixel-column-loop" click fieldaccumulation href "#sub-field-accumulation-loop" click insidecheck href "#sub-inside-outside-check"

❓ Frequently Asked Questions

What visual effects can I expect from the AI Metaball Lava Lamp sketch?

This sketch creates a mesmerizing visual display of colorful blobs that float and merge together, resembling the fluid motion of a lava lamp.

Is the AI Metaball Lava Lamp interactive for users?

The current version of the sketch is not interactive; it automatically animates the blobs without user input.

What coding technique does the AI Metaball Lava Lamp showcase?

The sketch demonstrates the use of distance-based fields to create smooth, merging blobs, a popular technique in generative and creative coding.

Preview

AI Metaball Lava Lamp - Hypnotic Blob Fusion Watch colorful blobs float and merge like a real lava - p5.js creative coding sketch preview
Sketch Preview
Code flow diagram showing the structure of AI Metaball Lava Lamp - Hypnotic Blob Fusion Watch colorful blobs float and merge like a real lava - Code flow showing setup, initfield, initmetaballs, draw, drawmetaballs, windowresized
Code Flow Diagram