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.
- WebGL & 3D
- threejs
- gsap
- canvas2d
- advanced
- heavy · WebGL
- mobile: full
- reduced-motion ✓
AI prompt
Recreate this effect with any AI tool
Paste into Claude, Cursor, v0, Lovable or Bolt. 1650 words of exact values — targets, eases, durations, triggers.
### 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. 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). 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 = 40.8`, `NEAR = SPACING × 1.2 = 2.04`, `BASE_SPEED = 1.1`, `MAX_SPEED = 14`, `INPUT_GAIN = 0.03`, `SPEED_LERP = 0.06`, `RETURN_LERP = 0.03`, `PARALLAX_LERP = 0.05`, 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 2.55 units 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`; `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)**: 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 3 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`, `RADIUS`, `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 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 40.8 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). Add this effect to my existing site/section: **Infinite 3D Gallery Tunnel**. Inside a `100vh` `.tunnel` section, mount a Three.js `WebGLRenderer` (DPR ≤ 2) with `scene.fog = new THREE.Fog(bg, 4, 32)` and a `PerspectiveCamera(58)` at the origin. Create 24 `PlaneGeometry(2.0, 2.5)` meshes textured with my project images (`MeshBasicMaterial { map, transparent: true, fog: true }`), placed at `(cos(i·2π/7)·2.9, sin(i·2π/7)·2.9, z)` and `lookAt(0, 0, 4.5)`. Every frame set `z = 2.04 − ((i·1.7 − travel) mod 40.8)` and `opacity = smoothstep(m, 0, 2.55)` so posters wrap infinitely. Drive `travel` with a speed model: `speed += (target − speed)·0.06`, `target += (1.1 − target)·0.03`, `travel += speed·dt`; feed it from GSAP `Observer.create({ type: 'wheel,touch,pointer', preventDefault: true, onChangeY: self => target = clamp(−14, 14, target + (wheel ? 1 : −1)·self.deltaY·0.03) })`. Add pointer parallax (`camera.position.x → px·0.45`, `rotation.y → −px·0.07`, lerp 0.05) and a counter that shows `floor(travel/1.7 + 2) mod 24`. Respect `prefers-reduced-motion` (no autoplay, render on input only), pause the loop when hidden, dispose on unmount. Keep my fonts, colours and layout. 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>
.tunnel { position: relative; height: 100vh; min-height: 560px; overflow: hidden; background: var(--bg); color: var(--text); touch-action: none; overscroll-behavior: contain; }
.tunnel__canvas { position: absolute; inset: 0; cursor: grab; }
.tunnel__canvas:active { cursor: grabbing; }
.tunnel__canvas canvas { width: 100%; height: 100%; display: block; }
/* soft vignette so the tunnel walls dissolve into the page */
.tunnel::after { content: ''; position: absolute; inset: 0; pointer-events: none; background: radial-gradient(70% 70% at 50% 50%, transparent 55%, color-mix(in srgb, var(--bg) 70%, transparent) 100%); }
.tunnel__header { position: absolute; left: clamp(1.25rem, 6vw, 5rem); top: clamp(1.5rem, 4vw, 3.5rem); z-index: 2; pointer-events: none; }
.tunnel__eyebrow { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); margin-bottom: 1rem; }
.tunnel__title { font-family: var(--font-display); font-weight: 300; font-variation-settings: 'opsz' 144, 'SOFT' 30; font-size: clamp(2.4rem, 5.5vw, 5.5rem); line-height: .92; letter-spacing: -0.04em; }
.tunnel__hud { position: absolute; left: clamp(1.25rem, 6vw, 5rem); bottom: clamp(1.5rem, 4vw, 3.5rem); z-index: 2; pointer-events: none; display: grid; gap: .9rem; }
.tunnel__counter { display: flex; align-items: baseline; gap: .6rem; }
.tunnel__index { font-family: var(--font-display); 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 { font-family: var(--font-mono); font-size: 12px; letter-spacing: .1em; color: var(--muted); }
.tunnel__caption { display: grid; gap: .3rem; padding-top: .9rem; border-top: 1px solid var(--line); min-width: 14rem; }
.tunnel__name { font-family: var(--font-display); font-weight: 400; font-size: clamp(1.25rem, 2vw, 1.75rem); letter-spacing: -0.02em; line-height: 1.1; will-change: transform, opacity; }
.tunnel__tag { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .12em; color: var(--muted); }
.tunnel__hint { position: absolute; right: clamp(1.25rem, 6vw, 5rem); bottom: clamp(1.5rem, 4vw, 3.5rem); z-index: 2; font-family: var(--font-mono); font-size: 11px; letter-spacing: .12em; text-transform: uppercase; color: var(--muted); pointer-events: none; }
.tunnel__hint::before { content: ''; display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--accent); margin-right: .7rem; vertical-align: middle; }
@media (max-width: 767px) {
.tunnel__hint { right: auto; left: clamp(1.25rem, 6vw, 5rem); bottom: .9rem; }
.tunnel__hud { bottom: 3.2rem; }
.tunnel__caption { min-width: 0; }
}
@media (prefers-reduced-motion: reduce) {
.tunnel__hint::before { display: none; }
}
// Infinite 3D gallery tunnel — 24 textured planes spiral down -z; Observer drives speed; z wraps modulo the loop length.
import * as THREE from 'three';
gsap.registerPlugin(Observer);
const COUNT = 24; // planes in the loop
const SPACING = 1.7; // z distance between consecutive planes
const RADIUS = 2.9; // distance of each plane from the tunnel axis
const TURN = (Math.PI * 2) / 7; // angular step per plane (7 per revolution → spiral, never a flat ring)
const LOOP = COUNT * SPACING; // 40.8 units: travel wraps modulo this
const NEAR = SPACING * 1.2; // planes pass 2.04 units behind the camera before wrapping to the far end
const BASE_SPEED = 1.1; // idle autoplay, units / second
const MAX_SPEED = 14;
const INPUT_GAIN = 0.03; // speed added per pixel of wheel / drag
const SPEED_LERP = 0.06; // speed → targetSpeed per frame
const RETURN_LERP = 0.03; // targetSpeed → BASE_SPEED per frame
const PARALLAX_LERP = 0.05;
const FOG_NEAR = 4, FOG_FAR = 32;
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const section = document.querySelector('.tunnel');
const mount = section.querySelector('.tunnel__canvas');
const indexEl = section.querySelector('.tunnel__index');
const nameEl = section.querySelector('.tunnel__name');
const tagEl = section.querySelector('.tunnel__tag');
const WORKS = [
['Aurora Station', 'Film · 2026'], ['Salt Flats', 'Photography · 2026'], ['Night Market', 'Identity · 2025'], ['Low Tide', 'Editorial · 2025'],
['Glasshouse', 'Architecture · 2026'], ['Cinder', 'Campaign · 2024'], ['Blue Hour', 'Film · 2025'], ['Paper Moon', 'Exhibition · 2024'],
['Meridian', 'Product · 2026'], ['Still Water', 'Photography · 2025'], ['Terrace', 'Architecture · 2025'], ['Velvet Signal', 'Music · 2024'],
['Ferrous', 'Industrial · 2026'], ['Half Light', 'Editorial · 2024'], ['Orchard', 'Identity · 2026'], ['Monsoon', 'Film · 2024'],
['Quarry', 'Architecture · 2024'], ['Soft Static', 'Music · 2025'], ['Harbour', 'Photography · 2026'], ['Lantern', 'Campaign · 2025'],
['Northline', 'Product · 2025'], ['Ember Field', 'Exhibition · 2026'], ['Tideglass', 'Identity · 2024'], ['Last Light', 'Film · 2026'],
];
const PALETTES = [
['#0b1530', '#1e3a6e', '#8fb3ff'], ['#1a0b1f', '#4a1f52', '#d79cff'], ['#0b1a12', '#1f4a2e', '#9fe3b0'], ['#1f0c08', '#6b2a16', '#ffb27a'],
['#1c1710', '#6b5a34', '#ffe2a8'], ['#0d0d10', '#2b2b36', '#bdbdd6'], ['#071a1c', '#145a5f', '#8ff0f0'], ['#200a12', '#6b2340', '#ffa3c4'],
];
// ---------------------------------------------------------------- procedural "photographs"
function makeGrain() {
const c = document.createElement('canvas'); c.width = c.height = 128;
const g = c.getContext('2d'); const img = g.createImageData(128, 128);
for (let i = 0; i < img.data.length; i += 4) { const v = 110 + Math.random() * 90; img.data[i] = img.data[i + 1] = img.data[i + 2] = v; img.data[i + 3] = 255; }
g.putImageData(img, 0, 0); return c;
}
const grain = makeGrain();
function makeTexture(i) {
const W = 512, H = 640;
const c = document.createElement('canvas'); c.width = W; c.height = H;
const g = c.getContext('2d');
const [c1, c2, c3] = PALETTES[i % PALETTES.length];
const base = g.createLinearGradient(0, 0, W, H); base.addColorStop(0, c2); base.addColorStop(1, c1);
g.fillStyle = base; g.fillRect(0, 0, W, H);
// subject: alternates between a sun disc, a horizon band and a halo arc
const kind = i % 3;
if (kind === 0) {
const r = g.createRadialGradient(W * 0.6, H * 0.4, 10, W * 0.6, H * 0.4, 230);
r.addColorStop(0, c3); r.addColorStop(0.45, c3 + 'aa'); r.addColorStop(1, 'transparent');
g.fillStyle = r; g.fillRect(0, 0, W, H);
g.fillStyle = c3; g.globalAlpha = 0.9; g.beginPath(); g.arc(W * 0.6, H * 0.4, 86, 0, Math.PI * 2); g.fill(); g.globalAlpha = 1;
} else if (kind === 1) {
const h = g.createLinearGradient(0, H * 0.5, 0, H); h.addColorStop(0, c3 + 'cc'); h.addColorStop(0.08, c1); h.addColorStop(1, c2);
g.fillStyle = h; g.fillRect(0, H * 0.5, W, H * 0.5);
g.fillStyle = c3; g.globalAlpha = 0.55; g.fillRect(0, H * 0.5 - 2, W, 3); g.globalAlpha = 1;
} else {
g.strokeStyle = c3; g.lineCap = 'round';
for (let k = 0; k < 5; k++) { g.globalAlpha = 0.9 - k * 0.17; g.lineWidth = 14 - k * 2; g.beginPath(); g.arc(W * 0.5, H * 0.62, 90 + k * 34, Math.PI * 1.08, Math.PI * 1.92); g.stroke(); }
g.globalAlpha = 1;
}
// film grain + hairline frame
g.globalCompositeOperation = 'overlay'; g.globalAlpha = 0.35; g.fillStyle = g.createPattern(grain, 'repeat'); g.fillRect(0, 0, W, H);
g.globalCompositeOperation = 'source-over'; g.globalAlpha = 1;
g.strokeStyle = 'rgba(255,255,255,.22)'; g.lineWidth = 1; g.strokeRect(20.5, 20.5, W - 41, H - 41);
// typography baked into the image
const [name, tag] = WORKS[i];
g.fillStyle = '#f2f2f2'; g.textBaseline = 'alphabetic';
g.font = '300 118px Fraunces, Georgia, serif'; g.fillText(String(i + 1).padStart(2, '0'), 40, 150);
g.font = '500 26px "Inter Tight", system-ui, sans-serif'; g.fillText(name.toUpperCase(), 44, H - 76);
g.font = '400 17px "JetBrains Mono", ui-monospace, monospace'; g.fillStyle = 'rgba(242,242,242,.7)'; g.fillText(tag.toUpperCase(), 44, H - 46);
const tex = new THREE.CanvasTexture(c);
tex.colorSpace = THREE.SRGBColorSpace; tex.anisotropy = 4; tex.generateMipmaps = true; tex.minFilter = THREE.LinearMipmapLinearFilter;
return tex;
}
// ---------------------------------------------------------------- three
const dprCap = window.MotionLab?.preview ? 1 : 2;
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(devicePixelRatio, dprCap));
mount.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.fog = new THREE.Fog(0x0a0a0a, FOG_NEAR, FOG_FAR);
const camera = new THREE.PerspectiveCamera(58, 1, 0.1, 80);
const group = new THREE.Group();
scene.add(group);
const geometry = new THREE.PlaneGeometry(2.0, 2.5);
const planes = [];
const textures = [];
let needsRender = true;
function syncTheme() {
const bg = getComputedStyle(document.documentElement).getPropertyValue('--bg').trim() || '#0a0a0a';
const col = new THREE.Color(bg);
scene.fog.color.copy(col);
renderer.setClearColor(col, 1);
needsRender = true;
}
const themeObserver = new MutationObserver(syncTheme);
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
syncTheme();
// ---------------------------------------------------------------- state
let travel = SPACING * 0.4; // distance travelled down the tunnel (wraps modulo LOOP)
let speed = reduced ? 0 : BASE_SPEED;
let targetSpeed = reduced ? 0 : BASE_SPEED;
let baseSpeed = reduced ? 0 : BASE_SPEED;
const pointer = { x: 0, y: 0 };
let parallaxOn = !reduced;
let currentIndex = -1;
function mod(n, m) { return ((n % m) + m) % m; }
function layout() {
for (let i = 0; i < planes.length; i++) {
const p = planes[i];
const m = mod(i * SPACING - travel, LOOP); // 0 = right behind the camera, LOOP = far end
p.position.z = NEAR - m;
const near = THREE.MathUtils.smoothstep(m, 0, SPACING * 1.5); // fade out as a plane passes the camera
p.material.opacity = near;
}
group.rotation.z = travel * 0.012; // the spiral slowly screws forward as you travel
}
function updateCounter() {
const idx = mod(Math.floor(travel / SPACING + 2), COUNT);
if (idx === currentIndex) return;
currentIndex = idx;
indexEl.textContent = String(idx + 1).padStart(2, '0');
const [name, tag] = WORKS[idx];
if (reduced) { nameEl.textContent = name; tagEl.textContent = tag; return; }
gsap.fromTo(nameEl, { yPercent: 50, opacity: 0 }, { yPercent: 0, opacity: 1, duration: 0.5, ease: 'power3.out', overwrite: true, onStart: () => { nameEl.textContent = name; tagEl.textContent = tag; } });
}
// ---------------------------------------------------------------- GSAP: Observer input + matchMedia
let observer;
const ctx = gsap.context(() => {
observer = Observer.create({
target: section,
type: 'wheel,touch,pointer',
preventDefault: true,
dragMinimum: 3,
onChangeY(self) {
const dir = self.event.type === 'wheel' ? 1 : -1; // drag/swipe up = travel forward (natural scroll)
targetSpeed = gsap.utils.clamp(-MAX_SPEED, MAX_SPEED, targetSpeed + dir * self.deltaY * INPUT_GAIN);
if (reduced) { travel += dir * self.deltaY * 0.02; layout(); updateCounter(); needsRender = true; }
},
});
const mm = gsap.matchMedia();
mm.add({ mobile: '(max-width: 767px)', touch: '(hover: none)' }, (c) => {
const { mobile, touch } = c.conditions;
camera.fov = mobile ? 72 : 58; camera.updateProjectionMatrix();
parallaxOn = !touch && !reduced;
needsRender = true;
return () => { camera.fov = 58; camera.updateProjectionMatrix(); parallaxOn = !reduced; };
});
if (!reduced) {
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 });
}
});
const onPointer = (e) => {
const r = mount.getBoundingClientRect();
pointer.x = ((e.clientX - r.left) / r.width) * 2 - 1;
pointer.y = ((e.clientY - r.top) / r.height) * 2 - 1;
};
section.addEventListener('pointermove', onPointer);
// ---------------------------------------------------------------- build planes once fonts are ready (so baked titles don't fall back)
async function build() {
try {
await document.fonts.ready;
await Promise.all([document.fonts.load('300 118px Fraunces'), document.fonts.load('500 26px "Inter Tight"'), document.fonts.load('400 17px "JetBrains Mono"')]);
} catch { /* fallbacks are fine */ }
for (let i = 0; i < COUNT; i++) {
const tex = makeTexture(i);
textures.push(tex);
const mat = new THREE.MeshBasicMaterial({ map: tex, transparent: true, fog: true, toneMapped: false, side: THREE.DoubleSide });
const mesh = new THREE.Mesh(geometry, mat);
const a = i * TURN;
mesh.position.set(Math.cos(a) * RADIUS, Math.sin(a) * RADIUS, 0);
mesh.lookAt(0, 0, 4.5); // face the tunnel axis, tilted slightly toward the viewer
mesh.position.z = -(i * SPACING); // overwritten every frame by layout()
group.add(mesh);
planes.push(mesh);
}
layout();
updateCounter();
needsRender = true;
if (reduced) render();
}
build();
// ---------------------------------------------------------------- loop
const clock = new THREE.Clock();
let raf = 0, running = false;
function render() { renderer.render(scene, camera); needsRender = false; }
function frame() {
raf = requestAnimationFrame(frame);
const dt = Math.min(clock.getDelta(), 0.05);
speed += (targetSpeed - speed) * SPEED_LERP;
targetSpeed += (baseSpeed - targetSpeed) * RETURN_LERP;
travel += speed * dt;
if (travel > LOOP * 1e3 || travel < -LOOP * 1e3) travel = mod(travel, LOOP); // keep the float small on very long sessions
const px = parallaxOn ? pointer.x : 0, py = parallaxOn ? pointer.y : 0;
camera.position.x += (px * 0.45 - camera.position.x) * PARALLAX_LERP;
camera.position.y += (-py * 0.3 - camera.position.y) * PARALLAX_LERP;
camera.rotation.y += (-px * 0.07 - camera.rotation.y) * PARALLAX_LERP;
camera.rotation.x += (-py * 0.045 - camera.rotation.x) * PARALLAX_LERP;
camera.rotation.z += (speed * 0.004 - camera.rotation.z) * PARALLAX_LERP; // a hint of roll with velocity
layout();
updateCounter();
render();
}
function start() { if (running || reduced) return; running = true; clock.getDelta(); raf = requestAnimationFrame(frame); }
function stop() { running = false; cancelAnimationFrame(raf); }
function resize() {
const w = mount.clientWidth, h = mount.clientHeight;
if (!w || !h) return;
camera.aspect = w / h; camera.updateProjectionMatrix();
renderer.setSize(w, h, false);
needsRender = true;
if (reduced) render();
}
const ro = new ResizeObserver(resize);
ro.observe(mount);
resize();
start();
const onPause = () => stop();
const onResume = () => start();
const onVisibility = () => (document.hidden ? stop() : start());
window.addEventListener('ml:pause', onPause);
window.addEventListener('ml:resume', onResume);
document.addEventListener('visibilitychange', onVisibility);
// reduced motion: render on demand only (input re-renders inside onChangeY)
if (reduced) {
const onDemand = () => { if (needsRender) render(); };
section.addEventListener('pointerup', onDemand);
}
// ---------------------------------------------------------------- cleanup
window.MotionLab?.onCleanup(() => {
stop();
observer?.kill();
ctx.revert();
ro.disconnect();
themeObserver.disconnect();
section.removeEventListener('pointermove', onPointer);
window.removeEventListener('ml:pause', onPause);
window.removeEventListener('ml:resume', onResume);
document.removeEventListener('visibilitychange', onVisibility);
textures.forEach((t) => t.dispose());
planes.forEach((p) => p.material.dispose());
geometry.dispose();
renderer.dispose();
renderer.domElement.remove();
});