# ThreeJS 3D Curved Slider (WebGL canvas-texture, Lenis scroll, no GSAP)

## Goal
Build a full-screen **3D curved plane that displays an endless vertical slideshow of captioned images**, rendered entirely in Three.js. Seven images with title captions are painted onto a tall repeating **2D canvas** that is used as a texture on one big **parabolically curved plane** tilted in 3D space. **Lenis smooth-scroll progress (0→1 over a very tall page) is fed into a scroll listener that shifts the texture's vertical offset and re-renders the WebGL scene**, so scrolling pushes the strip of images continuously up through the curved 3D sheet and loops forever. The star effect: the **image strip appears to bend and flow over a concave wave** because the flat canvas is mapped onto a plane whose vertices curve toward the camera at the top and bottom edges, viewed from an angled, rolled camera. A dark radial vignette overlay and fixed nav/footer frame the scene.

## Tech
- Vanilla HTML / CSS / JS with ES module imports, bundled by **Vite** (npm project).
- **`three` (npm)** imported `import * as THREE from "three";` and **`lenis` (npm)** imported `import Lenis from "lenis";`.
- **No GSAP, no ScrollTrigger, no shaders.** All motion is: Lenis for smooth scroll + a `lenis.on("scroll", …)` callback that redraws a 2D `<canvas>` texture and calls `renderer.render()`. There is **no continuous rAF render loop** — the scene renders once at init and then only on scroll events. `MeshBasicMaterial` (unlit) — no lights.
- Everything runs inside a single `window.addEventListener("load", …)` in one `script.js`.

## Layout / HTML
Almost no DOM — a fixed nav, a fixed footer, the WebGL `<canvas>` inside a wrapper, and a vignette overlay. Text is neutral/fictional demo copy (no real brands). Class names are load-bearing (`.slider-wrapper`, `.overlay`).

```html
<nav>
  <div class="site-info">
    <p id="logo">Motionprompts</p>
    <p>YouTube Channel</p>
  </div>
  <div class="nav-links">
    <p>Index</p>
    <p>About</p>
  </div>
</nav>

<footer>
  <p>Experiment 0410</p>
  <p>&copy; 2024</p>
</footer>

<div class="slider-wrapper">
  <canvas></canvas>
</div>

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

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

`document.querySelector("canvas")` is passed straight into the renderer. Use the copy above verbatim; "Motionprompts" is the fictional demo name.

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

- **Page = scroll runway.** `html, body { width:100%; height:1000vh; font-family:"Inter"; background:#000; color:#fff; }` — the body is **1000vh tall**; that giant page height is the entire scroll distance Lenis reports and the only thing that drives the slideshow. Nothing else scrolls.
- **Font:** Inter (system sans fallback fine). `p { font-size:13px; font-weight:400; line-height:1.5; opacity:0.5; }` and `p#logo { opacity:1; }` — small dimmed labels, logo at full opacity.
- **`nav`** — `position:fixed; top:0; width:100vw; padding:2em; display:flex; justify-content:space-between; align-items:center; z-index:2;`. `.nav-links { display:flex; gap:2em; }` (site-info left, nav-links right).
- **`footer`** — `position:fixed; bottom:0; width:100vw; padding:2em; display:flex; justify-content:space-between; align-items:center; z-index:2;`.
- **`.slider-wrapper`** — `position:fixed; width:100vw; height:100vh; overflow:hidden;`.
- **`canvas`** — `position:fixed; top:0; left:0; width:100%; height:100%;` (fills the viewport behind everything).
- **`.overlay`** — `position:fixed; top:0; left:0; width:100vw; height:100vh; z-index:1;` with a **radial vignette**: `background: radial-gradient(circle, rgba(0,0,0,0) 75%, rgba(0,0,0,0.5) 100%);` — transparent center, darkening to 50% black at the corners. Sits above the canvas (z-index 1) but below nav/footer (z-index 2).

## The star effect — Three.js curved plane + canvas-texture scroll (be exact)
Near-verbatim port. Reproduce the constants, the geometry bend, the texture-draw math, the camera placement, and the scroll wiring exactly.

### Smooth scroll (Lenis, self-driven rAF)
Create Lenis with **default options** and drive its own rAF loop (this loop only powers Lenis; it does **not** render the scene):
```js
const lenis = new Lenis();
function raf(time) {
  lenis.raf(time);
  requestAnimationFrame(raf);
}
requestAnimationFrame(raf);
```

### Image preload gate
Preload all **7** images (`/…/img1.jpg` … `img7.jpg`) into an `images[]` array; count `onload`/`onerror`, and only call `initializeScene()` once all 7 have resolved. This guarantees every slide texture is ready before the first paint.

### Renderer, scene, camera
```js
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 1000);

const renderer = new THREE.WebGLRenderer({
  canvas: document.querySelector("canvas"),
  antialias: true,
  powerPreference: "high-performance",
});
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setClearColor(0x000000);
```

### The curved plane geometry (the "wave")
One large subdivided plane whose vertices are bent along **Z** by a parabola of their **Y**:
```js
const parentWidth  = 20;
const parentHeight = 75;
const curvature    = 35;
const segmentsX    = 200;
const segmentsY    = 200;

const parentGeometry = new THREE.PlaneGeometry(parentWidth, parentHeight, segmentsX, segmentsY);

const positions = parentGeometry.attributes.position.array;
for (let i = 0; i < positions.length; i += 3) {
  const y = positions[i + 1];
  const distanceFromCenter = Math.abs(y / (parentHeight / 2));   // 0 at vertical center → 1 at top/bottom edge
  positions[i + 2] = Math.pow(distanceFromCenter, 2) * curvature; // Z = dist² × 35
}
parentGeometry.computeVertexNormals();
```
So the plane is **flat (z=0) across its vertical middle and curls forward (+z up to 35) toward its top and bottom edges** — a tall concave trough. This is what makes the image strip read as flowing over a 3D wave.

### Material, mesh, tilt
```js
const parentMaterial = new THREE.MeshBasicMaterial({ map: texture, side: THREE.DoubleSide });
const parentMesh = new THREE.Mesh(parentGeometry, parentMaterial);
parentMesh.position.set(0, 0, 0);
parentMesh.rotation.x = THREE.MathUtils.degToRad(-20);
parentMesh.rotation.y = THREE.MathUtils.degToRad(20);
scene.add(parentMesh);
```
The plane is tilted **−20° on X** and **+20° on Y** so we see it in perspective, not head-on.

### Camera placement (angled orbit + roll)
Position the camera on a 17.5-unit orbit at the same 20° azimuth as the mesh, lifted 5 units, aimed slightly below center, and rolled −5°:
```js
const distance = 17.5;
const heightOffset = 5;
const offsetX = distance * Math.sin(THREE.MathUtils.degToRad(20));   // ≈ 5.99
const offsetZ = distance * Math.cos(THREE.MathUtils.degToRad(20));   // ≈ 16.44

camera.position.set(offsetX, heightOffset, offsetZ);
camera.lookAt(0, -2, 0);
camera.rotation.z = THREE.MathUtils.degToRad(-5);                    // slight roll
```

### The texture canvas (where slides are drawn)
A single tall offscreen 2D canvas becomes the repeating texture:
```js
const textureCanvas = document.createElement("canvas");
const ctx = textureCanvas.getContext("2d", { alpha: false, willReadFrequently: false });
textureCanvas.width = 2048;
textureCanvas.height = 8192;               // tall strip

const texture = new THREE.CanvasTexture(textureCanvas);
texture.wrapS = THREE.RepeatWrapping;
texture.wrapT = THREE.RepeatWrapping;      // vertical wrap → seamless loop
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.anisotropy = Math.min(4, renderer.capabilities.getMaxAnisotropy());
```

### Slide layout constants + titles
```js
const totalSlides = 7;
const slideHeight = 15;
const gap = 0.5;
const cycleHeight = totalSlides * (slideHeight + gap);   // 7 × 15.5 = 108.5

const slideTitles = [
  "Field Unit", "Astral Convergence", "Eclipse Core",
  "Luminous", "Serenity", "Nebula Point", "Horizon",
];   // neutral one/two-word titles — no real brands
```

### `updateTexture(offset)` — redraw the canvas each scroll frame
`offset` is a normalized cycle offset (0 → one full loop). It clears to black, sets the caption font, then draws slides `-2 … totalSlides+1` (two extra above and below so the strip never shows a gap at the seam), each wrapped modulo the canvas height:
```js
function updateTexture(offset = 0) {
  ctx.fillStyle = "#000";
  ctx.fillRect(0, 0, textureCanvas.width, textureCanvas.height);

  const fontSize = 180;
  ctx.font = `500 ${fontSize}px Dahlia`;     // stylized display face, weight 500 (generic display fallback is fine)
  ctx.textAlign = "center";
  ctx.textBaseline = "middle";

  const extraSlides = 2;
  for (let i = -extraSlides; i < totalSlides + extraSlides; i++) {
    let slideY = -i * (slideHeight + gap);
    slideY += offset * cycleHeight;                        // scroll shifts every slide

    const textureY = (slideY / cycleHeight) * textureCanvas.height;
    let wrappedY = textureY % textureCanvas.height;
    if (wrappedY < 0) wrappedY += textureCanvas.height;    // always-positive wrap

    let slideIndex  = ((-i % totalSlides) + totalSlides) % totalSlides;   // 0..6, cycles
    let slideNumber = slideIndex + 1;

    const slideRect = {
      x: textureCanvas.width * 0.05,                       // 5% left margin
      y: wrappedY,
      width: textureCanvas.width * 0.9,                    // 90% wide (≈1843px)
      height: (slideHeight / cycleHeight) * textureCanvas.height,   // ≈1132px per slide
    };

    const img = images[slideNumber - 1];
    if (img) {
      // COVER-fit the image into slideRect (fill rect, crop overflow, keep aspect)
      const imgAspect  = img.width / img.height;
      const rectAspect = slideRect.width / slideRect.height;
      let drawWidth, drawHeight, drawX, drawY;
      if (imgAspect > rectAspect) {
        drawHeight = slideRect.height;
        drawWidth  = drawHeight * imgAspect;
        drawX = slideRect.x + (slideRect.width - drawWidth) / 2;
        drawY = slideRect.y;
      } else {
        drawWidth  = slideRect.width;
        drawHeight = drawWidth / imgAspect;
        drawX = slideRect.x;
        drawY = slideRect.y + (slideRect.height - drawHeight) / 2;
      }

      ctx.save();
      ctx.beginPath();
      ctx.roundRect(slideRect.x, slideRect.y, slideRect.width, slideRect.height);  // clip mask (radii default 0)
      ctx.clip();
      ctx.drawImage(img, drawX, drawY, drawWidth, drawHeight);
      ctx.restore();

      ctx.fillStyle = "white";
      ctx.fillText(slideTitles[slideIndex], textureCanvas.width / 2, wrappedY + slideRect.height / 2);
    }
  }

  texture.needsUpdate = true;    // push the redrawn canvas to the GPU
}
```
Each slide is an image (cover-fit, clipped to its rect) with its **white centered caption** drawn on top. The caption sits at the vertical middle of each slide rect.

### Scroll wiring (the whole engine)
Lenis reports scroll; convert to 0→1 progress, invert it into the texture offset, redraw, and render **once per scroll event**:
```js
let currentScroll = 0;
lenis.on("scroll", ({ scroll, limit }) => {
  currentScroll = scroll / limit;      // 0 at top → 1 at bottom of the 1000vh page
  updateTexture(-currentScroll);       // negative → strip moves upward as you scroll down
  renderer.render(scene, camera);
});
```
Because the texture uses `RepeatWrapping` and the draw loop wraps modulo the canvas height, the image strip **loops seamlessly** — scrolling down forever cycles the 7 slides upward through the curved plane; scrolling up reverses it exactly.

### Init + resize
- After building everything, call `updateTexture(0)` and `renderer.render(scene, camera)` **once** so the first frame is visible before any scroll.
- **Resize** (debounced 250 ms): `camera.aspect = innerWidth/innerHeight; camera.updateProjectionMatrix(); renderer.setSize(innerWidth, innerHeight);`.

## Assets / images
**7 slide images**, mostly **landscape** (~3:2 / 4:3 / 16:9) with one **portrait** frame — exact aspect doesn't matter since each is **cover-fit** (cropped to fill) into its slide rect. They read as a moody, editorial/cinematic set on mostly dark grounds, mixing portraits, 3D-rendered still lifes, and abstract texture. Describe generically, by role:

1. Woman in a peach silk blouse and pearl bracelets holding a vintage wooden transistor radio, warm retro tones (landscape).
2. Side profile of a person reclining in a black cube-frame sling chair, with scribbled white light-lines drawn around their head (landscape).
3. 3D-rendered still life of two pears, a stack of glossy plates and a knife on a golden marbled surface (landscape ~4:3).
4. Close-up of a pale marble classical statue tinted with soft pink, green and yellow gradient light (landscape).
5. Dim warm-lit portrait of a face partly obscured behind many taut horizontal strings (landscape).
6. Abstract glossy brown 3D blobs with speckled sparkle, smooth organic curves (wide ~16:9).
7. Classical oil-painting-style portrait of a young woman in a green turban and patterned blue-and-gold robe reading a book (portrait/tall).

No logos or baked-in brand text. If you have fewer than 7, repeat in order; the effect is identical regardless of content.

## Behavior notes
- **Scroll is the sole driver, and rendering is on-demand.** There is no autoplay and no continuous animation loop — the scene renders once at init and then only inside the Lenis `scroll` callback. Idle = a static frame; movement only while scrolling.
- **The 1000vh body IS the runway.** Lenis maps that height to `scroll/limit` = 0→1; that single progress value drives the texture offset. Keep the body at `height:1000vh`.
- **Endless loop** comes from `RepeatWrapping` + the modulo wrap in `updateTexture` + the two `extraSlides` of overdraw — never a visible seam.
- WebGL + heavy geometry (200×200 segments) means this is **desktop-oriented / not mobile-safe**. `pixelRatio` capped at 2, `MeshBasicMaterial` (no lights). No reduced-motion branch and no mobile layout switch in the original.
- Keep `overflow:hidden` on `.slider-wrapper`, and the vignette `.overlay` above the canvas but below the fixed nav/footer.
