10 KiB
AGENTS.md
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
- Desktop:
cargo run --release(debug is too slow to ray-trace; always use--releasefor visual checks). - Web (WebGPU only):
trunk serve→ http://127.0.0.1:8080. First-time setup:cargo install --locked trunkandrustup target add wasm32-unknown-unknown. Release web build:trunk build --release. .cargo/config.tomlsets--cfg web_sys_unstable_apisfor thewasm32target only — required forweb-sys's WebGPU bindings. It is a no-op on desktop; do not remove it.edition = "2024". No pinned toolchain file; tested on stable 1.96.- Release profiles (
Cargo.toml): desktop[profile.release]useslto = "fat",codegen-units = 1,panic = "abort",strip = "symbols". Web[profile.wasm-release]inherits release and overridesopt-level = "z"for binary size.trunk build --releasepicks upwasm-release. target/anddist/are gitignored.dist/may hold a ~200 MB wasm build locally — never commit it.
Test
cargo test. There is exactly one testable surface:src/physics.rs(inline#[cfg(test)]) +tests/physics_test.rs(integration test via thesingularity_rs::physicslib export).src/lib.rs(pub mod physics;) exists solely to exposephysicsfor these tests.- The GPU shader is not unit-tested. The whole point of
physics.rsis to be a CPU mirror that is testable. - Run a single test:
cargo test rk45_capture_radius_shrinks_with_spin(substring match works).
Architecture: the CPU ↔ shader mirror
The renderer is a multi-stage HDR pipeline of full-screen quads, each a Material2d writing into an offscreen Rgba16Float Image:
- Black-hole quad (
assets/shaders/black_hole.wgsl) — Kerr geodesic integration via an adaptive Dormand-Prince RK45 loop + disk/planets/grid/star/jets compositing. Renders at sub-resolution (render_scale) into the offscreen target. - Bright-pass (
brightpass.wgsl) → blur pyramid (blur.wgsl, 2 down + 2 up) → composite (composite.wgsl): bloom extraction, pyramid blur, then ACES tone-map of scene + bloom to the window's LDR surface.
Seven Camera2d entities are spawned at startup (offscreen + brightpass + 4 blur + composite), ordered by Camera.order from -20 (offscreen) up to the composite/window camera. Bloom is gated by BloomQuality (Off on web default, High on desktop); when Off the composite samples a 1×1 black texture (scene-only ACES). rebuild_bloom despawns/respawns the whole bloom sub-graph when the quality tier changes.
src/physics.rs is a hand-maintained CPU mirror of stage 1's 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 / 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 tests pass on code 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 capture loop). The loop-level invariants that must match: budget = accepted-steps-only, the dt_min forced-accept floor, and r₊(χ) as the capture radius. (Shader line numbers drift as the file grows — locate these by function name: deriv, rk45_step, and the main loop.)
Module wiring (entrypoints):
main.rs— app entry, web fallback gate, plugin wiring (render::BlackHolePlugin). See web gotchas below for the two non-obviousDefaultPluginsoverrides.render/plugin.rs—BlackHolePlugin: spawns the offscreen quad + bloom pipeline + composite camera,mirror_params(params → GPU uniform each frame),resize_offscreen,nudge_camera,rebuild_bloom.render/material.rs— the fourMaterial2ds:BlackHoleMaterial(+BlackHoleUniforms/SphereData),BrightPassMaterial,BlurMaterial,CompositeMaterial.camera.rs— orbit controller (yaw/pitch/zoom) +WantsPointer(disables orbit over the UI panel).params.rs—BlackHoleParamsresource + quality-tier enums (BloomQuality,DiskQuality,DiskColorMode,AaQuality), edited live by the egui panel, mirrored into the material each frame.scene/planets.rs—Planetcomponent + storage-buffer upload.ui.rs— egui Controls panel.web.rs— wasm-only: WebGPU detection + fallback message.
Bevy 0.19 gotchas (cause of the recurring "grey screen" / crash)
Silent failure modes — recent commits exist solely to fix these. Check them before the shader when debugging a blank/grey/crashing canvas:
nudge_camera(render/plugin.rs) works around Bevy 0.19 issue #24448: a staticCamera2dstops rendering after the first frame. It oscillates everyNudgablecamera by a sub-pixel amount each frame. Do not remove it expecting a cleanup — the offscreen camera freezing makes the composite re-sample a stale texture (frozen view).- bevy_egui 0.41 requires UI systems to run in
EguiPrimaryContextPass, notUpdate. Placingui_systeminUpdatepanics. PrimaryEguiContextmust be explicitly pinned to the composite (window) camera, withdisable_egui_auto_contextturning off bevy_egui's auto-assignment inPreStartup. Auto-assignment givesPrimaryEguiContextto the first spawned camera — the offscreen one, whoseRgba16Floatrender target mismatches egui'sRgba8UnormSrgbpipeline → format-mismatch crash. This is load-bearing now that the bloom pipeline spawns 7 cameras.- The planets storage buffer must be a real
ShaderBufferasset, notHandle::default(). A default handle makesAsBindGroupreturnRetryNextUpdateevery frame, silently skipping the quad's draw — the screen shows only the camera clear color. The quad is pre-filled with aMAX_PLANETS-sized zeroed buffer at startup;upload_planetsupdates it. - The skybox texture binding must declare
dimension = "cube"(render/material.rs). TheAsBindGroupderive defaults to D2; the shader declarestexture_cube<f32>, so a D2 layout makes the pipeline fail to specialize and the quad silently draws nothing. When no cubemap is set, Bevy binds its 1×1 cube fallback (gated out byskybox_intensity > 0anyway).
Web gotchas
main.rssetsAssetPlugin { meta_check: AssetMetaCheck::Never }: shaders ship as raw.wgslwith no.metacompanions. The defaultAlwaysfetches<path>.metaper asset; the trunk dev server doesn't 404 cleanly, so bevy tries to RON-deserialize the returned bytes and logs a deserialization error per shader.main.rsdisablesbevy::audio::AudioPlugin: the app has no audio, and the default plugin opens a WebAudio sink that browsers block until a user gesture, logging a noisy "AudioContext was not allowed to start" error.- The skybox must be sampled with
textureSampleLevel(nottextureSample) in the shader — Tint (the WGSL→-SPIR-V compiler used on web) enforces uniform-control-flow rules thattextureSampleviolates, failing the web build. See commit5c0b1db. Trunk.tomlcopiesassets/(shaders) intodist/viacopy-dir. If you add a new shader, it is picked up automatically because the wholeassets/tree is copied.
Conventions
- Natural units:
Rs = 1throughout (Rust + WGSL).BCRIT = 3√3/2·Rs ≈ 2.598is a literal inphysics.rsbecausef32::sqrtisn'tconst; the integration test guards the literal. disk_inneris spin-derived, not read from its param field.mirror_paramsoverwritesuniforms.disk_innerwithkerr_isco(params.spin)every frame;params.disk_innerexists only for the default/UI readout and is ignored at runtime. The disk inner edge tracks the Kerr ISCO (6M = 3 Rs at χ = 0, shrinking to M = Rs/2 at extremal spin).spin(χ ∈ [0,1]) drives the Kerr frame-dragging term in the shader'sderivand a spin-dependent capture radius (r₊); at χ = 0 the frame-dragging term vanishes and the integrator is exactly Schwarzschild.stepsandrender_scaleare performance levers.stepscaps accepted RK45 steps per ray;render_scalelowers the offscreen resolution. The RK45 integrator is ~an order of magnitude costlier than fixed-step RK4, so the defaultrender_scaleis 0.75 (desktop) / 0.5 (web).- Quality tiers (
params.rs) gate per-pixel work and default differently per target viacfg!(target_arch = "wasm32"):steps200 (web) vs 300 (desktop);render_scale0.5 vs 0.75;bloom_qualityLow vs High;disk_qualityLow vs High;aa_qualityOff vs Low. - Mirroring a new param end-to-end touches four places in lockstep:
BlackHoleParams(params.rs) →BlackHoleUniforms(render/material.rs, mind WGSLvec3-alignment padding) →mirror_paramsassignment (render/plugin.rs) → the shader struct + its use. Quality-tier enums also need a UI entry inui.rs.
Git workflow
- After finishing a change, decide for yourself whether it should be committed — don't stop and ask. Commit when the work forms a coherent, complete unit (builds, tests pass, code compiles); hold off only if it's mid-flight or known-broken.
- Commits are granular and detailed. Split by concern: one logical change per commit, not one giant dump. The message explains what and why (the gotcha it fixes, the invariant it restores), not just a restatement of the diff.
- Never push. Local commits only; leave pushing to the human.
Reference docs
README.md— controls, how-it-works, project layout, status.docs/superpowers/specs/+docs/superpowers/plans/— phased design specs and implementation plans:- Phase 1 (Schwarzschild):
2026-07-09-interstellar-blackhole-*.md - Phase 2 (Kerr):
2026-07-13-interstellar-blackhole-phase2-kerr-*.md - Phase 3 (cinematic: HDR/bloom/tone-map):
2026-07-14-blackhole-cinematic-rendering-*.md - Phase 3.1 (volumetric disk):
2026-07-15-volumetric-disk-*.md
- Phase 1 (Schwarzschild):
Skills
Matt Pocock's engineering skills are vendored under .agents/skills/ and pinned in skills-lock.json (e.g. tdd, code-review, diagnosing-bugs, codebase-design). They are workspace-scoped and load automatically.