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

916 lines
34 KiB
Markdown
Raw 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) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the Schwarzschild geodesic integrator with a Kerr (spinning) integrator so the black hole shows frame-dragging / ergosphere asymmetry, while staying bit-identical to Phase 1 at spin=0.
**Architecture:** Kerr lives entirely inside the shader's `deriv()` body (one added spin-orthogonal term) and the integrator shell (fixed-step RK4 → adaptive RK45). Spin axis is +Y, so the disk stays in the y=0 plane and every Phase 1 crossing test (disk/grid/planet) runs unchanged on the bent path. The `spin` parameter enters the GPU uniform by consuming an existing `_pad4` slot (no struct growth). `disk_inner` becomes spin-derived ISCO (Bardeen formula, CPU). `render_scale` is finally wired via offscreen render-to-texture + upscale (the highest-risk task, done first).
**Tech Stack:** Rust 2024, Bevy 0.19, bevy_egui 0.41, WGSL, trunk (web), WebGPU.
**Spec:** `docs/superpowers/specs/2026-07-13-interstellar-blackhole-phase2-kerr-design.md`
**Verified API facts (do not deviate):**
- Offscreen render: `Camera2d` + `Camera { order: -1, .. }` + `RenderTarget::Image(handle.clone().into())`. A second `Camera2d` (default order 0) draws the offscreen `Image` upscaled to the window. Template: Bevy 0.19 `examples/2d/pixel_grid_snap.rs`.
- Offscreen `Image`: `Image::new_target_texture(w, h, TextureFormat::Bgra8UnormSrgb, None)` — already sets `RENDER_ATTACHMENT` usage. Recreate on `WindowResized` via `MessageReader<WindowResized>` (NOT `EventReader` in 0.19).
- Upscale pass: a second `Material2d` (`UpscaleMaterial`) whose fragment samples the offscreen `Image` via `#[texture(0)] #[sampler(1)] source: Handle<Image>`.
- `Camera2d` is a unit-struct marker in 0.19; spawn `Camera2d` + `Camera { .. }` separately. No `Camera2dBundle`.
- `Material2d` / `Material2dPlugin` / `AlphaMode2d` import from `bevy::sprite_render`.
- Uniform field swap: `BlackHoleUniforms._pad4: f32` (at `material.rs:40`) → `spin: f32`. A `u32` (`steps`) immediately precedes it, and `f32` after a `u32` is 4-byte-aligned — no WGSL alignment shift. Rust `ShaderType` derive and WGSL struct must both change in lockstep.
- `spin: f32` already exists in `BlackHoleParams` (`src/params.rs:29`, default `0.0`); only the uniform plumbing is new.
---
## File structure (delta from Phase 1)
```
src/
physics.rs # +kerr_isco, +kerr_horizon, +kerr_bending_accel (CPU mirror, tested)
render/material.rs # BlackHoleUniforms: _pad4 → spin; +UpscaleMaterial
render/plugin.rs # spawn offscreen+upscale cameras; render_scale resize; mirror_params +spin/+disk_inner
params.rs # no struct change; render_scale default desktop 0.75 / web 0.5 (cfg)
ui.rs # +Spin slider + ISCO/Horizon read-only labels; disk_inner slider removed
assets/shaders/
black_hole.wgsl # deriv() Kerr body; loop RK45; struct spin field; capture radius spin-dependent
upscale.wgsl # NEW: fullscreen texture-sample blit
tests/
physics_test.rs # +Kerr degeneracy/ISCO/horizon/monotonic tests
```
---
## Task 1: Wire `render_scale` (offscreen render-to-texture + upscale)
**Why first:** highest-risk unknown; unblocks all Phase 2 perf tuning. Isolated to the render plugin + a new upscale material. After this task, lowering `render_scale` visibly reduces fragment invocations (blurry upscale) without breaking the view.
**Files:**
- Create: `assets/shaders/upscale.wgsl`
- Modify: `src/render/material.rs` (add `UpscaleMaterial`)
- Modify: `src/render/plugin.rs` (offscreen + upscale cameras, resize system)
- [ ] **Step 1: Write the upscale WGSL shader**
Create `assets/shaders/upscale.wgsl`:
```wgsl
#import bevy_sprite::mesh2d_vertex_output::VertexOutput
@group(#{MATERIAL_BIND_GROUP}) @binding(0) var tex: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(1) var samp: sampler;
@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
// in.uv is [0,1]; sample the offscreen image directly (linear sampler upscales).
return textureSample(tex, samp, in.uv);
}
```
- [ ] **Step 2: Add `UpscaleMaterial` to `material.rs`**
In `src/render/material.rs`, add after `BlackHoleMaterial` (before `impl Default for BlackHoleMaterial`):
```rust
/// Samples the sub-resolution offscreen render and blits it fullscreen.
/// Bound to a second Camera2d that draws after the offscreen camera.
#[derive(Asset, TypePath, AsBindGroup, Clone)]
pub struct UpscaleMaterial {
#[texture(0)]
#[sampler(1)]
pub source: Handle<Image>,
}
impl Material2d for UpscaleMaterial {
fn fragment_shader() -> ShaderRef {
"shaders/upscale.wgsl".into()
}
}
```
`ShaderRef`, `Asset`, `TypePath`, `AsBindGroup`, `Material2d`, `Image`, `Handle` are already imported at the top of `material.rs`. Verify the imports compile; add `use bevy::image::Image;` if missing.
- [ ] **Step 3: Refactor `spawn_fullscreen_quad` to build the offscreen pipeline**
In `src/render/plugin.rs`, replace the body of `spawn_fullscreen_quad` (currently `plugin.rs:39-75`). The black-hole quad now renders into an offscreen `Image`; a second camera + upscale quad draws that image to the window. Add these marker components at the top of the file (after `struct FullscreenQuad;`):
```rust
/// The offscreen Image the black-hole shader renders into (sub-resolution).
#[derive(Component)]
pub struct OffscreenTarget(pub Handle<Image>);
/// The camera that renders the black-hole quad into the offscreen Image.
#[derive(Component)]
pub struct OffscreenCamera;
/// The camera that draws the upscaled offscreen Image to the window.
#[derive(Component)]
pub struct UpscaleCamera;
/// The quad that displays the upscaled image.
#[derive(Component)]
struct UpscaleQuad;
```
Then replace `spawn_fullscreen_quad` with:
```rust
fn spawn_fullscreen_quad(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<BlackHoleMaterial>>,
mut upscale_materials: ResMut<Assets<crate::render::material::UpscaleMaterial>>,
mut buffers: ResMut<Assets<ShaderBuffer>>,
mut images: ResMut<Assets<Image>>,
window: Query<&Window>,
params: Res<crate::params::BlackHoleParams>,
) {
let win = match window.single() {
Ok(w) => w,
Err(_) => return,
};
let scale = params.render_scale.clamp(0.25, 1.0);
let w = ((win.width() * scale) as u32).max(1);
let h = ((win.height() * scale) as u32).max(1);
// Offscreen target at sub-resolution. new_target_texture sets RENDER_ATTACHMENT.
let offscreen = images.add(Image::new_target_texture(
w,
h,
TextureFormat::Bgra8UnormSrgb,
None,
));
commands.spawn(OffscreenTarget(offscreen.clone()));
// --- Black-hole quad (renders into the offscreen Image) ---
let half_w = w as f32 / 2.0;
let half_h = h as f32 / 2.0;
let planets_buffer = buffers.add(ShaderBuffer::from(vec![
super::material::SphereData::default();
super::material::MAX_PLANETS
]));
let mut material = BlackHoleMaterial::default();
material.planets = planets_buffer;
commands.spawn((
Mesh2d(meshes.add(Rectangle::new(2.0, 2.0))),
MeshMaterial2d(materials.add(material)),
Transform::default().with_scale(Vec3::new(half_w, half_h, 1.0)),
FullscreenQuad,
));
// Offscreen camera: order -1 so it renders before the upscale camera.
commands.spawn((
Camera2d,
Camera {
order: -1,
clear_color: ClearColorConfig::Custom(Color::srgb(0.1, 0.1, 0.1)),
..default()
},
RenderTarget::Image(offscreen.clone().into()),
Msaa::Off,
OffscreenCamera,
));
// --- Upscale quad (draws offscreen Image to the window) ---
commands.spawn((
Mesh2d(meshes.add(Rectangle::new(2.0, 2.0))),
MeshMaterial2d(upscale_materials.add(crate::render::material::UpscaleMaterial {
source: offscreen.clone(),
})),
Transform::default().with_scale(Vec3::new(win.width() / 2.0, win.height() / 2.0, 1.0)),
UpscaleQuad,
));
commands.spawn((Camera2d, Msaa::Off, UpscaleCamera));
}
```
Add imports to `plugin.rs` top: `use bevy::camera::RenderTarget; use bevy::render::render_resource::TextureFormat; use bevy::image::Image;`. (`Clear color` value matches the Phase 1 grey.)
- [ ] **Step 4: Replace `fit_quad_to_window` with a resize system that resizes the offscreen Image**
In `src/render/plugin.rs`, delete the existing `fit_quad_to_window` (`plugin.rs:79-93`) and add:
```rust
/// Recreate the offscreen Image and rescale both quads on window resize,
/// honoring the live `render_scale` param.
fn resize_offscreen(
mut images: ResMut<Assets<Image>>,
params: Res<crate::params::BlackHoleParams>,
target: Query<&OffscreenTarget>,
mut bh_quad: Query<&mut Transform, With<FullscreenQuad>>,
mut up_quad: Query<&mut Transform, With<UpscaleQuad>>,
window: Query<&Window>,
mut resized: MessageReader<bevy::window::WindowResized>,
) {
if resized.read().next().is_none() {
return;
}
let Ok(win) = window.single() else { return; };
let scale = params.render_scale.clamp(0.25, 1.0);
let w = ((win.width() * scale) as u32).max(1);
let h = ((win.height() * scale) as u32).max(1);
if let Ok(handle) = target.single() {
let img = Image::new_target_texture(w, h, TextureFormat::Bgra8UnormSrgb, None);
images.insert(handle.0.clone(), img);
}
for mut t in &mut bh_quad {
t.scale = Vec3::new(w as f32 / 2.0, h as f32 / 2.0, 1.0);
}
for mut t in &mut up_quad {
t.scale = Vec3::new(win.width() / 2.0, win.height() / 2.0, 1.0);
}
}
```
Then update the `Update` system set in `BlackHolePlugin::build` (`plugin.rs:23-31`): replace `fit_quad_to_window` with `resize_offscreen`, and add `Material2dPlugin::<crate::render::material::UpscaleMaterial>::default()` next to the existing `Material2dPlugin::<BlackHoleMaterial>`:
```rust
.add_plugins(Material2dPlugin::<BlackHoleMaterial>::default())
.add_plugins(Material2dPlugin::<crate::render::material::UpscaleMaterial>::default())
```
- [ ] **Step 5: Add `render_scale` to the egui Renderer section**
In `src/ui.rs`, inside the `"Renderer"` `CollapsingHeader` (currently `ui.rs:36-41`), replace the comment block with a live slider:
```rust
egui::CollapsingHeader::new("Renderer").show(ui, |ui| {
ui.add(egui::Slider::new(&mut params.steps, 50..=600).text("Steps"));
ui.add(egui::Slider::new(&mut params.render_scale, 0.25..=1.0).text("Render scale"));
});
```
- [ ] **Step 6: Bump `render_scale` defaults for Phase 2**
In `src/params.rs` (`params.rs:44`), change:
```rust
render_scale: if cfg!(target_arch = "wasm32") { 0.75 } else { 1.0 },
```
to:
```rust
render_scale: if cfg!(target_arch = "wasm32") { 0.5 } else { 0.75 },
```
Also update the `#[allow(dead_code)]` attribute at `params.rs:6` — remove `render_scale` from the "reserved" comment since it's now wired:
```rust
#[allow(dead_code)] // spin is reserved for Phase 2 (Kerr); render_scale now wired in Phase 2
```
- [ ] **Step 7: Compile and run**
Run: `cargo build`
Expected: compiles with no errors. (Warnings about unused `UpscaleCamera`/`OffscreenCamera` markers are fine — they're used for querying.)
Run: `cargo run`
Expected: the black hole renders as before, but the image is slightly blurry (0.75 upscale). Moving the `Render scale` slider in the UI changes sharpness live. Resizing the window does not break the view.
- [ ] **Step 8: Commit**
```bash
git add assets/shaders/upscale.wgsl src/render/material.rs src/render/plugin.rs src/ui.rs src/params.rs
git commit -m "feat: wire render_scale via offscreen render-to-texture + upscale"
```
---
## Task 2: Plumb `spin` into the GPU uniform
**Why second:** smallest change; proves the uniform path end-to-end before touching math. At spin=0 nothing visible changes (the shader doesn't use `spin` yet — that's Task 5).
**Files:**
- Modify: `src/render/material.rs` (uniform struct + default)
- Modify: `src/render/plugin.rs` (`mirror_params` gains one line)
- Modify: `assets/shaders/black_hole.wgsl` (struct field)
- [ ] **Step 1: Swap `_pad4` → `spin` in `BlackHoleUniforms`**
In `src/render/material.rs:40`, change:
```rust
pub steps: u32,
pub _pad4: f32,
pub _pad5: f32,
```
to:
```rust
pub steps: u32,
pub spin: f32, // Phase 2: dimensionless Kerr spin χ = a/M ∈ [0,1].
pub _pad5: f32,
```
In the `Default` impl (`material.rs:71-73`), change:
```rust
steps: 300,
_pad4: 0.0,
_pad5: 0.0,
```
to:
```rust
steps: 300,
spin: 0.0,
_pad5: 0.0,
```
- [ ] **Step 2: Mirror `spin` into the uniform each frame**
In `src/render/plugin.rs`, inside `mirror_params` (after `u.steps = params.steps;` at `plugin.rs:142`), add:
```rust
u.spin = params.spin;
```
- [ ] **Step 3: Add `spin` to the WGSL uniform struct**
In `assets/shaders/black_hole.wgsl:35-37`, change:
```wgsl
steps: u32,
_pad4: f32,
_pad5: f32,
```
to:
```wgsl
steps: u32,
spin: f32,
_pad5: f32,
```
- [ ] **Step 4: Compile**
Run: `cargo build`
Expected: compiles. No visual change (spin unused in deriv yet).
- [ ] **Step 5: Commit**
```bash
git add src/render/material.rs src/render/plugin.rs assets/shaders/black_hole.wgsl
git commit -m "feat: plumb spin parameter into GPU uniform"
```
---
## Task 3: CPU Kerr helpers (`kerr_isco`, `kerr_horizon`) with tests
**Why TDD here:** pure Rust, no GPU. Locks the ISCO/horizon formulas before the shader depends on them. The spin=0 values (3.0 and 1.0) are the Phase 1 compatibility contract.
**Files:**
- Modify: `src/physics.rs` (add two functions)
- Modify: `tests/physics_test.rs` (add tests)
- [ ] **Step 1: Write the failing tests**
Append to `tests/physics_test.rs`:
```rust
#[test]
fn kerr_isco_at_zero_is_schwarzschild() {
// spin=0 → ISCO = 6M = 3 Rs (Rs=1).
let isco = physics::kerr_isco(0.0);
assert!((isco - 3.0).abs() < 1e-3, "spin=0 ISCO should be 3.0, got {}", isco);
}
#[test]
fn kerr_isco_at_extremal_is_half_rs() {
// spin=1 → ISCO = M = Rs/2 = 0.5.
let isco = physics::kerr_isco(1.0);
assert!((isco - 0.5).abs() < 1e-3, "spin=1 ISCO should be 0.5, got {}", isco);
}
#[test]
fn kerr_isco_is_monotonically_decreasing() {
let a = physics::kerr_isco(0.3);
let b = physics::kerr_isco(0.6);
let c = physics::kerr_isco(0.9);
assert!(a > b, "0.3 > 0.6: {} vs {}", a, b);
assert!(b > c, "0.6 > 0.9: {} vs {}", b, c);
}
#[test]
fn kerr_horizon_at_zero_is_rs() {
// spin=0 → r+ = Rs = 1.0.
let r = physics::kerr_horizon(0.0);
assert!((r - 1.0).abs() < 1e-3, "spin=0 horizon should be 1.0, got {}", r);
}
#[test]
fn kerr_horizon_at_extremal_is_half_rs() {
// spin=1 → r+ = M = 0.5.
let r = physics::kerr_horizon(1.0);
assert!((r - 0.5).abs() < 1e-3, "spin=1 horizon should be 0.5, got {}", r);
}
#[test]
fn kerr_horizon_is_monotonically_decreasing() {
let a = physics::kerr_horizon(0.3);
let b = physics::kerr_horizon(0.6);
let c = physics::kerr_horizon(0.9);
assert!(a > b, "0.3 > 0.6: {} vs {}", a, b);
assert!(b > c, "0.6 > 0.9: {} vs {}", b, c);
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cargo test --test physics_test`
Expected: FAIL — `kerr_isco` and `kerr_horizon` do not exist (compile error).
- [ ] **Step 3: Implement `kerr_isco` and `kerr_horizon`**
In `src/physics.rs`, add after the existing `impact_parameter` function (before the `#[allow(dead_code)] fn _phantom`):
```rust
/// Prograde Kerr ISCO in Rs units (Rs=1, so M=0.5). `chi = a/M ∈ [0,1]`.
/// Bardeen-Press-Teukolsky (1972) closed form. Returns 6M=3.0 at chi=0,
/// M=0.5 at chi=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 r+ in Rs units (Rs=1, M=0.5). `chi = a/M ∈ [0,1]`.
/// Returns Rs=1.0 at chi=0, M=0.5 at chi=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()
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `cargo test --test physics_test`
Expected: PASS — all 6 Kerr tests + the existing `public_bcrt_constant_is_correct` test pass.
- [ ] **Step 5: Commit**
```bash
git add src/physics.rs tests/physics_test.rs
git commit -m "feat: add kerr_isco and kerr_horizon CPU helpers with tests"
```
---
## Task 4: Make `disk_inner` ISCO-derived in `mirror_params`
**Why before the shader math:** locks the disk-inner-edge behavior to the spin value before the integrator depends on it. At spin=0 disk_inner is still 3.0 (Phase 1 identical).
**Files:**
- Modify: `src/render/plugin.rs` (`mirror_params`)
- [ ] **Step 1: Override `disk_inner` with the ISCO value in `mirror_params`**
In `src/render/plugin.rs`, inside `mirror_params`, find the line `u.disk_inner = params.disk_inner;` (currently `plugin.rs:130`) and replace it with:
```rust
// disk_inner is spin-derived (Kerr ISCO); the params.disk_inner field is ignored.
u.disk_inner = crate::physics::kerr_isco(params.spin);
```
- [ ] **Step 2: Compile and run**
Run: `cargo build`
Expected: compiles.
Run: `cargo run`
Expected: at spin=0 (default) the disk looks identical to Phase 1 (disk_inner = 3.0). No visible change yet.
- [ ] **Step 3: Commit**
```bash
git add src/render/plugin.rs
git commit -m "feat: derive disk_inner from Kerr ISCO"
```
---
## Task 5: Kerr `deriv()` in the shader + spin-dependent capture radius
**The core math change.** After this task, spin>0 visibly bends rays asymmetrically (frame-dragging). spin=0 is bit-identical to Phase 1.
**Files:**
- Modify: `assets/shaders/black_hole.wgsl` (`deriv`, capture test)
- [ ] **Step 1: Replace the `deriv` body with the Kerr pseudo-Hamiltonian**
In `assets/shaders/black_hole.wgsl:110-119`, replace the entire `deriv` function:
```wgsl
fn deriv(pos: vec3<f32>, dir: vec3<f32>) -> Deriv {
let r = length(pos);
let rs = uniforms.rs;
// Kerr spin. χ ∈ [0,1]; a = χ·M, M = Rs/2 = 0.5 (Rs=1).
let chi = uniforms.spin;
let m = 0.5;
let a = chi * m;
// Schwarzschild radial bending (identical to Phase 1 at χ=0).
let h = cross(pos, dir);
let h2 = dot(h, h);
let r5 = max(r * r * r * r * r, 1e-6);
let radial = -1.5 * rs * h2 / r5 * pos;
// Frame-dragging (Lense-Thirring leading term). Spin axis = +Y.
let spin_axis = vec3<f32>(0.0, 1.0, 0.0);
let r3 = max(r * r * r, 1e-6);
let drag = 2.0 * m * a / r3 * cross(spin_axis, dir);
let accel = radial + drag;
return Deriv(dir, accel);
}
```
- [ ] **Step 2: Make the capture radius spin-dependent**
In `assets/shaders/black_hole.wgsl:268-273`, find the capture test:
```wgsl
let r = length(pos);
if (r < uniforms.rs) {
// Captured: whatever we've composited so far is the result.
break;
}
```
Replace the condition with the spin-dependent horizon radius:
```wgsl
let r = length(pos);
// Kerr horizon r+ = M + sqrt(M² - a²), M=0.5, a=χ·M. Equals Rs at χ=0.
let chi = uniforms.spin;
let m = 0.5;
let a = chi * m;
let r_plus = m + sqrt(max(m * m - a * a, 0.0));
if (r < r_plus) {
// Captured: whatever we've composited so far is the result.
break;
}
```
- [ ] **Step 3: Compile**
Run: `cargo build`
Expected: compiles.
- [ ] **Step 4: Add a CPU degeneracy test for the Kerr bending accel**
Append to `tests/physics_test.rs`:
```rust
#[test]
fn kerr_bending_accel_degenerates_to_schwarzschild_at_zero_spin() {
// At χ=0 the Kerr bending accel must equal the Schwarzschild one.
let pos = bevy::math::Vec3::new(3.0, 1.0, 4.0);
let dir = bevy::math::Vec3::new(0.2, -0.1, -0.97).normalize();
let schw = physics::bending_accel(pos, dir);
let kerr = physics::kerr_bending_accel(pos, dir, 0.0);
let diff = (schw - kerr).length();
assert!(diff < 1e-6, "spin=0 Kerr should match Schwarzschild; diff = {}", diff);
}
#[test]
fn kerr_bending_accel_nonzero_off_axis_at_nonzero_spin() {
// At χ>0 the drag term must produce a different accel (frame-dragging exists).
let pos = bevy::math::Vec3::new(3.0, 1.0, 4.0);
let dir = bevy::math::Vec3::new(0.2, -0.1, -0.97).normalize();
let schw = physics::bending_accel(pos, dir);
let kerr = physics::kerr_bending_accel(pos, dir, 0.8);
let diff = (schw - kerr).length();
assert!(diff > 1e-4, "spin=0.8 Kerr should differ from Schwarzschild; diff = {}", diff);
}
```
- [ ] **Step 5: Add `kerr_bending_accel` to `src/physics.rs`**
In `src/physics.rs`, after `bending_accel`, add:
```rust
/// Kerr bending acceleration (CPU mirror of the shader `deriv` accel).
/// `chi = a/M ∈ [0,1]`. At chi=0 this equals `bending_accel`.
pub fn kerr_bending_accel(pos: Vec3, dir: Vec3, chi: f32) -> Vec3 {
let r = pos.length();
let m = 0.5;
let a = chi * m;
let h = pos.cross(dir);
let h2 = h.dot(h);
let r5 = (r * r * r * r * r).max(1e-6);
let radial = -1.5 * RS * h2 / r5 * pos;
let spin_axis = Vec3::Y;
let r3 = (r * r * r).max(1e-6);
let drag = 2.0 * m * a / r3 * spin_axis.cross(dir);
radial + drag
}
```
- [ ] **Step 6: Run tests**
Run: `cargo test --test physics_test`
Expected: PASS — all tests including the two new degeneracy tests.
- [ ] **Step 7: Run the app and verify spin=0 is unchanged, spin>0 shows asymmetry**
Run: `cargo run`
Expected: at default (spin=0) the image is identical to Phase 1. There is no Spin UI yet — to test spin>0, temporarily add `params.spin = 0.5;` in `params.rs` `Default`, run, observe the disk halo is no longer mirror-symmetric, then revert the default back to `0.0`.
- [ ] **Step 8: Commit**
```bash
git add assets/shaders/black_hole.wgsl src/physics.rs tests/physics_test.rs
git commit -m "feat: Kerr deriv() with frame-dragging + spin-dependent horizon"
```
---
## Task 6: Adaptive RK45 integrator shell
**Replaces the fixed-step RK4 loop.** This is the second structural change after Task 1. The crossing tests (disk/grid/planet) and compositing stay identical — they run on each *accepted* RK45 segment.
**Files:**
- Modify: `assets/shaders/black_hole.wgsl` (integration loop)
- [ ] **Step 1: Add the Dormand-Prince RK45 step function**
In `assets/shaders/black_hole.wgsl`, add immediately before the `@fragment fn fragment` entry point (after the `grid_hit` function, before `// ====================== main ======================`):
```wgsl
// One Dormand-Prince RK45 step. Returns the 5th-order solution and the
// error estimate (y5 - y4) as a vec3 (position error; direction error is
// folded in via normalize so we only need position error for step control).
struct RkStep {
pos: vec3<f32>,
dir: vec3<f32>,
err: f32,
};
fn rk45_step(pos: vec3<f32>, dir: vec3<f32>, dt: f32) -> RkStep {
// Butcher tableau (Dormand-Prince), 6 stages. Each deriv() returns Deriv{dpos, ddir}.
let k1 = deriv(pos, dir);
let p2 = pos + k1.dpos * dt * 0.2;
let d2 = normalize(dir + k1.ddir * dt * 0.2);
let k2 = deriv(p2, d2);
let p3 = pos + (k1.dpos * 0.075 + k2.dpos * 0.225) * dt;
let d3 = normalize(dir + (k1.ddir * 0.075 + k2.ddir * 0.225) * dt);
let k3 = deriv(p3, d3);
let p4 = pos + (k1.dpos * 0.3 + k2.dpos * -0.9 + k3.dpos * 1.2) * dt;
let d4 = normalize(dir + (k1.ddir * 0.3 + k2.ddir * -0.9 + k3.ddir * 1.2) * dt);
let k4 = deriv(p4, d4);
let p5 = pos + (k1.dpos * -11.0/54.0 + k2.dpos * 2.5 + k3.dpos * -70.0/27.0 + k4.dpos * 35.0/27.0) * dt;
let d5 = normalize(dir + (k1.ddir * -11.0/54.0 + k2.ddir * 2.5 + k3.ddir * -70.0/27.0 + k4.ddir * 35.0/27.0) * dt);
let k5 = deriv(p5, d5);
let p6 = pos + (k1.dpos * 1631.0/55296.0 + k2.dpos * 175.0/512.0 + k3.dpos * 575.0/13824.0 + k4.dpos * 44275.0/110592.0 + k5.dpos * 253.0/4096.0) * dt;
let d6 = normalize(dir + (k1.ddir * 1631.0/55296.0 + k2.ddir * 175.0/512.0 + k3.ddir * 575.0/13824.0 + k4.ddir * 44275.0/110592.0 + k5.ddir * 253.0/4096.0) * dt);
let k6 = deriv(p6, d6);
// 5th-order solution (used to advance).
let new_pos = pos + (k1.dpos * 37.0/378.0 + k3.dpos * 250.0/621.0 + k4.dpos * 125.0/594.0 + k5.dpos * 512.0/1771.0 + k6.dpos * 0.0) * dt;
let new_dir = normalize(dir + (k1.ddir * 37.0/378.0 + k3.ddir * 250.0/621.0 + k4.ddir * 125.0/594.0 + k5.ddir * 512.0/1771.0 + k6.ddir * 0.0) * dt);
// 4th-order solution (for error estimate).
let pos4 = pos + (k1.dpos * 2825.0/27648.0 + k3.dpos * 18575.0/48384.0 + k4.dpos * 13525.0/55296.0 + k5.dpos * 277.0/14336.0 + k6.dpos * 0.25) * dt;
let err = length(new_pos - pos4);
return RkStep(new_pos, new_dir, err);
}
```
- [ ] **Step 2: Replace the integration loop with the adaptive RK45 loop**
In `assets/shaders/black_hole.wgsl`, find the integration loop (the section starting around `// Total path length to integrate:` at line ~256 through the end of the `for` loop at ~323). Replace from the line `let dt = total_path / f32(uniforms.steps);` through the closing brace of the `for` loop with:
```wgsl
let total_path = eye_dist + escape_r;
// Adaptive RK45 constants.
let steps_max = uniforms.steps;
let dt_init = total_path / f32(steps_max);
let dt_min = dt_init * 0.25;
let dt_max = dt_init * 4.0;
let tol = 1e-3;
let r_plus = 0.5 + sqrt(max(0.25 - (uniforms.spin * 0.5) * (uniforms.spin * 0.5), 0.0));
var pos = rot_x(uniforms.eye.xyz, -uniforms.disk_tilt);
var d = normalize(rot_x(dir, -uniforms.disk_tilt));
var dt = dt_init;
var prev = pos;
var budget = steps_max;
var accum_color = vec3<f32>(0.0);
var accum_alpha = 0.0;
loop {
if (budget == 0u) { break; }
let step = rk45_step(pos, d, dt);
let err = step.err;
if (err > tol * 10.0) {
// Reject: shrink dt, retry (does not consume budget).
dt = clamp(dt * 0.2, dt_min, dt_max);
continue;
}
// Accept: consume one budget unit, refine dt.
budget = budget - 1u;
dt = clamp(dt * pow(tol / max(err, 1e-12), 0.2), dt_min, dt_max);
let new_pos = step.pos;
let new_dir = step.dir;
let r = length(new_pos);
if (r < r_plus) {
break;
}
if (r > escape_r) {
let world_dir = normalize(rot_x(new_dir, uniforms.disk_tilt));
var bg = vec3<f32>(0.0);
bg += star_color(world_dir, uniforms.star_intensity);
if (uniforms.skybox_intensity > 0.0) {
bg += skybox_color(world_dir) * uniforms.skybox_intensity;
}
accum_color += (1.0 - accum_alpha) * bg;
accum_alpha = 1.0;
break;
}
if (disk_hit(prev, new_pos)) {
let ty = prev.y / (prev.y - new_pos.y);
let hit = mix(prev, new_pos, vec3<f32>(ty));
let dc = disk_color(hit, new_dir);
let a = 0.85;
accum_color += (1.0 - accum_alpha) * dc * a;
accum_alpha += (1.0 - accum_alpha) * a;
if (accum_alpha > 0.99) { break; }
}
let ph = planet_hit(prev, new_pos, new_dir);
if (ph.w > 0.0) {
accum_color += (1.0 - accum_alpha) * ph.xyz * ph.w;
accum_alpha += (1.0 - accum_alpha) * ph.w;
if (accum_alpha > 0.99) { break; }
}
if (uniforms.grid_enabled != 0u) {
let g = grid_hit(prev, new_pos);
if (g.x + g.y + g.z > 0.0) {
accum_color += g;
}
}
prev = new_pos;
pos = new_pos;
d = new_dir;
}
return vec4<f32>(accum_color, 1.0);
```
This replaces everything from the old `var pos = rot_x(...)` through the old `return vec4<f32>(accum_color, 1.0);`. Delete the old `for` loop and the old fixed-step RK4 body entirely. The `escape_r`, `eye_dist`, `disk_tilt`, compositing, and crossing-test calls are all preserved — only the loop machinery changes.
- [ ] **Step 3: Compile**
Run: `cargo build`
Expected: compiles.
- [ ] **Step 4: Run and verify spin=0 still looks right, then tune**
Run: `cargo run`
Expected: at spin=0 the image matches Phase 1 closely (adaptive stepping may produce very slightly different secondary-image detail, but the shadow, halo, and disk are visually equivalent). Rays near the photon sphere take small steps; far-field rays take large steps.
If the Einstein ring looks noisy/jagged, raise `tol` toward `5e-4` (tighter) is wrong direction — instead increase `steps` in the UI, or loosen `tol` toward `2e-3` if too many rays terminate early. Default `tol = 1e-3` is the spec value; only deviate if a real artifact appears.
- [ ] **Step 5: Commit**
```bash
git add assets/shaders/black_hole.wgsl
git commit -m "feat: adaptive RK45 integrator replacing fixed-step RK4"
```
---
## Task 7: UI — Spin slider + ISCO/Horizon read-only labels
**Files:**
- Modify: `src/ui.rs`
- [ ] **Step 1: Add the Black Hole section and remove the disk_inner slider**
In `src/ui.rs`, inside the `egui::Window::new("Controls")` closure, add a new collapsing header *before* the "Accretion Disk" header, and modify the "Accretion Disk" header to remove the `disk_inner` slider (it is now spin-derived).
Replace the block starting at `egui::CollapsingHeader::new("Accretion Disk")` (currently `ui.rs:23-31`) — insert the new "Black Hole" header before it and edit the disk section:
```rust
egui::CollapsingHeader::new("Black Hole")
.default_open(true)
.show(ui, |ui| {
ui.add(egui::Slider::new(&mut params.spin, 0.0..=1.0).text("Spin (χ)"));
ui.label(format!("ISCO (disk inner): {:.3}", crate::physics::kerr_isco(params.spin)));
ui.label(format!("Horizon r+: {:.3}", crate::physics::kerr_horizon(params.spin)));
});
egui::CollapsingHeader::new("Accretion Disk")
.default_open(true)
.show(ui, |ui| {
// disk_inner removed — now spin-derived (see Black Hole section).
ui.add(egui::Slider::new(&mut params.disk_outer, 6.0..=40.0).text("Outer radius"));
ui.add(egui::Slider::new(&mut params.disk_tilt, 0.0..=3.14).text("Tilt"));
ui.add(egui::Slider::new(&mut params.disk_brightness, 0.0..=3.0).text("Brightness"));
ui.add(egui::Slider::new(&mut params.disk_rotation_speed, 0.0..=3.0).text("Rotation speed"));
});
```
- [ ] **Step 2: Compile and run**
Run: `cargo build`
Expected: compiles.
Run: `cargo run`
Expected: the Controls panel has a new "Black Hole" section with a Spin slider (01) and two read-only ISCO/Horizon labels that update live as the slider moves. The "Accretion Disk" section no longer has an "Inner radius" slider. Sweeping spin from 0 to 0.9 visibly shrinks the disk inner edge and introduces frame-dragging asymmetry.
- [ ] **Step 3: Commit**
```bash
git add src/ui.rs
git commit -m "feat: add Spin slider + ISCO/Horizon read-only labels"
```
---
## Task 8: Validation — visual milestones + performance + web build
**Files:** none (verification only).
- [ ] **Step 1: spin=0 regression check**
Run: `cargo run`. Set spin=0 in the UI.
Expected: image is visually indistinguishable from Phase 1 (same shadow size ≈ bcrit, same halo, same Doppler side).
- [ ] **Step 2: Frame-dragging asymmetry at spin=0.5**
Set spin=0.5 in the UI.
Expected: the disk's bright Doppler side shears off the pure line-of-sight axis; the halo is no longer mirror-smetric across the spin axis (the +Y axis).
- [ ] **Step 3: ISCO shrink sweep**
Slowly sweep spin from 0 → 0.9.
Expected: disk inner edge visibly pulls inward (the read-only ISCO label confirms the radius dropping from 3.0 toward ~0.7).
- [ ] **Step 4: Higher-order Einstein ring still forms**
At spin=0.5, steps=300, look near the photon sphere edge.
Expected: secondary Einstein images still form (budget does not starve them at default steps).
- [ ] **Step 5: Desktop performance**
Run: `cargo run --release`. Default settings (render_scale=0.75, steps=300, spin=0.5).
Expected: ≥60 fps on a typical discrete GPU. If below, lower render_scale to 0.5 or steps to 200 in the UI and re-check.
- [ ] **Step 6: Grid/planets/skybox no regression**
Toggle Grid on; add a planet near the hole (if the planet feature is wired); verify they still render correctly at spin>0 (lensed through the Kerr geodesic).
- [ ] **Step 7: Full test suite**
Run: `cargo test`
Expected: all tests pass (Phase 1 bcrit tests + all Kerr tests).
- [ ] **Step 8: Web build**
Run: `trunk build --release`
Expected: wasm artifact builds. Open in Chrome/Edge; verify it renders at render_scale=0.5, steps=200, ≥30 fps, and the egui panel works. A non-WebGPU browser shows the fallback message.
- [ ] **Step 9: Final commit (if any tuning changes were made)**
If any defaults were tuned during validation, commit them:
```bash
git add -A
git commit -m "chore: Phase 2 tuning from validation"
```
If no changes, skip this step.
---
## Phase 2 complete — acceptance checklist
- [ ] spin=0 is visually identical to Phase 1 (no regression).
- [ ] spin>0 shows frame-dragging asymmetry (disk halo no longer mirror-symmetric across the spin axis).
- [ ] Disk inner edge tracks Kerr ISCO (Bardeen formula), shrinking from 3 Rs at spin=0 toward 0.5 Rs at extremal.
- [ ] Horizon radius shrinks with spin (r+ = M + sqrt(M²a²)).
- [ ] Adaptive RK45 integrator runs; render_scale=0.75 desktop / 0.5 web.
- [ ] All Phase 1 features (disk, Doppler, stars, grid, planets, skybox) work at spin>0.
- [ ] `cargo test` passes (Phase 1 + Kerr degeneracy/ISCO/horizon tests).
- [ ] Desktop ≥60 fps at default Phase 2 settings; web ≥30 fps on WebGPU.
- [ ] egui Spin slider + ISCO/Horizon read-only labels work; disk_inner slider removed.
---
## Phase 3 follow-up (out of scope; documented)
- Full exact Kerr Cartesian pseudo-Hamiltonian (Σ/Δ/Carter-separable form) for <1% photon-orbit accuracy at high spin.
- Negative spin / retrograde disk.
- Adaptive integrator *order* (currently fixed RK45 order, adaptive step only).
- Tilted spin axis (would break the y=0 disk assumption).