Asset Orb — Draggable WebGL Image Sphere
Goal
Build a full-viewport, pitch-black WebGL scene containing a single "orb" made of 100 small photo planes arranged on the surface of a sphere via a Fibonacci-sphere (golden-spiral) distribution, every plane textured with one of 30 editorial photographs picked at random. The user drags to spin the orb (with damped inertia, so it keeps gliding after release) and scrolls/pinches to zoom between a near and far limit. A fixed HTML nav ("ORB") and footer ("[ ARCHIVE BEYOND REALITY ]") float over the canvas. This is a pure Three.js piece — no GSAP at all; all motion comes from OrbitControls damping inside a requestAnimationFrame loop.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use three (npm) only — no GSAP, no Lenis, no other libraries. Import:
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
Layout / HTML
Nearly empty — the canvas is injected by JS:
<body>
<div class="container"></div>
<nav><h1>Orb</h1></nav>
<footer><p>[ Archive beyond reality ]</p></footer>
<script type="module" src="./script.js"></script>
</body>
JS appends the WebGL <canvas> (renderer.domElement) into .container.
Styling
Minimal — the canvas is the whole show.
- Global reset
* { margin: 0; padding: 0; box-sizing: border-box; }. .container { width: 100vw; height: 100vh; overflow: hidden; }— hosts the canvas.nav, footer { position: fixed; width: 100vw; display: flex; justify-content: center; align-items: center; padding: 3em; z-index: 2; }— both are centered overlay strips above the canvas.nav { top: 0 },footer { bottom: 0 }.h1 { text-transform: uppercase; font-family: "Gojo", sans-serif; font-size: 18px; font-weight: 900; color: #fff; }— a heavy display sans; no webfont needed, the bold sans fallback is fine.p { text-transform: uppercase; font-family: "Akkurat Mono", monospace; font-size: 11px; color: #777777; }— small grey mono caption (again, the monospace fallback is fine).- Background is not set in CSS — the black comes from the renderer clear color.
Three.js effect (the important part — be exhaustive)
Config constants
const totalImages = 30; // pool of photo files
const totalItems = 100; // planes on the sphere
const baseWidth = 1; // max plane width (world units)
const baseHeight = 0.6; // max plane height (world units)
const sphereRadius = 5;
Scene / camera / renderer
scene = new THREE.Scene()— no lights needed (materials are unlitMeshBasicMaterial).camera = new THREE.PerspectiveCamera(75, innerWidth / innerHeight, 0.1, 1000), positioned atcamera.position.z = 10(so the whole radius-5 orb fits comfortably in frame).- Renderer:
const renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true,
preserveDrawingBuffer: true,
powerPreference: "high-performance",
});
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setClearColor(0x000000); // the solid black backdrop
renderer.setPixelRatio(window.devicePixelRatio);
document.querySelector(".container").appendChild(renderer.domElement);
No tone mapping / color-space tweaks are required — defaults are fine.
OrbitControls (the interaction — this IS the "animation")
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true; // inertia: the orb keeps drifting after the drag ends
controls.dampingFactor = 0.05; // long, floaty glide
controls.rotateSpeed = 1.2; // slightly faster-than-default drag response
controls.minDistance = 6; // zoom-in limit (just outside the radius-5 shell)
controls.maxDistance = 10; // zoom-out limit (= initial camera distance)
controls.enableZoom = true; // wheel / pinch zooms the dolly distance
controls.enablePan = false; // no panning — the orb stays centered
Damping only works because controls.update() is called every frame in the render loop.
Fibonacci-sphere distribution (exact math)
100 points evenly spread over the sphere surface using the golden-spiral method. For i from 0 to totalItems - 1:
const phi = Math.acos(-1 + (2 * i) / totalItems); // polar angle: pole → pole
const theta = Math.sqrt(totalItems * Math.PI) * phi; // winding azimuth
Then convert to Cartesian on the radius-5 shell (note this exact axis mapping):
mesh.position.x = sphereRadius * Math.cos(theta) * Math.sin(phi);
mesh.position.y = sphereRadius * Math.sin(theta) * Math.sin(phi);
mesh.position.z = sphereRadius * Math.cos(phi);
Texture loading & plane creation (one mesh per point)
For each of the 100 points, load a random image from the pool with a shared THREE.TextureLoader:
const n = Math.floor(Math.random() * totalImages) + 1; // 1..30, duplicates allowed
// path e.g. `./img${n}.jpeg` — point at wherever the 30 files live
In the load callback:
- Texture filtering:
texture.generateMipmaps = false; texture.minFilter = THREE.LinearFilter; texture.magFilter = THREE.LinearFilter;(crisp, no mip blur on the small planes). - Aspect-fit plane geometry inside the 1 × 0.6 box — never crop, shrink one side instead:
const aspect = texture.image.width / texture.image.height;
let width = baseWidth, height = baseHeight;
if (aspect > 1) height = width / aspect; // landscape: full 1.0 wide, shorter
else width = height * aspect; // portrait/square: full 0.6 tall, narrower
const geometry = new THREE.PlaneGeometry(width, height);
- Material — unlit, double-sided (so planes on the far side of the orb are visible through the gaps, which gives the see-through "hollow shell of photos" look):
const material = new THREE.MeshBasicMaterial({
map: texture,
side: THREE.DoubleSide,
transparent: false,
depthWrite: true,
depthTest: true,
});
- Orientation — every plane faces outward from the center:
mesh.lookAt(0, 0, 0);
mesh.rotateY(Math.PI); // lookAt points the front face inward; flip it back out
scene.add(mesh).
Deferred start of the render loop
Keep a loadedCount; increment it in each texture callback and only start animating after all 100 textures have loaded:
loadedCount++;
if (loadedCount === totalItems) animate();
So the page shows plain black (with the nav/footer text) for a moment, then the fully-populated orb pops in at once — there is no progressive build-up. Log texture errors with the loader's onError callback.
Render loop
const animate = () => {
requestAnimationFrame(animate);
controls.update(); // applies damping/inertia every frame
renderer.render(scene, camera);
};
No autonomous rotation — the orb is perfectly still until the user drags it.
Resize handler
On window.resize: renderer.setSize(w, h), camera.aspect = w / h, camera.updateProjectionMatrix().
Assets / images
30 editorial fashion / portrait photographs (img1.jpeg … img30.jpeg), mixed orientations (portraits, profile shots, full-length figures, extreme close-ups), with a moody, cinematic, high-fashion palette — saturated reds, deep teals and blues, black-and-white shots, dark backdrops — so the tiles glow against the black void. They are interchangeable: each of the 100 planes picks one at random (repeats are expected and fine). Mixed aspect ratios are actually desirable — the aspect-fit sizing turns them into varied landscape/portrait tiles, which gives the orb its collage texture. If fewer files are available, reduce totalImages accordingly and repeat.
Behavior notes
- Interaction only — nothing animates on load; drag rotates (in any direction, full 360° including over the poles), release keeps a damped spin, wheel/pinch zooms between distance 6 and 10. No pan.
- The orb is hollow and see-through: through the gaps between front planes you see the mirrored backs of the far-side planes (
DoubleSideshows the texture mirrored on the reverse — correct and intentional). - Full-viewport canvas, responsive via the resize handler; interaction works with touch as well (OrbitControls handles pointer + touch), though the piece is desktop-oriented and WebGL-heavy (100 textured meshes).
- No GSAP, no ScrollTrigger, no scroll hijacking — the page itself never scrolls; the wheel is captured by OrbitControls for zoom.
- No reduced-motion handling in the original (all motion is user-driven anyway).