Rendering Liquid with Matter.js and Metaball Shaders

Rendering realistic 2D liquid surfaces from rigid-body physics requires bridging discrete particle simulations with field-based graphics. By simulating water droplets as circular rigid bodies in Matter.js and passing their positions to a WebGL fragment shader, you can evaluate an implicit surface field—known as metaballs—to blend individual discs into a cohesive, viscous fluid surface with crisp boundaries.

1. Setting Up the Matter.js Particle System

Begin by configuring the Matter.js engine with a high volume of small circular bodies. To make the particles behave like fluid, remove friction and set low restitution:

const { Engine, World, Bodies } = Matter;
const engine = Engine.create();

const particles = [];
const particleRadius = 12;

for (let i = 0; i < 150; i++) {
  const particle = Bodies.circle(100 + (i % 10) * 20, 50, particleRadius, {
    friction: 0.01,
    frictionAir: 0.001,
    restitution: 0.1,
    density: 0.002
  });
  particles.push(particle);
  World.add(engine.world, particle);
}

Keep the particle radius relatively small so they can pack closely and slide past one another naturally under gravity.

2. Passing Particle Positions to WebGL

Set up a full-screen quad in WebGL. On each animation frame, update Matter.js and extract the screen-space coordinates of every particle. Pass these positions into a uniform array or a data texture if handling hundreds of particles.

function getParticlePositions() {
  const positions = new Float32Array(particles.length * 2);
  for (let i = 0; i < particles.length; i++) {
    positions[i * 2] = particles[i].position.x;
    positions[i * 2 + 1] = particles[i].position.y;
  }
  return positions;
}

Send this array to the fragment shader via gl.uniform2fv.

3. Calculating the Metaball Influence Field

In the fragment shader, compute the scalar field value for each pixel by summing the radial influence of all particle centers. The influence function must decay smoothly over distance:

precision mediump float;

uniform vec2 u_resolution;
uniform vec2 u_particles[150];
uniform float u_radius;

void main() {
    vec2 st = gl_FragCoord.xy;
    st.y = u_resolution.y - st.y; // Match 2D canvas coordinates

    float energy = 0.0;
    
    for (int i = 0; i < 150; i++) {
        vec2 diff = u_particles[i] - st;
        float distSq = dot(diff, diff);
        
        // Inverse square falloff with an influence radius
        if (distSq < u_radius * u_radius * 4.0) {
            energy += (u_radius * u_radius) / (distSq + 0.0001);
        }
    }

    // Thresholding and rendering
    float edge = smoothstep(1.0, 1.05, energy);
    vec4 liquidColor = vec4(0.1, 0.5, 0.9, 1.0);
    
    gl_FragColor = liquidColor * edge;
}

The energy accumulator rises in areas where multiple particles overlap or sit adjacent to one another.

4. Thresholding and Edge Smoothing

A sharp threshold using step() produces harsh aliasing along the liquid edge. To achieve a smooth anti-aliased surface, use smoothstep(). Defining a narrow transition band (e.g., between 1.0 and 1.05) creates a soft, sub-pixel border that remains visually crisp without pixelated stepping artifacts.

You can also introduce an outline or meniscus effect by sampling two separate threshold bands:

float fill = smoothstep(1.0, 1.02, energy);
float border = smoothstep(0.9, 0.95, energy) - fill;

vec4 fillColor = vec4(0.15, 0.6, 0.95, 1.0);
vec4 borderColor = vec4(0.8, 0.95, 1.0, 1.0);

gl_FragColor = (fillColor * fill) + (borderColor * border);

5. Alternative High-Performance Pipeline: The Two-Pass Method

Evaluating hundreds of distance checks per pixel inside a fragment shader can degrade performance at high resolutions. An optimized alternative involves a two-pass render target (Frame Buffer Object):

  1. Pass 1 (Draw Discs): Render each Matter.js particle as a billboard sprite with a blurred radial alpha gradient to an offscreen texture. Particle overlaps will naturally add up alpha values if additive blending is enabled.
  2. Pass 2 (Threshold Pass): Render the resulting texture as a full-screen quad through a fragment shader that applies smoothstep to the combined alpha channel. Pixels below a defined alpha cutoff are discarded, while pixels above the cutoff are shaded with the liquid color.

This two-pass method shifts the load from per-pixel coordinate looping to GPU rasterization hardware, allowing thousands of Matter.js particle discs to be rendered smoothly at 60 FPS.