Fluid Simulation with Particles in Matter.js
This guide explains how to simulate 2D fluids using particle systems in the Matter.js physics engine. While Matter.js is fundamentally a rigid-body engine, you can achieve realistic liquid behavior by combining hundreds of low-friction circular particles with custom attraction forces and a visual blending technique known as the metaball effect. Below, you will learn the core physics configuration, particle interaction tuning, and canvas rendering methods required to bring fluid dynamics to life.
The Approach: Pseudo-Fluid Mechanics
True computational fluid dynamics (CFD) models Navier-Stokes equations, which are computationally expensive for real-time web applications. In Matter.js, fluids are typically approximated by:
- Generating Particle Clustered Bodies: Creating numerous small circular bodies.
- Minimizing Rigid Characteristics: Lowering friction and setting optimal density.
- Simulating Surface Tension: Applying cohesion forces between neighboring particles.
- Post-Processing the Visuals: Blurring and thresholding the rendered particles so they merge visually into a single continuous body of liquid.
Step 1: Initialize the Engine and Boundaries
Set up a standard Matter.js engine, world, and static container walls to hold the liquid.
const { Engine, Render, Runner, Bodies, Composite, Body, Vector } = Matter;
const engine = Engine.create();
const world = engine.world;
const canvas = document.getElementById('fluidCanvas');
const ctx = canvas.getContext('2d');
// Boundary walls
const ground = Bodies.rectangle(400, 590, 810, 30, { isStatic: true });
const leftWall = Bodies.rectangle(5, 300, 30, 600, { isStatic: true });
const rightWall = Bodies.rectangle(795, 300, 30, 600, { isStatic: true });
Composite.add(world, [ground, leftWall, rightWall]);Step 2: Create the Fluid Particles
Create a cluster of small circular bodies. Crucially, set
friction and frictionStatic to 0
to prevent particles from stacking like sand, and set
restitution (bounciness) very low.
const particles = [];
const particleRadius = 7;
const rows = 15;
const cols = 20;
for (let i = 0; i < rows; i++) {
for (let j = 0; j < cols; j++) {
const particle = Bodies.circle(300 + j * 16, 100 + i * 16, particleRadius, {
friction: 0.0,
frictionAir: 0.02,
frictionStatic: 0.0,
restitution: 0.05,
density: 0.002,
render: { visible: false } // Hidden for custom canvas rendering
});
particles.push(particle);
}
}
Composite.add(world, particles);Step 3: Implement Cohesion (Surface Tension)
To make particles behave cohesively like water rather than disconnected ball bearings, apply a gentle attractive force between particles that are close to one another before each physics update.
Matter.Events.on(engine, 'beforeUpdate', () => {
const maxDistance = particleRadius * 4;
const attractionStrength = 0.00005;
for (let i = 0; i < particles.length; i++) {
const pA = particles[i];
for (let j = i + 1; j < particles.length; j++) {
const pB = particles[j];
const delta = Vector.sub(pB.position, pA.position);
const distance = Vector.magnitude(delta);
if (distance < maxDistance && distance > 0) {
// Normalize and scale force inversely proportional to distance
const forceMagnitude = (1 - distance / maxDistance) * attractionStrength;
const force = Vector.mult(Vector.normalise(delta), forceMagnitude);
Body.applyForce(pA, pA.position, force);
Body.applyForce(pB, pB.position, Vector.neg(force));
}
}
}
});Step 4: Render Particles as Liquid (Metaballs)
Instead of using the default Matter.js wireframe renderer, draw radial gradients on a dedicated HTML5 canvas. Using an alpha threshold filter combines overlapping particles into a smooth fluid surface.
Method A: CSS Filter (Easiest & Fastest)
Apply a blur combined with a high-contrast filter to the canvas element in CSS. This blends overlapping semi-transparent circles and cuts off soft edges:
#fluidCanvas {
filter: blur(8px) contrast(20);
background: #ffffff;
}In your JavaScript animation loop, clear the canvas and draw circles:
function render() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#0077be';
for (let i = 0; i < particles.length; i++) {
const { x, y } = particles[i].position;
ctx.beginPath();
ctx.arc(x, y, particleRadius * 2, 0, Math.PI * 2);
ctx.fill();
}
requestAnimationFrame(render);
}
// Start simulation and rendering
const runner = Runner.create();
Runner.run(runner, engine);
render();Performance Tuning Tips
- Particle Cap: Keep particle counts under 400 for standard 60 FPS performance, as distance checks scale quadratically (\(O(n^2)\)) without a spatial grid.
- Spatial Hashing: For systems exceeding 300 particles, replace nested distance loops with a spatial hash grid or cell subdivision to only check nearby neighbors.
- Collision Filtering: Use
collisionFiltergroups if fluid particles should ignore collisions with specific particle types to cut down on solver cycles.