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

This sketch creates a hypnotic lava lamp effect with 6 colorful metaballs that float and smoothly merge together. Using a distance-based field algorithm rendered to a low-resolution buffer, the blobs oscillate in organic patterns while transitioning from orange to magenta colors, creating a mesmerizing, fluid visual.

🎓 Concepts You'll Learn

Metaball algorithmDistance fieldPixel manipulationOffscreen graphics bufferColor interpolationOscillation and animationPerformance optimizationAlpha blendingTrigonometric motion

🔄 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 --> clear[Clear Canvas] draw --> drawmetaballs[drawmetaballs] drawmetaballs --> pixelloop[pixel-loop] pixelloop --> fieldaccumulation[field-accumulation] fieldaccumulation --> colordecision[color-decision] colordecision --> drawmetaballs 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 windowresized href "#fn-windowresized" click pixelloop href "#sub-pixel-loop" click fieldaccumulation href "#sub-field-accumulation" click colordecision href "#sub-color-decision" initfield --> bufferdimensions[buffer-dimensions] bufferdimensions --> createbuffer[create-buffer] createbuffer --> initfield initmetaballs --> metaballoop[metaball-loop] metaballoop --> precomputecenters[precompute-centers] precomputecenters --> initmetaballs

📝 Code Breakdown

setup()

setup() runs once when the sketch starts. Here we initialize the canvas, optimize pixel rendering, and prepare the metaball system for animation.

function setup() {
  createCanvas(windowWidth, windowHeight);
  pixelDensity(1); // Keep pixel math simple & fast
  initField();
}

Line by Line:

createCanvas(windowWidth, windowHeight)
Creates a full-screen canvas that matches the browser window size
pixelDensity(1)
Sets pixel density to 1 for consistent pixel math and better performance on high-DPI screens
initField()
Calls the initialization function to set up the offscreen buffer and metaballs

initField()

This function creates a lower-resolution offscreen buffer (createGraphics) to compute the metaball field efficiently. The field is then scaled up to full screen, creating a smooth hypnotic effect while maintaining performance. This technique is called 'downsampling' and is commonly used in real-time graphics.

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();
}

🔧 Subcomponents:

calculation Low-Resolution Buffer Sizing const gw = max(160, floor(width / 2)); const gh = max(120, floor(height / 2));

Calculates buffer dimensions at half the canvas resolution for performance while maintaining smoothness

function-call Create Offscreen Graphics fieldGraphics = createGraphics(gw, gh);

Creates an offscreen buffer where the metaball field is computed before being scaled up

Line by Line:

const gw = max(160, floor(width / 2))
Calculates buffer width as half the canvas width, with a minimum of 160 pixels for quality
const gh = max(120, floor(height / 2))
Calculates buffer height as half the canvas height, with a minimum of 120 pixels for quality
fieldGraphics = createGraphics(gw, gh)
Creates an offscreen graphics buffer at the reduced resolution for faster computation
fieldGraphics.pixelDensity(1)
Ensures the offscreen buffer uses consistent pixel density matching the main canvas
initMetaballs()
Initializes the metaball objects with random parameters for this frame

initMetaballs()

Each metaball is an object storing motion parameters. By giving each metaball different speeds and phases, they move independently, creating organic, non-repetitive motion. The random() function ensures variety each time the sketch runs or resizes.

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
    });
  }
}

🔧 Subcomponents:

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

Iterates 6 times to create each metaball with unique random parameters

Line by Line:

metaballs = []
Clears the metaballs array to start fresh
const gw = fieldGraphics.width
Gets the width of the offscreen buffer for use in calculations
const gh = fieldGraphics.height
Gets the height of the offscreen buffer for use in calculations
const base = min(gw, gh)
Uses the smaller dimension as a base for scaling metaball sizes proportionally
const radius = random(0.18, 0.28) * base
Generates a random radius between 18-28% of the buffer's smaller dimension
const amplitudeX = random(0.20, 0.45) * gw
Sets how far the metaball oscillates horizontally (20-45% of buffer width)
const amplitudeY = random(0.20, 0.45) * gh
Sets how far the metaball oscillates vertically (20-45% of buffer height)
const speedX = random(0.15, 0.40)
Controls how fast the metaball moves along the X axis
const speedY = random(0.15, 0.40)
Controls how fast the metaball moves along the Y axis (can differ from speedX)
const phase = random(TWO_PI)
Sets a random starting phase (0 to 2π) so metaballs don't move in sync
metaballs.push({...})
Adds the new metaball object with all its parameters to the metaballs array

draw()

draw() runs 60 times per second (default frame rate). Each frame, we clear the canvas with the background color, then render the updated metaball animation.

function draw() {
  background(bgColor[0], bgColor[1], bgColor[2]);
  drawMetaballs();
}

Line by Line:

background(bgColor[0], bgColor[1], bgColor[2])
Fills the canvas with the dark background color (4, 2, 20) - a very dark blue
drawMetaballs()
Calls the function that computes and renders the metaball field

drawMetaballs()

This is the core of the metaball algorithm. For each pixel, we calculate how much 'influence' all metaballs have on it using the inverse-square law (field = Σ r²/d²). If the total influence exceeds the threshold (isoLevel), the pixel is 'inside' a blob and gets colored. The color smoothly transitions from orange to magenta based on depth, and edges fade out using alpha transparency. By computing on a low-res buffer and scaling up, we get smooth results with good performance.

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
}

🔧 Subcomponents:

for-loop Precompute Metaball Centers for (let i = 0; i < n; i++) { centersX[i] = gw / 2 + m.amplitudeX * sin(...); ... }

Calculates the current position of each metaball using sine/cosine oscillation

nested-for-loop Field Computation Loop for (let y = 0; y < gh; y++) { for (let x = 0; x < gw; x++) { ... } }

Iterates through every pixel in the buffer to compute the metaball field value

for-loop Field Accumulation for (let i = 0; i < n; i++) { ... field += strengths[i] / distSq; }

Sums the influence of all metaballs on the current pixel

conditional Inside/Outside Blob Decision if (field <= isoLevel) { ... } else { ... }

Determines if pixel is outside (dark) or inside (colored) the blob surface

Line by Line:

const gw = fieldGraphics.width
Gets the width of the offscreen buffer
const gh = fieldGraphics.height
Gets the height of the offscreen buffer
const isoLevel = 1.0
Sets the threshold value - pixels with field value > 1.0 are considered 'inside' the blob
const falloff = 3.0
Controls how quickly the color transitions from orange to magenta as you move deeper into the blob
const n = metaballs.length
Stores the number of metaballs (6) for use in loops
const centersX = new Array(n)
Creates an array to store the current X position of each metaball this frame
const centersY = new Array(n)
Creates an array to store the current Y position of each metaball this frame
const strengths = new Array(n)
Creates an array to store the field strength (radius²) of each metaball
const t = frameCount * 0.02
Calculates a time value that increases each frame, used for smooth oscillation
centersX[i] = gw / 2 + m.amplitudeX * sin(t * m.speedX + m.phase)
Positions metaball i horizontally using sine wave oscillation around the center
centersY[i] = gh / 2 + m.amplitudeY * cos(t * m.speedY + m.phase * 1.37)
Positions metaball i vertically using cosine wave oscillation; the 1.37 multiplier adds variation
strengths[i] = m.radius * m.radius
Stores the squared radius as the field strength (classic metaball formula)
fieldGraphics.loadPixels()
Loads the pixel array of the offscreen buffer so we can modify individual pixels
const pixels = fieldGraphics.pixels
Gets a reference to the pixel array for faster access
for (let y = 0; y < gh; y++) { for (let x = 0; x < gw; x++) {
Nested loops that iterate through every pixel in the buffer (width × height)
let field = 0
Initializes the field value for this pixel to zero before accumulating influences
const dx = x - centersX[i]
Calculates horizontal distance from pixel to metaball center
const dy = y - centersY[i]
Calculates vertical distance from pixel to metaball center
let distSq = dx * dx + dy * dy
Calculates squared distance (avoids expensive square root)
if (distSq < 0.0001) distSq = 0.0001
Prevents division by zero when pixel is exactly at metaball center
field += strengths[i] / distSq
Adds this metaball's influence to the total field (inverse square law)
const idx = 4 * (y * gw + x)
Calculates the pixel array index: each pixel has 4 values (R, G, B, A)
if (field <= isoLevel) { pixels[idx + 0] = bgColor[0]; ... }
If field is below threshold, set pixel to dark background color
const u = constrain((field - isoLevel) / falloff, 0, 1)
Calculates a 0-1 value for color interpolation based on distance from blob surface
const edgeWidth = 0.8
Controls how soft/blurry the blob edges are (lower = sharper, higher = softer)
const alpha = constrain((field - isoLevel) / edgeWidth, 0, 1)
Calculates transparency: pixels near the edge are more transparent
const r = startColor[0] + (endColor[0] - startColor[0]) * u
Interpolates red channel from orange (255) to magenta (255) based on u
const g = startColor[1] + (endColor[1] - startColor[1]) * u
Interpolates green channel from orange (170) to magenta (0) based on u
const b = startColor[2] + (endColor[2] - startColor[2]) * u
Interpolates blue channel from orange (0) to magenta (200) based on u
pixels[idx + 0] = r; pixels[idx + 1] = g; pixels[idx + 2] = b; pixels[idx + 3] = 255 * alpha
Sets the pixel's RGBA values with the computed color and transparency
fieldGraphics.updatePixels()
Applies all pixel changes to the offscreen buffer
image(fieldGraphics, 0, 0, width, height)
Scales and displays the low-resolution buffer to fill the entire canvas

windowResized()

windowResized() is a p5.js function that automatically runs whenever the browser window is resized. We use it to adapt the canvas and metaball system to the new dimensions, ensuring the animation continues smoothly at any screen size.

function windowResized() {
  resizeCanvas(windowWidth, windowHeight);
  initField(); // rebuild buffer & metaballs for new size
}

Line by Line:

resizeCanvas(windowWidth, windowHeight)
Resizes the main canvas to match the new window dimensions
initField()
Rebuilds the offscreen buffer and reinitializes metaballs for the new canvas size

📦 Key Variables

fieldGraphics p5.Renderer

An offscreen graphics buffer where the metaball field is computed at lower resolution before being scaled to full screen

let fieldGraphics; // initialized in initField()
NUM_METABALLS number

Constant that defines how many metaballs to create (set to 6)

const NUM_METABALLS = 6;
metaballs array

Array storing objects for each metaball, containing radius, amplitude, speed, and phase parameters

let metaballs = []; // filled in initMetaballs()
bgColor array

RGB color array for the dark background (very dark blue)

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

RGB color array for the starting blob color (orange)

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

RGB color array for the ending blob color (magenta)

const endColor = [255, 0, 200];
centersX array

Temporary array storing the current X position of each metaball in the current frame

const centersX = new Array(n);
centersY array

Temporary array storing the current Y position of each metaball in the current frame

const centersY = new Array(n);
strengths array

Temporary array storing the field strength (radius²) of each metaball

const strengths = new Array(n);
isoLevel number

Threshold value that determines where the blob 'surface' appears (pixels with field > 1.0 are inside)

const isoLevel = 1.0;
falloff number

Controls how quickly the color transitions from orange to magenta as you move deeper into the blob

const falloff = 3.0;
t number

Time variable that increases each frame, used to drive smooth sine/cosine oscillation of metaballs

const t = frameCount * 0.02;

🧪 Try This!

Experiment with the code by making these changes:

  1. Change NUM_METABALLS from 6 to 3 or 10 to see how fewer or more blobs affect the merging behavior and visual complexity
  2. Modify the startColor and endColor arrays to create different color gradients - try [255, 100, 100] to [100, 100, 255] for a red-to-blue gradient
  3. Increase the falloff value from 3.0 to 8.0 to make the color transition more gradual, or decrease it to 1.0 for sharper color changes
  4. Change isoLevel from 1.0 to 0.5 to make the blobs appear larger and merge more easily, or increase to 2.0 for smaller, more separated blobs
  5. Modify the time multiplier from frameCount * 0.02 to frameCount * 0.05 to make the metaballs move faster, or 0.01 for slower motion
  6. In the initMetaballs function, change the radius range from random(0.18, 0.28) to random(0.10, 0.40) to create more variation in blob sizes
  7. Add fill(255); text(frameRate(), 10, 20); before the closing brace of draw() to display the frame rate and monitor performance
Open in Editor & Experiment →

🔧 Potential Improvements

Here are some ways this code could be enhanced:

PERFORMANCE drawMetaballs() - pixel loop

Computing the field for every pixel every frame is expensive. For large canvases, this can cause frame rate drops.

💡 Consider implementing a spatial partitioning system or using WebGL shaders to offload computation to the GPU for better performance on high-resolution displays

BUG drawMetaballs() - field accumulation

The division by zero protection (distSq < 0.0001) is good, but very close pixels might still cause numerical instability or visual artifacts

💡 Use a slightly larger minimum value like 0.01 or add a smoothstep function to create a smoother falloff near metaball centers

STYLE initMetaballs() - parameter generation

Magic numbers like 0.18, 0.28, 0.20, 0.45, 0.15, 0.40 are hardcoded and difficult to adjust for different effects

💡 Create constants at the top of the file (e.g., MIN_RADIUS, MAX_RADIUS, MIN_SPEED, MAX_SPEED) to make tuning easier and the code more readable

FEATURE drawMetaballs() - color interpolation

The color gradient is fixed from orange to magenta. Users can't easily change it without editing code.

💡 Add mouse interaction to cycle through preset color palettes, or use HSB color mode to create dynamic hue shifts based on mouse position

BUG drawMetaballs() - edge softness

The edgeWidth constant (0.8) is hardcoded and doesn't scale with the isoLevel or falloff values, potentially causing inconsistent edge softness

💡 Make edgeWidth proportional to falloff: const edgeWidth = falloff * 0.2; to ensure edges scale consistently with color gradient changes

PERFORMANCE initField() - buffer sizing

The minimum buffer size (160x120) might be too low on very small screens, causing pixelation, or too high on mobile, causing slowdown

💡 Make minimum buffer size responsive: const minGw = isMobile() ? 80 : 160; or use a percentage of screen size instead of fixed minimums

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