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.
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 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
The whole-file #import "file.wgsl" form (without ::) does not reliably
bring functions into scope in naga_oil/Bevy 0.19 — the renderer was
failing at runtime with 'no definition in scope for identifier:
ray_direction' (and rot_x, deriv, etc.), producing a blank screen despite
'cargo build' passing (shaders aren't compile-checked at build time).
Fix: add #define_import_path to each module file and import symbols
explicitly via namespace::name (the canonical Bevy pattern from the
shader_material_2d example). Verified the full shader pipeline compiles
and runs at runtime with zero WGSL errors.
This was a pre-existing bug masked by incomplete runtime verification in
earlier tasks; it is NOT specific to Task 15's grid work. All earlier
visual features (shadow, Doppler disk, Einstein halo, stars, planets,
grid) now actually render.
Add planets as ray-traced spheres inside the RK4 geodesic loop. A new
planets.wgsl shader tests each integrator segment against a storage
buffer of SphereData (center/radius/color/emissive) and composites hits
front-to-back alongside the accretion disk. Lambert shading with a fixed
light direction; emissive flag bypasses shading.
- assets/shaders/planets.wgsl: segment-sphere intersection + shading
- src/scene/: Planet component, upload_planets system, default planet
- black_hole.wgsl: wire planet_hit into the compositing loop
- geodesic_schwarzschild.wgsl: replace tuple return with Deriv struct