# 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:**
  - `h1` — `font-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 a` — `font-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`).
2. **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);`
3. **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.
4. **curl** — `texture2D(uVelocity,R).y - texture2D(uVelocity,L).y - texture2D(uVelocity,T).x + texture2D(uVelocity,B).x` in red.
5. **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).
6. **pressure** — Jacobi iteration: neighbors clamped to `[0,1]`,
   `(pL + pR + pT + pB - divergence) * 0.25` in red.
7. **gradientSubtract** — `velocity.xy - vec2(pR-pL, pT-pB)` (project velocity to divergence-free).
8. **clear** — `gl_FragColor = value * texture2D(uTexture, vUv);` (multiplicative fade of pressure).
9. **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.)
6. **gradientSubtract** (pressure.read + velocity.read) → velocity.write, swap.
7. **advection of velocity** (`uVelocity=uSource=velocity.read`, simTexel,
   `dissipation=velocityDissipation=0.95`) → velocity.write, swap.
8. **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`.
