All components

Cappen Fluid Simulation

WebGL animation component · Published 2026-07-21 · by vanguardia.dev

Open live demo ↗ Raw prompt (.md)

What it does

Cappen-style GPU fluid simulation built with Three.js: moving the pointer injects ink and velocity splats into double-buffered HalfFloat render targets, and a Navier-Stokes shader solver (advection, curl/vorticity confinement, divergence, 40 pressure iterations) makes the ink swirl and dissipate. The full-screen canvas sits over a white typographic hero with mix-blend-mode: difference, so the flowing ink inverts the giant text beneath it.

How it's built

Category3d-webgl
Techthree
Complexitysection
Performance costheavy
Mobile-safedesktop-first

fluid-simulation webgl three shaders navier-stokes mouse mix-blend-mode hero canvas

Rebuild it with AI

To reproduce this animation in your own project, copy the prompt below into Claude Code, Cursor or any AI coding agent. The prompt is validated — it describes the exact structure, timing and easing, so the agent rebuilds the effect faithfully and you can then adapt colors, copy and layout to your design.

The full prompt

Fluid Simulation Hero (Cappen-style ink over inverted type)

Goal

Build a full-screen landing hero with a giant white typographic headline and a GPU fluid simulation painted on top of everything. Moving the pointer injects swirling "ink" that flows, curls and dissipates like real fluid (a Navier–Stokes solver running entirely in fragment shaders). The fluid canvas uses mix-blend-mode: difference, so the moving white ink inverts whatever it passes over — black over the white page, white over the black headline — producing the signature liquid, self-inverting trail. The star of this piece is the fluid solver, not any DOM animation.

Tech

  • Vanilla HTML / CSS / JS with ES module imports, bundled by Vite.
  • three (npm) is the only JS dependency — a WebGL fluid simulation written by hand in GLSL.
  • No GSAP, no ScrollTrigger, no SplitText, no Lenis. All motion is a requestAnimationFrame

physics loop driving custom shaders. Do not reach for any animation library.

  • Fonts loaded from Google Fonts: Inter (variable) and DM Mono.

Layout / HTML

Semantic, minimal. Body contains a fixed nav, a hero section, and a single fixed canvas.

<nav>
  <div class="nav-logo"><a href="#">Vortex</a></div>
  <div class="nav-links">
    <a href="#">works</a>
    <a href="#">about</a>
    <a href="#">updates</a>
    <a href="#">start a project</a>
  </div>
</nav>

<section class="hero">
  <div class="header">
    <h1>Fluid System In</h1>
    <h1>Constant Field</h1>
    <h1>Of Interaction</h1>
  </div>
</section>

<canvas id="fluid"></canvas>
<script type="module" src="./script.js"></script>

The three <h1> lines are staggered horizontally: line 1 left-aligned (default), line 2 align-self: flex-end (pushed right), line 3 align-self: center.

Styling

  • Palette: page/hero background #ffffff, text #000. Everything is black on white — the fluid

provides all the color via inversion.

  • Type:
  • h1font-family: "Inter", font-weight: 900, text-transform: uppercase,

font-size: clamp(3rem, 10vw, 15rem), line-height: 0.9, letter-spacing: -4%.

  • .nav-logo a — Inter, font-weight: 900, 1rem, uppercase, letter-spacing: -2%.
  • .nav-links afont-family: "DM Mono", font-weight: 500, 0.85rem, uppercase, #000.
  • Reset: * { margin:0; padding:0; box-sizing:border-box }.
  • nav — `position: fixed; top:0; left:0; width:100%; padding:2rem; display:flex;

justify-content:space-between; gap:1rem; z-index:2. .nav-links is display:flex; gap:4rem`.

  • .hero — `position:relative; width:100%; height:100svh; padding:2rem; background:#fff;

display:flex; flex-direction:column; justify-content:center; overflow:hidden. .header` is a flex column.

  • #fluid (critical) — `position:fixed; inset:0; width:100%; height:100%; pointer-events:none;

z-index:100; mix-blend-mode: difference. The pointer-events:none lets mouse events reach the page while the canvas still tracks them on window. The mix-blend-mode: difference` is what makes white ink read as black over the white page and invert the giant type where it crosses it.

  • Responsive @media (max-width:1000px).nav-links becomes a right-aligned vertical column

(flex-direction:column; align-items:flex-end; gap:0); all .hero h1 become align-self:center !important; text-align:center.

The star effect — real-time GPU fluid simulation (be exact)

Implement a stable-fluids Navier–Stokes solver on double-buffered (ping-pong) float render targets. This is a direct GLSL port; reproduce the pipeline, uniforms, and constants exactly.

Renderer & scene
  • THREE.WebGLRenderer({ canvas, alpha: true }).
  • renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); renderer.setSize(innerWidth, innerHeight).
  • Cache dpr = renderer.getPixelRatio(), and simulation width = innerWidth*dpr,

height = innerHeight*dpr. On window resize, re-setSize and recompute width/height.

  • Scene with an OrthographicCamera(-1, 1, 1, -1, 0, 1) and a single full-screen

Mesh(new PlaneGeometry(2, 2)) whose material is swapped every pass. A pass = set the quad's material, renderer.setRenderTarget(target ?? null), renderer.render(scene, camera).

Render targets

All targets: new THREE.WebGLRenderTarget(w, h, { type: THREE.HalfFloatType, depthBuffer: false }). A "double" is { read, write, swap() } where swap exchanges read/write.

  • aspect = width / height.
  • simSize = { w: simResolution, h: Math.round(simResolution / aspect) }.
  • dyeSize = { w: dyeResolution, h: Math.round(dyeResolution / aspect) }.
  • velocity = double at simSize, pressure = double at simSize, dye = double at dyeSize.
  • divergence = single at simSize, curl = single at simSize.
Config constants (use these exact values)
simResolution      = 256
dyeResolution      = 1024
curl (curlStrength)= 50
pressureIterations = 40
velocityDissipation= 0.95
dyeDissipation     = 0.95
splatRadius        = 0.3      // divided by 100 in the shader → 0.003
forceStrength      = 8.5
pressureDecay      = 0.75
threshold          = 1.0
edgeSoftness       = 0.0
inkColor           = THREE.Color(1, 1, 1)   // white
Shaders (all share one trivial vertex shader)

Vertex (all passes): varying vec2 vUv; void main(){ vUv = uv; gl_Position = vec4(position, 1.0); }. Fragment precision headers: precision highp float; (+ precision mediump sampler2D; for the sim passes). Write these nine fragment programs:

  1. splat — uniforms sampler2D uTarget, float aspectRatio, float radius, vec3 color,

vec2 point. vec2 p = vUv - point; p.x *= aspectRatio; gl_FragColor = vec4(texture2D(uTarget,vUv).xyz + exp(-dot(p,p)/radius) * color, 1.0); (adds a Gaussian blob of color centered at point).

  1. advection — uniforms sampler2D uVelocity, uSource, vec2 texelSize, float dt,

float dissipation. Semi-Lagrangian backtrace: gl_FragColor = vec4(dissipation * texture2D(uSource, vUv - dt*texture2D(uVelocity,vUv).xy*texelSize).rgb, 1.0);

  1. divergence — samples velocity at L/R/T/B neighbors (offset by texelSize.x/.y), with a

boundary helper that clamps the sampled uv to [0,1] and negates the fetched velocity component when it goes out of bounds (free-slip walls). Output 0.5 * (vel(R).x - vel(L).x + vel(T).y - vel(B).y) in the red channel.

  1. curltexture2D(uVelocity,R).y - texture2D(uVelocity,L).y - texture2D(uVelocity,T).x + texture2D(uVelocity,B).x in red.
  2. vorticity — uniforms uVelocity, uCurl, texelSize, curlStrength, dt. Compute

vec2 f = normalize(vec2(abs(curl(T)) - abs(curl(B)), abs(curl(R)) - abs(curl(L))) + 0.0001) * curlStrength * curl(center); then gl_FragColor = vec4(texture2D(uVelocity,vUv).xy + f*dt, 0.0, 1.0); (vorticity confinement — adds the swirl).

  1. pressure — Jacobi iteration: neighbors clamped to [0,1],

(pL + pR + pT + pB - divergence) * 0.25 in red.

  1. gradientSubtractvelocity.xy - vec2(pR-pL, pT-pB) (project velocity to divergence-free).
  2. cleargl_FragColor = value * texture2D(uTexture, vUv); (multiplicative fade of pressure).
  3. display — uniforms sampler2D uTexture, float threshold, edgeSoftness, vec3 inkColor.

float d = clamp(length(texture2D(uTexture,vUv).rgb), 0.0, 1.0); float a = edgeSoftness > 0.0 ? smoothstep(threshold - edgeSoftness*0.5, threshold + edgeSoftness*0.5, d) : step(threshold, d); gl_FragColor = vec4(inkColor, a); — with threshold=1.0, edgeSoftness=0.0 this is a hard step(1.0, d): opaque white ink only where dye magnitude ≥ 1, fully transparent elsewhere.

Input → splat
  • Track mouse = { x, y, velocityX, velocityY, moved }, all in device pixels.
  • On window mousemove (and touchmove, with preventDefault, {passive:false}), given

client x,y: velocityX = (x*dpr - mouse.x) * forceStrength, velocityY = (y*dpr - mouse.y) * forceStrength, then store mouse.x = x*dpr, mouse.y = y*dpr, set mouse.moved = true.

  • splat(x, y, vx, vy): set splat uniforms aspectRatio = width/height,

point = (x/width, 1 - y/height), radius = splatRadius/100. First splat into velocity: uTarget = velocity.read, color = vec3(vx, -vy, 0), render to velocity.write, swap. Then splat into dye: uTarget = dye.read, color = vec3(3,3,3) (bright white ink), render to dye.write, swap.

Simulation step order (per frame, simulate(dt)), simTexel = (1/simSize.w, 1/simSize.h)
  1. curl pass (velocity.read → curl).
  2. vorticity pass (velocity.read + curl, curlStrength=50, dt) → velocity.write, swap.
  3. divergence pass (velocity.read → divergence).
  4. clear pressure (value = pressureDecay = 0.75) → pressure.write, swap.
  5. pressure solve — loop pressureIterations (40) times: set uPressure = pressure.read,

render to pressure.write, swap. (uDivergence set once before the loop.)

  1. gradientSubtract (pressure.read + velocity.read) → velocity.write, swap.
  2. advection of velocity (uVelocity=uSource=velocity.read, simTexel,

dissipation=velocityDissipation=0.95) → velocity.write, swap.

  1. advection of dye (uVelocity=velocity.read, uSource=dye.read,

texel = (1/dyeSize.w, 1/dyeSize.h), dissipation=dyeDissipation=0.95) → dye.write, swap.

Render + loop
  • render(): display pass with uTexture = dye.read, threshold, edgeSoftness, inkColor,

rendered to the screen (setRenderTarget(null)).

  • loop(): dt = Math.min((Date.now() - lastTime)/1000, 0.016) (clamped to ~60fps step);

update lastTime. If mouse.moved, call splat(...) then reset moved=false. Then simulate(dt), render(), requestAnimationFrame. No easing curves — the fluid's dissipation factors (0.95 per frame) and the vorticity term ARE the motion feel: ink smears along the pointer path, curls into vortices, and fades out over ~1–2 seconds.

Assets / images

None. There are no image assets — the visual is 100% type + generated fluid.

Behavior notes

  • Desktop pointer-driven; also handles touchmove. There is no idle/auto animation — a still

pointer shows only the plain black-on-white hero; the ink appears and lives only while/after the pointer moves.

  • The effect is continuous and unbounded (runs every frame forever); dissipation keeps it from

saturating.

  • mix-blend-mode: difference on the canvas is essential and non-optional — without it the ink

would render as flat white rectangles instead of inverting the page and headline.

  • Half-float render targets are required for solver stability; keep depthBuffer:false.