102 Commits

Author SHA1 Message Date
xfy
95968c0575 docs(spec): Kerr orbiting planets design (Phase 3.4)
Spec for 5-8 planets on Kerr circular orbits with strong-field Lense-Thirring
nodal precession. Path A: CPU computes position each frame, uploads to the
existing storage buffer; shader unchanged.

Physics: closed-form Ω_φ (Bardeen 1972 eqn 2.16) and Ω_θ (Caltech Ph236
epicyclic vertical frequency), giving exact nodal precession Ω_φ - Ω_θ.
χ=0 degenerates exactly to Schwarzschild (Ω_LT = 0, Newtonian Ω_φ).

Explicitly rejects Mino-time analytic geodesics (Fujita-Hikida): circular
orbits collapse the three-frequency decomposition to Ω_φ alone, elliptic
functions have no WGSL/std backing, and it would break physics.rs's
single-testable-mirror design. Closed-form Kerr frequencies are the right
tool for circular orbits.

Orbit radius bound to k·kerr_isco(χ), so planets track the disk inner edge
as spin changes and span the strong-field region (k=2.5 → r ∈ [1.25, 7.5]).
Random initial attitudes (ChaCha8Rng + UI seed) so the three orbital planes
precess at different Ω_LT rates — the visible Kerr signature.

Decomposes Planet into OrbitParams (immutable elements) + Planet (derived
center), keeps SphereData GPU layout untouched. Adds time_scale UI knob
because Ω_LT at r=8 needs ~25 min per revolution otherwise.
2026-07-16 16:46:10 +08:00
xfy
dd1facae39 docs(agents): sync AGENTS.md with bloom pipeline, quality tiers, web gotchas, and Phase 3 docs 2026-07-16 16:24:51 +08:00
xfy
573e68e96d fix(jets): weight emission on geometric step length to kill base-of-jet bands
The concentric banding persisted on the jets (near the base) after the
disk moiré was fixed. Root cause: sample_jets used a fixed per-step
weight (0.5) instead of a per-unit-length one, so the jet's
per-unit-length contribution was ∝ 1/step_len. RK45 packs its accepted
steps tightly near the hole (high curvature → small dt), so the jet
brightened in step-dense rings exactly where the user saw bands. This
was the inverse of the earlier `* dt` attempt (which banded because dt
varies 16×); both forms couple brightness to the integrator's step
choice rather than to the physics.

Fix: pass the step's world-space length |new_pos − prev| in from the
caller (it already has prev and new_pos) and weight the per-step
emission and alpha on it — the same step-size-decoupling technique the
disk path already uses (integrate_disk_segment's analytic seg_len). The
per-unit-length coefficient (0.1) is calibrated to keep the default
scene's jet brightness close to the old fixed-0.5 look.

cargo build --release: clean. cargo test: passing.
2026-07-16 16:14:41 +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
934468f7a0 ui(jets): gray out Strength + hint when spin is below the render gate
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.
2026-07-16 14:11:02 +08:00
xfy
38d4bd00f2 fix(disk/jets): suppress spin-0 jets, cap beaming, decouple from RK45 dt
Three issues in sample_jets produced the reported "jet column + bright
white ring over the hole's pole" on the default scene (spin = 0,
jets_enabled = true):

1. Jets rendered at χ = 0. Relativistic jets are spin-powered
   (Blandford-Znajek taps the ergosphere), which doesn't exist for a
   non-rotating hole. jets_enabled was a free toggle decoupled from
   spin, so the default scene showed polar columns with no physical
   cause. Gate sample_jets on uniforms.spin >= 0.05.

2. Beaming saturates to white. With β=0.92 the approaching jet's
   δ^3.5 reaches ~258×, blowing the blue base color past the bloom
   threshold so High-level bloom (3-pyramid) collapses it to white.
   Cap beaming at 8× — the approaching side is still visibly brighter,
   and the base color (0.4, 0.7, 1.0) survives the HDR + bloom path.

3. dt-modulated accumulation painted ring structures. `* dt` weighted
   the per-step contribution by the RK45 adaptive step size, which
   varies across a 16× range (dt_min..dt_max) and between neighboring
   pixels — the same class of bug the disk path already documented and
   fixed in integrate_disk_segment (radial spokes). The jet has no
   analytic slab to clip to, so use a fixed normalized weight (0.5);
   brightness then depends only on geometry and beaming, never on dt.
   Drops the now-unused dt parameter.
2026-07-16 13:48:04 +08:00
xfy
614bd1d88c perf(build): add fat LTO + single codegen-unit to desktop release profile
The renderer is GPU-bound, but Bevy's CPU path (resource management,
system scheduling, egui, wgpu command submission) still benefits from
cross-crate inlining. Cargo's default release profile skips LTO and
splits codegen across 16 units, leaving inter-crate trampolines through
Bevy/wgpu in the hot path.

- lto = "fat": whole-program cross-crate inlining.
- codegen-units = 1: best optimization (slower compiles, accepted).
- panic = "abort": smaller binary, no unwind tables.
- strip = "symbols": drop debug symbols from the shipped binary.

wasm-release inherits release then overrides opt-level to "z", so web
builds keep their size focus. Release build verified; cargo test green
(14 passed).
2026-07-16 13:23:01 +08:00
xfy
2d764f5f19 chore(skills): pin rust-advanced-performance skill 2026-07-16 11:44:52 +08:00
xfy
5c0b1db01b fix(web): use textureSampleLevel for skybox to satisfy Tint's uniform-flow rule
The web build black-screened on the first frame: the black-hole fragment
shader failed to compile in Chrome's Tint (BrowserWebGpu backend) with
"'textureSample' must only be called from uniform control flow", which
invalidated the opaque_mesh2d_pipeline and made Bevy quit after frame 1.
Desktop (naga) was unaffected because naga does not enforce this WGSL rule,
which is why the regression was web-only.

skybox_color() called textureSample on the cubemap from inside the main RK45
integration loop. That loop's control flow is non-uniform — per-pixel early
exit via `if (accum_alpha > 0.99) { break; }` and per-iteration accept/reject
— and textureSample needs screen-space derivatives (uniform across the quad)
for mip selection, so the spec forbids it there.

Switch to textureSampleLevel with an explicit LOD of 0. textureSampleLevel
does no derivative computation and is permitted in non-uniform control flow.
LOD 0 is correct for the skybox: it is sampled at full resolution with no
mip minification, so the visual result is identical to the implicit-lod path.

Diagnosis required building a JS-side probe that fetches the WGSL, mirrors
naga_oil preprocessing (resolve #{MATERIAL_BIND_GROUP}, inline VertexOutput),
then calls device.createShaderModule + getCompilationInfo to surface Tint's
structured messages — Bevy's wgpu path swallows the raw Tint error on web and
only ever surfaces the downstream "Invalid ShaderModule due to a previous
error". The probe confirmed the error site and rule; it is not committed.
2026-07-16 11:31:15 +08:00
xfy
00aa27175e fix(web): silence spurious console errors (meta + AudioContext)
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.
2026-07-16 10:07:19 +08:00
xfy
30786eb253 fix(web): ship WGSL shaders to dist via trunk copy-dir
Bevy's HttpWasmAssetReader fetches asset paths over HTTP at runtime,
requesting 'assets/shaders/*.wgsl' (matching AssetPlugin's default
file_path of "assets"). Trunk only emits what index.html references,
so without an explicit copy-dir link the shaders were absent from dist/
and every material hit a 404 → grey screen.

Add the copy-dir link so trunk mirrors assets/ into dist/.
2026-07-16 10:07:07 +08:00
xfy
359d2f876f fix(disk): eliminate radial spokes by decoupling density weight from RK45 step size
The previous per-step accumulator weighted density by `step_len` — the
RK45 step length. That step size is spatially adaptive (small near the
hole where curvature is high, large in flat regions) and varies between
neighbouring pixels, so `density * step_len` injected a per-pixel
brightness modulation along the radial direction: radial spokes,
densest at the inner disk where steps are smallest.

Fix: a new integrate_disk_segment clips each step's segment to the disk
slab analytically and weights samples by `seg_len` — the world-space
length of the ray-slab intersection, which depends only on the ray's
incidence angle and the local slab height H, never on the RK45 step
size. Slab height uses the step-midpoint r (H/R ratio; r varies <5%
across one step).

Within the clipped segment, N=4 uniform samples replace the single
midpoint. A single sample under-integrates the Gaussian vertical decay
and leaves the disk translucent; four samples resolve the density
profile across the slab thickness while keeping the cost bounded (4 ×
2-octave fbm per in-slab step).

This recovers the pre-regression slab-clipping geometry but with
segment-multi-sampling instead of one midpoint — the slab clip removes
the step-size coupling (spokes), the multi-sample restores the
integration depth (opacity).

Off-tier (disk_quality == 0, flat path) is untouched: it samples a
zero-thickness midplane with no slab, so step-size weighting never
applied there.
2026-07-15 17:51:52 +08:00
xfy
b4f14b2f35 tune(disk): align defaults to Gargantua visuals
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.
2026-07-15 17:32:14 +08:00
xfy
7130d0c6b2 feat(disk): rewrite density model for solid Gargantua-style accretion disk
The volumetric disk rendered translucent and jelly-like. Three causes,
all fixed:

1. Density floor 0.55 (disk_color_volumetric): noise valleys stayed
   semi-transparent no matter the integration → jelly. Replaced with a
   physical density field: radial smoothstep falloff × Gaussian vertical
   decay × weak ±25% low-frequency turbulence. Density now reaches 0 at
   the edges and peak at the midplane, so per-step integration builds a
   solid disk instead of a uniform glow.

2. Single midpoint sample (main loop): the old design clipped the ray
   segment to the slab and sampled density once at the midpoint, then
   weighted by segment length. Under-integration → see-through. Switched
   to per-step accumulation: every accepted step whose endpoint is inside
   the slab samples density once × geometric step length — the standard
   front-to-back volumetric composite, matching the others/ reference
   (fragment.glsl.ts sample_accretion_disk).

3. Absolute slab thickness 0.3: vanishingly thin at large radius → even
   less integration. disk_half_thickness is now an H/R RATIO; the slab
   scales as H = r·disk_half_thickness (a thin disk's geometry), so a ray
   through large r traverses a proportionally thicker region.

The ridged-filament brightness driver and log-spiral arm modulation are
removed — they produced the noisy, striated look. The radial temperature
gradient inside disk_emission (Novikov-Thorne × Kerr Doppler) now
dominates, giving a smooth disk like DNEG's Gargantua render. Noise
functions (fbm3/ridged_fbm/value_noise3) are retained: jets and the flat
(Off-tier) path still use them.

disk_color_flat (Off tier) drops its fixed 0.85 alpha for the same radial
smoothstep, so edges feather consistently across all quality tiers.
2026-07-15 17:32:00 +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
a5ef427cb8 feat(disk): tune volumetric disk noise for opaque body with visible streaks 2026-07-15 16:59:49 +08:00
xfy
abdd50576b fix(ui): make Controls panel vertically scrollable
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.
2026-07-15 16:17:24 +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
f7eda338ab Merge branch 'feat/volumetric-disk'
Volumetric disk rendering: ridged multifractal noise + density/slab
integration in the RK45 loop, with a Disk Turbulence UI panel. 13 commits.
2026-07-15 15:15:55 +08:00
xfy
ef631ab848 feat(camera): default to yaw -1.065 / pitch -0.335 framing
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.
2026-07-15 15:09:53 +08:00
xfy
e00c37a3ae fix(disk): unify slab integration to eliminate radial spokes
Root cause of the radial spokes (the "zebra stripe" artifact): the
volumetric integration used TWO overlapping sampling paths per RK45 step
with inconsistent angle-dependent weights:

  (A) in-slab step:  accum += density * step_len
      where step_len = full RK45 step (may extend far outside the slab)
  (B) edge-capture:  accum += density * thickness_proj
      where thickness_proj = half_thickness / |dir.y|

At a straddling step BOTH fired. step_len (RK45 adaptive) and thickness_proj
vary with the ray's bending geometry differently, so their sum modulated
brightness by angle → radial spokes whose count/position shifted with the
camera angle. This was confirmed decisively: setting the texture to a
completely uniform constant (no noise, no arms) STILL produced spokes,
proving the texture was innocent and the integration was the source.

Fix: replace the two-path design with a single unified path. Clip each
RK45 step's segment to the slab |y|<=half_thickness (parametric clip in
t-space), sample once at the clipped segment midpoint, accumulate × the
in-slab segment length only. This gives consistent, geometry-correct
weights with no double-counting. Verified: uniform-constant texture now
renders a clean smooth ring (no spokes); full texture restores turbulent
filaments without spokes.

Also reverts the temporary uniform-constant debug texture to the full
polar-sampled ridged+density+arms function.
2026-07-15 14:57:21 +08:00
xfy
f236e47f83 chore(skills): pin optimizing-rust-performance skill 2026-07-15 14:23:57 +08:00
xfy
376abbc87a fix(disk): sample noise in polar coords to kill radial spokes
Root cause of the "zebra stripe" / radial spoke artifact: disk_color_volumetric
sampled noise using the raw Cartesian pos (x,y,z). On a disk, changing the
radius r moves x/z a lot (high-frequency oscillation → stripes), while a full
angular sweep revisits correlated lattice points (weak variation), so the
stripes elongate along radius into spokes. Verified numerically: the radial
sample line oscillated filament in [0.05, 0.77] while the angular line was
symmetric (phi=0 and phi=pi identical at 0.2475).

Fix: sample noise in polar space sp = (r/inner, phi*2.5 + rot, h/half_thickness).
This decouples radial and angular axes so turbulence flows tangentially — the
physically correct pattern for a rotating fluid. Post-fix radial/angular
variance ratio is 0.50 (radial smoother than angular), eliminating the spoke
bias.

Two secondary fixes in the same function:
- Density: replaced the hard smoothstep(0.3,0.7) cut (which made the slab
  patchy and over-transparent) with a soft 0.35+0.65*noise ramp.
- Brightness: raised the floor to 0.5+filament (was filament alone) so the
  disk reads as luminous plasma instead of dim brown.
- Guarded log(r) with max(r,0.1) for robustness near the inner edge.
2026-07-15 14:17:09 +08:00
xfy
19905ed86a fix(shader): port MAX_OCTAVES-with-break into fbm3
This branch is the first to call fbm3 with runtime-variable octave counts
(previously only compile-time constants 3u/4u). The spec's WebGPU safety
mitigation (fixed upper bound + early break) was applied to ridged_fbm
but missed on fbm3. Ports the same pattern so older WebGPU drivers don't
miscompile the dynamic loop bound.
2026-07-15 13:57:18 +08:00
xfy
7621ff12bb feat(ui): Disk Turbulence panel with quality tier + 8 sliders
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.
2026-07-15 13:44:46 +08:00
xfy
e2e2469971 feat(render): volumetric disk integration in the RK45 loop
Restructures the disk block in the main loop:
- Off tier: flat disk_color_flat single midplane sample (old behavior).
- Volumetric tiers: (A) in-slab per-step sampling accumulates emission ×
  arc length, reusing the RK45 adaptive step density; (B) midplane edge-
  capture weights one at-plane sample by slab depth along the ray.
Removes the Task-5 disk_color shim. physics.rs untouched; cargo test green.
2026-07-15 13:40:35 +08:00
xfy
107fbd0a74 feat(shader): add disk_color_volumetric (ridged + density + arms)
Three multiplying signal layers: ridged_fbm filaments for brightness,
smoothstep-gated FBM for density clumping, logarithmic-spiral arm
modulation riding the Keplerian shear. Octave triplet selected from
disk_quality tier. Returns DiskSample; inert until Task 7 wires it in.
2026-07-15 13:37:52 +08:00
xfy
de4ceee8ff refactor(shader): extract disk helpers, add DiskSample + flat fallback
Splits disk_color into shared helpers (temperature_color, radial_falloff,
apply_doppler, r_of) reused by both the flat and volumetric paths.
disk_color_flat returns a DiskSample with the old fixed 0.85 alpha,
preserving the exact pre-volumetric appearance behind the Off tier. A
temporary disk_color shim keeps the main-loop call site compiling until
Task 7 restructures it.
2026-07-15 13:29:48 +08:00
xfy
c9cc2eec99 feat(shader): add ridged_fbm multifractal noise
Ridged noise (1 - |2n-1|, raised to sharpness) produces the sharp bright
filaments the spec calls for, replacing the smooth FBM blobs. Fixed
MAX_OCTAVES=6 with early break — conservative form for runtime-chosen
octave counts on older WebGPU drivers. Inert; no caller yet.
2026-07-15 13:24:16 +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
35ae3ce5cc feat(uniform): add 9 volumetric-disk fields to BlackHoleUniforms
Rust and WGSL structs kept in lockstep: 8 f32 tunables + disk_quality
u32 tier. Appended after _pad5; scalar fields pack contiguously so no
explicit padding needed. Defaults mirror BlackHoleParams::default.
2026-07-15 13:18:02 +08:00
xfy
2a0c3bbe69 feat(params): add DiskQuality enum + volumetric disk params
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.
2026-07-15 13:16:50 +08:00
xfy
3ab432417a docs(plan): volumetric accretion disk implementation plan
9-task plan for the volumetric disk spec. Tasks are ordered for
independent compilability (a temporary disk_color shim in Task 5 keeps
the build green until Task 7 restructures the main loop):
  1. DiskQuality enum + 8 params + defaults (params.rs)
  2. 9 uniform fields, Rust + WGSL in lockstep (material.rs, shader)
  3. mirror_params copies (plugin.rs)
  4. ridged_fbm noise (shader, inert)
  5. shared helpers + DiskSample + disk_color_flat (shader refactor)
  6. disk_color_volumetric: ridged + density + spiral arms (shader, inert)
  7. main-loop restructure: in-slab sampling + edge-capture + tier dispatch
  8. Disk Turbulence egui panel (ui.rs)
  9. full verification: cargo test, desktop + web visual + perf check
2026-07-15 11:31:33 +08:00
xfy
d1ea789274 docs(spec): volumetric accretion disk design
Design for replacing the smooth zero-thickness disk with a Gargantua-style
volumetric gas disk. Supersedes the Phase 3 non-goal "disk stays a zero-
thickness plane" - user feedback identified zero thickness as a root cause
of the disk looking too smooth.

Key decisions captured:
- Approach A volumetric integration: in-slab sampling reuses the existing
  RK45 adaptive step (dense where light bends, sparse where straight),
  no separate ray-march. Midplane edge-capture retained for straddling steps.
- Ridged multifractal noise (ridge^2) for sharp bright filaments, replacing
  the smooth FBM blobs that produce the current mushy texture.
- Separate density field (smoothstep-gated FBM) for gas clumping, plus a
  logarithmic-spiral arm modulation riding the Keplerian shear.
- DiskQuality tier enum (Off/Low/Medium/High) gates octave counts; Off is
  a full revert to the flat disk as perf escape hatch + visual A/B.
- physics.rs untouched: volumetric integration is render-sampling layer,
  never enters the geodesic solver; existing tests stay green.
2026-07-15 11:17:12 +08:00
xfy
52cf4e4a29 fix(stars): balance AA so it only softens edges, not size or count
After the round-star fix, toggling "Anti-aliased stars" made stars look
bigger and more numerous instead of just smoother. The AA path differed
from the hard-edge path in three ways that each inflated the footprint:

  - radius: AA used 0.25 + b*0.4 (up to 0.65); hard path used fixed 0.5
  - gain:   AA used 4.0; hard path used 3.0  (+33% peak brightness)
  - falloff: AA Gaussian decayed slowly (36.8% at one radius, never 0);
             hard path smoothstep hit 0 at the radius

The slow Gaussian tail was the main offender: it kept dim stars lit far
past the hard path's cutoff, lifting many sub-threshold stars above the
visibility floor — the apparent "more stars".

Balance the two paths so the toggle is purely an edge-softness control:
  - both use radius = 0.25 + b*0.4  (same size, brightness-driven)
  - both use gain 3.0               (same peak brightness)
  - AA steepens the Gaussian to exp(-4.6*d²/r²) so it converges to ~1%
    near the same radius where smoothstep cuts to 0 — matched footprint,
    soft vs hard edge.

Peak brightness is now identical across the toggle; effective lit area
differs by <30% (the Gaussian is a cone, smoothstep a flatter dome) and
the visibility radius matches, so dim stars no longer pop in and out as
AA is toggled.

Validated: naga type-checks; app runs 20s on Metal (M4) with no shader
errors; cargo test (14 physics tests) passes.
2026-07-15 10:23:29 +08:00
xfy
f4e937f35b fix(stars): round anti-aliased stars via 3x3x3 neighborhood scan
Background stars looked square even with anti-aliasing on, and toggling
AA just made them bigger rather than round. Two compounding causes, both
in star_color():

1. Square shape: the fast path measured cell distance with
   max(f.x, max(f.y, f.z)) — Chebyshev distance, which draws an
   axis-aligned cube, not a sphere. The visible star was literally a
   faded cube face.

2. AA "just makes it bigger": the Gaussian AA path used Euclidean
   distance, but only inside ONE cell. hash13(cell) returns a different
   value across a cell boundary, so a star's radial falloff could never
   reach zero — it was hard-clipped at the cell edge. The star's outline
   was therefore the cell's bounding box regardless of the radius
   parameter; bumping the gain/radius just filled more of that box.

Fix: scan the 3x3x3 neighborhood of cells so a star whose center lies in
an adjacent cell still contributes across the whole cell it lives in,
and take the brightest contributor. Distance is Euclidean in both paths,
so both are now genuinely round. star_aa selects soft Gaussian glow (on)
vs. a tight smoothstep disk (off) — a real shape choice instead of
square-vs-clipped-square.

Validated: naga type-checks the preprocessed shader; app runs 20s on
Metal (Apple M4) with no shader/compile/validation errors; cargo test
(14 physics tests) passes.
2026-07-15 10:00:57 +08:00
xfy
dcc79b8040 chore: add .zcode to gitignore 2026-07-15 09:44:49 +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
7d3a8a81ba shader: fix FBM disk seam (atan2→Cartesian) + value_noise3 range
Two issues from code review: (1) the noise coordinate used phi=atan2
which has a branch-cut discontinuity at ±π, producing a visible radial
seam. Switch to Cartesian pos.x/pos.z (continuous, seam-free). (2)
value_noise3 summed three hash components per corner via dot(.,(1,1,1)),
giving a [-1.5,1.5) range instead of [0,1). Use a single scalar hash
(.x) per corner and drop the +0.5 recenter.
2026-07-14 23:51:29 +08:00
xfy
f31ef900d8 shader: domain-warped FBM disk texture replaces two-sine noise
The disk's noise was two sin() terms (phi*8, phi*23) producing geometric
bands. Replace with domain-warped FBM: a 3-octave value-noise field warps
the sampling coordinate of a 4-octave FBM, giving the feathered/smoky gas
texture of the Gargantua reference. The Keplerian shear (rot ∝ 1/r^1.5)
is retained as the flow time term so inner radii rotate faster.
2026-07-14 23:42:16 +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
d32ea10aec docs: implementation plan for cinematic rendering
10-task plan (Tasks 0-9) for the HDR bloom + FBM disk + ACES + AA
pipeline. Each task is a standalone commit: Rgba16Float offscreen →
FBM disk → gaussian-speck stars → brightpass → blur pyramid →
composite+ACES → BloomQuality enum → Quality panel → verification.

Conventions established upfront (camera orders, RenderLayers, the
QuadScaleFactor/CompositeQuad/Nudgable markers) so each task references
them rather than re-deriving. The naga var<private> array fix for the
blur kernel is baked into the shader from the start.
2026-07-14 23:24:14 +08:00
xfy
5c6c20fa00 docs: spec cinematic rendering (HDR bloom + FBM disk + ACES + AA)
Design for raising the renderer from geometrically-correct-but-flat to
Gargantua-reference fidelity. Four user-selected traits:

- HDR color + ACES tone mapping (Rgba16Float offscreen, Narkowicz fit)
- Multi-pass HDR bloom (bright-pass → down-pyramid → up-pyramid → composite)
- Domain-warped FBM disk texture (replaces two-sine noise)
- Anti-aliasing via render_scale + gaussian-speck stars

All quality options configurable via a dedicated egui Quality panel,
with tiered web defaults (Low bloom, no MSAA/star-AA) vs desktop (High).
BloomQuality::Off falls back to the original LDR path for WebGPU
browsers without float-filterable support.

Geodesic integrator (deriv/rk45_step/is_captured_rk45) untouched —
the physics.rs mirror contract is unaffected. No new tests.
2026-07-14 18:24:36 +08:00
xfy
e7c45804a4 tweak: lower default disk_tilt from 1.318 to 0.45
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).
2026-07-14 17:06:21 +08:00