docs: sync project status to shipped Phase 2 (Kerr) reality

The docs described Phase 2 as 'future work' while the code had already
shipped it: spin is wired through the uniform into the shader's Kerr deriv(),
the integrator is adaptive RK45, disk_inner is ISCO-derived, render_scale
runs a real offscreen render-to-texture + upscale pass, and the UI exposes a
Spin slider with live ISCO/Horizon readouts. Anyone reading the docs got a
wrong picture of completeness.

README:
- Intro: 'Schwarzschild' -> 'black-hole (Kerr at spin>0, Schwarzschild at 0)'.
- How it works: RK4 -> adaptive Dormand-Prince RK45; add the frame-dragging
  term to the bending accel; capture radius r+(chi); ISCO-tracked inner edge.
- Controls: add Black Hole section (Spin, ISCO/Horizon); render_scale slider.
- Performance: render_scale now wired (0.75 desktop / 0.5 web), not reserved.
- Status: Phase 1 shipped; Phase 2 implemented-pending-human-validation;
  Phase 3 (exact pseudo-Hamiltonian) is the actual future work.

AGENTS.md:
- Intro + mirror contract: name the Kerr/RK45 functions the mirror must track
  (kerr_bending_accel, rk45_step, is_captured_rk45) and the exact shader lines.
- Conventions: 'render_scale and spin are reserved' is false now — both wired;
  update the perf-lever and web-defaults notes accordingly.
- Reference docs: list both Phase 2 docs, not just Phase 1.

Phase 2 plan (docs/superpowers/plans/2026-07-13-...-kerr.md):
- Status banner: Tasks 1-7 implemented+committed, Task 8 (human visual/perf
  checklist) is what remains.
- Check the Task 1-7 step/commit boxes; leave all Task 8 manual steps and the
  visual/fps acceptance items unchecked. Check only the code-verifiable
  acceptance items (cargo test green, RK45+render_scale running, Spin slider
  shipped).
This commit is contained in:
xfy 2026-07-14 15:52:49 +08:00
parent 334e65fc76
commit d5c0998537
3 changed files with 71 additions and 63 deletions

View File

@ -1,6 +1,6 @@
# AGENTS.md # AGENTS.md
A real-time Schwarzschild black-hole renderer in Bevy 0.19. One binary, two targets: desktop and web/WebGPU. A real-time Kerr (spinning) black-hole renderer in Bevy 0.19. One binary, two targets: desktop and web/WebGPU. Spin χ = 0 degenerates exactly to Schwarzschild.
## Build & run ## Build & run
@ -17,9 +17,9 @@ A real-time Schwarzschild black-hole renderer in Bevy 0.19. One binary, two targ
## Architecture: the CPU ↔ shader mirror ## Architecture: the CPU ↔ shader mirror
The real renderer is a single full-screen quad running `assets/shaders/black_hole.wgsl` (RK4 geodesic integration + disk/planets/grid/star compositing). `src/physics.rs` is a hand-maintained **CPU mirror** of that integrator, kept only so the capture-vs-escape boundary is unit-testable. The real renderer is a single full-screen quad running `assets/shaders/black_hole.wgsl` (Kerr geodesic integration via an adaptive Dormand-Prince RK45 loop + disk/planets/grid/star compositing), rendered into a sub-resolution offscreen `Image` and upscaled to the window by a second camera (`render_scale`). `src/physics.rs` is a hand-maintained **CPU mirror** of that integrator, kept so the capture-vs-escape boundary is unit-testable on the CPU.
**Changing physics in one place means updating the other.** `bending_accel` / `is_captured` in `physics.rs` must stay in lockstep with the shader's `deriv`/step loop, or the tests will pass on code that the shader contradicts. **Changing physics in one place means updating the other.** `bending_accel` / `kerr_bending_accel` / `rk45_step` / `is_captured` / `is_captured_rk45` in `physics.rs` must stay in lockstep with the shader's `deriv` / `rk45_step` / integration loop, or the tests will pass on code that the shader contradicts. The mirror covers: the single-step Kerr derivative (`kerr_bending_accel``deriv`), the adaptive step (`rk45_step` ↔ shader `rk45_step`), and the full loop (`is_captured_rk45` ↔ the `loop` at `black_hole.wgsl:320-390`, including the budget = accepted-steps-only rule and the `dt_min` forced-accept floor).
Module wiring (entrypoints): Module wiring (entrypoints):
- `main.rs` — app entry, web fallback gate, plugin wiring (`render::BlackHolePlugin`). - `main.rs` — app entry, web fallback gate, plugin wiring (`render::BlackHolePlugin`).
@ -44,9 +44,9 @@ When debugging a blank/grey screen, check these three before the shader.
## Conventions ## Conventions
- **Natural units: `Rs = 1`** throughout (Rust + WGSL). `BCRIT = 3√3/2·Rs ≈ 2.598` is a literal in `physics.rs` because `f32::sqrt` isn't `const`; the integration test guards the literal. - **Natural units: `Rs = 1`** throughout (Rust + WGSL). `BCRIT = 3√3/2·Rs ≈ 2.598` is a literal in `physics.rs` because `f32::sqrt` isn't `const`; the integration test guards the literal.
- **`render_scale` and `spin` are reserved, not wired.** `render_scale` does not map to a real sub-resolution target (README documents this); `spin` is Phase 2 (Kerr). Both carry `#[allow(dead_code)]` deliberately — don't treat them as missing work. - **`spin` and `render_scale` are both wired (Phase 2).** `spin` (dimensionless χ = a/M ∈ [0,1]) drives the Kerr frame-dragging term in the shader's `deriv` and a spin-dependent capture radius (`r₊`); the disk inner edge is derived from `kerr_isco(spin)` in `mirror_params`. `render_scale` renders the black-hole quad into an offscreen `Image` at sub-resolution and a second camera upscales it (see `render/plugin.rs`: `OffscreenTarget` / `OffscreenCamera` / `UpscaleCamera`). The `#[allow(dead_code)]` on `BlackHoleParams` is now only for `spin`'s historical reservation — both fields are live.
- **`steps` is the real performance/quality lever**, not `render_scale`. Lower it in the Controls panel for FPS. - **Both `steps` and `render_scale` are performance levers.** `steps` caps *accepted* RK45 steps per ray; `render_scale` lowers the offscreen resolution. The RK45 integrator is ~an order of magnitude costlier than Phase 1's fixed-step RK4, so the default `render_scale` dropped to 0.75 (desktop) / 0.5 (web).
- **Web defaults differ** via `cfg!(target_arch = "wasm32")`: `steps` 200 (web) vs 300 (desktop), `render_scale` 0.75 vs 1.0. - **Web defaults differ** via `cfg!(target_arch = "wasm32")`: `steps` 200 (web) vs 300 (desktop), `render_scale` 0.5 vs 0.75.
## Git workflow ## Git workflow
@ -57,8 +57,10 @@ When debugging a blank/grey screen, check these three before the shader.
## Reference docs ## Reference docs
- `README.md` — controls, how-it-works, project layout, status. - `README.md` — controls, how-it-works, project layout, status.
- `docs/superpowers/specs/2026-07-09-interstellar-blackhole-design.md`design spec incl. Phase 2 (Kerr) plan. - `docs/superpowers/specs/2026-07-09-interstellar-blackhole-design.md`Phase 1 (Schwarzschild) design spec.
- `docs/superpowers/plans/2026-07-09-interstellar-blackhole-phase1.md` — Phase 1 implementation plan. - `docs/superpowers/plans/2026-07-09-interstellar-blackhole-phase1.md` — Phase 1 implementation plan.
- `docs/superpowers/specs/2026-07-13-interstellar-blackhole-phase2-kerr-design.md` — Phase 2 (Kerr) design spec.
- `docs/superpowers/plans/2026-07-13-interstellar-blackhole-phase2-kerr.md` — Phase 2 implementation plan (Tasks 17 done; Task 8 is the human visual/perf validation).
## Skills ## Skills

View File

@ -1,9 +1,11 @@
# singularity-rs # singularity-rs
A real-time, physically-motivated **Schwarzschild black-hole renderer** in [Bevy](https://bevyengine.org) 0.19. Each pixel geodesic-ray-traces curved spacetime, producing gravitational lensing, the Einstein ring, a Doppler-beamed accretion disk, a lensed starfield, and optional lensed planets, a spacetime-curvature (Flamm) grid, and a cubemap skybox. Runs on **desktop** and the **web** (WebGPU). A real-time, physically-motivated **black-hole renderer** in [Bevy](https://bevyengine.org) 0.19. Each pixel geodesic-ray-traces curved spacetime, producing gravitational lensing, the Einstein ring, a Doppler-beamed accretion disk, a lensed starfield, and optional lensed planets, a spacetime-curvature (Flamm) grid, and a cubemap skybox. Runs on **desktop** and the **web** (WebGPU).
The visual target is Gargantua from *Interstellar*: a black shadow surrounded by a tilted, glowing accretion disk whose back side is lensed up and over the hole (and down underneath), with one side brighter from relativistic Doppler beaming. The visual target is Gargantua from *Interstellar*: a black shadow surrounded by a tilted, glowing accretion disk whose back side is lensed up and over the hole (and down underneath), with one side brighter from relativistic Doppler beaming.
The integrator is a spinning (Kerr) geodesic: a dimensionless spin parameter χ ∈ [0,1] drives frame-dragging (Lense-Thirring) asymmetry and pulls the disk's inner edge inward along the Kerr ISCO. At χ = 0 it degenerates exactly to the Schwarzschild (non-spinning) case.
## Run ## Run
**Desktop** (Vulkan / Metal / D3D12): **Desktop** (Vulkan / Metal / D3D12):
@ -26,9 +28,10 @@ Release web build: `trunk build --release`. On a browser without WebGPU, the pag
- **Scroll** — zoom (changes distance / impact parameter). - **Scroll** — zoom (changes distance / impact parameter).
- **Controls panel** (top-left) — live-tune every parameter: - **Controls panel** (top-left) — live-tune every parameter:
- **Camera** — distance, yaw, pitch, FOV. - **Camera** — distance, yaw, pitch, FOV.
- **Accretion Disk** — inner/outer radius, tilt, brightness, rotation speed. - **Black Hole** — spin (χ), with live ISCO (disk inner edge) and horizon (r+) readouts.
- **Accretion Disk** — outer radius, tilt, brightness, rotation speed (inner radius is spin-derived ISCO).
- **Doppler** — enable + strength of the relativistic beaming asymmetry. - **Doppler** — enable + strength of the relativistic beaming asymmetry.
- **Renderer** — integrator step count (the main quality/perf lever). - **Renderer** — integrator step count (quality/perf lever) and render scale (sub-resolution offscreen target).
- **Background** — procedural star intensity, optional cubemap skybox intensity. - **Background** — procedural star intensity, optional cubemap skybox intensity.
- **Grid** — toggle the lensed Flamm-paraboloid curvature grid + density. - **Grid** — toggle the lensed Flamm-paraboloid curvature grid + density.
@ -39,19 +42,17 @@ Orbit input is automatically disabled while the cursor is over the panel.
A single full-screen quad carries a custom `Material2d` whose fragment shader, for every pixel: A single full-screen quad carries a custom `Material2d` whose fragment shader, for every pixel:
1. Generates a primary ray from the camera basis + FOV. 1. Generates a primary ray from the camera basis + FOV.
2. Integrates the ray through Schwarzschild spacetime with RK4, applying the discretized bending acceleration `a = -1.5·Rs·h²/r⁵ · pos` (`h` = angular momentum). 2. Integrates the ray through Kerr spacetime with an **adaptive Dormand-Prince RK45** loop, applying the discretized bending acceleration `a = -1.5·Rs·h²/r⁵ · pos + 2·M·a/r³ · (spin_axis × dir)` (`h` = angular momentum, `a = χ·M` the Kerr spin length). At χ = 0 the frame-dragging term vanishes and this is exactly the Schwarzschild bending.
3. At each step tests the bent segment against the accretion disk (equatorial plane), lensed planets (storage buffer), and the Flamm paraboloid grid surface — compositing hits front-to-back. 3. At each accepted step tests the bent segment against the accretion disk (equatorial plane), lensed planets (storage buffer), and the Flamm paraboloid grid surface — compositing hits front-to-back. Rejected steps (error above tolerance, `dt` still above its floor) retry at a smaller step without consuming the ray's step budget.
4. Terminates on capture (`r < Rs`, the shadow emerges naturally at the critical impact parameter `b_crit = 3√3/2·Rs ≈ 2.598`) or escape (samples the procedural starfield / cubemap along the bent final direction). 4. Terminates on capture (`r < r₊(χ)`, the spin-dependent horizon; at χ = 0 this is `Rs`, and the shadow emerges naturally at the critical impact parameter `b_crit = 3√3/2·Rs ≈ 2.598`) or escape (samples the procedural starfield / cubemap along the bent final direction).
The disk is tilted relative to the camera, so the back of the disk is lensed over the top and under the bottom of the shadow — the characteristic "halo." Doppler beaming brightens the approaching side. The disk is tilted relative to the camera, so the back of the disk is lensed over the top and under the bottom of the shadow — the characteristic "halo." Doppler beaming brightens the approaching side. At spin > 0 the disk's inner edge tracks the Kerr ISCO (shrinking from 3 Rs at χ = 0 toward Rs/2 at extremal spin) and frame-dragging shears the halo off the line-of-sight axis.
The CPU-side physics (`src/physics.rs`) mirrors the integrator and is unit-tested: `b < b_crit` rays are captured, `b > b_crit` rays escape. The CPU-side physics (`src/physics.rs`) mirrors the integrator — both the single-step Kerr derivative and the adaptive RK45 loop with its spin-dependent capture radius — and is unit-tested: `b < b_crit` rays are captured, `b > b_crit` rays escape, spin = 0 degenerates to Schwarzschild, and higher spin does not enlarge the capture set.
## Performance ## Performance
Defaults target ~60 FPS at full resolution on a discrete/integrated GPU (developed on Apple M4). On web, defaults drop to `steps=200` for interactivity. If you need more FPS, lower **Steps** in the Controls panel. Defaults target ~60 FPS on a discrete/integrated GPU (developed on Apple M4). The Kerr + adaptive RK45 integrator costs roughly an order of magnitude more per pixel than fixed-step RK4, so the default `render_scale` is 0.75 on desktop (the quad renders into a sub-resolution offscreen target that is then upscaled to the window). On web, defaults drop to `steps=200` and `render_scale=0.5` for interactivity. If you need more FPS, lower **Steps** or **Render scale** in the Controls panel.
Note: the `render_scale` parameter exists but is **not wired to a real sub-resolution render target in Phase 1** (the full-screen quad always renders at window resolution). It is reserved for future work; **Steps** is the real performance lever today.
## Project layout ## Project layout
@ -60,17 +61,19 @@ src/
main.rs app entry, plugin wiring main.rs app entry, plugin wiring
camera.rs orbit controller (yaw/pitch/zoom) + WantsPointer camera.rs orbit controller (yaw/pitch/zoom) + WantsPointer
params.rs BlackHoleParams (tunable, mirrored to GPU each frame) params.rs BlackHoleParams (tunable, mirrored to GPU each frame)
physics.rs CPU mirror of the geodesic integrator (unit-tested) physics.rs CPU mirror of the geodesic integrator: Kerr deriv, adaptive RK45 loop, ISCO/horizon (unit-tested)
ui.rs egui Controls panel (collapsible sections) ui.rs egui Controls panel (collapsible sections)
web.rs wasm glue: WebGPU detection + fallback message web.rs wasm glue: WebGPU detection + fallback message
scene/planets.rs Planet component + storage-buffer upload scene/planets.rs Planet component + storage-buffer upload
render/ BlackHolePlugin, material, fullscreen quad render/ BlackHolePlugin, material, offscreen + upscale cameras (render_scale)
assets/shaders/ WGSL: ray gen, Schwarzschild RK4, disk, stars, planets, grid, skybox assets/shaders/ WGSL: ray gen, Kerr RK45, disk, stars, planets, grid, skybox, upscale blit
docs/superpowers/ design spec + implementation plan docs/superpowers/ design spec + implementation plan
``` ```
## Status ## Status
**Phase 1 (Schwarzschild, this codebase)** — complete: shadow, tilted Doppler accretion disk with lensed Einstein halo, lensed starfield, lensed planets, lensed Flamm grid, optional cubemap skybox, live egui controls, desktop + web/WebGPU. **Phase 1 (Schwarzschild)** — shipped: shadow, tilted Doppler accretion disk with lensed Einstein halo, lensed starfield, lensed planets, lensed Flamm grid, optional cubemap skybox, live egui controls, desktop + web/WebGPU.
**Phase 2 (Kerr / true Gargatua) — future work.** Replace the Schwarzschild integrator with the Kerr metric (Boyer-Lindquist, adaptive RK4, spin parameter) for frame-dragging and ergosphere asymmetry. Same scene elements, camera, params, and UI carry over; only the integrator core swaps. See `docs/superpowers/specs/2026-07-09-interstellar-blackhole-design.md`. **Phase 2 (Kerr)** — **implemented, pending visual/performance validation.** The Schwarzschild fixed-step RK4 integrator was replaced by a Kerr geodesic with an adaptive Dormand-Prince RK45 loop: a spin parameter χ ∈ [0,1] drives frame-dragging and a spin-dependent capture radius (Kerr horizon), the disk inner edge tracks the Kerr ISCO, and `render_scale` is now wired through an offscreen render-to-texture + upscale pass. The CPU mirror (`src/physics.rs`) covers the Kerr derivative, the RK45 step, and the adaptive loop, and the unit tests assert the spin = 0 degeneracy plus capture/escape behavior at spin > 0. What remains is the human-in-the-loop checklist: spin = 0 visual regression against Phase 1, frame-dragging asymmetry at spin > 0, and desktop/web FPS at the new defaults — see `docs/superpowers/plans/2026-07-13-interstellar-blackhole-phase2-kerr.md` (Task 8).
**Phase 3 (future work).** Full exact Kerr Cartesian pseudo-Hamiltonian (Σ/Δ/Carter-separable form) for sub-percent photon-orbit accuracy at high spin, retrograde spin, tilted spin axis, and adaptive integrator *order*. See `docs/superpowers/specs/2026-07-13-interstellar-blackhole-phase2-kerr-design.md` §9.

View File

@ -10,6 +10,9 @@
**Spec:** `docs/superpowers/specs/2026-07-13-interstellar-blackhole-phase2-kerr-design.md` **Spec:** `docs/superpowers/specs/2026-07-13-interstellar-blackhole-phase2-kerr-design.md`
> **Status (2026-07-14):** Tasks 17 are implemented and committed; `cargo test` is green (17 tests). Task 8 remains — it is the human-in-the-loop visual + performance checklist (spin=0 regression, frame-dragging asymmetry, desktop/web FPS), which the code cannot self-verify. Items below are checked to reflect shipped code; unchecked acceptance items are the remaining human verification.
**Verified API facts (do not deviate):** **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 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). - 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).
@ -48,7 +51,7 @@ tests/
- Modify: `src/render/material.rs` (add `UpscaleMaterial`) - Modify: `src/render/material.rs` (add `UpscaleMaterial`)
- Modify: `src/render/plugin.rs` (offscreen + upscale cameras, resize system) - Modify: `src/render/plugin.rs` (offscreen + upscale cameras, resize system)
- [ ] **Step 1: Write the upscale WGSL shader** - [x] **Step 1: Write the upscale WGSL shader**
Create `assets/shaders/upscale.wgsl`: Create `assets/shaders/upscale.wgsl`:
@ -65,7 +68,7 @@ fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
} }
``` ```
- [ ] **Step 2: Add `UpscaleMaterial` to `material.rs`** - [x] **Step 2: Add `UpscaleMaterial` to `material.rs`**
In `src/render/material.rs`, add after `BlackHoleMaterial` (before `impl Default for BlackHoleMaterial`): In `src/render/material.rs`, add after `BlackHoleMaterial` (before `impl Default for BlackHoleMaterial`):
@ -88,7 +91,7 @@ impl Material2d for UpscaleMaterial {
`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. `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** - [x] **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;`): 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;`):
@ -183,7 +186,7 @@ fn spawn_fullscreen_quad(
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.) 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** - [x] **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: In `src/render/plugin.rs`, delete the existing `fit_quad_to_window` (`plugin.rs:79-93`) and add:
@ -226,7 +229,7 @@ Then update the `Update` system set in `BlackHolePlugin::build` (`plugin.rs:23-3
.add_plugins(Material2dPlugin::<crate::render::material::UpscaleMaterial>::default()) .add_plugins(Material2dPlugin::<crate::render::material::UpscaleMaterial>::default())
``` ```
- [ ] **Step 5: Add `render_scale` to the egui Renderer section** - [x] **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: In `src/ui.rs`, inside the `"Renderer"` `CollapsingHeader` (currently `ui.rs:36-41`), replace the comment block with a live slider:
@ -237,7 +240,7 @@ egui::CollapsingHeader::new("Renderer").show(ui, |ui| {
}); });
``` ```
- [ ] **Step 6: Bump `render_scale` defaults for Phase 2** - [x] **Step 6: Bump `render_scale` defaults for Phase 2**
In `src/params.rs` (`params.rs:44`), change: In `src/params.rs` (`params.rs:44`), change:
@ -257,7 +260,7 @@ Also update the `#[allow(dead_code)]` attribute at `params.rs:6` — remove `ren
#[allow(dead_code)] // spin is reserved for Phase 2 (Kerr); render_scale now wired in Phase 2 #[allow(dead_code)] // spin is reserved for Phase 2 (Kerr); render_scale now wired in Phase 2
``` ```
- [ ] **Step 7: Compile and run** - [x] **Step 7: Compile and run**
Run: `cargo build` Run: `cargo build`
Expected: compiles with no errors. (Warnings about unused `UpscaleCamera`/`OffscreenCamera` markers are fine — they're used for querying.) Expected: compiles with no errors. (Warnings about unused `UpscaleCamera`/`OffscreenCamera` markers are fine — they're used for querying.)
@ -265,7 +268,7 @@ Expected: compiles with no errors. (Warnings about unused `UpscaleCamera`/`Offsc
Run: `cargo run` 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. 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** - [x] **Step 8: Commit**
```bash ```bash
git add assets/shaders/upscale.wgsl src/render/material.rs src/render/plugin.rs src/ui.rs src/params.rs git add assets/shaders/upscale.wgsl src/render/material.rs src/render/plugin.rs src/ui.rs src/params.rs
@ -283,7 +286,7 @@ git commit -m "feat: wire render_scale via offscreen render-to-texture + upscale
- Modify: `src/render/plugin.rs` (`mirror_params` gains one line) - Modify: `src/render/plugin.rs` (`mirror_params` gains one line)
- Modify: `assets/shaders/black_hole.wgsl` (struct field) - Modify: `assets/shaders/black_hole.wgsl` (struct field)
- [ ] **Step 1: Swap `_pad4``spin` in `BlackHoleUniforms`** - [x] **Step 1: Swap `_pad4``spin` in `BlackHoleUniforms`**
In `src/render/material.rs:40`, change: In `src/render/material.rs:40`, change:
@ -317,7 +320,7 @@ spin: 0.0,
_pad5: 0.0, _pad5: 0.0,
``` ```
- [ ] **Step 2: Mirror `spin` into the uniform each frame** - [x] **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: In `src/render/plugin.rs`, inside `mirror_params` (after `u.steps = params.steps;` at `plugin.rs:142`), add:
@ -325,7 +328,7 @@ In `src/render/plugin.rs`, inside `mirror_params` (after `u.steps = params.steps
u.spin = params.spin; u.spin = params.spin;
``` ```
- [ ] **Step 3: Add `spin` to the WGSL uniform struct** - [x] **Step 3: Add `spin` to the WGSL uniform struct**
In `assets/shaders/black_hole.wgsl:35-37`, change: In `assets/shaders/black_hole.wgsl:35-37`, change:
@ -343,12 +346,12 @@ spin: f32,
_pad5: f32, _pad5: f32,
``` ```
- [ ] **Step 4: Compile** - [x] **Step 4: Compile**
Run: `cargo build` Run: `cargo build`
Expected: compiles. No visual change (spin unused in deriv yet). Expected: compiles. No visual change (spin unused in deriv yet).
- [ ] **Step 5: Commit** - [x] **Step 5: Commit**
```bash ```bash
git add src/render/material.rs src/render/plugin.rs assets/shaders/black_hole.wgsl git add src/render/material.rs src/render/plugin.rs assets/shaders/black_hole.wgsl
@ -365,7 +368,7 @@ git commit -m "feat: plumb spin parameter into GPU uniform"
- Modify: `src/physics.rs` (add two functions) - Modify: `src/physics.rs` (add two functions)
- Modify: `tests/physics_test.rs` (add tests) - Modify: `tests/physics_test.rs` (add tests)
- [ ] **Step 1: Write the failing tests** - [x] **Step 1: Write the failing tests**
Append to `tests/physics_test.rs`: Append to `tests/physics_test.rs`:
@ -417,12 +420,12 @@ fn kerr_horizon_is_monotonically_decreasing() {
} }
``` ```
- [ ] **Step 2: Run tests to verify they fail** - [x] **Step 2: Run tests to verify they fail**
Run: `cargo test --test physics_test` Run: `cargo test --test physics_test`
Expected: FAIL — `kerr_isco` and `kerr_horizon` do not exist (compile error). Expected: FAIL — `kerr_isco` and `kerr_horizon` do not exist (compile error).
- [ ] **Step 3: Implement `kerr_isco` and `kerr_horizon`** - [x] **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`): In `src/physics.rs`, add after the existing `impact_parameter` function (before the `#[allow(dead_code)] fn _phantom`):
@ -448,12 +451,12 @@ pub fn kerr_horizon(chi: f32) -> f32 {
} }
``` ```
- [ ] **Step 4: Run tests to verify they pass** - [x] **Step 4: Run tests to verify they pass**
Run: `cargo test --test physics_test` Run: `cargo test --test physics_test`
Expected: PASS — all 6 Kerr tests + the existing `public_bcrt_constant_is_correct` test pass. Expected: PASS — all 6 Kerr tests + the existing `public_bcrt_constant_is_correct` test pass.
- [ ] **Step 5: Commit** - [x] **Step 5: Commit**
```bash ```bash
git add src/physics.rs tests/physics_test.rs git add src/physics.rs tests/physics_test.rs
@ -469,7 +472,7 @@ git commit -m "feat: add kerr_isco and kerr_horizon CPU helpers with tests"
**Files:** **Files:**
- Modify: `src/render/plugin.rs` (`mirror_params`) - Modify: `src/render/plugin.rs` (`mirror_params`)
- [ ] **Step 1: Override `disk_inner` with the ISCO value in `mirror_params`** - [x] **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: 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:
@ -478,7 +481,7 @@ In `src/render/plugin.rs`, inside `mirror_params`, find the line `u.disk_inner =
u.disk_inner = crate::physics::kerr_isco(params.spin); u.disk_inner = crate::physics::kerr_isco(params.spin);
``` ```
- [ ] **Step 2: Compile and run** - [x] **Step 2: Compile and run**
Run: `cargo build` Run: `cargo build`
Expected: compiles. Expected: compiles.
@ -486,7 +489,7 @@ Expected: compiles.
Run: `cargo run` Run: `cargo run`
Expected: at spin=0 (default) the disk looks identical to Phase 1 (disk_inner = 3.0). No visible change yet. Expected: at spin=0 (default) the disk looks identical to Phase 1 (disk_inner = 3.0). No visible change yet.
- [ ] **Step 3: Commit** - [x] **Step 3: Commit**
```bash ```bash
git add src/render/plugin.rs git add src/render/plugin.rs
@ -502,7 +505,7 @@ git commit -m "feat: derive disk_inner from Kerr ISCO"
**Files:** **Files:**
- Modify: `assets/shaders/black_hole.wgsl` (`deriv`, capture test) - Modify: `assets/shaders/black_hole.wgsl` (`deriv`, capture test)
- [ ] **Step 1: Replace the `deriv` body with the Kerr pseudo-Hamiltonian** - [x] **Step 1: Replace the `deriv` body with the Kerr pseudo-Hamiltonian**
In `assets/shaders/black_hole.wgsl:110-119`, replace the entire `deriv` function: In `assets/shaders/black_hole.wgsl:110-119`, replace the entire `deriv` function:
@ -528,7 +531,7 @@ fn deriv(pos: vec3<f32>, dir: vec3<f32>) -> Deriv {
} }
``` ```
- [ ] **Step 2: Make the capture radius spin-dependent** - [x] **Step 2: Make the capture radius spin-dependent**
In `assets/shaders/black_hole.wgsl:268-273`, find the capture test: In `assets/shaders/black_hole.wgsl:268-273`, find the capture test:
@ -555,12 +558,12 @@ if (r < r_plus) {
} }
``` ```
- [ ] **Step 3: Compile** - [x] **Step 3: Compile**
Run: `cargo build` Run: `cargo build`
Expected: compiles. Expected: compiles.
- [ ] **Step 4: Add a CPU degeneracy test for the Kerr bending accel** - [x] **Step 4: Add a CPU degeneracy test for the Kerr bending accel**
Append to `tests/physics_test.rs`: Append to `tests/physics_test.rs`:
@ -588,7 +591,7 @@ fn kerr_bending_accel_nonzero_off_axis_at_nonzero_spin() {
} }
``` ```
- [ ] **Step 5: Add `kerr_bending_accel` to `src/physics.rs`** - [x] **Step 5: Add `kerr_bending_accel` to `src/physics.rs`**
In `src/physics.rs`, after `bending_accel`, add: In `src/physics.rs`, after `bending_accel`, add:
@ -610,17 +613,17 @@ pub fn kerr_bending_accel(pos: Vec3, dir: Vec3, chi: f32) -> Vec3 {
} }
``` ```
- [ ] **Step 6: Run tests** - [x] **Step 6: Run tests**
Run: `cargo test --test physics_test` Run: `cargo test --test physics_test`
Expected: PASS — all tests including the two new degeneracy tests. 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** - [x] **Step 7: Run the app and verify spin=0 is unchanged, spin>0 shows asymmetry**
Run: `cargo run` 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`. 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** - [x] **Step 8: Commit**
```bash ```bash
git add assets/shaders/black_hole.wgsl src/physics.rs tests/physics_test.rs git add assets/shaders/black_hole.wgsl src/physics.rs tests/physics_test.rs
@ -636,7 +639,7 @@ git commit -m "feat: Kerr deriv() with frame-dragging + spin-dependent horizon"
**Files:** **Files:**
- Modify: `assets/shaders/black_hole.wgsl` (integration loop) - Modify: `assets/shaders/black_hole.wgsl` (integration loop)
- [ ] **Step 1: Add the Dormand-Prince RK45 step function** - [x] **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 ======================`): In `assets/shaders/black_hole.wgsl`, add immediately before the `@fragment fn fragment` entry point (after the `grid_hit` function, before `// ====================== main ======================`):
@ -678,7 +681,7 @@ fn rk45_step(pos: vec3<f32>, dir: vec3<f32>, dt: f32) -> RkStep {
} }
``` ```
- [ ] **Step 2: Replace the integration loop with the adaptive RK45 loop** - [x] **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: 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:
@ -769,19 +772,19 @@ In `assets/shaders/black_hole.wgsl`, find the integration loop (the section star
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. 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** - [x] **Step 3: Compile**
Run: `cargo build` Run: `cargo build`
Expected: compiles. Expected: compiles.
- [ ] **Step 4: Run and verify spin=0 still looks right, then tune** - [x] **Step 4: Run and verify spin=0 still looks right, then tune**
Run: `cargo run` 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. 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. 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** - [x] **Step 5: Commit**
```bash ```bash
git add assets/shaders/black_hole.wgsl git add assets/shaders/black_hole.wgsl
@ -795,7 +798,7 @@ git commit -m "feat: adaptive RK45 integrator replacing fixed-step RK4"
**Files:** **Files:**
- Modify: `src/ui.rs` - Modify: `src/ui.rs`
- [ ] **Step 1: Add the Black Hole section and remove the disk_inner slider** - [x] **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). 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).
@ -820,7 +823,7 @@ Replace the block starting at `egui::CollapsingHeader::new("Accretion Disk")` (c
}); });
``` ```
- [ ] **Step 2: Compile and run** - [x] **Step 2: Compile and run**
Run: `cargo build` Run: `cargo build`
Expected: compiles. Expected: compiles.
@ -828,7 +831,7 @@ Expected: compiles.
Run: `cargo run` 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. 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** - [x] **Step 3: Commit**
```bash ```bash
git add src/ui.rs git add src/ui.rs
@ -899,11 +902,11 @@ If no changes, skip this step.
- [ ] spin>0 shows frame-dragging asymmetry (disk halo no longer mirror-symmetric across the spin axis). - [ ] 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. - [ ] 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²)). - [ ] Horizon radius shrinks with spin (r+ = M + sqrt(M²a²)).
- [ ] Adaptive RK45 integrator runs; render_scale=0.75 desktop / 0.5 web. - [x] 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. - [ ] All Phase 1 features (disk, Doppler, stars, grid, planets, skybox) work at spin>0.
- [ ] `cargo test` passes (Phase 1 + Kerr degeneracy/ISCO/horizon tests). - [x] `cargo test` passes (Phase 1 + Kerr degeneracy/ISCO/horizon tests).
- [ ] Desktop ≥60 fps at default Phase 2 settings; web ≥30 fps on WebGPU. - [ ] 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. - [x] egui Spin slider + ISCO/Horizon read-only labels work; disk_inner slider removed.
--- ---