<p class="hint">A tiny <b>2D fluid</b>. <b>Drag</b> across the canvas to push the liquid and inject swirl, then watch a clean stroke break up into curling <b>vortices</b> — the fingerprint of turbulence. In 2D this stays smooth forever; in 3D, whether it can blow up is a million-dollar open question.</p>
<canvas id="fluid" width="240" height="240"></canvas>
<div class="status" id="status">Drag across the fluid to stir it.</div>
<div class="btns">
<button id="stir" type="button">Auto-stir</button>
<button id="reset" type="button" class="ghost">Calm the fluid</button>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .9rem; color: #444; margin: 0 0 .7rem; line-height: 1.45; }
#fluid { width: 240px; height: 240px; border-radius: 10px; border: 1px solid #cdd9e3;
background: #0b1b2b; touch-action: none; cursor: crosshair; display: block; }
.status { font-size: 1rem; font-weight: 600; margin: .6rem 0; min-height: 1.4em; color: #1d3557; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
// A minimal 2D incompressible-fluid feel: we advect a scalar "dye" field and a velocity field on a grid. Dragging adds momentum; the field is diffused and advected each frame, so a straight push curls into vortices. This is the 2D case — provably smooth for all time (Ladyzhenskaya). The 3D version is the open Navier-Stokes Millennium Problem (Clay Institute, 2000).
const N = 48; // grid resolution
const cv = document.getElementById('fluid');
const ctx = cv.getContext('2d');
const SC = cv.width / N;
let vx, vy, dye; // velocity x/y and dye fields, size N*N
function blank(){ return new Float32Array(N*N); }
function reset(){ vx = blank(); vy = blank(); dye = blank(); }
reset();
const idx = (i,j) => i + j*N;
// Semi-Lagrangian advection: trace each cell back along the velocity field.
function advect(field){
const out = new Float32Array(N*N);
for(let j=0;j<N;j++) for(let i=0;i<N;i++){
let x = i - vx[idx(i,j)]*0.6;
let y = j - vy[idx(i,j)]*0.6;
x = Math.max(0.5, Math.min(N-1.5, x));
y = Math.max(0.5, Math.min(N-1.5, y));
const i0=Math.floor(x), j0=Math.floor(y), i1=i0+1, j1=j0+1;
const sx=x-i0, sy=y-j0;
out[idx(i,j)] =
(1-sx)*((1-sy)*field[idx(i0,j0)] + sy*field[idx(i0,j1)]) +
sx*((1-sy)*field[idx(i1,j0)] + sy*field[idx(i1,j1)]);
}
return out;
}
// Light diffusion = viscosity. Averages each cell toward its neighbours.
function diffuse(field, amt){
const out = new Float32Array(N*N);
for(let j=0;j<N;j++) for(let i=0;i<N;i++){
const c = field[idx(i,j)];
const l = field[idx(Math.max(0,i-1),j)];
const r = field[idx(Math.min(N-1,i+1),j)];
const u = field[idx(i,Math.max(0,j-1))];
const d = field[idx(i,Math.min(N-1,j+1))];
out[idx(i,j)] = c + amt*((l+r+u+d)/4 - c);
}
return out;
}
function step(){
vx = advect(vx); vy = advect(vy);
vx = diffuse(vx, 0.18); vy = diffuse(vy, 0.18);
dye = advect(dye);
dye = diffuse(dye, 0.06);
for(let k=0;k<N*N;k++){ vx[k]*=0.992; vy[k]*=0.992; dye[k]*=0.985; }
}
function render(){
const img = ctx.createImageData(cv.width, cv.height);
for(let py=0;py<cv.height;py++) for(let px=0;px<cv.width;px++){
const i = Math.min(N-1, Math.floor(px/SC));
const j = Math.min(N-1, Math.floor(py/SC));
const d = Math.min(1, dye[idx(i,j)]);
const speed = Math.min(1, Math.hypot(vx[idx(i,j)], vy[idx(i,j)])*0.5);
const o = (px + py*cv.width)*4;
img.data[o] = 20 + 235*d*(0.4+0.6*speed);
img.data[o+1] = 40 + 150*d + 60*speed;
img.data[o+2] = 60 + 180*d + 120*(1-speed)*d;
img.data[o+3] = 255;
}
ctx.putImageData(img, 0, 0);
}
// Inject momentum + dye in a small blob around the pointer.
function splat(i, j, fx, fy){
const R = 3;
for(let dj=-R;dj<=R;dj++) for(let di=-R;di<=R;di++){
const ni=i+di, nj=j+dj;
if(ni<0||ni>=N||nj<0||nj>=N) continue;
const w = Math.exp(-(di*di+dj*dj)/4);
vx[idx(ni,nj)] += fx*w;
vy[idx(ni,nj)] += fy*w;
dye[idx(ni,nj)] = Math.min(1, dye[idx(ni,nj)] + w);
}
}
let dragging=false, lastI=0, lastJ=0;
function toGrid(e){
const rect = cv.getBoundingClientRect();
const t = e.touches ? e.touches[0] : e;
return [ ((t.clientX-rect.left)/rect.width)*N, ((t.clientY-rect.top)/rect.height)*N ];
}
function down(e){ e.preventDefault(); dragging=true; const [i,j]=toGrid(e); lastI=i; lastJ=j;
setStatus('Keep dragging — watch the swirl form.'); }
function move(e){ if(!dragging) return; e.preventDefault();
const [i,j]=toGrid(e); splat(Math.floor(i), Math.floor(j), (i-lastI)*4, (j-lastJ)*4);
lastI=i; lastJ=j; }
function up(){ dragging=false; }
cv.addEventListener('mousedown', down); cv.addEventListener('mousemove', move);
window.addEventListener('mouseup', up);
cv.addEventListener('touchstart', down); cv.addEventListener('touchmove', move);
window.addEventListener('touchend', up);
function setStatus(t){ document.getElementById('status').textContent = t; }
// Auto-stir: a moving source that quickly turns laminar push into vortices.
let auto=0;
document.getElementById('stir').addEventListener('click', ()=>{ auto = 140;
setStatus('Auto-stirring — laminar flow is breaking into eddies.'); });
document.getElementById('reset').addEventListener('click', ()=>{ reset();
setStatus('The fluid is calm again. Drag to stir.'); });
function loop(){
if(auto>0){ const t = (140-auto)*0.12;
const i = Math.floor(N/2 + Math.cos(t)*N*0.28);
const j = Math.floor(N/2 + Math.sin(t*1.3)*N*0.28);
splat(i, j, -Math.sin(t)*6, Math.cos(t)*6);
auto--;
if(auto===0) setStatus('Order dissolved into turbulence. In 2D it stays smooth forever.');
}
step(); render();
requestAnimationFrame(loop);
}
loop();
<p class="hint">A tiny <b>2D fluid</b>. <b>Drag</b> across the canvas to push the liquid and inject swirl, then watch a clean stroke break up into curling <b>vortices</b> — the fingerprint of turbulence. In 2D this stays smooth forever; in 3D, whether it can blow up is a million-dollar open question.</p>
<canvas id="fluid" width="240" height="240"></canvas>
<div class="status" id="status">Drag across the fluid to stir it.</div>
<div class="btns">
<button id="stir" type="button">Auto-stir</button>
<button id="reset" type="button" class="ghost">Calm the fluid</button>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .9rem; color: #444; margin: 0 0 .7rem; line-height: 1.45; }
#fluid { width: 240px; height: 240px; border-radius: 10px; border: 1px solid #cdd9e3;
background: #0b1b2b; touch-action: none; cursor: crosshair; display: block; }
.status { font-size: 1rem; font-weight: 600; margin: .6rem 0; min-height: 1.4em; color: #1d3557; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
// A minimal 2D incompressible-fluid feel: we advect a scalar "dye" field and a velocity field on a grid. Dragging adds momentum; the field is diffused and advected each frame, so a straight push curls into vortices. This is the 2D case — provably smooth for all time (Ladyzhenskaya). The 3D version is the open Navier-Stokes Millennium Problem (Clay Institute, 2000).
const N = 48; // grid resolution
const cv = document.getElementById('fluid');
const ctx = cv.getContext('2d');
const SC = cv.width / N;
let vx, vy, dye; // velocity x/y and dye fields, size N*N
function blank(){ return new Float32Array(N*N); }
function reset(){ vx = blank(); vy = blank(); dye = blank(); }
reset();
const idx = (i,j) => i + j*N;
// Semi-Lagrangian advection: trace each cell back along the velocity field.
function advect(field){
const out = new Float32Array(N*N);
for(let j=0;j<N;j++) for(let i=0;i<N;i++){
let x = i - vx[idx(i,j)]*0.6;
let y = j - vy[idx(i,j)]*0.6;
x = Math.max(0.5, Math.min(N-1.5, x));
y = Math.max(0.5, Math.min(N-1.5, y));
const i0=Math.floor(x), j0=Math.floor(y), i1=i0+1, j1=j0+1;
const sx=x-i0, sy=y-j0;
out[idx(i,j)] =
(1-sx)*((1-sy)*field[idx(i0,j0)] + sy*field[idx(i0,j1)]) +
sx*((1-sy)*field[idx(i1,j0)] + sy*field[idx(i1,j1)]);
}
return out;
}
// Light diffusion = viscosity. Averages each cell toward its neighbours.
function diffuse(field, amt){
const out = new Float32Array(N*N);
for(let j=0;j<N;j++) for(let i=0;i<N;i++){
const c = field[idx(i,j)];
const l = field[idx(Math.max(0,i-1),j)];
const r = field[idx(Math.min(N-1,i+1),j)];
const u = field[idx(i,Math.max(0,j-1))];
const d = field[idx(i,Math.min(N-1,j+1))];
out[idx(i,j)] = c + amt*((l+r+u+d)/4 - c);
}
return out;
}
function step(){
vx = advect(vx); vy = advect(vy);
vx = diffuse(vx, 0.18); vy = diffuse(vy, 0.18);
dye = advect(dye);
dye = diffuse(dye, 0.06);
for(let k=0;k<N*N;k++){ vx[k]*=0.992; vy[k]*=0.992; dye[k]*=0.985; }
}
function render(){
const img = ctx.createImageData(cv.width, cv.height);
for(let py=0;py<cv.height;py++) for(let px=0;px<cv.width;px++){
const i = Math.min(N-1, Math.floor(px/SC));
const j = Math.min(N-1, Math.floor(py/SC));
const d = Math.min(1, dye[idx(i,j)]);
const speed = Math.min(1, Math.hypot(vx[idx(i,j)], vy[idx(i,j)])*0.5);
const o = (px + py*cv.width)*4;
img.data[o] = 20 + 235*d*(0.4+0.6*speed);
img.data[o+1] = 40 + 150*d + 60*speed;
img.data[o+2] = 60 + 180*d + 120*(1-speed)*d;
img.data[o+3] = 255;
}
ctx.putImageData(img, 0, 0);
}
// Inject momentum + dye in a small blob around the pointer.
function splat(i, j, fx, fy){
const R = 3;
for(let dj=-R;dj<=R;dj++) for(let di=-R;di<=R;di++){
const ni=i+di, nj=j+dj;
if(ni<0||ni>=N||nj<0||nj>=N) continue;
const w = Math.exp(-(di*di+dj*dj)/4);
vx[idx(ni,nj)] += fx*w;
vy[idx(ni,nj)] += fy*w;
dye[idx(ni,nj)] = Math.min(1, dye[idx(ni,nj)] + w);
}
}
let dragging=false, lastI=0, lastJ=0;
function toGrid(e){
const rect = cv.getBoundingClientRect();
const t = e.touches ? e.touches[0] : e;
return [ ((t.clientX-rect.left)/rect.width)*N, ((t.clientY-rect.top)/rect.height)*N ];
}
function down(e){ e.preventDefault(); dragging=true; const [i,j]=toGrid(e); lastI=i; lastJ=j;
setStatus('Keep dragging — watch the swirl form.'); }
function move(e){ if(!dragging) return; e.preventDefault();
const [i,j]=toGrid(e); splat(Math.floor(i), Math.floor(j), (i-lastI)*4, (j-lastJ)*4);
lastI=i; lastJ=j; }
function up(){ dragging=false; }
cv.addEventListener('mousedown', down); cv.addEventListener('mousemove', move);
window.addEventListener('mouseup', up);
cv.addEventListener('touchstart', down); cv.addEventListener('touchmove', move);
window.addEventListener('touchend', up);
function setStatus(t){ document.getElementById('status').textContent = t; }
// Auto-stir: a moving source that quickly turns laminar push into vortices.
let auto=0;
document.getElementById('stir').addEventListener('click', ()=>{ auto = 140;
setStatus('Auto-stirring — laminar flow is breaking into eddies.'); });
document.getElementById('reset').addEventListener('click', ()=>{ reset();
setStatus('The fluid is calm again. Drag to stir.'); });
function loop(){
if(auto>0){ const t = (140-auto)*0.12;
const i = Math.floor(N/2 + Math.cos(t)*N*0.28);
const j = Math.floor(N/2 + Math.sin(t*1.3)*N*0.28);
splat(i, j, -Math.sin(t)*6, Math.cos(t)*6);
auto--;
if(auto===0) setStatus('Order dissolved into turbulence. In 2D it stays smooth forever.');
}
step(); render();
requestAnimationFrame(loop);
}
loop();
Comments
Loading comments...