# Chromatic Aberration Lens Gallery

## Goal
Build a full-screen, edge-to-edge grid of editorial portraits that **pans slowly as the cursor moves**, overlaid by a circular **magnifying lens that follows the pointer**. Inside the lens the imagery is **zoomed in and its RGB channels are split apart into a chromatic-aberration fringe** — red pushes one way, blue the other — with the distortion ramping up toward the lens rim like real glass. Outside the lens you just see the flat grid. The whole thing is cursor-driven and runs forever: nothing scrolls, nothing clicks, it simply reacts to `mousemove`. The star effect is the **WebGL/GLSL lens distortion + chromatic aberration** sampled live from the grid every frame.

## Tech
Vanilla HTML/CSS/JS. **No GSAP, no libraries, no framework, no npm dependencies** — the entire effect is hand-rolled with the **raw WebGL API + a GLSL fragment shader**, plus two `requestAnimationFrame` lerp loops for easing. A single ES-module script (`<script type="module" src="./script.js">`) drives everything. Do **not** reach for Three.js, a shader helper, or a tween library; the original talks to `gl` directly with a hand-written vertex + fragment shader pair, and eases the pointer/pan with manual linear-interpolation loops. Must run in a fresh Vite + npm project (though no packages are actually imported).

## Layout / HTML
Almost nothing lives in the HTML — the grid is generated by JS and the lens is a full-screen `<canvas>` overlay.

```html
<body>
  <div class="viewport">
    <div class="container"></div>
  </div>
  <canvas></canvas>
  <script type="module" src="./script.js"></script>
</body>
```

- `.viewport` — a fixed, full-screen, `overflow:hidden` window.
- `.container` — the **oversized** panning grid (200vw × 200vh); the JS fills it with 300 image tiles and moves it around with `transform: translate()`.
- `<canvas>` — a single fixed, full-screen canvas pinned on top at `z-index:1000` that renders the lens. It is `pointer-events:none` and `alpha:true`, so **everywhere outside the lens circle it is fully transparent and you see the real DOM grid underneath**; only the circular lens region is painted.

## Styling

### Reset
`* { margin:0; padding:0; box-sizing:border-box; }`

### Viewport
`.viewport { position:fixed; top:0; left:0; width:100vw; height:100vh; overflow:hidden; }` — clips the oversized grid to the screen.

### Grid container (the panning surface)
```css
.container {
  position:absolute;
  width:200vw;              /* twice the viewport each way — there is room to pan */
  height:200vh;
  display:grid;
  grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
  gap:4px;
  padding:4px;
  transform: translate(0, 0);
  will-change: transform;
}
```
An auto-filled grid of ~100px columns with a tight **4px gap** and **4px padding**, so tiles pack densely edge to edge. `transform` is the only animated property (written by the pan loop).

### Tiles & images
```css
.img-wrapper { position:relative; width:100%; padding-bottom:100%; overflow:hidden; }  /* forces a square cell */
img { position:absolute; top:0; left:0; width:100%; height:100%; object-fit:cover; }
```
Every cell is a **1:1 square** (padding-bottom trick); the photo fills it with `object-fit:cover` (center-crop). No borders, no radius, no captions — a raw contact-sheet look.

### Canvas overlay
```css
canvas {
  position:fixed; top:0; left:0;
  width:100%; height:100%;
  pointer-events:none;
  z-index:1000;
}
```

There is **no page background color, no typography, no UI chrome** — the grid *is* the page.

## The effect (be exact — this is the whole component)

There is **no timeline library and no GSAP**. The component is: (1) a JS-built grid, (2) a pointer-driven **pan** eased with a rAF lerp, (3) a per-frame **rasterization of the DOM grid into a WebGL texture**, and (4) a **GLSL fragment shader** that draws a magnifying, chromatically-aberrated lens at the (eased) cursor position. Reproduce the constants, formulas and the shader verbatim.

### 1. Build the grid
```js
const imgSources = Array.from({ length: 25 }, (_, i) => `/…/img${i + 1}.jpg`); // 25 source photos
function getRandomImage() { return imgSources[Math.floor(Math.random() * imgSources.length)]; }
```
Create **300** `.img-wrapper` divs, each holding one `<img>` whose `src` is a **random pick** from the 25 sources (so the same photo repeats several times, scattered). Append them all into `.container`.

### 2. Pointer + pan state
Module-scope state (all numbers):
```js
let mouseX = innerWidth/2, mouseY = innerHeight/2;   // eased lens position (px)
let targetMouseX = mouseX, targetMouseY = mouseY;    // raw pointer target
let targetX = 0, targetY = 0;                        // pan target (px)
let currentX = 0, currentY = 0;                      // eased pan (px)
```

On `mousemove(e)`: set `targetMouseX = e.clientX`, `targetMouseY = e.clientY`, then call `updatePan(e.clientX, e.clientY)`.

**`updatePan(mx, my)`** — compute how far the oversized grid may slide and map the pointer into that range:
```js
const maxX = container.offsetWidth  - innerWidth;    // extra width  beyond the viewport
const maxY = container.offsetHeight - innerHeight;   // extra height beyond the viewport
targetX = -((mx / innerWidth)  * maxX * 0.75);       // note the 0.75 damping
targetY = -((my / innerHeight) * maxY * 0.75);
```
So moving the cursor to the right edge pushes the grid left (negative translate), revealing its right side, but only up to **75%** of the available travel.

**Pan loop `animatePan()`** (own rAF, infinite):
```js
const ease = 0.035;                                  // very slow, heavy drift
currentX += (targetX - currentX) * ease;
currentY += (targetY - currentY) * ease;
container.style.transform = `translate(${currentX}px, ${currentY}px)`;
requestAnimationFrame(animatePan);
```
A low **0.035** lerp factor makes the grid glide toward the target lazily — the pan always trails the cursor by a wide margin.

### 3. WebGL setup
```js
const gl = canvas.getContext("webgl", { preserveDrawingBuffer:false, antialias:true, alpha:true });
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);  // standard alpha over → transparent outside the lens
```
Compile the two shaders below, link a program, `useProgram`. Draw a **full-screen quad** as a `TRIANGLE_STRIP` of 4 vertices:
```js
const vertices = new Float32Array([ -1,-1,  1,-1,  -1,1,  1,1 ]);
```
Bind them to attribute `aPosition` (2 floats). Create one texture on `TEXTURE0`, bound to sampler uniform `iChannel0` (= 0).

**Vertex shader** — passthrough that also derives 0→1 UVs:
```glsl
attribute vec4 aPosition;
varying vec2 fragCoord;
void main() {
  gl_Position = aPosition;
  fragCoord = (aPosition.xy * 0.5 + 0.5);   // clip space (−1..1) → UV (0..1)
}
```

### 4. Rasterize the DOM grid into the texture — every frame (`updateTexture`)
The shader samples the grid as an image, so each frame the live DOM is drawn into an offscreen 2D canvas and uploaded:
- Create a temp `<canvas>` at **4× supersample**: `const scale = 4; tempCanvas.width = floor(innerWidth*scale); height = floor(innerHeight*scale);`.
- `tempCtx.imageSmoothingEnabled = true; imageSmoothingQuality = "high";` then **fill white** (`fillStyle="white"; fillRect(...)`) — white is the backdrop behind any gaps.
- Read the container's current transform and replay it on the temp context so the rasterized grid matches what's on screen, at 4×:
  ```js
  const viewportRect = container.getBoundingClientRect();
  const matrix = new DOMMatrix(getComputedStyle(container).transform);
  tempCtx.setTransform(matrix.a, matrix.b, matrix.c, matrix.d, matrix.e * scale, matrix.f * scale);
  ```
- For **every `<img>`** in the container, draw it into the temp canvas at its wrapper's position (relative to the container's top-left), all multiplied by `scale`:
  ```js
  const parent = img.parentElement.getBoundingClientRect();
  tempCtx.drawImage(img,
    (parent.left - viewportRect.left) * scale,
    (parent.top  - viewportRect.top)  * scale,
    parent.width  * scale,
    parent.height * scale);
  ```
- Reset with `tempCtx.setTransform(1,0,0,1,0,0)`, then upload: `gl.texImage2D(TEXTURE_2D, 0, RGBA, RGBA, UNSIGNED_BYTE, tempCanvas)` with `LINEAR` min/mag filters and `CLAMP_TO_EDGE` wrap on both axes.

(This means the whole visible grid is re-rendered to a 4×-resolution bitmap and re-uploaded to the GPU on every animation frame — the source of the "medium" perf cost.)

### 5. Render loop (`render`, own infinite rAF)
```js
const ease = 0.1;                                   // lens follows pointer at 0.1 lerp
mouseX += (targetMouseX - mouseX) * ease;
mouseY += (targetMouseY - mouseY) * ease;

canvas.width = innerWidth; canvas.height = innerHeight;
gl.viewport(0, 0, canvas.width, canvas.height);

updateTexture();

gl.uniform2f(iResolutionLoc, canvas.width, canvas.height);
gl.uniform2f(iMouseLoc, mouseX, canvas.height - mouseY);   // NOTE: Y is flipped for GL space
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
requestAnimationFrame(render);
```
Two independent easings: the **lens** chases the pointer at **0.1** (fairly snappy) while the **pan** drifts at **0.035** (sluggish), so the lens and the imagery move at different rates.

### 6. The fragment shader (the lens — reproduce exactly)
Uniforms: `vec2 iResolution`, `vec2 iMouse`, `sampler2D iChannel0`; varying `vec2 fragCoord` (0..1 UV).

**Fixed parameters inside `main`:**
```glsl
vec2  sphereCenter = iMouse.xy;
float sphereRadius = iResolution.y * 0.3;   // lens radius = 30% of viewport height
float focusFactor  = 0.25;                  // inner "flat" focus zone = 25% of radius
float chromaticAberrationFactor = 0.25;     // strength of the R/B split
float zoom         = 1.75;                  // magnification inside the lens
```

**Step A — zoom the sample UV around the lens center:**
```glsl
vec2 zoomUV(vec2 uv, vec2 center, float zoom) {
  float z = 1.0 / zoom;
  return (uv - center) * z + center;         // pull UVs toward center → magnify
}
vec2 spehereCenterUv = sphereCenter / iResolution;
vec2 zoomedUv = zoomUV(fragCoord, spehereCenterUv, 1.75);
```

**Step B — the lens distortion + chromatic aberration** (`getLensDistortion`, called with `p = fragCoord*iResolution`, `uv = zoomedUv`):
```glsl
vec2  distortionDirection = normalize(p - sphereCenter);
float focusRadius   = sphereRadius * focusFactor;      // = radius*0.25
float focusStrength = sphereRadius / 5000.0;           // base displacement magnitude
float focusSdf  = length(sphereCenter - p) - focusRadius;   // signed dist from focus edge
float speherSdf = length(sphereCenter - p) - sphereRadius;  // signed dist from lens edge
float inside    = smoothstep(0.0, 1.0, -speherSdf / (sphereRadius * 0.001));  // ~hard 0/1 mask of the disc

float magnifierFactor = focusSdf / (sphereRadius - focusRadius);
float mFactor = clamp(magnifierFactor * inside, 0.0, 1.0);
mFactor = pow(mFactor, 5.0);                             // distortion stays ~0 in the focus zone, ramps hard near the rim

vec3 distortionFactors = vec3(
  mFactor * focusStrength * (1.0 + chromaticAberrationFactor),  // R displaced more
  mFactor * focusStrength,                                       // G reference
  mFactor * focusStrength * (1.0 - chromaticAberrationFactor)   // B displaced less
);
```
Each channel is displaced along `distortionDirection` by its own factor via:
```glsl
vec2 getDistortedUv(vec2 uv, vec2 direction, float factor) {
  vec2 d = direction; d.y *= 2.0;            // vertical displacement doubled
  return uv - d * factor;
}
vec2 uv_R = getDistortedUv(uv, distortionDirection, distortionFactors.r);
vec2 uv_G = getDistortedUv(uv, distortionDirection, distortionFactors.g);
vec2 uv_B = getDistortedUv(uv, distortionDirection, distortionFactors.b);
```
Then each distorted UV is **vertically flipped about the lens center** (the DOM texture is Y-down vs GL Y-up):
```glsl
vec2 fixRotation(vec2 uv, vec2 center) {
  vec2 c = uv - center; c.y = -c.y; return c + center;
}
vec2 sphereCenterUv = sphereCenter / iResolution;
uv_R = fixRotation(uv_R, sphereCenterUv); // same for uv_G, uv_B
```

**Step C — compose:**
```glsl
vec4 baseTexture   = texture2D(iChannel0, fragCoord);            // flat grid at this pixel
vec3 imageDistorted = vec3(
  texture2D(iChannel0, uv_R).r,   // sample RED   from the R-shifted UV
  texture2D(iChannel0, uv_G).g,   // sample GREEN from the G UV
  texture2D(iChannel0, uv_B).b    // sample BLUE  from the B-shifted UV
);
vec3 result = mix(baseTexture.rgb, imageDistorted, inside);      // only inside the disc
gl_FragColor = vec4(result, inside);                             // alpha = disc mask → transparent elsewhere
```

**Net feel:** a crisp circular lens (radius = 30% of viewport height) glides under the cursor. Its inner ~25% is a clean 1.75× magnification; from there to the rim the RGB channels progressively peel apart (red pulling outward, blue inward, vertical split exaggerated ×2) so the fringe intensifies toward the edge — like looking through a thick glass sphere. Outside the disc the canvas is transparent and the plain grid shows through, panning lazily against the snappier lens.

### 7. Init order & resize
`init()`: build the grid → **await the first image's `load`** (so the texture isn't blank) → `initWebGL()` → attach listeners → `animatePan()` → `render()`.

On `resize`: reset `canvas.width/height` and `gl.viewport` to the new size; **recenter** everything — `targetMouseX = mouseX = innerWidth/2`, `targetMouseY = mouseY = innerHeight/2`, and `targetX = currentX = targetY = currentY = 0` (snap the pan back to origin).

## Assets / images
**25 source photos**, a cohesive **high-fashion editorial portrait** set — studio and location fashion photography, one subject per frame, head-and-shoulders to full-body. Keep the collection tonally varied but of a piece: a **mix of full-color and high-contrast black-and-white** frames, moody low-key lighting alongside clean high-key studio seamless, and a **restrained palette** (neutral tan / grey / concrete backdrops, creamy off-whites, denim and leather, with occasional saturated grounds — blue, red, warm tan — as accents). Subjects wear editorial styling: leather jackets, knit balaclavas and fur hats, denim, tailored blazers, avant-garde beauty/painted-face looks, sunglasses; a range of hair colors and skin tones, several close-ups with visible freckles. Source aspect ratios can be anything — every tile is **center-cropped to a 1:1 square** (~100px cells, `object-fit:cover`), and the shader zooms 1.75× so **composition, contrast and tone matter far more than resolution**. Any set of ~25 minimal, editorial fashion portraits at square-croppable framing works; frames with clear tonal contrast make the chromatic fringe read best. Do **not** use real brand imagery or client logos. The same 25 are scattered randomly across 300 tiles, so repeats are expected.

## Behavior notes
- **Desktop / pointer only.** The whole effect is `mousemove`-driven — there is no touch, scroll, click or keyboard interaction, and no reduced-motion branch. It is not mobile-safe.
- **Runs forever.** Two infinite `requestAnimationFrame` loops (pan at 0.035 lerp, lens+texture at 0.1 lerp) run continuously; the grid re-rasterizes to a 4× offscreen canvas and re-uploads to the GPU every frame (medium perf cost).
- **Transparent overlay.** The lens canvas sits at `z-index:1000` with `pointer-events:none`; outside the circular disc its alpha is 0, so the real DOM grid — not a shader copy — is what you see there.
- **First-paint guard.** Rendering waits for the first grid image to load so the initial texture upload isn't empty/white.
