Inspiration

Every atom in your body heavier than helium was forged inside a star. The iron in your blood was made in a supernova. That is not poetry — it is stellar nucleosynthesis, and it is the strangest true thing I know.

I wanted to run that fact forwards as visual music rather than explain it. Which led to the one constraint the whole project hangs on: there is only one particle cloud in the entire site. It never disappears, never reloads, never cuts to a new scene. It only ever rearranges itself into the next thing it is about to become — because that is literally what happened. The same matter, six times over.

Most "scrolly" 3D sites are a gallery of separate scenes wearing a scroll bar. I wanted the opposite: one continuous object, and a scroll position that is just a number the object is interpolating toward.

The second inspiration was a refusal. No models, no textures, no images, no audio files. If the story is that everything came from nothing, downloading a 40 MB GLB of a tree would be cheating.

What it does

Scroll, and 147,456 particles pass through six states: Void (formless drift), Spark (ignition — a molten core inside a thin shell, pierced by flares), Bloom (a five-armed spiral galaxy with bulge, disc and halo), Helix (two antiparallel strands and their base pairs), Arbor (a recursively grown tree, solid trunk to particulate canopy), and Æther (the word itself — matter organised well enough to name itself).

Between every chapter the cloud tears itself apart into turbulence and reassembles, because nothing in the universe changes shape politely.

It opens on a single luminous nucleus behind the title. Pressing Begin blooms it outward into the void while the camera dollies back — the first thing you see is the thesis of the whole piece.

Alongside the scroll: drag to orbit, move the pointer to push a soft hole through the cloud, arrow keys or the chapter rail to jump. A generative soundtrack retunes itself as you travel and strikes a bell when a chapter locks in.

How we built it

Vanilla JavaScript, Three.js r160, hand-written GLSL. No framework, no bundler, no runtime npm dependencies.

The six shapes are functions, not files. Each generator fills a Float32Array with x, y, z, size-weight per particle — a galaxy from polar math, an L-system tree grown once then dressed in particles, a helix with discrete base pairs. The finale is the only place a rasteriser appears: the word is drawn to an offscreen Canvas 2D once, and its lit pixels become particle coordinates.

All six live on the GPU at once, baked into a single RGBA32F DataTexture (384 × 2304), so particle i of form f sits at a known texel. The morph is a texture lookup in the vertex shader:

vec4 slot(float f){ return texture2D(uTargets, vec2(aUv.x, (f + aUv.y) / uFormCount)); } Each vertex reads its slot in form A and form B and mixes. Changing chapter costs nothing — no re-uploads, no allocations, no hitch. Scroll is one float that can sit anywhere between 0.0 and 5.0.

The dissolve: every particle carries a random delay, so the cloud comes apart in a wave instead of snapping. A sin(π · t) bell peaks mid-transition and drives a two-octave flow field.

Grading is hand-rolled — no EffectComposer. HDR half-float buffer → bright-pass → three cascading gaussian mips → composite with ACES, vignette, grain and aberration. About 140 lines.

Sound is synthesised, not sampled: six oscillators, a brown-noise bed, and a convolution reverb whose impulse response is generated from decaying noise at load.

Verified by rendering, not by reading. I drove the site in headless Chrome with Puppeteer, capturing every chapter plus mid-transition frames at desktop and mobile, and looked at the actual pixels.

Challenges we ran into Everything clipped to white. The first render was gorgeous in structure and unusable in practice. Additive blending stacks thousands of particles per pixel, bloom adds more, ACES flattens anything above 1.0. My first correction overshot into near-black. Three grading passes taught the real lesson: with additive particles you are budgeting total energy, and exposure, point size, particle count and bloom threshold are the same dial wearing different hats.

The film grain moired into a diagonal cross-hatch. The cause was the classic fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453) hash — at high fragment coordinates it loses float precision and degenerates into a repeating pattern. A sin-free hash fixed it.

Chromatic aberration turned the stars into confetti. Splitting the sharp particle buffer per channel offsets single-pixel stars into isolated red, green and blue dots. The fix was conceptual: apply aberration to the bloom only. The blurred halo gives the lens fringe; the sharp buffer stays sharp.

The tree had no trunk. I was weighting wood particles by segment length, and the tree has ~1,000 tiny twigs against one big trunk, so the twigs won. Weighting by volume (length × radius²) gave the trunk the density it physically deserves.

Mobile cropped every subject. Field of view is vertical, so a tall phone viewport silently narrows the horizontal frame and cut the tree, galaxy and word in half.

The entry screen was black. The load-in started particles at six times their final radius — for the Void, a 370-unit sphere seen from 66 units away, so they were off-screen entirely. Rebuilding the pre-entry state as a dense nucleus turned a bug into the best moment in the piece.

Accomplishments that we're proud of Zero assets. Every shape and every sound is generated in the browser at runtime.

The morph never touches the CPU. Because all six targets are resident in one float texture, scrolling between chapters does not upload a single byte. That is what makes the transitions feel continuous rather than staged.

60 fps at 1920 × 1080 on integrated graphics — Intel UHD 630, no discrete GPU, sustained while scrolling at the worst case. Trigonometric turbulence instead of simplex noise, bloom at half resolution and below, and no depth buffer anywhere all bought that.

73 kB. The entire experience is one HTML file plus Three.js from a CDN.

The finale renders the site's own display face — the giant particle word is rasterised in Cormorant Garamond, the same face as the wordmark, so the ending reads as an echo rather than a third typeface showing up uninvited.

What we learned

Additive blending is an exposure problem, not a colour problem. I kept trying to fix blown-out whites by changing hues. The answer was always energy budget. sin()-based GLSL hashes are a trap. Fine in a shader toy at 512 px; moiré at 1080p. Post-processing belongs on the data it was designed for. Aberration is a lens phenomenon — on sharp point sprites it makes noise, on a blurred halo it makes the effect you wanted. Distribute particles by volume, not by length. Anywhere a shape has thickness, weighting by extent alone gives you a wireframe. Storing morph targets in a data texture is the whole trick — it removes the buffer upload that makes scroll-driven particle work feel janky. Field of view is vertical. Obvious in retrospect, invisible until a phone viewport cuts your hero in half. Look at the pixels. Every visual bug here was invisible in the source and obvious in a screenshot. Reading code cannot review a render.

What's next for ÆTHER — a cosmogony in particles

A demo video — the optional item I didn't get to, and the one thing a still cannot convey: the transitions are the piece. Test on hardware I don't own. My perf number is one integrated Intel GPU; discrete GPUs and real phones are unverified, and the touch particle budget is a guess rather than a measurement. A WebGPU compute path — the morph is already GPU-resident; moving turbulence into a compute shader opens the door to a million particles and real physics between chapters. Let the finale be your word. The last chapter rasterises text at runtime, so it could be a name someone types, with a shareable link to their own ending. Deep links per chapter. The whole journey is currently one URL. More timbre in the sound. Spark should crackle; Arbor should breathe. A readable mode. The writing is a real part of this and currently requires WebGL to reach.

Built With

Share this project:

Updates

Submission history