# Three.js Infinite Curved-Distortion Slider — Velocity-Reactive Vertex Warp

## Goal
Build a **full-viewport, infinitely looping horizontal image slider rendered entirely in Three.js**. Ten textured planes drift left/right in 3D space, driven by mouse wheel, touch drag, or arrow keys. The star effect: as the strip moves, the planes **bend toward the camera in a radial bulge centered on the middle of the screen** — a curved-screen distortion whose intensity is proportional to scroll velocity. Fast flicks warp the slides dramatically; when motion settles, the planes relax back to perfectly flat. The strip wraps seamlessly, so you can scroll forever in either direction. Everything is lerped every frame — position, per-slide easing, and the distortion factor itself — so the whole thing feels weighty and fluid, never snappy.

## Tech
Vanilla HTML/CSS/JS with ES module imports. **`three` (npm) only — no GSAP, no smooth-scroll library.** The entire engine is a hand-rolled `requestAnimationFrame` loop with linear interpolation and a velocity tracker. `import * as THREE from "three";`

## Layout / HTML
```
nav                 (fixed top strip — two small labels)
  p  "[ Silhouette ]"
  p  "/ Experiment by Silhouette"
footer              (fixed bottom strip — two small labels)
  p  "Infinite WebGL Slider"
  p  "Scroll to explore ↓"
canvas#canvas       (the Three.js render target, fills the viewport)
```
The `<script type="module" src="./script.js">` goes at the end of `<body>`.

## Styling
Minimal — the canvas is the whole show.
- Global reset `* { margin: 0; padding: 0; box-sizing: border-box; }`.
- `body { font-family: "Akkurat Mono", monospace; background-color: #e3e3db; color: #0f0f0f; }` (no webfont needed — the monospace fallback is fine).
- `p { text-transform: uppercase; font-size: 13px; font-weight: 600; letter-spacing: -0.02em; -webkit-font-smoothing: antialiased; }`
- `nav, footer { position: fixed; width: 100vw; padding: 2em; display: flex; justify-content: space-between; align-items: center; z-index: 2; opacity: 0; }` — **yes, `opacity: 0`**: the labels are intentionally invisible in this demo (kept in the DOM but hidden). `nav { top: 0 }`, `footer { bottom: 0 }`.
- `#canvas { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; overflow: hidden; }`

## The Three.js engine (exhaustive — this is the effect)

### Renderer / scene / camera
```js
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, preserveDrawingBuffer: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));

const scene = new THREE.Scene();
scene.background = new THREE.Color(0xe3e3db);          // matches the page background exactly

const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 100);
camera.position.z = 5;
```
On `resize`: update `camera.aspect`, `camera.updateProjectionMatrix()`, `renderer.setSize(...)`.

### Constants and settings (use these exact values)
```js
const settings = {
  wheelSensitivity: 0.01,
  touchSensitivity: 0.01,
  momentumMultiplier: 2,
  smoothing: 0.1,            // lerp factor: currentPosition → targetPosition
  slideLerp: 0.075,          // lerp factor: each slide's currentX → targetX
  distortionDecay: 0.95,
  maxDistortion: 2.5,        // max Z bulge in world units at distortionFactor = 1
  distortionSensitivity: 0.15,
  distortionSmoothing: 0.075 // lerp factor: currentDistortionFactor → targetDistortionFactor
};

const slideWidth = 3.0;      // world units
const slideHeight = 1.5;     // world units (2:1 landscape planes)
const gap = 0.1;
const slideCount = 10;
const imagesCount = 5;       // textures cycle: slide i uses image (i % 5) + 1
const totalWidth = slideCount * (slideWidth + gap);   // 31
const slideUnit = slideWidth + gap;                   // 3.1
```
State variables: `currentPosition`, `targetPosition`, `isScrolling`, `autoScrollSpeed`, `lastTime`, `touchStartX`, `touchLastX`, `currentDistortionFactor`, `targetDistortionFactor`, `peakVelocity`, and `velocityHistory = [0, 0, 0, 0, 0]` (a 5-entry rolling window).

### Building the slides
For each of the 10 slides:
- `new THREE.PlaneGeometry(slideWidth, slideHeight, 32, 16)` — the **32×16 segment resolution is load-bearing**; the distortion bends individual vertices.
- Material: `THREE.MeshBasicMaterial({ color, side: THREE.DoubleSide })` where `color` cycles through 5 placeholder hexes `["#FF5733", "#33FF57", "#3357FF", "#F3FF33", "#FF33F3"]` (visible only until the texture loads).
- `mesh.position.x = index * slideUnit`, and store in `mesh.userData`: a **copy of the original position array** (`[...geometry.attributes.position.array]`) and the index.
- Load the texture asynchronously with `THREE.TextureLoader`. On load: set `texture.colorSpace = THREE.SRGBColorSpace`, assign `material.map = texture`, reset `material.color.set(0xffffff)`, `material.needsUpdate = true`. Then **aspect-fit (contain)** the image by scaling the mesh: if the image is wider than the 2:1 slide, `mesh.scale.y = slideAspect / imgAspect`; otherwise `mesh.scale.x = imgAspect / slideAspect`.
- Image paths: `./images/img1.jpg` … `img5.jpg`, slide `i` uses image `(i % 5) + 1`.

After creating all slides, recenter the strip: subtract `totalWidth / 2` from every slide's `position.x`, and initialize `slide.userData.targetX = slide.userData.currentX = slide.position.x`.

### The vertex distortion (`updateCurve(mesh, worldPositionX, distortionFactor)`)
A radial bulge fixed at **world center (0, 0)** with **radius 2.0**. For every vertex of the plane (reading X/Y from the stored *original* vertices, never the mutated ones):
```js
const vertexWorldPosX = worldPositionX + x;   // x, y = original local vertex coords
const distFromCenter = Math.sqrt((vertexWorldPosX - 0) ** 2 + (y - 0) ** 2);
const distortionStrength = Math.max(0, 1 - distFromCenter / 2.0);
const curveZ = Math.pow(Math.sin((distortionStrength * Math.PI) / 2), 1.5)
             * settings.maxDistortion * distortionFactor;
positionAttribute.setZ(i, curveZ);
```
Then `positionAttribute.needsUpdate = true` and `mesh.geometry.computeVertexNormals()`. The `sin^1.5` falloff makes a smooth dome: vertices near screen center push up to `2.5 × distortionFactor` world units toward the camera, fading to zero at radius 2.

### Input handlers (all on `window`)
- **Wheel** (`{ passive: false }`, `e.preventDefault()`):
  - `targetDistortionFactor = Math.min(1.0, targetDistortionFactor + Math.abs(e.deltaY) * 0.001)`
  - `targetPosition -= e.deltaY * settings.wheelSensitivity` (×0.01)
  - `isScrolling = true`; `autoScrollSpeed = Math.min(Math.abs(e.deltaY) * 0.0005, 0.05) * Math.sign(e.deltaY)` — a momentum kick in the scroll direction.
  - Debounce: clear + set a 150ms timeout that flips `isScrolling = false`.
- **Touch**: `touchstart` records `touchStartX = touchLastX = touches[0].clientX`, `isScrolling = false`. `touchmove` (`passive: false`, preventDefault) uses the **incremental** delta from `touchLastX`: `targetDistortionFactor = min(1, target + |deltaX| * 0.02)`, `targetPosition -= deltaX * settings.touchSensitivity`, `isScrolling = true`. `touchend` computes flick velocity `(touchLastX - touchStartX) * 0.005`; if `|velocity| > 0.5`: `autoScrollSpeed = -velocity * settings.momentumMultiplier * 0.05`, `targetDistortionFactor = min(1, |velocity| * 3 * settings.distortionSensitivity)`, `isScrolling = true`, then `isScrolling = false` after an 800ms timeout.
- **Keyboard**: `ArrowLeft` → `targetPosition += slideUnit`; `ArrowRight` → `targetPosition -= slideUnit`; both also `targetDistortionFactor = Math.min(1.0, targetDistortionFactor + 0.3)` — one keypress steps exactly one slide and pulses the warp.

### The rAF loop (runs forever)
Per frame, with `deltaTime` in seconds (fallback 0.016 on the first frame):

1. **Momentum**: if `isScrolling`, `targetPosition += autoScrollSpeed`, then decay it: `autoScrollSpeed *= Math.max(0.92, 0.97 - Math.abs(autoScrollSpeed) * 0.5)`; zero it below 0.001.
2. **Master lerp**: `currentPosition += (targetPosition - currentPosition) * settings.smoothing` (×0.1).
3. **Velocity tracking**: `currentVelocity = |currentPosition - prevPosition| / deltaTime`. Push into the 5-entry `velocityHistory` (shift the oldest out), average it. Track `peakVelocity = max(peakVelocity, avgVelocity)`, and decay it by `×0.99` every frame. `isDecelerating = (avgVelocity / (peakVelocity + 0.001)) < 0.7 && peakVelocity > 0.5`.
4. **Velocity → distortion**: `movementDistortion = Math.min(1.0, currentVelocity * 0.1)`; if `currentVelocity > 0.05`, `targetDistortionFactor = Math.max(targetDistortionFactor, movementDistortion)`. If `isDecelerating || avgVelocity < 0.2`, decay: `targetDistortionFactor *= isDecelerating ? settings.distortionDecay : settings.distortionDecay * 0.9` (0.95 or 0.855).
5. **Distortion lerp**: `currentDistortionFactor += (targetDistortionFactor - currentDistortionFactor) * settings.distortionSmoothing` (×0.075).
6. **Per-slide wrap + ease** — for each slide `i`:
   ```js
   let baseX = i * slideUnit - currentPosition;
   baseX = ((baseX % totalWidth) + totalWidth) % totalWidth;  // wrap into [0, totalWidth)
   if (baseX > totalWidth / 2) baseX -= totalWidth;           // center into [-15.5, 15.5]

   // teleport (don't ease) when a slide wraps around an edge:
   if (Math.abs(baseX - slide.userData.targetX) > slideWidth * 2) slide.userData.currentX = baseX;

   slide.userData.targetX = baseX;
   slide.userData.currentX += (slide.userData.targetX - slide.userData.currentX) * settings.slideLerp; // ×0.075

   const wrapThreshold = totalWidth / 2 + slideWidth;         // 18.5
   if (Math.abs(slide.userData.currentX) < wrapThreshold * 1.5) {
     slide.position.x = slide.userData.currentX;
     updateCurve(slide, slide.position.x, currentDistortionFactor);
   }
   ```
   The per-slide 0.075 lerp on top of the master 0.1 lerp gives the strip a subtle elastic follow-through; the teleport check makes the infinite wrap invisible.
7. `renderer.render(scene, camera)`.

## Assets / images
**5 abstract, cinematic art images** (they repeat across the 10 slides in order 1-2-3-4-5-1-2-3-4-5). They display aspect-fit inside 2:1 landscape planes, so landscape sources look best. They should read as one cohesive experimental series — think motion-blurred thermal-camera silhouettes, swirling fluid-ink vortices on warm gradients, neon heat-map figures with raised arms, dancing silhouettes against sunburst gradients. Bold saturated color fields (yellow/red/orange, violet/indigo, magenta) with soft blur and movement.

## Behavior notes
- The strip is **truly infinite both ways** — the modulo wrap plus teleport check means no visible ends and no snap when a slide recycles.
- The page never scrolls; the wheel is fully hijacked (`preventDefault`) to drive the slider. Touch drag works the same way on mobile.
- With no input, the strip glides to a stop (momentum decay) and the bulge melts away (distortion decay) until the planes are flat again.
- Slide distortion depends on **world position**, not slide identity: whichever slide passes through screen center bulges the most, its neighbors bend partially where they enter the radius-2 zone.
- The nav/footer labels are rendered but invisible (`opacity: 0`) — keep them anyway for structure fidelity.
