⭐ Built in the Agent OS — get it inside the AI Profit BoardroomJoin AIPB →

Liquid in a Bowl

Click and drag to swirl the liquid.

``` Fluid code: ```javascript const canvas = document.getElementById('c'); const ctx = canvas.getContext('2d'); const N = 150; const size = (N+2)*(N+2); canvas.width = N+2; canvas.height = N+2; const u = new Float32Array(size), v = new Float32Array(size); const u_prev = new Float32Array(size), v_prev = new Float32Array(size); const dens = new Float32Array(size), dens_prev = new Float32Array(size); const dt = 0.1; const diff = 0.0001; // diffusion const visc = 0.0001; // viscosity function IX(i,j){ return i*(N+2)+j; } function add_source(x, s, dt){ for(let i=0;iN+0.5) x=N+0.5; i0=Math.floor(x); i1=i0+1; if(y<0.5) y=0.5; if(y>N+0.5) y=N+0.5; j0=Math.floor(y); j1=j0+1; s1 = x-i0; s0 = 1-s1; t1 = y-j0; t0 = 1-t1; d[IX(i,j)] = s0*(t0*d0[IX(i0,j0)]+t1*d0[IX(i0,j1)]) + s1*(t0*d0[IX(i1,j0)]+t1*d0[IX(i1,j1)]); } } set_bnd(b,d); } ``` Step: ```javascript function step(){ // velocity diffuse(1, u_prev, u, visc, dt); diffuse(2, v_prev, v, visc, dt); project(u_prev, v_prev, u, v); advect(1, u, u_prev, u_prev, v_prev, dt); advect(2, v, v_prev, u_prev, v_prev, dt); project(u, v, u_prev, v_prev); // density diffuse(0, dens_prev, dens, diff, dt); advect(0, dens, dens_prev, u, v, dt); } ``` Note: In project, p and div are arrays. Need to allocate them. I used u and v as temporary arrays? Actually in the first project, I passed u_prev, v_prev, u, v; but u and v are velocity arrays, using them as p/div will overwrite velocity. That's wrong. Need separate arrays for div and p. Let me re-check classic code: ```javascript void project ( float * u , float * v , float * p , float * div ) { for(...){ div[IX(i,j)] = ...; p[IX(i,j)] = 0; } set_bnd (0, div); set_bnd (0, p); lin_solve (0, p, div, 1, 4); for(...){ u[IX(i,j)] -= 0.5*(p[IX(i+1,j)]-p[IX(i-1,j)])*N; v[IX(i,j)] -= 0.5*(p[IX(i,j+1)]-p[IX(i,j-1)])*N; } set_bnd (1, u); set_bnd (2, v); } ``` Here p and div are separate scratch arrays. So I need to allocate `p` and `div` arrays. Then: ```javascript function vel_step(){ diffuse(1, u_prev, u, visc, dt); diffuse(2, v_prev, v, visc, dt); project(u_prev, v_prev, u, v); // project(u, v, p, div)? Wait classic: project(u_prev, v_prev, u, v) where u acts as p and v as div? No. } ``` Let me re-check classic: In `vel_step`, after diffuse, `u_prev` and `v_prev` are the diffused velocities. Then project them to be mass-conserving. The function signature in Stam's code: ```javascript void vel_step ( int N, float * u, float * v, float * u0, float * v0, float visc, float dt ) { add_source ( N, u, u0, dt ); add_source ( N, v, v0, dt ); SWAP ( u0, u ); diffuse ( N, 1, u, u0, visc, dt ); SWAP ( v0, v ); diffuse ( N, 2, v, v0, visc, dt ); project ( N, u, v, u0, v0 ); SWAP ( u0, u ); SWAP ( v0, v ); advect ( N, 1, u, u0, u0, v0, dt ); advect ( N, 2, v, v0, u0, v0, dt ); project ( N, u, v, u0, v0 ); } ``` Here `u0` and `v0` are used as p and div in project. So in the first project, `u0` and `v0` are overwritten with p and div. Then they are swapped with u and v before advect. In our implementation, `u_prev` and `v_prev` can serve this dual purpose. So: ```javascript function vel_step(){ diffuse(1, u_prev, u, visc, dt); diffuse(2, v_prev, v, visc, dt); project(u_prev, v_prev, u, v); // u_prev=div, v_prev=p? Actually project(u,v, p,div) where p=u_prev, div=v_prev? Need to match signature. } ``` Wait signature `project(u, v, p, div)`? In classic code, `project(N, u, v, u0, v0)` means u and v are the velocities to project, u0 and v0 are scratch for p and div. So my function should be `project(u, v, p, div)` and inside modifies u and v using p and div as scratch. Then: ```javascript function vel_step(){ diffuse(1, u_prev, u, visc, dt); // u_prev = diffused u diffuse(2, v_prev, v, visc, dt); // v_prev = diffused v project(u_prev, v_prev, u, v); // project velocities u_prev, v_prev, using u and v as scratch p,div // Now u_prev and v_prev are divergence-free advect(1, u, u_prev, u_prev, v_prev, dt); advect(2, v, v_prev, u_prev, v_prev, dt); project(u, v, u_prev, v_prev); // project u,v using u_prev,v_prev as scratch } ``` Yes, this matches classic. Note `project` signature: `project(u, v, p, div)`. In first call, velocities are u_prev,v_prev, scratch is u,v. In second call, velocities are u,v, scratch is u_prev,v_prev. Good. Similarly dens_step: ```javascript function dens_step(){ diffuse(0, dens_prev, dens, diff, dt); advect(0, dens, dens_prev, u, v, dt); } ``` But we also need add_source. Since we directly modify dens and u/v in interaction, no need for separate source arrays. Interaction: ```javascript let mouse = {x:0,y:0,px:0,py:0,down:false}; function getGridPos(e){ const rect = canvas.getBoundingClientRect(); const x = (e.clientX - rect.left) / rect.width * (N+2); const y = (e.clientY - rect.top) / rect.height * (N+2); return {x,y}; } canvas.addEventListener('mousedown', e=>{ mouse.down=true; const p=getGridPos(e); mouse.x=p.x; mouse.y=p.y; mouse.px=p.x; mouse.py=p.y; addSplash(p.x,p.y,2,0,0); }); canvas.addEventListener('mousemove', e=>{ if(!mouse.down) return; const p=getGridPos(e); const vx = (p.x - mouse.px) * 5; const vy = (p.y - mouse.py) * 5; addSplash(p.x, p.y, 2, vx, vy); mouse.px=p.x; mouse.py=p.y; }); canvas.addEventListener('mouseup', ()=>mouse.down=false); canvas.addEventListener('touchstart', e=>{...}, {passive:false}); canvas.addEventListener('touchmove', e=>{...}, {passive:false}); ``` `addSplash(x,y, amount, vx, vy)` adds density and velocity in a Gaussian area. Need to ensure within bounds. Can iterate over a small radius. ```javascript function addSplash(x, y, amount, vx, vy){ const r = 4; for(let j=Math.max(1,Math.floor(y-r)); j<=Math.min(N,Math.floor(y+r)); j++){ for(let i=Math.max(1,Math.floor(x-r)); i<=Math.min(N,Math.floor(x+r)); i++){ const dx = i-x, dy = j-y; const d2 = dx*dx+dy*dy; if(d2 > r*r) continue; const f = Math.exp(-d2/(0.5*r*r)); const idx = IX(i,j); dens[idx] += amount * f; u[idx] += vx * f; v[idx] += vy * f; } } } ``` Rendering: Use `createImageData` and `putImageData`. Map density to color. ```javascript function render(){ const img = ctx.createImageData(N+2, N+2); const data = img.data; for(let j=0;j bowlR, set color to background and maybe set density to 0 to clear. But if density accumulates outside, it will be hidden. To also clear it, in step we can set outside density to 0. Alternatively, in render, only color inside bowl; outside is transparent or background. But the bowl outline should be visible. Let's set outside color to [2,4,10] (dark background). Also, in `step`, after advect, we can zero out outside cells. Actually, for the simulation, we can keep rectangular boundaries but in `set_bnd` enforce rectangular walls. The visible bowl masks outside. That's fine. Density outside remains hidden. But to avoid wasting computation and possible artifacts, zero outside each step. Simpler: In render, only show inside bowl. In step, no special handling. Interactions only add inside bowl. This is enough. But initial state: maybe add a small splash in the center so the first frame has visible content. Also, to make it look like liquid in a bowl, maybe add some initial swirl or just still liquid. Now animation loop: ```javascript function frame(){ step(); if(mouse.down && (mouse.x!==mouse.px || mouse.y!==mouse.py)) { // already added in move event } render(); drawBowl(); requestAnimationFrame(frame); } ``` Need to add initial density. e.g., `addSplash(cx, cy, 5, 0, 0);` at start. Performance: `createImageData` each frame allocates a new object. Better create once outside. But for simplicity, create each frame; modern browsers handle it. To be safe, create once. ```javascript const imgData = ctx.createImageData(N+2, N+2); ``` In render, fill `imgData.data`. Now consider the constants: `dt=0.1`, `diff=0.0001`, `visc=0.0001`. N=150. The Jacobi iterations 20 might be enough. For better stability, can increase to 30. Need to test. Potential issue: In `project`, the pressure solve with only 20 iterations may leave some divergence, causing visual artifacts. But usually it's okay. Now think about the bowl boundary more carefully. If the bowl mask only shows a circle, the rectangular boundary walls may be visible near the circle tangent. Actually, since the circle is inscribed, the rectangle corners are outside the circle and hidden. The fluid will flow along the rectangle walls but only the inscribed circle part is visible. At the edges of the circle, the boundary is the rectangle wall, which is straight, not curved. So the visible boundary will be straight at 0,90,180,270 degrees along the circle edge. This might look slightly unnatural, but likely acceptable. To improve, I can implement a circular boundary condition. Let's consider if it's worth it. Implementing circular boundary condition in projection: - For each cell, compute mask (inside/outside). Solid cells: velocity zero, density zero. - In `lin_solve`, for fluid cells, use neighbors. For solid neighbors, use current cell's value (Neumann) for pressure, or 0 (Dirichlet). For velocity diffusion, solid neighbors should be zero. - In `project`, divergence only for fluid cells. Pressure solve with solid cells treated as Neumann (equal pressure). Then adjust velocity. This is manageable. Let me define `solid` array. For set_bnd, for fluid cells, do nothing; for solid cells, set to 0. For lin_solve, when updating fluid cells, for each neighbor, if solid, use the current cell's value (for pressure) or 0 (for velocity?). Hmm. Actually, for velocity diffusion with solid walls, the no-slip condition means velocity at wall is 0. So when solving diffusion for a fluid cell adjacent to a solid cell, the solid neighbor contributes 0. For pressure, the normal derivative at the wall is 0, so pressure at solid equals pressure at adjacent fluid. So in pressure Poisson, for a fluid cell, the neighbor sum uses fluid neighbors' pressure; if neighbor is solid, treat it as the current cell's pressure. This requires knowing if neighbor is solid for each direction. It complicates lin_solve because the behavior depends on b (velocity or pressure) and mask. We can create a custom solve. Given the complexity and the user's main request is smooth viscous flow and beautiful gradients, I think a rectangular boundary with circular mask is sufficient and much more robust. I'll go with that. Add a glassy bowl overlay to sell the effect. One more thing: to make the "bowl" feel more physical, I can add a weak inward force near the visible boundary, simulating surface tension or gravity pulling the liquid down? No, not needed. Maybe add a "floor" effect by making the bottom have higher density? Could add gravity: add a small downward velocity each frame. This would make the liquid settle at the bottom of the bowl. But the simulation is 2D top-down; gravity doesn't make sense. The bowl is viewed from above. So no gravity. Actually, "liquid in a bowl" could be viewed from the side. But 2D fluid simulation on a grid is usually top-down. A side view would require free surface simulation, which is more complex. So top-down with a circular bowl outline is appropriate. Now check the prompt: "Liquid in a bowl — an interactive 2D fluid simulation, click and drag to add density and velocity, smooth viscous flow, beautiful colour gradients." It doesn't specify perspective. Top-down is fine. Now think about colors and visual polish. Can make the background dark. UI text should be subtle. Maybe add a hint that fades out. Potential improvement: Use a more sophisticated color mapping with multiple octaves or a "fire" palette. But the designed blue-purple-pink is beautiful. Potential improvement: Add bloom/glow via additive blending. But with image data rendering, glow isn't easy. Could use a radial gradient overlay? Not necessary. The color gradient itself is beautiful. Potential improvement: Render density with alpha compositing to create a "liquid" feel. But image data with full opacity and color mapping is okay. Potential issue: The canvas is stretched by CSS to full screen, so mouse coordinates mapping uses bounding rect, which is correct. Potential issue: On high-DPI screens, CSS width/height is physical pixels, and canvas internal resolution is N+2. The mapping uses rect width/height, correct. Potential issue: Touch events need `preventDefault` to avoid scrolling. Potential issue: `requestAnimationFrame` may run at 60fps. The simulation dt is fixed, so time step is per frame. That's fine. Potential issue: The `addSplash` event handler is called every mousemove; if the mouse moves fast, it adds