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 programs from 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 site is built against, with the measured value next to each. Nothing in this table is typed by hand: CI enforces the same numbers (scripts/check-bundle-budgets.mjs), and a build whose output drifts from what this page claims fails (gen-perf-report.mjs --check, ±5%).

Generated 2026-07-12 at 4d01efb by scripts/gen-perf-report.mjs — regenerated per release, not hand-written.

Metric Budget Last measured Note
Hero engine graph JS (gz) ≤ 240 KiB (target 235) 231.4 KiB three-core + three-post + the createScene chunk + the warmThree facade; dynamically imported, so it never blocks first paint.
Home pre-engine JS (gz) ≤ 100 KiB (target 96) 93.4 KiB Everything / requests before the engine import fires: island runtime, hero shell, HUD nav, loader controls.
Reading-route JS (gz, worst route) ≤ 40 KiB (target 20) 15.6 KiB Reading routes ship zero three.js (e2e-asserted); this caps everything else they load.
HTML per page (gz, worst page) ≤ 90 KiB (target 56) 52.8 KiB Stylesheets are inlined, so this one number also caps CSS growth — grain data-URIs included.
Largest shipped raster ≤ 500 KiB (target 250) 197 KiB (of 56) Every content raster goes through astro:assets; the CI gate has no grandfather list.
CLS (lab, worst route) ≤ 0.02 (CI error) 0.001 Lighthouse 2026-07-11, 5 routes (local runner). Fixed-footprint canvas host + dimensioned images.
LCP (lab, home) ≤ 2.0 s (CI warn) 1.55 s Lighthouse 2026-07-11, throttled mobile profile on a software-rendered local runner — an upper bound: the loader paints as server HTML before any engine byte arrives.

Field Core Web Vitals (real-visitor INP, LCP, CLS) are deliberately absent: they need RUM, which this site does not run yet — the wiring is documented and waiting rather than the numbers being guessed. Main-thread lab time (TBT) is also collected per release but not tabled: the available runners render WebGL in software, so that number describes the runner, not the site — it lives in perf-report.json and gates as a CI warning until real rendering hardware joins the pipeline.

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 the reduced-motion preference. 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.