# 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