<!-- SPH splash demo — HTML structure -->
<div class="controls">
<label>Gravity: <input type="range" id="gravity" min="100" max="800" value="400" step="10"></label>
<label>Damping: <input type="range" id="damping" min="0" max="99" value="20" step="1"></label>
<button id="btn-drop" type="button">Drop particles</button>
<button id="btn-reset" type="button" class="ghost">Reset</button>
</div>
<canvas id="c" width="480" height="340"></canvas>
<div id="status" class="status">Press "Drop particles" to start the simulation.</div>
/* SPH demo styles — canvas + controls */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; background: #f0f4f8; }
.controls { display: flex; gap: .6rem; flex-wrap: wrap; align-items: center;
padding: .5rem .4rem; background: #e2eaf2; border-radius: 8px; margin-bottom: .5rem; }
label { font-size: .82rem; color: #334; display: flex; align-items: center; gap: .3rem; }
input[type=range] { width: 80px; cursor: pointer; }
canvas { display: block; border-radius: 8px; border: 1px solid #c4d0dc; background: #dce8f5; width: 100%; }
.status { font-size: .88rem; font-weight: 600; color: #2a4d6e; margin-top: .4rem; min-height: 1.3em; }
button { font: 600 13px system-ui; padding: .38rem .8rem; border: 1px solid #2a4d6e;
background: #2a4d6e; color: #fff; border-radius: 6px; cursor: pointer; }
button.ghost { background: #fff; color: #2a4d6e; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
/* SPH (Smoothed-Particle Hydrodynamics): fluid is represented as particles. */
/* Each particle samples density and pressure from its neighbors via a kernel. */
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
/* Simulation parameters */
const NUM = 80;
const RADIUS = 28; // smoothing radius in pixels
const REST_DENSITY = 8; // target rest density
const STIFFNESS = 800; // pressure stiffness constant
const VISCOSITY = 0.3; // viscosity coefficient
const PARTICLE_R = 5; // visual radius
let gravity = 400;
let damping = 0.20;
let animId = null;
let running = false;
let particles = [];
/* Poly6 smoothing kernel for density estimation */
function poly6W(r2, h) {
const h2 = h * h;
if (r2 >= h2) return 0;
const d = h2 - r2;
return (315 / (64 * Math.PI * Math.pow(h, 9))) * d * d * d;
}
/* Spiky gradient kernel for pressure force */
function spikyGrad(r, h) {
if (r <= 0 || r >= h) return 0;
const d = h - r;
return -(45 / (Math.PI * Math.pow(h, 6))) * d * d;
}
/* Viscosity Laplacian kernel */
function viscLaplacian(r, h) {
if (r >= h) return 0;
return (45 / (Math.PI * Math.pow(h, 6))) * (h - r);
}
function initParticles() {
particles = [];
/* Drop particles in a rectangular blob near top-center */
const cols = 8, rows = Math.ceil(NUM / cols);
const sx = W * 0.35, sy = H * 0.05;
const spacing = PARTICLE_R * 2.2;
for (let i = 0; i < NUM; i++) {
const col = i % cols, row = Math.floor(i / cols);
particles.push({
x: sx + col * spacing + (Math.random() - 0.5) * 2,
y: sy + row * spacing + (Math.random() - 0.5) * 2,
vx: 0, vy: 0,
density: REST_DENSITY,
pressure: 0
});
}
}
function computeDensityPressure() {
/* Estimate density at each particle by summing kernel contributions */
for (const pi of particles) {
pi.density = 0;
for (const pj of particles) {
const dx = pj.x - pi.x, dy = pj.y - pi.y;
const r2 = dx * dx + dy * dy;
pi.density += poly6W(r2, RADIUS);
}
pi.density = Math.max(pi.density, 0.001);
pi.pressure = STIFFNESS * (pi.density - REST_DENSITY);
}
}
function computeForces(dt) {
/* Compute pressure + viscosity forces and integrate positions */
for (const pi of particles) {
let fx = 0, fy = gravity;
for (const pj of particles) {
if (pi === pj) continue;
const dx = pj.x - pi.x, dy = pj.y - pi.y;
const r = Math.sqrt(dx * dx + dy * dy);
if (r < 0.001 || r >= RADIUS) continue;
/* Pressure gradient force (symmetric form) */
const gradW = spikyGrad(r, RADIUS);
const pterm = -(pi.pressure + pj.pressure) / (2 * pj.density) * gradW / r;
fx += pterm * dx;
fy += pterm * dy;
/* Viscosity diffusion force */
const lapW = viscLaplacian(r, RADIUS);
fx += VISCOSITY * (pj.vx - pi.vx) / pj.density * lapW;
fy += VISCOSITY * (pj.vy - pi.vy) / pj.density * lapW;
}
/* Semi-implicit Euler integration */
pi.vx += fx * dt / pi.density;
pi.vy += fy * dt / pi.density;
/* Boundary collision with energy loss (damping) */
const margin = PARTICLE_R;
if (pi.x < margin) { pi.x = margin; pi.vx = Math.abs(pi.vx) * (1 - damping); }
if (pi.x > W - margin) { pi.x = W - margin; pi.vx = -Math.abs(pi.vx) * (1 - damping); }
if (pi.y < margin) { pi.y = margin; pi.vy = Math.abs(pi.vy) * (1 - damping); }
if (pi.y > H - margin) { pi.y = H - margin; pi.vy = -Math.abs(pi.vy) * (1 - damping); }
pi.x += pi.vx * dt;
pi.y += pi.vy * dt;
}
}
function draw() {
ctx.clearRect(0, 0, W, H);
/* Color by speed — blue=slow, red=fast */
for (const p of particles) {
const speed = Math.sqrt(p.vx * p.vx + p.vy * p.vy);
const t = Math.min(speed / 200, 1);
const r = Math.round(40 + t * 180), b = Math.round(200 - t * 140);
ctx.beginPath();
ctx.arc(p.x, p.y, PARTICLE_R, 0, Math.PI * 2);
ctx.fillStyle = `rgba(${r},120,${b},0.85)`;
ctx.fill();
}
}
let last = null;
function loop(ts) {
if (!running) return;
if (last === null) last = ts;
const dt = Math.min((ts - last) / 1000, 0.02);
last = ts;
computeDensityPressure();
computeForces(dt);
draw();
animId = requestAnimationFrame(loop);
}
function startDrop() {
if (running) { running = false; cancelAnimationFrame(animId); last = null; }
initParticles();
draw();
running = true;
last = null;
animId = requestAnimationFrame(loop);
document.getElementById('status').textContent = i18nFmt('Simulating %n% SPH particles — watch them splash and settle.', { n: NUM });
}
function reset() {
running = false;
cancelAnimationFrame(animId);
last = null;
initParticles();
draw();
document.getElementById('status').textContent = 'Press "Drop particles" to start the simulation.';
}
document.getElementById('gravity').addEventListener('input', function() { gravity = +this.value; });
document.getElementById('damping').addEventListener('input', function() { damping = +this.value / 100; });
document.getElementById('btn-drop').addEventListener('click', startDrop);
document.getElementById('btn-reset').addEventListener('click', reset);
initParticles();
draw();
document.getElementById('status').textContent = 'Press "Drop particles" to start the simulation.';
Comments
Loading comments...