All components

Ashfall 3D Cylinder Gallery

WebGL animation component · Published 2026-07-21 · by vanguardia.dev

Open live demo ↗ Raw prompt (.md)

What it does

A Three.js WebGL gallery arranges dozens of curved image planes into a tall rotating cylinder that spins continuously. Lenis smooth-scroll drives the camera vertically through the stack while scroll velocity briefly accelerates the group's Y rotation for an inertial, momentum-based spin.

How it's built

Category3d-webgl
Techthree, lenis
Complexitypage
Performance costheavy
Mobile-safedesktop-first
Scrollhijacks scrolling

three.js webgl scroll gallery cylinder lenis rotation immersive curved-plane

Rebuild it with AI

To reproduce this animation in your own project, copy the prompt below into Claude Code, Cursor or any AI coding agent. The prompt is validated — it describes the exact structure, timing and easing, so the agent rebuilds the effect faithfully and you can then adapt colors, copy and layout to your design.

The full prompt

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:

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:

<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;
  • .navposition: 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:

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.