Sampling the PNG
There is only one source asset: /assets/rexGreenSmall.png, a 256×256 PNG of the rex. On load the module draws it onto an offscreen canvas, reads the pixels back and keeps every one whose alpha is above a threshold, together with its colour. That gives around eleven thousand candidates. A seeded pseudo-random shuffle picks a fixed subset of them, up to 7,000 on a desktop and 3,400 on a touch device, so the cloud looks the same every visit and never costs more than the phone can afford.
Each surviving pixel becomes one point with a handful of constants: its colour, a seed, a small sub-pixel jitter (so the grid of the bitmap does not show through), a random direction on the unit sphere and two random radii. Those constants never change; everything else is derived from them at render time.
Four shapes, one shader
The points are a single THREE.Points object with a custom ShaderMaterial. The geometry carries two positions per point, position (where it is coming from) and aTo (where it is going), and a uniform uMix that blends between them. The blend is not uniform across points: each one waits for a delay taken from its seed before it starts moving, so a shape never snaps into place, it pours.
float delay = aSeed * 0.45;
float m = smoothstep(delay, delay + 0.55, uMix);
vec3 p = mix(position, aTo, m);
float arc = m * (1.0 - m); // only while in transit
p += breathing(uTime, ph) * uDrift; // idle sway
p += swirl(ph, uTime) * arc * uSwirl; // curls along the way
p += buzz(uTime, ph) * uExcite * 7.0; // while the rex is held
Four target shapes are built once, and rebuilt on resize because the rex is sized to its box in the hero:
- cloud: a sphere slightly larger than the rex, with a cube-root radius so the volume is filled evenly rather than bunched at the centre.
- rex: the sampled pixel positions, centred, scaled, with a little depth from the jitter so it reads as a slab rather than a sheet.
- dot: the same points inside a sphere of radius 1.6. From a distance that is one bright point.
- burst: a shell two to five rex-widths out, stretched along the depth axis so it flies past the camera.
A "segment" is a pair of shapes. Switching segment copies the pair into the two attribute buffers and flags them for upload, which happens three times over the whole story: cloud→rex, rex→dot, dot→burst. The GPU does all of the actual moving.
The scroll drives it
The particle module never looks at the scroll position itself. The landing page's GSAP orchestration writes three numbers into a shared object, window.rexHero, and the module reads them every frame:
t, from 0 at the top of the page to 1 when the statement paragraph is centred. A ScrollTrigger with no tweens attached produces it.burst, from 0 to 1 across the stretch where the statement is pinned.excite, 1 while the rex is being held down (the same long-press that opens the easter egg).
The frame loop eases towards those targets rather than using them raw, so a fast wheel flick still produces a smooth move. Then it decides which segment and mix to use:
- loadThe intro: cloud→rex over 2.2 seconds with a cubic ease-out, independent of scrolling.
- t 0 → 0.5The whole group lerps from its spot in the hero to the centre of the screen and grows about fifteen percent. The rotation follows the pointer, a slow idle sway, and the scroll.
- t 0.6 → 1rex→dot. Point size grows as they crowd together so the dot stays bright, and the breathing fades out.
- burst 0 → 0.12The dot charges: point size swells while nothing moves yet.
- burst 0.1 → 1dot→burst with extra swirl and spin, opacity fading over the last third. A CSS flash fires underneath from the same timeline.
The camera is set up so one world unit is one CSS pixel on the z = 0 plane, which makes "put the rex where the hero box is" a matter of reading a getBoundingClientRect(). When the burst finishes the render loop stops entirely. Scrolling back up starts it again; a hidden tab stops it too.
When it cannot run
The plain PNG is always in the document, hidden only while the point cloud is alive. The module fails on purpose when WebGL is missing, when the visitor prefers reduced motion, when the browser reports a data-saver connection, or when the Three.js module never arrives from the CDN. Each of those dispatches rexhero:failed and the PNG fades in; if nothing at all reports back within six seconds the page shows the PNG anyway. The first successful frame dispatches rexhero:ready.
The shader also takes a uTint uniform. The sampled greens are lifted for dark surfaces, so on the light theme the cloud is multiplied down a little to keep its contrast, and it follows the theme toggle live.
Two lessons
ScrollTrigger pins must be created in page order. The balloon sky and the phone track are both pinned sections, and a pin changes the position of everything below it. Creating a trigger for a lower section before an upper one measures against the wrong layout. Every trigger on the page is therefore created inside one gsap.matchMedia() callback, top to bottom, and the reveal animations come last.
Three.js is the heaviest thing on the page. Two module files, about 186 KB over the wire, for what is ultimately one draw call of points with a custom shader. A raw WebGL program would do it in a few kilobytes. It stays for now because the effect is the best thing on the page and the rest of the page is light, but it is the first thing to go if the load number ever matters.