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-motionisn't a toggle you sprinkle in later; it reshapes the entire architecture. The journey hook returnsdamped = rawwhen 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+XRControllerwould 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
- github
- githubpages
- googlefonts
- jura
- lenis
- lucide
- oxlint
- pnpm
- react
- tailwind
- three.js
- typescript
- vite
- webgl2
- zustand
Log in or sign up for Devpost to join the conversation.