# 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