About this technique →
mpm-splash/sketch.js
// Particle Fluid Splash (CPU, p5 mode)
//
// A simplified particle-based fluid simulation in 2D. Each pair of particles
// within a smoothing radius pushes apart with a quadratic-overlap repulsion
// force; neighbors also blend velocities via a small viscosity term. The
// result is a cohesive fluid that settles, splashes, and slosh-bounces off
// walls — robust to parameter tuning, no density calibration required.
//
// A drop falls into a basin from above. Particles colored by speed.

export const WARMUP = { framesBeforeReady: 180 };

export const PARAMS = {
  count:      { value: 700, min: 200, max: 1500, step: 25, label: "Particles", folder: "Structure", rebuildOnChange: true },
  gravity:    { value: 480, min: 0, max: 1500, step: 10, label: "Gravity", folder: "Behavior" },
  repulsion:  { value: 950, min: 100, max: 4000, step: 25, label: "Repulsion", folder: "Behavior" },
  viscosity:  { value: 0.18, min: 0, max: 1, step: 0.01, label: "Viscosity", folder: "Behavior" },
  smoothing:  { value: 22, min: 8, max: 60, step: 1, label: "Smoothing Radius (px)", folder: "Behavior" },
  damping:    { value: 0.992, min: 0.95, max: 1.0, step: 0.001, label: "Velocity Damping", folder: "Behavior" },
  wallDamp:   { value: 0.45, min: 0, max: 0.95, step: 0.01, label: "Wall Bounce Damping", folder: "Behavior" },
  substeps:   { value: 3, min: 1, max: 6, step: 1, label: "Substeps", folder: "Performance" },
  particleR:  { value: 6, min: 1, max: 12, step: 0.5, label: "Particle Radius (px)", folder: "Appearance" },
  colorA:     { value: "#0a3d8f", type: "color", label: "Low Speed Color", folder: "Appearance" },
  colorB:     { value: "#5dcfff", type: "color", label: "High Speed Color", folder: "Appearance" },
  bg:         { value: "#08152a", type: "color", label: "Background", folder: "Appearance" },
};

export const SHARE = { bookmarked: ["count", "gravity", "repulsion", "viscosity", "smoothing"] };

let state;

function hexToRgb(hex) {
  const v = parseInt(hex.replace('#', ''), 16);
  return [(v >> 16) & 255, (v >> 8) & 255, v & 255];
}

export function sketchSetup(p, w, h, params) {
  p.pixelDensity(1);

  const N = params.count;
  const px = new Float32Array(N);
  const py = new Float32Array(N);
  const vx = new Float32Array(N);
  const vy = new Float32Array(N);

  // Place particles: ~30% as a falling drop near the top, rest as a basin.
  const dropCount = Math.round(N * 0.3);
  const basinCount = N - dropCount;

  // The drop is packed TIGHTER than the basin so it visually reads as a
  // cohesive blob; once it splashes in, repulsion equilibrates the densities.
  const dropSpacing = Math.max(2 * params.particleR + 2, params.smoothing * 0.5);
  const basinSpacing = Math.max(8, params.smoothing * 0.7);

  // Drop: hex-packed disc in upper-center.
  const dropCx = w * 0.5;
  const dropCy = h * 0.18;
  const dropRadius = Math.sqrt(dropCount / Math.PI) * dropSpacing * 1.05;
  let dropPlaced = 0;
  let dropRow = 0;
  for (let dy = -dropRadius; dy <= dropRadius && dropPlaced < dropCount; dy += dropSpacing * 0.866, dropRow++) {
    const xOff = (dropRow & 1) ? dropSpacing * 0.5 : 0;
    for (let dx = -dropRadius + xOff; dx <= dropRadius && dropPlaced < dropCount; dx += dropSpacing) {
      if (dx * dx + dy * dy > dropRadius * dropRadius) continue;
      // Tiny jitter so it's not perfectly crystalline
      px[dropPlaced] = dropCx + dx + (p.random() - 0.5) * dropSpacing * 0.1;
      py[dropPlaced] = dropCy + dy + (p.random() - 0.5) * dropSpacing * 0.1;
      vx[dropPlaced] = 0;
      vy[dropPlaced] = 0;
      dropPlaced++;
    }
  }
  // If a few drop slots remain, fill with random points inside the disc
  for (let i = dropPlaced; i < dropCount; i++) {
    const a = p.random(Math.PI * 2);
    const r = p.random(dropRadius);
    px[i] = dropCx + Math.cos(a) * r;
    py[i] = dropCy + Math.sin(a) * r;
    vx[i] = 0;
    vy[i] = 0;
  }

  // Basin: rough hex packing across the bottom 35% at the same spacing.
  const basinTop = h * 0.65;
  const basinBottom = h - 6;
  const basinLeft = 6;
  const basinRight = w - 6;
  let placed = 0;
  let row = 0;
  for (let y = basinBottom; y > basinTop && placed < basinCount; y -= basinSpacing * 0.866, row++) {
    const xOff = (row & 1) ? basinSpacing * 0.5 : 0;
    for (let x = basinLeft + xOff; x < basinRight && placed < basinCount; x += basinSpacing) {
      const idx = dropCount + placed;
      // Add small jitter so particles don't crystallize on a regular grid
      px[idx] = x + (p.random() - 0.5) * basinSpacing * 0.2;
      py[idx] = y + (p.random() - 0.5) * basinSpacing * 0.2;
      vx[idx] = 0;
      vy[idx] = 0;
      placed++;
    }
  }
  // If we couldn't place all basin particles in the hex pack, fill remainder randomly
  for (let i = dropCount + placed; i < N; i++) {
    px[i] = p.random(basinLeft, basinRight);
    py[i] = p.random(basinTop, basinBottom);
    vx[i] = 0;
    vy[i] = 0;
  }

  state = { px, py, vx, vy, N };
  return state;
}

export function sketchDraw(p, w, h, params) {
  const { px, py, vx, vy, N } = state;

  const hSm     = params.smoothing;
  const hSm2    = hSm * hSm;
  const grav    = params.gravity;
  const repK    = params.repulsion;
  const visc    = params.viscosity;
  const damp    = params.damping;
  const wDamp   = params.wallDamp;
  const sub     = Math.max(1, params.substeps | 0);
  const dt      = 1 / (60 * sub);

  for (let s = 0; s < sub; s++) {
    // 1. Apply gravity
    for (let i = 0; i < N; i++) {
      vy[i] += grav * dt;
    }

    // 2. Pairwise repulsion + viscosity
    for (let i = 0; i < N; i++) {
      for (let j = i + 1; j < N; j++) {
        const dx = px[j] - px[i];
        const dy = py[j] - py[i];
        const r2 = dx * dx + dy * dy;
        if (r2 >= hSm2 || r2 < 0.01) continue;

        const r = Math.sqrt(r2);
        const overlap = hSm - r;
        // Repulsion: quadratic in overlap, scaled by stiffness, normalized by hSm.
        // Force magnitude per particle pair (each gets ±half).
        const fMag = (repK * overlap * overlap) / (hSm * hSm);
        const fx = (dx / r) * fMag * dt;
        const fy = (dy / r) * fMag * dt;
        vx[i] -= fx; vy[i] -= fy;
        vx[j] += fx; vy[j] += fy;

        // Viscosity: blend velocities with neighbor weighted by overlap.
        const weight = visc * (overlap / hSm) * dt * 6;
        const dvx = vx[j] - vx[i];
        const dvy = vy[j] - vy[i];
        vx[i] += dvx * weight;
        vy[i] += dvy * weight;
        vx[j] -= dvx * weight;
        vy[j] -= dvy * weight;
      }
    }

    // 3. Integrate position + global damping + wall bounce
    const margin = 3;
    for (let i = 0; i < N; i++) {
      vx[i] *= damp;
      vy[i] *= damp;

      px[i] += vx[i] * dt;
      py[i] += vy[i] * dt;

      if (px[i] < margin)       { px[i] = margin;       vx[i] =  Math.abs(vx[i]) * wDamp; }
      if (px[i] > w - margin)   { px[i] = w - margin;   vx[i] = -Math.abs(vx[i]) * wDamp; }
      if (py[i] < margin)       { py[i] = margin;       vy[i] =  Math.abs(vy[i]) * wDamp; }
      if (py[i] > h - margin)   { py[i] = h - margin;   vy[i] = -Math.abs(vy[i]) * wDamp; }
    }
  }

  // Render
  const [bgR, bgG, bgB] = hexToRgb(params.bg);
  p.background(bgR, bgG, bgB);

  const [aR, aG, aB] = hexToRgb(params.colorA);
  const [bR, bG, bB] = hexToRgb(params.colorB);
  const pR = params.particleR;
  const diam = pR * 2;

  p.noStroke();
  for (let i = 0; i < N; i++) {
    const spd = Math.sqrt(vx[i] * vx[i] + vy[i] * vy[i]);
    const t   = Math.min(spd / 600, 1);
    const r   = Math.round(aR + (bR - aR) * t);
    const g   = Math.round(aG + (bG - aG) * t);
    const b   = Math.round(aB + (bB - aB) * t);
    p.fill(r, g, b, 230);
    p.ellipse(px[i], py[i], diam, diam);
  }
}