Model Cloud Droplet Coalescence in Matter.js

This article explains how to simulate cloud droplet coalescence into falling raindrops using Matter.js particle bodies. By leveraging the engine's rigid-body physics, collision events, and dynamic body modifications, you can model the transition from suspended microscopic cloud droplets to falling precipitation. The following sections break down the physics principles, collision detection strategies, body merging calculations, and drag adjustments necessary for an accurate simulation.

1. Conceptual Physics Model

Cloud droplets remain suspended due to updrafts and high surface-area-to-mass ratios, leading to low terminal velocities governed by Stokes' Law. When droplets collide, they often coalesce into a single, larger drop rather than bouncing apart. As droplets grow, their mass increases proportionally to the cube of their radius (\(r^3\)), while cross-sectional area increases by the square (\(r^2\)). This causes larger drops to overcome upward air resistance and fall as rain.

In Matter.js, this process requires:

2. Spawning Suspended Droplets

Begin by configuring the Matter.js engine with reduced gravity to mimic a cloud updraft. Droplets are created using Bodies.circle with low mass and high air friction.

const { Engine, Render, Runner, Bodies, Composite, Events, Body } = Matter;

const engine = Engine.create({
  gravity: { x: 0, y: 0.05, scale: 0.001 } // Mild downward pull
});

function createDroplet(x, y, radius = 2) {
  return Bodies.circle(x, y, radius, {
    restitution: 0,
    friction: 0,
    frictionAir: 0.08, // High drag keeps droplets floating
    label: 'droplet',
    render: {
      fillStyle: 'rgba(200, 220, 255, 0.6)'
    }
  });
}

3. Detecting Droplet Collisions

Matter.js resolves collisions elastically by default. To simulate coalescence, attach an event listener to collisionStart. Identify colliding droplet pairs and queue them for merging during the next engine tick to avoid altering the physics state mid-step.

const mergeQueue = [];

Events.on(engine, 'collisionStart', (event) => {
  event.pairs.forEach((pair) => {
    const { bodyA, bodyB } = pair;

    if (bodyA.label === 'droplet' && bodyB.label === 'droplet') {
      // Determine dominant drop and absorbed drop
      const primary = bodyA.circleRadius >= bodyB.circleRadius ? bodyA : bodyB;
      const secondary = bodyA === primary ? bodyB : bodyA;

      // Prevent processing the same drop multiple times
      if (!mergeQueue.some(item => item.secondary === secondary || item.primary === secondary)) {
        mergeQueue.push({ primary, secondary });
      }
    }
  });
});

4. Executing Coalescence Mechanics

After detecting contact, execute the coalescence process using an afterUpdate or beforeUpdate hook:

  1. Volume and Radius Conservation: Assuming 3D volume preservation mapped to 2D scale: \[r_{\text{new}} = \sqrt[3]{r_1^3 + r_2^3}\]
  2. Momentum Conservation: \[\vec{v}_{\text{new}} = \frac{m_1 \vec{v}_1 + m_2 \vec{v}_2}{m_1 + m_2}\]
  3. Dynamic Drag Reduction: Decrease frictionAir relative to drop size so larger drops fall faster.
Events.on(engine, 'afterUpdate', () => {
  while (mergeQueue.length > 0) {
    const { primary, secondary } = mergeQueue.shift();

    // Check if secondary still exists in the world
    if (!Composite.allBodies(engine.world).includes(secondary)) continue;

    const r1 = primary.circleRadius;
    const r2 = secondary.circleRadius;
    const m1 = primary.mass;
    const m2 = secondary.mass;

    // Calculate new radius and scale factor
    const newRadius = Math.cbrt(Math.pow(r1, 3) + Math.pow(r2, 3));
    const scaleFactor = newRadius / r1;

    // Conserve momentum
    const newVelocity = {
      x: (primary.velocity.x * m1 + secondary.velocity.x * m2) / (m1 + m2),
      y: (primary.velocity.y * m1 + secondary.velocity.y * m2) / (m1 + m2)
    };

    // Remove the absorbed drop
    Composite.remove(engine.world, secondary);

    // Update primary drop geometry, velocity, and properties
    Body.scale(primary, scaleFactor, scaleFactor);
    Body.setVelocity(primary, newVelocity);

    // As radius increases, reduce air friction toward standard falling speeds
    primary.frictionAir = Math.max(0.005, 0.08 - (newRadius * 0.005));

    // Update visual styling to signify a denser raindrop
    if (newRadius > 6) {
      primary.render.fillStyle = 'rgba(100, 150, 240, 0.9)';
    }
  }
});

5. Managing Lifecycles and Boundaries

As droplets coalesce and fall through the bottom of the simulation bounds, remove them from the composite using world boundary checks to prevent memory leaks. This setup creates a continuous, natural cycle where cloud droplets remain suspended until Brownian motion or micro-currents induce collisions, producing accelerating raindrops that fall out of the cloud layer.