From 9e9641891eddedd1c69eb8457bc8659b8a2eaccd Mon Sep 17 00:00:00 2001 From: supersurviveur Date: Tue, 15 Sep 2026 15:49:21 +0200 Subject: [PATCH] feat: distance-estimation shading for crisp deep-zoom filaments --- Cargo.toml | 3 ++ src/app.rs | 16 ++++++++- src/fractal/renderer.rs | 4 ++- src/shaders/mandelbrot.wgsl | 71 +++++++++++++++++++++++++++++++------ tests/shader_valid.rs | 31 ++++++++++++++++ 5 files changed, 112 insertions(+), 13 deletions(-) create mode 100644 tests/shader_valid.rs diff --git a/Cargo.toml b/Cargo.toml index a78a366..be97af5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,3 +34,6 @@ opt-level = 1 [profile.dev.package."*"] opt-level = 3 + +[dev-dependencies] +naga = { version = "30", features = ["wgsl-in"] } diff --git a/src/app.rs b/src/app.rs index 94ae055..a1de971 100644 --- a/src/app.rs +++ b/src/app.rs @@ -133,6 +133,9 @@ pub struct FractalApp { palette: u32, /// Supersample each pixel 2×2 for smoother edges (costs ~4× fragment work). antialias: bool, + /// Distance-estimation shading: darkens toward the set boundary using the + /// orbit derivative, giving crisp filaments at deep zoom instead of speckle. + de_coloring: bool, /// Reference orbit (`Z_n` as f32 pairs) for the current view. reference: Arc>, @@ -231,6 +234,7 @@ impl FractalApp { color_offset: 0.0, palette: 0, antialias: false, + de_coloring: false, reference: Arc::new(Vec::new()), generation: 0, ref_center_re, @@ -296,6 +300,9 @@ impl FractalApp { if let Ok(spec) = std::env::var("MANDEL_VIEW") { app.apply_view_spec(&spec); } + if std::env::var("MANDEL_DE").is_ok() { + app.de_coloring = true; + } if std::env::var("MANDEL_EXPORT").is_ok() { app.export_requested = true; } @@ -573,7 +580,8 @@ impl FractalApp { kind: self.kind.shader_id(), power: self.power, dc_offset: self.dc_offset(), - _pad: [0, 0], + de_coloring: self.de_coloring as u32, + _pad: 0, } } @@ -857,6 +865,12 @@ impl FractalApp { }); ui.checkbox(&mut self.antialias, "Antialiasing (2×2)") .on_hover_text("Supersample each pixel for smoother edges (~4× slower)."); + ui.checkbox(&mut self.de_coloring, "Distance shading") + .on_hover_text( + "Shade by distance to the set boundary (from the orbit derivative) \ + for crisp filaments at deep zoom. Exact for Mandelbrot/Multibrot, \ + approximate for Burning Ship/Tricorn.", + ); ui.separator(); // Editable center coordinates. Shown at full precision; parsed diff --git a/src/fractal/renderer.rs b/src/fractal/renderer.rs index d2a6ba0..31c4bb0 100644 --- a/src/fractal/renderer.rs +++ b/src/fractal/renderer.rs @@ -43,8 +43,10 @@ pub struct Uniforms { /// or reused reference (computed at a slightly different center) still maps /// correctly. Added to every pixel's per-pixel offset. pub dc_offset: [f32; 2], + /// 0 = escape-time coloring, 1 = distance-estimation shading. + pub de_coloring: u32, /// Padding to a 16-byte multiple (uniform buffer requirement). - pub _pad: [u32; 2], + pub _pad: u32, } /// Offscreen texture the fractal is rendered into, plus the bind group used to diff --git a/src/shaders/mandelbrot.wgsl b/src/shaders/mandelbrot.wgsl index 67a69ae..fbec524 100644 --- a/src/shaders/mandelbrot.wgsl +++ b/src/shaders/mandelbrot.wgsl @@ -27,6 +27,8 @@ struct Uniforms { // Exponent for the Multibrot kind. power: u32, dc_offset: vec2, + // 0 = escape-time coloring, 1 = distance-estimation shading. + de_coloring: u32, }; const KIND_MANDELBROT: u32 = 0u; @@ -130,6 +132,23 @@ fn advance_delta(z: vec2, e: vec2) -> vec2 { return 2.0 * cmul(z, e) + cmul(e, e); // Mandelbrot } +// Derivative f'(Z) of the iteration map at the full value Z, used to propagate +// the orbit derivative for distance-estimation shading. Exact for the +// holomorphic kinds (z^2 -> 2Z, z^p -> p Z^{p-1}); for the non-holomorphic +// Burning Ship / Tricorn we use |f'| ~ |2Z|, which keeps the DE magnitude close +// enough to de-speckle filaments. +fn fprime(z: vec2) -> vec2 { + if (u.kind == KIND_MULTIBROT) { + let p = clamp(u.power, 2u, 8u); + var zk = vec2(1.0, 0.0); // Z^0 + for (var k: u32 = 1u; k < p; k = k + 1u) { + zk = cmul(zk, z); // -> Z^{p-1} + } + return f32(p) * zk; + } + return 2.0 * z; +} + // Smooth cyclic palettes (Inigo Quilez cosine palettes), selected by id. fn palette(id: u32, t: f32) -> vec3 { if (id == 4u) { @@ -155,14 +174,20 @@ fn palette(id: u32, t: f32) -> vec3 { // step (delta starts at 0); for Julia it is the z-plane offset that seeds the // initial delta (c is fixed, so nothing is added per step). Interior pixels // return black. -fn shade(offset: vec2) -> vec3 { +fn shade(offset: vec2, px: f32) -> vec3 { let z0 = ref_orbit[0]; // reference start (0 for Mandelbrot, center for Julia) var step_add = offset; var e = vec2(0.0, 0.0); + // Orbit derivative for distance estimation. For the set plane it is d/dc + // (starts at 0, gains +1 each step); for Julia it is d/dz0 (starts at 1). + var dz = vec2(0.0, 0.0); + var dz_seed = vec2(1.0, 0.0); if (u.is_julia != 0u) { step_add = vec2(0.0, 0.0); e = offset; + dz = vec2(1.0, 0.0); + dz_seed = vec2(0.0, 0.0); } var m: u32 = 0u; // reference index; invariant: y_n = X[m] + e @@ -183,6 +208,12 @@ fn shade(offset: vec2) -> vec3 { break; // interior } + // Propagate the derivative of the full orbit (unaffected by rebasing, + // which only re-expresses the same value). Only when DE is enabled. + if (u.de_coloring != 0u) { + dz = cmul(fprime(z), dz) + dz_seed; + } + // Advance the delta by this fractal's formula (+ dc for the set plane). e = advance_delta(xm, e) + step_add; m = m + 1u; @@ -209,8 +240,10 @@ fn shade(offset: vec2) -> vec3 { return vec3(0.0, 0.0, 0.0); // interior of the set } + let z2 = dot(z, z); + // Continuous (smooth) iteration count. - let log_zn = 0.5 * log(max(dot(z, z), 1.0)); + let log_zn = 0.5 * log(max(z2, 1.0)); let nu = log2(log_zn / log(2.0)); let smooth_i = f32(n) + 1.0 - nu; @@ -218,22 +251,38 @@ fn shade(offset: vec2) -> vec3 { // varies smoothly instead of aliasing into speckle. let ci = sqrt(max(smooth_i, 0.0)); let t = fract(ci * u.color_scale + u.color_offset); - return palette(u.palette_id, t); + var col = palette(u.palette_id, t); + + if (u.de_coloring != 0u) { + // Exterior distance estimate (complex-plane units): |z|·ln|z| / |dz|. + // Divided by the pixel footprint it becomes a distance in pixels; we + // darken toward the boundary (< ~1 px away) so filaments stay crisp + // instead of aliasing into speckle. If |dz| overflowed, de -> 0 and the + // boundary simply reads as dark, which is the correct limit. + let zmag = sqrt(max(z2, 1.0)); + let dzmag = sqrt(max(dot(dz, dz), 1e-20)); + let de = zmag * log(zmag) / dzmag; + let de_px = de / max(px, 1e-30); + col = col * clamp(de_px, 0.0, 1.0); + } + return col; } @fragment fn fs_main(in: VsOut) -> @location(0) vec4 { let base = in.centered * u.span + u.dc_offset; - let aa = max(u.aa_level, 1u); - if (aa <= 1u) { - return vec4(shade(base), 1.0); - } - - // Screen-space complex-units-per-pixel, used to place sub-pixel samples. - // Derivatives must be evaluated in uniform control flow, so take them here. + // Screen-space complex-units-per-pixel. Derivatives must be evaluated in + // uniform control flow, so take them here; used to place sub-pixel AA + // samples and to convert the distance estimate into pixels. let dx = dpdx(base); let dy = dpdy(base); + let px = length(abs(dx) + abs(dy)); // ~ complex units per pixel (footprint) + + let aa = max(u.aa_level, 1u); + if (aa <= 1u) { + return vec4(shade(base, px), 1.0); + } var acc = vec3(0.0, 0.0, 0.0); let inv = 1.0 / f32(aa); @@ -242,7 +291,7 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { // Sample centers evenly spread across the pixel, jitter in (-0.5, 0.5). let jx = (f32(sx) + 0.5) * inv - 0.5; let jy = (f32(sy) + 0.5) * inv - 0.5; - acc = acc + shade(base + jx * dx + jy * dy); + acc = acc + shade(base + jx * dx + jy * dy, px); } } return vec4(acc / f32(aa * aa), 1.0); diff --git a/tests/shader_valid.rs b/tests/shader_valid.rs new file mode 100644 index 0000000..272b499 --- /dev/null +++ b/tests/shader_valid.rs @@ -0,0 +1,31 @@ +//! Static validation of the WGSL shaders. `cargo build` does not type-check +//! WGSL (that happens at pipeline creation), so this parses and validates each +//! shader with the same `naga` version wgpu uses — catching shader errors +//! without needing a GPU or a display. + +fn validate(name: &str, src: &str) { + let module = match naga::front::wgsl::parse_str(src) { + Ok(m) => m, + Err(e) => panic!("{name}: WGSL parse error:\n{}", e.emit_to_string(src)), + }; + let mut validator = naga::valid::Validator::new( + naga::valid::ValidationFlags::all(), + naga::valid::Capabilities::all(), + ); + if let Err(e) = validator.validate(&module) { + panic!("{name}: WGSL validation error:\n{}", e.emit_to_string(src)); + } +} + +#[test] +fn mandelbrot_shader_is_valid() { + validate( + "mandelbrot.wgsl", + include_str!("../src/shaders/mandelbrot.wgsl"), + ); +} + +#[test] +fn blit_shader_is_valid() { + validate("blit.wgsl", include_str!("../src/shaders/blit.wgsl")); +}