GENESIS.EXE — Project Story


Inspiration

The universe is 13.8 billion years old. That number is too large for human intuition. Every science documentary tries to compress it: "If the age of the universe were a calendar year, humans appear at 11:59 PM on December 31st." Helpful, but abstract.

I wanted something visceral. A direct mapping: your scroll position is cosmic time. One continuous camera move from the Big Bang to the present. No cuts. No loading screens. Just you, pulling the universe into existence with your finger.

The hackathon theme — 3D Websites — was the perfect excuse. The web has matured: React Three Fiber, WebGL 2, postprocessing pipelines, and compute-capable GPUs are now baseline. A cinematic, scroll-driven 3D experience is finally practical as a static site — zero backend, deployable anywhere.


What I Learned

1. Scroll ≠ Time is a UX Problem, Not a Math Problem

Mapping scroll to a 0–1 parameter is trivial. Making it feel right is not.

  • Damping is mandatory. Raw scroll is jittery. A critically damped spring (useDampedProgress) turns the browser's discrete scroll events into a continuous, buttery timeline. The camera follows the user — it never fights them.
  • Reduced motion is a first-class citizen. prefers-reduced-motion isn't a toggle you sprinkle in later; it reshapes the entire architecture. The journey hook returns damped = raw when reduced, bypassing the spring entirely. No animation, no auto-scroll, no parallax drift.
  • The "boot" problem. WebGL initialization takes 300–800 ms. Users stare at a black canvas. Solution: a DOM-only CRT terminal boot sequence (BootScreen.tsx) that streams fake initialization lines while assets load in the background. An "Enter Anyway" escape hatch appears at 8 s if something stalls. The canvas only mounts after the boot gate opens.

2. Per-Frame Values Must Never Touch React State

This is the single most important architectural rule in the codebase.

// BAD — triggers re-render every frame
const [progress, setProgress] = useState(0)
useFrame(() => setProgress(scrollY / maxScroll))

// GOOD — ref + zustand, zero React renders
const journey = useDampedProgress() // returns { raw, damped } via ref
useFrame(() => { camera.position.copy(pointAt(journey.damped)) })

The journey object is a plain mutable ref updated in the R3F render loop. Components read journey.damped directly — no subscriptions, no context, no re-renders. The same pattern applies to the quality governor, ignition flash envelope, and postprocessing grades. 60 fps is a budget; React renders are expensive.

3. Quality Tiers Are a Render-Resolution Ladder, Not a Feature Toggle

Most "quality settings" swap shaders or disable effects. That causes visible pops. Instead, I built a DPR ladder:

Tier DPR Range Star Count Postprocessing
Low 0.6–0.9 6,000 Bloom only + CSS grain
Medium 0.85–1.0 12,000 Bloom + Vignette
High 1.0–1.25 24,000 Full stack

The QualityGovernor runs inside useFrame, samples FPS over 1 s windows, and steps tiers imperatively (direct Zustand store writes). Downgrade is instant (one bad second → step down). Upgrade is slow (three consecutive good seconds → step up). A 2.5 s cooldown prevents flapping. The device heuristic (navigator.hardwareConcurrency, pointer: coarse) caps mobile/low-core devices at Medium. No user settings, no pop, no re-render.

4. Epoch Transitions Are Color Grades, Not Scene Swaps

Nine epochs. Each carries a grade: fog color/near/far, tone-mapping exposure, bloom strength. The EpochDirector blends grades across a 6% transition band centered on each boundary using smoothstep. The scene fog, background, exposure, and bloom intensity all interpolate together — a single continuous "look" from T=0 to NOW. No hard cuts.

const k = smoothstep(boundary - halfBand, boundary + halfBand, progress)
fog.color.lerp(colorA, colorB, k)
fog.near = lerp(nearA, nearB, k)
exposure = lerp(expA, expB, k) + ignitionFlash.boost

5. Custom Shaders Beat Three.js Materials for "Hero" Moments

The Event Horizon accretion disk is a single ShaderMaterial with a custom fragment shader implementing:

  • Doppler beaming: material orbiting toward the camera burns brighter (pow(0.5 + 0.5 * sin(ang), 2.0))
  • Orbital shear noise: layered noise in (angle, radius) space simulates turbulent streams
  • Additive blending + depthWrite=false: the disk glows through the starfield behind it

The photon ring is a billboarded sprite with a hand-drawn radial gradient texture. Total: ~110 lines of GLSL + JS, zero external dependencies.


How I Built It

Tech Stack (Intentionally Minimal)

Layer Choice Why
Framework React 19 + Vite 8 Fast HMR, modern React, tiny bundle
3D Three.js r185 + R3F 9 + Drei 10 Declarative WebGL, battle-tested
Postprocessing @react-three/postprocessing + postprocessing 6.x Composable EffectComposer, Bloom/Vignette
Scroll Lenis 1.3 Smooth scroll, works with R3F frame loop
State Zustand 5 Tiny, imperative, works outside React
Math maath + Three.js built-ins Easing, damping, noise, no extra deps
Styling Tailwind CSS v4 Utility-first, zero config, design tokens in CSS
Lint Oxlint 10–50× faster than ESLint, zero config
Audio Native <audio> + lazy load Opt-in, no Web Audio API complexity

No backend. No database. No auth. No API keys. The entire site is a static bundle (pnpm build → dist/). Deployed to GitHub Pages via Actions.

Architecture Overview

Browser
 └─ React 19 root (main.tsx)
     ├─ "/"        → App.tsx (scroll journey)
     └─ "/observatory" → Observatory.tsx (free explore)
     ├─ App
     │   ├─ <Canvas> (R3F, aria-hidden, tier-clamped DPR)
     │   │    └─ Scene
     │   │         ├─ CameraRig        — damped scroll→spline follower
     │   │         ├─ EpochDirector    — mounts ≤2 epochs, applies grades
     │   │         ├─ epochs/*         — self-contained scene per epoch
     │   │         ├─ Particles/Nebulae/Ignition/SpacetimeWarp
     │   │         └─ Effects          — EffectComposer: Bloom, Vignette
     │   ├─ Hud (DOM overlay): BootScreen, CosmicClock, EpochLabel, FactCard, AudioToggle, FinalePlaque
     │   └─ QualityGovernor — rolling-FPS watchdog, downgrades tier imperatively
     └─ State
          ├─ zustand store (UI state only: booted/ignited/audio/tier)
          └─ journey ref (per-frame values bypass React re-renders)

Camera Path = Narrative Structure

The camera follows a Catmull-Rom spline (cameraPath.ts) with 10 control points — one per epoch zone plus a finale crane rise. The spline is the "spine" of the experience. Every epoch scene anchors its content at pointAt(u) so visual set pieces align perfectly with the camera's arrival.

const POINTS: XYZ[] = [
  [0, 0, 10],      // hero · singularity
  [5, -2, -70],    // inflation
  [-7, 3, -120],   // quark soup
  [8, -4, -165],   // first light
  [-10, 3, -225],  // cosmic dawn
  [12, -5, -295],  // galaxy era weave
  [-11, 5, -355],  // stellar forge
  [9, -3, -405],   // event horizon
  [0, 2, -470],    // solar system approach
  [0, 7, -520],    // finale crane rise
]

Lateral weave at Galaxy Era (points 12→13→14) creates a gentle banking turn — the camera "flies through" the forming spiral arms. The finale crane rise (point 17→18) lifts the camera above the solar system for the closing plaque.

Epoch Scenes — Each a Self-Contained Visual Experiment

Epoch Technique Highlight
Singularity Pulsing point + bloom spike The "hero frame" — white hot, minimal
Inflation SpacetimeWarp — radial line field Streaks brighten only in the inflation window (0.08–0.16)
Quark Soup High-temp particle plasma Color-shifted additive particles, intense bloom
First Light CMB sphere + clearing fog Fog far animates from 110→170 as universe becomes transparent
Cosmic Dawn First stars as bright halos Pop-in timed to scroll, warm color temp
Galaxy Era Procedural spiral arms maath noise + rotation, particles follow density waves
Stellar Forge Supernova remnant + ejecta Expanding shell shader, heavy element color palette
Event Horizon Custom accretion disk shader Doppler beaming + orbital shear noise (see above)
You Are Here Textured Solar System Real planet textures (Solar System Scope, CC-BY 4.0), OrbitControls in Observatory mode

Observatory Mode — The "Free Camera" Bonus

/observatory mounts the same epoch scenes but swaps CameraRig for OrbitControls. Each epoch is a standalone 3D diorama you can rotate/zoom. No scroll, no timeline — just exploration. Same grades, same postprocessing, same quality tiers. Zero code duplication — the epoch components are pure presentational; the director and camera are the only things that change.


Challenges Faced

1. The "Black Canvas on Load" Problem

Problem: Three.js + R3F + postprocessing + textures = 400–800 ms before first frame. Users see nothing, think it's broken.

Solution: BootScreen.tsx — a pure DOM terminal animation that runs in parallel with asset loading. It uses @react-three/drei's useProgress() for real loading progress, but also streams fake "initialization lines" on a 420 ms timer so it feels alive even if assets are cached. Minimum 900 ms hold after lines finish ensures the boot sequence is readable. "Enter Anyway" button at 8 s is the escape hatch.

2. Mobile Performance — The Fill Rate Wall

Problem: Bloom at full resolution on mobile GPUs (Adreno / Mali) tanks FPS to 15–20. Starfield particles (24k) + bloom + vignette = fill rate saturation.

Solution: The tiered DPR system. On mobile (pointer: coarse || hardwareConcurrency ≤ 4), the ceiling is Medium (DPR 0.85–1.0, 12k stars). The governor starts at Medium on all devices, then upgrades capable desktops to High after 3 good seconds. This means:

  • First-time mobile users never see a frame drop
  • Desktop users get the full fidelity automatically
  • No user-facing settings, no "low/medium/high" dropdown

3. Safari / iOS WebGL Context Loss

Problem: iOS Safari aggressively kills WebGL contexts on memory pressure or tab backgrounding. The canvas goes black permanently.

Solution: CanvasErrorBoundary + WebGLContextLossGuard in App.tsx. The guard listens for webglcontextlost / webglcontextrestored, forces a remount with a 2 s debounce, and shows a toast ("Restoring simulation…"). The journey ref survives remounts because it lives outside React state.

4. Audio Autoplay Policy

Problem: Browsers block autoplay. The ambient nebula soundscape (carina-nebula.mp3, 2.3 MB) must be opt-in.

Solution: AudioToggle.tsx — a single button in the HUD. Click → audio.play() → button state flips to "Mute". The audio element is lazy-created on first click (no download until opted in). prefers-reduced-motion also disables audio by default.

5. The "One Scene Mounted at a Time" Myth

Problem: Early prototypes mounted all 9 epochs simultaneously. Draw calls: 400+. Triangles: 800k+. FPS: 20 on desktop.

Solution: EpochDirector mounts at most 2 epochs — the current one and the next (within ±0.12 progress). Scenes outside the window unmount completely. This is enforced by conditional rendering in Scene.tsx:

{EPOCHS.map((epoch) => {
  const inWindow = Math.abs(progress - epoch.scrollStart) < 0.12 || epoch === current
  return inWindow && <EpochComponent key={epoch.id} />
})}

Result: ≤120 draw calls typical, ≤200 peak. 350k triangles max.

6. Accessibility Without Compromising the Vision

Problem: A WebGL canvas is a black box to screen readers. aria-hidden="true" on the canvas is necessary but insufficient.

Solution:

  • All narrative content lives in the DOM HUD: CosmicClock (live T+ readout), EpochLabel, FactCards (clickable cosmology facts), FinalePlaque. The canvas is purely visual.
  • Keyboard navigation: Every interactive element (AudioToggle, FactCard close, Rewind button, Enter Anyway) is focusable with visible focus styles.
  • Touch targets ≥ 44 px: Verified on mobile.
  • Reduced motion: Disables damping, auto-scroll, parallax drift, ignition flash, ring expansion, particle twinkle — but keeps the scroll→time mapping functional.

The Math Behind the Magic

Damped Progress (Critical Damping)

// useDampedProgress.ts — simplified
const STIFFNESS = 180
const DAMPING = 24 // critical: 2 * sqrt(STIFFNESS) ≈ 26.8, slightly underdamped for feel

function step(dt: number) {
  const x = raw - damped
  const v = velocity
  const a = -STIFFNESS * x - DAMPING * v
  velocity += a * dt
  damped += velocity * dt
}

This runs in the R3F frame loop (not React), so damped is a smooth C²-continuous function even when raw jumps (trackpad, scrollbar drag, keyboard).

Smoothstep for Grade Blending

$$ \text{smoothstep}(a, b, x) = t^2(3 - 2t), \quad t = \text{clamp}\left(\frac{x - a}{b - a}, 0, 1\right) $$

Used in EpochDirector for every grade parameter (fog color, near/far, exposure, bloom). The 6% transition band (TRANSITION_BAND = 0.06) means epochs cross-fade over ~6% of the total scroll distance — long enough to be imperceptible, short enough to feel like a distinct "chapter."

Doppler Beaming in the Accretion Disk Shader

$$ I(\theta) = I_0 \left[ 0.35 + 1.25 \cdot \sin^2(\theta) \right] $$

where $\theta$ is the azimuthal angle around the black hole. Material orbiting toward the camera ($\theta \approx \pi/2$) gets a 1.6× brightness boost; material orbiting away ($\theta \approx 3\pi/2$) drops to 0.35×. This is the same physics that makes relativistic jets appear one-sided.

Orbital Shear Noise

$$ n(\mathbf{p}) = \text{noise}(\mathbf{p}) \cdot 0.6 + \text{noise}(2.3\mathbf{p}) \cdot 0.4 $$ $$ \mathbf{p} = \left( 6\theta - 0.9t / \max(r, 0.32), \; 22r \right) $$

The 1/r time scaling means inner orbits rotate faster — Keplerian shear. The noise domain stretches with radius, creating streaky turbulent structures.


What's Next

  • WebGPU migration: Compute shaders for particle simulation (currently CPU-side in useFrame). Would unlock 100k+ stars at 60 fps on integrated graphics.
  • Spatial audio: Web Audio API panner tied to camera position — hear the supernova as you pass it.
  • Localization: The HUD text (epoch labels, fact cards, finale plaque) is in data/ — ready for i18n.
  • WebXR mode: The observatory camera rig is already OrbitControls; a VRButton + XRController would make it a seated VR experience with minimal changes.

Final Thought

Building GENESIS.EXE taught me that performance is a design constraint, not an optimization pass. Every visual decision — the particle count, the postprocessing chain, the shader complexity, the mount window — was made with the 60 fps budget in mind. The result is a site that runs on a 2018 MacBook Air and a 2024 RTX 4090, degrades gracefully, and never asks the user to choose "quality."

The universe is 13.8 billion years old. You just scrolled through it.


Built for the 3D Websites Hackathon · August 2026

Built With

Share this project:

Updates

Submission history