From 0693c3d1654325031aba7094dee3558f6c2912a4 Mon Sep 17 00:00:00 2001 From: xfy Date: Mon, 13 Jul 2026 18:24:49 +0800 Subject: [PATCH] docs: Phase 2 (Kerr) design spec --- ...terstellar-blackhole-phase2-kerr-design.md | 313 ++++++++++++++++++ 1 file changed, 313 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-13-interstellar-blackhole-phase2-kerr-design.md diff --git a/docs/superpowers/specs/2026-07-13-interstellar-blackhole-phase2-kerr-design.md b/docs/superpowers/specs/2026-07-13-interstellar-blackhole-phase2-kerr-design.md new file mode 100644 index 0000000..067152a --- /dev/null +++ b/docs/superpowers/specs/2026-07-13-interstellar-blackhole-phase2-kerr-design.md @@ -0,0 +1,313 @@ +# 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/(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) + +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.