diff --git a/docs/superpowers/plans/2026-07-09-interstellar-blackhole-phase1.md b/docs/superpowers/plans/2026-07-09-interstellar-blackhole-phase1.md new file mode 100644 index 0000000..8a8a317 --- /dev/null +++ b/docs/superpowers/plans/2026-07-09-interstellar-blackhole-phase1.md @@ -0,0 +1,2109 @@ +# Interstellar Black Hole Renderer — Phase 1 (Schwarzschild) 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:** A real-time, cross-platform (desktop + WebGPU) Schwarzschild black-hole renderer in Bevy 0.19 that matches the reference video: black shadow, tilted Doppler accretion disk with lensed halo, lensed starfield, plus optional lensed grid/planets/skybox — all driven by an egui control panel. + +**Architecture:** One full-screen quad with a custom `Material2d` whose fragment shader geodesic-ray-traces curved spacetime per pixel and intersects the disk, stars, planets, and grid along the bent path. All tunable state lives in a `BlackHoleParams` `Resource`, mirrored into the material uniform each frame. A `bevy_egui` panel edits the resource live. + +**Tech Stack:** Rust 2024, Bevy 0.19, bevy_egui 0.41, WGSL shaders, trunk (web build), WebGPU. + +**Spec:** `docs/superpowers/specs/2026-07-09-interstellar-blackhole-design.md` + +**Verified API facts (do not deviate):** +- `Material2d` material bind group is **group 2**; use `#{MATERIAL_BIND_GROUP}` token in WGSL. +- Imports from `bevy::sprite_render`: `Material2d`, `Material2dPlugin`, `AlphaMode2d`. +- Component-based spawning: `commands.spawn(Camera2d)` and `(Mesh2d(h), MeshMaterial2d(h))`. No bundles. +- `fragment_shader()` is a static fn returning `ShaderRef::Path("...".into())`; entry point is `@fragment fn fragment(...)`. +- WGSL must `#import bevy_sprite::mesh2d_vertex_output::VertexOutput`; `in.position.xy` is pixel coords; `in.uv` is `[0,1]`. +- Web: enable Bevy `webgpu` feature → `WgpuSettings::default()` auto-selects `BROWSER_WEBGPU`. No `RenderPlugin` backend config. `getrandom` handled transitively. +- `AsBindGroup`: `#[uniform(0)]` (single arg = binding index), `#[texture(1)]`, `#[sampler(2)]`, `#[storage(3, read_only)]`. A struct field bound as uniform must derive `ShaderType`. + +--- + +## File structure (created across tasks) + +``` +Cargo.toml # deps (Task 1) +Trunk.toml # web build (Task 3) +web/index.html # web entry (Task 3) +assets/shaders/ + common.wgsl # structs + constants (Task 7) + stars.wgsl # procedural stars (Task 11) + disk.wgsl # disk + Doppler (Task 12) + planets.wgsl # sphere intersection (Task 14) + grid.wgsl # Flamm grid (Task 15) + skybox.wgsl # cubemap sampling (Task 16) + geodesic_schwarzschild.wgsl # RK4 integrator (Task 9) + black_hole.wgsl # entry point (Task 8, grows each task) +src/ + main.rs # app + plugin wiring + params.rs # BlackHoleParams resource (Task 6) + camera.rs # orbit controller (Task 5) + ui.rs # egui panel (Task 17) + web.rs # wasm glue (Task 3) + physics.rs # CPU-mirrored math + unit tests (Task 10) + scene/mod.rs, disk.rs, planets.rs # scene helpers + render/ + mod.rs + plugin.rs # BlackHolePlugin (Task 4) + material.rs # BlackHoleMaterial + BlackHoleUniforms (Task 7) +tests/physics_test.rs # (Task 10) +``` + +--- + +## Task 1: Project dependencies + +**Files:** +- Modify: `Cargo.toml` + +- [ ] **Step 1: Write the dependency block** + +Replace the entire contents of `Cargo.toml` with: + +```toml +[package] +name = "singularity-rs" +version = "0.1.0" +edition = "2024" + +[dependencies] +bevy = "0.19" +bevy_egui = "0.41" + +# Web-only deps for WebGPU detection + fallback message. +[target.'cfg(target_arch = "wasm32")'.dependencies] +web-sys = { version = "0.3", features = ["Gpu", "Navigator", "Window", "Document", "HtmlElement", "Element"] } +wasm-bindgen = "0.2" + +# Smaller wasm binary in release web builds (per Bevy examples README). +[profile.wasm-release] +inherits = "release" +opt-level = "z" +lto = "fat" +codegen-units = 1 +``` + +- [ ] **Step 2: Verify it resolves** + +Run: `cargo check` +Expected: compiles with no errors (downloads bevy + bevy_egui; may take a few minutes the first time). + +- [ ] **Step 3: Commit** + +```bash +git add Cargo.toml Cargo.lock +git commit -m "build: add bevy 0.19 and bevy_egui 0.41 dependencies" +``` + +--- + +## Task 2: Minimal Bevy window opens (smoke test) + +**Files:** +- Modify: `src/main.rs` + +- [ ] **Step 1: Write a minimal app that opens a window** + +Replace `src/main.rs` with: + +```rust +use bevy::prelude::*; + +fn main() { + App::new() + .add_plugins(DefaultPlugins) + .add_systems(Startup, setup) + .run(); +} + +fn setup(mut commands: Commands) { + commands.spawn(Camera2d); +} +``` + +- [ ] **Step 2: Run it and confirm a blank window opens** + +Run: `cargo run` +Expected: a Bevy window titled "App" opens with a black background and no panics. Close it with the window close button. + +- [ ] **Step 3: Commit** + +```bash +git add src/main.rs +git commit -m "feat: minimal bevy window opens" +``` + +--- + +## Task 3: Web build works (Trunk + WebGPU + fallback) + +**Files:** +- Create: `Trunk.toml` +- Create: `web/index.html` +- Create: `src/web.rs` +- Modify: `src/main.rs` + +- [ ] **Step 1: Create `Trunk.toml`** + +```toml +[build] +target = "web/index.html" +dist = "dist" + +[serve] +address = "127.0.0.1" +port = 8080 +open = false +``` + +- [ ] **Step 2: Create `web/index.html`** + +```html + + + + + singularity-rs + + + + + + +``` + +- [ ] **Step 3: Create `src/web.rs` with WebGPU check + fallback** + +```rust +#[cfg(target_arch = "wasm32")] +pub fn webgpu_available() -> bool { + web_sys::window() + .and_then(|w| w.navigator()) + .and_then(|n| n.gpu()) + .is_some() +} + +#[cfg(target_arch = "wasm32")] +pub fn show_fallback_message() { + if let Some(window) = web_sys::window() { + if let Some(document) = window.document() { + if let Some(body) = document.body() { + let el = document.create_element("div").unwrap(); + el.set_inner_text( + "WebGPU is not available in this browser. \ + Please use a recent version of Chrome, Edge, or Firefox.", + ); + el.set_attribute( + "style", + "position:fixed;inset:0;display:flex;align-items:center;\ + justify-content:center;font-family:sans-serif;font-size:1.5rem;\ + text-align:center;padding:2rem;background:#111;color:#eee;", + ) + .ok(); + body.append_child(&el).ok(); + } + } + } +} +``` + +- [ ] **Step 4: Wire the check into `main` and enable canvas-fit on web** + +Replace `src/main.rs` with: + +```rust +use bevy::prelude::*; +use bevy::window::WindowPlugin; + +#[cfg(target_arch = "wasm32")] +mod web; + +fn main() { + // On web, abort startup if WebGPU isn't available and show a message. + #[cfg(target_arch = "wasm32")] + { + if !web::webgpu_available() { + web::show_fallback_message(); + return; + } + } + + App::new() + .add_plugins(DefaultPlugins.set(WindowPlugin { + primary_window: Some(Window { + title: "singularity-rs".into(), + // On web, make the canvas track the browser window size. + fit_canvas_to_parent: true, + ..default() + }), + ..default() + })) + .add_systems(Startup, setup) + .run(); +} + +fn setup(mut commands: Commands) { + commands.spawn(Camera2d); +} +``` + +- [ ] **Step 5: Verify desktop still builds and runs** + +Run: `cargo run` +Expected: window opens, title is "singularity-rs". + +- [ ] **Step 6: Install trunk and the wasm target** + +Run: +```bash +cargo install --locked trunk +rustup target add wasm32-unknown-unknown +``` +Expected: `trunk` installed; wasm target added. + +- [ ] **Step 7: Verify the web build serves** + +Run: `trunk serve` +Expected: serving on `http://127.0.0.1:8080`. Open it in Chrome/Edge → a black canvas fills the window (the `Camera2d` with no content). Check the browser console for no errors. Stop with Ctrl-C. + +- [ ] **Step 8: Commit** + +```bash +git add Trunk.toml web/index.html src/web.rs src/main.rs +git commit -m "feat: web build via trunk with WebGPU detection + fallback" +``` + +--- + +## Task 4: BlackHolePlugin scaffold + full-screen quad + +**Files:** +- Create: `src/render/mod.rs` +- Create: `src/render/plugin.rs` +- Modify: `src/main.rs` + +This task creates the plugin and spawns a full-screen quad whose material renders a flat color, proving the full-screen-shader pipeline works before any physics. + +- [ ] **Step 1: Create `src/render/mod.rs`** + +```rust +pub mod plugin; +pub mod material; + +pub use plugin::BlackHolePlugin; +``` + +- [ ] **Step 2: Create `src/render/material.rs` (placeholder flat-color material)** + +```rust +use bevy::prelude::*; +use bevy::reflect::TypePath; +use bevy::render::render_resource::AsBindGroup; +use bevy::sprite_render::Material2d; + +#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)] +pub struct BlackHoleMaterial { + #[uniform(0)] + pub time: f32, +} + +impl Material2d for BlackHoleMaterial { + fn fragment_shader() -> bevy::sprite_render::ShaderRef { + "shaders/black_hole.wgsl".into() + } +} +``` + +- [ ] **Step 3: Create `src/render/plugin.rs`** + +```rust +use bevy::prelude::*; +use bevy::sprite_render::Material2dPlugin; + +use super::material::BlackHoleMaterial; + +pub struct BlackHolePlugin; + +impl Plugin for BlackHolePlugin { + fn build(&self, app: &mut App) { + app.add_plugins(Material2dPlugin::::default()) + .add_systems(Startup, spawn_fullscreen_quad) + .add_systems(Update, update_time); + } +} + +fn spawn_fullscreen_quad( + mut commands: Commands, + mut meshes: ResMut>, + mut materials: ResMut>, +) { + // A large quad that covers the camera's orthographic view. + commands.spawn(( + Mesh2d(meshes.add(Rectangle::new(2.0, 2.0))), + MeshMaterial2d(materials.add(BlackHoleMaterial { time: 0.0 })), + // Camera2d default projection spans [-1,1] in x; scale quad to fill. + Transform::default(), + )); +} + +fn update_time(time: Res