// 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);
}
}