# 3D Circular Image Gallery

## Goal
Build a full-viewport **WebGL image gallery** where ~100 curved image tiles are wrapped around the surface of a tall vertical cylinder that **spins slowly and continuously** on its Y axis. Smooth-scrolling drives the camera **vertically** up and down through the stack, and each burst of scroll velocity briefly **accelerates the cylinder's spin** for an inertial, momentum-based feel. The star effect is the endlessly rotating 3D drum of photos that you travel through as you scroll.

## Tech
Vanilla HTML/CSS/JS with ES module imports, bundled by Vite. No framework.
- `three` (npm) — the entire scene is Three.js WebGL. **No GSAP is used.**
- `lenis` (npm) — smooth scroll, whose scroll position and velocity drive the camera and spin.

Import them as:
```js
import * as THREE from "three";
import Lenis from "lenis";
```

## Layout / HTML
Minimal DOM — the gallery is a `<canvas>` that Three.js appends to `<body>`. Only two fixed overlay blocks of monospace text sit on top:

```html
<div class="nav">
  <div class="nav-col">
    <p>Silhouette</p>
    <p>Microfolio <br />2017 - Ongoing</p>
  </div>
  <div class="nav-col"><p>Info</p></div>
</div>

<div class="footer">
  <p>Experiment CG407</p>
  <p>By Silhouette</p>
</div>
```
(Text is a neutral fictional studio label — swap freely; it is decorative only.)

The `<canvas>` is created by the renderer and appended to `body` from JS — do not hardcode it in HTML.

## Styling
- **Body is 500vh tall** (`html, body { width: 100vw; height: 500vh; }`) so there is scroll distance to move the camera. Background `#0f0f0f`.
- `canvas { position: fixed; top: 0; left: 0; }` — pinned full-screen behind everything.
- `img { width:100%; height:100%; object-fit:cover; }` (defensive default; images are used as WebGL textures, not `<img>` tags).
- Overlay text `p`: `text-transform: uppercase; font-family: "Akkurat Mono", monospace; font-size: 10px; font-weight: 500; line-height: 1.125; color: #fff;`
- `.nav` — `position: fixed; top:0; left:0; width:100vw; padding:1.5em; display:flex; z-index:2; mix-blend-mode: difference;`
- `.footer` — same but `bottom:0`, `justify-content: space-between`.
- `.nav-col { flex:1; }`; first nav-col is `display:flex` with its `p` children each `flex:1`; second nav-col is `text-align:right`.
- **`mix-blend-mode: difference`** on nav and footer is important — the white labels invert against whatever image passes behind them.
- Include Lenis's recommended CSS (`.lenis.lenis-smooth { scroll-behavior: auto !important; }`, `.lenis.lenis-stopped { overflow: clip; }`, etc.).

## The 3D effect (be exact)

### Renderer, scene, camera
- `THREE.Scene`.
- `THREE.PerspectiveCamera(75, window.innerWidth/window.innerHeight, 0.1, 1000)`.
- `THREE.WebGLRenderer({ antialias: true, alpha: true })`; `renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))`; `renderer.setSize(innerWidth, innerHeight)`; `renderer.setClearColor(0x000000, 0)` (transparent so the `#0f0f0f` body shows through). Append `renderer.domElement` to `body`.
- One light only: `THREE.AmbientLight(0xffffff, 1)` added to the scene.
- Camera initial position: `camera.position.z = 12`, `camera.position.y = 0` (looking down −Z at the origin by default).

### Group + invisible reference cylinder
- Create `const galleryGroup = new THREE.Group()` and add it to the scene. **All tiles and the reference cylinder are children of this group; the whole group is what rotates.**
- Global constants: `radius = 6`, `height = 30`, `segments = 30`.
- Add an **invisible** guide cylinder to the group: `THREE.CylinderGeometry(radius, radius, height, segments, 1, true)` (open-ended) with `MeshPhongMaterial({ color: 0xffffff, transparent: true, opacity: 0, side: THREE.DoubleSide })`. It renders nothing — it just documents the drum's dimensions; keep it for fidelity.

### Curved image plane geometry (custom BufferGeometry)
Write `createCurvedPlane(width, height, radius, segments)` that returns a `THREE.BufferGeometry` — a rectangular mesh **bent horizontally to hug a cylinder of the given radius**:
- `segmentsX = segments * 4`, `segmentsY = Math.floor(height * 12)`, `theta = width / radius` (the angular arc the tile subtends).
- Build the vertex grid: for `y` in `0..segmentsY`, `yPos = (y/segmentsY - 0.5) * height` (vertically centered on 0). For `x` in `0..segmentsX`, `xAngle = (x/segmentsX - 0.5) * theta`, then `xPos = Math.sin(xAngle) * radius`, `zPos = Math.cos(xAngle) * radius`. Push `(xPos, yPos, zPos)` — this curves the tile outward on the +Z side of a radius-`radius` circle.
- **UVs with a 10% inset crop:** `u = (x/segmentsX) * 0.8 + 0.1`, `v = y/segmentsY`. (The `*0.8 + 0.1` samples only the middle 80% of the texture horizontally, trimming the left/right edges.)
- Indices: two triangles per quad — `a = x + (segmentsX+1)*y`, `b = x + (segmentsX+1)*(y+1)`, `c = x+1 + (segmentsX+1)*(y+1)`, `d = x+1 + (segmentsX+1)*y`; push `(a,b,d)` and `(b,c,d)`.
- Set `position` (Float32, itemSize 3) and `uv` (Float32, itemSize 2) attributes, `setIndex(indices)`, then `computeVertexNormals()`.

### Texture loading
- `const textureLoader = new THREE.TextureLoader()`.
- `getRandomImageNumber()` → `Math.floor(Math.random() * 50) + 1` (integer 1–50).
- `loadImageTexture(n)` returns a Promise that loads `/c/ashfall-3d-image-gallery/img${n}.jpg`; in the onLoad callback set `generateMipmaps = true`, `minFilter = THREE.LinearMipmapLinearFilter`, `magFilter = THREE.LinearFilter`, `anisotropy = renderer.capabilities.getMaxAnisotropy()`, then resolve with the texture.

### Placing the tiles on the drum
Layout constants: `numVerticalSections = 20`, `blocksPerSection = 5`, `verticalSpacing = 5`.
- `totalBlockHeight = numVerticalSections * verticalSpacing` (100); `heightBuffer = (height - totalBlockHeight) / 2` (= −35); `startY = -height/2 + heightBuffer + verticalSpacing` (= −45).
- `sectionAngle = (Math.PI * 2) / blocksPerSection` (72°); `maxRandomAngle = sectionAngle * 0.3` (±21.6° jitter).

`createBlock(baseY, yOffset, sectionIndex, blockIndex)` (async):
- `blockGeometry = createCurvedPlane(2.5, 1.75, radius, 10)` — each tile is **2.5 wide × 1.75 tall** wrapped on the radius-6 drum.
- Load a random texture; material = `MeshPhongMaterial({ map: texture, side: THREE.DoubleSide, toneMapped: false })`.
- `block = new THREE.Mesh(blockGeometry, blockMaterial)`; `block.position.y = baseY + yOffset`.
- Wrap it: `const blockContainer = new THREE.Group()`. `baseAngle = sectionAngle * blockIndex`; `randomAngleOffset = (Math.random()*2 - 1) * maxRandomAngle`; `blockContainer.rotation.y = baseAngle + randomAngleOffset`. Add the block to the container and return the container.

`initializeBlocks()` (async): loop `section` `0..numVerticalSections`, `baseY = startY + section * verticalSpacing`; inner loop `i` `0..blocksPerSection`, `yOffset = Math.random()*0.2 - 0.1`; `await createBlock(...)`, push to a `blocks[]` array, and add the container to `galleryGroup`. Call `initializeBlocks()` once at startup.

Net result: **20 stacked rings × 5 tiles = ~100 curved photo tiles** on a radius-6 cylinder, spanning roughly y = −45…+50, each tile evenly spaced 72° apart with random angular and tiny vertical jitter so the rings don't look mechanical. Each tile independently picks one of 50 source images, so duplicates naturally appear.

### Scroll → camera, velocity → spin (the animated part)
State: `currentScroll = 0`, `totalScroll = document.documentElement.scrollHeight - window.innerHeight`, `rotationSpeed = 0`, `baseRotationSpeed = 0.0025`, `maxRotationSpeed = 0.05`.

Init Lenis with `new Lenis({ autoRaf: true })`. On its scroll event:
```js
lenis.on("scroll", (e) => {
  currentScroll = window.pageYOffset;
  rotationSpeed = e.velocity * 0.005;   // fresh spin impulse from scroll speed
});
```

`requestAnimationFrame` render loop `animate()`:
- `scrollFraction = currentScroll / totalScroll` (0→1 top→bottom).
- `targetY = scrollFraction * height - height/2`; **`camera.position.y = -targetY`** — so at the top the camera sits at y ≈ +15, at the bottom y ≈ −15, i.e. scrolling **down** flies the camera **down** through the drum (about 30 units of vertical travel).
- **`galleryGroup.rotation.y += baseRotationSpeed + rotationSpeed`** — the constant `0.0025` gives the always-on slow spin; the scroll-derived `rotationSpeed` adds a temporary boost.
- Then `rotationSpeed *= 2;` each frame — the impulse is re-amplified frame to frame but gets overwritten by the next scroll event, so it flares while you scroll and collapses back to ~0 (the slow base spin) once the scroll velocity settles.
- `renderer.render(scene, camera)`; call `requestAnimationFrame(animate)` at the top of the function; kick it off once with `animate()`.

### Resize
`window.addEventListener("resize", ...)`: update `camera.aspect = innerWidth/innerHeight`, `camera.updateProjectionMatrix()`, `renderer.setSize(innerWidth, innerHeight)`, `renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))`.

## Assets / images
- **50 source images** named `img1.jpg … img50.jpg`, served from `/c/ashfall-3d-image-gallery/`. They are randomly mapped onto the ~100 tiles.
- Visually: **abstract 3D-rendered still-life artworks** — glossy sculptural forms on solid studio-colored backgrounds (braided rope coils, cream blobs with cutouts, twisted knots, draped fabric folds, ribbon loops, molten metallic blobs, interlocking rings, smooth sand dunes, cube cityscapes, liquid swirls, petal/flame shapes). Warm editorial palette: browns, creams, rust-reds, teals, purples, tans.
- Roughly **square to slightly landscape** framing; the geometry crops ~10% off each horizontal edge via the UV inset, so keep subjects centered. Any set of centered abstract renders works.

## Behavior notes
- Desktop-first, **not mobile-safe** (heavy WebGL; scroll is hijacked by Lenis). No reduced-motion handling in the original — the base spin runs forever.
- The drum spins continuously even with no scroll input (`baseRotationSpeed`). Scrolling both moves the camera and briefly speeds the spin proportional to velocity.
- Everything is transparent-cleared over the dark `#0f0f0f` body; the fixed monospace nav/footer use `mix-blend-mode: difference` so they stay legible over passing images.
