# ThreeJS Infinite Vertical Slider (WebGL, velocity vertex-bend, no GSAP)

## Goal
Build a full-screen **infinite vertical slider rendered entirely in Three.js** — no DOM images, no
animation library. Ten textured planes of slightly random heights are stacked head-to-toe into one
tall column inside a WebGL scene and **loop endlessly** as you scroll. Input comes from the **mouse
wheel, click-drag, and touch swipe**; a hand-rolled `requestAnimationFrame` engine lerps a virtual
scroll position, adds **momentum with friction** on release, and wraps the column so slides recycle
forever in both directions. The star effect: **scroll velocity bends each plane's vertices along Z**
— a sine-shaped bulge that peaks at screen center and is **signed by scroll direction** (curves one
way scrolling down, the other way scrolling up) and **scaled by how fast/decelerating you are**, so
the images ripple into a soft curved sheet while moving and flatten back out when still. Two small
HTML text nodes — a slide **title** and a zero-padded **counter** — always track the slide currently
closest to center.

## Tech
- Vanilla HTML / CSS / JS with ES module imports, bundled by **Vite**.
- **`three` (npm)** only, imported `import * as THREE from "three";`.
- **No GSAP, no ScrollTrigger, no Lenis, no shaders.** The mesh geometry is deformed on the CPU by
  writing vertex Z positions every frame; all motion is a single custom `requestAnimationFrame` loop
  with manual lerp smoothing and momentum. Uses `MeshBasicMaterial` (unlit) — no lights needed.
- Desktop uses wheel + mouse drag; mobile uses touch. Everything is a single `script.js`.

## Data model
Ten slide objects, each `{ name, img }`. Use neutral, fictional one/two-word titles (no real
brands), e.g.:
```js
const slides = [
  { name: "Contour",        img: "/path/img1.jpg"  },
  { name: "Velum Drift",    img: "/path/img2.jpg"  },
  { name: "Quiet Exchange", img: "/path/img3.jpg"  },
  { name: "Earth Routine",  img: "/path/img4.jpg"  },
  { name: "Metal Echo",     img: "/path/img5.jpg"  },
  { name: "Tanned Edge",    img: "/path/img6.jpg"  },
  { name: "Humidity",       img: "/path/img7.jpg"  },
  { name: "Limestone Air",  img: "/path/img8.jpg"  },
  { name: "Warm Surface",   img: "/path/img9.jpg"  },
  { name: "Dust & Craft",   img: "/path/img10.jpg" },
];
```

## Layout / HTML
Almost no DOM — just the WebGL `<canvas>` and a two-item text overlay. The `<p>` nodes start empty;
JS fills them.
```html
<section class="slider">
  <div class="slide-info">
    <p id="slide-title"></p>
    <p id="slide-count"></p>
  </div>
  <canvas></canvas>
</section>
<script type="module" src="./script.js"></script>
```
`document.querySelector("canvas")` is passed straight into the renderer; `p#slide-title` and
`p#slide-count` are cached for the active-slide readout.

## Styling
Global reset: `* { margin:0; padding:0; box-sizing:border-box; user-select:none; }`

- **Font:** `p { font-family: "PP Neue Montreal", sans-serif; font-weight: 500; }` — a clean neutral
  grotesque sans (use any Neue-Montreal-style face or a system sans fallback). All text weight **500**.
  Default browser font size (no size override on the `<p>`s — they render at ~16px).
- **`section`** — `position: relative; width: 100%; height: 100svh; overflow: hidden;`
- **`canvas`** — `position: fixed; top: 0; left: 0; width: 100%; height: 100%; overflow: hidden;`
  (the WebGL surface fills the whole viewport behind the text).
- **`.slide-info`** — `position: absolute; top: 50%; left: 0; transform: translateY(-50%);
  width: 100%; padding: 0 2rem; display: flex; justify-content: space-between; color: #fff;
  z-index: 2;` — a vertically centered row spanning the full width: **title pinned left, counter
  pinned right**, both white, floating over the canvas.
- The scene's own background is a near-black `#141414` set on the Three.js scene (below), so the
  page reads as white text on a very dark WebGL field.

## The star effect — Three.js infinite column + velocity vertex-bend (be exact)
This is a near-verbatim port. Reproduce the constants, the stacking math, the distortion function,
the input math, and the per-frame loop exactly.

### 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(0x141414);            // near-black field

const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 100);
camera.position.z = 5;                                    // looking down the -Z axis at the column
```

### Config constants (use these exact values)
```js
const config = {
  minHeight: 1,               // slide plane min height (world units)
  maxHeight: 1.5,             // slide plane max height
  aspectRatio: 1.5,           // plane width = height * 1.5 (landscape planes)
  gap: 0.05,                  // vertical gap between stacked slides
  smoothing: 0.05,            // scrollPosition -> scrollTarget lerp per frame
  distortionStrength: 2.5,    // Z bulge multiplier
  distortionSmoothing: 0.1,   // distortionAmount lerp per frame
  momentumFriction: 0.95,     // momentum decay per frame
  momentumThreshold: 0.001,   // below this momentum snaps to 0
  wheelSpeed: 0.01,           // wheel delta -> scroll units
  wheelMax: 150,              // per-wheel-event delta clamp
  dragSpeed: 0.01,            // mouse-drag px -> scroll units
  dragMomentum: 0.01,         // mouse-drag release -> momentum
  touchSpeed: 0.01,           // touch-move px -> scroll units
  touchMomentum: 0.1,         // touch release -> momentum
};
```

Helpers used throughout:
```js
const wrap = (value, range) => ((value % range) + range) % range;   // always-positive modulo
const zeroPad = (n) => String(n).padStart(2, "0");
```

### Random heights + head-to-toe stacking (defines the loop length)
Each slide gets a random height in `[minHeight, maxHeight]`, then they are stacked into one column;
`slideOffsets[i]` is the **center position** of slide i along the vertical axis:
```js
const totalSlides = slides.length;                                   // 10
const slideHeights = Array.from({ length: totalSlides },
  () => config.minHeight + Math.random() * (config.maxHeight - config.minHeight));

const slideOffsets = [];
let stackPosition = 0;
for (let i = 0; i < totalSlides; i++) {
  if (i === 0) {
    slideOffsets.push(0);
    stackPosition = slideHeights[0] / 2;
  } else {
    stackPosition += config.gap + slideHeights[i] / 2;   // walk to this slide's center
    slideOffsets.push(stackPosition);
    stackPosition += slideHeights[i] / 2;                // walk to its far edge
  }
}
const loopLength = stackPosition + config.gap + slideHeights[0] / 2;  // full cycle length
const halfLoop = loopLength / 2;
```

### Meshes + texture cover-fit
One `PlaneGeometry` per slide, **subdivided `32 × 16`** (width × height segments — enough vertices
for a smooth CPU bend). `MeshBasicMaterial`, double-sided, starts flat grey `0x999999` until its
texture loads:
```js
for (let i = 0; i < totalSlides; i++) {
  const height = slideHeights[i];
  const width  = height * config.aspectRatio;
  const geometry = new THREE.PlaneGeometry(width, height, 32, 16);
  const material = new THREE.MeshBasicMaterial({ side: THREE.DoubleSide, color: 0x999999 });
  const mesh = new THREE.Mesh(geometry, material);

  mesh.userData = {
    originalVertices: [...geometry.attributes.position.array],   // pristine copy for distortion
    offset: slideOffsets[i],
    name: slides[i].name,
    index: i,
  };

  textureLoader.load(slides[i].img, (texture) => {
    texture.colorSpace = THREE.SRGBColorSpace;
    material.map = texture;
    material.color.set(0xffffff);          // reveal texture at full brightness
    material.needsUpdate = true;
    // fit image aspect by shrinking the mesh in one axis (letterbox-contain, keeps no stretch)
    const imageAspect = texture.image.width / texture.image.height;
    const planeAspect = width / height;    // = config.aspectRatio
    const ratio = imageAspect / planeAspect;
    if (ratio > 1) mesh.scale.y = 1 / ratio;   // image wider than plane -> shrink height
    else           mesh.scale.x = ratio;        // image taller -> shrink width
  });

  scene.add(mesh);
  meshes.push(mesh);
}
```
Keep `mesh.userData.originalVertices` — the distortion always reads from this pristine array, never
from the already-bent live positions, so it never accumulates.

### The vertex bend (`applyDistortion`, called per visible mesh per frame)
For every vertex, measure its distance from world origin using its **local x** and its **world y**
(mesh position + local y), build a sine-shaped falloff that peaks at center and dies by distance 2,
and write that as the vertex's Z. `strength` is the signed, velocity-scaled amount from the loop:
```js
function applyDistortion(mesh, positionY, strength) {
  const positions = mesh.geometry.attributes.position;
  const original  = mesh.userData.originalVertices;
  for (let i = 0; i < positions.count; i++) {
    const x = original[i * 3];
    const y = original[i * 3 + 1];
    const distance = Math.sqrt(x * x + (positionY + y) ** 2);   // radial distance from screen center
    const falloff  = Math.max(0, 1 - distance / 2);             // 1 at center -> 0 at dist 2
    const bend     = Math.pow(Math.sin((falloff * Math.PI) / 2), 1.5);  // eased 0..1 hump
    positions.setZ(i, bend * strength);                          // push vertex toward/away from camera
  }
  positions.needsUpdate = true;
  mesh.geometry.computeVertexNormals();
}
```
Because `strength` can be negative (see `signedDistortion`), the sheet bulges toward the camera when
scrolling one direction and away when scrolling the other — the whole column reads as a curved,
direction-aware ripple centered on the middle of the screen.

### State (module-level)
```js
let scrollPosition = 0, scrollTarget = 0, scrollMomentum = 0, isScrolling = false, lastFrameTime = 0;
let distortionAmount = 0, distortionTarget = 0, velocityPeak = 0, scrollDirection = 0, directionTarget = 0;
const velocityHistory = [0, 0, 0, 0, 0];                 // rolling window of 5 samples
let isDragging = false, dragStartY = 0, dragDelta = 0, touchStartY = 0, touchLastY = 0;
let activeSlideIndex = -1;
const addDistortionBurst = (amount) => { distortionTarget = Math.min(1, distortionTarget + amount); };
```

### Input handlers (exact math — all on `window`, drag also toggles the canvas cursor)
- **Wheel** (`{ passive:false }`, `preventDefault`): clamp the delta to `±wheelMax`, add a distortion
  burst proportional to it, advance the scroll target, and mark scrolling with a 150 ms debounce:
  ```js
  const clampedDelta = Math.sign(e.deltaY) * Math.min(Math.abs(e.deltaY), config.wheelMax);
  addDistortionBurst(Math.abs(clampedDelta) * 0.001);
  scrollTarget += clampedDelta * config.wheelSpeed;
  isScrolling = true;
  clearTimeout(window._scrollTimeout);
  window._scrollTimeout = setTimeout(() => (isScrolling = false), 150);
  ```
- **Touchstart** (`{ passive:false }`): `touchStartY = touchLastY = e.touches[0].clientY;
  isScrolling = false; scrollMomentum = 0;`
- **Touchmove** (`{ passive:false }`, `preventDefault`):
  ```js
  const deltaY = e.touches[0].clientY - touchLastY;
  touchLastY = e.touches[0].clientY;
  addDistortionBurst(Math.abs(deltaY) * 0.02);
  scrollTarget -= deltaY * config.touchSpeed;      // drag up -> scroll forward
  isScrolling = true;
  ```
- **Touchend:** compute a swipe velocity; if it exceeds a threshold, fling with momentum and a big
  distortion burst, then release scrolling after 800 ms:
  ```js
  const swipeVelocity = (touchLastY - touchStartY) * 0.005;
  if (Math.abs(swipeVelocity) > 0.5) {
    scrollMomentum = -swipeVelocity * config.touchMomentum;
    addDistortionBurst(Math.abs(swipeVelocity) * 0.45);
    isScrolling = true;
    setTimeout(() => (isScrolling = false), 800);
  }
  ```
- **Mouse drag** — `canvas.style.cursor = "grab"` initially.
  - **mousedown:** `isDragging = true; dragStartY = e.clientY; dragDelta = 0; scrollMomentum = 0;
    canvas.style.cursor = "grabbing";`
  - **mousemove** (only while dragging): `deltaY = e.clientY - dragStartY; dragStartY = e.clientY;
    dragDelta = deltaY; addDistortionBurst(Math.abs(deltaY) * 0.02);
    scrollTarget -= deltaY * config.dragSpeed; isScrolling = true;`
  - **mouseup:** `isDragging = false; canvas.style.cursor = "grab";` then if `|dragDelta| > 2`:
    `scrollMomentum = -dragDelta * config.dragMomentum;
    addDistortionBurst(Math.abs(dragDelta) * 0.005); isScrolling = true;`
    and `setTimeout(() => (isScrolling = false), 800);`
- **Resize:** update `camera.aspect`, `camera.updateProjectionMatrix()`, and `renderer.setSize(...)`.

### The render loop (`animate(time)` — recursive rAF, this is the whole engine)
```js
function animate(time) {
  requestAnimationFrame(animate);
  const deltaTime = lastFrameTime ? (time - lastFrameTime) / 1000 : 0.016;
  lastFrameTime = time;
  const previousScroll = scrollPosition;

  // 1. momentum (only while "scrolling"): add, then decay by friction, snap tiny to 0
  if (isScrolling) {
    scrollTarget += scrollMomentum;
    scrollMomentum *= config.momentumFriction;                 // 0.95
    if (Math.abs(scrollMomentum) < config.momentumThreshold) scrollMomentum = 0;
  }

  // 2. smooth the rendered scroll toward its target
  scrollPosition += (scrollTarget - scrollPosition) * config.smoothing;   // 0.05
  const frameDelta = scrollPosition - previousScroll;

  // 3. smoothed scroll DIRECTION (-1..1), lerped so sign flips ease in
  if (Math.abs(frameDelta) > 0.00001) directionTarget = frameDelta > 0 ? 1 : -1;
  scrollDirection += (directionTarget - scrollDirection) * 0.08;

  // 4. velocity + 5-sample rolling average, and a decaying peak tracker
  const velocity = Math.abs(frameDelta) / deltaTime;
  velocityHistory.push(velocity); velocityHistory.shift();
  const averageVelocity = velocityHistory.reduce((a, b) => a + b) / velocityHistory.length;
  if (averageVelocity > velocityPeak) velocityPeak = averageVelocity;
  const isDecelerating = averageVelocity / (velocityPeak + 0.001) < 0.7 && velocityPeak > 0.5;
  velocityPeak *= 0.99;

  // 5. drive the distortion target from speed, and shrink it when slow/decelerating
  if (velocity > 0.05) distortionTarget = Math.max(distortionTarget, Math.min(1, velocity * 0.1));
  if (isDecelerating || averageVelocity < 0.2)
    distortionTarget *= isDecelerating ? 0.95 : 0.855;
  distortionAmount += (distortionTarget - distortionAmount) * config.distortionSmoothing;  // 0.1
  const signedDistortion = distortionAmount * scrollDirection;   // <- signed by direction

  // 6. place every mesh in the wrapped column, bend the visible ones, track the centered slide
  let closestDistance = Infinity, closestIndex = 0;
  meshes.forEach((mesh) => {
    const { offset } = mesh.userData;
    let y = -(offset - wrap(scrollPosition, loopLength));
    y = wrap(y + halfLoop, loopLength) - halfLoop;    // recenter into [-halfLoop, halfLoop]
    mesh.position.y = y;
    if (Math.abs(y) < closestDistance) { closestDistance = Math.abs(y); closestIndex = mesh.userData.index; }
    if (Math.abs(y) < halfLoop + config.maxHeight)
      applyDistortion(mesh, y, config.distortionStrength * signedDistortion);
  });

  // 7. update the HTML title + counter when the centered slide changes
  if (closestIndex !== activeSlideIndex) {
    activeSlideIndex = closestIndex;
    titleElement.textContent  = slides[activeSlideIndex].name;
    counterElement.textContent = zeroPad(activeSlideIndex + 1);   // "01".."10"
  }

  renderer.render(scene, camera);
}
animate();
```

### What makes the feel (keep every constant)
- **Infinite loop:** step 6's double-`wrap` maps each slide's fixed `offset` into `[-halfLoop,
  halfLoop]` around the live scroll, so planes leaving one edge reappear at the other — endless in
  both directions with only 10 meshes.
- **Two-stage smoothing + momentum:** `smoothing 0.05` lerps the rendered scroll toward the target;
  on release, `scrollMomentum` (from drag/swipe) keeps feeding the target while decaying at
  `friction 0.95` until it drops under `0.001`. That is the weighty glide-to-stop.
- **Signed, velocity-reactive bend:** `distortionTarget` rises with instantaneous velocity (capped
  at 1), input bursts add to it, and it decays faster when the rolling average shows you're
  decelerating (`×0.95`) or nearly idle (`×0.855`). `distortionAmount` lerps toward it at `0.1`.
  Multiplying by the smoothed `scrollDirection (−1..1)` gives `signedDistortion`, so the sheet
  curves toward the camera one way and away the other, and flattens to zero when you stop.
- The per-frame `computeVertexNormals()` after each bend is what keeps the double-sided planes
  shading correctly as they deform.

## Assets / images
**10 slide textures** — an editorial set split between **high-contrast black-and-white portrait
crops** (the majority) and a few **warm-toned natural macro / still-life** frames on dark grounds.
The B&W frames set the tone; the warm nature shots are the accents. Real subjects and dominant
colors in the visible set:
- **B&W portrait crops (the dominant look):** a straight-on cropped close-up of a young man's face —
  cropped at hairline and mouth, eyes to camera, on a plain pale-grey studio ground (neutral greys,
  landscape ~4:3); a woman's **profile** with a sleek dark bob and a single pearl earring over a
  black blazer collar, against light grey (**portrait/tall ~2:3** — the one clearly vertical frame);
  a cropped lips/chin/neck rising out of a black blazer lapel, dramatic side light on grey
  (charcoal + grey, landscape ~4:3); a mouth and chin seen **through translucent sheer fabric / a
  veil** that fills most of the frame in soft high-key white-grey (near-white, landscape ~3:2).
- **Warm natural macro / still life (the accents):** curled, veined **dried leaves** glowing
  copper/rust, lit against a near-black ground (deep warm browns + amber, landscape ~3:2);
  slender **backlit seedlings / sprouts** rimmed gold against a dark violet-blue bokeh (olive-gold
  on deep purple — the coolest background in the set, landscape ~3:2); two pale **cream moths** with
  faint pink edges on a rich **dark burgundy** velvet ground (ivory on maroon, landscape ~3:2).
- One remaining warm frame continues in the same register: a **golden backlit** smiling woman with
  curly dark hair and white earbuds, blown-out sun flare over a Mediterranean courtyard (warm
  ambers/creams, landscape ~4:3).

Aspect ratios **vary** — most are **landscape ~3:2 / 4:3**, one is clearly **portrait/tall** (the
B&W profile). Exact aspect doesn't matter: each mesh contain-fits its own texture by scaling one
axis, so nothing stretches. No logos or baked-in text. (For a faithful repro, any 10 high-detail,
tonal photos work; the bend and loop show best on textured frames — the set stays cohesive by
pairing moody B&W portrait crops with a few warm, dark-ground nature/still-life shots.)

## Behavior notes
- **Interaction-driven, no autoplay:** the column only moves on wheel / drag / touch; when idle it
  sits still and fully flat (distortion eases to 0).
- **Whole viewport is the surface** — the fixed canvas fills the screen; the page itself never
  scrolls (wheel/touch are `preventDefault`ed and consumed by the virtual scroll).
- `pixelRatio` capped at 2. `MeshBasicMaterial` means no lighting setup. No reduced-motion branch and
  no mobile/desktop layout switch in the original — the same scene runs everywhere, driven by
  whichever input the device provides.
- `preserveDrawingBuffer: true` is set on the renderer (needed for reliable off-thread capture /
  thumbnails); keep it.
