Skip to content
Home

2026, solo, end to endNext.js · React Three Fiber · GLSL · WebAudio

Éclosion

A 3D web experience where scrolling is time: eight acts, from the Void to the Dawn, travelled with the wheel, from a seed's first beat to the world's first sunrise.

One hundred per cent procedural.

No 3D models, no textures, no audio files, no external requests at runtime. Geometry, materials (noise and FBM in GLSL), over a hundred thousand particles and the music itself (pure WebAudio synthesis) are produced by the code. One deliberate exception: the typeface, committed to the repository.

Roughly 11,600 lines across 195 files. Next.js 15, React 19, strict TypeScript, Three.js and React Three Fiber, GSAP ScrollTrigger, Lenis, zustand, WebAudio.

The dawn of Éclosion: the sun grazes the horizon above procedural terrain.
The Dawn, final act. The solar disc ignores the fog so it can clear the bloom threshold.

Technical decisions

Three decisions that shape everything

The scroll contract: zero React renders at 60 frames per second

One smoothing layer (Lenis), one master ScrollTrigger scrubbing a GSAP timeline of duration 1, and a registry of mutable uniforms that the tweens write and the renderer reads every frame, never through a React render. React only wakes up on act changes. The DOM text follows through a single MotionValue, also outside React.

One world, one truth

An analytic function groundHeight(x, z) generates the visible terrain, constrains walking in free-roam, bounces the physics and bounds the birds' flight. What you see is exactly what you touch. Same logic for the camera: a single continuous CatmullRom curve, each act travelling to the first point of the next. No cut across the whole film.

Stateless particles

Every behaviour (rain, curl-noise fireflies, embers, the final vortex) is a closed function of (seed, time) evaluated in the vertex shader. No simulation to maintain, no GPU round trip. Buffers are allocated once at the highest quality level; changing tier only moves a setDrawRange.

Measurements

The engineering, in numbers

Integrated-GPU bench forced to the highest tier, the worst measurable case.
MeasurementBeforeAfter
First-visit payload422 Kio342 Kio
Worst frame at act boundaries617 ms167 ms
Forest trees, at equal frame cost420630
Lighthouse : accessibility, best practices, SEO100 / 100 / 100

The 19%

The italic font, 80 KB preloaded at high priority, was rendered nowhere. A single occurrence of the word "italic" in the entire codebase: its own declaration.

The 617 ms

Three stalls stacked at every act boundary, untangled with the CPU profiler and GL hooks placed on linkProgram:

  • the cache-key trap: post-processing renders the scene under its own renderer state, so all shader warm-up was compiling programs the real render never used, and the first visible frame relinked everything;
  • the link wait: an act would draw as soon as the camera swept it, ready or not;
  • the driver's deferred reflection: even linked, a program pays for its first introspection, about 180 ms each on an integrated GPU.

The answer: each act mounts invisible, compiles under the composer's state, gets one program introspected per frame, then reveals itself, all of it covered by the scene's fog. No visual change, smooth boundaries.

Nothing gets fixed without being seen.

A CDP harness drives a real Chrome: enter the experience, jump to a precise beat, capture, before and after, for every rendering change. Speed-linked effects have to be captured mid-scroll; a still capture shows nothing. The GPU tier is forced by injecting the renderer string.

The sun that wouldn't ignite

The dawn disc rendered grey. Exponential fog was eating 99.5% of it at that distance, and its luminance ceiling stayed below the bloom threshold: "bright enough to bloom", said the comment; it had never been true. The fix: the sun ignores the fog (the moon re-emulates it identically in JavaScript) and an ignition curve clears the threshold: ember below the horizon, gold at grazing, cream at the zenith.

The ocean's lines

One symptom, "white streaks", turned out to be five stacked defects, each masking the next: motion-blur ghosting, light-shaft edges, the night sky dome visible underwater, the Gerstner sheet's edge, an overlit floor. The method that finally won: mask components one by one to isolate the source before touching a single shader.

The Void: constellations traced on an empty sky.
The Void, generated constellations, no horizon.
The Storm: dense rain and lightning above the terrain.
The Storm, captured mid-scroll, the only way to see the speed blur.
The Forest: hundreds of trees generated as instances.
The Forest, 630 trees across five InstancedMesh, at unchanged frame cost.
The Ocean: a Gerstner sheet crossed by light shafts.
The Ocean, a Gerstner sheet and light shafts below the surface.

Quality and accessibility

Holding quality over time

  • Continuous integration on every push: strict linting (the any type is banned), strict TypeScript, 16 unit tests (act tiling, seeded pseudo-random generator, analytic ground, FR/EN text parity), build.
  • One end-to-end test plays the whole film (boot, entry, every act, the emergence from the water, the fallback, in both languages) and requires zero console errors. It caught a real race the day it went live: a dispose during a shader link.
  • Without WebGL2: a fallback page serves the complete narrative as server-rendered HTML, without JavaScript, in both languages.
  • Reduced motion: native scrolling, particles at 4%, no shake, sound off. aria-live announcements on each act, full keyboard navigation, visible focus throughout.

Next