docs: Phase 2 (Kerr) implementation plan

This commit is contained in:
xfy 2026-07-13 18:36:12 +08:00
parent 0693c3d165
commit 15cbf52fb6

View File

@ -0,0 +1,915 @@
# 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).