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:
parent
934468f7a0
commit
65a7611868
@ -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<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) ----------
|
||||
struct SphereData {
|
||||
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.
|
||||
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<f32>(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<f32>, dir: vec3<f32>, dt: f32) -> RkStep {
|
||||
}
|
||||
|
||||
// ====================== 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
|
||||
// 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<f32> {
|
||||
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<f32> {
|
||||
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);
|
||||
}
|
||||
|
||||
@ -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 },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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() {
|
||||
|
||||
16
src/ui.rs
16
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).");
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user