17 KiB
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 = 0the renderer is bit-identical in behavior to Phase 1 (Kerr math degenerates to Schwarzschild). - At
spin > 0the 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:
// 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):
// 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:
// 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:
- Degeneracy:
kerr_deriv(chi=0) == schwarzschild_derivwithin1e-6(asserts thedragterm vanishes). Requires a CPU mirror ofderivfor both metrics. - ISCO endpoints:
kerr_isco(0.0) ≈ 3.0;kerr_isco(1.0) ≈ 0.5. - ISCO monotonic:
kerr_isco(0.3) > kerr_isco(0.6) > kerr_isco(0.9). - Horizon endpoints:
kerr_horizon(0.0) ≈ 1.0;kerr_horizon(1.0) ≈ 0.5. - Horizon monotonic: decreases in
χ. - Existing Phase 1 tests still pass (bcrit ≈ 2.598; capture/escape) — unchanged.
6.2 Visual milestones (manual)
- Spin=0 regression: with
spin = 0, the image is visually indistinguishable from Phase 1 (same shadow size, same halo, same Doppler). - 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. - ISCO shrink: sweeping
spin0 → 0.9 visibly pulls the disk inner edge inward. - Higher-order Einstein ring: still forms near the photon sphere at spin > 0 (budget doesn't starve it at default steps).
- Performance: ≥60 fps desktop at render_scale=0.75, steps=300; ≥30 fps web at render_scale=0.5, steps=200.
- 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/(y0−y1) 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)
- render_scale wiring (prerequisite; unblocks perf tuning).
spinuniform plumbing (material + plugin + WGSL struct), verify spin=0 still compiles and renders.- CPU
kerr_isco/kerr_horizon+ unit tests (pure Rust, no GPU). - Kerr
deriv()in shader + capture radius; verify spin=0 bit-identical, spin>0 shows bending. - RK45 adaptive shell replacing the RK4 loop; tune
tol/dt_min/dt_max. - UI (Spin slider, read-only ISCO/Horizon).
- Visual + performance validation against §6 milestones; adjust defaults.