singularity-rs/docs/superpowers/specs/2026-07-09-interstellar-blackhole-design.md
xfy 041e8f3b21 docs(spec): correct Bevy 0.19 API details (verified)
- Material2d bind group is group 2 (#{MATERIAL_BIND_GROUP}), not group 1
- RenderPlugin has no wgpu_backends field; enable bevy 'webgpu' feature instead
- WgpuSettings::default() auto-selects BROWSER_WEBGPU on wasm32
- Imports from bevy::sprite_render (Material2d, AlphaMode2d)
- Component-based spawning (Camera2d, Mesh2d/MeshMaterial2d), no bundles
- getrandom 0.4/wasm_js handled transitively by Bevy
2026-07-09 18:47:44 +08:00

21 KiB
Raw Permalink Blame History

singularity-rs — Interstellar-style Black Hole Renderer (Design Spec)

Date: 2026-07-09 Status: Draft (awaiting user review) Project: singularity-rs (Rust, edition 2024, Bevy 0.19) — desktop + web (WebGPU)

0. Dependencies

[dependencies]
bevy = "0.19"        # engine (first release on Rust edition 2024)
bevy_egui = "0.41"   # UI panel; depends on bevy ^0.19 + egui ^0.35
# (rand/noise for procedural stars & disk texture — chosen in plan phase)

[dependencies.web-sys]  # web only; see §13
version = "0.3"
features = ["Window"]   # expanded in plan phase as needed

Bevy feature flags: default. No heavy features needed (no 3D scene, no audio). The full-screen Material2d path uses Bevy's core pipeline only. Storage buffers (planets) are usable on both native and WebGPU.

1. Goal

A real-time, physically-motivated renderer of a Gargantua-style black hole in Bevy, matching the visual reference video (5u1ymibkiixg1.mp4): a black shadow, a tilted glowing accretion disk with the lensed halo wrapping over and under the hole, a Doppler brightness asymmetry, and a lensed background starfield — all produced by geodesic ray-tracing curved spacetime per pixel.

The scope is deliberately larger than the reference video: it additionally includes a lensed spacetime-curvature (gravity-well) grid, lensed scene-planet spheres, and a cubemap skybox layer. The renderer is delivered in two phases: a fully-working Schwarzschild (non-spinning) renderer first, then an upgrade to the Kerr (spinning) metric for true Gargantua accuracy.

2. Reference video analysis

30 frames were extracted (1/sec) from 5u1ymibkiixg1.mp4 (800×432, 10fps, 30s) and analyzed. Findings that constrain this design:

Feature Present in video Notes
Black hole shadow Yes Dark circular region, stays centered
Accretion disk Yes Orange/red-hot; tilted relative to camera orbit
Lensed halo (over + under hole) Yes The classic wrap-over/wrap-under Einstein ring
Doppler asymmetry Yes One side clearly brighter/hotter; bright side shifts as camera orbits
Background stars Yes Visible in dark regions, distorted near the hole
Gravity-well grid No Not present (but in scope per user decision)
Planets / scene objects No Not present (but in scope per user decision)
Cubemap skybox No Background is stars only (procedural)
UI / HUD No Clean frame

Camera motion in video: smooth continuous orbit around the hole while slowly zooming; disk is tilted relative to the orbit plane, so both the disk face and the lensed halo are visible throughout.

Visual style target: photorealistic, Gargantua-like — not stylized/cartoonish.

3. Non-goals (Phase 1)

  • Kerr metric / frame dragging (Phase 2).
  • Volumetric rendering of the disk (it is a thin emitting surface).
  • Audio, gameplay, networking.
  • Loading external mesh assets (everything is procedural or simple spheres).
  • A real-time editable GUI editor (parameters are tuned via a debug params resource + keyboard; an egui panel is optional later).

4. Physics & units

Natural units with the Schwarzschild radius Rs = 1 everywhere in the shader. This makes the characteristic radii clean and numerically stable:

Feature Radius (Rs units)
Event horizon 1.0
Photon sphere 1.5
Shadow (critical impact parameter bcrit = 3√3/2 · Rs) ≈ 2.598
ISCO (accretion disk inner edge) 3.0
Accretion disk outer edge (tunable) ~1220

Geodesic integration (Schwarzschild)

For each pixel, generate a photon ray (position pos, direction dir) and integrate the geodesic. The standard discretized form used across well-known real-time black-hole demos (mholub "Basic Black Hole Rendering"; rantonels starless):

per step:
  r  = length(pos)
  h2 = |cross(pos, dir)|^2          // squared angular momentum
  a  = -1.5 * Rs * h2 / r^5 * pos   // geodesic bending acceleration (Rs=1)
  dir = normalize(dir + a*dt)
  pos = pos + dir*dt

Integrator: RK4 (4th-order Runge-Kutta) with a tunable step count (default ~300, range 150600). RK4 is chosen over Euler because Euler requires far more steps for the same accuracy near the photon sphere and smears the Einstein ring.

Termination conditions for a ray (checked each step):

  1. r < Rs → captured by horizon → return pure black (this is the shadow; it emerges naturally at bcrit).
  2. r > R_escape (e.g. 1000) → escaped → sample skybox + procedural stars along final direction.
  3. Disk plane intersection within [r_in, r_out] → shade disk, continue integrating. A ray typically hits the disk multiple times (front pass, then wrapped over/under the hole) — each hit contributes an emissive color. Composite front-to-back by accumulating (color·α) and (1α); stop accumulating once the running alpha saturates near 1. This is what produces the over/under halo and higher-order images.
  4. Planet sphere intersection (within current step segment) → shade nearest planet, composite front-to-back; terminate the ray once alpha saturates (opaque planets occlude everything behind).
  5. Gravity-well (Flamm) surface intersection → shade grid (additive, low alpha), continue.

Critical for secondary images: the loop does NOT terminate when a ray merely crosses the photon sphere and comes back. It runs the full step budget unless the accumulated alpha saturates (opaque hit) or the ray is captured/escapes. This is what produces secondary/higher-order Einstein images as in Luminet's paper.

Integrator form note: the discretized pseudo-acceleration shown above (Cartesian (pos, dir) with the effective bending acceleration) is one valid real-time approximation. An alternative is the integrable Binet form d²u/dφ² = (3/2)·Rs·u² 1/(2Rs) in u=1/r, which is more accurate but harder to combine with the off-plane disk/grid/planet intersection tests. The exact choice is a Phase-1 implementation decision in the plan; either way the behavior (lensing, shadow at bcrit, wrap halo) is identical.

5. Scene elements

5.1 Accretion disk

  • Thin emitting ring in the equatorial plane (rotated by a configurable disk tilt).
  • r ∈ [r_in=3, r_out=15] (tunable).
  • Intersection: detect sign change of the disk-plane coordinate across an integration step; solve for the crossing point; accept only if r ∈ [r_in, r_out].
  • Procedural texture: layered radial + angular noise (hash-based, time-animated to simulate rotation). Brightness falls off near r_in (inner edge hottest) and r_out.
  • Color temperature gradient: hotter (white/blue) near r_in, cooler (orange/red) near r_out.
  • Doppler beaming: at each disk hit, compute the disk orbital velocity (relativistic Keplerian, v = sqrt(Rs/(2r)) / sqrt(1 - Rs/r) capped), the Doppler factor δ = 1/(γ(1 β·n̂)) relative to the ray direction, and scale intensity by δ³ (and shift hue slightly blue for δ>1, red for δ<1). This produces the one-side-bright asymmetry seen in the reference.

5.2 Background skybox (cubemap) + procedural stars

  • Optional cubemap texture bound to the material. When a ray escapes, sample the cubemap along its final direction. Procedural stars are layered on top as an additive detail (hash-based points on the unit sphere) with an intensity uniform.
  • If no cubemap is provided, procedural stars are the sole background (matches the reference video).
  • Because rays bend, the background (cubemap or stars) is naturally lensed: stars near the hole smear into arcs and can form secondary images.

5.3 Planets (scene entities)

  • A Planet component: Transform (center), radius, color, emissive flag.
  • A system collects all Planet entities each frame into a Vec<SphereData> and uploads to a storage buffer bound to the material (@group(1) var<storage, read> planets: array<SphereData>).
  • In the shader, each integration step tests all spheres for ray-segment intersection; the nearest hit wins and is shaded (Lambert-ish with the color, optionally emissive). Planets are therefore fully lensed: a planet near the hole bends into an arc and can show secondary images.
  • The storage buffer also carries a count, and the loop is bounded by a MAX_PLANETS constant (e.g. 32) for unrolled/loop performance.

5.4 Spacetime-curvature grid (Flamm paraboloid)

  • The classic embedding surface z(r) = 2·sqrt(Rs·(r Rs)) (dips down toward the center), oriented in the disk plane.
  • Ray-traced in the shader: each step tests intersection with the paraboloid surface. At a hit, apply a polar grid pattern from the (embedded) (r, φ): bright rings at chosen radius intervals, radial spokes at chosen angle intervals. Fade with depth (z) so it reads as "below" the hole.
  • The grid is lensed: because it is traced through curved spacetime, grid lines bend dramatically near the hole and can wrap. Partially transparent (additive) so it never fully occludes the disk.
  • This feature is off by default (the reference video does not show it); toggled via a parameter.

6. Bevy rendering architecture

  • The scene is 100% procedural (no 3D meshes to composite over). The black hole is drawn by a single full-screen quad with a custom Material2d, whose fragment shader performs all ray-tracing. No scene-color texture is read, so a full-screen Material2d is simpler than a render-graph post-processing node.
  • The "camera" is just a set of material uniforms (eye position, forward/right/up vectors, FOV, aspect) updated each frame from the orbit controller.
  • Material2d puts the material bind group at group 2 (group 0 = Bevy view uniforms, group 1 = mesh bindings, group 2 = material). WGSL uses the #{MATERIAL_BIND_GROUP} token (resolved by Material2dPlugin) so bindings are:
    • @group(#{MATERIAL_BIND_GROUP}) @binding(0) var<uniform> uniforms: BlackHoleUniforms; (single uniform struct: params + camera packed)
    • @group(#{MATERIAL_BIND_GROUP}) @binding(1) var skybox: texture_cube<f32>; + @binding(2) var skybox_sampler: sampler;
    • @group(#{MATERIAL_BIND_GROUP}) @binding(3) var<storage, read> planets: array<SphereData>; (+ count in uniforms)
  • Imports: Material2d, Material2dPlugin, AlphaMode2d from bevy::sprite_render. Spawn via components (no bundles): commands.spawn(Camera2d) and (Mesh2d(..), MeshMaterial2d(..)).
  • Render-scale support: the quad is drawn at render_scale (0.51.0) of the window and upscaled, needed for the Phase 2 Kerr integrator. Phase 1 targets 1.0 at 60fps.
  • UI compositing: bevy_egui (v0.41.0, depends on Bevy ^0.19 + egui ^0.35) renders the control panel on top of the black-hole quad via its own render pass. Mouse input is routed to the egui panel when the cursor is over it, otherwise to the orbit controller (see §7).

7. Camera & interaction

Orbit controller around the origin:

  • Drag (mouse) → yaw/pitch the camera around the hole (ignored while the cursor is over the egui panel).
  • Wheel → change camera distance (= impact parameter / how close to the hole); consumed by egui when over the panel.
  • Parameters are live-tuned via an egui panel (no keyboard hotkeys required). The panel reads/writes the BlackHoleParams Resource, and a system mirrors that resource into the material's uniform each frame.

Default scene (matches the reference): camera at moderate distance, disk tilted ~7080° from face-on (so the lensed halo is prominent), Doppler enabled, grid off, procedural stars on.

7.5. Parameter UI (egui)

A single collapsible egui window docked to a screen corner (top-left), titled "Controls", with grouped egui::CollapsingHeader sections so the user can fold sections away or hide the whole window to admire the view:

  • Camera — orbit distance (slider), yaw/pitch (sliders; also driven by drag), FOV, reset button.
  • Accretion Disk — inner radius, outer radius, tilt, brightness, rotation speed.
  • Doppler — enable checkbox, intensity slider.
  • Renderer — integrator step count (slider 100600), render scale (0.51.0), show FPS.
  • Background — procedural star intensity, skybox load/clear, skybox intensity.
  • Grid — enable checkbox (off by default), line density, depth fade, color.
  • Planets — add/remove a test planet, edit the active planet's radius/color/position.

Every control mutates BlackHoleParams (or the relevant component) in place; the mirror system propagates it to the GPU uniform, so changes appear within one frame. Sliders show live numeric values. bevy_egui EguiPlugin is added to the app; a ui_system in Update builds the window each frame from the Resource.

8. File structure

8. File structure

src/
  main.rs              # App setup, plugin registration, default scene
  params.rs            # BlackHoleParams resource (CPU mirror of uniforms)
  camera.rs            # Orbit camera input controller
  ui.rs                # egui "Controls" panel (collapsible sections)
  web.rs               # #[cfg(wasm32)] glue: WebGPU backend, fallback message, canvas
  scene/
    mod.rs
    disk.rs            # Disk parameters & defaults
    planets.rs         # Planet component + storage-buffer upload system
  render/
    plugin.rs          # Full-screen quad + camera + material setup
    material.rs        # BlackHoleMaterial: AsBindGroup (uniforms, cubemap, storage buffer)
assets/shaders/
  black_hole.wgsl      # Entry point: ray gen → integrate → composite
  ray_gen.wgsl         # Per-pixel ray direction from camera params
  geodesic_schwarzschild.wgsl   # RK4 integrator (Phase 1)
  geodesic_kerr.wgsl            # Kerr integrator (Phase 2)
  disk.wgsl            # Plane intersection + procedural texture + Doppler
  planets.wgsl         # Sphere intersection + shading
  grid.wgsl            # Flamm paraboloid intersection + grid pattern
  stars.wgsl           # Procedural starfield
  skybox.wgsl          # Cubemap sampling
  common.wgsl          # Shared structs (SphereData, params, camera), constants
web/                   # web build inputs (Trunk)
  index.html           # canvas + fallback message container
Trunk.toml             # trunk build config (wasm32-unknown-unknown)

9. Parameters (BlackHoleParams)

Tunable, mirrored to the GPU uniform each frame:

Field Default Meaning
rs 1.0 Schwarzschild radius (natural units)
disk_inner 3.0 Disk inner radius (ISCO)
disk_outer 15.0 Disk outer radius
disk_tilt ~75° Disk plane tilt vs. camera
disk_brightness 1.0 Global disk intensity
doppler_strength 1.0 Multiplier on beaming asymmetry
steps 300 Integrator step count
dt derived Step size (auto from escape radius / steps)
render_scale 1.0 Render resolution scale
grid_enabled false Toggle Flamm grid
star_intensity 1.0 Procedural star brightness
spin 0.0 Kerr spin parameter (Phase 2)
planet_count 0 Number of valid entries in planets buffer

10. Phasing

Phase 1 — Schwarzschild (this project's main deliverable)

All scene elements (disk + Doppler, skybox/stars, planets, grid) against the Schwarzschild geodesic integrator. Deliverables:

  • Working Bevy app matching the reference video look (shadow, tilted Doppler disk, lensed halo over/under, lensed stars) by default.
  • Grid + planets + cubemap as additional enabled features.
  • Orbit camera + live params.
  • Unit tests for the CPU-mirrored math constants (bcrit ≈ 2.598; a ray at large impact parameter has known small deflection).
  • Target 60fps at full render scale on a typical discrete GPU.

Phase 2 — Kerr (true Gargantua; documented follow-up, separate plan)

Replace geodesic_schwarzschild.wgsl with geodesic_kerr.wgsl integrating the Kerr geodesic equations (Boyer-Lindquist) with adaptive RK4. Adds frame-dragging/ergosphere asymmetry. Expected to require render_scale ≈ 0.5 + upscaling. The same scene elements, camera, and params carry over unchanged; only the integrator core swaps. This is a separate spec/plan once Phase 1 ships.

11. Testing & verification

This is primarily a visual artifact; verification strategy:

  • Unit tests (Rust): mirror the shader's key constants/relations in CPU code and assert: bcrit = 3√3/2 ≈ 2.598; a ray launched with impact parameter b < bcrit falls below Rs; a ray with b >> bcrit is deflected by an amount within tolerance of the weak-field formula δθ ≈ 2Rs/b. These guard the math against silent regressions.
  • Visual milestones (manual):
    1. Plain starfield renders with the full-screen shader.
    2. Black circular shadow appears, sized ~bcrit.
    3. First Einstein ring appears when a disk is added edge-on.
    4. Tilted disk shows the over/under halo (the money shot matching the reference).
    5. Doppler asymmetry: one side brighter, bright side shifts as camera orbits.
    6. Lensing of stars visible near the hole edge.
    7. (Feature) grid bends near the hole; (feature) planet arcs near the hole.
  • Performance budget check: confirm ≥60fps at render_scale=1.0, Steps=300, in Phase 1 on desktop; confirm interactive (≥30fps) at render_scale=0.75, Steps=200 in a WebGPU browser.
  • Web build check: trunk build --release produces a wasm artifact that loads in Chrome/Edge, renders the same scene, the egui panel is usable, and a non-WebGPU browser shows the fallback message instead of a blank canvas.

12. Risks & mitigations

Risk Mitigation
RK4 too slow at 300 steps/full-res Make steps + render_scale live params; profile early. Phase 1 default can drop to 200 steps.
WGSL storage-buffer support edge cases for planets Bound loop with MAX_PLANETS constant; storage buffers are valid on both native and WebGPU (our only web target).
Flamm grid adds noise/visual clutter Off by default; additive + faded; separate toggle.
Kerr math instability (Phase 2) Isolated to Phase 2; adaptive step; does not block Phase 1.
Secondary images absent (loop terminates early) Explicit non-termination policy across photon sphere (Section 4).
Web performance lower than desktop Web target starts at render_scale=0.75, steps=200 (tunable in UI); lazy/integration cost is GPU-bound regardless of platform.
Browser WebGPU not available Detect navigator.gpu failure, show a plain HTML/CSS message via the web shim telling the user to use a WebGPU-capable browser (Chrome/Edge/modern Firefox & Safari).

13. Platform support (desktop + web/WebGPU)

The app runs on desktop (native wgpu: Vulkan/Metal/D3D12) and web via WebGPU (WebGL2 is intentionally not targeted — see constraints below).

13.1 Why WebGPU only (not WebGL2)

  • Bevy/wgpu forces a compile-time choice between WebGPU and WebGL2; they cannot coexist in one binary (bevy issue #13168).
  • WebGL2 has no storage buffers (max_storage_buffers_per_stage = 0), which our planets feature depends on. WebGPU supports them, as does native.
  • WebGPU is now in Chrome, Edge, and recent Firefox/Safari. For unsupported browsers we show a fallback message rather than silently failing.

13.2 Build & toolchain

  • Desktop: cargo run --release (standard wgpu backend auto-selected by OS).
  • Web: target wasm32-unknown-unknown. Build with trunk (recommended) or wasm-bindgen-cli. A Trunk.toml + index.html are part of the deliverable. trunk serve for dev, trunk build --release for the deployable artifact. Pass the webgpu feature: trunk serve --features webgpu (or bake it into index.html via data-cargo-features="webgpu").
  • Bevy web backend: enable Bevy's webgpu cargo feature. WgpuSettings::default() then auto-selects Backends::BROWSER_WEBGPU on wasm32 and Backends::all() on desktop — no manual RenderPlugin backend config needed (RenderPlugin has no wgpu_backends field in 0.19). getrandom 0.4 / wasm_js is handled by Bevy transitively; no user Cargo entries required.
  • Asset loading on web: shaders live under assets/shaders/ and are loaded via Bevy's asset system, which works on web (paths, not absolute). No filesystem access assumed.
  • Canvas sizing on web: set Window.fit_canvas_to_parent = true so the canvas tracks the browser window and resizes propagate as Bevy WindowResized events.
  • bevy_egui works on both native and web (it handles its own input/winit wiring); no UI changes needed between platforms.

13.3 Cross-platform code hygiene

  • All WGSL is backend-agnostic; no #ifdef-style shader branching is needed (we don't use WebGL2).
  • Rust code uses #[cfg(target_arch = "wasm32")] only for: (a) forcing the WebGPU backend, (b) a WebGPU-availability check + fallback message, (c) canvas/resizing glue. All simulation/render logic is identical across platforms.
  • Avoid std::time/blocking I/O patterns that misbehave on wasm; use Bevy's Time resource and async asset loading.

13.4 Web-specific parameters/UX

  • Default render_scale lowered to 0.75 and steps to 200 on web (auto-detected via cfg) to keep frame times reasonable; both still live-tunable in the egui panel.
  • Canvas resizes to the browser window; render_scale is relative to that.
  • Pointer (mouse + touch) input routed the same way as desktop: egui panel gets priority when hovered, else the orbit controller consumes it.