28 Commits

Author SHA1 Message Date
xfy
77b1817f58 feat(ui): wire setup_egui_style one-shot into EguiPrimaryContextPass
Local<bool> guard retries until ctx_mut() first succeeds, applies the
sci-fi theme once, then never runs again. .chain()'d before ui_system so
the very first successful frame is already themed. Per-frame set_style
would dirty layout caches; this avoids that.
2026-07-17 15:37:34 +08:00
xfy
ec69698530 feat(planets): spawn_planet_system with deterministic ChaCha8Rng seeding 2026-07-16 18:07:53 +08:00
xfy
65a7611868 fix(disk): kill concentric moiré + smooth lensed-image rings via AA
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.
2026-07-16 16:02:41 +08:00
xfy
6801f196c3 style: clear remaining clippy warnings
- 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.
2026-07-15 17:01:40 +08:00
xfy
6f1f2ac75b feat(disk): port blackbody color, Kerr 4-velocity Doppler, relativistic jets
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.
2026-07-15 16:12:12 +08:00
xfy
827edd35a2 feat(mirror): copy volumetric-disk params into uniform each frame
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.
2026-07-15 13:22:15 +08:00
xfy
33ce1061ba fix(render): resolve validation crash from egui camera + brightpass uniform
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.
2026-07-15 09:42:19 +08:00
xfy
7591b790b2 ui: Quality panel + runtime bloom-quality rebuild
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.
2026-07-15 00:31:50 +08:00
xfy
c06f1543db params: BloomQuality enum + tiered web/desktop defaults + Off fallback
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.
2026-07-15 00:22:39 +08:00
xfy
a7490c0c2f render: composite + ACES tone-map (bloom stage [5]), replaces upscale
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.
2026-07-15 00:14:03 +08:00
xfy
ed6639738f render: blur pyramid (bloom stages [3]/[4]) — down then up
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.
2026-07-15 00:07:09 +08:00
xfy
6ccd0a173b render: bright-pass material + camera (bloom stage [2])
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.
2026-07-15 00:01:31 +08:00
xfy
0f33d80fd3 shader: gaussian-speck stars + star_aa toggle + bloom/exposure uniforms
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.
2026-07-14 23:55:17 +08:00
xfy
d7fdef541e render: offscreen → Rgba16Float + Nudgable/QuadScaleFactor/CompositeQuad markers
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.
2026-07-14 23:31:38 +08:00
xfy
9afab04308 render: resolve clippy warnings in BlackHolePlugin systems
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.
2026-07-14 15:09:39 +08:00
xfy
22489f50da fix: group conflicting Transform queries in ParamSet to fix B0001 panic
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.
2026-07-14 15:00:05 +08:00
xfy
7d4a53358f feat: derive disk_inner from Kerr ISCO 2026-07-14 14:18:36 +08:00
xfy
61edcfabd2 feat: plumb spin parameter into GPU uniform 2026-07-14 14:12:31 +08:00
xfy
e158c8c668 feat: wire render_scale via offscreen render-to-texture + upscale 2026-07-14 14:09:39 +08:00
xfy
ee15ddecb7 fix: make the renderer actually draw (step size, storage buffer, quad fit)
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).
2026-07-13 11:47:31 +08:00
xfy
1e8cd93bc3 fix: keep full-screen quad filled on window resize
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.
2026-07-13 10:00:42 +08:00
xfy
7e0ac65d55 feat: full egui control panel + pointer routing 2026-07-13 09:57:54 +08:00
xfy
8931904495 feat: lensed planet ray-sphere intersection in geodesic tracer
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
2026-07-10 17:06:19 +08:00
xfy
983413e9af feat: egui UI wired with live disk brightness + steps 2026-07-10 10:54:22 +08:00
xfy
265ec9925d feat: BlackHoleUniforms + camera/params mirror system 2026-07-10 10:33:10 +08:00
xfy
1a2b157ef3 feat: BlackHoleParams tunable resource 2026-07-10 10:27:10 +08:00
xfy
d039064efa feat: orbit camera controller (yaw/pitch/zoom) 2026-07-10 10:25:16 +08:00
xfy
a45d42c07a feat: full-screen quad + BlackHoleMaterial pipeline 2026-07-10 10:20:38 +08:00