Introduction
This effect renders a chain of photos winding up a spiral in 3D space, and no matter how long you scroll it never runs out — the last photo simply hands off to the first one again. There is no infinite array of meshes and no teleporting camera. A single ribbon of geometry is bent into a helix once, and the sense of endless scroll comes entirely from a shader that wraps one UV coordinate back on itself.
The technique rests on four ideas: every source photo gets packed edge-to-edge into one wide canvas texture, so the GPU only ever samples a single image — that texture is stretched across a plane whose vertices get pushed into a helix by pure math, no bones or skinning involved — a fragment shader reads a scrolling offset and wraps it with fract(), which is what turns a finite strip of photos into something that feels like it has no end — and a small physics rig folds wheel, drag, and touch events into one damped velocity, so every input device drives the same offset the same way.
How to pack a variable number of differently-sized images into one canvas texture without distorting any of them — how to bend a flat plane into a spiral using only vertex position math — how a one-line UV wrap in GLSL creates the illusion of infinite scroll from finite geometry — how to unify wheel, drag, and touch input behind one damped velocity — and a real NaN bug worth knowing about before you hit it yourself.
Step 1 — Packing a Photo Strip at Load Time
Every image loads independently, but none of them get their own texture. A TextureAtlas class waits for all of them with Promise.all, records each photo's aspect ratio as it resolves, then draws them left-to-right into a single canvas at a fixed row height — each photo scaled only by width, so nothing gets stretched vertically. That canvas becomes one CanvasTexture, wrapped so it repeats horizontally, which is what later lets the shader treat the whole strip as one continuous loop instead of twenty-five separate slides.
_pack(frames) {
const widths = frames.map(f => Math.round(f.ratio * this.rowHeight))
const totalWidth = widths.reduce((sum, w) => sum + w, 0)
const canvas = document.createElement('canvas')
canvas.width = totalWidth
canvas.height = this.rowHeight
let cursor = 0
frames.forEach((frame, i) => {
ctx.drawImage(frame.image, cursor, 0, widths[i], this.rowHeight)
cursor += widths[i]
})
const texture = new THREE.CanvasTexture(canvas)
texture.wrapS = THREE.RepeatWrapping
return { texture, ratios: frames.map(f => f.ratio), widths, totalWidth, rowHeight: this.rowHeight }
}| Field | Type | Role |
|---|---|---|
| texture | CanvasTexture | The packed strip, repeat-wrapped on the horizontal axis |
| ratios | number[] | Each photo's native aspect ratio, used to size its geometry slot |
| widths / totalWidth | number[] / number | Pixel widths inside the atlas, used to compute UV boundaries |
| rowHeight | number | The fixed pack height every photo is scaled to before drawing |
Step 2 — Vertex Math, Not Bones
The gallery is one THREE.PlaneGeometry, subdivided into hundreds of segments along its length so the bend reads as smooth rather than faceted. SpiralGeometry._bendIntoHelix() walks every vertex, converts its horizontal position into a t value from 0 to 1 along the ribbon, then reprojects that vertex onto a circle whose angle sweeps t * Math.PI * 2 * turns and whose height rises linearly with t. The vertex's original y-position is kept as a small offset so each photo still has visible height on the spiral rather than collapsing to a flat line.
_bendIntoHelix(geometry, config) {
const position = geometry.attributes.position
const width = geometry.parameters.width
for (let i = 0; i < position.count; i++) {
const x = position.getX(i)
const y = position.getY(i)
const t = clamp((x + width / 2) / width, 0, 1)
const angle = t * Math.PI * 2 * config.turns
const radius = config.radius * (1 - t * config.curvature * 0.4)
const px = Math.sin(angle) * radius
const pz = Math.cos(angle) * radius
const py = (t - 0.5) * config.height + y * 0.32
position.setXYZ(i, px, py, pz)
}
}The mesh never animates its own shape. Once the vertices are bent into a helix at load time, everything that looks like motion afterward is a texture coordinate sliding underneath a completely static piece of geometry.
Before the bend happens, _remapUVs() walks the same geometry and squeezes each photo's UV range into its own boundary slot in the atlas, leaving a thin gap on either edge. That gap is what keeps adjacent photos from bleeding into each other once the ribbon curves and the camera sees several photos at oblique angles at once.
Step 3 — Wrapping One Float Instead of Cloning Geometry
The obvious way to build an "infinite" gallery is to keep spawning more geometry as the user scrolls. This one never does. The atlas texture is wrapped with THREE.RepeatWrapping, and the fragment shader adds a single offset uniform to the incoming UV coordinate, then wraps the result back into 0–1 with fract(). Scrolling forever just keeps incrementing that one float — the geometry, the atlas, and the draw call never change size or count.
uniform sampler2D map;
uniform float offset;
varying vec2 vUv;
void main() {
float u = fract(vUv.x + offset);
gl_FragColor = texture2D(map, vec2(u, vUv.y));
}On the JavaScript side, the offset itself is kept small and well-behaved on purpose: orbitOffset % 1 runs every frame before it reaches the uniform, rather than letting a session-long scroll accumulate into a huge floating-point number. Large floats lose precision over time, and a fract() fed a very large input can visibly stutter — wrapping the JS-side number, not just the shader-side one, keeps the loop stable indefinitely.
Step 4 — One Damped Velocity, Three Input Sources
An InputRig class is the only thing that knows about wheel, mousedown/mousemove, and touchstart/touchmove. Every one of those handlers does the same small thing: nudge a _targetVelocity value up or down. A separate update() call, run once per animation frame, applies friction to that target and eases the rig's actual velocity toward it — so a hard flick and a slow, deliberate scroll both end up producing the same kind of settling motion, just at different magnitudes.
update() {
this._targetVelocity *= this.config.friction
this.velocity = lerp(this.velocity, this._targetVelocity, 1 - this.config.smoothing)
if (Math.abs(this.velocity) > 0.00005) {
this.orbitOffset += this.velocity
} else {
this.velocity = 0
}
}Drag and pinch input is handled separately from scroll velocity — it feeds a small tilt.x / tilt.z pair instead, which the gallery lerps the whole rig toward every frame. That split matters: scrolling should feel like it has momentum and coast to a stop, while tilt should feel directly attached to the pointer and snap back the instant you let go. Routing both through the same velocity system would have made one of the two feel wrong.
Step 5 — Wiring the Atlas, Geometry, and Input Together
OrbitGallery is the only class that calls requestAnimationFrame. Its init() builds the scene and lights, awaits the atlas, hands the result to SpiralGeometry.build(), constructs the shader material, and only then constructs the InputRig — everything upstream of input has to exist before input can safely mutate it. Each frame, the loop steps the input physics, writes the wrapped offset into the shader uniform, eases the rig's rotation toward the current tilt target, and renders.
_loop() {
requestAnimationFrame(this._loop)
this.input.update()
let offset = this.input.orbitOffset % 1
if (offset < 0) offset += 1
this.material.uniforms.offset.value = offset
this.rig.rotation.x = lerp(this.rig.rotation.x, this.baseTiltX + this.input.tilt.x, 0.12)
this.rig.rotation.z = lerp(this.rig.rotation.z, this.baseTiltZ + this.input.tilt.z, 0.12)
this.renderer.render(this.scene, this.camera)
}The atlas packer originally returned { texture, ratios, widths, totalWidth } without rowHeight. The geometry builder divided totalWidth by that missing field to compute the ribbon's width — dividing by undefined silently produces NaN, which then propagates into every vertex position, and Three.js reports it as computeBoundingSphere(): Computed radius is NaN rather than pointing at the real cause. The fix was a one-line addition of rowHeight to the returned object; the lesson is that a geometry NaN is almost always upstream, in whatever numbers built that geometry, not in the render call that finally surfaces it.
Tuning Reference
| Constant | Default | Effect |
|---|---|---|
| frameHeight | 6.6 | Height of each photo along the ribbon |
| frameGap | 0.045 | Fraction of each UV slot left as a gap between photos |
| radius | 3.4 | Base radius of the helix |
| turns | 2.6 | How many full rotations the ribbon makes top to bottom |
| height | 13 | Total vertical rise of the helix |
| wheelSensitivity | 0.00075 | How much scroll delta converts into target velocity |
| friction | 0.945 | Per-frame decay applied to target velocity |
| maxSpeed | 0.045 | Hard clamp on offset velocity, keeps flicks from overshooting |
Full Source Code
The complete effect is a single self-contained HTML document — a vertex/fragment shader pair, five small ES classes (TextureAtlas, SpiralGeometry, InputRig, OrbitGallery, FullscreenToggle), and no runtime dependency beyond Three.js itself.
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/infinite-loop-gallery.htmlOne texture, one bent plane, and a single float wrapped with fract(). The whole effect — atlas packing, helix geometry, infinite scroll, unified input physics, and a resize-safe render loop — is well under 500 lines of code.