singularity-rs/docs/superpowers/specs/2026-07-13-interstellar-blackhole-phase2-kerr-design.md

314 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Interstellar Black Hole Renderer — Phase 2 (Kerr) Design Spec
**Date:** 2026-07-13
**Status:** Draft (awaiting user review)
**Project:** `singularity-rs` (Rust, edition 2024, Bevy 0.19) — desktop + web (WebGPU)
**Predecessor:** `docs/superpowers/specs/2026-07-09-interstellar-blackhole-design.md` (Phase 1, Schwarzschild — shipped)
## 0. Context
Phase 1 delivered a working Schwarzschild (non-spinning) renderer: black shadow, tilted Doppler disk with lensed halo, lensed stars, optional grid/planets/skybox, orbit camera, egui panel. The integrator core is a fixed-step RK4 in `black_hole.wgsl:110-119` (`deriv`) and `:268-322` (the integration loop).
Phase 2 replaces that integrator core with a Kerr (spinning) geodesic integrator so the hole exhibits frame-dragging / ergosphere asymmetry — the defining visual feature of a true Gargantua. Per the Phase 1 spec (§10), "only the integrator core swaps; the same scene elements, camera, and params carry over unchanged."
This spec covers *only* the Phase 2 delta. Anything not mentioned here is inherited unchanged from Phase 1.
## 1. Goal
Replace the Schwarzschild geodesic integrator with a Kerr geodesic integrator (Cartesian pseudo-Hamiltonian form, adaptive RK45), so that:
- At `spin = 0` the renderer is **bit-identical in behavior** to Phase 1 (Kerr math degenerates to Schwarzschild).
- At `spin > 0` the accretion disk and lensed halo show frame-dragging asymmetry — the bright/dark Doppler sides shear off-axis, and the photon sphere becomes oblate/prolate relative to the spin axis.
- The disk inner edge tracks the Kerr ISCO (Bardeen formula), shrinking from 3 Rs at spin=0 toward Rs/2 at extremal spin.
- All Phase 1 scene elements (disk, stars, planets, grid, skybox), the orbit camera, and the egui panel continue to work without behavioral regression.
**Non-goals (deferred to future work, documented in §8):**
- Full exact Kerr pseudo-Hamiltonian (this spec ships the leading-order frame-dragging term; the exact form is future work).
- Retrograde disk / spin sign (spin is non-negative; the hole spins one way).
- Volumetric disk, relativistic beaming beyond the Phase 1 Doppler model.
## 2. Physics & units (delta from Phase 1 §4)
Natural units with **Rs = 1** everywhere, inherited from Phase 1. Phase 2 introduces the Kerr spin parameter:
| Quantity | Definition | Range |
|---|---|---|
| `χ` (UI "spin") | dimensionless spin `a/M` | `[0, 1]` (0 = Schwarzschild, 1 = extremal Kerr) |
| `a` (internal) | Kerr spin length `a = χ·M` | `[0, M] = [0, 0.5]` |
| `M` (internal) | mass `Rs/2 = 0.5` | constant |
**Spin-axis orientation:** Kerr spin axis aligned with world **+Y**. The disk remains the equatorial plane (xz-plane, `y=0`). This preserves every Phase 1 crossing test (`disk_hit`, `planet_hit`, `grid_hit` all assume `y=0` equatorial geometry). No coordinate-conversion code leaks outside `deriv()`.
**Spin-dependent radii (Rs units, Rs=1):**
| Radius | Formula | spin=0 | spin=1 (extremal) |
|---|---|---|---|
| Event horizon `r₊` | `M + sqrt(M² a²)` | 1.0 (= Rs) | 0.5 (= M) |
| ISCO (prograde) | Bardeen formula, §2.1 | 3.0 (= 3 Rs) | 0.5 (= Rs/2) |
| Photon sphere | (emerges from integrator; not pinned) | ≈1.5 | < 1.5, oblate |
### 2.1 ISCO (Bardeen-Press-Teukolsky 1972, prograde)
```
Z1 = 1 + (1 χ²)^(1/3) · [ (1 + χ)^(1/3) + (1 χ)^(1/3) ]
Z2 = sqrt(3χ² + Z1²)
ISCO = M · [ 3 + Z2 sqrt( (3 Z1) · (3 + Z1 + 2·Z2) ) ] // prograde: minus sign
```
Verified: χ=0 ISCO = 6M = 3 Rs ✓; χ=1 ISCO = M = Rs/2 ✓. Monotonically decreasing in χ.
### 2.2 Geodesic `deriv()` — Cartesian pseudo-Hamiltonian, leading-order frame-dragging
The Phase 1 `deriv(pos, dir) -> Deriv{dpos, ddir}` signature is preserved. The body gains one spin-orthogonal term:
```
fn deriv(pos, dir) -> Deriv {
let r = length(pos);
let M = 0.5;
let chi = uniforms.spin; // dimensionless, [0,1]
let a = chi * M;
let Rs = uniforms.rs; // = 1.0
// Schwarzschild radial bending (unchanged at chi=0)
let h = cross(pos, dir);
let h2 = dot(h, h);
let r3 = r * r * r;
let r5 = r3 * r * r;
let radial = -1.5 * Rs * h2 / r5 * pos;
// Frame-dragging (Lense-Thirring leading term). Spin axis = +Y.
let spin_axis = vec3(0, 1, 0);
let drag = 2.0 * M * a / r3 * cross(spin_axis, dir);
let accel = radial + drag;
return Deriv(dir, accel);
}
```
**Degeneracy:** at `χ=0`, `a=0` `drag=0` `accel = -1.5·Rs·h²/r⁵·pos`, exactly the Phase 1 formula (`black_hole.wgsl:117`). This is the load-bearing correctness assertion, covered by a unit test.
**Approximation status:** `drag` is the leading-order `g_{tφ}` (Lense-Thirring) term in Cartesian dress. It reproduces the frame-dragging *direction* and asymmetry sign correctly. The exact Kerr Cartesian pseudo-Hamiltonian (Riazzi-style Σ/Δ terms) is deferred see §8. At high spin the photon-orbit radii will deviate from exact BL by a few percent; at spin 0.7 the deviation is sub-percent.
## 3. Integrator — adaptive RK45 (Dormand-Prince)
The Phase 1 fixed-step RK4 loop (`black_hole.wgsl:268-322`) is replaced by an adaptive-step Dormand-Prince (RK45) loop. Same termination conditions (capture / escape / alpha-saturation), same crossing-test calls on each accepted segment.
### 3.1 Loop structure
```
var pos = rot_x(eye, -disk_tilt); // inherited: disk-local space
var d = normalize(rot_x(dir, -disk_tilt));
var dt = total_path / steps_max; // initial step (same seed as Phase 1)
var prev = pos;
var budget = steps_max; // uniforms.steps (hard cap)
loop {
if (budget == 0) break;
// Dormand-Prince step: 6 deriv() evals → (y5, y4) for error estimate
let result = rk45_step(pos, d, dt); // → (new_pos, new_dir, err_vec)
let err_norm = length(result.err);
// Step-size control.
if (err_norm > tol * 10.0) {
// Reject: shrink and retry. Does NOT consume budget.
dt = clamp(dt * 0.2, dt_min, dt_max);
continue;
}
// Accept: consume one budget unit, refine dt for next step.
budget -= 1;
dt = clamp(dt * pow(tol / max(err_norm, 1e-12), 0.2), dt_min, dt_max);
// Crossing tests on the ACCEPTED segment [prev, new_pos] — unchanged calls.
... disk_hit(prev, result.pos) ... // composites disk color
... planet_hit(prev, result.pos, result.dir) ...
... grid_hit(prev, result.pos) ... // if enabled
prev = result.pos;
pos = result.pos;
d = result.dir;
// Termination (spin-dependent capture radius).
if (length(pos) < r_plus) break; // captured by horizon
if (length(pos) > escape_r) { /* sample sky + stars, break */ }
}
```
### 3.2 Constants & budget semantics
| Knob | Value | Rationale |
|---|---|---|
| `steps_max` | `uniforms.steps` (UI-tunable, default 300 desktop / 200 web) | Hard cap on **accepted** steps. Rejected steps do not consume budget. |
| `tol` | `1e-3` (hardcoded, not UI-exposed) | ~0.1% of horizon radius; tight enough to keep the Einstein ring crisp. |
| `dt_min` | `total_path / (steps_max * 4)` | Prevents step collapse near the photon sphere. |
| `dt_max` | `total_path / (steps_max / 4)` | Prevents a single step from crossing the whole domain. |
**Budget = accepted steps only.** Rejected steps (failed `err > 10·tol` check) retry with a smaller `dt` without decrementing `budget`. This prevents the photon-sphere region (where steps shrink) from starving the rest of the ray. When `budget` hits zero the ray stops gracefully mid-flight same degradation Phase 1 has.
### 3.3 Capture radius becomes spin-dependent
Phase 1 tests `r < Rs`. Phase 2 tests `r < r₊(χ) = M + sqrt(max(M² a², 0))`. At spin=0 this equals Rs (no regression). The `max(., 0)` guards against floating error at extremal spin.
### 3.4 Cost model
Dormand-Prince: 6 `deriv()` evaluations per *attempted* step (vs RK4's 4 per *taken* step). Kerr `deriv` (leading-order) 30 ALU. At 300 accepted steps × ~1.5 reject ratio near the hole 450 attempts × 180 ALU = **~81k ALU/pixel** for the integrator core. Phase 1 Schwarzschild was ~12k ALU/pixel. The ~7× increase is the reason `render_scale` drops 5).
## 4. Uniform & parameter changes
### 4.1 `spin` enters the GPU uniform
`spin` already exists in `BlackHoleParams` (`src/params.rs:29`) but is **not** in `BlackHoleUniforms` today. Add it by consuming the `_pad4` slot no struct size change, no WGSL alignment shift.
`src/render/material.rs` `BlackHoleUniforms`:
```rust
// before:
pub steps: u32,
pub _pad4: f32,
pub _pad5: f32,
// after:
pub steps: u32,
pub spin: f32, // was _pad4. Dimensionless χ = a/M ∈ [0,1].
pub _pad5: f32, // trailing pad retained.
```
`assets/shaders/black_hole.wgsl` `BlackHoleUniforms` struct (mirror the same slot):
```wgsl
// before:
steps: u32,
_pad4: f32,
_pad5: f32,
// after:
steps: u32,
spin: f32, // was _pad4.
_pad5: f32,
```
`src/render/plugin.rs` `mirror_params` gains one line: `u.spin = params.spin;`.
### 4.2 `disk_inner` becomes ISCO-derived (CPU)
`disk_inner` is no longer free-tunable; it is the Kerr ISCO computed from `spin`. Add a CPU helper and call it in `mirror_params`:
```rust
// src/physics.rs (extends the existing Phase 1 module)
/// Prograde Kerr ISCO in Rs units (Rs=1). chi = a/M ∈ [0,1].
pub fn kerr_isco(chi: f32) -> f32 {
let m = 0.5;
let cbrt_pos = (1.0 + chi).cbrt();
let cbrt_neg = (1.0 - chi).cbrt();
let z1 = 1.0 + (1.0 - chi * chi).cbrt() * (cbrt_pos + cbrt_neg);
let z2 = (3.0 * chi * chi + z1 * z1).sqrt();
m * (3.0 + z2 - ((3.0 - z1) * (3.0 + z1 + 2.0 * z2)).sqrt())
}
/// Kerr event-horizon radius in Rs units (Rs=1). chi = a/M ∈ [0,1].
pub fn kerr_horizon(chi: f32) -> f32 {
let m = 0.5;
let a = chi * m;
m + (m * m - a * a).max(0.0).sqrt()
}
```
In `mirror_params`: `u.disk_inner = kerr_isco(params.spin);` (overrides whatever `params.disk_inner` held). The `params.disk_inner` field is retained for Phase 1 backward-compat in the struct but ignored when `spin` is the driver.
### 4.3 UI (egui) changes — `src/ui.rs`
Under the existing "Camera" section's sibling, add a **"Black Hole"** collapsing header (or extend the Physics group):
- **Spin (χ):** `Slider::new(&mut params.spin, 0.0..=1.0)` live; drives ISCO and frame-dragging.
- **ISCO (read-only):** display `kerr_isco(params.spin)` as a label shows the derived disk inner edge.
- **Horizon (read-only):** display `kerr_horizon(params.spin)` as a label.
The existing **disk_inner** slider is removed (or shown disabled with the ISCO value), since it is now spin-derived. All other controls (disk_tilt, brightness, Doppler, steps, render_scale, stars, grid, planets, skybox) are unchanged.
## 5. Performance & render_scale
### 5.1 render_scale must actually be wired (Phase 2 prerequisite)
Phase 1 targeted `render_scale = 1.0` and `fit_quad_to_window` (`src/render/plugin.rs:79-93`) scales the quad to the full window without consulting `render_scale`. For Phase 2 the integrator cost 3.4) forces sub-1.0 render scales, so this gap must be closed as a Phase 2 task.
**Why the naive approach fails:** simply shrinking the full-screen quad (multiplying `fit_quad_to_window`'s target scale by `render_scale`) does reduce fragment invocations, but leaves the surrounding area as camera clear-color border the view no longer fills the window. That is not acceptable.
**The required mechanism is render-to-texture + upscale:** render the black-hole quad into an offscreen `Image` target sized `window × render_scale`, then draw that texture upscaled (linear) to the full-window camera view. Concretely this means a second `Camera` (or a render-graph node) whose target is the sub-resolution `Image`, and the existing fullscreen quad draws into *that* camera; a second pass/blit fills the visible window. The exact Bevy 0.19 wiring (offscreen `Image` handle, `Camera::render_target`, render-graph ordering, whether to use a second `Camera2d` or a post-processing pass) is a plan-phase implementation decision. The *behavior* fewer shader invocations at lower `render_scale`, view still fills the window is what this spec pins.
This is called out as the first implementation task 10) because it unblocks all Phase 2 performance tuning and is itself the highest-risk unknown in the plan.
### 5.2 Defaults
| Platform | render_scale | steps | Notes |
|---|---|---|---|
| Desktop (Phase 1) | 1.0 | 300 | inherited |
| Desktop (Phase 2) | **0.75** | 300 | 7× integrator cost; 0.75 buys headroom |
| Web (Phase 1) | 0.75 | 200 | inherited |
| Web (Phase 2) | **0.5** | 200 | RK45 cost heavy on wasm |
These are defaults; both remain UI-tunable live.
### 5.3 Budget vs. quality
At `tol = 1e-3` and 300 budget, rays in the far-field (boring straight-ish paths) take few large steps and finish well under budget; rays grazing the photon sphere spend their whole budget there. This is the desired adaptive behavior compute goes where the bending is. If the frame is dominated by photon-sphere pixels (close camera), budget may saturate and some rays terminate mid-flight, softening higher-order Einstein images. Acceptable; documented as a known quality lever (raise `steps` in the UI).
## 6. Testing & verification
### 6.1 Unit tests (`src/physics.rs`, `tests/physics_test.rs`)
Mirrors of the shader math in CPU code, asserted in Rust:
1. **Degeneracy:** `kerr_deriv(chi=0) == schwarzschild_deriv` within `1e-6` (asserts the `drag` term vanishes). Requires a CPU mirror of `deriv` for both metrics.
2. **ISCO endpoints:** `kerr_isco(0.0) ≈ 3.0`; `kerr_isco(1.0) ≈ 0.5`.
3. **ISCO monotonic:** `kerr_isco(0.3) > kerr_isco(0.6) > kerr_isco(0.9)`.
4. **Horizon endpoints:** `kerr_horizon(0.0) ≈ 1.0`; `kerr_horizon(1.0) ≈ 0.5`.
5. **Horizon monotonic:** decreases in `χ`.
6. **Existing Phase 1 tests still pass** (bcrit 2.598; capture/escape) unchanged.
### 6.2 Visual milestones (manual)
1. **Spin=0 regression:** with `spin = 0`, the image is visually indistinguishable from Phase 1 (same shadow size, same halo, same Doppler).
2. **Frame-dragging asymmetry:** at `spin = 0.5`, the disk's bright Doppler side shears off the pure line-of-sight axis; the halo is no longer mirror-symmetric across the spin axis.
3. **ISCO shrink:** sweeping `spin` 0 0.9 visibly pulls the disk inner edge inward.
4. **Higher-order Einstein ring:** still forms near the photon sphere at spin > 0 (budget doesn't starve it at default steps).
5. **Performance:** ≥60 fps desktop at render_scale=0.75, steps=300; ≥30 fps web at render_scale=0.5, steps=200.
6. **No regression** in grid/planets/skybox when toggled on at spin > 0.
## 7. File structure (delta)
```
src/
physics.rs # +kerr_isco, +kerr_horizon, +CPU-mirrored kerr_deriv (tests)
render/material.rs # BlackHoleUniforms: _pad4 → spin
render/plugin.rs # mirror_params: +u.spin, +u.disk_inner=isco; fit_quad_to_window: +render_scale
params.rs # (no struct change; spin field exists; default unchanged=0.0)
ui.rs # +Spin slider, ISCO/Horizon read-only labels; disk_inner slider removed
assets/shaders/
black_hole.wgsl # deriv() body (Kerr); loop (RK45); struct spin field; capture radius
tests/
physics_test.rs # +Kerr degeneracy/ISCO/horizon tests
```
No new files. No new crates. The change is concentrated in `black_hole.wgsl` (integrator), `physics.rs` (CPU mirror + formulas), `material.rs`/`plugin.rs` (one uniform field), and `ui.rs` (one slider).
## 8. Risks & mitigations
| Risk | Mitigation |
|---|---|
| Leading-order frame-dragging deviates from exact Kerr at high spin | Documented (§2.2). At χ ≤ 0.7 sub-percent; visible mainly at χ > 0.9. Full pseudo-Hamiltonian is future work. |
| RK45 cost blowup near photon sphere | Budget cap (accepted-steps-only) + `dt_min` clamp; rays terminate gracefully. |
| `render_scale` not yet wired | First Phase 2 task (§5.1); blocks performance validation. |
| Disk crossing test under tiny steps | The `t = y0/(y0y1)` lerp is resolution-independent; more/smaller crossings raise compositing cost but alpha-saturation early-out bounds it. |
| Extremal-spin numerical edge cases (χ → 1) | `max(., 0)` guards in horizon; ISCO formula stable through χ=1 (verified in §2.1). UI clamps χ ≤ 1.0. |
| spin=0 visual regression | Unit test + visual milestone #1; degeneracy is the central correctness assertion. |
## 9. Out of scope (future work)
- Full exact Kerr Cartesian pseudo-Hamiltonian (Σ/Δ/Carter-separable form) for <1% photon-orbit accuracy at all spins.
- Negative spin / retrograde disk (would need ISCO sign flip and UI sign toggle).
- Adaptive *order* integrator (currently fixed RK45 order, adaptive step only).
- Tilted-spin (spin axis not aligned to +Y) would break the y=0 disk assumption and require generalizing all crossing tests.
## 10. Phasing within Phase 2 (suggested task ordering for the plan)
1. **render_scale wiring** (prerequisite; unblocks perf tuning).
2. **`spin` uniform plumbing** (material + plugin + WGSL struct), verify spin=0 still compiles and renders.
3. **CPU `kerr_isco` / `kerr_horizon` + unit tests** (pure Rust, no GPU).
4. **Kerr `deriv()` in shader** + capture radius; verify spin=0 bit-identical, spin>0 shows bending.
5. **RK45 adaptive shell** replacing the RK4 loop; tune `tol`/`dt_min`/`dt_max`.
6. **UI** (Spin slider, read-only ISCO/Horizon).
7. **Visual + performance validation** against §6 milestones; adjust defaults.