# 3D Tunnel Image Slider — WebGL Shader Tunnel + Scroll-Driven Z-Flight Carousel

## Goal
Build a full-screen experience where **ten framed portrait images fly toward the camera down the Z axis of a CSS-perspective tunnel**, over a **hypnotic monochrome neon-tunnel pattern rendered by a Three.js fragment shader** that fills the whole background. Smooth scrolling (Lenis) feeds a GSAP `ScrollTrigger` with `scrub: 1`: the same scroll progress (a) pushes every slide's `translateZ` from deep-space toward the viewer, fading each one in as it nears, and (b) drives a `scrollOffset` uniform that warps/advances the shader tunnel in sync. Result: as you scroll, images emerge one after another out of a spinning light-tunnel and rush past you, with the pattern accelerating in lock-step.

## Tech
- Vanilla HTML / CSS / JS with ES module imports, bundled by **Vite** (npm project).
- **`gsap` (npm)** plus the GSAP plugin **`ScrollTrigger`** (`import { ScrollTrigger } from "gsap/ScrollTrigger"`, register with `gsap.registerPlugin(ScrollTrigger)`).
- **`lenis` (npm)** for smooth scroll, wired into GSAP's ticker.
- **`three` (npm)** imported `import * as THREE from "three"` — a single full-screen shader plane (raw GLSL, `ShaderMaterial`). No other Three geometry.
- The tunnel background is **WebGL**; the carousel slides are **plain DOM `<div>`s** transformed in 3D by CSS + inline styles. The two systems only share the scroll progress value.

Lenis + GSAP ticker wiring (put at top of the module, runs immediately):
```js
const lenis = new Lenis();
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => { lenis.raf(time * 1000); });
gsap.ticker.lagSmoothing(0);
```

## Layout / HTML
Class names / ids are load-bearing (JS queries `.slider`, `.container`; CSS styles the rest). Nav/footer copy is neutral fictional demo text (no real brands).

```html
<nav>
  <div class="nav-items">
    <a href="#">Works</a>
    <a href="#">Archive</a>
  </div>
  <div class="logo">
    <a href="#">Tunnel Vision</a>
  </div>
  <div class="nav-items">
    <a href="#">Info</a>
    <a href="#">Contact</a>
  </div>
</nav>

<footer>
  <p>Watch Showreel</p>
  <p>Launching 2024</p>
</footer>

<div class="container">
  <div class="overlay"></div>
  <div class="slider"></div>
</div>

<!-- Raw GLSL shaders live as inline <script> tags read by their id -->
<script id="vertexShader" type="x-shader/x-vertex"> …see shaders… </script>
<script id="fragmentShader" type="x-shader/x-fragment"> …see shaders… </script>

<script type="module" src="./script.js"></script>
```

- `.slider` is **empty in the HTML** — the JS generates the 10 `.slide` elements into it at load.
- Each generated slide has this shape:
  ```html
  <div class="slide" id="slide-N">
    <div class="slide-img"><img src="/…/imgN.jpg" alt=""></div>
    <div class="slide-copy"><p>{title}</p><p>{id}</p></div>
  </div>
  ```
- The Three.js `renderer.domElement` `<canvas>` is created in JS and `appendChild`-ed to `<body>`; CSS pins it behind everything.

## Styling
Global reset: `* { margin:0; padding:0; box-sizing:border-box; }`. `body { width:100%; height:100%; background:#000; overflow-x:hidden; }`.

**Fonts** (both proprietary in the original — use close generic fallbacks):
- Body / links / captions: a **monospace** face (`"Akkurat Mono", Arial, sans-serif`) — any clean mono works. `a, p { color:#fff; text-decoration:none; text-transform:uppercase; font-family:<mono>; font-size:12px; font-weight:500; }`.
- Logo: an **elegant condensed display serif** (`"Timmons NY 2.005", Arial, sans-serif`) — a tall light-weight serif. `.logo a { font-family:<display-serif>; font-size:48px; font-weight:lighter; }`.

**Key rules:**
- `canvas { position:fixed; top:0; left:0; z-index:-1; }` — the shader tunnel sits behind the whole page.
- `img { width:100%; height:100%; object-fit:cover; }`.
- `nav, footer { position:fixed; width:100%; padding:2em; display:flex; justify-content:space-between; align-items:center; mix-blend-mode:exclusion; z-index:2; }` — `mix-blend-mode:exclusion` is what makes the nav/footer text invert against whatever tunnel/slide is behind it. `nav { top:0; } footer { bottom:0; }`.
- `nav > div { flex:1; display:flex; gap:2em; }`; `.logo { display:flex; justify-content:center; }`; `.nav-items:nth-child(3) { justify-content:flex-end; }` (left group / centered logo / right group).
- **`.container { width:100%; height:2000vh; }`** — this **20-viewport-tall block is the entire scroll runway** and the only ScrollTrigger `trigger`. Nothing visible scrolls; the height just gives scroll distance.
- **`.slider { position:fixed; top:0; width:100vw; height:100vh; transform-style:preserve-3d; perspective:500px; overflow:hidden; z-index:2; }`** — the fixed 3D stage. **`perspective:500px` is critical**: with the huge Z translations below, it makes slides shrink to nothing when far and balloon as they reach the camera.
- `.slide { position:absolute; width:400px; height:500px; will-change:transform, opacity; }` (each frame is 4:5 portrait).
- `.slide-img { width:100%; height:100%; padding:0.5em; background-color:rgba(255,255,255,0.1); border:1px solid rgba(255,255,255,0.2); backdrop-filter:blur(20px); }` — a frosted glass mat around each photo.
- `.slide-copy { position:absolute; width:100%; bottom:-24px; display:flex; justify-content:space-between; }` — title left, id right, just below the frame.
- `.overlay { position:fixed; top:0; left:0; width:100%; height:100%; background: radial-gradient(circle, rgba(0,0,0,0) 60%, rgba(0,0,0,1) 100%); }` — a radial vignette: clear center, fully black corners. It sits above the tunnel canvas but the slider (`z-index:2`) renders above it.
- Include the standard Lenis helper CSS (`.lenis.lenis-smooth { scroll-behavior:auto !important; }`, `.lenis.lenis-stopped { overflow:hidden; }`, etc.).

## The star effect (be exact)

### Part A — Three.js shader tunnel (full-screen background)
Orthographic full-screen plane textured by a raw fragment shader. Set up once:
```js
const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

const geometry = new THREE.PlaneGeometry(2, 2);   // covers the whole clip space
const uniforms = {
  iTime:        { value: 0 },
  iResolution:  { value: new THREE.Vector2(window.innerWidth, window.innerHeight) },
  scrollOffset: { value: 0 },
};
const material = new THREE.ShaderMaterial({
  uniforms,
  vertexShader:   document.getElementById("vertexShader").textContent,
  fragmentShader: document.getElementById("fragmentShader").textContent,
});
scene.add(new THREE.Mesh(geometry, material));
```

**Vertex shader** (pass-through, no projection — the plane already spans clip space):
```glsl
void main() {
  gl_Position = vec4(position, 1.0);
}
```

**Fragment shader** — the tunnel. A monochrome (white-on-black) radial pattern: `atan` of the centered pixel makes angular spokes, and `sin` of a `1/length` (reciprocal-radius) term makes concentric rings that pack tighter toward the center, reading as depth. `iTime` spins/pulses it continuously; `scrollOffset` (0→1 from ScrollTrigger) is added with a ×200 gain so scrolling rushes the tunnel forward:
```glsl
uniform vec2  iResolution;
uniform float iTime;
uniform float scrollOffset;

void mainImage(out vec4 o, vec2 I) {
  I -= o.zw = iResolution.xy / 2.0;                 // recenter pixel; stash half-res in o.zw
  float t = iTime * 5.0 + scrollOffset * 200.0;     // time advance + scroll rush
  float pattern = sin(atan(I.y, I.x) / 0.1)         // angular spokes (angle × 10)
                * sin(20.0 * (o.w /= length(I)) + t)// rings from reciprocal radius, animated
                - 1.0 + o.w;                         // brighten toward center
  float monochrome = 1.0 - pattern * 0.5;
  float invertedMonochrome = 1.0 - monochrome;       // = pattern * 0.5
  o = vec4(invertedMonochrome, invertedMonochrome, invertedMonochrome, 1.0);
}
void main() { mainImage(gl_FragColor, gl_FragCoord.xy); }
```

**Continuous render loop** (drives `iTime` from wall-clock delta, independent of scroll — the tunnel is always alive):
```js
let lastTime = 0;
function animateTunnel(time) {
  const deltaTime = time - lastTime;
  lastTime = time;
  uniforms.iTime.value += deltaTime * 0.001;   // ms → sec-ish
  renderer.render(scene, camera);
  requestAnimationFrame(animateTunnel);
}
animateTunnel(0);
```

**Resize:** `renderer.setSize(innerWidth, innerHeight); uniforms.iResolution.value.set(innerWidth, innerHeight);`.

### Part B — generate the 10 slides
Constants: `totalSlides = 10`, `zStep = 2500`, `initialZ = -22500`. Loop `i = 1..10`, build the slide DOM (`img.src = "/…/img" + i + ".jpg"`, caption `<p>{title}</p><p>{id}</p>`), append to `.slider`, then set its starting transform:
```js
const zPosition = initialZ + (i - 1) * zStep;        // -22500, -20000, … , 0  (slide 10 starts at 0)
const xPosition = i % 2 === 0 ? "30%" : "70%";       // alternate left / right → tunnel weave
const opacity   = i === totalSlides ? 1 : 0;         // only the last slide (z=0) starts visible

gsap.set(slide, {
  top: "50%", left: xPosition,
  xPercent: -50, yPercent: -50,
  z: zPosition, opacity,
});
```
So the ten frames are stacked receding into the distance every 2500px, **alternating horizontally between 30% and 70%** of the width so they zig-zag rather than stack dead-center. The nearest (slide 10) sits at the camera plane; the rest are far away and invisible.

### Part C — scroll wiring (`scrub: 1`, one shader trigger + one per slide)
All triggers use the same tall runway: `trigger: ".container", start: "top top", end: "bottom bottom", scrub: 1`.

**Shader trigger** — map scroll progress straight into the uniform:
```js
ScrollTrigger.create({
  trigger: ".container", start: "top top", end: "bottom bottom", scrub: 1,
  onUpdate: (self) => { uniforms.scrollOffset.value = self.progress; },
});
```

**Per-slide triggers** — for each slide, read its starting Z with `gsap.getProperty(slide, "z")`, then on every update push it +22500 across the full scroll and recompute opacity from its Z:
```js
const mapRange = (v, inMin, inMax, outMin, outMax) =>
  ((v - inMin) * (outMax - outMin)) / (inMax - inMin) + outMin;

slides.forEach((slide) => {
  const initialZ = gsap.getProperty(slide, "z");
  ScrollTrigger.create({
    trigger: ".container", start: "top top", end: "bottom bottom", scrub: 1,
    onUpdate: (self) => {
      const currentZ = initialZ + self.progress * 22500;   // travels 22500px toward camera
      let opacity;
      if (currentZ >= -2500) opacity = mapRange(currentZ, -2500, 0, 0, 1); // fade in over last 2500px
      else                   opacity = 0;                                   // hidden while far
      slide.style.opacity = opacity;
      slide.style.transform =
        `translateX(-50%) translateY(-50%) translateZ(${currentZ}px)`;
    },
  });
});
```

**How it reads:** each slide's Z sweeps by exactly +22500 over the scroll. A slide is fully hidden while `currentZ < -2500` (tiny and far), **fades 0→1 as its Z crosses −2500 → 0** (mapRange, un-clamped so it stays ≥1 = fully opaque afterward), and — because the transform keeps applying past 0 into positive Z — it keeps rushing toward and past the camera, growing huge through the `perspective:500px` before exiting. Slides enter staggered (their start Z is offset by 2500 each), so scrolling produces a continuous stream of frames emerging from the tunnel one after another. Note the per-frame `slide.style.transform` uses a fixed `translateX(-50%) translateY(-50%)` to re-center on its `left` anchor (30%/70%), so the horizontal zig-zag from Part B persists while only Z changes.

Because everything is `scrub: 1`, scrolling **up** reverses the whole thing — slides retreat back down the tunnel and the shader unwinds — with a ~1s smoothing lag.

## Assets / images
**10 carousel slides**, each a **portrait 4:5** frame (displayed 400×500, `object-fit:cover`). A cohesive cyberpunk / editorial-fashion set on saturated grounds — moody portraits behind ribbed/lenticular glass, figures crossed by neon light bars and long-exposure streaks, models in reflective visors — heavy on **red, teal/cyan, and orange neon** against near-black or vivid-red backdrops. Describe generically, no brands/logos. Any 10 images work; if you have fewer, repeat in order — the effect is identical. Example roles:
1. Long-exposure figure raising one hand behind fine vertical slats, red & cyan light bands on near-black.
2. Portrait through red ribbed lenticular glass, teal-split face, glowing red LED bar across the eyes.
3. Two models (leather jacket + reflective goggles / blonde wig) on turquoise, orange vertical light stripes across their faces.
4. Portrait behind red-and-white ribbed glass, blue-lit face, horizontal white streak on saturated red.
5. Dark long-exposure crouching figure behind vertical slats, red and white-blue horizontal streaks.
6. Close-up cyberpunk portrait, teal-lit man on vivid red, glowing orange holographic HUD visor.
7. Blurred portrait behind slats, yellow LED eye-bar, magenta/white streaks on dark navy.
8. Two models in black on deep red, both in reflective/LED visor sunglasses, one sharp / one blurred.
9. Side-profile portrait behind fine lines, yellow-green LED bar, red-white streaks on dark teal.
10. Portrait through red ribbed glass, two bright teal glowing orbs over the eyes on vivid red.

**Captions** — each slide shows a one-word title (left) and a neutral catalog id (right). Use neutral values, e.g. titles `Neon, Volt, Echo, Glitch, Pulse, Cipher, Nova, Synth, Flux, Vapor` and short numeric ids like `30128, 39102, 84729, …` (no brand-prefixed codes).

## Behavior notes
- **Two decoupled clocks:** the shader animates continuously via its own rAF loop (never idle); the slide Z-flight and the shader's `scrollOffset` warp are purely scroll-driven. Idle = tunnel keeps spinning, slides frozen.
- **Scrub smoothing** (`scrub: 1`) gives ~1s inertia to both the slide motion and the tunnel-rush; fully reversible on scroll-up.
- **Keep `.container` at `height:2000vh`** — that height *is* the animation timeline; shrink it and slides fly past too fast.
- **Desktop / performance-heavy:** WebGL shader + `backdrop-filter:blur(20px)` on ten frames. No reduced-motion branch and no mobile layout in the original — treat as desktop-oriented, not mobile-safe.
- `perspective:500px` on `.slider` + `transform-style:preserve-3d` are mandatory; without them the Z translations do nothing.
