Aduok Code

A WebGL Spiral Gallery That Loops Forever, Built with a Texture Atlas and Scroll Physics

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.

What you will learn

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.

01
Part One
One Texture, Every Photo

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.

JS
_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 }
}
FieldTypeRole
textureCanvasTextureThe packed strip, repeat-wrapped on the horizontal axis
ratiosnumber[]Each photo's native aspect ratio, used to size its geometry slot
widths / totalWidthnumber[] / numberPixel widths inside the atlas, used to compute UV boundaries
rowHeightnumberThe fixed pack height every photo is scaled to before drawing
02
Part Two
Bending a Ribbon into a Helix

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.

JS
_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.

03
Part Three
Faking Infinity in the Shader

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.

GLSL
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.

04
Part Four
Wheel, Drag, and Touch as One Signal

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.

JS
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.

05
Part Five
One Class Owns the Render Loop

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.

JS
_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)
}
A NaN bug worth knowing about

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

ConstantDefaultEffect
frameHeight6.6Height of each photo along the ribbon
frameGap0.045Fraction of each UV slot left as a gap between photos
radius3.4Base radius of the helix
turns2.6How many full rotations the ribbon makes top to bottom
height13Total vertical rise of the helix
wheelSensitivity0.00075How much scroll delta converts into target velocity
friction0.945Per-frame decay applied to target velocity
maxSpeed0.045Hard 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.

HTML — infinite-loop-gallery.html (complete)
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/infinite-loop-gallery.html

One 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.

Want to see it in action?Watch the full build on YouTube