The docs described Phase 2 as 'future work' while the code had already
shipped it: spin is wired through the uniform into the shader's Kerr deriv(),
the integrator is adaptive RK45, disk_inner is ISCO-derived, render_scale
runs a real offscreen render-to-texture + upscale pass, and the UI exposes a
Spin slider with live ISCO/Horizon readouts. Anyone reading the docs got a
wrong picture of completeness.
README:
- Intro: 'Schwarzschild' -> 'black-hole (Kerr at spin>0, Schwarzschild at 0)'.
- How it works: RK4 -> adaptive Dormand-Prince RK45; add the frame-dragging
term to the bending accel; capture radius r+(chi); ISCO-tracked inner edge.
- Controls: add Black Hole section (Spin, ISCO/Horizon); render_scale slider.
- Performance: render_scale now wired (0.75 desktop / 0.5 web), not reserved.
- Status: Phase 1 shipped; Phase 2 implemented-pending-human-validation;
Phase 3 (exact pseudo-Hamiltonian) is the actual future work.
AGENTS.md:
- Intro + mirror contract: name the Kerr/RK45 functions the mirror must track
(kerr_bending_accel, rk45_step, is_captured_rk45) and the exact shader lines.
- Conventions: 'render_scale and spin are reserved' is false now — both wired;
update the perf-lever and web-defaults notes accordingly.
- Reference docs: list both Phase 2 docs, not just Phase 1.
Phase 2 plan (docs/superpowers/plans/2026-07-13-...-kerr.md):
- Status banner: Tasks 1-7 implemented+committed, Task 8 (human visual/perf
checklist) is what remains.
- Check the Task 1-7 step/commit boxes; leave all Task 8 manual steps and the
visual/fps acceptance items unchecked. Check only the code-verifiable
acceptance items (cargo test green, RK45+render_scale running, Spin slider
shipped).
Phase 2 shipped the Kerr deriv() and the adaptive RK45 integration loop in
the shader (black_hole.wgsl:260, 320-390), but the CPU mirror in physics.rs
only covered the single-step Kerr derivative (kerr_bending_accel), not the
loop. That left the AGENTS.md 'CPU <-> shader mirror' contract broken for
Phase 2: the spin-dependent capture radius, the adaptive step control, and
the budget = accepted-steps-only / dt_min forced-accept semantics existed
only on the GPU, with nothing testable on the CPU side.
Add a faithful CPU mirror and the loop-level tests that close the gap:
- rk45_step(): Dormand-Prince step mirroring black_hole.wgsl:260-285, using
kerr_bending_accel so it is also the Phase 1 step at chi=0.
- is_captured_rk45(): the full adaptive loop mirroring black_hole.wgsl:320-390
— same seeding (total_path / steps), same reject/retry at dt_min (the
forced-accept floor that prevents infinite retry), same accept-then-refine,
and the spin-dependent capture radius r+(chi) via kerr_horizon.
Tests (tests/physics_test.rs):
- spin=0 RK45 loop captures below bcrit and escapes above (degeneracy).
- spin=0.9 still captures a b~2.0 ray (horizon shrinks but not past b<bcrit).
- capture set does not grow as spin increases across a bcrit-straddling sweep.
- rk45_step error shrinks monotonically with dt (the property the adaptive
loop's reject/accept decision depends on).
Note: probing the error scaling showed it falls between 2nd and 4th order in
dt, not a clean 5th, because of the per-stage normalize() projection the
shader (and now the mirror) applies. The loop only relies on monotonicity, so
this is correct-as-shipped; documented in a doc-comment on rk45_step so a
future reader is not misled by the '45' in the name.
cargo test: 17 passed (3 Phase 1 inline + 14 integration, up from 12).
Fixes the following clippy warnings in plugin.rs:
1. clippy::too_many_arguments: Suppressed on spawn_fullscreen_quad since Bevy system functions naturally take many arguments as resources/queries.
2. clippy::field_reassign_with_default: Refactored instantiation of BlackHoleMaterial to set fields in the struct literal instead of post-creation assignment.
3. clippy::type_complexity: Suppressed on resize_offscreen and nudge_camera systems as Bevy queries with Filters/Disjunctions are inherently complex.
Clippy warned that the manual Default implementation for BlackHoleMaterial
could be derived (clippy::derivable_impls). We now derive Default directly
on the struct and remove the manual implementation block.
Fixes clippy::approx_constant errors caused by using the hardcoded value 3.14
for camera yaw and accretion disk tilt sliders. Using std::f32::consts::PI
resolves the errors and uses the precise constant value.
Clippy warned about excessive precision in the BCRIT float literal (2.5980762).
Since f32 has 24 bits of mantissa (approx 7 decimal digits of precision), the
excessive decimal digits are truncated to 2.598_076 to compile cleanly under
clippy::excessive_precision.
The integration test still passes as the difference is well within the 1e-5 limit.
resize_offscreen declared two Query<&mut Transform> params (FullscreenQuad
and UpscaleQuad filters). Bevy's conflict checker does not treat With<T>
filters as disjoint access — both queries declare write access to the same
component, so the app panicked at startup with error[B0001].
Merge the two queries into a ParamSet (borrowed one at a time) and move
them to the end of the parameter list per the Params convention.
- Set default pitch to 0.7 rad to keep the camera away from the π/2 gimbal pole, preventing horizontal drag from degenerating into an in-plane roll.
- Adjust scroll zoom logic to divide instead of multiply by the zoom factor, so that scrolling up zooms in (reduces distance) rather than out.
Compact onboarding file capturing hard-won, non-obvious repo facts:
- Dual-target build (desktop + web/WebGPU) and the wasm-only
web_sys_unstable_apis rustflag (.cargo/config.toml) that is a no-op
on desktop — easy to break during cleanup.
- Always run --release for visual checks; the debug ray tracer is too
slow and will look broken.
- The CPU<->shader mirror in src/physics.rs: it is a hand-maintained
duplicate of black_hole.wgsl kept solely so the capture/escape
boundary is testable. Editing one side without the other makes tests
pass on code the shader contradicts.
- The three Bevy 0.19 gotchas that cause the recurring grey screen
(nudge_camera for issue #24448, EguiPrimaryContextPass placement,
real storage buffer asset instead of default handle) — exactly what
this branch's recent commits fix.
- Reserved-but-intentionally-unwired fields (render_scale, spin) and
the Rs=1 natural-units convention so an agent does not 'fix'
intentional dead code or invent unit conversions.
- Git workflow: self-judge whether to commit, granular detailed
commits, never push.
No code changes.
The app showed only the camera clear color (grey srgb 43,44,47) — the
fullscreen quad's fragment shader produced no output. Two issues; the
second was the real blocker.
1. render/material.rs: skybox.wgsl declared binding 1 as texture_cube<f32>
but the material's #[texture(1)] defaulted to D2. The mismatched
bind-group layout vs. shader caused the pipeline to fail to specialize.
Fix: dimension = "cube" on the texture attribute so the layout matches.
2. SHADER COMPOSITION (the actual blocker): the shader was split across
naga_oil modules (ray_gen, geodesic, stars, disk, planets, grid, skybox)
pulled in via #import singularity::... This compiled and validated with
zero errors, but at runtime calling ANY cross-module-imported function
made the fragment output nothing (only the clear color showed). Local
functions were fine. Confirmed by bisecting a minimal repro with the
user watching the screen: local fn -> renders; imported fn (even pure
math) -> grey.
Rather than chase the naga_oil 0.22 / Bevy 0.19 composition bug, inline
every function into black_hole.wgsl and drop the #import singularity::*
lines. The 7 standalone module files are removed. Rendering now works.
Also fixes scene/planets.rs: upload_planets was allocating a fresh
ShaderBuffer (new handle) every frame, which re-triggered the
AsBindGroup RetryNextUpdate that the previous commit's startup pre-fill
was meant to eliminate. Now mutates the existing buffer asset in place
via set_data, keeping the handle stable.
The committed Phase 1 code rendered a black screen due to three
independent bugs, fixed here:
- black_hole.wgsl: dt was sized as |eye|/steps, so the full integration
only traveled |eye| units total — rays never reached capture or escape
and every pixel fell through to accum=black. Now dt covers
eye_dist + escape_r across the configured step count, and the escape
test uses the dynamic escape_r instead of a hardcoded 1000.0.
- render/plugin.rs: the planets binding was Handle::default(), causing
AsBindGroup to return RetryNextUpdate every frame and silently skip
the fullscreen quad's draw. Pre-fill a MAX_PLANETS-sized zeroed
ShaderBuffer at startup so the binding resolves immediately.
- render/plugin.rs: scale the fullscreen quad by half the window size
to match Camera2d's default WindowSize projection (1 unit = 1 px);
the old aspect-based scaling letterboxed the image.
- render/plugin.rs: add nudge_camera to work around Bevy 0.19 #24448,
where a static camera stops rendering after the first frame.
- planets.wgsl: rotate world-space planet centers into disk-local space
before the ray-sphere test (ray and centers were in different spaces).
README documents the renderer, run instructions (desktop + web), controls,
how the geodesic pipeline works, performance notes, and Phase 2 (Kerr) as
future work. Allow dead_code on BlackHoleParams since render_scale + spin
are intentionally reserved for Phase 2.
The quad mesh was built once at startup with the initial aspect ratio, so
widening the window afterward left empty edges. Add a FullscreenQuad marker
component and a fit_quad_to_window system that rescales the quad's Transform
to the live aspect each frame (only mutating on change).
Also remove the misleading 'Render scale' slider — it isn't wired to a real
sub-resolution render target in Phase 1 (the full-screen quad always renders
at window resolution). Lowering Steps is the real perf lever. Documented in
a code comment for future work.
The whole-file #import "file.wgsl" form (without ::) does not reliably
bring functions into scope in naga_oil/Bevy 0.19 — the renderer was
failing at runtime with 'no definition in scope for identifier:
ray_direction' (and rot_x, deriv, etc.), producing a blank screen despite
'cargo build' passing (shaders aren't compile-checked at build time).
Fix: add #define_import_path to each module file and import symbols
explicitly via namespace::name (the canonical Bevy pattern from the
shader_material_2d example). Verified the full shader pipeline compiles
and runs at runtime with zero WGSL errors.
This was a pre-existing bug masked by incomplete runtime verification in
earlier tasks; it is NOT specific to Task 15's grid work. All earlier
visual features (shadow, Doppler disk, Einstein halo, stars, planets,
grid) now actually render.
Add planets as ray-traced spheres inside the RK4 geodesic loop. A new
planets.wgsl shader tests each integrator segment against a storage
buffer of SphereData (center/radius/color/emissive) and composites hits
front-to-back alongside the accretion disk. Lambert shading with a fixed
light direction; emissive flag bypasses shading.
- assets/shaders/planets.wgsl: segment-sphere intersection + shading
- src/scene/: Planet component, upload_planets system, default planet
- black_hole.wgsl: wire planet_hit into the compositing loop
- geodesic_schwarzschild.wgsl: replace tuple return with Deriv struct
- Material2d bind group is group 2 (#{MATERIAL_BIND_GROUP}), not group 1
- RenderPlugin has no wgpu_backends field; enable bevy 'webgpu' feature instead
- WgpuSettings::default() auto-selects BROWSER_WEBGPU on wasm32
- Imports from bevy::sprite_render (Material2d, AlphaMode2d)
- Component-based spawning (Camera2d, Mesh2d/MeshMaterial2d), no bundles
- getrandom 0.4/wasm_js handled transitively by Bevy