# Turbulent Inversion Lens Hover Effect

## Goal

Build a full-viewport WebGL image viewer with a "turbulent inversion lens". A single photograph fills the screen, rendered on a Three.js fullscreen quad through a custom fragment shader. The star effect: as the cursor moves over the image, a **circular lens trails the mouse with a smooth lag**, and everything inside that circle is shown as **inverted grayscale** (a photographic-negative look). The lens edge is not a clean circle — it is **jittered by animated fractal turbulence (fBm noise)**, so the boundary constantly boils and crackles like static. The lens radius **grows open when the cursor enters** the container and **shrinks closed to nothing when it leaves** (or when the section scrolls out of view). No scroll effects, no clicks — just cursor-driven shading.

## Tech

Vanilla HTML/CSS/JS with ES module imports. **No GSAP, no Lenis.** The only runtime dependency is `three` (npm). All motion comes from a `requestAnimationFrame` loop that lerps values into shader uniforms.

Imports needed:

- `three` (`import * as THREE from "three"`)
- A local `./shaders.js` module exporting two GLSL strings: `vertexShader` and `fragmentShader` (given verbatim below — they are load-bearing).

## Layout / HTML

Minimal. One full-viewport container holding a hidden `<img>`; the WebGL canvas is created in JS and appended into the container.

```html
<div class="inversion-lens">
  <img src="<path-to-image>" alt="" />
</div>
<script type="module" src="./script.js"></script>
```

The `<img>` is never displayed — JS reads its `src` and loads it as a texture. The JS must support multiple `.inversion-lens` containers: `document.querySelectorAll(".inversion-lens").forEach(initHoverEffect)`.

## Styling

Tiny stylesheet — all visuals come from WebGL.

- Global reset: `* { margin:0; padding:0; box-sizing:border-box; }`
- `.inversion-lens { position:relative; width:100vw; height:100svh; overflow:hidden; }`
- `.inversion-lens img { display:none; }`

No fonts, no text, no other elements.

## Core effect (be exhaustive — this is the whole component)

### Config (exact values)

```js
const config = {
  maskRadius: 0.15,          // lens radius in UV space when hovering
  maskSpeed: 0.75,           // multiplier on u_time for the turbulence scroll
  lerpFactor: 0.05,          // mouse-follow lerp per frame
  radiusLerpSpeed: 0.1,      // radius open/close lerp per frame
  turbulenceIntensity: 0.075 // amplitude of the edge jitter
};
```

### Per-container state

- `targetMouse = new THREE.Vector2(0.5, 0.5)` and `lerpedMouse = new THREE.Vector2(0.5, 0.5)` — pointer position in UV space (0–1, y flipped).
- `targetRadius = 0.0` — desired lens radius (0 when closed, `config.maskRadius` when hovering).
- `isInView`, `isMouseInsideContainer`, `lastMouseX`, `lastMouseY` trackers.

### Texture load → scene setup

Use `new THREE.TextureLoader().load(img.src, callback)`. In the callback:

1. `imageAspect = texture.image.width / texture.image.height` — passed to the shader for cover-fit cropping.
2. Texture quality: `texture.minFilter = THREE.LinearMipMapLinearFilter; texture.magFilter = THREE.LinearFilter; texture.anisotropy = 16;`
3. `scene = new THREE.Scene()`; `camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1)` (identity fullscreen camera — the vertex shader ignores matrices anyway).
4. Uniforms (exact):
   ```js
   uniforms = {
     u_texture:            { value: texture },
     u_mouse:              { value: new THREE.Vector2(0.5, 0.5) },
     u_time:               { value: 0.0 },
     u_resolution:         { value: new THREE.Vector2(containerW, containerH) },
     u_radius:             { value: 0.0 },                        // starts closed
     u_speed:              { value: config.maskSpeed },           // 0.75
     u_imageAspect:        { value: imageAspect },
     u_turbulenceIntensity:{ value: config.turbulenceIntensity }, // 0.075
   };
   ```
5. Mesh: `new THREE.Mesh(new THREE.PlaneGeometry(2, 2), new THREE.ShaderMaterial({ uniforms, vertexShader, fragmentShader }))`, added to the scene.
6. Renderer: `new THREE.WebGLRenderer({ antialias: true })`, `setPixelRatio(window.devicePixelRatio)`, `setSize(container.clientWidth, container.clientHeight)`, then append `renderer.domElement` into the container. (No resize handler — the canvas keeps its initial size.)

### Pointer / visibility tracking

- `document.addEventListener("mousemove", ...)` (on **document**, not the container) → call `updateCursorState(e.clientX, e.clientY)`.
- `window.addEventListener("scroll", ...)` → re-run `updateCursorState(lastMouseX, lastMouseY)` so the lens tracks correctly while the page scrolls under a stationary cursor.
- An `IntersectionObserver` on the container with `{ threshold: 0.1 }`: when the container leaves the viewport set `targetRadius = 0.0` (lens closes).

`updateCursorState(x, y)`:

- Store `lastMouseX/Y`, get `container.getBoundingClientRect()` and test whether the point is inside the rect.
- If inside: `targetMouse.x = (x - rect.left) / rect.width;` `targetMouse.y = 1.0 - (y - rect.top) / rect.height;` (flip Y into UV space) and `targetRadius = config.maskRadius` (0.15).
- If outside: `targetRadius = 0.0`.

### The rAF loop (the actual "animation")

Every frame:

```js
lerpedMouse.lerp(targetMouse, 0.05);                       // lens center trails the cursor
uniforms.u_mouse.value.copy(lerpedMouse);
uniforms.u_time.value += 0.01;                             // drives turbulence scroll
uniforms.u_radius.value += (targetRadius - uniforms.u_radius.value) * 0.1; // radius eases open/closed
renderer.render(scene, camera);
```

- Mouse lerp factor `0.05` → noticeable elastic lag behind the cursor.
- Radius lerp factor `0.1` → the circle grows from 0 to 0.15 on enter and collapses back to 0 on leave, both eased exponentially.
- `u_time` advances a fixed `0.01` per frame; the shader multiplies it by `u_speed` (0.75) to scroll the noise field, which makes the lens edge boil continuously even when the cursor is still.

### Shaders (`shaders.js` — exact GLSL, load-bearing)

```js
export const vertexShader = `
  varying vec2 v_uv;
  void main() {
    v_uv = uv;
    gl_Position = vec4(position, 1.0);
  }
`;

export const fragmentShader = `
  precision highp float;

  uniform sampler2D u_texture;
  uniform vec2 u_mouse;
  uniform float u_time;
  uniform vec2 u_resolution;
  uniform float u_radius;
  uniform float u_speed;
  uniform float u_imageAspect;
  uniform float u_turbulenceIntensity;

  varying vec2 v_uv;

  float hash(vec2 p) {
    return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
  }

  float noise(vec2 p) {
    vec2 i = floor(p);
    vec2 f = fract(p);
    vec2 u = f * f * (3.0 - 2.0 * f);
    return mix(
      mix(hash(i + vec2(0.0, 0.0)), hash(i + vec2(1.0, 0.0)), u.x),
      mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x),
      u.y
    );
  }

  float turbulence(vec2 p) {
    float t = 0.0;
    float w = 0.5;
    for (int i = 0; i < 8; i++) {
      t += abs(noise(p)) * w;
      p *= 2.0;
      w *= 0.5;
    }
    return t;
  }

  void main() {
    vec2 uv = v_uv;
    float screenAspect = u_resolution.x / u_resolution.y;
    float ratio = u_imageAspect / screenAspect;

    vec2 texCoord = vec2(
      mix(0.5 - 0.5 / ratio, 0.5 + 0.5 / ratio, uv.x),
      uv.y
    );

    vec4 tex = texture2D(u_texture, texCoord);
    float gray = dot(tex.rgb, vec3(0.299, 0.587, 0.114));
    vec3 invertedGray = vec3(1.0 - gray);

    vec2 correctedUV = uv;
    correctedUV.x *= screenAspect;
    vec2 correctedMouse = u_mouse;
    correctedMouse.x *= screenAspect;

    float dist = distance(correctedUV, correctedMouse);

    float jaggedDist = dist + (turbulence(uv * 25.0 + u_time * u_speed) - 0.5) * u_turbulenceIntensity;

    float mask = step(jaggedDist, u_radius);

    vec3 finalColor = mix(invertedGray, tex.rgb, 1.0 - mask);
    gl_FragColor = vec4(finalColor, 1.0);
  }
`;
```

What the fragment shader does, step by step:

- **Cover-fit crop:** `ratio = imageAspect / screenAspect`; the x texture coordinate is remapped with `mix(0.5 - 0.5/ratio, 0.5 + 0.5/ratio, uv.x)` so a wider-than-viewport image is center-cropped horizontally (like CSS `object-fit: cover`).
- **Negative look:** luma via the Rec.601 weights `(0.299, 0.587, 0.114)`, then inverted: `vec3(1.0 - gray)`.
- **Aspect-corrected distance:** both the fragment UV and the mouse UV have their x multiplied by `screenAspect` before `distance()`, so the lens is a true circle on any viewport.
- **Turbulent edge:** `jaggedDist = dist + (turbulence(uv * 25.0 + u_time * u_speed) - 0.5) * u_turbulenceIntensity` — an 8-octave value-noise fBm (`abs(noise)`, weight halving each octave) sampled at 25× UV frequency, scrolled by time, remapped to ±0.5 and scaled by 0.075, perturbs the distance field.
- **Hard mask:** `mask = step(jaggedDist, u_radius)` — a binary inside/outside test (no soft edge; the jitter itself provides the texture).
- **Composite:** `mix(invertedGray, tex.rgb, 1.0 - mask)` — inverted grayscale inside the lens, untouched photo outside.

## Assets / images

One image only:

- A single **full-bleed photographic portrait in landscape orientation, roughly 3:2** (e.g. a studio portrait of a person against a light, uncluttered background). High resolution (~1440px wide or more). Light/bright imagery reads best because the inversion flips it to a dramatic dark negative inside the lens. Referenced by the hidden `<img>` in the container; the shader center-crops it to cover the viewport.

## Behavior notes

- Initial state: plain photo, no lens (`u_radius` starts at 0 and `targetRadius` is 0 until the first mousemove lands inside the container).
- The lens closes (radius eases to 0) whenever the cursor exits the container rect **and** when the container is less than 10% visible (IntersectionObserver).
- Mouse tracking is document-level plus a scroll listener re-check, so the effect stays correct in a scrolling page; the component itself does not hijack scroll.
- Desktop / pointer-driven only — no touch handling, no click states, no reduced-motion branch. The rAF loop runs continuously.
- No window-resize handling in the original; keep the renderer at its initial container size.
