# Interactive Fluid Particle Logo

## Goal
Build a full-viewport **WebGL hero** where a PNG logo is rasterized pixel-by-pixel into tens of thousands of soft round `GL_POINTS` particles floating on a near-black background. The star effect: **moving the mouse repels nearby particles with an inverse-square force**, scattering them fluidly, and each particle then **springs back to its home position** through velocity damping plus a constant return force — with an exponentially damped clamp that stops any particle drifting more than ~100px from home. The whole simulation is raw WebGL + a hand-rolled physics loop on `requestAnimationFrame`. **No GSAP, no Three.js, no libraries at all.**

## Tech
Vanilla HTML/CSS/JS with ES module scripts. **Zero npm dependencies** — use the raw `canvas.getContext("webgl")` API directly. Put the two GLSL source strings in a sibling module and import them:
```js
import { vertexShader, fragmentShader } from "./shaders.js";
```

## Layout / HTML
Minimal — the entire page is one canvas:
```html
<body>
  <canvas id="canvas"></canvas>
  <script type="module" src="./script.js"></script>
</body>
```
Page title: "Interactive Fluid Particle Logo".

## Styling
Only a global reset — nothing else:
```css
* { margin: 0; padding: 0; box-sizing: border-box; }
```
The canvas is sized by JS (below) and the background color is painted by WebGL's `clearColor`, not CSS.

## Config (top of `script.js`)
Centralize every tunable in one object; all the exact values matter:
```js
const config = {
  logoPath: "/path/logo.png",   // square PNG with transparency
  logoSize: 1250,               // sampling grid resolution (px)
  logoColor: "#404040",         // tint multiplied onto the logo pixels
  canvasBg: "#141414",          // WebGL clear color
  distortionRadius: 3000,       // mouse influence radius (device px, used squared)
  forceStrength: 0.0035,        // scales the repulsion acceleration
  maxDisplacement: 100,         // max px a particle may drift from home
  returnForce: 0.025,           // spring factor pulling back to origin
};
```

## Canvas + WebGL setup
- **Canvas sizing (DPR-aware):** `canvas.width = innerWidth * dpr; canvas.height = innerHeight * dpr;` with `canvas.style.width/height` set to the CSS pixel size (`dpr = window.devicePixelRatio || 1`). All simulation coordinates live in **device pixels**.
- **Context:** `canvas.getContext("webgl", { alpha: true, depth: false, stencil: false, antialias: true, powerPreference: "high-performance", premultipliedAlpha: false })`.
- **Blending:** `gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);`
- Compile the two shaders, link one program. No depth test, no textures.

## Shaders (`shaders.js`)

**Vertex shader** — positions arrive in device-pixel space; convert to clip space with a Y-flip, forward the per-particle color, fixed point size **3.5**:
```glsl
precision highp float;
uniform vec2 u_resolution;
attribute vec2 a_position;
attribute vec4 a_color;
varying vec4 v_color;
void main() {
    vec2 zeroToOne = a_position / u_resolution;
    vec2 clipSpace = (zeroToOne * 2.0 - 1.0);
    v_color = a_color;
    gl_Position = vec4(clipSpace * vec2(1.0, -1.0), 0.0, 1.0);
    gl_PointSize = 3.5;
}
```

**Fragment shader** — each point is a **soft anti-aliased disc**: radial alpha falloff via `smoothstep`, discarding fully transparent particles:
```glsl
precision highp float;
varying vec4 v_color;
void main() {
    if (v_color.a < 0.01) discard;
    vec2 coord = gl_PointCoord - vec2(0.5);
    float dist = length(coord);
    float alpha = 1.0 - smoothstep(0.0, 0.5, dist);
    gl_FragColor = vec4(v_color.rgb, v_color.a * alpha);
}
```

## Building the particles from the logo
1. Load the PNG with `new Image()`. In `onload`, draw it onto an **offscreen 2D canvas of `logoSize × logoSize` (1250×1250)** at **scale 0.9, centered** (`size = 1250 * 0.9`, `offset = (1250 - size) / 2`).
2. `getImageData` over the full 1250×1250 grid. Loop rows `i` (y) and columns `j` (x); `pixelIndex = (i * logoSize + j) * 4`.
3. For every pixel with **alpha > 10**, create one particle at:
   - `particleX = canvas.width/2 + (j - logoSize/2) * 1.0`
   - `particleY = canvas.height/2 + (i - logoSize/2) * 1.0`
   
   i.e. **1 particle per source pixel, 1 device-pixel spacing**, logo centered in the canvas (so the point cloud spans up to 1250 device px).
4. Per-particle color = original pixel RGB (normalized 0–1) **multiplied channel-wise by the `#404040` tint**, alpha = original pixel alpha / 255 (this keeps anti-aliased logo edges soft). Result: dark-grey logo on the `#141414` background — subtle, low-contrast.
5. Each particle object stores `{ originalX, originalY, velocityX: 0, velocityY: 0 }`. Current positions live in a flat `Float32Array` (`positionArray`, 2 floats per particle) uploaded to a `gl.DYNAMIC_DRAW` buffer; colors in a `Float32Array` (4 floats per particle) uploaded once as `gl.STATIC_DRAW`.
6. Only after particles exist, start the rAF loop.

Expect a large count (a bold logo yields ~100k–400k points) — the physics loop must be plain indexed `for` loops over typed arrays to stay 60fps.

## The interaction physics (the important part — be exact)

### Activity gate — simulation sleeps when idle
Keep a counter `animationCount = 0`. Every `mousemove` sets `animationCount = 300`; each physics tick decrements it and **the whole physics update early-returns when it reaches 0** (rendering continues every frame regardless). Net effect: the fluid simulation runs for ~300 frames (~5s) after the last mouse movement, letting every particle settle home, then freezes for free performance.

### Mouse tracking
`mousemove` on `document`, converted to **device pixels** relative to the canvas:
```js
mouse.x = (event.clientX - rect.left) * dpr;
mouse.y = (event.clientY - rect.top) * dpr;
animationCount = 300;
```

### Per-frame physics (for each particle `i`, current pos read from `positionArray`)
Let `radiusSquared = distortionRadius² = 9,000,000`.

1. **Inverse-square repulsion** — `deltaX/deltaY = mouse − current`, `distanceSquared = deltaX² + deltaY²`. If `0 < distanceSquared < radiusSquared`:
   - `force = -radiusSquared / distanceSquared` (negative → pushes **away** from the cursor; explodes near the cursor, fades with distance).
   - `angle = atan2(deltaY, deltaX)`.
   - **Displacement-based falloff:** `forceMultiplier = max(0.1, 1 − distFromOrigin / (maxDisplacement * 2))` where `distFromOrigin` is the particle's current distance from its home — particles already far from home resist further pushing (floor at 0.1).
   - `velocityX += force * cos(angle) * forceStrength * forceMultiplier` (same for Y with `sin`). `forceStrength = 0.0035`.
2. **Friction:** `velocityX *= 0.82; velocityY *= 0.82;` every tick, unconditionally.
3. **Spring integration:** `targetX = currentX + velocityX + (originalX − currentX) * returnForce` (returnForce = **0.025** — a lazy constant pull home; same for Y).
4. **Soft max-displacement clamp:** compute the target's offset from home; if its length `distFromOrigin > maxDisplacement (100)`:
   - `excess = distFromOrigin − maxDisplacement`
   - `scale = maxDisplacement / distFromOrigin`
   - `dampedScale = scale + (1 − scale) * exp(−excess * 0.02)` — an **exponentially damped clamp**: slight overshoot allowed near the limit, hard stop far past it (no visible snapping).
   - position = `original + offset * dampedScale`, and bleed energy: `velocity *= 0.7` (both axes).
   
   Otherwise position = target.
5. After the loop, re-upload positions with `gl.bufferSubData` on the dynamic buffer.

### Render (every frame)
`gl.viewport(0, 0, canvas.width, canvas.height)`, clear to `#141414` (alpha 1), bind program, set `u_resolution = (canvas.width, canvas.height)`, bind position attrib (2 floats) and color attrib (4 floats), then `gl.drawArrays(gl.POINTS, 0, particleCount)`.

### Resulting feel
Sweeping the cursor through the logo blasts a hole through the particles; they curl outward fluidly (the inverse-square profile makes close particles fly and distant ones barely stir), then drift back over ~1–2 seconds like iron filings re-forming, with no elastic wobble thanks to the 0.82 friction + damped clamp.

## Resize behavior
On `window.resize`, re-run the canvas sizing, then **reposition the existing particles as a centered `√N × √N` square grid** at 1px spacing (`row = floor(i / dim)`, `col = i % dim`, positioned around the new canvas center), reset `positionArray` to those spots and re-upload with `bufferSubData`. (This is a deliberate cheap re-center — after a resize the cloud reflows into a solid square block rather than re-sampling the logo; keep this behavior.)

## Assets / images
- **1 image** — a **square (1:1) PNG logo/mark on a fully transparent background**: a simple, bold, geometric shape (e.g. an abstract mountain/triangle mark) in a **light grey**, ideally ≥1000px so the 1250-grid sampling stays crisp. Solid silhouette with clean anti-aliased edges works best; the code multiplies its color by `#404040`, so a light source logo lands as dark grey on the near-black canvas. No real brand.

## Behavior notes
- **Desktop / mouse-driven** — there is no touch fallback; on touch devices the logo simply renders statically.
- The logo renders at device-pixel scale (1250 device px wide at most), so on a 2× display it appears ~625 CSS px wide — that's expected.
- No GSAP, no ScrollTrigger, no smooth-scroll — one rAF loop drives everything.
- The `animationCount` gate is the only perf guard; keep it, along with `powerPreference: "high-performance"` and the typed-array buffers.
