Infinite 3D Gallery Tunnel

Twenty-four procedurally painted posters spiral down a fog-filled tunnel; wheel, drag or swipe sets the camera's speed, idle autoplay keeps it drifting, and plane depths wrap modulo the loop so the archive never ends.

Loading

AI prompt

Recreate this effect with any AI tool

Paste into Claude, Cursor, v0, Lovable or Bolt. 1677 words of exact values — rewritten live as you tune the Playground.

prompt.md · Infinite 3D Gallery Tunnel
### EFFECT: Infinite 3D Gallery Tunnel
**What it does (visual):** A full-viewport dark scene. The viewer floats inside a tunnel built from 24 portrait posters (4:5), each a soft duotone image with a large thin serif index number ("01"…"24"), a project name and a tag baked into the picture. The posters are arranged on a spiral — every poster sits 2.9 units from the centre axis, rotated 1/7 of a turn from the previous one and 1.7 units deeper — and all face inward toward the camera. From the first frame the camera is drifting forward slowly (1.1 units/s): near posters grow and slide past the edges of the screen, far ones emerge from fog at the end of the tunnel (fog reaches full density 32 units away). Scrolling the wheel, dragging, or swiping up adds speed; the camera accelerates smoothly, coasts, and relaxes back to the idle drift over ~2s. Scrolling the other way reverses travel. The whole spiral screws gently as you move, and the camera rolls a few degrees with velocity. The pointer position pans and tilts the camera a little (parallax eased at 0.05; enabled: true). Bottom-left, a big serif counter ("07 / 24") and the current project name + tag update as posters pass, the name rising in from below (0.5s). The tunnel has no end: posters that pass behind the camera reappear in the fog at the far end.

**Tech stack:**
- Three.js r160+ (`import * as THREE from 'three'`), Canvas 2D for the procedural poster textures.
- GSAP 3.13+ with the Observer plugin (free). CDN: `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>` + `Observer.min.js`; npm: `import { Observer } from "gsap/Observer"; gsap.registerPlugin(Observer);`
- Fonts: a variable serif for the index numerals (demo uses Fraunces 300), a sans and a mono for labels — loaded with `document.fonts.load()` before painting the textures.

**HTML structure:**
```html
<section class="tunnel">
  <div class="tunnel__canvas"></div>                      <!-- renderer canvas appended here -->
  <header class="tunnel__header">
    <p class="tunnel__eyebrow">WebGL · Observer · Infinite loop</p>
    <h1 class="tunnel__title">Archive,<br>without end</h1>
  </header>
  <div class="tunnel__hud">
    <div class="tunnel__counter" aria-live="polite"><span class="tunnel__index">01</span><span class="tunnel__total">/ 24</span></div>
    <p class="tunnel__caption"><span class="tunnel__name">Aurora Station</span><span class="tunnel__tag">Film · 2026</span></p>
  </div>
  <p class="tunnel__hint">Scroll, drag or swipe to travel</p>
</section>
```

**CSS requirements:**
- `.tunnel`: `position: relative; height: 100vh; min-height: 560px; overflow: hidden; touch-action: none; overscroll-behavior: contain; background: var(--bg, #0a0a0a); color: var(--text, #ededed);`
- `.tunnel__canvas`: `position: absolute; inset: 0; cursor: grab;` (`:active { cursor: grabbing }`), its canvas `width: 100%; height: 100%; display: block`.
- `.tunnel::after`: vignette overlay, `radial-gradient(70% 70% at 50% 50%, transparent 55%, rgba(bg, .7) 100%)`, `pointer-events: none`.
- `.tunnel__header`: `position: absolute; left: clamp(1.25rem, 6vw, 5rem); top: clamp(1.5rem, 4vw, 3.5rem); z-index: 2; pointer-events: none;` Title: display serif `font-weight: 300; font-size: clamp(2.4rem, 5.5vw, 5.5rem); line-height: .92; letter-spacing: -0.04em`. Eyebrow: mono 11px uppercase, `letter-spacing: .14em`, muted.
- `.tunnel__hud`: `position: absolute; left: same; bottom: clamp(1.5rem, 4vw, 3.5rem); z-index: 2; display: grid; gap: .9rem; pointer-events: none;`
- `.tunnel__index`: display serif `font-weight: 300; font-size: clamp(3rem, 7vw, 6rem); line-height: .8; letter-spacing: -0.04em; font-variant-numeric: tabular-nums; min-width: 1.2em;` `.tunnel__total`: mono 12px muted, `letter-spacing: .1em`.
- `.tunnel__caption`: `display: grid; gap: .3rem; padding-top: .9rem; border-top: 1px solid var(--line, rgba(255,255,255,.08)); min-width: 14rem;` `.tunnel__name`: serif 400, `font-size: clamp(1.25rem, 2vw, 1.75rem); letter-spacing: -0.02em; will-change: transform, opacity`. `.tunnel__tag`: mono 11px uppercase muted.
- `.tunnel__hint`: `position: absolute; right: clamp(1.25rem, 6vw, 5rem); bottom: same as hud;` mono 11px uppercase muted with a 6px lime dot (`::before`). Under 768px it moves to the left under the HUD.

**Animation spec (exact values):**
Constants: `COUNT = 24`, `SPACING = 1.7`, `RADIUS = 2.9`, `TURN = 2π/7`, `LOOP = COUNT × SPACING`, `NEAR = SPACING × 1.2`, `BASE_SPEED = 1.1`, `MAX_SPEED = 14`, `INPUT_GAIN = 0.03`, `SPEED_LERP = 0.06`, `RETURN_LERP = 0.03`, `PARALLAX_LERP = 0.05`, `PARALLAX = true`, fog `near 4 / far 32`.
1. **Textures** (one 512×640 canvas per poster): linear gradient `c2 → c1` corner to corner; subject by `i % 3` — (0) a disc: radial gradient `c3 → c3@66% → transparent` radius 230 at (60%, 40%) plus a solid 86px disc at 0.9 alpha; (1) a horizon: lower half gradient `c3@80% → c1 (8%) → c2` with a 3px `c3` line at 55% alpha; (2) five concentric arcs (`lineWidth 14 − 2k`, radii `90 + 34k`, alpha `0.9 − 0.17k`, from `1.08π` to `1.92π`) centred (50%, 62%). Then `overlay` a tiled 128² random grain at 0.35 alpha, a 1px `rgba(255,255,255,.22)` frame inset 20px, and text: index `300 118px serif` at (40, 150), name `500 26px sans` uppercase at (44, H−76), tag `400 17px mono` at (44, H−46) in `rgba(242,242,242,.7)`. Palettes (c1, c2, c3) cycle through 8 duotones: navy `#0b1530/#1e3a6e/#8fb3ff`, plum `#1a0b1f/#4a1f52/#d79cff`, moss `#0b1a12/#1f4a2e/#9fe3b0`, ember `#1f0c08/#6b2a16/#ffb27a`, sand `#1c1710/#6b5a34/#ffe2a8`, ink `#0d0d10/#2b2b36/#bdbdd6`, teal `#071a1c/#145a5f/#8ff0f0`, rose `#200a12/#6b2340/#ffa3c4`. `CanvasTexture` with `colorSpace = SRGBColorSpace`, `anisotropy 4`, mipmaps.
2. **Scene**: `WebGLRenderer({ antialias: true })`, `setPixelRatio(Math.min(devicePixelRatio, 2))`, `scene.fog = new THREE.Fog(bg, 4, 32)`, clear colour = `--bg`. `PerspectiveCamera(58, aspect, 0.1, 80)` at the origin looking down −z. A `Group` holds all planes.
3. **Planes**: `PlaneGeometry(2.0, 2.5)` shared; per plane `MeshBasicMaterial({ map, transparent: true, fog: true, toneMapped: false, side: DoubleSide })`. Position `(cos(i·TURN)·RADIUS, sin(i·TURN)·RADIUS, z)`, then `mesh.lookAt(0, 0, 4.5)` so each faces the axis tilted toward the viewer.
4. **Layout (every frame)** — the infinite wrap: `m = ((i·SPACING − travel) mod LOOP + LOOP) mod LOOP`; `plane.position.z = NEAR − m`; `plane.material.opacity = smoothstep(m, 0, SPACING × 1.5)` (fades out over the last 1.5 spacings before passing the camera); `group.rotation.z = travel × 0.012`.
5. **Speed model (every frame, dt clamped to 50ms)**: `speed += (targetSpeed − speed) × 0.06`; `targetSpeed += (BASE_SPEED − targetSpeed) × 0.03` (BASE_SPEED = 1.1); `travel += speed × dt`. Initial `travel = SPACING × 0.4` so the first frame already shows posters at pleasing depths.
6. **Observer**: `Observer.create({ target: section, type: 'wheel,touch,pointer', preventDefault: true, dragMinimum: 3, onChangeY(self) { const dir = self.event.type === 'wheel' ? 1 : −1; targetSpeed = clamp(−14, 14, targetSpeed + dir × self.deltaY × 0.03); } })` — wheel down and drag/swipe up both travel forward.
7. **Parallax (every frame, only when `PARALLAX` = true is true)**: pointer normalised to −1…1 over the section. `camera.position.x → px × 0.45`, `camera.position.y → −py × 0.3`, `camera.rotation.y → −px × 0.07`, `camera.rotation.x → −py × 0.045`, `camera.rotation.z → speed × 0.004`; each eased with `+= (target − current) × 0.05`.
8. **Counter**: `idx = floor(travel / SPACING + 2) mod 24`; when it changes set `.tunnel__index` to `String(idx+1).padStart(2,'0')` and `gsap.fromTo('.tunnel__name', { yPercent: 50, opacity: 0 }, { yPercent: 0, opacity: 1, duration: 0.5, ease: 'power3.out', overwrite: true })`, swapping the name/tag text in `onStart`.
9. **Intro**: `gsap.from('.tunnel__title', { y: 30, opacity: 0, duration: 1.2, ease: 'power3.out', delay: 0.2 })`; `gsap.from(['.tunnel__hud', '.tunnel__hint'], { y: 16, opacity: 0, duration: 1, ease: 'power3.out', delay: 0.5, stagger: 0.1 })`.
10. **Theme**: read `getComputedStyle(document.documentElement).getPropertyValue('--bg')` into a `THREE.Color` for fog + clear colour; re-run on a `MutationObserver` watching `data-theme`.

**Interaction:** Wheel: each 100px of delta adds `100 × 0.03` units/s to the target speed (capped at ±14), so a flick roughly doubles the drift and a hard scroll races through ~6 posters before coasting back to idle in about 2s. Drag (mouse) and swipe (touch) do the same with inverted sign; `preventDefault: true` keeps the page from scrolling under the tunnel. Pointer movement pans/tilts the camera (no click needed). Nothing is clickable in the demo; in production make each plane a raycast target that opens the project.

**Responsive behavior:** `gsap.matchMedia({ mobile: '(max-width: 767px)', touch: '(hover: none)' })`: under 768px the camera `fov` is 72 (instead of 58) so the ring still surrounds the viewer on a portrait screen; on touch devices pointer parallax is disabled (the finger is the drag input). The HUD type scales with `clamp()`; the hint moves under the counter on phones. `ResizeObserver` on the canvas wrapper updates renderer size + aspect.

**Accessibility:** With `prefers-reduced-motion: reduce`: `BASE_SPEED` is 0 (no autoplay), parallax and roll are off, and there is no rAF loop — the scene is rendered once after build/resize, and each Observer `onChangeY` moves `travel` directly by `deltaY × 0.02` and re-renders, so the archive is still browsable by scroll without continuous motion; the counter updates without the rise animation. The canvas wrapper is `aria-hidden`; the counter is an `aria-live="polite"` region so the current index/name is announced; the poster titles are also plain text in the HUD, not only baked into textures.

**Performance rules:** DPR ≤ `Math.min(devicePixelRatio, 2)` (halved in the library hover preview). 24 draw calls, one shared geometry, 24 × 512×640 textures with mipmaps (~20 MB GPU). Only positions/opacity update per frame — no geometry rebuilds. Clamp `dt` to 0.05s so a background tab doesn't teleport the camera. Stop the rAF loop on `ml:pause` and `visibilitychange`, restart on `ml:resume`. Cleanup: stop the loop, `observer.kill()`, `ctx.revert()`, disconnect observers, remove listeners, dispose every texture/material, the geometry and the renderer, remove the canvas.

**Customization knobs:**
- `COUNT`, `SPACING` (1.7), `RADIUS` (2.9), `TURN` — density and spiral shape (`TURN = 2π/8` makes a flat ring of 8 per revolution).
- `BASE_SPEED` (1.1) idle drift; `INPUT_GAIN` (0.03) gesture sensitivity; `MAX_SPEED` (14).
- `SPEED_LERP` (0.06) / `RETURN_LERP` (0.03) — acceleration feel and coast length.
- Fog `near/far` (4/32) — how early posters dissolve.
- `PALETTES` / `WORKS` arrays — swap for real `<img>`-backed `TextureLoader` textures.
- Parallax on/off (true) and amplitudes (0.45 / 0.3 / 0.07 / 0.045).

**Acceptance criteria:**
- [ ] The camera drifts forward on its own from the first frame; posters appear from the fog and pass the screen edges without ever popping or clipping through the camera.
- [ ] Wheel/drag/swipe changes speed smoothly (no stepping) and the speed returns to idle within ~2s of the last input; reverse direction works.
- [ ] After travelling `24 × 1.7` units the scene is identical to the start — no visible seam.
- [ ] Pointer parallax is subtle and eased (0.05), never snapping.
- [ ] The counter and name update as posters pass; the name rises in over 0.5s.
- [ ] Light theme: fog and clear colour match the light background.
- [ ] Reduced motion: static frame, scroll still steps through posters, no autoplay.
- [ ] No console errors; textures use real fonts (no fallback glyphs).

Source

Full source code

<section class="tunnel" aria-label="Infinite 3D gallery tunnel">
  <div class="tunnel__canvas" aria-hidden="true"></div>

  <header class="tunnel__header">
    <p class="tunnel__eyebrow">WebGL · Observer · Infinite loop</p>
    <h1 class="tunnel__title">Archive,<br>without end</h1>
  </header>

  <div class="tunnel__hud">
    <div class="tunnel__counter" aria-live="polite">
      <span class="tunnel__index">01</span><span class="tunnel__total">/ 24</span>
    </div>
    <p class="tunnel__caption">
      <span class="tunnel__name">Aurora Station</span>
      <span class="tunnel__tag">Film · 2026</span>
    </p>
  </div>

  <p class="tunnel__hint">Scroll, drag or swipe to travel</p>
</section>