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