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
# 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