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

17 KiB
Raw Blame History

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=0drag=0accel = -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.rsBlackHoleUniforms:

// 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.wgslBlackHoleUniforms struct (mirror the same slot):

// before:
steps: u32,
_pad4: f32,
_pad5: f32,

// after:
steps: u32,
spin: f32,      // was _pad4.
_pad5: f32,

src/render/plugin.rsmirror_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:

// 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.