# 3D Spiral Image Gallery

## Goal
Build a single scrollable hero where **75 curved image tiles wind down a five-revolution 3D helix**, rendered in WebGL. Ten editorial photos are cycled across the tiles, a **custom shader dims each tile as it turns away from the camera** (depth shading around the spiral), the whole spiral **slowly auto-rotates and gets spun by scroll velocity** (with inertial decay), and **scroll progress lerps the camera downward through the helix** so you appear to descend the spiral. On desktop, mouse position adds a subtle X/Z parallax tilt to the whole spiral. Smooth scroll via Lenis. After the tall hero comes a short second section.

## Tech
Vanilla HTML/CSS/JS with ES module imports (Vite/npm project). Use `three` for all WebGL and `lenis` for smooth scroll. **No GSAP, no ScrollTrigger** — all motion is a hand-rolled `requestAnimationFrame` loop with manual lerp/inertia. Put the two GLSL shader strings in a separate `shaders.js` and import them. Instantiate Lenis once with `new Lenis({ autoRaf: true })` (it drives its own rAF; you still run your own render loop separately).

## Layout / HTML
Two stacked sections; the WebGL canvas is injected into the first by JS.

```html
<section class="hero">
  <h1>Somewhere between structure and disorder new forms quietly start to emerge</h1>
</section>
<section class="about">
  <h3>New forms begin here</h3>
</section>
<script type="module" src="./script.js"></script>
```

The JS queries `.hero`, creates the renderer, and appends `renderer.domElement` into `.hero`. Copy text is neutral editorial filler — no brand names.

## Styling
**Font** — a tight uppercase grotesk. Load "PP Neue Montreal" via `@import url("https://fonts.cdnfonts.com/css/pp-neue-montreal")` with `sans-serif` fallback; any Neue-Montreal-like grotesk is fine.

- `* { margin:0; padding:0; box-sizing:border-box }`
- `body { font-family:"PP Neue Montreal", sans-serif }`
- `h1, h3 { text-transform:uppercase; letter-spacing:-0.1rem; line-height:0.8 }`
- `h1 { font-size:clamp(3.5rem, 10vw, 15rem) }` (massive, wall-of-text)
- `h3 { font-size:clamp(2.5rem, 5vw, 7.5rem) }`
- `section { position:relative; width:100%; padding:2rem; color:#d2d2d2; overflow:hidden }`
- `.hero { height:150svh; background:#242424; text-align:justify }` — the extra height (1.5 viewports) is the scroll runway that drives the camera descent; `text-align:justify` spreads the H1 edge to edge.
- `.about { height:100svh; background:#171717; display:flex; justify-content:center; align-items:center; text-align:center }`
- `canvas { position:absolute; top:0; left:0; width:100%; height:100% }` — fills the hero, sits under the H1.
- `@media (max-width:1000px) { .hero { height:125svh } }`

## The effect (be exhaustive — Three.js helix + shader + interaction)

### Config (module constant `CONFIG`)
```js
const CONFIG = {
  totalImages: 10,
  tilesPerRevolution: 15,
  revolutions: 5,
  startRadius: 5,
  endRadius: 3.5,
  tileHeightRatio: 1.1,
  tileSegments: 24,
  spiralGap: 0.35,
  tileOverlap: 0.005,
  cameraZ: 12,
  cameraSmoothing: 0.075,
  baseRotationSpeed: 0.001,
  scrollRotationMultiplier: 0.0035,
  rotationDecay: 0.9,
  scrollMultiplier: 1.25,
  cameraYMultiplier: 0.2,
  parallaxStrength: 0.1,
};
```
Derived: `totalTiles = Math.floor(tilesPerRevolution * revolutions)` = **75**; `angleStep = (Math.PI*2) / tilesPerRevolution` (24° per tile → 15 tiles per full turn).

### Scene / renderer / camera
- `scene = new THREE.Scene()`.
- `camera = new THREE.PerspectiveCamera(75, hero.clientWidth / hero.clientHeight, 0.1, 1000)`; `camera.position.z = CONFIG.cameraZ` (12). Aspect is tall because the hero is 150svh.
- `renderer = new THREE.WebGLRenderer({ antialias:true, alpha:true })`; `renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))`; `renderer.setSize(hero.clientWidth, hero.clientHeight)`; append `renderer.domElement` to `.hero`.
- Shared uniform object: `const cameraPositionUniform = { value: new THREE.Vector3(0, 0, CONFIG.cameraZ) }` — every tile material references this same object, updated each frame.

### Textures
Load 10 images with `THREE.TextureLoader` from `/c/studiodialect-image-gallery/img${i+1}.jpg` (i = 0..9). In each load callback set `t.minFilter = THREE.LinearMipmapLinearFilter` and `t.anisotropy = renderer.capabilities.getMaxAnisotropy()`. Store in a `textures` array.

### Precompute the vertical edges of every tile (`tileEdgesY`)
Start `const tileEdgesY = [0];`. For `i` in `0..totalTiles-1`:
```
progress  = i / totalTiles;
radius    = startRadius + (endRadius - startRadius) * progress;   // 5 → 3.5 (radius tapers inward as you descend)
arcWidth  = (2*Math.PI*radius) / tilesPerRevolution;
tileHeight= arcWidth * tileHeightRatio;                            // 1.1× → slightly taller than wide
tileEdgesY.push(tileEdgesY[i] - (tileHeight + spiralGap) / tilesPerRevolution);
```
This yields a monotonically decreasing array of Y break-points; consecutive tiles step down by a fraction of `(tileHeight + gap)`, so a full revolution (15 tiles) drops by roughly one tile-height + gap.

### Build the spiral (custom curved geometry per tile)
`const spiral = new THREE.Group(); scene.add(spiral);` Then for each `i` in `0..totalTiles-1`:
- Recompute `progress, radius, arcWidth, tileHeight` as above.
- `tileAngle = arcWidth/radius + tileOverlap` (angular width of the panel, plus a hair of overlap to hide seams).
- `centerY = (tileEdgesY[i] + tileEdgesY[i+1]) / 2` (vertical center of this tile).
- `slope = tileEdgesY[i+1] - tileEdgesY[i]` (negative — the amount the tile must shear downward across its width so its edges meet the neighbours into a continuous ramp).

Build a `BufferGeometry` by hand — a 2-row × (`tileSegments`+1)-column strip (segments = 24 → 25 columns). Loop `row` 0→1, `col` 0→segments:
```
angle = (col/segments - 0.5) * tileAngle;
position = [ Math.sin(angle)*radius,
            (row-0.5)*tileHeight + (col/segments - 0.5)*slope,
            Math.cos(angle)*radius ];
uv = [ col/segments, row ];
```
So each tile is a **curved arc** at distance `radius` from the Y axis (x/z from sin/cos), spanning `tileHeight` vertically, sheared by `slope` across its width so it leans down the helix. Indices: for each `col` 0..segments-1, two triangles `(current, below, current+1)` and `(below, below+1, current+1)` where `below = current + segments + 1`. Call `computeVertexNormals()`.

Material (per tile):
```js
new THREE.ShaderMaterial({
  vertexShader, fragmentShader,
  uniforms: {
    uMap: { value: textures[i % CONFIG.totalImages] },   // photos cycle every 10 tiles
    uCameraPosition: cameraPositionUniform,               // shared object
  },
  side: THREE.DoubleSide,
});
```
Then: `mesh.position.y = centerY;` wrap it in a per-tile `THREE.Group` whose `rotation.y = i * angleStep;` add the mesh to that group and the group to `spiral`. The Y-rotation per tile (24°) plus the descending `centerY` is what coils the 75 tiles into a 5-turn helix.

Finally: `const spiralHeight = Math.abs(tileEdgesY[totalTiles]);` — total vertical span of the helix, used to scale the camera descent.

### Shaders (`shaders.js`)
**Vertex** — pass through UV and world-space normal/position:
```glsl
varying vec2 vUv;
varying vec3 vWorldNormal;
varying vec3 vWorldPosition;
void main() {
  vUv = uv;
  vWorldNormal = normalize((modelMatrix * vec4(normal, 0.0)).xyz);
  vWorldPosition = (modelMatrix * vec4(position, 1.0)).xyz;
  gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4(position, 1.0);
}
```
**Fragment** — sample the photo and **dim tiles facing away from the camera**:
```glsl
uniform sampler2D uMap;
uniform vec3 uCameraPosition;
varying vec2 vUv;
varying vec3 vWorldNormal;
varying vec3 vWorldPosition;
void main() {
  vec4 tex = texture2D(uMap, vUv);
  vec3 viewDir = normalize(uCameraPosition - vWorldPosition);
  float facing = max(dot(-normalize(vWorldNormal), viewDir), 0.0);
  float falloff = smoothstep(-0.2, 0.5, facing) * 0.45 + 0.42;   // ~0.42 (back) → ~0.87 (front)
  vec3 color = mix(vec3(1.0), tex.rgb * falloff, 0.975) * 1.25;   // slight white lift, then ×1.25 brighten
  gl_FragColor = vec4(color, tex.a);
}
```
Net look: tiles on the far side of the spiral read noticeably darker; tiles facing the camera are bright and slightly washed/lifted. The `-normalize(vWorldNormal)` inverts the normal so DoubleSide back-faces still shade correctly.

### Input state
- `let scrollY = 0, spinVelocity = 0;`
- `lenis.on("scroll", (e) => { scrollY = window.pageYOffset; spinVelocity = e.velocity * CONFIG.scrollRotationMultiplier; })` — Lenis reports scroll velocity; multiply by 0.0035 to convert it into an angular kick.
- Mouse (range −1..1): on `window` `mousemove`, `mouseX = (e.clientX/window.innerWidth - 0.5)*2`, `mouseY = (e.clientY/window.innerHeight - 0.5)*2`. Keep smoothed copies `smoothX, smoothY` (start 0).
- `let isMobile = window.innerWidth < 1000;`

### Render loop (`requestAnimationFrame`, the star)
```js
function animate() {
  requestAnimationFrame(animate);

  // 1. Scroll progress → camera descent (eased)
  const progress = Math.min(scrollY / (window.innerHeight * CONFIG.scrollMultiplier), 1); // 0→1 over 1.25 viewports
  camera.position.y +=
    (-(progress * spiralHeight * CONFIG.cameraYMultiplier) - camera.position.y)
    * CONFIG.cameraSmoothing;                       // lerp toward −(progress·spiralHeight·0.2), factor 0.075

  // 2. Desktop-only mouse parallax tilt on the whole spiral
  if (!isMobile) {
    smoothX += (mouseX - smoothX) * 0.02;           // slow 0.02 follow
    smoothY += (mouseY - smoothY) * 0.02;
    spiral.rotation.x =  smoothY * CONFIG.parallaxStrength;         // pitch  ±0.1 rad
    spiral.rotation.z = -smoothX * CONFIG.parallaxStrength * 0.3;   // roll   ±0.03 rad
  }

  // 3. Feed camera position to the shader (drives the facing/dimming)
  cameraPositionUniform.value.copy(camera.position);

  // 4. Continuous spin + scroll-velocity kick with inertial decay
  spiral.rotation.y += CONFIG.baseRotationSpeed + spinVelocity;     // always drifts at 0.001 rad/frame
  spinVelocity *= CONFIG.rotationDecay;                             // 0.9 decay → spins settle after scroll stops

  renderer.render(scene, camera);
}
animate();
```
Key feel: the spiral **always** rotates slowly (`baseRotationSpeed 0.001`); scrolling injects extra angular velocity proportional to scroll speed, which **decays by ×0.9 each frame** so a flick keeps spinning and coasts to a stop. Camera Y eases (0.075) toward a target proportional to scroll progress × total helix height × 0.2, so scrolling walks the camera down the spiral. Mouse tilt is a separate, very slow (0.02) parallax overlay applied only on desktop.

### Resize
On `window` `resize`: recompute `isMobile = window.innerWidth < 1000`; `camera.aspect = hero.clientWidth / hero.clientHeight`; `camera.position.z = isMobile ? 15 : CONFIG.cameraZ` (pull the camera back on mobile); `camera.updateProjectionMatrix()`; `renderer.setSize(hero.clientWidth, hero.clientHeight)`.

## Assets / images
**10 dreamlike, high-fashion editorial photos, all TALL PORTRAIT orientation (~2:3, roughly 960×1440)** — the texture is stretched across each curved panel so exact ratio is flexible. They cycle across the 75 tiles (`textures[i % 10]`), so each photo appears ~7–8 times around the helix. The set is deliberately **mixed**, not one single mood: it swings between three registers — (a) **soft, hazy, desaturated color** scenes (misty whites, muted greens, warm faded daylight) built around pale, white/platinum-haired figures in ceremonial white dress; (b) **bold saturated studio color** (deep crimson against electric cobalt blue); and (c) **stark, high-contrast black-and-white studio portraits**. Surreal fashion-editorial feel; the contrast between the airy white/green scenes and the punchy studio shots is part of the look. Representative roles by form and content (no brands):

1. Profile-ish shot of a **blindfolded bald young figure** with a bright **red satin ribbon** over the eyes, wearing a ruffled white lace collar and loose white shirt, standing under **gothic stone arches** draped in ivy and small white blossoms; soft green-and-grey daylight — a white scene with one hot red accent.
2. **White-haired figure in a flowing white robe** seated in a **derelict, flower-flooded room** — peeling off-white walls, tall bright window, gilt mirror, stone fireplace, and a vintage TV, with white blossoms carpeting the floorboards; misty white-green palette.
3. **Pale, platinum/albino woman in a long white Victorian lace dress** standing on a small grassy islet flanked by **two black swans** on a **misty dawn pond**, pines behind, water lilies in the foreground; muted foggy greens and whites.
4. Sunlit near-profile of a **platinum-blonde woman** with long hair and **translucent, crystalline glass flowers/butterflies** in her hair and over a sheer dress, set in a pastel **rose garden** under a hazy blue sky; luminous washed-out palette.
5. Full-length studio shot of a **woman in a deep crimson maxi dress and matching red overcoat**, standing against a flat **electric cobalt-blue backdrop** — the most saturated, high-key-color image in the set (bold red-on-blue, no haze).
6. **High-contrast black-and-white profile portrait** of a woman with a sleek dark bob and **pearl drop earrings**, in a black blazer against a plain pale-grey backdrop; crisp monochrome.
7. **High-contrast black-and-white studio portrait** of a bald, androgynous young face, three-quarter view with dramatic side-lit shadow, dark collar, plain grey backdrop.
8. **Soft, grainy black-and-white portrait** of a face seen through billowing **sheer white organza/veil fabric** that half-obscures it; dreamy high-key monochrome.

The remaining tiles continue in these same registers — pale white/floral scenes, one bold saturated studio color shot, and several stark black-and-white portraits — all in the same tall portrait crop. Any similarly eclectic surreal-editorial set works; the deliberate mix of airy washed-out color, one punchy red-on-blue, and high-contrast B&W portraiture matters more than the exact subjects.

## Behavior notes
- **Desktop-only parallax:** the mouse tilt branch is gated behind `!isMobile` (`innerWidth < 1000`); on mobile the spiral only auto-rotates + reacts to scroll, and the camera sits farther back (`z = 15`).
- **Inertia, not scrub:** scroll doesn't directly rotate the spiral — it deposits velocity that decays, so the spin feels weighty and keeps coasting after you stop.
- **Camera descent is clamped:** `progress` maxes at 1 after ~1.25 viewports of scroll, so the camera settles at the bottom of the helix and the `.about` section follows.
- **No reduced-motion guard** in the original; the auto-rotation and render loop run continuously. All colors sit in a near-black palette (`#242424` / `#171717`) so the bright bright tiles pop against the dark surround.
