Preset::Cinematic/Performance/Web write 5 params (steps, render_scale,
bloom/disk/aa quality); Custom is a no-op marker. params_hash hashes
ONLY preset-touched fields so non-preset edits (disk_tilt, etc.) don't
spuriously flip the bar to Custom. Test-pinned via tests/preset_test.rs.
Exports ui + params from lib.rs so the test can construct params.
Lib-compile unblock: the library crate could not build with only
`pub mod {params,physics,ui}` because src/ui/mod.rs's ui_system
references crate::camera and crate::scene (transitively crate::render).
Added pub mod {camera,scene,render,web} to lib.rs and reworked main.rs
to consume them via `use singularity_rs::*` instead of redeclaring
the same file-level modules. preset is declared `pub mod` (not private
`mod`) because tests/preset_test.rs imports it cross-crate.
Plan transcribed #3C4646 as rgb(60,70,90)=#3C465A (20 off on blue);
spec §1 specifies rgb(60,70,70). Also add module-level egui-0.35
version-drift note and hex comments on inline widget-state colors
for grep-able spec↔code comparison.
Palette constants + sci_fi_visuals() + setup(). Hand-applied over
Visuals::dark(); no custom font, no new deps. Not yet wired into
ui_system (Task 4 does that via setup_egui_style).
Design spec for the egui control panel overhaul. Addresses the
user-reported 'settings all look bad' by fixing the structural root
causes (flat 11-CollapsingHeader scroll, no styling, ragged sliders,
no presets) rather than a cosmetic reskin.
Key decisions captured:
- Chassis: right SidePanel (replaces floating Window)
- Theme: deep-space cyan/orange, hand-applied over Visuals::dark()
- Structure: 4 always-open framed cards + 6 collapsing sections
(11→10 groups; Renderer merges into Quality, fixing a latent
duplicate render_scale slider bug)
- Preset bar: Cinematic/Performance/Web/Custom with per-frame hash
detection of hand-edits
- Two-column Grid rows with unit suffixes + log scales on wide ranges
- Group-enable toggles in collapsing headers; standardized warning text
Non-goals explicit: no params/shader/physics changes, no custom font
binary, no painted toggle, no new deps.
Higher default turbulence frequency gives the volumetric disk more
trackable texture features at startup, making the Keplerian rotation
(rot ∝ 1/r^1.5) visually readable without needing to open the egui
panel. The old 0.8 default produced a near-featureless smooth slab at
default settings — the disk was rotating but the eye had nothing to lock
onto. 2.0 sits in the upper half of the slider range (0.2–3.0) while
still leaving room to push higher for debugging.
Records that an exact-Kerr attempt exists on the phase4-exact-kerr-
experimental branch (CPU sub-percent accurate, shader visual broken)
and names the resume strategy: per-pixel debug-color output to localize
the CPU/WGSL divergence. Saves the next session from re-discovering the
state of the work.
The Status section was stale: Phase 2 was still marked 'pending
visual/performance validation' and Phase 3's sub-phases (3.1–3.4)
were entirely absent despite each having landed with its own spec,
plan, and commits.
Updates after human-in-the-loop validation confirmed spin=0
degeneracy, frame-dragging asymmetry at spin>0, and desktop/web FPS
at the new defaults:
- Phase 2: 'implemented, pending validation' -> 'shipped & validated'.
- Phase 3 (cinematic): new section listing the HDR/bloom pipeline,
volumetric disk (3.1), blackbody disk + relativistic jets (3.2),
anti-aliased lensed-image rings (3.3), and Kerr orbiting planets
(3.4) — each with the physics/aesthetic it ships.
- Performance defaults: record the concrete per-target defaults
(steps/render_scale/bloom/disk/aa for desktop vs web) and the
release-profile LTO/codegen choices so the README reflects what
the code actually ships.
- Future work renamed Phase 3 -> Phase 4, since Phase 3 is now
filled by the cinematic work.
Planets were landing on/in the accretion disk. Root cause: orbit radius was
r = radius_factor · kerr_isco(χ), keyed off the disk's INNER edge. The disk
extends from ISCO out to disk_outer (default 25), so for every spin the
planets' radii fell inside the disk's radial span:
χ=0: planets r ∈ [4.5, 17.25], disk [3, 25] — all inside
χ=1: planets r ∈ [0.75, 2.88], disk [0.5, 25] — all inside, near hole
Re-keyed to the disk OUTER edge: r = radius_factor · disk_outer. With the
factor now meaning "multiple of disk_outer" (default 1.3, slider 1.1..=2.0,
per-planet jitter ±0.25 floored at 1.05), planets stay at r ≈ 32.5 —
reliably outside the disk regardless of spin, while still close enough for
lensing to curve their images.
Changes:
- orbit_position: new disk_outer param, r = radius_factor · disk_outer
- orbit_system: passes params.disk_outer
- spawn_planet_system: jitter range tightened to ±0.25 around the factor,
floor 1.05 (was ±0.75 around an ISCO-keyed k, floor 1.0)
- params default planet_radius_factor 2.5 → 1.3 (new semantics)
- UI: slider 1.5..=5.0 → 1.1..=2.0, label "Radius (× disk outer)",
readout shows orbit r and disk_outer instead of ISCO
- tests updated for the new signature and r = factor · disk_outer invariant
The "Radius factor k" UI slider wrote params.planet_radius_factor and
triggered respawn via the dirty flag, but spawn_planet_system ignored it —
every planet's radius_factor came from rng.gen_range(2.0..4.0), so the
slider was a silent no-op (it changed the ISCO readout label and respawned,
but the actual orbit radii never reflected its value).
Now samples radius_factor as planet_radius_factor ± 0.75, so the slider
controls the orbital scale while preserving per-planet radius diversity
(no fully-identical radii). Clamped to >=1.0 so jitter near the slider's
low end can't drop a planet inside the ISCO.
Caught by the Phase 3.4 final code review.
rand 0.8 depends on getrandom 0.2, which refuses to compile on
wasm32-unknown-unknown without its "js" feature (it needs to call into the
Web Crypto API via wasm-bindgen). Bevy's own rand usage sits on getrandom
0.3+, which enables this by default, so the web build only broke once we
added a direct rand 0.8 dep in 76be513.
Force-enabling getrandom/js in the wasm32 target deps section unblocks
`trunk build`. Desktop is unaffected (the cfg gates it to wasm only).
Caught by the Task 8 wasm cargo-check gate.
The vertical epicyclic frequency's cross term is a√M/r^1.5 (radius to the
−1.5), following Okazaki 1987. The spec previously wrote a·Ω_φ/r, which makes
Ω_θ too large and breaks monotonic growth of precession with spin. Caught
during Task 1 implementation by the grows_with_spin test; syncing the spec
to the corrected formula that shipped in 891eadf.
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).
ChaCha8Rng needs both crates: rand (the core traits Rng/SeedableRng) and
rand_chacha (the ChaCha8Rng type, split out of rand since 0.9). rand was
already a transitive dep via bevy_math but not declared directly.
Pinned to 0.8/0.3 (LTS, API-stable with the Bevy 0.19 ecosystem) rather
than the newer 0.10 line, which would force a wider dep upgrade.
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.
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.
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.
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.
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).
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.
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.
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/.
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.
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.
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.
- 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.
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.
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.
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.
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.
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.
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.
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.
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.