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; .nav—position: fixed; top:0; left:0; width:100vw; padding:1.5em; display:flex; z-index:2; mix-blend-mode: difference;.footer— same butbottom:0,justify-content: space-between..nav-col { flex:1; }; first nav-col isdisplay:flexwith itspchildren eachflex:1; second nav-col istext-align:right.mix-blend-mode: differenceon 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#0f0f0fbody shows through). Appendrenderer.domElementtobody.- 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) withMeshPhongMaterial({ 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
yin0..segmentsY,yPos = (y/segmentsY - 0.5) * height(vertically centered on 0). Forxin0..segmentsX,xAngle = (x/segmentsX - 0.5) * theta, thenxPos = 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-radiuscircle. - UVs with a 10% inset crop:
u = (x/segmentsX) * 0.8 + 0.1,v = y/segmentsY. (The*0.8 + 0.1samples 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) anduv(Float32, itemSize 2) attributes,setIndex(indices), thencomputeVertexNormals().
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 setgenerateMipmaps = 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 constant0.0025gives the always-on slow spin; the scroll-derivedrotationSpeedadds 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); callrequestAnimationFrame(animate)at the top of the function; kick it off once withanimate().
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
#0f0f0fbody; the fixed monospace nav/footer usemix-blend-mode: differenceso they stay legible over passing images.