The hero section of melement.ink isn't a video or a static image. It's a real-time 3D corridor rendered in WebGL — and it responds to your scroll position, driving the camera deeper into the scene as you explore.
Here's how we built it, what decisions we made, and the performance tricks that keep it running at 60fps on most devices.
The concept
We wanted something that communicated depth — literally. Melement is an agency built on layers (brand, web, motion, labs), and a corridor metaphor captures that idea of moving through space, discovering what's further in.
The scene needed to:
- Load fast (under 2 seconds to first frame)
- Respond to scroll (camera depth tied to hero scroll progress)
- Degrade gracefully (static fallback if WebGL isn't available)
- Not tank the battery on mobile
Tech stack
- Three.js 0.160 — Scene graph, geometry, materials, lighting
- EffectComposer — Post-processing pipeline
- UnrealBloomPass — Glow on the edge lights and strips
- Custom ShaderPass — Chromatic aberration, vignette, and film grain
- GSAP ScrollTrigger — Maps scroll position to camera Z
The corridor geometry
The corridor is generated programmatically. Sixteen "gates" are spaced 9 units apart along the Z-axis. Each gate consists of:
- Two vertical slabs (the pillars)
- A lintel connecting them
- Glowing edge strips in alternating coral and orange
- Every fourth gate has an artwork plane mounted on the wall
``javascript`
for (let i = 0; i < GATES; i++) {
const z = -i * SPACING;
for (const sx of [-5, 5]) {
const slab = new THREE.Mesh(
new THREE.BoxGeometry(1, 7.6, 1.4), dark
);
slab.position.set(sx, 0.5, z);
corridor.add(slab);
}
}
Simple box geometry means the GPU has almost nothing to sweat over. The visual richness comes from lighting and post-processing, not polygon count.
Scroll-driven camera
GSAP's ScrollTrigger maps the hero section's scroll progress (0 → 1) to a target Z position. The camera interpolates toward that target each frame with a lerp factor of 0.08 — smooth enough to feel fluid, responsive enough to feel connected to your input.
`javascript
ScrollTrigger.create({
trigger: hero,
start: 'top top',
end: 'bottom bottom',
scrub: true,
onUpdate: (self) => { targetProg = self.progress; }
});
// In the render loop:
const targetZ = 6 + targetProg * (END - 6);
camZ += (targetZ - camZ) * 0.08;
camera.position.z = camZ;
`
Post-processing: the "film" look
Three passes run after the main render:
- UnrealBloomPass — Threshold 0.55, strength 0.85, radius 0.65. This makes the orange strips and edges glow without washing out the scene.
- Custom shader — Combines chromatic aberration (RGB channel offset that increases toward edges), a vignette (darken corners), and animated film grain.
- OutputPass — Tone mapping and colour space conversion.
The chromatic aberration also responds to scroll velocity — faster scrolling means more distortion, creating a subtle motion blur effect without actually blurring anything.
Performance budget
We set a hard rule: the scene must not drop below 55fps on a 2020 MacBook Air. To achieve this:
- Pixel ratio capped at 2 (1.5 on mobile)
- IntersectionObserver pauses rendering once the hero scrolls out of view
- Particle count reduced on viewports under 760px (260 vs 520 dust particles)
- No shadows — All lighting is direct and ambient; shadow maps would double the draw calls
- Reduced motion — If the user prefers reduced motion, the entire scene is skipped and a static gradient fallback appears
Graceful degradation
If WebGL isn't available (old devices, aggressive privacy browsers), the body gets a no-gl class. CSS takes over with a radial gradient that captures the warm tone of the scene without any JavaScript.
`css``
body.no-gl .hero {
height: 100vh;
background: radial-gradient(
120% 90% at 70% 30%,
rgba(252,142,0,.45), transparent 60%
), linear-gradient(160deg, var(--paper), var(--paper-2));
}
Lessons learned
- Post-processing is worth the budget. The bloom and aberration add 2–3ms per frame but transform the scene from "3D demo" to "cinematic."
- Scroll-linked animation needs interpolation. Direct binding feels janky. Lerping the camera position creates the illusion of physical mass.
- Always provide a fallback. Not everyone has WebGL, and that's fine. The site should still communicate the brand without it.
- Kill what you can't see. The IntersectionObserver pause is the single biggest performance win. Once the hero is offscreen, the GPU idles.
Ready to build something exceptional?
Whether it's a brand, a website, or a full digital product — we'd love to hear what you're working on.
Start a project