Skip to content

Demo 03 · Lab

The event horizon

A ray-traced, gravitationally-lensed black hole. The arch above and the bowl below are one disk, seen along bent light.

Use this when

Your hero carries exactly one message and can afford a centrepiece with real structure.

Skip it if: Your hero contains product UI. The object will fight the screenshot.

The prompt

The event horizon prompt
# Recreate the Event Horizon glow: paste everything below into a fresh session

This prompt rebuilds the black-hole hero glow from this project exactly: a
real-time, ray-traced, gravitationally-lensed black hole with flowing streaks,
as a single self-contained React component. Every number in it was measured or
hard-won; follow them literally, and treat the **traps** as law. Each one cost
real debugging time.

---

Build me a real-time WebGL black-hole glow as a React client component
`GlowCanvas.tsx` with this exact contract: `<GlowCanvas onReady={fn} />`,
rendering into a `<canvas style="width:100%; aspect-ratio:16/9; display:block">`.
The parent fades the canvas in when `onReady` fires after the first rendered
frame. No libraries: raw WebGL2, two shader passes. No video files, no images:
everything procedural.

## The one method that works

**Simulate the scene; do not paint the picture.** A 2D shader that draws the
parts as separate shapes (ring + arch + bowl + wings summed) WILL fail at page
scale: seams between the parts read as an "eye", and without real bloom it
looks like flat airbrushed art, even if every sampled pixel matches. The arch
above the hole and the bowl below it must EMERGE from light bending, because
they are the far side of one disk seen along curved rays.

### Pass 1: HDR scene (render to a float texture at 0.62× canvas resolution)

Units: Schwarzschild radius = 1. Camera at distance 30, elevated 0.068 rad
(~3.9°) above the disk plane, looking at the hole. Screen mapping: work in
fractions of frame height, hole center at `(0.5, 0.508)`, **note
`gl_FragCoord` is bottom-up; 0.508 from the bottom = 49.2% from the top**.
Focal length: the shadow rim (impact parameter b = 3√3/2 ≈ 2.5981) must land at
radius **R = 0.1019 × frame height**: `sinθ = 2.5981·√(1−1/30)/30`,
`focal = R·cosθ/sinθ` ≈ 1.19.

Per pixel: cast a ray, fast-forward analytically to a sphere of radius 15.5,
then march ≤150 steps with `dt = clamp((r>6 ? 0.11 : 0.06)·r, 0.035, 1.1)`
(fine near the hole, coarse far out where rays are nearly straight), bending the
velocity by the cheap geodesic `a = −1.5·h²·p/r⁵` (h² = |p×v|² computed once at
entry, it is conserved). Capture at r < 1.02 (add interior fog 0.05, the
shadow is violet, never black); escape when r > 15.5 moving outward.

The accretion disk is the equatorial plane, radii 1.15 → 14. Whenever a step
crosses the plane (`prev.y · pos.y < 0`), interpolate the crossing point and
add surface emission:

- Radial profile: `smoothstep(1.15, 1.55, rc) · exp(−max(rc−3.4,0)·0.30) ·
  (1 − smoothstep(12, 14, rc))`, times an inner-edge blaze
  `1 + 3.6·exp(−(rc−1.15)·1.4)`, this blaze is what fuses the white crown.
- Doppler beaming: orbital direction chosen so the LEFT arm approaches;
  `β = max(√(0.5/rc), 0.30)` (the floor keeps the left/right asymmetry visible
  far out, the reference's left arm is ~2× the right), boost = `1/(1−0.68·β·μ)`
  cubed, where μ = orbital velocity · direction-to-camera (use the ray's
  CURRENT direction, it has bent).
- Also accumulate a faint volumetric halo each step hugging the plane
  (`exp(−y²·1.3)`, gain 0.035/unit length) for the wings and dome.

**Empirical shaping, keyed to crossing kinematics, not screen position** (the
reference look demands it, and screen-position gates are unreachable from
inside the shader anyway):

- Crossings that pierce the plane STEEPLY behind the hole (|dir.y| > 0.45)
  form the white arch crest, leave them at full strength.
- SHALLOW behind-crossings (|dir.y| 0.10–0.45) land on the upper diagonals,
  which the reference keeps as darker violet grooves, multiply by 0.35.
- Front-side crossings near the center (behind-ness by z, within ~2 of the
  axis) get `mix(0.35, 0.75, smoothstep(1.8, 6.0, rc))`, this preserves the
  dark pocket between the near-side line and the crown.
- Upward crossings (ray piercing from below = the lensed underside/bowl):
  gate to `0.55·smoothstep(2.0, 2.6, rc)·(1 − 0.55·smoothstep(2.8, 4.5, rc))`.
  Without the rc > 2 gate, plunging rays paint a bright skirt right under the
  near-side line where the reference is dark.

### Motion: the part everyone gets wrong twice

The disk must visibly FLOW: thin streaks orbiting the hole, sliding along the
lensed arch, radiating outward, at constant pace forever.

- **Trap 1: soft noise moves invisibly.** fbm blobs drifting read as a static
  glow; bloom and the tone curve flatten them. The reference's motion, seen in
  an amplified frame-difference image, is thin coherent RIBBONS. Build the
  texture as `rib = cos³(2π·(rc·3.2 − 0.28·t + 3·n))`, tight radial bands
  warped by the turbulence field `n`, plus `n²` for large-scale patchiness:
  emission factor `0.22 + 1.7·TURB·n² + 1.15·rib·(0.35 + 0.9·n)`, TURB ≈ 1.35.
- `n` = one octave-4 value-noise fbm sampled on `(cos φ′·3, sin φ′·3, rc·2.4 −
  drift)`, the (cos, sin) domain makes it seamless around the circle.
- Differential rotation: angular speed `ω = 2.8·rc^−1.5` (inner streaks
  overtake outer ones, this shearing IS the "light flowing around" look).
- **Trap 2: differential advection winds up and dies.** `φ − ω(rc)·t` coils
  the pattern ever tighter; within ~30 s the streaks shear below pixel size and
  the motion visibly grinds to a halt (it looks perfect at t=0, which is why
  naive checks miss it). Fix: carry the pattern with a RIGID spin (0.86 rad/s,
  never winds) and run only the differential residual `ω − 0.86` on two
  half-offset bounded ages (`(fract(t/7)−0.5)·7` and the +0.5 phase),
  crossfaded with complementary triangle weights (flow-map trick). Fold the
  outward radial drift (0.35/s) into the same bounded ages. Give the two
  phases decorrelated noise domains (offset one by +7.31). Pace at t=500 s
  must equal pace at t=5 s.
- Add ±5% slow global breathing (value noise over t·0.35).

### Pass 2: bloom and grade (bloom is NOT optional; the look is mostly bloom)

Render pass 1 into RGBA16F (`EXT_color_buffer_float`, else
`EXT_color_buffer_half_float`, else RGBA8 with intensities ÷12, probe FBO
completeness before committing to float). Store intensity in R and
Doppler-weighted intensity in G (accumulate `e·(μ·0.5+0.5)` so it survives an
unsigned fallback).

Bloom: 4 progressively-downsampled 13-tap separable gaussian octaves (½, ¼,
⅛, 1/16 of scene res, σ=3 texels), each blurring the previous octave's output.
Composite: `hdr = scene + b1·0.30 + b2·0.45 + b3·0.70 + b4·0.70`, tone-map
`tone = 1 − exp(−1.02·I)`, then this exact measured ramp (intensity high→low):
`(252,252,252) → (238,176,255) → (150,85,252) → (80,38,190) → (30,12,90) →
(2,0,16)` with smoothstep blends at t ≈ 1.0/0.78/0.55/0.32/0.12/0. Doppler
tint: shift = `2·G/R − 1`, scale mid-tones by `1 + 0.10·shift·tone(1−tone)·4·
(−0.6, −0.05, 0.5)` (approaching side blue-white). Finish with ±1/255
animated hash grain, without it the long violet gradients band on real
displays.

### Component behavior

- IntersectionObserver (threshold 0.2) pauses the rAF loop off-screen.
- `prefers-reduced-motion` → render exactly one frame (fixed t), still fire
  `onReady`.
- Resolution is adaptive, and the probe design matters. Start at DPR 2 (retina
  sharpness; a hard 1.35 cap renders the scene at 0.84 CSS px and reads soft).
  Watch real frames and fall back to a 1.35 cap only if the GPU genuinely
  can't hold rate, but three things masquerade as slow frames to a naive
  `gap > 20ms` check and will lock fast machines to the soft cap: page-load
  jank (start the loop, and the probe, only once the canvas is on screen, then
  discard ~30 warm-up frames), background-tab/battery-saver throttling (ignore
  frames while `document.hidden` and any gap over ~150ms), and the throttled
  cadence itself (collect ~45 gaps and judge each against
  `max(20ms, min(gaps) × 1.4)`, the minimum gap is the cadence this
  environment actually delivers, so a 30fps-throttled tab is its own normal).
  Recreate all render targets on resize and on a cap change.
- **Trap 4: no `backdrop-filter` anywhere over the canvas.** It cannot sample
  a separately-composited WebGL layer (so it looks like nothing), yet the
  compositor still copies-and-blurs behind the element every canvas frame.
  A page decoration carrying one janked the sibling CSS star animations on
  loaded GPUs while contributing zero visible blur.
- Log shader compile/link errors to the console; render nothing on failure.
- **Trap 3: StrictMode kills the context.** Dev React runs mount → cleanup →
  mount on the SAME canvas node. If cleanup calls
  `WEBGL_lose_context.loseContext()` synchronously, the remount inherits a
  dead context, every compile fails with a null info log, and the canvas
  vanishes, but ONLY on client-side navigation, so you'll miss it if you only
  test hard refreshes. Cleanup must `setTimeout(loseContext, 0)` and the next
  mount must cancel the pending timer; also bail out if
  `gl.isContextLost()` at mount.

### The full composition (what the live demo layers over the glow)

The shader is the centrepiece, but the published demo is three layers, all
plain CSS/DOM above the canvas. This is what makes it read as a place rather
than a picture. Sizes are fractions of stage HEIGHT so the composition scales
with its container (the shader already anchors the hole to frame height):

- **Rings**: three concentric circles at 99.75% / 75.06% / 51.36% of stage
  height, 1px `rgba(186,156,255,0.3)` borders, only their top arcs visible via
  a vertical mask fading out by ~30%. Outer two rotate over 100s, innermost
  holds still; eight 6px dots sit at each ring's 45° stations. Hide the rings
  below ~1248px viewport width.
- **In-falling stars**: ~100 two-pixel stars in a centred square field 86.4%
  of stage height, positions from a seeded PRNG (same field every mount). Each
  lights up, then over 7–14s translates exactly 0.9 × (field centre − its own
  position) while shrinking to scale(0.5), falling 90% of the way into the
  hole at constant rate before the loop restarts it. The field counter-rotates
  over 70s, and a ring-shaped radial mask hides stars at the centre and edge.
- **Edge fade**: the stage (canvas + rings + stars together) carries
  `mask-image: radial-gradient(50% 50% at 50% 50%, #fff 60.94%, transparent
  100%)`, the scene has no hard rectangle edge against the page.
- All of it pauses off-screen (IntersectionObserver toggling a class) and
  disables under `prefers-reduced-motion`. Trap 4 applies to every dot: no
  backdrop-filter anywhere over the canvas.

### GLSL safety (silent-failure class: the layer just vanishes)

No `atan(0,0)` (guard the denominator), no `pow` with a possibly-negative base
(square by multiplication), no reversed-edge `smoothstep`, no GLSL reserved
words as variable names. Sweep the browser console after EVERY shader edit.

## Verification bar (do these in this order; each caught real failures)

1. **Gestalt first**: record the page headlessly in a real Chromium (a
   backgrounded preview pane pauses rAF and lies about WebGL), full page at
   1440×900. Judge the WHOLE image: one continuous blazing mass, no eye, no
   seams, no banding. Only then compare center crops and probe pixel values.
2. **Motion against the reference, not against zero.** "Frame diff shows
   change" passes while a human sees stillness. Measure mean |frame diff| over
   1 s on the reference and hit ≥⅔ of it (reference ≈ 3.2/255 per channel at
   1440 page scale). Make an amplified difference image
   (`blend=difference`, ×8): it must show coherent streamlines along the arch,
   line, and bowl, not speckle.
3. **Pace endurance**: motion metric at t≈38 s must equal t≈8 s (winding trap).
4. **Navigation**: click through from another page (client-side nav), not just
   refresh (StrictMode trap).
5. **Debug rule for any raymarched feature**: when a screen region is wrong,
   don't guess where its rays cross the scene, port the marcher to a 20-line
   Node script and TRACE that pixel (print every crossing's position, radius,
   and |dir.y|). Guessed world-space gates failed three rounds straight; one
   trace found the real discriminator immediately.
6. rAF ≥ 55 fps on an M-series; `pnpm build` and `pnpm lint` clean.
7. **Smoothness is judged on real hardware, not headless.** A headless probe
   read flat 8 ms frames while a real, loaded GPU janked the sibling CSS
   animations, headless passing proves nothing about compositor pressure.
   Keep the GPU budget lean (scene scale, far-field steps, DPR cap, no
   backdrop-filter anywhere over the canvas) and have a human confirm on the
   machine that showed the stutter.

Performance notes

Technique
WebGL2, ray-traced
Render passes
Two — the scene, then its glow
JavaScript
18.1 KB
Effect CSS
3.9 KB
Over the wire
8.4 KB gzipped
Dependencies
None
Device pixel ratio
Adaptive, 2 → 1.35
Off-screen
Render loop paused
Reduced motion
Single static frame
Hidden tab
Render loop paused