Build notes

Behind the Build

How the scroll-scrubbed stellar lifecycle on the home page actually works: one engine, nine shaders, and the budgets that keep it fast enough to ship.

Architecture — one engine, one number

Every visible piece of the hero — the cloud, the shadow, the photon ring, the supernova flash, the yellow star, the nebula, the lonely pale-blue dot — is rendered by a single instance of createScene (see src/hero/scene/createScene.ts). That engine takes one input each frame: a number called getStage, somewhere in [0, 3.5]. Everything downstream is a pure function of that number.

Where the number comes from is the only thing the rest of the codebase has to know. On the home page it is the scroll position routed through the band-walker in sceneTable.ts. On an article like this one it is the article scroll mapped into an authored window (journey). On an inline figure it is pinned to a constant. The engine cannot tell the difference, and there is exactly one engine to debug.

Mid-collapse: same engine, getStage pinned to ~1.6 (supernova → red-giant grow).

The walker logic lives in src/hero/sceneTable.ts as a flat SEGMENTS table. Each row is a timed span with a relative weight, start/end stage values, and an easing function. The prefix-sum of the weights reproduces the original breakpoints exactly. Adding a beat means adding a row; the engine never changes.

Nine GLSL files in src/hero/shaders/. Each panel below pins the engine to the stage that best showcases the shader, with the actual source toggleable underneath. The canvases are independent createScene instances mounted on intersection — the engine chunk only downloads when the figure scrolls into view.

Performance budgets

Budgets the engine is tuned against, with the last measured value next to each. Anything that changes one of these by more than a small step should be re-measured against the same conditions.

Metric Budget Last measured Note
JS initial payload (gz) ≤ 220 KB ~205 KB three.js + the engine chunk; dynamically imported so it never blocks first paint.
LCP (laptop, broadband) ≤ 1.5 s ~1.2 s The intro loader is server-rendered HTML + CSS; it paints before the engine arrives.
INP at scroll (median) ≤ 100 ms ~32 ms Scroll is sampled on rAF; HUD broadcasts gate on a PROGRESS_MIN_DELTA so React stays out of the hot path.
CLS ≤ 0.05 0.00 The canvas host has a fixed footprint and the prose lives inside a single column — no layout shift on hydration.
Render pixel ratio (high tier) ≤ 1.85× 1.85× Clamped in tuneRenderPixelRatio so a 3× DPR display never burns four times the fragments for invisible gain.
Render pixel ratio (low tier) ≤ 0.6× 0.6× Low tier renders well below native and lets the browser upscale — the cloud is fragment-bound.

And what your machine is doing right now — independent of the engine, just the browser's own rAF clock:

Tierauto
Live FPS

Fallback ladder

The same beat at four capability levels. The article only ever loses the backdrop in the bottom two rows — the prose, the HUD, and the chrome stay unchanged. None of these rows depend on the live engine to render: each is a static card describing what the system would do.

  • High tier

    The full ~1.2M-point cloud, post chain on, native DPR clamp.

    What you are reading this paragraph through — the engine running unhindered.

  • Low tier

    16k–24k points, no bloom, DPR 0.6×, capped at ~30 fps.

    Any mobile / weak GPU / ≤ 4 cores / ≤ 4 GB / no WebGL2 demotes here automatically.

  • Reduced motion

    No live engine; CSS-painted poster of the same beat.

    Respects prefers-reduced-motion. The article prose is unchanged — only the backdrop swaps.

  • No WebGL

    A flat dark room with the same dim wash CSS already provides.

    A typed WebGLUnavailableError is caught at mount; the article stays fully readable.

Design decisions

Why DPR is clamped. The cloud is fragment-bound. Doubling DPR quadruples the work the fragment shader does without changing what the eye reads. tuneRenderPixelRatio caps the ratio at 1.85× on capable laptops and 0.6× on low-tier devices, and the browser upscales.

Why the low tier caps at ~30 fps. A mobile GPU under CPU throttle cannot hold 60 fps with the cloud lit. Targeting 30 fps lets the engine spend its frame budget on getting each frame correct instead of thrashing toward a number it could never reach.

Why text is server-rendered. Astro renders every paragraph on this page to HTML at build time. With JavaScript disabled the page is still complete: the article reads top to bottom, the headings still anchor, the GLSL excerpts are still inspectable. The engine is an enhancement, not a prerequisite.

Why shaders compile behind the dive veil. A fresh WebGL program can take 50–200 ms to link on first use; the engine triggers that compile while the page is already under a white plunge overlay, so the cost lands in a frame the visitor cannot see.

Source for everything on this page lives at github.com/ilies-bel.