A Q U A R I U M
↔ bougez ou inclinez l'écran
/* ============================================================ SWE FLUID CORE State: h[i] = water height (column height from bottom) u[i] = horizontal velocity (depth-averaged) Equations: ∂h/∂t + ∂(hu)/∂x = 0 (mass conservation) ∂u/∂t + u·∂u/∂x = -g·∂h/∂x (momentum, gravity-driven) + viscosity damping + wall BC + tilt gravity ============================================================ */ const N = 180; // number of fluid columns (high res) const G = 980; // gravity px/s² (tuned for pixel space) const VISC = 0.018; // kinematic viscosity (damping) const FILL = 0.58; // rest fill fraction of tank height let h, u, hPrev, uPrev; // SWE state arrays let tilt = 0; // lateral gravity −1..1 let gLateral = 0; // smoothed tilt acceleration function initFluid() { h = new Float64Array(N); u = new Float64Array(N); hPrev = new Float64Array(N); uPrev = new Float64Array(N); const restH = H * FILL; for(let i = 0; i < N; i++) { h[i] = restH; u[i] = 0; } } /* --- Godunov-style upwind SWE step (stable, wave-capturing) --- */ function stepFluid(dt) { // Clamp dt for stability dt = Math.min(dt, 0.012); const dx = W / N; const gLat = tilt * G * 0.22; // lateral body force // Lax-Friedrichs flux at each interface i+½ const Fh = new Float64Array(N + 1); const Fu = new Float64Array(N + 1); for(let i = 0; i < N - 1; i++) { const hL = h[i], hR = h[i+1]; const uL = u[i], uR = u[i+1]; // wave speed (Roe average) const c = Math.sqrt(G * 0.5 * (hL + hR + 1e-6)); // Rusanov (local Lax-Friedrichs) flux — robust & fast const sMax = Math.max(Math.abs(uL) + c, Math.abs(uR) + c); // Flux of h: F_h = h*u Fh[i+1] = 0.5 * (hL*uL + hR*uR) - 0.5 * sMax * (hR - hL); // Flux of hu: F_hu = h*u² + ½g*h² const phiL = hL*uL*uL + 0.5*G*hL*hL; const phiR = hR*uR*uR + 0.5*G*hR*hR; Fu[i+1] = 0.5 * (phiL + phiR) - 0.5 * sMax * (hR*uR - hL*uL); } // Wall BCs: reflecting (zero normal flux) Fh[0] = 0; Fu[0] = 0; Fh[N] = 0; Fu[N] = 0; // Update conserved variables: h, hu → then derive u for(let i = 0; i < N; i++) { const dh = -(dt / dx) * (Fh[i+1] - Fh[i]); const dhu = -(dt / dx) * (Fu[i+1] - Fu[i]); const hNew = Math.max(h[i] + dh, 1.0); // h > 0 always const huNew = h[i]*u[i] + dhu + dt * h[i] * gLat; // body force // Viscosity: diffuse u let uDiff = 0; if(i > 0) uDiff += u[i-1] - u[i]; if(i < N-1) uDiff += u[i+1] - u[i]; const uNew = huNew / hNew + VISC * uDiff; hPrev[i] = hNew; uPrev[i] = uNew * (1 - VISC * 0.5); // global damping } // Swap buffers const tmp = h; h = hPrev; hPrev = tmp; const tmu = u; u = uPrev; uPrev = tmu; // Volume correction — keep total water volume constant let totalH = 0; const targetH = H * FILL * N; for(let i = 0; i < N; i++) totalH += h[i]; const correction = (targetH - totalH) / N; for(let i = 0; i < N; i++) h[i] = Math.max(h[i] + correction, 0.5); } /* --- Surface Y position for column i (from top) --- */ function surfaceY(i) { return H - h[i]; } /* --- Surface normal for caustics / specular (finite difference) --- */ function surfaceNormal(i) { const dx = W / N; let dydx; if(i === 0) dydx = (surfaceY(1) - surfaceY(0)) / dx; else if(i === N-1) dydx = (surfaceY(N-1) - surfaceY(N-2)) / dx; else dydx = (surfaceY(i+1) - surfaceY(i-1)) / (2*dx); // Normal vector (normalised): perpendicular to tangent (1, dydx) const len = Math.sqrt(1 + dydx*dydx); return { nx: -dydx/len, ny: 1/len, slope: dydx }; } /* --- Perturb fluid at pixel x with velocity impulse --- */ function splash(px, strength = 8) { const ci = Math.floor((px / W) * N); const r = 4; // radius in cells for(let i = Math.max(0, ci-r); i <= Math.min(N-1, ci+r); i++) { const dist = Math.abs(i - ci) / r; const w = Math.exp(-dist * dist * 3); u[i] += strength * w * 60; // velocity impulse } } /* ============================================================ DRAW WATER — volumetric multi-layer rendering ============================================================ */ function drawWater() { wCtx.clearRect(0, 0, W, H); const dx = W / N; /* --- Layer 1: Deep water body --- */ const bodyGrad = wCtx.createLinearGradient(0, 0, 0, H); bodyGrad.addColorStop(0, 'rgba(8, 80, 160, 0.0)'); bodyGrad.addColorStop(0.05,'rgba(12, 95, 175, 0.65)'); bodyGrad.addColorStop(0.4, 'rgba(8, 65, 140, 0.82)'); bodyGrad.addColorStop(0.75,'rgba(4, 40, 100, 0.92)'); bodyGrad.addColorStop(1, 'rgba(2, 20, 60, 0.97)'); buildSurfacePath(wCtx, 0); wCtx.fillStyle = bodyGrad; wCtx.fill(); /* --- Layer 2: Mid-water scattering glow (Mie-like) --- */ const midY = H * 0.65; const scatter = wCtx.createLinearGradient(0, surfaceY(N>>1), 0, H); scatter.addColorStop(0, 'rgba(30,140,220, 0.18)'); scatter.addColorStop(0.35,'rgba(15,100,190, 0.12)'); scatter.addColorStop(1, 'rgba(5, 50, 130, 0.0)'); buildSurfacePath(wCtx, 0); wCtx.fillStyle = scatter; wCtx.fill(); /* --- Layer 3: Caustic shimmer stripes under surface --- */ drawCausticStripes(); /* --- Layer 4: Surface specular highlights --- */ drawSpecular(); /* --- Layer 5: Surface line with foam --- */ drawSurfaceLine(); } /* Build the water polygon path (offset = vertical shift for layering) */ function buildSurfacePath(ctx, yOffset) { const dx = W / N; ctx.beginPath(); ctx.moveTo(0, surfaceY(0) + yOffset); for(let i = 1; i < N; i++) { // Catmull-Rom style smooth curve via midpoints const x0 = (i-1) * dx, x1 = i * dx; const mx = (x0 + x1) * 0.5; const my = (surfaceY(i-1) + surfaceY(i)) * 0.5 + yOffset; wCtx.quadraticCurveTo(x0, surfaceY(i-1) + yOffset, mx, my); } wCtx.lineTo(W, surfaceY(N-1) + yOffset); wCtx.lineTo(W, H); wCtx.lineTo(0, H); wCtx.closePath(); } /* Caustic light stripes: driven by surface curvature focusing light */ function drawCausticStripes() { const dx = W / N; for(let i = 1; i < N - 1; i++) { const n = surfaceNormal(i); // Curvature proxy: second derivative of surface height const curv = (surfaceY(i+1) + surfaceY(i-1) - 2*surfaceY(i)); if(curv < -0.4) { // concave → focusing → bright caustic const intensity = Math.min(1, -curv * 0.08); const x = i * dx; const sy = surfaceY(i); // Draw a refracted light column downward const cgrad = wCtx.createLinearGradient(x, sy, x, H); cgrad.addColorStop(0, `rgba(140,210,255,${intensity * 0.22})`); cgrad.addColorStop(0.3, `rgba(100,180,240,${intensity * 0.10})`); cgrad.addColorStop(1, 'rgba(60,140,200,0)'); wCtx.fillStyle = cgrad; wCtx.fillRect(x - dx*1.5, sy, dx*3, H - sy); } } } /* Specular highlights: mirror-like reflections on surface peaks */ function drawSpecular() { const dx = W / N; wCtx.save(); // Clip to water body buildSurfacePath(wCtx, 0); wCtx.clip(); for(let i = 1; i < N-1; i++) { const n = surfaceNormal(i); // Specular intensity: how close normal is to vertical (Blinn-Phong) const spec = Math.pow(Math.max(0, n.ny), 12); if(spec < 0.01) continue; const x = i * dx; const sy = surfaceY(i); const sg = wCtx.createRadialGradient(x, sy, 0, x, sy, dx*3); sg.addColorStop(0, `rgba(220,240,255,${spec * 0.7})`); sg.addColorStop(0.4, `rgba(180,220,255,${spec * 0.25})`); sg.addColorStop(1, 'rgba(160,210,255,0)'); wCtx.fillStyle = sg; wCtx.fillRect(x - dx*3, sy - 4, dx*6, 18); } wCtx.restore(); } /* Surface line: smooth foam edge + secondary wave shimmer */ function drawSurfaceLine() { const dx = W / N; // Primary surface — bright edge wCtx.beginPath(); wCtx.moveTo(0, surfaceY(0)); for(let i = 1; i < N; i++) { const x0 = (i-1)*dx, x1 = i*dx; const mx = (x0+x1)*0.5, my = (surfaceY(i-1)+surfaceY(i))*0.5; wCtx.quadraticCurveTo(x0, surfaceY(i-1), mx, my); } wCtx.lineTo(W, surfaceY(N-1)); wCtx.strokeStyle = 'rgba(200,235,255, 0.70)'; wCtx.lineWidth = 2.5; wCtx.stroke(); // Foam: secondary softer line slightly below wCtx.beginPath(); wCtx.moveTo(0, surfaceY(0)+3); for(let i = 1; i < N; i++) { const x0=(i-1)*dx, x1=i*dx; const mx=(x0+x1)*0.5, my=(surfaceY(i-1)+surfaceY(i))*0.5+3; wCtx.quadraticCurveTo(x0, surfaceY(i-1)+3, mx, my); } wCtx.lineTo(W, surfaceY(N-1)+3); wCtx.strokeStyle = 'rgba(255,255,255, 0.18)'; wCtx.lineWidth = 5; wCtx.stroke(); // Sub-surface diffuse band wCtx.beginPath(); wCtx.moveTo(0, surfaceY(0)+1); for(let i = 1; i < N; i++) { const x0=(i-1)*dx, x1=i*dx; const mx=(x0+x1)*0.5, my=(surfaceY(i-1)+surfaceY(i))*0.5+1; wCtx.quadraticCurveTo(x0, surfaceY(i-1)+1, mx, my); } wCtx.lineTo(W, surfaceY(N-1)+1); wCtx.lineTo(W, surfaceY(N-1)+22); // back along offset for(let i=N-1;i>=0;i--){ const x0=i*dx; wCtx.lineTo(x0, surfaceY(i)+22); } wCtx.closePath(); const subGrad = wCtx.createLinearGradient(0,0,0,30); subGrad.addColorStop(0,'rgba(120,200,255,0.18)'); subGrad.addColorStop(1,'rgba(80,160,230,0)'); wCtx.fillStyle = subGrad; wCtx.fill(); } /* ============================================================ FISH ============================================================ */ const FISH_COLORS = [ ['#ff7a2f','#ff4400'], ['#ffcc00','#ff8800'], ['#ff4488','#cc1166'], ['#44ddff','#0077cc'], ['#88ff88','#22aa44'], ]; class Fish { constructor(){ this.reset(true); } reset(init=false){ this.size = 18 + Math.random()*22; const [c1,c2] = FISH_COLORS[Math.floor(Math.random()*FISH_COLORS.length)]; this.col1 = c1; this.col2 = c2; this.speed = (0.4 + Math.random()*0.6) * (Math.random()<0.5?1:-1); this.y = H*0.55 + Math.random()*(H*0.38); this.x = this.speed>0 ? -60 : W+60; this.vy = 0; this.ay = 0; this.targetY = this.y; this.tailPhase = Math.random()*Math.PI*2; this.finPhase = Math.random()*Math.PI*2; this.wobble = 0; if(init) this.x = Math.random()*W; } getWaterY(x){ const i = Math.max(0, Math.min(N-1, Math.floor((x/W)*N))); return surfaceY(i); } update(dt){ this.tailPhase += dt*7; this.finPhase += dt*4; // drift vertically toward target if(Math.random()<0.005) this.targetY = H*0.55 + Math.random()*(H*0.36); this.vy += (this.targetY - this.y)*0.004; this.vy *= 0.94; this.y += this.vy; // stay below water surface const wy = this.getWaterY(this.x); if(this.y < wy + this.size*0.6) { this.y = wy + this.size*0.6; this.vy = Math.abs(this.vy)*0.5; } if(this.y > H - this.size*0.5) this.y = H - this.size*0.5; this.x += this.speed * dt * 60; // wrap if(this.speed>0 && this.x > W+80) this.reset(); if(this.speed<0 && this.x < -80) this.reset(); } draw(ctx){ const dir = this.speed>0 ? 1 : -1; ctx.save(); ctx.translate(this.x, this.y); ctx.scale(dir, 1); const tw = this.size*1.8, th = this.size*0.7; const tail = Math.sin(this.tailPhase)*this.size*0.45; const s = this.size; // tail ctx.beginPath(); ctx.moveTo(-tw*0.55, 0); ctx.lineTo(-tw*0.95, -s*0.55 + tail*0.6); ctx.lineTo(-tw, tail); ctx.lineTo(-tw*0.95, s*0.55 + tail*0.6); ctx.closePath(); const tg = ctx.createLinearGradient(-tw, 0, -tw*0.5, 0); tg.addColorStop(0, this.col2+'88'); tg.addColorStop(1, this.col1); ctx.fillStyle = tg; ctx.fill(); // body ctx.beginPath(); ctx.ellipse(0, 0, tw*0.55, th, 0, 0, Math.PI*2); const bg = ctx.createRadialGradient(-s*0.2, -s*0.1, 0, 0, 0, tw*0.6); bg.addColorStop(0, '#fff8'); bg.addColorStop(0.3, this.col1); bg.addColorStop(1, this.col2); ctx.fillStyle = bg; ctx.fill(); // dorsal fin ctx.beginPath(); const fp = Math.sin(this.finPhase)*s*0.1; ctx.moveTo(-s*0.1, -th+fp); ctx.quadraticCurveTo(s*0.1, -th-s*0.5+fp, s*0.3, -th+fp*0.5); ctx.lineTo(s*0.05, -th*0.7); ctx.closePath(); ctx.fillStyle = this.col2+'aa'; ctx.fill(); // eye ctx.beginPath(); ctx.arc(s*0.35, -s*0.05, s*0.12, 0, Math.PI*2); ctx.fillStyle = '#111'; ctx.fill(); ctx.beginPath(); ctx.arc(s*0.37, -s*0.07, s*0.045, 0, Math.PI*2); ctx.fillStyle = '#fff'; ctx.fill(); // scale shimmer ctx.globalAlpha = 0.12; for(let i=0;i<4;i++){ ctx.beginPath(); const sx = -s*0.3 + i*s*0.22; ctx.arc(sx, 0, s*0.25, Math.PI*0.7, Math.PI*2.3); ctx.strokeStyle = '#fff'; ctx.lineWidth = 1; ctx.stroke(); } ctx.globalAlpha = 1; ctx.restore(); } } /* ============================================================ SEABED & DECORATIONS ============================================================ */ function drawSeabed(ctx){ // sandy bottom const sg = ctx.createLinearGradient(0, H*0.85, 0, H); sg.addColorStop(0, '#c8a86888'); sg.addColorStop(1, '#a07840cc'); ctx.fillStyle = sg; ctx.beginPath(); ctx.moveTo(0, H*0.86); ctx.quadraticCurveTo(W*0.25, H*0.83, W*0.5, H*0.86); ctx.quadraticCurveTo(W*0.75, H*0.89, W, H*0.85); ctx.lineTo(W, H); ctx.lineTo(0, H); ctx.closePath(); ctx.fill(); // pebbles const pebbles = [{x:0.1,y:0.88,r:5},{x:0.22,y:0.9,r:7},{x:0.35,y:0.87,r:4}, {x:0.5,y:0.89,r:6},{x:0.63,y:0.88,r:5},{x:0.78,y:0.91,r:8},{x:0.9,y:0.87,r:5}]; pebbles.forEach(p=>{ ctx.beginPath(); ctx.ellipse(p.x*W, p.y*H, p.r*1.6, p.r*0.9, 0, 0, Math.PI*2); ctx.fillStyle = '#8a6a40cc'; ctx.fill(); ctx.beginPath(); ctx.ellipse(p.x*W-p.r*0.3, p.y*H-p.r*0.3, p.r*0.4, p.r*0.25, 0.5, 0, Math.PI*2); ctx.fillStyle = '#fff5'; ctx.fill(); }); } function drawSeaweed(ctx, t){ const weeds = [ {x:0.08, h:0.22, col:'#1a9944', col2:'#0d6630'}, {x:0.18, h:0.16, col:'#22bb55', col2:'#158833'}, {x:0.72, h:0.25, col:'#1a9944', col2:'#0d6630'}, {x:0.82, h:0.18, col:'#22bb55', col2:'#158833'}, {x:0.55, h:0.14, col:'#33cc66', col2:'#229944'}, ]; weeds.forEach(w=>{ const bx = w.x*W, by = H*0.87; const segs = 7; const sh = w.h*H/segs; ctx.lineWidth = 4; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.beginPath(); ctx.moveTo(bx, by); let cx=bx, cy=by; for(let i=0;i{ const cx=cr.x*W, cy=cr.y*H, s=cr.s; for(let b=0;b<5;b++){ const angle=-Math.PI/2 + (b-2)*0.38; ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(cx+Math.cos(angle)*s*2.2, cy+Math.sin(angle)*s*2.2); ctx.lineWidth = s*0.45; ctx.lineCap='round'; ctx.strokeStyle=cr.c+'cc'; ctx.stroke(); // tip ball ctx.beginPath(); ctx.arc(cx+Math.cos(angle)*s*2.2, cy+Math.sin(angle)*s*2.2, s*0.32, 0, Math.PI*2); ctx.fillStyle=cr.c; ctx.fill(); } }); } /* ============================================================ BUBBLES DOM ============================================================ */ function spawnBubble(){ const b = document.createElement('div'); b.className='bubble'; const sz = 4+Math.random()*10; const x = 20+Math.random()*(W-40); const dur= 3+Math.random()*3; const i = Math.max(0, Math.min(N-1, Math.floor((x/W)*N))); const sy = surfaceY(i); const travel = -(H - sy + sz + 10); b.style.cssText=` width:${sz}px;height:${sz}px; left:${x}px;bottom:${H*0.06}px; --dur:${dur}s; --travel:${travel}px; --sway:${(Math.random()-0.5)*20}px; `; tank.appendChild(b); setTimeout(()=>b.remove(), dur*1000); } setInterval(spawnBubble, 420); /* ============================================================ LIGHT RAYS ============================================================ */ const caustics = document.getElementById('caustics'); for(let i=0;i<7;i++){ const r = document.createElement('div'); r.className='ray'; const left = 5+i*14; const h = 120+Math.random()*160; const d = 3+Math.random()*3; const rot1 = -8+i*2.5; const rot2 = rot1+8+Math.random()*10; r.style.cssText=` left:${left}%;height:${h}px; --d:${d}s;--delay:${-Math.random()*d}s; --r1:${rot1}deg;--r2:${rot2}deg; animation-delay:${-Math.random()*d}s; `; caustics.appendChild(r); } /* ============================================================ INPUT — mouse/touch/tilt ============================================================ */ let lastMX = null; tank.addEventListener('mousemove', e=>{ const rect = tank.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; if(lastMX!==null){ const speed = (x-lastMX)*0.35; splash(x, speed); } lastMX=x; }); tank.addEventListener('mouseleave',()=>lastMX=null); tank.addEventListener('click', e=>{ const rect=tank.getBoundingClientRect(); splash(e.clientX-rect.left, 18); }); // Touch let lastTX=null; tank.addEventListener('touchmove', e=>{ e.preventDefault(); const rect=tank.getBoundingClientRect(); const x=e.touches[0].clientX-rect.left; if(lastTX!==null) splash(x,(x-lastTX)*0.5); lastTX=x; },{passive:false}); tank.addEventListener('touchend',()=>lastTX=null); // Device tilt window.addEventListener('deviceorientation', e=>{ if(e.gamma!==null){ tilt = Math.max(-1, Math.min(1, e.gamma/35)); } }); // Mouse tilt simulation on desktop document.addEventListener('mousemove', e=>{ const cx = window.innerWidth/2; tilt = Math.max(-1, Math.min(1, (e.clientX-cx)/(window.innerWidth*0.4))); }); /* ============================================================ FISH SETUP ============================================================ */ const fishes = Array.from({length:6}, ()=>new Fish()); /* ============================================================ MAIN LOOP — fixed substep for SWE stability ============================================================ */ initFluid(); let last=0, t=0; const SUBSTEPS = 4; function loop(ts){ const dtFrame = Math.min((ts - last) / 1000, 0.032); last = ts; t += dtFrame; // SWE substeps for numerical stability (CFL condition) const dtSub = dtFrame / SUBSTEPS; for(let s = 0; s < SUBSTEPS; s++) stepFluid(dtSub); drawWater(); lCtx.clearRect(0,0,W,H); drawSeabed(lCtx); drawSeaweed(lCtx, t); drawCoral(lCtx); fishes.forEach(f=>{ f.update(dtFrame); f.draw(lCtx); }); requestAnimationFrame(loop); } requestAnimationFrame(ts=>{last=ts; loop(ts);});