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.
This commit is contained in:
xfy 2026-07-16 16:02:41 +08:00
parent 934468f7a0
commit 65a7611868
5 changed files with 134 additions and 16 deletions

View File

@ -53,10 +53,20 @@ struct BlackHoleUniforms {
disk_temp: f32, // blackbody base temperature (Kelvin) disk_temp: f32, // blackbody base temperature (Kelvin)
jets_enabled: u32, // 0=off, 1=on jets_enabled: u32, // 0=off, 1=on
jets_strength: f32, // jet brightness multiplier 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<uniform> uniforms: BlackHoleUniforms; @group(#{MATERIAL_BIND_GROUP}) @binding(0) var<uniform> 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<private>` is the same form
// `blur.wgsl` uses for its runtime-indexed kernel tables.
var<private> pixel_seed: vec2<f32>;
// ---------- planets storage (binding 3) ---------- // ---------- planets storage (binding 3) ----------
struct SphereData { struct SphereData {
center: vec4<f32>, // xyz = center (world space), w = radius center: vec4<f32>, // 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. // Analytic in-slab length the key: depends on geometry, NOT on RK45 dt.
let seg_len = (t1 - t0) * length(new_pos - prev); let seg_len = (t1 - t0) * length(new_pos - prev);
// N uniform samples along the clipped segment, front-to-back composite. // N samples along the clipped segment, front-to-back composite. Each sample
// Each sample carries density × (seg_len / N) so the N samples sum to the // carries density × (seg_len / N) so the N samples sum to the full segment
// full segment integral when density is roughly constant; the Gaussian // integral when density is roughly constant; the Gaussian vertical decay is
// vertical decay is captured by sampling at differing heights within the // captured by sampling at differing heights within the slab. N is a
// slab. N is a compile-time constant (WGSL requires static loop bounds). // 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; const N = 4u;
for (var i: u32 = 0u; i < N; i = i + 1u) { 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<f32>(pixel_seed, f32(i)));
let t = t0 + (t1 - t0) * stratum / f32(N);
let p = mix(prev, new_pos, vec3f(t)); let p = mix(prev, new_pos, vec3f(t));
let rp = r_of(p); let rp = r_of(p);
if (rp < uniforms.disk_inner || rp > uniforms.disk_outer) { if (rp < uniforms.disk_inner || rp > uniforms.disk_outer) {
@ -708,16 +730,13 @@ fn rk45_step(pos: vec3<f32>, dir: vec3<f32>, dt: f32) -> RkStep {
} }
// ====================== main ====================== // ====================== main ======================
@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
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<f32>) -> vec3<f32> {
// Total path length to integrate: enough to go from the camera, past the // Total path length to integrate: enough to go from the camera, past the
// hole, and far enough beyond to count as escaped. // hole, and far enough beyond to count as escaped.
let eye_dist = length(uniforms.eye.xyz); let eye_dist = length(uniforms.eye.xyz);
@ -731,6 +750,8 @@ fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
let tol = 1e-3; let tol = 1e-3;
let r_plus = 0.5 + sqrt(max(0.25 - (uniforms.spin * 0.5) * (uniforms.spin * 0.5), 0.0)); 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 pos = rot_x(uniforms.eye.xyz, -uniforms.disk_tilt);
var d = normalize(rot_x(dir, -uniforms.disk_tilt)); var d = normalize(rot_x(dir, -uniforms.disk_tilt));
var dt = dt_init; var dt = dt_init;
@ -829,5 +850,47 @@ fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
d = new_dir; d = new_dir;
} }
return vec4<f32>(accum_color, 1.0); return accum_color;
}
@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
// 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<f32>((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<f32>(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<f32>(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<f32>(color_sum / f32(n_done), 1.0);
} }

View File

@ -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), /// All tunable black-hole parameters. Edited by the egui panel (Task 17),
/// mirrored into BlackHoleUniforms each frame (Task 7). /// mirrored into BlackHoleUniforms each frame (Task 7).
#[derive(Resource, Clone)] #[derive(Resource, Clone)]
@ -125,6 +149,8 @@ pub struct BlackHoleParams {
pub disk_temp: f32, pub disk_temp: f32,
pub jets_enabled: bool, pub jets_enabled: bool,
pub jets_strength: f32, pub jets_strength: f32,
// Anti-aliasing (Phase 3.3): supersample count for the lensed-image rings.
pub aa_quality: AaQuality,
} }
impl Default for BlackHoleParams { impl Default for BlackHoleParams {
@ -175,6 +201,11 @@ impl Default for BlackHoleParams {
disk_temp: 6500.0, disk_temp: 6500.0,
jets_enabled: true, jets_enabled: true,
jets_strength: 1.0, 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 },
} }
} }
} }

View File

@ -58,6 +58,10 @@ pub struct BlackHoleUniforms {
pub disk_temp: f32, // blackbody base temperature (Kelvin) pub disk_temp: f32, // blackbody base temperature (Kelvin)
pub jets_enabled: u32, pub jets_enabled: u32,
pub jets_strength: f32, 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 { impl Default for BlackHoleUniforms {
@ -107,6 +111,9 @@ impl Default for BlackHoleUniforms {
disk_temp: 6500.0, disk_temp: 6500.0,
jets_enabled: 1, jets_enabled: 1,
jets_strength: 1.0, jets_strength: 1.0,
aa_samples: 1, // overridden by params.aa_quality each frame
_pad6: 0.0,
_pad7: 0.0,
} }
} }
} }

View File

@ -633,6 +633,7 @@ fn mirror_params(
u.disk_temp = params.disk_temp; u.disk_temp = params.disk_temp;
u.jets_enabled = params.jets_enabled as u32; u.jets_enabled = params.jets_enabled as u32;
u.jets_strength = params.jets_strength; u.jets_strength = params.jets_strength;
u.aa_samples = params.aa_quality.samples();
} }
// Update brightpass threshold (live-tunable). // Update brightpass threshold (live-tunable).
for (_, mat) in brightpass_materials.iter_mut() { for (_, mat) in brightpass_materials.iter_mut() {

View File

@ -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.exposure, 0.5..=3.0).text("Exposure"));
ui.add(egui::Slider::new(&mut params.render_scale, 0.25..=1.0).text("Resolution scale")); 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"); 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)."); ui.label("MSAA is decorative on a fullscreen shader (no geometry edges to sample).");
}); });
}); });