# 3D Scroll Tunnel

## Goal
Build a full-viewport, pitch-black "spotlight" stage containing an **infinite 3D image tunnel** flown by the **mouse wheel**. A ring of four images sits on an ellipse; several of these rings are stacked in depth along the Z axis inside a CSS `perspective` container. A `gsap.ticker` loop **lerps a virtual scroll value** and pushes every ring toward the camera, **wrapping depth modulo the tunnel length** so the flight never ends. Each image carries a black overlay whose opacity is driven by a `--overlay` CSS variable: images **emerge from black in the far depths, resolve to full clarity at the camera plane, then darken back to black and vanish as they pass the lens**. There is no native page scroll and no ScrollTrigger — everything is a wheel accumulator plus a per-frame ticker.

## Tech
Vanilla HTML/CSS/JS with ES module imports. Use **`gsap` (npm) only** — no plugins, no Lenis, no ScrollTrigger. Import as:
```js
import gsap from "gsap";
```
The whole animation runs inside a single `gsap.ticker.add(() => { ... })` callback. The wheel drives a target value; the ticker lerps a current value toward it and applies 3D transforms with `gsap.set`.

## Layout / HTML
The static HTML is essentially empty — the tunnel is built entirely in JS:
```html
<body>
  <section class="spotlight"></section>
  <script type="module" src="./script.js"></script>
</body>
```
JS creates and appends:
```
section.spotlight            (the fixed black stage, perspective container)
  div.tunnel                 (centered 3D group, transform-style: preserve-3d)
    div.layer  × N           (one per depth ring; its translateZ is animated)
      div.item × up to 4     (positioned around an ellipse; 180×220 px each)
        img                  (the tunnel image)
        div.item-overlay     (black veil, opacity = var(--overlay))
```

## Styling
Global reset: `* { margin:0; padding:0; box-sizing:border-box; }`. Images: `img { width:100%; height:100%; object-fit:cover; }`.

- `.spotlight`: `position:fixed; width:100%; height:100svh; background-color:#000; perspective:1000px; overflow:hidden;` — this **`perspective:1000px` is the camera** and is essential to the 3D depth.
- `.tunnel`: `position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); transform-style:preserve-3d;` — centered in the stage; `preserve-3d` lets children live in real 3D space so their `translateZ` reads as depth.
- `.layer`: `position:absolute;` (no width/height — a pure transform group). Sits at the tunnel center; JS animates its `z` (translateZ).
- `.item`: `position:absolute; width:180px; height:220px;` — placed via inline `left`/`top`.
- `.item-overlay`: `position:absolute; inset:0; background:#000; opacity:var(--overlay, 1); pointer-events:none;` — a black rectangle over each image whose opacity is fed by the inherited `--overlay` variable.

No custom fonts, no text — this is a pure image/motion piece on a black background.

## Building the tunnel (JS construction — do this before the ticker)

### Config constants
```js
const CONFIG = { totalImages: 12, scrollSpeed: 2, layerGap: 2500, lerp: 0.07 };
```

### Derived counts (compute exactly like this)
```js
const contentLayerCount = Math.ceil(CONFIG.totalImages / 4); // 3 unique rings of 4 images
const totalLayerCount   = Math.max(contentLayerCount, 6);    // 6 rings actually rendered (min 6)
const tunnelDepth       = totalLayerCount * CONFIG.layerGap;  // 15000  (the modulo wrap length)
const visibleDepth      = 3 * CONFIG.layerGap;               // 7500   (fade-in distance)
const exitPoint         = 1500;                              // where a ring "exits" past the lens
const initialScroll     = 750;                               // starting virtual-scroll offset
```
With 12 images and 4 per ring there are only **3 unique rings** of imagery, but **6 layers** are rendered; the image sets repeat (rings 0,1,2,then 0,1,2 again) so the tunnel is always populated.

### Layer + item generation loop
`tunnelEl` is appended to `.spotlight`. Then for `i` from `0` to `totalLayerCount-1`:
- Create a `.layer` div.
- `imageStartIndex = (i % contentLayerCount) * 4` → i:0→0, 1→4, 2→8, 3→0, 4→4, 5→8.
- For `j` from `0` to `3`:
  - `imageNumber = imageStartIndex + j + 1`; if `imageNumber > CONFIG.totalImages` **break**.
  - Place the item around an ellipse: `angle = (j/4) * Math.PI*2 - Math.PI/2` (starts at top, goes clockwise: top → right → bottom → left).
  - `radiusX = 400`, `radiusY = 280`.
  - `itemX = Math.cos(angle) * radiusX - 90` (the `-90` = half the 180px width, so the item is **centered** on the ellipse point).
  - `itemY = Math.sin(angle) * radiusY - 110` (the `-110` = half the 220px height).
  - The four resulting positions are: **top** `(-90, -390)`, **right** `(310, -110)`, **bottom** `(-90, 170)`, **left** `(-490, -110)`.
  - Build `.item` with inline `left:${itemX}px; top:${itemY}px;`, append an `<img>` (src = tunnel image `imageNumber`) and a `.item-overlay` div. Append item → layer.
- Append layer → tunnel. Push `{ el: layerEl, baseZ: -i * CONFIG.layerGap }` into a `layerData` array. Ring 0 sits at z=0, ring 1 at −2500, … ring 5 at −12500 (marching away from the camera).

## GSAP effect (the important part — be exhaustive)

### 1. Virtual scroll accumulator (wheel-driven)
```js
let targetScroll  = initialScroll; // 750
let currentScroll = initialScroll; // 750
window.addEventListener("wheel", (e) => {
  targetScroll += e.deltaY * CONFIG.scrollSpeed; // scrollSpeed = 2
});
```
The page itself never scrolls (the stage is `position:fixed`, full viewport). Wheel deltas simply accumulate into `targetScroll`. Scrolling down (positive `deltaY`) increases the value and flies **forward** through the tunnel; scrolling up flies backward. There is no clamping — the value can grow unbounded, and the modulo wrap keeps depth in range.

### 2. Depth-driven overlay function
```js
function calculateOverlay(z) {
  if (z > exitPoint)     return 1;                 // z > 1500: past the lens → fully black
  if (z > 0)             return z / exitPoint;      // 0 < z < 1500: darkening as it exits (0 → 1)
  if (z > -visibleDepth) {                          // -7500 < z < 0: fading IN from the depths
    const progress = Math.abs(z) / visibleDepth;    // 1 (far) → 0 (at camera plane)
    return progress * progress;                     // QUADRATIC ease-in of the black veil
  }
  return 1;                                          // z <= -7500: too far → fully black
}
```
Interpretation of the veil opacity across depth:
- **Far field** (`z ≤ −7500`): overlay = 1, image is solid black.
- **Approach** (`−7500 → 0`): overlay eases from 1 down to 0 following `(|z|/7500)²`, so the image swells out of darkness and only becomes fully clear right at the camera plane (`z = 0`).
- **Fly-through** (`0 → 1500`): overlay ramps linearly 0 → 1, so the image darkens again as it blows past the lens.
- **Behind camera** (`z > 1500`): overlay = 1, black.

The visible "clear" band is therefore a thin slice around `z = 0`; only a couple of rings are legible at once, everything else fades to black — this is what sells the tunnel depth.

### 3. The ticker loop (runs every frame)
```js
gsap.ticker.add(() => {
  // (a) lerp the virtual scroll toward the wheel target
  currentScroll += (targetScroll - currentScroll) * CONFIG.lerp; // lerp = 0.07 → heavy inertia/glide

  // (b) reposition every ring in depth
  layerData.forEach((layer) => {
    let z = layer.baseZ + currentScroll;                  // ring's raw depth
    z = ((z % tunnelDepth) + tunnelDepth) % tunnelDepth;  // wrap into [0, 15000) (handles negatives)
    z = z - tunnelDepth + exitPoint;                      // remap to [-13500, 1500): far behind → just past lens

    const overlay = calculateOverlay(z);

    gsap.set(layer.el, {
      z: z,                                               // translateZ in px (the 3D depth)
      "--overlay": Math.min(1, Math.max(0, overlay)),     // clamp 0..1, fed to .item-overlay
      visibility: overlay >= 1 ? "hidden" : "visible",    // fully-black rings are hidden (perf + no z-fighting)
    });
  });
});
```
Key mechanics to reproduce exactly:
- **Lerp factor `0.07`** gives a long, floaty glide: after you stop scrolling, the tunnel keeps drifting and eases to rest. Do not replace with a direct assignment.
- **The three-step z math is the heart of the infinite loop.** `((z % tunnelDepth) + tunnelDepth) % tunnelDepth` is a positive modulo (JS `%` can go negative), wrapping any depth into `[0, 15000)`. Subtracting `tunnelDepth` and adding `exitPoint` re-centers that range to `[−13500, 1500)`, so as a ring's `z` crosses `1500` it seamlessly re-enters at `−13500` (deep in the fog) — a perfectly recycled, endless tunnel with no visible seam because those wrap points are always at overlay = 1 (black).
- `gsap.set` (not `.to`) — the tween is the ticker itself; each frame writes an absolute state. `z` maps to `translateZ`. The `--overlay` custom property is set on the `.layer`, and each child `.item-overlay` inherits it via `opacity:var(--overlay)`.
- `visibility:hidden` when overlay ≥ 1 removes fully-black rings from paint.

No SplitText, no CustomEase, no ScrollTrigger, no timeline — the only easing is the manual `lerp = 0.07` interpolation of `currentScroll`, plus the quadratic overlay curve.

## Assets / images
**12 tunnel images**, each displayed in a fixed **180×220 px portrait frame with `object-fit:cover`** (the tunnel frame is ~**9:11 / ~4:5** portrait; the source files themselves vary from square-ish to tall portrait and are center-cropped to fill the frame). All twelve share one role — interchangeable **tunnel gallery frames** — and all read as a cohesive, muted, high-contrast **editorial / art-photography** set that pops against the black stage. Concretely, the real set is a varied but tonally-linked mix:

- **Minimalist white sculptural still lifes** — a smooth abstract organic form and soft creases on a near-white ground; palette almost entirely **off-white / pale grey**, very low contrast, luminous.
- **Grey-and-white marble / liquid-swirl textures** — flowing veined stone-like abstraction in **whites, silvers and charcoal-grey**.
- **Silver product / packaging shots** — e.g. a brushed **aluminium tin** on a soft grey seamless backdrop; **metallic silver and neutral grey** tones.
- **Motion-blurred figure** — a person in mid-movement, streaked and soft; muted **grey, taupe and off-white** clothing against a blurred neutral background.
- **Ethereal beauty / portrait crop** — a pale, backlit fair-haired figure with delicate translucent floral/butterfly adornments in a soft garden; **pastel** palette of cream, blush-pink and soft green with hazy warm light.
- **Backlit macro botanical** — slender pale sprouts and leaves against a **deep indigo / violet** background — the one saturated, dark-background accent in an otherwise pale, neutral set.

The dominant overall palette is **whites, silvers, greys and soft neutrals** with occasional **pastel and one deep-violet** accent, so the frames glow when they resolve at the camera plane. They are grouped 4 per ring across 3 unique rings, which repeat to fill 6 rendered layers. Provide 12 files named sequentially (`img1…img12`); keep the same neutral, high-contrast, luminous-on-black feel across all of them. If fewer are available, repeat them in order.

## Behavior notes
- **Wheel / trackpad only** — no ScrollTrigger, no click, no keyboard. On a device without wheel events the tunnel sits at the `initialScroll = 750` resting pose (still 3D, just static).
- **Infinite in both directions**: the modulo wrap means you can fly forward or backward forever.
- **Desktop-oriented**; sizing is in fixed px (radii 400/280, items 180×220) so the composition is tuned for a large viewport. No responsive breakpoints in the original.
- Stage height uses `100svh` so mobile browser chrome doesn't clip it.
- No reduced-motion handling in the original; motion is entirely user-driven (nothing autoplays), though the lerp glide continues briefly after input stops.
