diff --git a/src/app.rs b/src/app.rs index 2a08cc6..dfbbbca 100644 --- a/src/app.rs +++ b/src/app.rs @@ -46,6 +46,7 @@ const KINDS: &[(FractalKind, &str)] = &[ (FractalKind::Perpendicular, "Perpendicular"), (FractalKind::Buffalo, "Buffalo"), (FractalKind::Phoenix, "Phoenix"), + (FractalKind::Lambda, "Lambda"), ]; /// UI label for a fractal kind. @@ -60,7 +61,7 @@ fn kind_label(kind: FractalKind) -> &'static str { type JuliaPreset = (&'static str, f64, f64, u32, Option<(f64, f64)>); /// Nice-looking Julia constants offered as presets. -const JULIA_PRESETS: [&[JuliaPreset]; FractalKind::Phoenix as usize + 1] = [ +const JULIA_PRESETS: [&[JuliaPreset]; FractalKind::Lambda as usize + 1] = [ &[ ("dendrite", -0.8, 0.156, 400, None), ("rabbit", -0.123, 0.745, 400, None), @@ -78,6 +79,7 @@ const JULIA_PRESETS: [&[JuliaPreset]; FractalKind::Phoenix as usize + 1] = [ ("archipelago 1", -0.415, -0.267, 500, Some((-0.556, 0.253))), ("archipelago 2", -0.556, 0.253, 500, Some((-0.415, -0.267))), ], + &[], ]; type SetPreset = ( @@ -92,7 +94,7 @@ type SetPreset = ( /// Curated beautiful locations offered as one-click presets. /// Each is `(name, center_re, center_im, half_height, iterations)`; the centers /// are decimals parsed at full precision so deep places stay sharp. -const SET_PRESETS: [&[SetPreset]; FractalKind::Phoenix as usize + 1] = [ +const SET_PRESETS: [&[SetPreset]; FractalKind::Lambda as usize + 1] = [ &[ ( "Seahorse Valley", @@ -142,6 +144,7 @@ const SET_PRESETS: [&[SetPreset]; FractalKind::Phoenix as usize + 1] = [ 1000, Some((-0.9, -0.49)), )], + &[], ]; /// Parameters a reference orbit was (or will be) computed for. Used to decide @@ -153,6 +156,7 @@ struct RequestKey { julia: bool, julia_c: (f64, f64), phoenix_p: (f64, f64), + lambda_l: (f64, f64), iter: u32, kind: FractalKind, power: u32, @@ -196,6 +200,13 @@ struct AnimState { phoenix_base: (f64, f64), phoenix_angle: f64, + /// Drift the Lambda distortion `λ` around a circle. + lambda: bool, + lambda_speed: f32, + lambda_radius: f64, + lambda_base: (f64, f64), + lambda_angle: f64, + /// Continuously zoom toward the current center. zoom: bool, /// e-folds per second; positive zooms in, negative zooms out. @@ -217,6 +228,11 @@ impl Default for AnimState { phoenix_radius: 0.08, phoenix_base: (0.0, 0.0), phoenix_angle: 0.0, + lambda: false, + lambda_speed: 0.05, + lambda_radius: 0.08, + lambda_base: (0.0, 0.0), + lambda_angle: 0.0, zoom: false, zoom_speed: 0.5, } @@ -234,6 +250,8 @@ pub struct FractalApp { julia_c: (f64, f64), /// Distortion constant `p` for the Phoenix kind (`z^2 + c + p·z_{n-1}`). phoenix_p: (f64, f64), + /// Distortion constant `l` for the Lambda kind (`l·z(1 - z_{n-1})`). + lambda_l: (f64, f64), max_iterations: u32, /// When set, `max_iterations` tracks the zoom depth automatically (so deep /// zooms stay sharp without hand-tuning); the manual slider takes over when @@ -356,6 +374,7 @@ impl FractalApp { power: 3, julia_c: (-0.8, 0.156), phoenix_p: (-0.5, 0.0), + lambda_l: (-0.5, 0.0), max_iterations: 512, auto_iterations: true, color_scale: 0.15, @@ -517,6 +536,7 @@ impl FractalApp { iterations: self.max_iterations, julia_c: self.julia_c, phoenix_p: self.phoenix_p, + lambda_l: self.lambda_l, color_scale: self.color_scale, color_offset: self.color_offset, palette: self.palette, @@ -534,6 +554,7 @@ impl FractalApp { self.power = s.power.clamp(2, 8); self.julia_c = s.julia_c; self.phoenix_p = s.phoenix_p; + self.lambda_l = s.lambda_l; self.color_scale = s.color_scale; self.color_offset = s.color_offset; self.palette = (s.palette as usize).min(PALETTE_NAMES.len() - 1) as u32; @@ -582,6 +603,7 @@ impl FractalApp { FractalKind::Perpendicular => (-0.5, 0.0, 1.5), FractalKind::Buffalo => (-0.5, -0.5, 1.5), FractalKind::Phoenix => (0.0, 0.0, 1.6), + FractalKind::Lambda => (0.0, 0.0, 1.6), }; ViewState::with_center(big_from_f64(cr, 53), big_from_f64(ci, 53), hh) } @@ -594,6 +616,7 @@ impl FractalApp { julia: matches!(self.mode, FractalMode::Julia), julia_c: self.julia_c, phoenix_p: self.phoenix_p, + lambda_l: self.lambda_l, iter: self.max_iterations, kind: self.kind, power: self.power, @@ -609,7 +632,8 @@ impl FractalApp { /// Whether the reference should be (re)computed: parameters changed, or the /// view drifted / zoomed far enough that the current reference no longer - /// serves it well. + /// serves it well. Lambda in Set mode has a static fractal (doesn't depend + /// on center), so we skip center drift checks but allow zoom precision updates. fn should_request(&self) -> bool { let Some(key) = &self.last_request else { return true; @@ -617,12 +641,19 @@ impl FractalApp { if key.julia != matches!(self.mode, FractalMode::Julia) || key.julia_c != self.julia_c || key.phoenix_p != self.phoenix_p + || key.lambda_l != self.lambda_l || key.iter != self.max_iterations || key.kind != self.kind || key.power != self.power { return true; } + // Lambda in Set mode is a static fractal; don't trigger recompute on center drift. + if self.kind == FractalKind::Lambda && matches!(self.mode, FractalMode::Mandelbrot) { + // But still recompute on significant zoom changes for precision + let ratio = self.view.half_height / key.half_height; + return !(0.5..=2.0).contains(&ratio); + } let ratio = self.view.half_height / key.half_height; self.drift_from(key) > 0.5 * self.view.half_height || !(0.5..=2.0).contains(&ratio) } @@ -650,10 +681,16 @@ impl FractalApp { /// thread and pick up completed results. Web: compute inline. fn ensure_reference(&mut self) { if self.should_request() { - let key = self.current_key(); + let mut key = self.current_key(); let precision = self.view.precision_bits(); let max_iter = key.iter.min(MAX_REF_POINTS as u32 - 1); + // Lambda in Set mode has a static fractal centered at origin. + if key.kind == FractalKind::Lambda && !key.julia { + key.center_re = big_from_f64(0.0, precision); + key.center_im = big_from_f64(0.0, precision); + } + #[cfg(not(target_arch = "wasm32"))] { self.worker.request(crate::worker::RefRequest { @@ -667,6 +704,7 @@ impl FractalApp { kind: key.kind, power: key.power, phoenix_p: key.phoenix_p, + lambda_l: key.lambda_l, }); self.pending = true; } @@ -685,6 +723,7 @@ impl FractalApp { key.kind, key.power, key.phoenix_p, + key.lambda_l, ) } else { compute_set_reference( @@ -695,6 +734,7 @@ impl FractalApp { key.kind, key.power, key.phoenix_p, + key.lambda_l, ) }; self.apply_reference( @@ -727,12 +767,13 @@ impl FractalApp { is_julia: matches!(self.mode, FractalMode::Julia) as u32, palette_id: self.palette, aa_level: if self.antialias { 2 } else { 1 }, - kind: self.kind.shader_id(), + kind: self.kind as u32, power: self.power, dc_offset: self.dc_offset(), phoenix_p: [self.phoenix_p.0 as f32, self.phoenix_p.1 as f32], + lambda_l: [self.lambda_l.0 as f32, self.lambda_l.1 as f32], de_coloring: self.de_coloring as u32, - _pad: [0, 0, 0], + _pad: [0], } } @@ -1046,10 +1087,11 @@ impl FractalApp { /// repaint while active. Animations render at full resolution/AA (they do not /// trigger the interaction low-res pass). fn tick_animations(&mut self, ui: &egui::Ui) { - // Julia c only matters in Julia mode; Phoenix p only for the Phoenix kind. + // Julia c only matters in Julia mode; Phoenix p only for the Phoenix kind; Lambda λ only for Lambda kind. let julia_on = self.anim.julia && self.mode == FractalMode::Julia; let phoenix_on = self.anim.phoenix && self.kind == FractalKind::Phoenix; - if !(self.anim.color || self.anim.zoom || julia_on || phoenix_on) { + let lambda_on = self.anim.lambda && self.kind == FractalKind::Lambda; + if !(self.anim.color || self.anim.zoom || julia_on || phoenix_on || lambda_on) { return; } @@ -1076,6 +1118,15 @@ impl FractalApp { self.anim.phoenix_base.1 + self.anim.phoenix_radius * s, ); } + let lambda_on = self.anim.lambda && self.kind == FractalKind::Lambda; + if lambda_on { + self.anim.lambda_angle += std::f64::consts::TAU * self.anim.lambda_speed as f64 * dt; + let (s, c) = self.anim.lambda_angle.sin_cos(); + self.lambda_l = ( + self.anim.lambda_base.0 + self.anim.lambda_radius * c, + self.anim.lambda_base.1 + self.anim.lambda_radius * s, + ); + } if self.anim.zoom && self.anim.zoom_speed != 0.0 { let min_hh = DEFAULT_HALF_HEIGHT * 1.0e-26; // practical f32-perturbation depth let max_hh = DEFAULT_HALF_HEIGHT * 4.0; @@ -1124,6 +1175,22 @@ impl FractalApp { ui.label("i"); }); } + if self.kind == FractalKind::Lambda { + ui.horizontal(|ui| { + ui.label("λ ="); + ui.add( + egui::DragValue::new(&mut self.lambda_l.0) + .speed(0.001) + .range(-2.0..=2.0), + ); + ui.add( + egui::DragValue::new(&mut self.lambda_l.1) + .speed(0.001) + .range(-2.0..=2.0), + ); + ui.label("i"); + }); + } if self.kind != prev_kind { self.view = Self::default_view_for(self.mode, self.kind); } @@ -1133,7 +1200,7 @@ impl FractalApp { ui.radio_value(&mut self.mode, FractalMode::Julia, "Julia"); }); - if self.mode == FractalMode::Julia { + if self.mode == FractalMode::Julia && self.kind != FractalKind::Lambda { ui.horizontal(|ui| { ui.label("c ="); ui.add( @@ -1271,6 +1338,24 @@ impl FractalApp { ); } } + if self.kind == FractalKind::Lambda { + if ui.checkbox(&mut self.anim.lambda, "Morph λ").changed() && self.anim.lambda { + self.anim.lambda_base = self.lambda_l; + self.anim.lambda_angle = 0.0; + } + if self.anim.lambda { + ui.add( + egui::Slider::new(&mut self.anim.lambda_speed, 0.005..=0.5) + .text("λ rev/s") + .logarithmic(true), + ); + ui.add( + egui::Slider::new(&mut self.anim.lambda_radius, 0.005..=0.5) + .text("λ radius") + .logarithmic(true), + ); + } + } }); ui.separator(); diff --git a/src/fractal/reference.rs b/src/fractal/reference.rs index dc0f63f..a2f5aa2 100644 --- a/src/fractal/reference.rs +++ b/src/fractal/reference.rs @@ -18,37 +18,23 @@ use crate::view::{Big, big_from_f64}; #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum FractalKind { /// `z -> z^2 + c`. - Mandelbrot, + Mandelbrot = 0, /// `z -> (|Re z| + i|Im z|)^2 + c`. - BurningShip, + BurningShip = 1, /// `z -> conj(z)^2 + c` (the Mandelbar). - Tricorn, + Tricorn = 2, /// `z -> z^power + c` (power >= 2). - Multibrot, + Multibrot = 3, /// `z -> |Re(z^2)| + i·Im(z^2) + c` (abs on the real output of the square). - Celtic, + Celtic = 4, /// `z -> (x^2 - y^2) - 2·x·|y|·i + c` (abs on the imaginary input). - Perpendicular, + Perpendicular = 5, /// `z -> |Re(z^2)| - |Im(z^2)|·i + c` (abs on both outputs). - Buffalo, + Buffalo = 6, /// `z -> z^2 + c + p·z_{n-1}` (two-term recurrence; `p` is `phoenix_p`). - Phoenix, -} - -impl FractalKind { - /// Integer id matching the shader's `KIND_*` constants. - pub fn shader_id(self) -> u32 { - match self { - FractalKind::Mandelbrot => 0, - FractalKind::BurningShip => 1, - FractalKind::Tricorn => 2, - FractalKind::Multibrot => 3, - FractalKind::Celtic => 4, - FractalKind::Perpendicular => 5, - FractalKind::Buffalo => 6, - FractalKind::Phoenix => 7, - } - } + Phoenix = 7, + /// `z -> lambda·z(1 - z)` (logistic map). + Lambda = 8, } /// Reference orbit escapes once |Z|^2 exceeds this. Kept larger than the pixel @@ -70,6 +56,7 @@ pub fn compute_reference( kind: FractalKind, power: u32, phoenix_p: (f64, f64), + lambda_l: (f64, f64), ) -> Vec<[f32; 2]> { let cr = c_re.clone().with_precision(precision).value(); let ci = c_im.clone().with_precision(precision).value(); @@ -82,6 +69,9 @@ pub fn compute_reference( // Phoenix distortion constant `p` (a small fixed complex number). let pr = big_from_f64(phoenix_p.0, precision); let pi = big_from_f64(phoenix_p.1, precision); + // Lambda distortion constant `l` (a small fixed complex number). + let lr = big_from_f64(lambda_l.0, precision); + let li = big_from_f64(lambda_l.1, precision); let mut points: Vec<[f32; 2]> = Vec::with_capacity(max_iter as usize + 1); @@ -144,6 +134,14 @@ pub fn compute_reference( let pzi = &pr * &zi_prev + &pi * &zr_prev; (re2 + &cr + pzr, im2 + &ci + pzi) } + FractalKind::Lambda => { + // λ·z(1 - z): logistic map. + let re2 = 1 - &zr; + let im2 = -&zi; + let lzr = &lr * &zr - &li * &zi; + let lzi = &lr * &zi + &li * &zr; + (&lzr * &re2 - &lzi * &im2, re2 * lzi + lzr * im2) + } }; // Shift the previous iterate (only the Phoenix arm reads it). @@ -182,6 +180,7 @@ fn complex_pow(zr: &Big, zi: &Big, power: u32, precision: usize) -> (Big, Big) { /// Convenience: parameter-plane ("Mandelbrot-set") reference (`z0 = 0`, /// `c = center`) for any `kind`. +#[allow(clippy::too_many_arguments)] pub fn compute_set_reference( center_re: &Big, center_im: &Big, @@ -190,10 +189,11 @@ pub fn compute_set_reference( kind: FractalKind, power: u32, phoenix_p: (f64, f64), + lambda_l: (f64, f64), ) -> Vec<[f32; 2]> { let zero = big_zero(precision); compute_reference( - &zero, &zero, center_re, center_im, max_iter, precision, kind, power, phoenix_p, + &zero, &zero, center_re, center_im, max_iter, precision, kind, power, phoenix_p, lambda_l, ) } @@ -207,8 +207,16 @@ mod tests { fn reference_matches_naive_f64() { let cr = Big::try_from(-0.75_f64).unwrap(); let ci = Big::try_from(0.1_f64).unwrap(); - let points = - compute_set_reference(&cr, &ci, 60, 200, FractalKind::Mandelbrot, 2, (0.0, 0.0)); + let points = compute_set_reference( + &cr, + &ci, + 60, + 200, + FractalKind::Mandelbrot, + 2, + (0.0, 0.0), + (0.0, 0.0), + ); // Independent naive f64 orbit. let (c_re, c_im) = (-0.75_f64, 0.1_f64); @@ -238,8 +246,16 @@ mod tests { fn interior_orbit_runs_full_length() { let cr = Big::try_from(-0.2_f64).unwrap(); let ci = Big::try_from(0.0_f64).unwrap(); - let points = - compute_set_reference(&cr, &ci, 500, 120, FractalKind::Mandelbrot, 2, (0.0, 0.0)); + let points = compute_set_reference( + &cr, + &ci, + 500, + 120, + FractalKind::Mandelbrot, + 2, + (0.0, 0.0), + (0.0, 0.0), + ); assert_eq!(points.len(), 501, "interior orbit should not escape"); } @@ -248,8 +264,16 @@ mod tests { fn burning_ship_reference_matches_naive_f64() { let cr = Big::try_from(-1.75_f64).unwrap(); let ci = Big::try_from(-0.03_f64).unwrap(); - let points = - compute_set_reference(&cr, &ci, 60, 200, FractalKind::BurningShip, 2, (0.0, 0.0)); + let points = compute_set_reference( + &cr, + &ci, + 60, + 200, + FractalKind::BurningShip, + 2, + (0.0, 0.0), + (0.0, 0.0), + ); let (c_re, c_im) = (-1.75_f64, -0.03_f64); let (mut zr, mut zi) = (0.0_f64, 0.0_f64); @@ -269,8 +293,16 @@ mod tests { fn multibrot3_reference_matches_naive_f64() { let cr = Big::try_from(0.3_f64).unwrap(); let ci = Big::try_from(0.2_f64).unwrap(); - let points = - compute_set_reference(&cr, &ci, 60, 200, FractalKind::Multibrot, 3, (0.0, 0.0)); + let points = compute_set_reference( + &cr, + &ci, + 60, + 200, + FractalKind::Multibrot, + 3, + (0.0, 0.0), + (0.0, 0.0), + ); let (c_re, c_im) = (0.3_f64, 0.2_f64); let (mut zr, mut zi) = (0.0_f64, 0.0_f64); @@ -304,6 +336,7 @@ mod tests { FractalKind::Mandelbrot, 2, (0.0, 0.0), + (0.0, 0.0), ); let (mut zr, mut zi) = (0.15_f64, -0.1_f64); @@ -324,7 +357,16 @@ mod tests { fn celtic_reference_matches_naive_f64() { let cr = Big::try_from(-0.6_f64).unwrap(); let ci = Big::try_from(0.4_f64).unwrap(); - let points = compute_set_reference(&cr, &ci, 60, 200, FractalKind::Celtic, 2, (0.0, 0.0)); + let points = compute_set_reference( + &cr, + &ci, + 60, + 200, + FractalKind::Celtic, + 2, + (0.0, 0.0), + (0.0, 0.0), + ); let (c_re, c_im) = (-0.6_f64, 0.4_f64); let (mut zr, mut zi) = (0.0_f64, 0.0_f64); @@ -345,8 +387,16 @@ mod tests { fn perpendicular_reference_matches_naive_f64() { let cr = Big::try_from(-0.7_f64).unwrap(); let ci = Big::try_from(-0.2_f64).unwrap(); - let points = - compute_set_reference(&cr, &ci, 60, 200, FractalKind::Perpendicular, 2, (0.0, 0.0)); + let points = compute_set_reference( + &cr, + &ci, + 60, + 200, + FractalKind::Perpendicular, + 2, + (0.0, 0.0), + (0.0, 0.0), + ); let (c_re, c_im) = (-0.7_f64, -0.2_f64); let (mut zr, mut zi) = (0.0_f64, 0.0_f64); @@ -367,7 +417,16 @@ mod tests { fn buffalo_reference_matches_naive_f64() { let cr = Big::try_from(-1.2_f64).unwrap(); let ci = Big::try_from(-0.35_f64).unwrap(); - let points = compute_set_reference(&cr, &ci, 60, 200, FractalKind::Buffalo, 2, (0.0, 0.0)); + let points = compute_set_reference( + &cr, + &ci, + 60, + 200, + FractalKind::Buffalo, + 2, + (0.0, 0.0), + (0.0, 0.0), + ); let (c_re, c_im) = (-1.2_f64, -0.35_f64); let (mut zr, mut zi) = (0.0_f64, 0.0_f64); @@ -389,7 +448,8 @@ mod tests { let cr = Big::try_from(0.5667_f64).unwrap(); let ci = Big::try_from(0.0_f64).unwrap(); let p = (-0.5_f64, 0.0_f64); - let points = compute_set_reference(&cr, &ci, 60, 200, FractalKind::Phoenix, 2, p); + let points = + compute_set_reference(&cr, &ci, 60, 200, FractalKind::Phoenix, 2, p, (0.0, 0.0)); let (c_re, c_im) = (0.5667_f64, 0.0_f64); let (mut zr, mut zi) = (0.0_f64, 0.0_f64); diff --git a/src/fractal/renderer.rs b/src/fractal/renderer.rs index ce6bbf7..33bb037 100644 --- a/src/fractal/renderer.rs +++ b/src/fractal/renderer.rs @@ -78,10 +78,13 @@ pub struct Uniforms { /// ignored by other kinds. Kept next to `dc_offset` so both `vec2`s land on /// 8-byte boundaries, matching the shader's layout. pub phoenix_p: [f32; 2], + /// Distortion constant `l` for the Lambda map (`l·z(1 - z)`); + /// ignored by other kinds. + pub lambda_l: [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; 3], + pub _pad: [u32; 1], } /// Offscreen textures for the two-pass render, recreated whenever the widget's diff --git a/src/fractal/share.rs b/src/fractal/share.rs index fff8639..4e06d9d 100644 --- a/src/fractal/share.rs +++ b/src/fractal/share.rs @@ -23,6 +23,8 @@ pub struct ShareState { pub julia_c: (f64, f64), /// Distortion constant for the Phoenix kind (ignored by others). pub phoenix_p: (f64, f64), + /// Distortion constant for the Lambda kind (ignored by others). + pub lambda_l: (f64, f64), pub color_scale: f32, pub color_offset: f32, /// Palette index (`palette_id` in the shader). @@ -44,6 +46,7 @@ impl ShareState { FractalKind::Perpendicular => "perp", FractalKind::Buffalo => "buffalo", FractalKind::Phoenix => "phoenix", + FractalKind::Lambda => "lambda", } )); s.push_str(&format!("&pw={}", self.power)); @@ -53,6 +56,7 @@ impl ShareState { )); s.push_str(&format!("&jr={}&ji={}", self.julia_c.0, self.julia_c.1)); s.push_str(&format!("&px={}&py={}", self.phoenix_p.0, self.phoenix_p.1)); + s.push_str(&format!("&lx={}&ly={}", self.lambda_l.0, self.lambda_l.1)); s.push_str(&format!( "&cs={}&co={}&pal={}", self.color_scale, self.color_offset, self.palette @@ -82,6 +86,7 @@ impl ShareState { "perp" => FractalKind::Perpendicular, "buffalo" => FractalKind::Buffalo, "phoenix" => FractalKind::Phoenix, + "lambda" => FractalKind::Lambda, _ => FractalKind::Mandelbrot, }) .unwrap_or(FractalKind::Mandelbrot), @@ -98,6 +103,10 @@ impl ShareState { map.get("px").and_then(|s| s.parse().ok()).unwrap_or(-0.5), map.get("py").and_then(|s| s.parse().ok()).unwrap_or(0.0), ), + lambda_l: ( + map.get("lx").and_then(|s| s.parse().ok()).unwrap_or(-0.5), + map.get("ly").and_then(|s| s.parse().ok()).unwrap_or(0.0), + ), color_scale: map.get("cs").and_then(|s| s.parse().ok()).unwrap_or(0.02), color_offset: map.get("co").and_then(|s| s.parse().ok()).unwrap_or(0.0), palette: map.get("pal").and_then(|s| s.parse().ok()).unwrap_or(0), @@ -121,6 +130,7 @@ mod tests { iterations: 4000, julia_c: (-0.123, 0.745), phoenix_p: (-0.5, 0.1), + lambda_l: (-0.5, 0.0), color_scale: 0.02, color_offset: 0.25, palette: 3, diff --git a/src/shaders/mandelbrot.wgsl b/src/shaders/mandelbrot.wgsl index a2815f7..4e99935 100644 --- a/src/shaders/mandelbrot.wgsl +++ b/src/shaders/mandelbrot.wgsl @@ -30,6 +30,9 @@ struct Uniforms { // Distortion constant p for the Phoenix map (z^2 + c + p*z_{n-1}); unused // by other kinds. Placed by dc_offset so both vec2s stay 8-byte aligned. phoenix_p: vec2, + // Distortion constant l for the Lambda map (l*z(1 - z_{n-1})); unused + // by other kinds. + lambda_l: vec2, // 0 = escape-time coloring, 1 = distance-estimation shading. de_coloring: u32, }; @@ -42,6 +45,7 @@ const KIND_CELTIC: u32 = 4u; const KIND_PERPENDICULAR: u32 = 5u; const KIND_BUFFALO: u32 = 6u; const KIND_PHOENIX: u32 = 7u; +const KIND_LAMBDA: u32 = 8u; @group(0) @binding(0) var u: Uniforms; @group(0) @binding(1) var ref_orbit: array>; @@ -82,7 +86,7 @@ fn conj(a: vec2) -> vec2 { // the sign flips that happen all along the axes, where the ship's detail lives. fn diffabs(c: f32, d: f32) -> f32 { let cd = c + d; - if (c >= 0.0) { + if c >= 0.0 { return select(-(2.0 * c + d), d, cd >= 0.0); } return select(-d, 2.0 * c + d, cd > 0.0); @@ -120,7 +124,7 @@ fn multibrot_delta(z: vec2, e: vec2, p: u32) -> vec2 { // where `z` is the reference orbit value X_m. `step_add` (dc) is added by the // caller. Must match `FractalKind` on the CPU side. fn advance_delta(z: vec2, e: vec2) -> vec2 { - if (u.kind == KIND_BURNING_SHIP) { + if u.kind == KIND_BURNING_SHIP { // (|x| + i|y|)^2 has real part x^2 - y^2 (an ordinary square delta) and // imaginary part 2|x y|. The imaginary delta is 2(|x y| - |X Y|); diffabs // computes it exactly, even where the product x y changes sign — which the @@ -129,29 +133,33 @@ fn advance_delta(z: vec2, e: vec2) -> vec2 { let base = 2.0 * cmul(z, e) + cmul(e, e); let dp = z.x * e.y + z.y * e.x + e.x * e.y; return vec2(base.x, 2.0 * diffabs(z.x * z.y, dp)); - } else if (u.kind == KIND_TRICORN) { + } else if u.kind == KIND_TRICORN { let cz = conj(z); let ce = conj(e); return 2.0 * cmul(cz, ce) + cmul(ce, ce); - } else if (u.kind == KIND_MULTIBROT) { + } else if u.kind == KIND_MULTIBROT { return multibrot_delta(z, e, clamp(u.power, 2u, 8u)); - } else if (u.kind == KIND_CELTIC) { + } else if u.kind == KIND_CELTIC { // z^2 delta split: sq.x = delta of Re(z^2), sq.y = delta of Im(z^2). // Celtic abs the real output, so |Re(z^2)| delta = diffabs(Re(Z^2), sq.x). let sq = 2.0 * cmul(z, e) + cmul(e, e); return vec2(diffabs(z.x * z.x - z.y * z.y, sq.x), sq.y); - } else if (u.kind == KIND_BUFFALO) { + } else if u.kind == KIND_BUFFALO { // Abs both outputs: real |Re(z^2)|, imag -|Im(z^2)| (Im(Z^2) = 2 X Y). let sq = 2.0 * cmul(z, e) + cmul(e, e); return vec2(diffabs(z.x * z.x - z.y * z.y, sq.x), -diffabs(2.0 * z.x * z.y, sq.y)); - } else if (u.kind == KIND_PERPENDICULAR) { + } else if u.kind == KIND_PERPENDICULAR { // real x^2 - y^2 (ordinary square delta), imag -2 x |y|. // d(-2 x |y|) = -2[ X·(|Y+ey|-|Y|) + ex·|Y+ey| ]; diffabs gives |Y+ey|-|Y|. let sq = 2.0 * cmul(z, e) + cmul(e, e); let da = diffabs(z.y, e.y); // |Y + ey| - |Y| let abs_yf = abs(z.y) + da; // |Y + ey| return vec2(sq.x, -2.0 * (z.x * da + e.x * abs_yf)); + } else if u.kind == KIND_LAMBDA { + // Lambda map: z^{n+1} = λ·z·(1-z). Delta: e = λ·e·(1-2z-e). + let one_minus_2z_minus_e = vec2(1.0 - 2.0*z.x - e.x, -2.0*z.y - e.y); + return cmul(u.lambda_l, cmul(e, one_minus_2z_minus_e)); } return 2.0 * cmul(z, e) + cmul(e, e); // Mandelbrot (and Phoenix square part) } @@ -162,31 +170,34 @@ fn advance_delta(z: vec2, e: vec2) -> vec2 { // 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) { + 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; + } else if u.kind == KIND_LAMBDA { + // Lambda: f'(z) = λ·(1-2z). + return cmul(u.lambda_l, vec2(1.0 - 2.0*z.x, -2.0*z.y)); } return 2.0 * z; } // Smooth cyclic palettes (Inigo Quilez cosine palettes), selected by id. fn palette(id: u32, t: f32) -> vec3 { - if (id == 4u) { + if id == 4u { return vec3(t, t, t); // grayscale } let a = vec3(0.5, 0.5, 0.5); let b = vec3(0.5, 0.5, 0.5); var c = vec3(1.0, 1.0, 1.0); var d = vec3(0.00, 0.10, 0.20); // 0: amber / blue - if (id == 1u) { + if id == 1u { d = vec3(0.00, 0.33, 0.67); // rainbow - } else if (id == 2u) { + } else if id == 2u { d = vec3(0.30, 0.20, 0.20); // warm ember - } else if (id == 3u) { + } else if id == 3u { c = vec3(1.0, 1.0, 0.5); d = vec3(0.80, 0.90, 0.30); // lime / magenta } @@ -220,7 +231,7 @@ fn iterate_sample(offset: vec2, px: f32) -> Sample { // y_{n-1}, and its derivative for DE). Both start at 0 (y_{-1} = 0). var e_prev = vec2(0.0, 0.0); var dz_prev = vec2(0.0, 0.0); - if (u.is_julia != 0u) { + if u.is_julia != 0u { step_add = vec2(0.0, 0.0); e = offset; dz = vec2(1.0, 0.0); @@ -232,65 +243,65 @@ fn iterate_sample(offset: vec2, px: f32) -> Sample { var z = vec2(0.0, 0.0); // full value y_n, kept for coloring var escaped = false; - loop { - let xm = ref_orbit[m]; - z = xm + e; + loop { + let xm = ref_orbit[m]; + z = xm + e; - let z2 = dot(z, z); - if (z2 > u.bailout_sq) { - escaped = true; - break; - } - if (n >= u.max_iter) { - break; // interior - } + let z2 = dot(z, z); + if z2 > u.bailout_sq { + escaped = true; + break; + } + if n >= u.max_iter { + break; // interior + } // Propagate the derivative of the full orbit (unaffected by rebasing, // which only re-expresses the same value). Only when DE is enabled. // Phoenix's two-term map adds p·dz_{n-1} and carries the previous dz. - if (u.de_coloring != 0u) { - var dz_new = cmul(fprime(z), dz) + dz_seed; - if (u.kind == KIND_PHOENIX) { - dz_new = dz_new + cmul(u.phoenix_p, dz_prev); - dz_prev = dz; + if u.de_coloring != 0u { + var dz_new = cmul(fprime(z), dz) + dz_seed; + if u.kind == KIND_PHOENIX { + dz_new = dz_new + cmul(u.phoenix_p, dz_prev); + dz_prev = dz; + } + dz = dz_new; } - dz = dz_new; - } // Advance the delta by this fractal's formula (+ dc for the set plane). // Phoenix additionally adds p·e_{n-1} and carries the previous delta. - let e_old = e; - e = advance_delta(xm, e) + step_add; - if (u.kind == KIND_PHOENIX) { - e = e + cmul(u.phoenix_p, e_prev); - e_prev = e_old; - } - m = m + 1u; - n = n + 1u; + let e_old = e; + e = advance_delta(xm, e) + step_add; + if u.kind == KIND_PHOENIX { + e = e + cmul(u.phoenix_p, e_prev); + e_prev = e_old; + } + m = m + 1u; + n = n + 1u; // Keep the reference index valid and the delta small. - if (m >= u.ref_len) { + if m >= u.ref_len { // Reference exhausted: any pixel that followed it this far has // effectively escaped (interior pixels rebase before reaching here). - z = ref_orbit[u.ref_len - 1u] + e; - escaped = true; - break; - } - let y = ref_orbit[m] + e; - if (dot(y, y) < dot(e, e)) { + z = ref_orbit[u.ref_len - 1u] + e; + escaped = true; + break; + } + let y = ref_orbit[m] + e; + if dot(y, y) < dot(e, e) { // Rebase to index 0: carry the full value as the new delta. Valid // because y_n = X[0] + (y_n - X[0]); for Mandelbrot X[0]=0. // Phoenix: after rebasing the implied previous reference is Y[-1]=0, // so the previous delta becomes the full previous value y_n (= z). - if (u.kind == KIND_PHOENIX) { - e_prev = z; + if u.kind == KIND_PHOENIX { + e_prev = z; + } + e = y - z0; + m = 0u; } - e = y - z0; - m = 0u; } - } - if (!escaped) { + if !escaped { return Sample(0.0, 1.0, false); // interior of the set } @@ -306,7 +317,7 @@ fn iterate_sample(offset: vec2, px: f32) -> Sample { let ci = sqrt(max(smooth_i, 0.0)); var de = 1.0; - if (u.de_coloring != 0u) { + 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 @@ -324,7 +335,7 @@ fn iterate_sample(offset: vec2, px: f32) -> Sample { // only color-dependent step, so it can be redone without re-iterating. Interior // samples are black. fn color_sample(s: Sample) -> vec3 { - if (!s.escaped) { + if !s.escaped { return vec3(0.0, 0.0, 0.0); } let t = fract(s.ci * u.color_scale + u.color_offset); @@ -353,7 +364,7 @@ fn fs_data(in: VsOut) -> @location(0) vec4 { let jx = (f32(sx) + 0.5) * inv - 0.5; let jy = (f32(sy) + 0.5) * inv - 0.5; let s = iterate_sample(base + jx * dx + jy * dy, px); - if (s.escaped) { + if s.escaped { ci_sum = ci_sum + s.ci; de_sum = de_sum + s.de; escaped_n = escaped_n + 1u; diff --git a/src/worker.rs b/src/worker.rs index 790efab..beb7a4b 100644 --- a/src/worker.rs +++ b/src/worker.rs @@ -24,6 +24,8 @@ pub struct RefRequest { pub power: u32, /// Distortion constant for the Phoenix map (ignored by other kinds). pub phoenix_p: (f64, f64), + /// Distortion constant for the Lambda map (ignored by other kinds). + pub lambda_l: (f64, f64), } pub struct RefResult { @@ -104,6 +106,7 @@ fn compute(req: &RefRequest) -> Vec<[f32; 2]> { req.kind, req.power, req.phoenix_p, + req.lambda_l, ) } else { compute_set_reference( @@ -114,6 +117,7 @@ fn compute(req: &RefRequest) -> Vec<[f32; 2]> { req.kind, req.power, req.phoenix_p, + req.lambda_l, ) } }