916 lines
34 KiB
Markdown
916 lines
34 KiB
Markdown
# 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 (0–1) 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).
|