Learn how to render organic-looking metaball shapes using the marching squares algorithm, with interactive demos and code walkthroughs.

Metaballs and Marching Squares: Rendering Gooey 2D Blobs


Metaballs (or “blobs”) are organic-looking shapes that smoothly merge and separate — think lava lamps, water droplets, or those gooey UI effects you see in modern web design. In this post, we’ll explore how to render 2D metaballs using the marching squares algorithm.

Try It Out

Drag the control points below to see the metaballs merge and separate. The physics simulation gives them that satisfying springy motion:

The Concept

Before diving into the math, think of a candle flame. Close to the wick, it’s hot. As you move away, the heat drops off. If you bring two candles close together, the heat fields between them merge, creating a combined zone of warmth that connects them.

Metaballs work the same way:

  1. Field of Influence: Each blob emits a “field” that is strong at the center and weak at the edges.
  2. Threshold: We decide to draw a line exactly where the combined field strength equals a specific value (e.g., 1.0).

When two blobs are far apart, their fields don’t overlap enough to reach that threshold in the middle, so they look like separate circles. As they get closer, the fields add up, pushing the threshold boundary outward and creating a bridge between them.

The Math

To simulate this “field of influence,” we use an equation that mimics gravity or light intensity — the Inverse Square Law.

The strength (“potential”) of the field at any pixel (x,y)(x, y) is the sum of the influence from all blobs:

Potential(x,y)=i=1nri2di2\text{Potential}(x, y) = \sum_{i=1}^{n} \frac{r_i^2}{d_i^2}

Where:

  • rir_i is the radius of blob ii (how “hot” it is).
  • did_i is the distance from the pixel to the center of blob ii.

If the total potential at a pixel is 1.0\ge 1.0, it’s inside the blob. If it’s <1.0< 1.0, it’s outside. The “gooey” surface exists exactly where the potential equals 1.01.0.

The Marching Squares Algorithm

We have a mathematical definition of our blobs, but how do we draw them? We can’t check every single pixel on the screen — that would be too slow. Instead, we use Marching Squares, an algorithm designed to find contours (outlines).

Think of it like drawing a topographic map. You measure the elevation at regular intervals, and then you draw lines where the elevation crosses a specific height (like 1000ft).

How It Works

  1. Grid Sampling: We divide the screen into a coarse grid (e.g., 10x10 pixel squares). We calculate the field strength only at the corners of these squares.

  2. Binary Classification: For each square, we look at its four corners. Is the corner “inside” (hot) or “outside” (cold)?

    • Inside = 1
    • Outside = 0

    This gives us 4 corners, meaning there are 24=162^4 = 16 possible patterns for how the surface might cut through that square.

  3. Lookup Table: We use a lookup table to decide how to draw the lines for each of those 16 cases.

    Case 0: ○ ○    Case 5: ○ ●    Case 10: ● ○
            ○ ○            ● ○             ○ ●
    (empty)        (saddle)        (saddle)
  4. Linear Interpolation: To make the shape smooth, we don’t just connect the midpoints. If one corner is very hot (2.5) and the other is barely cold (0.9), the surface line should be much closer to the cold corner. We interpolate the exact position along the edge.

    function lerp(x1, y1, x2, y2, v1, v2, threshold) {
      const t = (threshold - v1) / (v2 - v1);
      return {
        x: x1 + t * (x2 - x1),
        y: y1 + t * (y2 - y1)
      };
    }

The Implementation

Here is a simplified look at the loop that drives the algorithm:

function generateIsolines(
  field: (x: number, y: number) => number,
  bounds: Bounds,
  resolution: number,
  threshold: number
): Point[][] {
  // 1. Pre-compute grid values
  const grid = new Float32Array(gridWidth * gridHeight);
  for (let y = 0; y < gridHeight; y++) {
    for (let x = 0; x < gridWidth; x++) {
      grid[y * gridWidth + x] = field(px, py);
    }
  }

  // 2. March through each cell
  for (let y = 0; y < rows; y++) {
    for (let x = 0; x < cols; x++) {
      // Get corner values
      const v0 = grid[y * gridWidth + x];       // top-left
      const v1 = grid[y * gridWidth + x + 1];   // top-right
      const v2 = grid[(y+1) * gridWidth + x + 1]; // bottom-right
      const v3 = grid[(y+1) * gridWidth + x];   // bottom-left

      // Build 4-bit index (0-15)
      let index = 0;
      if (v0 >= threshold) index |= 8;
      if (v1 >= threshold) index |= 4;
      if (v2 >= threshold) index |= 2;
      if (v3 >= threshold) index |= 1;

      // 3. Lookup case & add segments...
      // (Implementation detail: retrieve edges from lookup table)
    }
  }

  return connectSegments(segments);
}

Performance Optimizations

The demo above runs at 60fps even with multiple blobs. Simulation at this speed requires being smart about what we calculate:

  1. Pre-computed Grid: We sample the field once per frame at grid corners, rather than re-calculating per edge.
  2. Edge Caching: The right edge of cell (x, y) is the same as the left edge of cell (x+1, y). We cache these interpolation results to avoid duplicate work.
  3. TypedArrays: Using Float32Array for the grid data is significantly faster than standard JavaScript arrays due to memory locality.
  4. Bounded Sampling: We calculate the bounding box of all blobs and only run the marching squares algorithm within that active area, skipping the empty space around them.

Color Blending

The gradient effect that blends colors when metaballs merge uses the same distance data. We calculate a “blend factor” based on how close the blobs are:

const dist = Math.sqrt(dx * dx + dy * dy);
// 200px is our "interaction radius"
const blendFactor = Math.min(dist / 200, 1);

// Full separation: distinct colors
// Close together: mixed color
const c1 = lerpColor(mixedColor, color1, blendFactor);
const c2 = lerpColor(mixedColor, color2, blendFactor);

Going Further

The marching squares algorithm is a foundational technique in computer graphics:

  • Terrain contours: Generating topographic maps from height data.
  • Image segmentation: Computer vision tasks to outline objects.
  • Fluid simulation: Visualizing 2D liquid dynamics.
  • UI effects: Creating organic transitional interfaces.

References