20 tasks covering: deps, minimal window, web/Trunk/WebGPU, full-screen Material2d pipeline, orbit camera, params resource, uniforms+mirror, ray gen, RK4 Schwarzschild integrator, CPU physics tests, lensed starfield, Doppler accretion disk w/ Einstein halo, egui panel, storage-buffer planets, Flamm grid, cubemap skybox, render scale, web verification, README. Verified against Bevy 0.19 API (group-2 material bind group, component spawning, WgpuSettings defaults).
70 KiB
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):
Material2dmaterial 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 returningShaderRef::Path("...".into()); entry point is@fragment fn fragment(...).- WGSL must
#import bevy_sprite::mesh2d_vertex_output::VertexOutput;in.position.xyis pixel coords;in.uvis[0,1]. - Web: enable Bevy
webgpufeature →WgpuSettings::default()auto-selectsBROWSER_WEBGPU. NoRenderPluginbackend config.getrandomhandled transitively. AsBindGroup:#[uniform(0)](single arg = binding index),#[texture(1)],#[sampler(2)],#[storage(3, read_only)]. A struct field bound as uniform must deriveShaderType.
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:
[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
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:
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
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
[build]
target = "web/index.html"
dist = "dist"
[serve]
address = "127.0.0.1"
port = 8080
open = false
- Step 2: Create
web/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>singularity-rs</title>
<style>
html, body { margin: 0; padding: 0; height: 100%; overflow: hidden; background: #000; }
canvas { width: 100vw; height: 100vh; display: block; }
</style>
</head>
<body>
<link
data-trunk rel="rust"
data-bin="singularity-rs"
data-type="main"
data-cargo-features="webgpu"
data-wasm-opt="z"
/>
</body>
</html>
- Step 3: Create
src/web.rswith WebGPU check + fallback
#[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
mainand enable canvas-fit on web
Replace src/main.rs with:
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:
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
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
pub mod plugin;
pub mod material;
pub use plugin::BlackHolePlugin;
- Step 2: Create
src/render/material.rs(placeholder flat-color material)
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
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::<BlackHoleMaterial>::default())
.add_systems(Startup, spawn_fullscreen_quad)
.add_systems(Update, update_time);
}
}
fn spawn_fullscreen_quad(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<BlackHoleMaterial>>,
) {
// 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<Time>, mut materials: ResMut<Assets<BlackHoleMaterial>>) {
for (_, mat) in materials.iter_mut() {
mat.time = time.elapsed_secs();
}
}
- Step 4: Create the placeholder shader
assets/shaders/black_hole.wgsl
#import bevy_sprite::mesh2d_vertex_output::VertexOutput
@group(#{MATERIAL_BIND_GROUP}) @binding(0) var<uniform> time: f32;
@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
// Gradient from time, just to prove the uniform flows.
let t = (sin(time) + 1.0) * 0.5;
let col = mix(vec3<f32>(0.02, 0.02, 0.05), vec3<f32>(0.08, 0.04, 0.12), t);
return vec4<f32>(col, 1.0);
}
- Step 5: Wire the plugin into
main.rs
Replace the body of fn main() in src/main.rs — add the plugin after DefaultPlugins:
mod render;
mod web; // (keep the existing #[cfg] gate around the `mod web;` declaration)
Add .add_plugins(render::BlackHolePlugin) to the app builder, after DefaultPlugins. The resulting main() (keep the WebGPU guard at top):
use bevy::prelude::*;
use bevy::window::WindowPlugin;
mod render;
#[cfg(target_arch = "wasm32")]
mod web;
fn main() {
#[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(),
fit_canvas_to_parent: true,
..default()
}),
..default()
}))
.add_plugins(render::BlackHolePlugin)
.run();
}
Remove the now-unused setup system and its Startup registration (the quad spawn moved into the plugin).
- Step 6: Run and confirm a time-varying gradient fills the window
Run: cargo run
Expected: the whole window shows a slowly pulsing purple-blue gradient. No compile errors. If the quad doesn't fill the window, see the sizing note below.
- Step 7: Commit
git add src/render/ src/main.rs assets/shaders/black_hole.wgsl
git commit -m "feat: full-screen quad + BlackHoleMaterial pipeline"
Sizing note (if the quad doesn't fill):
Camera2d's default projection usesScalingMode::FixedVertical(1.0), so the visible x-range is[-aspect, aspect]and y is[-1,1]. ARectangle::new(2.0, 2.0)centered at the origin therefore covers y but may not cover the full width on wide screens. If you see letterboxing, set the quad's scale based on aspect, or change the camera projection. For correctness in the ray-generation step we'll compute rays fromin.uv(always[0,1]over the quad), so the simplest fix is to scale the quad to the camera's visible width: addTransform::default().with_scale(Vec3::new(aspect*2.0, 2.0, 1.0))whereaspect = window width / height. We'll handle this precisely in Task 8 when we generate rays.
Task 5: Orbit camera controller
Files:
- Create:
src/camera.rs - Modify:
src/render/plugin.rs - Modify:
src/render/mod.rs(re-export) - Modify:
src/main.rs(declare module)
The orbit controller is a Resource holding yaw/pitch/distance. It does NOT move a Bevy camera (we have no 3D camera) — it computes the CameraParams (eye position + basis vectors) that the shader uses. It consumes mouse input only when egui is not capturing it (the egui capture check is added in Task 17; here we just read raw input).
- Step 1: Create
src/camera.rs
use bevy::input::mouse::MouseMotion;
use bevy::input::mouse::MouseWheel;
use bevy::prelude::*;
/// Orbit camera state. The black hole is at the origin (Rs=1 units).
/// `distance` is the camera radius; `yaw`/`pitch` orient it.
#[derive(Resource, Clone, Copy)]
pub struct OrbitCamera {
pub yaw: f32,
pub pitch: f32,
pub distance: f32,
/// Vertical field of view in radians.
pub fov: f32,
}
impl Default for OrbitCamera {
fn default() -> Self {
Self {
yaw: 0.0,
pitch: 1.3, // ~75 deg — slightly above the disk plane
distance: 30.0,
fov: 1.0, // radians
}
}
}
impl OrbitCamera {
/// Compute the camera eye position and an orthonormal basis (forward/right/up)
/// in Bevy's right-handed Y-up coordinate system. The black hole sits at origin.
/// Disk plane is the xz-plane (y=0); the disk tilt is applied in the shader
/// via the params, so the camera basis here is in world space.
pub fn basis(&self) -> (Vec3, Vec3, Vec3, Vec3) {
let cp = self.pitch.cos();
let sp = self.pitch.sin();
let cy = self.yaw.cos();
let sy = self.yaw.sin();
// Eye position on a sphere around the origin.
let eye = Vec3::new(
self.distance * cp * sy,
self.distance * sp,
self.distance * cp * cy,
);
// Forward points from eye toward the origin.
let forward = (-eye).normalize();
let world_up = Vec3::Y;
let right = forward.cross(world_up).normalize();
let up = right.cross(forward).normalize();
(eye, forward, right, up)
}
}
pub fn orbit_controller(
mut camera: ResMut<OrbitCamera>,
mouse: Res<ButtonInput<MouseButton>>,
mut motion: EventReader<MouseMotion>,
mut wheel: EventReader<MouseWheel>,
) {
if mouse.pressed(MouseButton::Left) {
for ev in motion.read() {
camera.yaw -= ev.delta.x * 0.005;
// Clamp pitch to avoid flipping.
camera.pitch = (camera.pitch + ev.delta.y * 0.005).clamp(0.05, std::f32::consts::PI - 0.05);
}
}
for ev in wheel.read() {
// Zoom: multiply distance by a factor of the scroll amount.
camera.distance = (camera.distance * (1.0 + ev.y * 0.1)).clamp(2.6, 500.0);
// 2.6 ≈ bcrit; don't let the camera pass through the shadow.
}
}
Note on egui/pointer: Task 17 introduces a
WantsPointerresource that the UI system sets and the camera reads, so orbit input is ignored over the panel. We don't add any pointer-awareness scaffolding here in Task 5.
- Step 2: Register the resource + system in
BlackHolePlugin
In src/render/plugin.rs, add to build():
app.init_resource::<crate::camera::OrbitCamera>()
.add_systems(Update, crate::camera::orbit_controller);
- Step 3: Declare the module in
src/main.rs
Add near the other mod lines: mod camera;
- Step 4: Build to confirm it compiles
Run: cargo build
Expected: compiles. (No visible behavior change yet — the camera state isn't read by the shader until Task 8.)
- Step 5: Commit
git add src/camera.rs src/render/plugin.rs src/main.rs
git commit -m "feat: orbit camera controller (yaw/pitch/zoom)"
Task 6: BlackHoleParams resource
Files:
- Create:
src/params.rs - Modify:
src/render/plugin.rs - Modify:
src/main.rs
The single source of truth for all tunable values, edited by the UI and mirrored to the GPU.
- Step 1: Create
src/params.rs
use bevy::prelude::*;
/// All tunable black-hole parameters. Edited by the egui panel (Task 17),
/// mirrored into BlackHoleUniforms each frame (Task 7).
#[derive(Resource, Clone)]
pub struct BlackHoleParams {
// Physics (natural units, Rs = 1)
pub rs: f32,
// Accretion disk
pub disk_inner: f32,
pub disk_outer: f32,
pub disk_tilt: f32, // radians, tilt of disk plane vs. camera
pub disk_brightness: f32,
pub disk_rotation_speed: f32,
pub doppler_enabled: bool,
pub doppler_strength: f32,
// Renderer
pub steps: u32,
pub render_scale: f32,
pub star_intensity: f32,
pub grid_enabled: bool,
pub grid_density: f32,
// Background
pub skybox_intensity: f32,
// Planets (count matches the storage buffer; Task 14)
pub planet_count: u32,
// Kerr (Phase 2; unused in Phase 1)
pub spin: f32,
}
impl Default for BlackHoleParams {
fn default() -> Self {
Self {
rs: 1.0,
disk_inner: 3.0,
disk_outer: 15.0,
disk_tilt: 1.318, // ~75.5 deg, matching the reference video
disk_brightness: 1.0,
disk_rotation_speed: 0.5,
doppler_enabled: true,
doppler_strength: 1.0,
steps: 300,
render_scale: 1.0,
star_intensity: 1.0,
grid_enabled: false,
grid_density: 1.0,
skybox_intensity: 0.0, // procedural stars only by default
planet_count: 0,
spin: 0.0,
}
}
}
- Step 2: Register the resource in the plugin
In src/render/plugin.rs build(), add:
app.init_resource::<crate::params::BlackHoleParams>();
- Step 3: Declare the module in
src/main.rs
Add: mod params;
- Step 4: Build
Run: cargo build
Expected: compiles.
- Step 5: Commit
git add src/params.rs src/render/plugin.rs src/main.rs
git commit -m "feat: BlackHoleParams tunable resource"
Task 7: Material uniforms + storage/texture bindings
Files:
- Modify:
src/render/material.rs - Modify:
src/render/plugin.rs
Define the uniform struct, the skybox texture/sampler bindings, and the planets storage buffer. Then a mirror system copies BlackHoleParams + OrbitCamera into the material each frame.
- Step 1: Rewrite
src/render/material.rs
use bevy::prelude::*;
use bevy::reflect::TypePath;
use bevy::render::render_resource::{AsBindGroup, ShaderType};
use bevy::sprite_render::Material2d;
/// GPU uniform: params + camera packed into one struct bound at binding 0.
#[derive(Clone, ShaderType)]
pub struct BlackHoleUniforms {
// Camera basis + eye (4 vec3s = must align; pad each to vec4 via the shader struct)
pub eye: Vec3,
pub _pad0: f32,
pub forward: Vec3,
pub _pad1: f32,
pub right: Vec3,
pub _pad2: f32,
pub up: Vec3,
pub fov: f32,
// Resolution
pub resolution: Vec2,
pub time: f32,
pub _pad3: f32,
// Physics + disk
pub rs: f32,
pub disk_inner: f32,
pub disk_outer: f32,
pub disk_tilt: f32,
pub disk_brightness: f32,
pub disk_rotation_speed: f32,
pub doppler_strength: f32,
pub star_intensity: f32,
pub skybox_intensity: f32,
pub grid_density: f32,
// Flags packed as f32 (bools aren't valid uniform scalar types in WGSL)
pub doppler_enabled: u32,
pub grid_enabled: u32,
pub planet_count: u32,
pub steps: u32,
pub _pad4: f32,
pub _pad5: f32,
}
impl Default for BlackHoleUniforms {
fn default() -> Self {
Self {
eye: Vec3::new(0.0, 0.0, 30.0),
_pad0: 0.0,
forward: Vec3::new(0.0, 0.0, -1.0),
_pad1: 0.0,
right: Vec3::new(1.0, 0.0, 0.0),
_pad2: 0.0,
up: Vec3::new(0.0, 1.0, 0.0),
fov: 1.0,
resolution: Vec2::new(1280.0, 720.0),
time: 0.0,
_pad3: 0.0,
rs: 1.0,
disk_inner: 3.0,
disk_outer: 15.0,
disk_tilt: 1.318,
disk_brightness: 1.0,
disk_rotation_speed: 0.5,
doppler_strength: 1.0,
star_intensity: 1.0,
skybox_intensity: 0.0,
grid_density: 1.0,
doppler_enabled: 1,
grid_enabled: 0,
planet_count: 0,
steps: 300,
_pad4: 0.0,
_pad5: 0.0,
}
}
}
/// One planet's data, uploaded in a storage buffer (binding 3).
#[derive(Clone, Copy, ShaderType, Default)]
pub struct SphereData {
pub center: Vec3,
pub radius: f32,
pub color: Vec3,
pub emissive: u32,
pub _pad0: f32,
pub _pad1: f32,
pub _pad2: f32,
}
pub const MAX_PLANETS: usize = 32;
#[derive(Asset, TypePath, AsBindGroup, Clone)]
pub struct BlackHoleMaterial {
#[uniform(0)]
pub uniforms: BlackHoleUniforms,
#[texture(1)]
pub skybox: Option<Handle<Image>>,
#[sampler(2)]
pub skybox_sampler: bool, // AsBindGroup needs a sampler field; use a dummy bool presence
#[storage(3, read_only)]
pub planets: Vec<SphereData>,
}
impl Material2d for BlackHoleMaterial {
fn fragment_shader() -> bevy::sprite_render::ShaderRef {
"shaders/black_hole.wgsl".into()
}
}
impl Default for BlackHoleMaterial {
fn default() -> Self {
Self {
uniforms: BlackHoleUniforms::default(),
skybox: None,
skybox_sampler: true,
planets: vec![SphereData::default(); MAX_PLANETS],
}
}
}
- Step 2: Update
spawn_fullscreen_quadand add the mirror system insrc/render/plugin.rs
Replace the file contents with:
use bevy::prelude::*;
use bevy::sprite_render::Material2dPlugin;
use bevy::window::WindowResized;
use super::material::{BlackHoleMaterial, BlackHoleUniforms};
pub struct BlackHolePlugin;
impl Plugin for BlackHolePlugin {
fn build(&self, app: &mut App) {
app.init_resource::<crate::camera::OrbitCamera>()
.init_resource::<crate::params::BlackHoleParams>()
.add_plugins(Material2dPlugin::<BlackHoleMaterial>::default())
.add_systems(Startup, spawn_fullscreen_quad)
.add_systems(Update, (crate::camera::orbit_controller, mirror_params));
}
}
fn spawn_fullscreen_quad(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<BlackHoleMaterial>>,
window: Query<&Window>,
) {
let win = window.single();
let aspect = win.width() / win.height();
commands.spawn((
Mesh2d(meshes.add(Rectangle::new(2.0 * aspect, 2.0))),
MeshMaterial2d(materials.add(BlackHoleMaterial::default())),
Transform::default(),
));
commands.spawn(Camera2d);
}
fn mirror_params(
camera: Res<crate::camera::OrbitCamera>,
params: Res<crate::params::BlackHoleParams>,
time: Res<Time>,
window: Query<&Window>,
mut materials: ResMut<Assets<BlackHoleMaterial>>,
) {
let win = match window.single() {
Ok(w) => w,
Err(_) => return,
};
// Only update when something changed.
let (eye, forward, right, up) = camera.basis();
for (_, mat) in materials.iter_mut() {
let u = &mut mat.uniforms;
u.eye = eye;
u.forward = forward;
u.right = right;
u.up = up;
u.fov = camera.fov;
u.resolution = Vec2::new(win.width(), win.height());
u.time = time.elapsed_secs();
u.rs = params.rs;
u.disk_inner = params.disk_inner;
u.disk_outer = params.disk_outer;
u.disk_tilt = params.disk_tilt;
u.disk_brightness = params.disk_brightness;
u.disk_rotation_speed = params.disk_rotation_speed;
u.doppler_strength = params.doppler_strength;
u.star_intensity = params.star_intensity;
u.skybox_intensity = params.skybox_intensity;
u.grid_density = params.grid_density;
u.doppler_enabled = params.doppler_enabled as u32;
u.grid_enabled = params.grid_enabled as u32;
u.planet_count = params.planet_count;
u.steps = params.steps;
}
}
- Step 3: Update the placeholder shader to match the new uniform
Replace assets/shaders/black_hole.wgsl with:
#import bevy_sprite::mesh2d_vertex_output::VertexOutput
struct BlackHoleUniforms {
eye: vec4<f32>,
forward: vec4<f32>,
right: vec4<f32>,
up: vec4<f32>,
resolution: vec2<f32>,
time: f32,
_pad3: f32,
rs: f32,
disk_inner: f32,
disk_outer: f32,
disk_tilt: f32,
disk_brightness: f32,
disk_rotation_speed: f32,
doppler_strength: f32,
star_intensity: f32,
skybox_intensity: f32,
grid_density: f32,
doppler_enabled: u32,
grid_enabled: u32,
planet_count: u32,
steps: u32,
_pad4: f32,
_pad5: f32,
};
@group(#{MATERIAL_BIND_GROUP}) @binding(0) var<uniform> uniforms: BlackHoleUniforms;
@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
// Prove the camera basis + uniforms flow: tint by eye direction.
let n = normalize(uniforms.eye.xyz);
return vec4<f32>(abs(n) * 0.5 + 0.3, 1.0);
}
Note: the Rust
Vec3 + pad f32fields becomevec4<f32>in WGSL (std140 alignment). The shader struct above mirrors that exactly.right/up/forward/eyeare each avec4whose.wis the padding scalar (we only read.xyz).
- Step 4: Build and run
Run: cargo run
Expected: window fills with a color that changes as you orbit-drag (eye direction tints the screen). Confirms camera basis + uniforms reach the shader. If dragging does nothing, confirm the OrbitCamera resource is initialized (it is, via the plugin).
- Step 5: Commit
git add src/render/material.rs src/render/plugin.rs assets/shaders/black_hole.wgsl
git commit -m "feat: BlackHoleUniforms + camera/params mirror system"
Task 8: Ray generation + camera ray launch
Files:
- Create:
assets/shaders/ray_gen.wgsl - Modify:
assets/shaders/black_hole.wgsl
Generate a primary ray per pixel from the camera basis + FOV + aspect.
- Step 1: Create
assets/shaders/ray_gen.wgsl
// Builds a world-space camera ray direction for the current pixel.
// `uv` is the pixel coordinate normalized to [-1,1] with aspect correction.
fn ray_direction(uv: vec2<f32>) -> vec3<f32> {
let tan_half_fov = tan(uniforms.fov * 0.5);
let dir =
normalize(uniforms.forward.xyz)
+ uniforms.right.xyz * (uv.x * tan_half_fov)
+ uniforms.up.xyz * (uv.y * tan_half_fov);
return normalize(dir);
}
- Step 2: Update
black_hole.wgslto use it
After the uniform struct (keep it), replace the fragment body:
#import bevy_sprite::mesh2d_vertex_output::VertexOutput
#import "shaders/ray_gen.wgsl"
// ... (uniform struct stays here, unchanged from Task 7) ...
@group(#{MATERIAL_BIND_GROUP}) @binding(0) var<uniform> uniforms: BlackHoleUniforms;
@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
// in.uv is [0,1] across the quad. Center and flip y, apply aspect.
let aspect = uniforms.resolution.x / uniforms.resolution.y;
var uv = (in.uv * 2.0 - 1.0);
uv.x *= aspect;
let dir = ray_direction(uv);
// Visualize ray direction as a color (sanity check).
return vec4<f32>(abs(dir), 1.0);
}
- Step 3: Run and verify
Run: cargo run
Expected: the screen shows a smoothly varying color field (ray directions). As you orbit the camera, the colors shift. Confirms per-pixel ray generation with correct aspect.
- Step 4: Commit
git add assets/shaders/ray_gen.wgsl assets/shaders/black_hole.wgsl
git commit -m "feat: per-pixel camera ray generation"
Task 9: Schwarzschild geodesic integrator (RK4)
Files:
- Create:
assets/shaders/geodesic_schwarzschild.wgsl - Modify:
assets/shaders/black_hole.wgsl
The core: integrate each ray through curved spacetime, terminating on capture or escape. This produces the black shadow (rays with impact parameter < bcrit fall below Rs).
- Step 1: Create
assets/shaders/geodesic_schwarzschild.wgsl
const R_ESCAPE: f32 = 1000.0;
// One RK4 sub-step derivative of (pos, dir) under the Schwarzschild
// bending acceleration. Rs is uniforms.rs.
fn deriv(pos: vec3<f32>, dir: vec3<f32>) -> (vec3<f32>, vec3<f32>) {
let r = length(pos);
let rs = uniforms.rs;
// Angular momentum squared: |cross(pos, dir)|^2
let h = cross(pos, dir);
let h2 = dot(h, h);
// Avoid division by zero.
let r5 = max(r * r * r * r * r, 1e-6);
// d(pos)/dt = dir
let dpos = dir;
// d(dir)/dt = bending acceleration (re-normalized each step in integrate)
let accel = -1.5 * rs * h2 / r5 * pos;
return (dpos, accel);
}
// Integrate a ray from `pos` along `dir`. Returns:
// .status: 0 = escaped, 1 = captured (shadow)
// .final_pos, .final_dir: end state (used for sky sampling on escape)
struct RayResult {
status: u32,
final_pos: vec3<f32>,
final_dir: vec3<f32>,
}
// Accumulator callback pattern: the caller passes a function-style body via
// a per-step check. Because WGSL has no first-class closures, we inline the
// per-step intersection tests in black_hole.wgsl's integrate_and_trace().
// This function returns ONLY the escape/capture classification, used as a
// fallback when no scene object is hit.
fn classify_ray(start_pos: vec3<f32>, start_dir: vec3<f32>, steps: u32, dt: f32) -> RayResult {
var pos = start_pos;
var dir = start_dir;
for (var i: u32 = 0u; i < steps; i = i + 1u) {
let r = length(pos);
if (r < uniforms.rs) {
return RayResult(1u, pos, dir);
}
if (r > R_ESCAPE) {
return RayResult(0u, pos, dir);
}
// RK4
let (k1p, k1d) = deriv(pos, dir);
let (k2p, k2d) = deriv(pos + k1p * dt * 0.5, normalize(dir + k1d * dt * 0.5));
let (k3p, k3d) = deriv(pos + k2p * dt * 0.5, normalize(dir + k2d * dt * 0.5));
let (k4p, k4d) = deriv(pos + k3p * dt, normalize(dir + k3d * dt));
pos = pos + (k1p + 2.0 * k2p + 2.0 * k3p + k4p) * dt / 6.0;
dir = normalize(dir + (k1d + 2.0 * k2d + 2.0 * k3d + k4d) * dt / 6.0);
}
// Ran out of steps without a clear verdict: treat as escaped.
return RayResult(0u, pos, dir);
}
- Step 2: Verify shadow appearance in
black_hole.wgsl
Replace the fragment body in black_hole.wgsl with (keep the uniform struct + imports):
#import bevy_sprite::mesh2d_vertex_output::VertexOutput
#import "shaders/ray_gen.wgsl"
#import "shaders/geodesic_schwarzschild.wgsl"
// ... uniform struct + binding unchanged ...
@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
let aspect = uniforms.resolution.x / uniforms.resolution.y;
var uv = (in.uv * 2.0 - 1.0);
uv.x *= aspect;
let dir = ray_direction(uv);
// Step size scales with how far we need to travel (~ distance/ steps).
let dt = max(length(uniforms.eye.xyz), 20.0) / f32(uniforms.steps);
let res = classify_ray(uniforms.eye.xyz, dir, uniforms.steps, dt);
if (res.status == 1u) {
// Captured = shadow.
return vec4<f32>(0.0, 0.0, 0.0, 1.0);
}
// Escaped: dim grey for now (stars come in Task 11).
return vec4<f32>(0.02, 0.02, 0.02, 1.0);
}
- Step 3: Run and confirm a black disk appears
Run: cargo run
Expected: a roughly circular black region (the shadow) appears centered, surrounded by dark grey. As you zoom in (wheel), the black disk grows. This is the first money shot — the shadow emerges from the geodesic integration, no hard-coded sphere.
- Step 4: Commit
git add assets/shaders/geodesic_schwarzschild.wgsl assets/shaders/black_hole.wgsl
git commit -m "feat: Schwarzschild RK4 geodesic integrator + black shadow"
Task 10: CPU physics mirror + unit tests
Files:
- Create:
src/physics.rs - Create:
tests/physics_test.rs - Modify:
src/main.rs
Mirror the shader math in Rust so we can unit-test the constants and the capture criterion (bcrit ≈ 2.598). This guards against silent regressions in the shader.
- Step 1: Create
src/physics.rs
//! CPU mirror of the shader physics, for unit-testing.
//! Natural units: Rs = 1.
use glam::{Vec3, Vec4};
pub const RS: f32 = 1.0;
/// Critical impact parameter for a Schwarzschild hole: bcrit = (3*sqrt(3)/2) * Rs.
pub const BCRIT: f32 = 3.0 * 3.0_f32.sqrt() / 2.0;
/// One Euler step of the discretized geodesic (mirrors the shader's `deriv`).
pub fn bending_accel(pos: Vec3, dir: Vec3) -> Vec3 {
let r = pos.length();
let h = pos.cross(dir);
let h2 = h.dot(h);
let r5 = (r * r * r * r * r).max(1e-6);
-1.5 * RS * h2 / r5 * pos
}
/// Classify a ray by integrating it. Returns true if captured (r < Rs).
/// Uses RK4 like the shader. `dt` is the step size, `steps` the count.
pub fn is_captured(start_pos: Vec3, start_dir: Vec3, steps: u32, dt: f32) -> bool {
let mut pos = start_pos;
let mut dir = start_dir;
const R_ESCAPE: f32 = 1000.0;
for _ in 0..steps {
let r = pos.length();
if r < RS {
return true;
}
if r > R_ESCAPE {
return false;
}
let k1p = dir;
let k1d = bending_accel(pos, dir);
let k2p = dir + k1d * dt * 0.5;
let k2d = bending_accel(pos + k1p * dt * 0.5, (dir + k1d * dt * 0.5).normalize());
let k3p = dir + k2d * dt * 0.5;
let k3d = bending_accel(pos + k2p * dt * 0.5, (dir + k2d * dt * 0.5).normalize());
let k4p = dir + k3d * dt;
let k4d = bending_accel(pos + k3p * dt, (dir + k3d * dt).normalize());
pos += (k1p + 2.0 * k2p + 2.0 * k3p + k4p) * dt / 6.0;
dir = (dir + (k1d + 2.0 * k2d + 2.0 * k3d + k4d) * dt / 6.0).normalize();
}
false
}
/// Compute the impact parameter of a ray given eye position and direction:
/// b = |eye x dir| / |dir| (since dir is unit, b = |eye x dir|).
pub fn impact_parameter(eye: Vec3, dir: Vec3) -> f32 {
eye.cross(dir).length()
}
// Silence unused-import warning for Vec4 if not used; kept for future expansion.
#[allow(dead_code)]
fn _phantom(_v: Vec4) {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bcrit_value() {
assert!((BCRIT - 2.598).abs() < 0.01, "bcrit should be ~2.598, got {}", BCRIT);
}
#[test]
fn ray_below_bcrit_is_captured() {
// Eye far on the z-axis; aim slightly off-center with b < bcrit.
let eye = Vec3::new(0.0, 0.0, 50.0);
let dir = Vec3::new(0.0, 2.0, -50.0).normalize(); // b ~ 2.0 < 2.598
let b = impact_parameter(eye, dir);
assert!(b < BCRIT, "b {} should be < bcrit {}", b, BCRIT);
assert!(is_captured(eye, dir, 2000, 0.1), "ray below bcrit should be captured");
}
#[test]
fn ray_above_bcrit_escapes() {
let eye = Vec3::new(0.0, 0.0, 50.0);
let dir = Vec3::new(0.0, 10.0, -50.0).normalize(); // b ~ 9.8 >> bcrit
let b = impact_parameter(eye, dir);
assert!(b > BCRIT);
assert!(!is_captured(eye, dir, 2000, 0.1), "ray above bcrit should escape");
}
}
- Step 2: Declare the module in
src/main.rs
Add: mod physics;
- Step 3: Create an integration test file
tests/physics_test.rs
use singularity_rs::physics;
#[test]
fn public_bcrt_constant_is_correct() {
// 3*sqrt(3)/2 ≈ 2.598076
let expected = 1.5 * 3.0_f32.sqrt();
assert!((physics::BCRIT - expected).abs() < 1e-5);
}
For this to compile, physics must be reachable from the integration test. Add to src/main.rs top, and expose the module publicly on the crate. Since this is a binary crate, create src/lib.rs:
pub mod physics;
And add to the [package] section of Cargo.toml nothing extra (Cargo auto-detects src/lib.rs + src/main.rs). Add the lib target explicitly to be safe — append to Cargo.toml under [package]:
[lib]
name = "singularity_rs"
path = "src/lib.rs"
Then have src/main.rs use the library: change its module declarations to reference the lib crate. Simplest: in src/main.rs, replace mod physics; with use singularity_rs::physics; and remove the local mod physics;. Keep the other mod (camera, params, render, web) local to the binary as before. (Or move them all to the lib; but minimal change = just physics.)
- Step 4: Run the tests
Run: cargo test
Expected: all tests pass (3 unit tests in physics.rs + 1 integration test). If ray_below_bcrit_is_captured fails, increase steps to 5000 — RK4 with dt=0.1 near the photon sphere needs fine resolution.
- Step 5: Commit
git add src/physics.rs src/lib.rs tests/physics_test.rs Cargo.toml src/main.rs
git commit -m "test: CPU-mirrored geodesic physics + bcrit/capture unit tests"
Task 11: Procedural starfield (lensed background)
Files:
- Create:
assets/shaders/stars.wgsl - Modify:
assets/shaders/black_hole.wgsl
When a ray escapes, sample a procedural star field along its final direction. Because rays bent, the stars are naturally lensed near the hole.
- Step 1: Create
assets/shaders/stars.wgsl
// Hash-based procedural stars on the unit sphere. Returns RGB radiance.
fn hash13(p: vec3<f32>) -> f32 {
var q = vec3<f32>(dot(p, vec3<f32>(127.1, 311.7, 74.7)),
dot(p, vec3<f32>(269.5, 183.3, 246.1)),
dot(p, vec3<f32>(113.5, 271.9, 124.6)));
let h = fract(sin(q) * 43758.5453);
return h.x;
}
fn star_color(dir: vec3<f32>, intensity: f32) -> vec3<f32> {
// Divide the sphere into cells; a cell gets a star if its hash passes a threshold.
let scale = 80.0;
let cell = floor(dir * scale);
let h = hash13(cell);
let threshold = 0.985; // ~1.5% of cells hold a star
if (h > threshold) {
// Brightness from the hash remainder.
let b = (h - threshold) / (1.0 - threshold);
let col = mix(vec3<f32>(0.6, 0.7, 1.0), vec3<f32>(1.0, 0.9, 0.7), b);
// Soften the star with the fractional position inside the cell.
let f = abs(dir * scale - cell);
let d = max(f.x, max(f.y, f.z));
let falloff = smoothstep(0.5, 0.0, d);
return col * b * falloff * 3.0 * intensity;
}
return vec3<f32>(0.0);
}
- Step 2: Wire stars into
black_hole.wgsl
Add the import and use it on escape. Replace the escape branch:
#import "shaders/stars.wgsl"
// ... after classify ...
if (res.status == 1u) {
return vec4<f32>(0.0, 0.0, 0.0, 1.0);
}
// Escaped: sample procedural stars along the bent final direction.
let star = star_color(normalize(res.final_dir), uniforms.star_intensity);
return vec4<f32>(star, 1.0);
- Step 3: Run and verify lensed stars
Run: cargo run
Expected: the dark-grey background is replaced by a star field. Stars near the black shadow are visibly distorted/smeared into arcs (lensing). The shadow stays black.
- Step 4: Commit
git add assets/shaders/stars.wgsl assets/shaders/black_hole.wgsl
git commit -m "feat: procedural lensed starfield background"
Task 12: Accretion disk + Doppler beaming + lensed halo
Files:
- Create:
assets/shaders/disk.wgsl - Modify:
assets/shaders/black_hole.wgsl
The defining feature. We restructure black_hole.wgsl from "classify only" to "step-by-step integration that checks the disk plane at each step and composites hits front-to-back." This is the largest shader task.
- Step 1: Create
assets/shaders/disk.wgsl
// Disk plane is the xz-plane in world space, tilted by `disk_tilt` around the
// x-axis. We work in "disk-local" coordinates by rotating the ray.
// Rotate a vector around the X axis by angle a.
fn rot_x(v: vec3<f32>, a: f32) -> vec3<f32> {
let c = cos(a);
let s = sin(a);
return vec3<f32>(v.x, c * v.y - s * v.z, s * v.y + c * v.z);
}
// Returns true and sets out_t if the segment pos->pos+dir*dt crosses the
// disk plane (y=0) within radius [disk_inner, disk_outer].
fn disk_hit(prev: vec3<f32>, cur: vec3<f32>) -> bool {
let y0 = prev.y;
let y1 = cur.y;
if (y0 * y1 > 0.0) {
return false; // same side, no crossing
}
// Linear interpolate to the crossing point.
let t = y0 / (y0 - y1);
let cross = mix(prev, cur, vec3<f32>(t));
let r = length(vec2<f32>(cross.x, cross.z));
return r >= uniforms.disk_inner && r <= uniforms.disk_outer;
}
// Shade a disk hit: procedural texture + Doppler beaming + temperature color.
fn disk_color(pos: vec3<f32>, dir: vec3<f32>) -> vec3<f32> {
let r = length(vec2<f32>(pos.x, pos.z));
let phi = atan2(pos.z, pos.x);
// Procedural noise: layered angular + radial, animated by rotation.
let rot = uniforms.time * uniforms.disk_rotation_speed / pow(r, 1.5);
let n = sin(phi * 8.0 + rot) * 0.5 + 0.5;
let n2 = sin(phi * 23.0 - rot * 1.7 + r * 2.0) * 0.5 + 0.5;
let noise = mix(n, n2, 0.4);
// Temperature gradient: hotter (white-blue) near inner edge, cooler (orange-red) outer.
let t = (r - uniforms.disk_inner) / (uniforms.disk_outer - uniforms.disk_inner);
let tcol = mix(vec3<f32>(1.0, 0.95, 0.85), vec3<f32>(1.0, 0.45, 0.12), clamp(t, 0.0, 1.0));
// Falloff: brighter at inner edge.
let falloff = 1.0 / pow(r / uniforms.disk_inner, 2.0);
var col = tcol * (0.6 + 0.4 * noise) * falloff;
// Doppler beaming. Disk orbits Keplerian-ish: v ~ sqrt(Rs/(2r)).
let v_orbital = sqrt(uniforms.rs / (2.0 * r));
// Orbital velocity direction (tangent) in the disk plane.
let tangent = normalize(vec3<f32>(-sin(phi), 0.0, cos(phi)));
let beta = vec3<f32>(0.0); // placeholder; we use a scalar approximation below
let _ = beta;
// Scalar approximation: projection of orbital velocity onto ray direction.
let vdotn = dot(tangent * v_orbital, -dir); // toward viewer if positive
let gamma = 1.0 / sqrt(max(1.0 - v_orbital * v_orbital, 1e-4));
var doppler = 1.0;
if (uniforms.doppler_enabled != 0u) {
let delta = 1.0 / (gamma * (1.0 - vdotn));
doppler = pow(delta, 3.0) * uniforms.doppler_strength;
}
col *= doppler;
return col * uniforms.disk_brightness;
}
- Step 2: Restructure
black_hole.wgslto integrate step-by-step with disk compositing
Replace the fragment function (keep imports + uniform struct) with:
#import bevy_sprite::mesh2d_vertex_output::VertexOutput
#import "shaders/ray_gen.wgsl"
#import "shaders/geodesic_schwarzschild.wgsl"
#import "shaders/disk.wgsl"
#import "shaders/stars.wgsl"
// ... uniform struct + binding unchanged ...
@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
let aspect = uniforms.resolution.x / uniforms.resolution.y;
var uv = (in.uv * 2.0 - 1.0);
uv.x *= aspect;
let dir = ray_direction(uv);
// Work in disk-local space: rotate eye + dir by -disk_tilt around X so the
// disk lies on y=0. (disk_hit/disk_color assume disk-local coords.)
var pos = rot_x(uniforms.eye.xyz, -uniforms.disk_tilt);
var d = normalize(rot_x(dir, -uniforms.disk_tilt));
let dt = max(length(uniforms.eye.xyz), 20.0) / f32(uniforms.steps);
let steps = uniforms.steps;
// Front-to-back compositing.
var accum_color = vec3<f32>(0.0);
var accum_alpha = 0.0;
var prev = pos;
for (var i: u32 = 0u; i < steps; i = i + 1u) {
let r = length(pos);
if (r < uniforms.rs) {
// Captured: whatever we've composited so far is the result.
break;
}
if (r > 1000.0) {
// Escaped: add background stars along the (disk-local) final dir.
// Rotate back to world for the star sample.
let world_dir = normalize(rot_x(d, uniforms.disk_tilt));
let star = star_color(world_dir, uniforms.star_intensity);
accum_color += (1.0 - accum_alpha) * star;
accum_alpha = 1.0;
break;
}
// RK4 step (single step), then test disk crossing on the segment.
let (k1p, k1d) = deriv(pos, d);
let (k2p, k2d) = deriv(pos + k1p * dt * 0.5, normalize(d + k1d * dt * 0.5));
let (k3p, k3d) = deriv(pos + k2p * dt * 0.5, normalize(d + k2d * dt * 0.5));
let (k4p, k4d) = deriv(pos + k3p * dt, normalize(d + k3d * dt));
let new_pos = pos + (k1p + 2.0*k2p + 2.0*k3p + k4p) * dt / 6.0;
let new_dir = normalize(d + (k1d + 2.0*k2d + 2.0*k3d + k4d) * dt / 6.0);
if (disk_hit(prev, new_pos)) {
// Approximate the crossing point by interpolating to y=0.
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; // disk is nearly opaque
accum_color += (1.0 - accum_alpha) * dc * a;
accum_alpha += (1.0 - accum_alpha) * a;
if (accum_alpha > 0.99) { break; }
}
prev = new_pos;
pos = new_pos;
d = new_dir;
}
return vec4<f32>(accum_color, 1.0);
}
- Step 3: Run — the money shot
Run: cargo run
Expected: the black shadow is now surrounded by a glowing tilted accretion disk. The disk wraps OVER the top of the shadow and UNDER the bottom (the lensed halo / Einstein ring), exactly like the reference video. One side of the disk is brighter (Doppler). Orbit to see it from different angles.
If the halo doesn't appear: the rays must continue past the disk plane and wrap around — confirm the integration loop does NOT break on the first disk hit (it composites and continues). Confirm
stepsis ≥ 200.
- Step 4: Commit
git add assets/shaders/disk.wgsl assets/shaders/black_hole.wgsl
git commit -m "feat: accretion disk with Doppler beaming + lensed Einstein halo"
Task 13: Wire the UI and confirm live params (UI skeleton)
This task wires bevy_egui and proves a live param (e.g. disk_brightness) changes the image. Full panel content lands in Task 17.
Files:
-
Create:
src/ui.rs -
Modify:
src/render/plugin.rs -
Modify:
src/main.rs -
Step 1: Create
src/ui.rs
use bevy::prelude::*;
pub fn ui_system(
mut contexts: bevy_egui::EguiContexts,
mut params: ResMut<crate::params::BlackHoleParams>,
) {
egui::Window::new("Controls").show(contexts.ctx_mut(), |ui| {
ui.add(egui::Slider::new(&mut params.disk_brightness, 0.0..=3.0).text("Disk brightness"));
ui.add(egui::Slider::new(&mut params.steps, 50..=600).text("Steps"));
});
}
- Step 2: Add
EguiPlugin+ the system in the plugin
In src/render/plugin.rs, change build() to add the egui plugin and system:
app.add_plugins(bevy_egui::EguiPlugin)
.add_systems(Update, crate::ui::ui_system);
(Add the .add_plugins((Material2dPlugin::<BlackHoleMaterial>::default(), bevy_egui::EguiPlugin)) form, or two separate .add_plugins calls — either works.)
- Step 3: Declare the module in
src/main.rs
Add: mod ui;
- Step 4: Build, run, confirm live control
Run: cargo run
Expected: an egui "Controls" window appears (top-left) with two sliders. Dragging "Disk brightness" visibly brightens/dims the accretion disk in real time. Dragging "Steps" changes render quality.
- Step 5: Commit
git add src/ui.rs src/render/plugin.rs src/main.rs
git commit -m "feat: egui UI wired with live disk brightness + steps"
Task 14: Planets (scene entities via storage buffer)
Files:
-
Create:
src/scene/mod.rs -
Create:
src/scene/planets.rs -
Modify:
src/render/plugin.rs -
Modify:
src/render/material.rs(mirror planets) -
Create:
assets/shaders/planets.wgsl -
Modify:
assets/shaders/black_hole.wgsl -
Step 1: Create
src/scene/mod.rs
pub mod planets;
- Step 2: Create
src/scene/planets.rs
use bevy::prelude::*;
use bevy::render::render_resource::ShaderType;
use crate::render::material::{SphereData, MAX_PLANETS};
/// A planet rendered as a lensed sphere inside the geodesic shader.
#[derive(Component, Clone, Copy)]
pub struct Planet {
pub center: Vec3,
pub radius: f32,
pub color: Vec3,
pub emissive: bool,
}
/// Collects all Planet components and uploads them to every BlackHoleMaterial's
/// planets storage buffer each frame. Also updates planet_count in params.
pub fn upload_planets(
planets: Query<&Planet>,
mut params: ResMut<crate::params::BlackHoleParams>,
mut materials: ResMut<Assets<crate::render::material::BlackHoleMaterial>>,
) {
let mut data: Vec<SphereData> = planets
.iter()
.take(MAX_PLANETS)
.map(|p| SphereData {
center: p.center,
radius: p.radius,
color: p.color,
emissive: p.emissive as u32,
_pad0: 0.0,
_pad1: 0.0,
_pad2: 0.0,
})
.collect();
// Pad to MAX_PLANETS so the buffer size is constant (avoids reallocation).
data.resize(MAX_PLANETS, SphereData::default());
params.planet_count = planets.iter().count().min(MAX_PLANETS as u32) as u32;
for (_, mat) in materials.iter_mut() {
mat.planets = data.clone();
}
}
/// Spawns a default test planet behind the hole so lensing is visible.
pub fn spawn_default_planet(mut commands: Commands) {
commands.spawn(Planet {
center: Vec3::new(0.0, 2.0, -25.0),
radius: 2.0,
color: Vec3::new(0.3, 0.5, 1.0),
emissive: false,
});
}
- Step 3: Register planet systems in the plugin
In src/render/plugin.rs build(), add:
app.add_systems(Startup, crate::scene::planets::spawn_default_planet)
.add_systems(Update, crate::scene::planets::upload_planets);
- Step 4: Declare modules in
src/main.rs
Add: mod scene;
- Step 5: Create
assets/shaders/planets.wgsl
struct SphereData {
center: vec4<f32>, // xyz + radius
color: vec4<f32>, // rgb + emissive flag
};
@group(#{MATERIAL_BIND_GROUP}) @binding(3) var<storage, read> planets: array<SphereData>;
// Test the segment prev->cur against all planets. Returns hit color & alpha,
// or black/0 if no hit. `dir` is the ray direction (for shading).
fn planet_hit(prev: vec3<f32>, cur: vec3<f32>, dir: vec3<f32>) -> vec4<f32> {
var nearest_t = 1e9;
var nearest_col = vec3<f32>(0.0);
var found = false;
for (var i: u32 = 0u; i < uniforms.planet_count; i = i + 1u) {
let s = planets[i];
let center = s.center.xyz;
let radius = s.center.w;
// Ray-sphere intersection for the segment.
let seg = cur - prev;
let oc = prev - center;
let a = dot(seg, seg);
let b = 2.0 * dot(oc, seg);
let c = dot(oc, oc) - radius * radius;
let disc = b * b - 4.0 * a * c;
if (disc < 0.0) { continue; }
let sq = sqrt(disc);
var t = (-b - sq) / (2.0 * a);
if (t < 0.0) { t = (-b + sq) / (2.0 * a); }
if (t >= 0.0 && t <= 1.0 && t < nearest_t) {
nearest_t = t;
let hit_pos = prev + seg * t;
let n = normalize(hit_pos - center);
// Lambert shading from a fixed light direction.
let light_dir = normalize(vec3<f32>(0.5, 0.8, 0.3));
let ndl = max(dot(n, light_dir), 0.0);
var col = s.color.xyz * (0.2 + 0.8 * ndl);
if (s.color.w > 0.5) { col = s.color.xyz; } // emissive
nearest_col = col;
found = true;
}
}
if (found) {
return vec4<f32>(nearest_col, 0.95);
}
return vec4<f32>(0.0, 0.0, 0.0, 0.0);
}
- Step 6: Add planet compositing into
black_hole.wgsl
Add #import "shaders/planets.wgsl" to the imports. Inside the integration loop, after the disk-hit test (and before prev = new_pos;), add:
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; }
}
- Step 7: Run and verify a lensed planet
Run: cargo run
Expected: a blue planet is visible behind/above the hole. Move the camera so the planet passes near the shadow — it bends into an arc and may show a secondary image on the opposite side of the hole.
- Step 8: Commit
git add src/scene/ assets/shaders/planets.wgsl assets/shaders/black_hole.wgsl src/render/plugin.rs src/main.rs
git commit -m "feat: lensed planets via storage buffer"
Task 15: Spacetime-curvature grid (Flamm paraboloid)
Files:
-
Create:
assets/shaders/grid.wgsl -
Modify:
assets/shaders/black_hole.wgsl -
Step 1: Create
assets/shaders/grid.wgsl
// Flamm's paraboloid embedding: z(r) = 2*sqrt(Rs*(r - Rs)), dips below the disk.
// We trace against this surface in disk-local space (disk on y=0); the paraboloid
// opens downward (negative y). Returns additive grid color + alpha.
fn flamm_depth(r: f32) -> f32 {
if (r <= uniforms.rs) { return 0.0; }
return -2.0 * sqrt(uniforms.rs * (r - uniforms.rs));
}
fn grid_hit(prev: vec3<f32>, cur: vec3<f32>) -> vec3<f32> {
// Sample the paraboloid at the segment endpoints; if the segment crosses it,
// find an approximate crossing by sampling.
let r0 = length(vec2<f32>(prev.x, prev.z));
let r1 = length(vec2<f32>(cur.x, cur.z));
let z0_surf = flamm_depth(r0);
let z1_surf = flamm_depth(r1);
// Did the ray's y cross the surface y between endpoints?
if ((prev.y - z0_surf) * (cur.y - z1_surf) > 0.0) {
return vec3<f32>(0.0);
}
// Crossing: linear-search for the crossing point.
var hit = vec3<f32>(0.0);
var found = false;
for (var s: i32 = 0; s < 8; s = s + 1) {
let f = f32(s + 1) / 8.0;
let p = mix(prev, cur, vec3<f32>(f));
let r = length(vec2<f32>(p.x, p.z));
let surf = flamm_depth(r);
if (abs(p.y - surf) < 0.3) {
hit = p;
found = true;
break;
}
}
if (!found) { return vec3<f32>(0.0); }
// Polar grid pattern from (r, phi).
let r = length(vec2<f32>(hit.x, hit.z));
let phi = atan2(hit.z, hit.x);
let ring = smoothstep(0.06, 0.0, abs(fract(r * uniforms.grid_density * 0.5) - 0.5));
let spoke = smoothstep(0.04, 0.0, abs(fract(phi * 6.0 / 6.283185) - 0.5));
let grid = max(ring, spoke);
// Fade with depth so the grid reads as "below" the hole.
let fade = smoothstep(-15.0, -1.0, hit.y);
let col = vec3<f32>(0.15, 0.3, 0.6) * grid * fade;
return col * 0.5; // additive, low intensity
}
Note: WGSL allows calling
flamm_depthbefore its definition (forward references are legal), so no alias is needed.flamm_depthis defined at the top of this file;grid_hitcalls it.
- Step 2: Add grid into
black_hole.wgsl
Add #import "shaders/grid.wgsl". Inside the integration loop, after the planet test, add (only when grid is enabled):
if (uniforms.grid_enabled != 0u) {
let g = grid_hit(prev, new_pos);
if (g.x + g.y + g.z > 0.0) {
accum_color += g; // additive
}
}
- Step 3: Run and verify
Run: cargo run then enable the grid (add grid_enabled: true temporarily to BlackHoleParams::default(), or wait for the UI in Task 17).
Expected: a polar grid appears below the hole, dipping toward the center (the gravity well), and grid lines near the hole are visibly bent by lensing.
- Step 4: Commit
git add assets/shaders/grid.wgsl assets/shaders/black_hole.wgsl
git commit -m "feat: lensed Flamm paraboloid curvature grid"
Task 16: Cubemap skybox
Files:
-
Modify:
src/render/material.rs(load a skybox) -
Create:
assets/shaders/skybox.wgsl -
Modify:
assets/shaders/black_hole.wgsl -
Step 1: Create
assets/shaders/skybox.wgsl
@group(#{MATERIAL_BIND_GROUP}) @binding(1) var skybox: texture_cube<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(2) var skybox_sampler: sampler;
fn skybox_color(dir: vec3<f32>) -> vec3<f32> {
return textureSample(skybox, skybox_sampler, dir).rgb * uniforms.skybox_intensity;
}
- Step 2: Load a skybox (optional asset) in the plugin
Add to spawn_fullscreen_quad signature an assets: Res<AssetServer> param, and load a cubemap if present. For robustness, only set the skybox if the asset exists — but Bevy's asset server will just log a warning on a missing path. Simplest: bundle a placeholder cubemap at assets/skybox/skybox.png (a panorama) OR leave it None and rely on procedural stars. To enable it, the user places a cubemap and sets skybox_intensity > 0.
For this task, just ensure the binding compiles with None (procedural stars remain the default). No Rust change strictly required beyond what Task 7 already has (skybox: Option<Handle<Image>>). Document in the panel (Task 17) that intensity > 0 needs an asset.
- Step 3: Use skybox in the escape branch of
black_hole.wgsl
In the escape branch (where stars are added), prepend the skybox sample:
// Escaped:
let world_dir = normalize(rot_x(d, uniforms.disk_tilt));
var bg = skybox_color(world_dir);
bg += star_color(world_dir, uniforms.star_intensity);
accum_color += (1.0 - accum_alpha) * bg;
accum_alpha = 1.0;
break;
- Step 4: Build (skybox texture may be None — confirm no GPU error)
Run: cargo run
Expected: with no cubemap asset and skybox_intensity = 0, output is identical to before (procedural stars). Confirms the binding path doesn't crash when skybox is None.
If binding a None texture crashes: WGSL
textureSampleof an unbound texture is invalid. In that case gate the skybox sample:if (uniforms.skybox_intensity > 0.0) { bg = skybox_color(...) }— but the binding still must exist.AsBindGroupwithOption<Handle<Image>>binds a 1x1 fallback whenNone, which is safe to sample. If your build complains, provide a tiny 1x1 cubemap fallback asset.
- Step 5: Commit
git add assets/shaders/skybox.wgsl assets/shaders/black_hole.wgsl
git commit -m "feat: cubemap skybox layer (optional, procedural stars default)"
Task 17: Full egui control panel + pointer routing
Files:
-
Modify:
src/ui.rs -
Modify:
src/camera.rs(real egui-wants-pointer check) -
Modify:
src/render/plugin.rs -
Step 1: Expand
src/ui.rswith all sections
use bevy::prelude::*;
pub fn ui_system(
mut contexts: bevy_egui::EguiContexts,
mut params: ResMut<crate::params::BlackHoleParams>,
mut camera: ResMut<crate::camera::OrbitCamera>,
) {
let mut wants = false;
egui::Window::new("Controls")
.collapsible(true)
.default_pos([16.0, 16.0])
.show(contexts.ctx_mut(), |ui| {
egui::CollapsingHeader::new("Camera")
.default_open(true)
.show(ui, |ui| {
ui.add(egui::Slider::new(&mut camera.distance, 3.0..=200.0).text("Distance"));
ui.add(egui::Slider::new(&mut camera.yaw, -3.14..=3.14).text("Yaw"));
ui.add(egui::Slider::new(&mut camera.pitch, 0.05..=3.09).text("Pitch"));
ui.add(egui::Slider::new(&mut camera.fov, 0.3..=2.0).text("FOV"));
});
egui::CollapsingHeader::new("Accretion Disk")
.default_open(true)
.show(ui, |ui| {
ui.add(egui::Slider::new(&mut params.disk_inner, 1.5..=6.0).text("Inner radius"));
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"));
});
egui::CollapsingHeader::new("Doppler").show(ui, |ui| {
ui.checkbox(&mut params.doppler_enabled, "Enabled");
ui.add_enabled(params.doppler_enabled, egui::Slider::new(&mut params.doppler_strength, 0.0..=3.0).text("Strength"));
});
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.5..=1.0).text("Render scale"));
});
egui::CollapsingHeader::new("Background").show(ui, |ui| {
ui.add(egui::Slider::new(&mut params.star_intensity, 0.0..=3.0).text("Star intensity"));
ui.add(egui::Slider::new(&mut params.skybox_intensity, 0.0..=3.0).text("Skybox intensity"));
});
egui::CollapsingHeader::new("Grid").show(ui, |ui| {
ui.checkbox(&mut params.grid_enabled, "Enabled");
ui.add_enabled(params.grid_enabled, egui::Slider::new(&mut params.grid_density, 0.1..=4.0).text("Density"));
});
});
// bevy_egui sets ctx wants pointer; we read it in the camera system via the resource.
wants = contexts.ctx_mut().wants_pointer_input();
// Store via a dedicated resource updated here; camera reads it.
// (Simplest: use bevy_egui's EguiInputSet; here we use a lightweight flag resource.)
let _ = wants;
}
- Step 2: Add a
WantsPointerresource and wire the camera to respect it
In src/camera.rs, add:
#[derive(Resource, Default)]
pub struct WantsPointer(pub bool);
Replace orbit_controller to consume the resource and bail when egui wants input:
pub fn orbit_controller(
wants: Res<WantsPointer>,
mut camera: ResMut<OrbitCamera>,
mouse: Res<ButtonInput<MouseButton>>,
mut motion: EventReader<MouseMotion>,
mut wheel: EventReader<MouseWheel>,
) {
if wants.0 { motion.clear(); wheel.clear(); return; }
// ... (existing body unchanged) ...
}
(The Task 5 orbit_controller signature changes to add the wants parameter; keep its body identical otherwise.)
In src/ui.rs, set the resource:
pub fn ui_system(
mut contexts: bevy_egui::EguiContexts,
mut params: ResMut<crate::params::BlackHoleParams>,
mut camera: ResMut<crate::camera::OrbitCamera>,
mut wants: ResMut<crate::camera::WantsPointer>,
) {
// ... panel ...
wants.0 = contexts.ctx_mut().wants_pointer_input();
}
Register WantsPointer in the plugin: app.init_resource::<crate::camera::WantsPointer>();
- Step 3: Build and run
Run: cargo run
Expected: full collapsible control panel. Dragging sliders live-updates the scene. Orbit-drag/zoom only works when the cursor is NOT over the panel.
- Step 4: Commit
git add src/ui.rs src/camera.rs src/render/plugin.rs
git commit -m "feat: full egui control panel + pointer routing"
Task 18: Render-scale implementation + resize handling
Files:
- Modify:
src/render/plugin.rs
render_scale lowers the effective resolution for performance. The cleanest approach in our full-screen-quad setup is to scale the quad smaller and let it stretch — but that distorts. Better: adjust the Camera2d to render at lower res. Simplest correct approach for a procedural shader: scale the window's logical resolution used in the shader (multiply resolution by render_scale) and let the GPU upscale the quad output. Since our shader is fully procedural (no texture detail to lose except stars), the visual cost is minimal.
- Step 1: Apply render_scale to the effective resolution
In mirror_params in src/render/plugin.rs, change:
u.resolution = Vec2::new(win.width(), win.height());
to:
u.resolution = Vec2::new(win.width(), win.height()) * params.render_scale;
This makes the shader compute rays at fewer "logical" pixels' worth of UV granularity; combined with the quad filling the screen, lower render_scale gives a blockier but faster result. (True sub-resolution rendering would need an offscreen render target — out of scope; this gives the perf lever.)
- Step 2: Handle window resize (recompute quad aspect)
Add a system that rebuilds the quad on resize:
pub fn on_resize(
mut resize_reader: EventReader<bevy::window::WindowResized>,
window: Query<&Window>,
meshes: Res<Assets<Mesh>>,
mut commands: Commands,
mut mesh_handles: Query<&Mesh2d>,
) {
if resize_reader.read().next().is_none() { return; }
let win = match window.single() { Ok(w) => w, Err(_) => return };
let aspect = win.width() / win.height();
// Rebuild the rectangle mesh in place. (Simpler: despawn + respawn; see note.)
// Minimal: just update aspect in the shader via resolution (already done).
// Quad is already sized via the Camera2d projection scaling, so no change needed.
let _ = (meshes, commands, mesh_handles, aspect);
}
Add it to Update in the plugin. In practice the quad is sized by the Camera2d projection and resolution updates live, so resize works automatically. This system is a no-op placeholder confirming the resize path; remove if unused after verification.
- Step 3: Build, run, resize the window, confirm no distortion
Run: cargo run
Expected: resizing the window keeps the black hole circular and centered (no stretching). Lowering "Render scale" in the panel reduces detail but keeps frame rate up.
- Step 4: Commit
git add src/render/plugin.rs
git commit -m "feat: render-scale lever + resize handling"
Task 19: Web verification + polish
Files:
-
Modify:
Trunk.toml(if needed for release profile) -
Modify:
Cargo.toml(ensure wasm-release profile is used) -
Step 1: Run the web build in release
Run: trunk build --release
Expected: produces dist/ with the wasm artifact. No compile errors.
- Step 2: Serve and verify in Chrome/Edge
Run: trunk serve
Open in Chrome/Edge. Expected:
-
The black hole renders identically to desktop.
-
The egui panel is usable.
-
Orbit-drag and zoom work (mouse + touch).
-
Performance is interactive (≥30fps); if not, lower default
stepsto 200 andrender_scaleto 0.75 inparams.rsfor web via a#[cfg(target_arch = "wasm32")]default. -
Step 3: Add web-specific defaults to
params.rs
In BlackHoleParams::default(), branch on target:
steps: if cfg!(target_arch = "wasm32") { 200 } else { 300 },
render_scale: if cfg!(target_arch = "wasm32") { 0.75 } else { 1.0 },
- Step 4: Test the fallback in a non-WebGPU browser
Open in Safari with WebGPU disabled (or an old Firefox). Expected: the fallback message div appears instead of a blank canvas.
- Step 5: Final commit
git add Cargo.toml Trunk.toml src/params.rs
git commit -m "feat: web release build + web-tuned defaults + fallback verified"
Task 20: Performance check + final documentation
Files:
-
Create:
README.md -
Modify: nothing else
-
Step 1: Measure desktop FPS
Run: cargo run --release, orbit and zoom. Confirm ≥60fps at default settings (steps=300, render_scale=1.0) on a discrete GPU. If below, document recommended steps=200.
- Step 2: Write
README.md
# singularity-rs
A real-time, physically-motivated Schwarzschild black-hole renderer in Bevy 0.19.
Geodesic ray-tracing per pixel produces gravitational lensing, the Einstein ring,
a Doppler-beamed accretion disk, lensed stars, and optional lensed planets,
a curvature grid, and a cubemap skybox. Runs on desktop and WebGPU.
## Run
Desktop:
```sh
cargo run --release
Web (WebGPU; Chrome/Edge/recent Firefox):
cargo install --locked trunk
rustup target add wasm32-unknown-unknown
trunk serve
# open http://127.0.0.1:8080
Controls
- Drag (mouse) — orbit the camera.
- Scroll — zoom.
- Controls panel (top-left) — live-tune all parameters.
Tuning performance
Lower Steps and Render scale in the Controls panel for higher FPS.
Web defaults to steps=200, render_scale=0.75.
- [ ] **Step 3: Commit**
```bash
git add README.md
git commit -m "docs: README with run + control instructions"
Phase 1 complete — acceptance checklist
- Black shadow appears, sized ~bcrit (≈2.6 Rs), centered.
- Tilted accretion disk wraps OVER and UNDER the hole (Einstein halo), matching the reference video.
- Doppler asymmetry: one side brighter; bright side shifts as the camera orbits.
- Lensed procedural starfield; stars near the hole smear into arcs.
- (Optional, toggled) Flamm curvature grid dips toward the center and bends near the hole.
- (Optional) Lensed planets, including secondary images near the hole.
- (Optional) Cubemap skybox when an asset + intensity > 0.
- egui panel live-tunes all params; orbit-drag/zoom disabled over the panel.
cargo testpasses (bcrit + capture/escape unit tests).- Desktop ≥60fps at default settings; web interactive (≥30fps) on WebGPU.
- Non-WebGPU browser shows the fallback message.
Phase 2 follow-up (Kerr — separate spec/plan)
Replace geodesic_schwarzschild.wgsl with geodesic_kerr.wgsl (Boyer-Lindquist, adaptive RK4, spin parameter). Adds frame-dragging/ergosphere asymmetry. Likely needs render_scale≈0.5. Same scene elements, camera, params, UI carry over.