Introduction
In this article we build a parallax portrait gallery — a fullscreen WebGL canvas where portrait photographs scroll horizontally across five depth layers, each moving at a different speed to create a convincing sense of 3D depth. The nearest layer moves fastest, the furthest slowest. Every portrait gently breathes with a sine-wave pulse, the ball floats on a subtle vertical drift, and the whole scene responds to mouse drag, touch swipe, and scroll wheel with inertia. The entire effect is written in vanilla JavaScript using Three.js for GPU-accelerated sprite rendering. No framework, no bundler, no build step.
The effect relies on four mechanisms working together: an OrthographicCamera mapped to pixel coordinates so sprites can be placed by screen position directly — THREE.Sprite objects that always face the camera and accept any loaded texture — an ImagePool class that shuffles portrait URLs into a non-repeating queue — and a velocity variable with exponential decay that gives drag and scroll their inertia feel.
How to set up a Three.js OrthographicCamera in screen-pixel space so you can position sprites by pixel coordinates — how to use THREE.Sprite for GPU-accelerated image rendering without managing geometry or UV mapping — how to build a shuffled non-repeating image pool that cycles indefinitely — how to implement velocity-based inertia for drag and scroll input — how to prune off-screen sprites to keep memory bounded — and how encapsulating everything in a class makes the scene easy to resize and reset.
Step 1 — Class-Based Architecture
The entire scene lives inside two classes: ImagePool handles texture URL shuffling, and ParallaxGallery owns the Three.js scene, renderer, camera, layers array, animation loop, and all event listeners. Structuring around a class means resize() can tear down and rebuild the sprite layout by calling this._fillViewport(), and the constructor can wire all events with arrow methods that automatically capture this. There are no module-level variables except the constants at the top of the file.
const LAYER_COUNT = 5; // number of depth planes
const PER_LAYER = 10; // sprites per layer
const MAX_DIM = 160; // max px dimension of one sprite
const TOTAL_NEEDED = LAYER_COUNT * PER_LAYER; // 50 textures total
const LAYER_CFG = [
{ scale: 1.5, speed: 80, opacity: 1.00 }, // nearest — fastest, fully opaque
{ scale: 1.0, speed: 40, opacity: 0.85 },
{ scale: 0.8, speed: 30, opacity: 0.70 },
{ scale: 0.6, speed: 20, opacity: 0.55 },
{ scale: 0.5, speed: 15, opacity: 0.40 }, // furthest — slowest, most transparent
];| Constant | Value | Role |
|---|---|---|
| LAYER_COUNT | 5 | Number of independent depth planes |
| PER_LAYER | 10 | Portrait sprites placed per layer on startup |
| MAX_DIM | 160 | Largest dimension a sprite may occupy in pixels before scaling |
| TOTAL_NEEDED | 50 | Total textures loaded before the scene starts — LAYER_COUNT × PER_LAYER |
Step 2 — OrthographicCamera in Pixel Space
Three.js defaults to a PerspectiveCamera with a normalised coordinate system. For a 2D scrolling gallery we use an OrthographicCamera instead, configured so that one unit in scene space equals one CSS pixel. The camera is constructed with left=0, right=clientWidth, top=clientHeight, bottom=0, which maps the scene origin to the top-left corner of the container exactly as CSS does. Sprites can then be positioned with sprite.position.set(x, y, z) where x and y are plain pixel values read from getBoundingClientRect or clientWidth.
_initRenderer() {
this._scene = new THREE.Scene();
this._renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true, // transparent background — page bg shows through
powerPreference: 'high-performance',
});
this._renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); // cap at 2× for perf
this._renderer.setClearColor(0x000000, 0); // fully transparent clear
this._stage.appendChild(this._renderer.domElement);
}
_initCamera() {
const { clientWidth: w, clientHeight: h } = this._stage;
this._renderer.setSize(w, h);
// OrthographicCamera(left, right, top, bottom, near, far)
// Maps scene units 1:1 with CSS pixels, origin at top-left
this._camera = new THREE.OrthographicCamera(0, w, h, 0, -1000, 1000);
this._camera.position.z = 10;
}A PerspectiveCamera applies perspective divide — objects further from the camera appear smaller. For a parallax effect we control depth ourselves via the scale and speed values in LAYER_CFG. An OrthographicCamera renders all sprites at exactly their scale.set() size regardless of their z position, so the scale values in LAYER_CFG are the only thing controlling perceived depth. This gives complete, predictable control over the parallax illusion.
Step 3 — Shuffled Non-Repeating Image Queue
ImagePool takes the array of portrait URLs, Fisher-Yates shuffles a copy of it, and hands out URLs one at a time via next(). When the pool is exhausted it reshuffles and starts again. This means no portrait appears twice in a row, the distribution is uniform, and adding or removing URLs from the source array requires no changes to the consuming code. The pool is used both during initial texture loading and by the resize handler when the scene is rebuilt.
class ImagePool {
constructor(urls) {
this._source = urls;
this._pool = [];
this._cursor = 0;
this._refill();
}
_refill() {
// Fisher-Yates shuffle into a fresh copy
this._pool = [...this._source].sort(() => Math.random() - 0.5);
this._cursor = 0;
}
next() {
if (this._cursor >= this._pool.length) this._refill();
return this._pool[this._cursor++];
}
}The pool pattern decouples "which image comes next" from "how many textures have been loaded". The loader calls pool.next() exactly TOTAL_NEEDED times during startup — 50 times for a 5-layer × 10-per-layer scene — and the pool handles deduplication automatically. Adding a new portrait URL to PORTRAIT_URLS is all that is needed to include it in the rotation.
Step 4 — Building and Placing Portrait Sprites
Each portrait is a THREE.Sprite — a billboard quad that always faces the camera and accepts a texture mapped to its surface. The sprite's pixel size is computed from the image's natural aspect ratio clamped to MAX_DIM, then multiplied by the layer's scale factor and a small random size variation (0.85–1.15) so portraits on the same layer are not all identical in size. A random horizontal gap is added between each sprite so the spacing feels organic rather than grid-like.
_addSprite(layerIdx, rightEdge) {
const cfg = LAYER_CFG[layerIdx];
const texture = this._textures[Math.floor(Math.random() * this._textures.length)];
const mat = new THREE.SpriteMaterial({ map: texture, transparent: true, opacity: cfg.opacity });
const sprite = new THREE.Sprite(mat);
// Preserve aspect ratio within MAX_DIM bounds
const img = texture.image;
let w = MAX_DIM, h = MAX_DIM;
if (img?.width && img?.height) {
const ratio = img.width / img.height;
ratio > 1 ? (h = MAX_DIM / ratio) : (w = MAX_DIM * ratio);
}
const sizeVar = this._rand(0.85, 1.15);
const sw = w * cfg.scale * sizeVar; // final sprite width in pixels
const sh = h * cfg.scale * sizeVar; // final sprite height in pixels
const gap = sw * this._rand(0.4, 0.85); // random horizontal gap before this sprite
sprite.scale.set(sw, sh, 1);
const baseY = this._rand(sh / 2, stageH - sh / 2); // random Y, fully on screen
sprite.position.set(rightEdge + sw / 2 + gap, baseY, -layerIdx * 50);
sprite.userData = { speed: cfg.speed * this._rand(0.45, 1.15), w: sw, h: sh,
seed: this._rand(0, 1000), baseY, opacity: cfg.opacity };
this._layers[layerIdx].push(sprite);
this._scene.add(sprite);
}| userData field | Type | Purpose |
|---|---|---|
| speed | number | Pixels per second this sprite moves — layer base speed × random variation |
| w / h | number | Final rendered pixel dimensions — used for wrap-around boundary checks |
| seed | number | Random offset fed into Math.sin() so each sprite's breathing pulse is phase-shifted |
| baseY | number | The sprite's Y position at rest — the sine drift is applied as an offset from this |
| opacity | number | Layer opacity value cached here so it can be re-applied each frame after material reuse |
Step 5 — The requestAnimationFrame Loop
The animation loop runs via requestAnimationFrame. Each frame it computes a delta time (dt) capped at 40 ms to prevent large jumps after tab switches. Velocity decays by a factor of 0.93 per frame — this is the exponential decay that produces the inertia feel after a drag or scroll ends. Each sprite moves by ud.speed × activeSpeed × dt pixels. When a sprite drifts fully off the visible edge in the current direction, it is teleported to the opposite edge to re-enter from there.
_tick() {
requestAnimationFrame(() => this._tick());
const now = performance.now();
const dt = Math.min(40, now - this._lastTime) / 1000; // seconds, capped at 40ms
this._lastTime = now;
this._velocity *= 0.93; // exponential decay — inertia
if (Math.abs(this._velocity) > 0.001) {
this._direction = Math.sign(this._velocity);
}
const stageW = this._stage.clientWidth;
const activeSpeed = Math.abs(this._velocity) > 0.001
? this._velocity * 5 // drag/scroll active — speed proportional to velocity
: this._direction; // coasting — constant 1px/s × per-sprite speed
for (const sprites of this._layers) {
for (const s of sprites) {
const ud = s.userData;
s.position.x += ud.speed * activeSpeed * dt;
// Wrap around when fully off screen
if (this._direction > 0 && s.position.x - ud.w / 2 > stageW) {
s.position.x = -ud.w / 2 - this._rand(0, ud.w);
} else if (this._direction < 0 && s.position.x + ud.w / 2 < 0) {
s.position.x = stageW + ud.w / 2 + this._rand(0, ud.w);
}
// Breathing pulse — gentle sine-wave scale and vertical drift
const pulse = 1 + Math.sin(now * 0.001 + ud.seed) * 0.013;
s.scale.set(ud.w * pulse, ud.h * pulse, 1);
s.position.y = ud.baseY + Math.sin(now * 0.001 + ud.seed) * 4;
s.material.opacity = ud.opacity;
}
}
this._renderer.render(this._scene, this._camera);
}Multiplying velocity by 0.93 each frame is exponential decay. After 10 frames at 60 fps it retains 0.93^10 ≈ 48% of its original value. After 30 frames it retains 11%. This produces a natural-feeling coast that slows gradually, matching how a physical object decelerates under friction — without needing to know the deceleration force explicitly.
Step 6 — Wiring Up Input Events
All three input methods — mouse drag, touch swipe, and scroll wheel — write into the same this._velocity variable. Drag and touch compute dx from the difference between the current and previous pointer X position, then set velocity to dx × 0.02. The scroll wheel sets velocity to ±(current abs velocity + 0.8) clamped to 5, always in the direction of the wheel delta. Because all three inputs share one velocity, the inertia decay applies uniformly regardless of how the user initiated the scroll.
// Mouse drag
this._stage.addEventListener('mousemove', e => {
if (!this._drag.active) return;
const dx = e.clientX - this._drag.lastX;
this._drag.lastX = e.clientX;
this._velocity = dx * 0.02;
});
// Scroll wheel — accelerate in wheel direction, cap at ±5
this._stage.addEventListener('wheel', e => {
e.preventDefault();
const sign = Math.sign(e.deltaY);
const MAX_V = 5;
this._direction = sign;
this._velocity = sign * Math.min(MAX_V, Math.abs(this._velocity) + 0.8);
}, { passive: false });
// Touch is identical to mouse drag but reads e.touches[0].clientX
_clientX(e) {
return e.touches ? e.touches[0].clientX : e.clientX;
}Step 7 — Keeping Memory Bounded with Pruning
As the gallery scrolls, sprites wrap from one edge to the other — they are never removed during normal scrolling. However, if the user rapidly reverses direction many times, sprites can stack up in a layer because the wrapping code re-enters them from the opposite edge without removing the old ones. The pruning pass runs with a 1% probability each frame (Math.random() < 0.01) and removes sprites beyond the PER_LAYER + 4 budget that are already off-screen in the current direction. This keeps each layer capped at roughly 14 sprites regardless of how long the scene runs.
_pruneOffscreen() {
const stageW = this._stage.clientWidth;
const buffer = stageW * 0.5; // half a viewport of tolerance before removing
const maxPer = PER_LAYER + 4;
for (let l = 0; l < LAYER_COUNT; l++) {
const sprites = this._layers[l];
if (sprites.length <= maxPer) continue; // under budget — skip
for (let i = sprites.length - 1; i >= 0 && sprites.length > maxPer; i--) {
const s = sprites[i];
const ud = s.userData;
// Only remove if already off-screen in the direction we are travelling
const tooFarRight = this._direction > 0 && s.position.x - ud.w / 2 > stageW + buffer;
const tooFarLeft = this._direction < 0 && s.position.x + ud.w / 2 < -buffer;
if (tooFarRight || tooFarLeft) {
this._scene.remove(s);
s.material.dispose(); // release GPU texture handle
sprites.splice(i, 1);
}
}
}
}Removing a sprite from the scene with scene.remove() does not free the GPU memory its material and texture occupy. You must call material.dispose() — and texture.dispose() if the texture is not shared — to release the WebGL resources. Failing to do so causes a GPU memory leak that grows slowly over time in long-running scenes. In this gallery textures are shared across sprites, so only the material is disposed on prune; the textures array is cleared only when the entire scene is rebuilt on resize.
Tuning Reference
| Token / Property | Default | Effect |
|---|---|---|
| LAYER_COUNT | 5 | Number of depth planes — more layers = richer parallax, higher draw call count |
| PER_LAYER | 10 | Portraits spawned per layer on startup — increase to fill wider viewports |
| MAX_DIM | 160 | Maximum px size of a portrait before layer scaling is applied — increase for larger thumbnails |
| scale (LAYER_CFG) | 1.5–0.5 | Visual size multiplier per layer — spread between nearest and furthest controls depth illusion |
| speed (LAYER_CFG) | 80–15 | Pixels per second per layer at activeSpeed = 1 — larger spread = stronger parallax |
| opacity (LAYER_CFG) | 1.0–0.4 | Opacity of sprites per layer — fading back layers enhances atmospheric depth |
| velocity decay 0.93 | per frame | How quickly inertia fades — lower value = shorter coast, higher = longer glide |
| velocity × 5 | drag/scroll multiplier | Scales drag dx into scene speed — reduce to 3 for tighter control, raise to 8 for loose |
| pulse 0.013 amplitude | sine scale | Breathing intensity — 0 disables pulse, 0.03 makes it clearly visible |
| drift 4px amplitude | sine Y | Vertical float range in pixels — 0 disables, 10 gives a stronger floating feel |
Full Source Code
The complete file is a single self-contained HTML document. Three.js r128 is loaded from cdnjs. The ParallaxGallery and ImagePool classes follow directly. Open in any browser that supports WebGL — Chrome 90+, Firefox 89+, Safari 15+. No build step, no npm, no dependencies beyond the CDN script tag.
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/parallax-portrait-gallery.htmlOne HTML file, one CDN script, two classes, fifty sprites across five layers. The entire parallax portrait gallery — inertia drag, touch swipe, scroll wheel, breathing pulse, aspect-ratio-preserving portrait fitting, safe GPU disposal on resize — is roughly 220 lines of vanilla JavaScript. No framework. No build step. Both the sprite movement and the pulse run on the GPU compositor thread via WebGL, keeping the main thread free.