diff --git a/assets/shaders/black_hole.wgsl b/assets/shaders/black_hole.wgsl index 23798e0..c883fb9 100644 --- a/assets/shaders/black_hole.wgsl +++ b/assets/shaders/black_hole.wgsl @@ -53,10 +53,20 @@ struct BlackHoleUniforms { disk_temp: f32, // blackbody base temperature (Kelvin) jets_enabled: u32, // 0=off, 1=on jets_strength: f32, // jet brightness multiplier + aa_samples: u32, // per-pixel supersample count (1/2/4); >1 antialiases the higher-order lensed-image rings + _pad6: f32, // explicit round-up to 16-byte uniform alignment + _pad7: f32, // (matches BlackHoleUniforms in material.rs byte-for-byte) }; @group(#{MATERIAL_BIND_GROUP}) @binding(0) var uniforms: BlackHoleUniforms; +// Per-pixel fragment coordinate, set at the top of `fragment` from +// `in.position.xy` (the @builtin(position) frag coord). Used as a deterministic +// seed for the slab sub-sample jitter and the supersample jitter so the noise +// is spatially stable (no per-frame flicker). `var` is the same form +// `blur.wgsl` uses for its runtime-indexed kernel tables. +var pixel_seed: vec2; + // ---------- planets storage (binding 3) ---------- struct SphereData { center: vec4, // xyz = center (world space), w = radius @@ -507,14 +517,26 @@ fn integrate_disk_segment(prev: vec3f, new_pos: vec3f, dir: vec3f, // Analytic in-slab length — the key: depends on geometry, NOT on RK45 dt. let seg_len = (t1 - t0) * length(new_pos - prev); - // N uniform samples along the clipped segment, front-to-back composite. - // Each sample carries density × (seg_len / N) so the N samples sum to the - // full segment integral when density is roughly constant; the Gaussian - // vertical decay is captured by sampling at differing heights within the - // slab. N is a compile-time constant (WGSL requires static loop bounds). + // N samples along the clipped segment, front-to-back composite. Each sample + // carries density × (seg_len / N) so the N samples sum to the full segment + // integral when density is roughly constant; the Gaussian vertical decay is + // captured by sampling at differing heights within the slab. N is a + // compile-time constant (WGSL requires static loop bounds). + // + // The sub-sample position is STOCHASTIC, not on the deterministic + // (i+0.5)/N grid. A deterministic grid aliases against the 2-D pixel grid + // and against the per-ray RK45 step lattice → a concentric moiré on the + // disk. Folding the pixel coordinate and the sample index into a hash and + // using it to jitter the position within stratum i turns the moiré into + // unstructured noise (which the HDR + bloom pipeline tolerates far better + // than coherent bands). The jitter stays inside stratum i (amplitude 1/N), + // so the quadrature order is preserved and the integrated density is + // unbiased. The seed is pixel-only (no time) → the noise is spatially + // stable, no per-frame flicker. const N = 4u; for (var i: u32 = 0u; i < N; i = i + 1u) { - let t = t0 + (t1 - t0) * (f32(i) + 0.5) / f32(N); + let stratum = f32(i) + hash13(vec3(pixel_seed, f32(i))); + let t = t0 + (t1 - t0) * stratum / f32(N); let p = mix(prev, new_pos, vec3f(t)); let rp = r_of(p); if (rp < uniforms.disk_inner || rp > uniforms.disk_outer) { @@ -708,16 +730,13 @@ fn rk45_step(pos: vec3, dir: vec3, dt: f32) -> RkStep { } // ====================== main ====================== -@fragment -fn fragment(in: VertexOutput) -> @location(0) vec4 { - let aspect = uniforms.resolution.x / uniforms.resolution.y; - var uv = (in.uv * 2.0 - 1.0); - uv.x *= aspect; - let dir = ray_direction(uv); - - // Work in disk-local space: rotate eye + dir by -disk_tilt around X so the - // disk lies on y=0. (disk_hit/disk_color assume disk-local coords.) +// One full ray march (camera → capture/escape/budget) for a single ray +// direction, returning the accumulated HDR color. Factored out of `fragment` +// so the supersampling loop can fire several jittered rays per pixel and +// average them. `dir` is the camera-space ray direction; the disk-local +// rotation and all per-ray state live in here so each sample is independent. +fn march(dir: vec3) -> vec3 { // Total path length to integrate: enough to go from the camera, past the // hole, and far enough beyond to count as escaped. let eye_dist = length(uniforms.eye.xyz); @@ -731,6 +750,8 @@ fn fragment(in: VertexOutput) -> @location(0) vec4 { let tol = 1e-3; let r_plus = 0.5 + sqrt(max(0.25 - (uniforms.spin * 0.5) * (uniforms.spin * 0.5), 0.0)); + // Work in disk-local space: rotate eye + dir by -disk_tilt around X so the + // disk lies on y=0. (disk_hit/disk_color assume disk-local coords.) var pos = rot_x(uniforms.eye.xyz, -uniforms.disk_tilt); var d = normalize(rot_x(dir, -uniforms.disk_tilt)); var dt = dt_init; @@ -829,5 +850,47 @@ fn fragment(in: VertexOutput) -> @location(0) vec4 { d = new_dir; } - return vec4(accum_color, 1.0); + return accum_color; +} + +@fragment +fn fragment(in: VertexOutput) -> @location(0) vec4 { + // Seed the per-pixel hash used by the slab sub-sample jitter (Fix B) and + // the supersample jitter (Fix A). @builtin(position) is the fragment's + // framebuffer coordinate; using it makes the noise spatially stable. + pixel_seed = in.position.xy; + + let aspect = uniforms.resolution.x / uniforms.resolution.y; + let base_uv = vec2((in.uv * 2.0 - 1.0).x * aspect, (in.uv * 2.0 - 1.0).y); + + // Per-pixel stochastic supersampling (Fix A). The concentric rings that + // appear on the disk are higher-order gravitationally-lensed disk images: + // rays near the critical impact parameter wrap around the hole, and each + // wrap crosses the disk plane once, projecting to a ring. The integrator + // renders each wrap as a sharp discrete band rather than a smooth image, so + // the rings read as an artifact. Firing several jittered sub-rays per pixel + // and averaging antialiases those sharp band edges into a smooth gradient + // (the physical Einstein-ring structure is preserved; only its staircase + // discretization is removed). + // + // MAX_AA_SAMPLES is a compile-time cap (WGSL needs static loop bounds); + // the runtime count comes from uniforms.aa_samples with an early break — + // the same MAX-with-break form fbm3/ridged_fbm use for dynamic octaves. + const MAX_AA_SAMPLES = 4u; + var color_sum = vec3(0.0); + var n_done = 0u; + for (var s: u32 = 0u; s < MAX_AA_SAMPLES; s = s + 1u) { + if (s >= uniforms.aa_samples) { break; } + var uv = base_uv; + if (uniforms.aa_samples > 1u) { + // ~half-pixel jitter in NDC, seeded by pixel + sample index. The + // seed is pixel-only (no time) so the pattern is temporally stable. + let j = hash33(vec3(pixel_seed, f32(s))) - 0.5; + uv = uv + j.xy / uniforms.resolution; + } + color_sum = color_sum + march(ray_direction(uv)); + n_done = n_done + 1u; + } + + return vec4(color_sum / f32(n_done), 1.0); } diff --git a/src/params.rs b/src/params.rs index 9430f6a..7a0490c 100644 --- a/src/params.rs +++ b/src/params.rs @@ -76,6 +76,30 @@ impl DiskColorMode { } } +/// Per-pixel supersampling for the higher-order lensed-image rings. The rings +/// are physical (lensed disk images), but the integrator renders each wrap as a +/// sharp discrete band; firing several jittered sub-rays per pixel and +/// averaging antialiases those edges into a smooth gradient. Off = 1 ray/pixel +/// (cheapest, rings visible); Low = 2; High = 4 (smoothest, ~4× the march cost). +#[derive(Clone, Copy, PartialEq, Eq, Default, Debug)] +pub enum AaQuality { + Off, + Low, + #[default] + High, +} + +impl AaQuality { + /// Sub-rays per pixel, consumed by the shader's supersampling loop. + pub fn samples(self) -> u32 { + match self { + AaQuality::Off => 1, + AaQuality::Low => 2, + AaQuality::High => 4, + } + } +} + /// All tunable black-hole parameters. Edited by the egui panel (Task 17), /// mirrored into BlackHoleUniforms each frame (Task 7). #[derive(Resource, Clone)] @@ -125,6 +149,8 @@ pub struct BlackHoleParams { pub disk_temp: f32, pub jets_enabled: bool, pub jets_strength: f32, + // Anti-aliasing (Phase 3.3): supersample count for the lensed-image rings. + pub aa_quality: AaQuality, } impl Default for BlackHoleParams { @@ -175,6 +201,11 @@ impl Default for BlackHoleParams { disk_temp: 6500.0, jets_enabled: true, jets_strength: 1.0, + // Supersampling off on web (WebGPU budget) and Low (2×) on desktop. + // The RK45 march is already ~10× Phase 1's cost, so desktop stays + // at 2× by default — enough to soften the rings — with High (4×) + // available in the UI for a fully smooth Gargantua look. + aa_quality: if cfg!(target_arch = "wasm32") { AaQuality::Off } else { AaQuality::Low }, } } } diff --git a/src/render/material.rs b/src/render/material.rs index 69c61b4..ff41e85 100644 --- a/src/render/material.rs +++ b/src/render/material.rs @@ -58,6 +58,10 @@ pub struct BlackHoleUniforms { pub disk_temp: f32, // blackbody base temperature (Kelvin) pub jets_enabled: u32, pub jets_strength: f32, + // Anti-aliasing (Phase 3.3): per-pixel supersample count for the lensed-image rings. + pub aa_samples: u32, + pub _pad6: f32, + pub _pad7: f32, } impl Default for BlackHoleUniforms { @@ -107,6 +111,9 @@ impl Default for BlackHoleUniforms { disk_temp: 6500.0, jets_enabled: 1, jets_strength: 1.0, + aa_samples: 1, // overridden by params.aa_quality each frame + _pad6: 0.0, + _pad7: 0.0, } } } diff --git a/src/render/plugin.rs b/src/render/plugin.rs index b441e4b..9ef5352 100644 --- a/src/render/plugin.rs +++ b/src/render/plugin.rs @@ -633,6 +633,7 @@ fn mirror_params( u.disk_temp = params.disk_temp; u.jets_enabled = params.jets_enabled as u32; u.jets_strength = params.jets_strength; + u.aa_samples = params.aa_quality.samples(); } // Update brightpass threshold (live-tunable). for (_, mat) in brightpass_materials.iter_mut() { diff --git a/src/ui.rs b/src/ui.rs index da671ab..542b3a1 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -127,6 +127,22 @@ pub fn ui_system( ui.add(egui::Slider::new(&mut params.exposure, 0.5..=3.0).text("Exposure")); ui.add(egui::Slider::new(&mut params.render_scale, 0.25..=1.0).text("Resolution scale")); ui.checkbox(&mut params.star_aa, "Anti-aliased stars"); + { + // Per-pixel supersampling: antialiases the higher-order + // lensed-image rings on the disk into a smooth gradient. + // Cost scales linearly with sample count (each sub-ray + // runs the full RK45 march). + use crate::params::AaQuality; + let mut a = params.aa_quality; + egui::ComboBox::from_label("Ring anti-alias") + .selected_text(format!("{:?} ({}×)", a, a.samples())) + .show_ui(ui, |ui| { + ui.selectable_value(&mut a, AaQuality::Off, "Off (1×)"); + ui.selectable_value(&mut a, AaQuality::Low, "Low (2×)"); + ui.selectable_value(&mut a, AaQuality::High, "High (4×)"); + }); + params.aa_quality = a; + } ui.label("MSAA is decorative on a fullscreen shader (no geometry edges to sample)."); }); });