Adds kerr_nodal_precession(r, chi) = Ω_φ − Ω_θ using the Okazaki (1987)
vertical epicyclic frequency:
Ω_θ² = Ω_φ² · (1 − 4a√M/r^1.5 + 3a²/r²)
χ=0 → a=0 → Ω_θ = Ω_φ → zero precession (exact Schwarzschild degeneracy).
Note on the cross term: it is a√M/r^1.5 (radius to the −1.5), NOT a·Ω_φ/r.
The latter makes Ω_θ too large and breaks the "precession grows monotonically
with spin" property — caught by the grows_with_spin test.
Three tests: vanishes at zero spin, grows monotonically with spin at fixed r,
and exceeds the weak-field 2aM/r³ approximation in the strong-field (r<6)
region. Full suite green (14 tests).
Two distinct sampling artifacts on the accretion disk, found with a
CPU pixel-mirror of the integrator:
1. Moiré on the disk. integrate_disk_segment placed its N sub-samples
on the deterministic (i+0.5)/N grid. That lattice aliases against
the 2-D pixel grid and the per-ray RK45 step lattice → a concentric
banding. Fix: jitter the sub-sample position within stratum i using
a hash of the pixel coordinate and the sample index. The jitter is
pixel-seeded only (no time), so the noise is spatially stable (no
per-frame flicker) and stays inside the stratum, preserving the
quadrature order. Verified by CPU mirror: banding transitions drop
sharply and the noise is unstructured.
2. Higher-order lensed-image rings read as an artifact. Rays near the
critical impact parameter wrap around the hole; each wrap crosses
the disk plane once, projecting to a ring (a physical Einstein-ring
structure, the same one Gargantua shows). The integrator renders
each wrap as a sharp discrete band, so at high `steps` the rings
look like a bug. Fix: per-pixel stochastic supersampling — fire
several jittered sub-rays per pixel and average them, which
antialiases the band edges into a smooth gradient while keeping the
underlying lensing physics.
The two fixes share a `pixel_seed` (var<private>, set from
@builtin(position)) so the jitter is deterministic per pixel.
Shader refactor: the single-ray march is extracted into march(dir);
fragment() now loops it MAX_AA_SAMPLES times (compile-time cap for
WGSL's static-loop-bound requirement, runtime count from a new
aa_samples uniform via early break — the fbm3/MAX_OCTAVES pattern).
Uniform layout: added aa_samples: u32 + two f32 pads to round the
struct to 16-byte alignment, mirrored byte-for-byte across the WGSL
struct and BlackHoleUniforms (ShaderType).
UI/params: new AaQuality enum (Off/Low/High = 1/2/4 samples) in the
Quality panel. Defaults are tiered per AGENTS.md: Off on web, Low (2×)
on desktop — the RK45 march is already ~10× Phase 1's cost, so desktop
stays at 2× with High (4×) selectable for a fully smooth look.
cargo test: 20 passed. cargo build --release: clean.
sample_jets suppresses jets for χ < 0.05 (previous commit), but the egui
panel's checkbox still flipped freely and Strength stayed editable at low
spin, so enabling jets there did nothing with no explanation — the toggle
looked broken.
Mirror the shader's gate in the UI: keep the Enabled checkbox interactive
(it expresses user intent), gray out Strength when jets won't render
(matching the existing pattern used for disk quality, Doppler, color
model, and bloom), and show a one-line hint ("Spin (χ) too low — jets
need χ ≥ 0.05.") so the no-op is self-documenting.
Two independent sources of noise in the web console, both from the
default plugin set:
1. 'Failed to deserialize meta for asset shaders/*.wgsl'
DefaultPlugins adds AssetPlugin with meta_check = Always, so bevy
fetches <path>.meta for every shader. The shaders ship raw (no .meta
files); on the trunk dev server those requests don't return a clean
404 that bevy maps to NotFound, so bevy receives bytes and tries to
RON-deserialize them as AssetMetaMinimal, logging a SpannedError per
shader. Set meta_check = Never: it skips the meta lookup and uses the
loader's default meta — correct for processor-free raw assets.
2. 'AudioContext was not allowed to start'
The default AudioPlugin opens a WebAudio sink at startup; browsers
block that until a user gesture and log it as a JS error. This app
has no audio, so disable AudioPlugin entirely — removes the noise and
shrinks the wasm binary.
Defaults retuned to match the Interstellar look now that the density
model drives appearance:
- disk_outer 15 → 25: Gargantua-like extent.
- disk_half_thickness semantics → H/R ratio, default 0.15 (was an
absolute 0.3/0.2 world-space band). Standard thin-disk scale height.
- disk_color_mode default → Blackbody (was Gradient): the Novikov-Thorne
radial temperature gradient × Kerr Doppler gives the smooth white-hot
inner → deep-orange outer look the new density model is built around.
- disk_temp 10000 → 6500 K: tuned so the NT profile yields warm-white
inner fading to deep orange at the edge.
- density_strength 1.0 → 1.2: the new model has no floor and decays to 0
at the edges, so a slightly higher multiplier keeps the bulk opaque
after per-step integration.
UI: "Half thickness" → "Thickness (H/R)" with a 0.02..=0.3 range
(thin-disk regime); "Outer radius" range widened to 6..=50.
BlackHoleUniforms::Default (material.rs) mirrors the same values so the
GPU default matches params.rs before the first mirror_params tick. Field
order/types/layout unchanged; no uniform-struct migration needed.
- params.rs: collapse `if cfg!(wasm) { false } else { true }` for
star_aa into `!cfg!(wasm)` (clippy::needless_bool).
- render/plugin.rs: `resize_offscreen` is a Bevy system whose argument
count is fixed by its SystemParam set; allow clippy::too_many_arguments
alongside the existing type_complexity allow on the same function.
The control panel grew past one screen after the disk/jet additions
(Color model, Temperature, Jets section). egui::Window without a height
constraint just clips overflowing content. Give it a default width/height,
enable resizing, and wrap the body in ScrollArea::vertical so all sections
remain reachable.
Bring three accretion-disk strengths from others/blackhole-simulation into
the WGSL renderer, behind opt-in controls. The physics.rs CPU mirror is
untouched — these are all color/shading functions, outside the deriv/rk45/
integration-loop lockstep surface.
Color model (DiskColorMode uniform, default Gradient to preserve the prior
look):
- Gradient (mode 0): existing white-hot → orange ramp + Newtonian
apply_doppler, unchanged.
- Blackbody (mode 1): Novikov-Thorne radial temperature profile
(isco_r^0.75 · (1−√isco_r)^0.25) shifted by the Kerr equatorial
4-velocity Doppler factor δ, fed through a Tanner-Helland blackbody
color function, times δ^3.5 beaming. The approaching side therefore
turns hotter/bluer and the receding side cooler/redder — the physical
signature the gradient mode lacks. kerr_doppler() solves g_μν u^μ u^ν=-1
for circular orbits exactly; the max(0.01,…) floor guards pow-of-negative
NaN. A unified disk_emission() now assembles color for both the flat and
volumetric paths, replacing two duplicated tcol/falloff/doppler chains.
Relativistic jets (jets_enabled, default on): bipolar cones along the spin
axis with 0.92c outflow beaming (δ^3.5, no floor needed — β fixed keeps the
denominator positive), Gaussian radial falloff, exponential length decay,
and outward-flowing value-noise turbulence. Front-to-back composited into
the same accumulators as the disk. Ported to ptr<function,…> accumulators
(WGSL has no inout) and the existing value_noise3.
UI: Color model dropdown + temperature slider under Accretion Disk; new
Jets section with enable + strength. Four uniform fields added as one
16-byte row, Rust/WGSL order kept identical.
Verified: cargo build --release, cargo test (17 tests, mirror untouched),
naga WGSL validation.
Set the OrbitCamera default to the framing the UI screenshot shows:
yaw -1.065, pitch -0.335, distance 30, fov 1.0 (the latter two were
already the defaults).
The old default (yaw 0, pitch +0.7) was a 3/4 view from above the disk
plane. The new pitch sits the eye just below the disk plane, so the
gravitationally lensed far side of the disk arcs over the top of the
shadow — the iconic Interstellar framing. yaw -1.065 (~-61°) picks a
viewing azimuth that keeps the lensed arc and photon ring balanced in
frame.
Live-tunable volumetric disk params. Quality ComboBox (Off/Low/Medium/
High) mirrors the Bloom quality pattern; the 8 sliders disable when Off.
Off reverts to the flat disk for perf escape + visual A/B.
Extends mirror_params with the 9 new field assignments. DiskQuality enum
gains an as_u32() accessor for the WGSL tier selector. GPU now receives
live values; shader does not consume them yet.
Data layer for the volumetric disk. DiskQuality gates octave counts
(Off/Low/Medium/High); Off is a full revert to the flat disk. Eight new
tunable f32 fields for thickness, filament, density, and spiral-arm
control. Web defaults to Low tier + 0.2 half-thickness; desktop to
High + 0.3. Nothing reads these yet.
Two runtime validation errors crashed the app on startup:
1. BrightPassMaterial used #[uniform(0)] threshold: f32 (bare f32 = 4-byte
binding), but the WGSL BrightPassUniform struct is 16 bytes (threshold +
3 pads). The pipeline-layout mismatch ("Buffer structure size 16...
greater than min_binding_size, which is 4") killed the opaque_mesh2d
pipeline. Fix: wrap threshold in a BrightPassUniform struct matching the
WGSL layout, same pattern as BlurUniform/CompositeUniform.
2. bevy_egui 0.41's auto-context assigns PrimaryEguiContext to the FIRST
Added<Camera> — with the 7-camera bloom pipeline, that's the offscreen
camera (renders to Rgba16Float Image). egui's pipeline expects the
window's Rgba8UnormSrgb surface → format mismatch crash. Fix: disable
auto_create_primary_context (PreStartup system) and explicitly tag the
composite (window) camera with PrimaryEguiContext.
Both fixes verified: the app now runs without validation errors for 8+
seconds. Physics tests (20/20) unaffected.
Adds a dedicated Quality collapsible section: bloom quality dropdown
(Off/Low/Medium/High), bloom threshold/strength, exposure, resolution
scale, star AA toggle. Changing bloom quality at runtime triggers a
pipeline rebuild (despawn bloom entities + respawn via spawn_bloom_pipeline
helper). MSAA honestly labeled as decorative.
Note: the rebuild currently re-spawns the full 3-level pyramid whenever
bloom is enabled (Off vs On). Partial pyramids for Low/Medium are a
future enhancement.
BloomQuality { Off, Low, Medium, High } controls pyramid depth (0-3).
Web defaults to Low (1 level, minimal float textures), desktop to High
(3 levels, full cinematic). Off skips all bloom passes and composites
scene-only with ACES — the fallback path for WebGPU browsers without
float-filterable support. The composite samples a 1x1 black texture
when bloom is off.
Note: this task gates Off vs full-On (3-level pyramid always runs when
enabled). Partial pyramid for Low/Medium is deferred to the runtime
rebuild in Task 8.
Adds CompositeMaterial + composite.wgsl: combines the HDR scene with the
bloom pyramid output, applies ACES (Narkowicz) tone mapping, and writes
LDR to the window. Removes UpscaleMaterial + upscale.wgsl.
mirror_params now updates composite (bloom_strength, exposure) and
brightpass (threshold) uniforms each frame. resize_offscreen rebuilds
the full bloom pyramid targets on window resize.
Adds BlurMaterial + blur.wgsl with a 13-tap weighted Gaussian kernel
(var<private> arrays — naga rejects dynamic indexing of const arrays),
run in downsample then upsample passes. For the 3-level pyramid:
bloom_0 (half, from brightpass) → bloom_1 (quarter) → bloom_2 (eighth),
then upsample to bloom_final (half). Camera orders -18/-17/-16/-15.
The bloom output is not yet composited (Task 6); this commit only wires
the passes and confirms they run in sequence without crashing.
Adds BrightPassMaterial + brightpass.wgsl: extracts luminance above a
soft-knee threshold from the HDR offscreen into a half-res Rgba16Float
target (bloom_0). Camera order -19 (after offscreen at -20). The brightpass
output is not yet composited (Task 6); this commit only adds the pass and
confirms it runs without crashing.
star_color's smoothstep(0.5,0.0,d) with scale=80 produced blocky rectangles
at low render_scale. Replace with a gaussian speck: distance to cell center
with exp(-d²/r²) falloff, producing a round 2-3 pixel anti-aliased disk.
star_aa uniform toggles between the gaussian path (desktop default) and the
old square-cell fast path (web default).
Also adds bloom_threshold/bloom_strength/exposure uniform fields to
BlackHoleUniforms (used by later bloom tasks) with safe defaults.
Switch the offscreen target from Bgra8UnormSrgb to Rgba16Float for HDR
headroom (disk brightness can exceed 1.0 without clamping). Set offscreen
camera order to -20 (the bloom pipeline, added next, needs cameras ordered
so offscreen renders first). Introduce three marker components:
- Nudgable: all render cameras carry it; nudge_camera now queries this
instead of a hardcoded Or<(OffscreenCamera, UpscaleCamera)>.
- QuadScaleFactor(fx, fy): fraction of offscreen res each quad fills;
resize_offscreen rescales each quad independently via this.
- CompositeQuad: the final quad renders to the window (rescaled against
window res, not offscreen).
The upscale path still works (textureSample reads float textures);
ACES tone-mapping is added in a later commit.
At 1.318 rad (~75.5 deg) the disk is nearly vertical: its normal points
almost along +Z while the camera yaw axis is world-Y, so the two axes
sit ~75 deg apart. Orbiting via yaw (a horizontal circle around world-Y)
therefore shears the disk through view -- full circle, squashed ellipse,
edge-on sliver, back circle -- which reads as the disk swinging or the
scene rolling.
0.45 rad (~25.8 deg) brings the disk near-horizontal (normal approx
(0, 0.90, 0.43)), shrinking the yaw-axis / disk-normal misalignment
from ~75 deg to ~26 deg. The swing is correspondingly muted; orbiting
feels like turning around the hole rather than shearing across a wall.
Both defaults are updated in lockstep: BlackHoleParams (the live runtime
default) and BlackHoleUniforms (the bootstrap uniform filled before the
first mirror_params frame).
basis() derived `right` from `forward × world_up`, which degenerated to
a zero vector when forward aligned with world-Y (pitch = ±π/2) — the
cross product vanished, normalization produced garbage, and crossing
the pole flipped the image 180°.
Right now depends on yaw alone: Vec3::new(cos yaw, 0, -sin yaw). It is
a unit vector in the y=0 plane for any yaw, so it never goes to zero
regardless of pitch. In the original upper hemisphere (cp > 0) this is
exactly the old value after normalization, so existing views are
unchanged; it additionally stays perfectly horizontal for all pitch,
fixing a subtle roll the cross-product form introduced below the equator.
With the singularity gone, pitch is unclamped from [0.05, π-0.05] to
[-π+0.05, π-0.05]: the camera can now cross both poles and reach below
the disk plane without flipping. The ±0.05 margin only avoids the exact
origin-axis points where forward = ∓Y and yaw becomes meaningless.
yaw is also wrapped to (-π, π] on each drag so it cannot drift out of
the UI slider range (which would snap the view when the slider is later
touched); cos/sin periodicity makes the wrap visually lossless.
The UI pitch slider range is widened to match. Stale "gimbal pole"
comments that described the old restriction are corrected.
basis() still returns (eye, forward, right, up), so the uniform bridge
in plugin.rs and both shaders are untouched.
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.
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.
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