438

Rain Ripples

Raindrops strike an angled water surface and burst into expanding rainbow ripples. A lattice of dots recedes toward the horizon, lighting up in shifting hues as each elliptical wavefront sweeps through it, with additive glow rings tracing the surface. Inspired by Okazz's #p5js raindrop study.

60 FPS • p5.js

p5.js Sketch Code

// Rain Ripples — colorful raindrops on a water surface, viewed at an angle
// Inspired by Okazz (@okazz_) — https://x.com/okazz_/status/2100938132002914437
// Original p5.js implementation for Math Art.

let drops = [];
let ripples = [];
let dots = [];
let W = 0;
let H = 0;
let cx = 0;
let horizonY = 0;
let bottomY = 0;
let sNear = 1;
let worldHalf = 8;
let Zmax = 40;
let P = 1.2;
let spawnAcc = 0;
let U = 1;
let curView = 0;
let curSpacing = 0;
const MAX_RIPPLES = 60;
const MARGIN = 60;

function param(key, fallback) {
  const pr = p.getParams ? p.getParams() : null;
  const v = pr ? pr[key] : undefined;
  return typeof v === "number" ? v : fallback;
}

function tFromZ(z) {
  return Math.max(0, Math.min(1, z / Zmax));
}
function yAtT(t) {
  return horizonY + (bottomY - horizonY) * (1 - t) / (1 + P * t);
}
function scaleAtT(t) {
  return sNear * (1 - 0.68 * t);
}
function fprimeAtT(t) {
  return (bottomY - horizonY) * (1 + P) / (Zmax * Math.pow(1 + P * t, 2));
}
function projectGround(x, z) {
  const t = tFromZ(z);
  return { x: cx + x * scaleAtT(t), y: yAtT(t), s: scaleAtT(t), fp: fprimeAtT(t), t: t };
}

function buildScene() {
  W = p.width;
  H = p.height;
  U = H / 800;
  cx = W * 0.5;
  horizonY = H * 0.2;
  bottomY = H * 1.03;
  const spacing = Math.max(12, Math.round(param("gridSize", 22)));
  const view = param("viewAngle", 40);
  curSpacing = spacing;
  curView = view;
  const k0 = p.map(view, 15, 70, 0.4, 0.92);
  sNear = (W * 0.5 + MARGIN) / worldHalf;
  Zmax = (bottomY - horizonY) * (1 + P) * worldHalf / ((W * 0.5 + MARGIN) * k0);
  const rows = Math.max(24, Math.round((bottomY - horizonY) * (1 + P) / spacing));
  dots = [];
  for (let j = 0; j <= rows; j++) {
    const t = j / rows;
    const s = scaleAtT(t);
    const y = yAtT(t);
    if (y < horizonY + 1) continue;
    const halfRow = (W * 0.5 + MARGIN) / s;
    const step = Math.max(0.04, spacing / s);
    for (let x = -halfRow; x <= halfRow; x += step) {
      dots.push({ x: cx + x * s, y: y, s: s, t: t });
    }
  }
}

function spawnDrop() {
  const x = p.random(-worldHalf * 0.85, worldHalf * 0.85);
  const z = p.random(Zmax * 0.03, Zmax * 0.72);
  const target = projectGround(x, z);
  const duration = p.random(0.55, 1.15);
  drops.push({
    wx: x,
    wz: z,
    tx: target.x,
    ty: target.y,
    x: target.x + p.random(-16, 16),
    y: p.random(-170, -30),
    vy: (target.y + 80) / duration,
    len: p.random(14, 40) * U,
    hue: p.random(360),
  });
}

function spawnRipple(x, z, hue) {
  const reach = param("rippleReach", 220) / 100;
  const pt = projectGround(x, z);
  ripples.push({
    wx: x,
    wz: z,
    cx: pt.x,
    cy: pt.y,
    s: pt.s,
    fp: pt.fp,
    r: 0,
    life: 1,
    maxR: p.random(reach * 0.45, reach),
    hue: hue,
  });
  if (ripples.length > MAX_RIPPLES) ripples.shift();
}

p.setup = () => {
  const w = p.container ? p.container.clientWidth : 800;
  const h = p.container ? p.container.clientHeight : 800;
  p.createCanvas(w, h);
  p.colorMode(p.HSB, 360, 100, 100, 100);
  p.noStroke();
  buildScene();
};

p.draw = () => {
  const spacing = Math.max(12, Math.round(param("gridSize", 22)));
  const view = param("viewAngle", 40);
  if (spacing !== curSpacing || view !== curView) buildScene();

  const speed = param("rippleSpeed", 90) / 100;
  const density = param("rainDensity", 9);
  const shift = param("colorShift", 0);
  const dt = Math.min(1 / 30, p.deltaTime / 1000);

  // Smooth deep-water backdrop with a faint horizon sheen
  const grad = p.drawingContext.createLinearGradient(0, 0, 0, H);
  grad.addColorStop(0, 'rgb(7,9,20)');
  grad.addColorStop(0.18, 'rgb(20,32,52)');
  grad.addColorStop(0.205, 'rgb(9,16,30)');
  grad.addColorStop(0.6, 'rgb(7,12,24)');
  grad.addColorStop(1, 'rgb(5,8,16)');
  p.drawingContext.fillStyle = grad;
  p.drawingContext.fillRect(0, 0, W, H);
  p.noStroke();

  // Spawn raindrops
  spawnAcc += density * dt;
  while (spawnAcc >= 1) {
    spawnAcc -= 1;
    spawnDrop();
  }

  // Falling streaks
  p.strokeWeight(1.4 * U);
  p.stroke(200, 22, 98, 42);
  for (let i = drops.length - 1; i >= 0; i--) {
    const d = drops[i];
    d.y += d.vy * dt;
    if (d.y >= d.ty) {
      spawnRipple(d.wx, d.wz, d.hue);
      drops.splice(i, 1);
      continue;
    }
    const x = p.lerp(d.tx, d.x, p.constrain((d.ty - d.y) / (d.ty + 80), 0, 1));
    p.line(x, d.y, x, d.y - d.len);
  }
  p.noStroke();

  // Expand ripples
  for (let i = ripples.length - 1; i >= 0; i--) {
    const rp = ripples[i];
    rp.r += speed * dt;
    rp.life = Math.max(0, 1 - rp.r / rp.maxR);
    if (rp.r >= rp.maxR) ripples.splice(i, 1);
  }

  // Dot field lit by passing ripple rings
  for (let k = 0; k < dots.length; k++) {
    const dot = dots[k];
    let best = 0;
    let hue = 0;
    for (let i = 0; i < ripples.length; i++) {
      const rp = ripples[i];
      const dx = (dot.x - rp.cx) / rp.s;
      const dy = (dot.y - rp.cy) / rp.fp;
      const D = Math.sqrt(dx * dx + dy * dy);
      const band = 1.1 * spacing / rp.s;
      const diff = Math.abs(D - rp.r);
      if (diff > band) continue;
      const w = (1 - diff / band) * rp.life;
      if (w > best) {
        best = w;
        hue = (rp.hue + D * 22 + shift) % 360;
      }
    }
    const depthFade = 1 - dot.t * 0.7;
    if (best <= 0.02) {
      p.fill(212, 40, p.lerp(9, 22, depthFade), 100);
      p.circle(dot.x, dot.y, Math.max(1.2, dot.s * 0.04));
    } else {
      p.fill(hue, 82, 100, 100 * p.lerp(0.5, 1, depthFade));
      p.circle(dot.x, dot.y, dot.s * (0.04 + best * 0.17));
    }
  }

  // Additive ripple rings, projected so they read as ellipses on the plane
  p.blendMode(p.ADD);
  p.noFill();
  const SEG = 48;
  for (let i = 0; i < ripples.length; i++) {
    const rp = ripples[i];
    for (let ring = 0; ring < 3; ring++) {
      const R = rp.r * (1 - ring * 0.22);
      p.strokeWeight((2.0 + ring * 1.4) * (0.55 + 0.45 * rp.life));
      p.stroke((rp.hue + shift + ring * 34 + 360) % 360, 62, 100, (20 - ring * 5) * rp.life);
      p.beginShape();
      for (let a = 0; a <= SEG; a++) {
        const ang = (a / SEG) * p.TWO_PI;
        const wx = rp.wx + R * Math.cos(ang);
        const wz = Math.max(0.05, Math.min(Zmax, rp.wz + R * Math.sin(ang)));
        const t = tFromZ(wz);
        p.vertex(cx + wx * scaleAtT(t), yAtT(t));
      }
      p.endShape();
    }
  }
  p.noStroke();
  p.blendMode(p.BLEND);
};

p.windowResized = () => {
  if (p.container) {
    p.resizeCanvas(p.container.clientWidth, p.container.clientHeight);
    buildScene();
  }
};

Properties

#p5js#raindrops#ripples#rain#colorful#okazz#water#isometric#generative-art
Author: Inspired by Okazz (@okazz_) Target: 60 FPS
ESC
↑↓ Navigate ↵ Select
113 Mathematical Artworks
Made by Fazley Rabbi
☕

Support the Project

Keep mathematical creative coding alive & open source

Or via Direct Payoneer ($0 Fee)
Payoneer Customer ID: $0 Fee Direct
24076084

💡 Payoneer app: Go to Pay → Pay to recipient → Enter ID 24076084.

Thank you for supporting generative mathematical animations! ✨