# Three.js Wheel-Driven Shader Slider

## Goal
Build a full-viewport **WebGL image slider driven by the mouse wheel**. A single 16:9 plane floats in the center of the screen over a soft grey-to-white gradient page. Scrolling wheels through an endless loop of 7 images: a custom fragment shader performs a **vertical filmstrip wipe** (the next image slides up from the bottom edge while the current one slides out the top), a vertex shader **bulges the plane toward the camera** proportionally to scroll velocity, and the whole plane **swells/shrinks slightly** with scroll intensity. When scrolling stops, a lerp **snaps to the nearest whole image** and the project title (clipped inside a 16px-tall mask) **slides back up into view** with the new slide's name. Everything is hand-rolled in a `requestAnimationFrame` loop plus one CSS transition — **no GSAP, no ScrollTrigger, no page scroll**.

## Tech
Vanilla HTML/CSS/JS with ES module imports. Use **`three` (npm) only**:
```js
import * as THREE from "three";
```
No GSAP, no Lenis. Structure the JS in three modules: `script.js` (main), `shaders.js` (exports `vertexShader` and `fragmentShader` GLSL strings), and `slides.js` (exports a `slides` array of `{ title, url, image }`).

## Layout / HTML
```html
<body>
  <nav>
    <div class="logo"><a href="#">Motionprompts</a></div>
    <div class="links">
      <a href="#">About</a>
      <a href="#">Contact</a>
      <div class="socials">
        <a href="#">FB</a><a href="#">IG</a><a href="#">YT</a>
      </div>
    </div>
  </nav>

  <footer>
    <p>Experiment 444</p>
    <p>Scroll to explore</p>
  </footer>

  <div class="gradient-bg"></div>

  <div class="container">
    <div class="project-title-container">
      <a href="#" id="project-link">
        <div class="project-title-mask">
          <p id="project-title">Title 1</p>
        </div>
      </a>
    </div>
  </div>
  <script type="module" src="./script.js"></script>
</body>
```
The Three.js `<canvas>` is appended by JS into `.container`. The title sits in a fixed, centered overlay exactly matching the plane's footprint.

## Styling
Import Google font **Roboto Mono** (`@import url("https://fonts.googleapis.com/css2?family=Roboto+Mono:ital,wght@0,100..700;1,100..700&display=swap")`). Global reset `* { margin:0; padding:0; box-sizing:border-box; }`.

- `a, p`: `display:block; text-decoration:none; text-transform:uppercase; color:#000; font-family:"Roboto Mono"; font-size:12px; font-weight:400;` — everything on the page is tiny uppercase mono text.
- `nav, footer`: `position:absolute; left:0; width:100vw; padding:1em; display:flex; justify-content:space-between; gap:2em; z-index:1;` — nav pinned `top:0`, footer pinned `bottom:0`.
- `.links, .socials`: `display:flex; gap:2em;`. Also `nav > *, .links a { flex:1; }` so the nav spreads evenly across the top.
- `.gradient-bg`: `position:absolute; bottom:0; width:100vw; height:100svh; background:linear-gradient(0deg, rgba(204,204,204,1) 0%, rgba(255,255,255,1) 100%); z-index:0;` — light grey at the bottom fading to white at the top. The WebGL canvas is transparent, so this gradient is the page background.
- `.container`: `position:relative; width:100vw; height:100svh; overflow:hidden; z-index:2;` — hosts the canvas.
- `.project-title-container`: `position:fixed; top:50%; left:50%; transform:translate(-50%,-50%); width:50%; aspect-ratio:16/9;` — the exact same box as the WebGL plane (50% of viewport width, 16:9), so the title is vertically centered over the image.
- `#project-link`: `width:100%; height:100%; display:flex; align-items:center; color:#fff;`.
- `.project-title-mask`: `position:relative; width:100%; height:16px; clip-path:polygon(0 0, 100% 0, 100% 100%, 0% 100%); overflow:hidden;` — a **16px-tall clipping window**; the title hides by translating just past it.
- `#project-title`: `display:block; position:relative; transform:translateY(0px); color:#fff; text-align:center; text-transform:uppercase; font-family:"Roboto Mono"; font-size:13px; line-height:1; transition: transform 0.5s ease-in-out;` — **this CSS transition is the only tweened animation**; JS just flips the `transform` value.

## The effect (be exhaustive — hand-rolled rAF + shaders, no GSAP)

### Slides data (`slides.js`)
7 entries, each `{ title, url, image }`. Titles: **"Chromatic Loopscape", "Solar Bloom", "Neon Handscape", "Echo Discs", "Void Gaze", "Gravity Sync", "Heat Core"**. URLs are placeholder links (e.g. `https://example.com/alpha` … `/eta`). Images are the 7 texture files. On load, set `#project-title` text and `#project-link` href from `slides[0]`.

### State variables (exact values matter)
```js
let scrollIntensity = 0, targetScrollIntensity = 0;
const maxScrollIntensity = 1.0;
const scrollSmoothness = 0.5;          // fast lerp — intensity reacts snappily

let scrollPosition = 0, targetScrollPosition = 0;
const scrollPositionSmoothness = 0.05; // slow lerp — position glides with heavy inertia

let isMoving = false;
const movementThreshold = 0.001;
let isSnapping = false;

let stableCurrentIndex = 0, stableNextIndex = 1, isStable = false;
let titleHidden = false, titleAnimating = false, currentProjectIndex = 0;
```

### Scene setup
- `THREE.Scene()`, `THREE.PerspectiveCamera(75, innerWidth/innerHeight, 0.1, 1000)`, `camera.position.z = 5`.
- `THREE.WebGLRenderer({ antialias: true })`; `setSize(innerWidth, innerHeight)`; `setPixelRatio(Math.min(devicePixelRatio, 2))`; **`setClearColor(0xffffff, 0)`** — alpha 0 so the CSS gradient shows through. Append `renderer.domElement` to `.container`.
- **Plane dimensions from the camera frustum**: `viewportHeight = 2 * tan(fovRadians/2) * camera.position.z`, `viewportWidth = viewportHeight * camera.aspect`. Then `widthFactor = innerWidth < 900 ? 0.9 : 0.5`; `planeWidth = viewportWidth * widthFactor`; `planeHeight = planeWidth * (9/16)`. So the plane occupies **50% of the viewport width on desktop, 90% under 900px**, always 16:9 — matching `.project-title-container`.
- `THREE.PlaneGeometry(width, height, 32, 32)` — the **32×32 segments are required** for the vertex-shader bend.
- Load all 7 textures with `THREE.TextureLoader`; set `minFilter` and `magFilter` to `THREE.LinearFilter`; mark each `needsUpdate = true` to preload.
- `THREE.ShaderMaterial` with `side: THREE.DoubleSide` and uniforms:
  - `uScrollIntensity` (float, scroll velocity −1..1)
  - `uScrollPosition` (float, fractional slide progress 0..1)
  - `uCurrentTexture`, `uNextTexture` (samplers, init `textures[0]` / `textures[1]`)

### Vertex shader (velocity bulge)
```glsl
uniform float uScrollIntensity;
varying vec2 vUv;
void main() {
  vUv = uv;
  vec3 pos = position;
  float sideDistortion = sin(uv.y * 3.14159) * uScrollIntensity * 0.5;
  float topBottomDistortion = sin(uv.x * 3.14159) * uScrollIntensity * 0.2;
  pos.z += sideDistortion + topBottomDistortion;
  gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
}
```
Both `sin(uv * π)` terms peak at the plane's center and vanish at the edges, so the plane **bows toward the camera like a sail** (amplitude 0.5 along the vertical axis, 0.2 along the horizontal). Sign follows scroll direction: scroll down bulges forward, scroll up bows backward.

### Fragment shader (vertical filmstrip wipe)
```glsl
uniform sampler2D uCurrentTexture;
uniform sampler2D uNextTexture;
uniform float uScrollPosition;
varying vec2 vUv;
void main() {
  float normalizedPosition = fract(uScrollPosition);
  vec2 currentUv = vec2(vUv.x, mod(vUv.y - normalizedPosition, 1.0));
  vec2 nextUv = vec2(vUv.x, mod(vUv.y + 1.0 - normalizedPosition, 1.0));
  if (vUv.y < normalizedPosition) {
    gl_FragColor = texture2D(uNextTexture, nextUv);
  } else {
    gl_FragColor = texture2D(uCurrentTexture, currentUv);
  }
}
```
As `uScrollPosition` goes 0 → 1, a horizontal seam rises from the bottom edge: below the seam the **next** texture shows, above it the **current** one, and both textures' `uv.y` are offset by the same amount so the pair scrolls together like one continuous vertical filmstrip.

### Texture index bookkeeping
`determineTextureIndices(position)` — with 7 slides:
- `baseIndex = Math.floor(position % totalImages)`; if negative, wrap with `(totalImages + baseIndex) % totalImages` — supports scrolling **backwards past zero** infinitely.
- `nextIndex = (currentIndex + 1) % totalImages`.
- `normalizedPosition = position % 1`, +1 if negative.

Every frame, `updateTextureIndices()` assigns `uCurrentTexture`/`uNextTexture` from these indices — **unless `isStable` is true**, in which case it uses the frozen `stableCurrentIndex`/`stableNextIndex` (prevents a 1-frame texture pop at rest).

### Wheel input (the only trigger)
```js
window.addEventListener("wheel", (event) => {
  event.preventDefault();          // the page never scrolls
  isSnapping = false; isStable = false;
  hideTitle();
  targetScrollIntensity += event.deltaY * 0.001;
  targetScrollIntensity = Math.max(-1, Math.min(1, targetScrollIntensity)); // clamp ±1
  targetScrollPosition += event.deltaY * 0.001;
  isMoving = true;
}, { passive: false });
```

### The rAF loop (every frame, in this order)
1. `scrollIntensity += (targetScrollIntensity - scrollIntensity) * 0.5` → write to `uScrollIntensity`.
2. `scrollPosition += (targetScrollPosition - scrollPosition) * 0.05` (heavy glide).
3. `uScrollPosition = isStable ? 0 : positiveFract(scrollPosition)`.
4. `updateTextureIndices()`.
5. **Scale breathing**: `baseScale = 1.0`, `scaleIntensity = 0.1`. If `scrollIntensity > 0`: `scale = 1 + scrollIntensity * 0.1`; else `scale = 1 - |scrollIntensity| * 0.1`. Apply `plane.scale.set(scale, scale, 1)` — the plane grows up to 10% scrolling down, shrinks up to 10% scrolling up.
6. **Velocity decay**: `targetScrollIntensity *= 0.98` — intensity (bulge + scale) relaxes back to 0 on its own after input stops.
7. **Snap logic**: `scrollDelta = |targetScrollPosition - scrollPosition|`.
   - If `scrollDelta < 0.001` and `isMoving && !isSnapping` → `snapToNearestImage()`: set `targetScrollPosition = Math.round(scrollPosition)`, compute stable indices from that rounded position, store `currentProjectIndex`, call `showTitle()`. The 0.05 lerp then glides the seam to the nearest whole image.
   - If `scrollDelta < 0.0001` → if not already stable: `isStable = true`, `scrollPosition = Math.round(scrollPosition)`, `targetScrollPosition = scrollPosition`. Then `isMoving = false`, `isSnapping = false`.
8. `renderer.render(scene, camera)`.

### Title hide/show (CSS transition, 0.5s ease-in-out)
- `hideTitle()` (on first wheel event): if not hidden and not animating, set `projectTitle.style.transform = "translateY(20px)"` — the text drops below the 16px mask and disappears. A 500ms `setTimeout` flips `titleHidden = true`.
- `showTitle()` (called by the snap): if hidden and not animating, first swap `textContent` to `slides[currentProjectIndex].title` and the link `href` to the slide's URL, then set `transform = "translateY(0px)"` — the new title rises back into the mask. 500ms timeout resets the guard flags.
- Both use the `titleAnimating` flag to prevent overlap; the motion itself comes from the CSS `transition: transform 0.5s ease-in-out`.

### Resize
On `resize`: update `camera.aspect` + `updateProjectionMatrix()`, `renderer.setSize` + `setPixelRatio(min(dpr, 2))`, recompute plane dimensions (with the `< 900px → 0.9` width factor), `dispose()` the old geometry, and assign a fresh `PlaneGeometry(w, h, 32, 32)`.

## Assets / images
**7 slide textures, displayed in a 16:9 landscape frame** (any generous resolution, e.g. 1280×720+; they map edge-to-edge onto the plane). They should read as one cohesive set of **dark, saturated, abstract 3D renders with iridescent / chromatic-aberration lighting** — glossy surreal objects glowing against black or deep-gradient backgrounds. By role:

1. Slide 1 "Chromatic Loopscape" — interlocking rounded U-shaped tubes with iridescent chromatic edges on black.
2. Slide 2 "Solar Bloom" — a soft glossy translucent orb floating over a warm red-orange gradient haze.
3. Slide 3 "Neon Handscape" — two symmetrical stylized 3D hands with long fingers in purple/orange thermal tones on black.
4. Slide 4 "Echo Discs" — stacked glossy elliptical discs orbited by a thin ring over a saturated rainbow gradient.
5. Slide 5 "Void Gaze" — two glowing orbs inside a dark almond-shaped void framed by purple/orange bands, like an eye.
6. Slide 6 "Gravity Sync" — a floating ringed planet with smaller spheres over a purple-to-orange gradient.
7. Slide 7 "Heat Core" — a cross-shaped cluster of glossy metallic cylinders with orange/purple iridescent reflections on black.

Name them sequentially (`img1.jpg` … `img7.jpg`) and reference them from `slides.js`.

## Behavior notes
- **Wheel/trackpad only** — the wheel listener calls `preventDefault()` (registered with `{ passive: false }`), so the document never scrolls; "Scroll to explore" refers to the slider itself. No click/drag/keyboard navigation.
- **Infinite in both directions**: positive-modulo index math loops 7 → 1 forward and 1 → 7 backward with no seam.
- The slider always comes to rest **exactly on a whole image** (snap + stable-state clamp), with `uScrollPosition` forced to 0 while stable.
- **Desktop-oriented**; the only responsive concession is the plane width factor (0.5 → 0.9 below 900px viewport width) and full resize handling. No reduced-motion handling — motion is entirely user-driven.
- Canvas is transparent; the grey→white CSS gradient is the visible backdrop, and thin black mono type (nav/footer) frames the composition while the white title floats centered on the imagery.
