use std::sync::{Arc, Mutex}; use eframe::CreationContext; use eframe::egui_wgpu; use eframe::egui_wgpu::wgpu; #[cfg(not(target_arch = "wasm32"))] use crate::cli::Cli; use crate::fractal::{ BuddhabrotCallback, BuddhabrotRenderer, BuddhabrotUniforms, ExportRender, FractalCallback, FractalKind, FractalRenderer, MAX_REF_POINTS, ShareState, Uniforms, }; #[cfg(target_arch = "wasm32")] use crate::fractal::{compute_reference, compute_set_reference}; use crate::lights::Light; use crate::view::{ Big, DEFAULT_HALF_HEIGHT, ViewState, big_from_decimal_str, big_from_f64, big_to_decimal_str, precision_for, }; #[cfg(not(target_arch = "wasm32"))] use clap::Parser; const BAILOUT_SQ: f32 = 1.0e6; /// Cap on exported image dimension (px), to stay within GPU texture limits. const MAX_EXPORT_DIM: u32 = 8192 * 16; /// While the user is actively panning/zooming, the fractal is rendered into a /// cache texture downscaled by this factor per axis (and with AA forced off), so /// each interacting frame is cheap; the linear blit upsamples it to the widget. /// A full-resolution render replaces it once input settles. 2 → quarter the /// pixels (~4× faster); raise for more speed at the cost of more blur in motion. const INTERACT_DOWNSCALE: u32 = 2; /// Seconds without pan/zoom input after which the view counts as settled and is /// re-rendered at full resolution. const INTERACT_SETTLE: f64 = 0.12; /// Palette names; index maps to `palette_id` in the shader. const PALETTE_NAMES: &[&str] = &["Amber", "Rainbow", "Ember", "Lime", "Grayscale"]; /// Shadow palette names; index maps to `palette_id` in the shader. const SHADOW_PALETTE_NAMES: &[&str] = &["Grayscale", "Red & Blue", "Custom lights"]; /// Buddhabrot tonemap style names; index maps to `BuddhabrotUniforms::palette`. const BUDDHA_PALETTE_NAMES: &[&str] = &["Nebula", "Yellow", "Grayscale"]; #[derive(Clone, Copy, PartialEq, Eq)] pub enum FractalMode { Mandelbrot, Julia, } /// Selectable fractal formulas, with UI labels. const KINDS: &[(FractalKind, &str)] = &[ (FractalKind::Mandelbrot, "Mandelbrot"), (FractalKind::BurningShip, "Burning Ship"), (FractalKind::Tricorn, "Tricorn"), (FractalKind::Multibrot, "Multibrot"), (FractalKind::Celtic, "Celtic"), (FractalKind::Perpendicular, "Perpendicular"), (FractalKind::Buffalo, "Buffalo"), (FractalKind::Phoenix, "Phoenix"), (FractalKind::Lambda, "Lambda"), ]; /// UI label for a fractal kind. fn kind_label(kind: FractalKind) -> &'static str { KINDS .iter() .find(|(k, _)| *k == kind) .map(|(_, name)| *name) .unwrap_or("Mandelbrot") } type JuliaPreset = (&'static str, f64, f64, u32, Option<(f64, f64)>); /// Nice-looking Julia constants offered as presets. const JULIA_PRESETS: [&[JuliaPreset]; FractalKind::Lambda as usize + 1] = [ &[ ("dendrite", -0.8, 0.156, 400, None), ("rabbit", -0.123, 0.745, 400, None), ("spiral", -0.4, 0.6, 400, None), ("san marco", -0.75, 0.0, 400, None), ("siegel", -0.391, -0.587, 400, None), ], &[("eyes", -0.241, 0.157, 1000, None)], &[("pools", -0.50381, 0.07750, 400, None)], &[], &[], &[], &[], &[ ("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 = ( &'static str, &'static str, &'static str, f64, u32, Option<(f64, f64)>, ); /// 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::Lambda as usize + 1] = [ &[ ( "Seahorse Valley", "-0.743643887037158704752191506114774", "0.131825904205311970493132056385139", 4.0e-6, 1500, None, ), ( "Elephant Valley", "0.2549870375144766", "0.0005679790528465", 6.0e-5, 2000, None, ), ("Scepter Valley", "-1.36012", "0.0406", 2.5e-4, 2000, None), ("Starburst", "-1.62917", "0.0203968", 1.5e-3, 1500, None), ( "Deep Spiral", "-0.7436438870371587", "0.1318259042053", 8.0e-8, 2000, None, ), ], &[( "Ship", "-1.76485017213465", "-0.0317013204392752", 5.3e-2, 1500, None, )], &[], &[], &[], &[], &[], &[( "Galaxy", "-0.2165696026100408", "-0.0676553191878954", 5e-1, 1000, Some((-0.9, -0.49)), )], &[], ]; /// Parameters a reference orbit was (or will be) computed for. Used to decide /// when the current reference is stale enough to recompute. struct RequestKey { center_re: Big, center_im: Big, half_height: f64, julia: bool, julia_c: (f64, f64), phoenix_p: (f64, f64), lambda_l: (f64, f64), iter: u32, kind: FractalKind, power: u32, } /// Shared state for an in-progress PNG export. The worker (a background thread /// on native, an async task on web) writes `fraction`/`phase` as it goes and /// sets `result` once when finished; the UI reads it each frame to draw a /// progress bar and, on completion, to report the outcome. struct ExportShared { fraction: f32, phase: &'static str, result: Option>, } /// Time-based animation of a few view/coloring parameters. Each toggle drives /// continuous repaints while on; orbit-affecting ones (Julia c, Phoenix p, zoom) /// recompute the reference each frame and render the cheap low-res pass so they /// stay smooth. #[derive(Clone)] struct AnimState { /// Cycle the palette offset (colours flow through the fractal). color: bool, /// Palette cycles per second. color_speed: f32, /// Drift the Julia constant `c` around a circle to morph the Julia set. julia: bool, /// Revolutions per second. julia_speed: f32, /// Circle radius in the c-plane. julia_radius: f64, /// Circle center, captured when the animation is enabled. julia_base: (f64, f64), julia_angle: f64, /// Drift the Phoenix distortion `p` around a circle. phoenix: bool, phoenix_speed: f32, phoenix_radius: f64, 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. zoom_speed: f32, } impl Default for AnimState { fn default() -> Self { Self { color: false, color_speed: 0.15, julia: false, julia_speed: 0.05, julia_radius: 0.08, julia_base: (0.0, 0.0), julia_angle: 0.0, phoenix: false, phoenix_speed: 0.05, 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, } } } /// Top-level egui application. pub struct FractalApp { view: ViewState, mode: FractalMode, /// Iteration formula. kind: FractalKind, /// Exponent for the Multibrot kind. power: u32, 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 /// unset. Turned off when a preset or share link supplies an explicit count. auto_iterations: bool, color_scale: f32, color_offset: f32, palette: u32, shadow_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, // Use shadow coloring shadow: bool, /// List of enabled lights in the world lights: Vec, /// Render as a Buddhabrot (Monte-Carlo orbit-density histogram) instead of /// the ordinary escape-time set. Plain f32 view — no deep zoom, no /// perturbation/reference-orbit machinery (see `fractal::buddhabrot`). buddhabrot: bool, /// Nested escape-iteration caps for the R/G/B histogram channels /// (Nebulabrot coloring); kept ordered r <= g <= b by the UI. buddha_r_cap: u32, buddha_g_cap: u32, buddha_b_cap: u32, /// Tonemap brightness multiplier. buddha_exposure: f32, /// Tonemap colour style (index into `BUDDHA_PALETTE_NAMES`). buddha_palette: u32, /// Keep dispatching new sample batches every frame (progressive /// accumulation). Turning it off freezes the current histogram. buddha_accumulate: bool, /// Whether the controls side panel is expanded. Collapsible so the fractal /// can take (nearly) the whole screen — important on a phone. controls_open: bool, /// Whether the app is in fullscreen (browser Fullscreen API on web, viewport /// fullscreen on native). Kept in sync with the real state each frame. fullscreen: bool, /// Time-based animation of colours / Julia c / Phoenix p / zoom. anim: AnimState, /// Smoothed frames-per-second, recomputed each ~0.5 s window. Only advances /// while the app is actually repainting (interaction / animation / export); /// idle frames aren't forced, so a frozen value means "nothing to render". fps: f32, /// Frames counted in the current FPS window, and its start time (`i.time`). fps_frames: u32, fps_window_start: f64, /// Reference orbit (`Z_n` as f32 pairs) for the current view. reference: Arc>, /// Bumped whenever `reference` is replaced, so the GPU re-uploads it. generation: u64, /// Center + zoom the current `reference` was computed at (may differ /// slightly from the live view; the shader compensates via `dc_offset`). ref_center_re: Big, ref_center_im: Big, ref_half_height: f64, /// Parameters of the most recent reference request (drift baseline / dedupe). last_request: Option, #[cfg(not(target_arch = "wasm32"))] worker: crate::worker::RefWorker, /// A reference computation is in flight (native async worker). pending: bool, /// PNG export resolution multiplier over the on-screen size. export_scale: f32, /// Last on-screen fractal size in physical pixels (for export sizing). last_size_px: egui::Vec2, /// egui time (seconds) of the most recent pan/zoom. While recent (within /// `INTERACT_SETTLE`) the fractal renders downscaled for smooth interaction. last_interact_time: f64, /// Set when the user requests a PNG export (handled after the panels draw). export_requested: bool, /// Progress/handle for an in-flight PNG export, if any. export: Option>>, /// Output path for `--export` (native CLI only); falls back to a /// timestamped name when unset. export_path: Option, /// Short status line (saved path, "link copied", errors). status: Option, /// Editable text buffers for the center coordinates (decimal, full /// precision). Kept in sync with the live view except while the field is /// focused, so the user's in-progress typing is not clobbered by pan/zoom. center_re_edit: String, center_im_edit: String, /// Editable magnification (×). Its display is lossy, so `zoom_edited` guards /// applying it: without that, clicking in and out would round-trip the value /// through the display format and drift the zoom. zoom_edit: String, zoom_edited: bool, } /// Significant decimal digits to show for a center at the given precision (bits). fn sig_digits_for(bits: usize) -> usize { ((bits as f64) * std::f64::consts::LOG10_2).ceil() as usize + 3 } /// Format a magnification for the editable field (compact scientific). fn format_magnification(m: f64) -> String { format!("{m:.4e}") } /// Precision (bits) to parse a typed center at: at least what the current zoom /// needs, but enough to preserve every digit the user pasted, so a deep /// coordinate entered while zoomed out is not truncated. Capped like `view`. fn parse_bits_for(s: &str, min_bits: usize) -> usize { let digits = s.chars().filter(char::is_ascii_digit).count(); let from_input = (digits as f64 * std::f64::consts::LOG2_10).ceil() as usize + 16; min_bits.max(from_input).min(2048) } impl FractalApp { pub fn new(cc: &CreationContext<'_>) -> Self { let render_state = cc .wgpu_render_state .as_ref() .expect("eframe must run with the wgpu backend"); let renderer = FractalRenderer::new(&render_state.device, render_state.target_format); let buddhabrot_renderer = BuddhabrotRenderer::new(&render_state.device, render_state.target_format); { let mut guard = render_state.renderer.write(); guard.callback_resources.insert(renderer); guard.callback_resources.insert(buddhabrot_renderer); } let view = ViewState::default(); let ref_center_re = view.center_re.clone(); let ref_center_im = view.center_im.clone(); let ref_half_height = view.half_height; let sig = sig_digits_for(view.precision_bits()); let center_re_edit = big_to_decimal_str(&view.center_re, sig); let center_im_edit = big_to_decimal_str(&view.center_im, sig); let zoom_edit = format_magnification(view.magnification()); let mut app = Self { view, mode: FractalMode::Mandelbrot, kind: FractalKind::Mandelbrot, 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, color_offset: 0.0, palette: 0, shadow_palette: 0, antialias: false, de_coloring: false, shadow: false, lights: vec![Light::default()], buddhabrot: false, buddha_r_cap: 50, buddha_g_cap: 500, buddha_b_cap: 2000, buddha_exposure: 1.0, buddha_palette: 0, buddha_accumulate: true, controls_open: true, fullscreen: false, anim: AnimState::default(), fps: 0.0, fps_frames: 0, fps_window_start: 0.0, reference: Arc::new(Vec::new()), generation: 0, ref_center_re, ref_center_im, ref_half_height, last_request: None, #[cfg(not(target_arch = "wasm32"))] worker: crate::worker::RefWorker::spawn(), pending: false, export_scale: 2.0, last_size_px: egui::vec2(1280.0, 720.0), last_interact_time: -1.0e9, export_requested: false, export: None, export_path: None, status: None, center_re_edit, center_im_edit, zoom_edit, zoom_edited: false, }; // On the web, restore a shared view from the URL fragment (#...). #[cfg(target_arch = "wasm32")] if let Some(frag) = web_location_hash() { if let Some(state) = ShareState::decode(&frag) { app.apply_share(&state); } } // Debug/testing hooks, driven by CLI flags. #[cfg(not(target_arch = "wasm32"))] { let cli = Cli::parse(); if let Some(k) = cli.kind { app.kind = k.into(); if let Some(p) = cli.power { app.power = p.clamp(2, 8); } app.view = Self::default_view_for(app.mode, app.kind); } if let Some(jc) = cli.julia { let p: Vec<&str> = jc.split(',').collect(); if let (Some(Ok(re)), Some(Ok(im))) = ( p.first().map(|s| s.trim().parse::()), p.get(1).map(|s| s.trim().parse::()), ) { app.mode = FractalMode::Julia; app.julia_c = (re, im); app.view = Self::default_view_for(FractalMode::Julia, app.kind); } } if let Some(frag) = cli.share && let Some(state) = ShareState::decode(&frag) { app.apply_share(&state); } if let Some(spec) = cli.view { app.apply_view_spec(&spec); } if cli.de { app.de_coloring = true; } if cli.buddhabrot { app.buddhabrot = true; } if let Some(p) = cli.buddha_palette { app.buddha_palette = p.min(BUDDHA_PALETTE_NAMES.len() as u32 - 1); } app.export_path = cli.export_path; if cli.export { app.export_requested = true; } } app } /// Apply a view spec "re,im,half_height[,iterations]" (re/im are decimal, /// parsed at full precision). Used by the native debug env var. #[allow(dead_code)] pub fn apply_view_spec(&mut self, spec: &str) -> bool { let parts: Vec<&str> = spec.split(',').collect(); if parts.len() < 3 { return false; } let Ok(half_height) = parts[2].trim().parse::() else { return false; }; if !(half_height > 0.0 && half_height.is_finite()) { return false; } let bits = precision_for(half_height); let (Some(re), Some(im)) = ( big_from_decimal_str(parts[0], bits), big_from_decimal_str(parts[1], bits), ) else { return false; }; self.view = ViewState::with_center(re, im, half_height); if let Some(it) = parts.get(3) && let Ok(v) = it.trim().parse::() { self.max_iterations = v.clamp(32, MAX_REF_POINTS as u32 - 1); } true } /// Jump to a preset Mandelbrot location: decimal center (parsed at the /// precision the zoom needs), half-height, and a fitting iteration count. fn go_to_place(&mut self, re: &str, im: &str, half_height: f64, iterations: u32) { let bits = precision_for(half_height); if let (Some(cre), Some(cim)) = ( big_from_decimal_str(re, bits), big_from_decimal_str(im, bits), ) { self.mode = FractalMode::Mandelbrot; self.view = ViewState::with_center(cre, cim, half_height); // Presets carry a hand-tuned count; don't let the auto-scaler clobber it. self.auto_iterations = false; self.max_iterations = iterations.clamp(32, MAX_REF_POINTS as u32 - 1); } } /// Iteration count scaled to the current zoom depth, used while /// `auto_iterations` is on. Grows roughly linearly with zoom decades so deep /// zooms keep enough iterations to stay sharp instead of banding. fn auto_iteration_count(&self) -> u32 { let decades = self.view.magnification().log10().max(0.0); let iters = 400.0 + 900.0 * decades; (iters.round() as u32).clamp(200, MAX_REF_POINTS as u32 - 1) } /// Snapshot the current view as a shareable state. fn share_state(&self) -> ShareState { let sig_digits = sig_digits_for(self.view.precision_bits()); ShareState { julia: matches!(self.mode, FractalMode::Julia), kind: self.kind, power: self.power, center_re: big_to_decimal_str(&self.view.center_re, sig_digits), center_im: big_to_decimal_str(&self.view.center_im, sig_digits), half_height: self.view.half_height, 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, shadow_palette: self.shadow_palette, } } /// Restore a shared state into this app. fn apply_share(&mut self, s: &ShareState) { self.mode = if s.julia { FractalMode::Julia } else { FractalMode::Mandelbrot }; self.kind = s.kind; 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; self.shadow_palette = (s.shadow_palette as usize).min(SHADOW_PALETTE_NAMES.len() - 1) as u32; // The link carries an explicit iteration count; honor it rather than // letting the auto-scaler immediately overwrite it. self.auto_iterations = false; self.max_iterations = s.iterations.clamp(32, MAX_REF_POINTS as u32 - 1); let bits = precision_for(s.half_height); if let (Some(re), Some(im)) = ( big_from_decimal_str(&s.center_re, bits), big_from_decimal_str(&s.center_im, bits), ) { self.view = ViewState::with_center(re, im, s.half_height); } } /// A full shareable URL. On web this is the page URL with a `#fragment`; on /// native (no page) it is just the fragment for pasting onto a deployment. fn share_url(&self) -> String { let fragment = self.share_state().encode(); #[cfg(target_arch = "wasm32")] { if let Some(w) = web_sys::window() { let loc = w.location(); let origin = loc.origin().unwrap_or_default(); let path = loc.pathname().unwrap_or_default(); return format!("{origin}{path}#{fragment}"); } } format!("#{fragment}") } /// Default view for a given set type and fractal kind. The Julia (dynamical) /// plane is centered on the origin for every kind; the parameter plane frames /// each kind's interesting region. fn default_view_for(mode: FractalMode, kind: FractalKind) -> ViewState { if mode == FractalMode::Julia { return ViewState::with_center(big_from_f64(0.0, 53), big_from_f64(0.0, 53), 1.5); } let (cr, ci, hh) = match kind { FractalKind::Mandelbrot => (-0.5, 0.0, 1.25), FractalKind::BurningShip => (-0.5, -0.5, 1.3), FractalKind::Tricorn => (-0.25, 0.0, 1.6), FractalKind::Multibrot => (0.0, 0.0, 1.5), FractalKind::Celtic => (-0.5, 0.0, 1.6), 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) } fn current_key(&self) -> RequestKey { RequestKey { center_re: self.view.center_re.clone(), center_im: self.view.center_im.clone(), half_height: self.view.half_height, 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, } } /// Distance (complex units) the live view center has drifted from `key`. fn drift_from(&self, key: &RequestKey) -> f64 { let dre = (&self.view.center_re - &key.center_re).to_f64().value(); let dim = (&self.view.center_im - &key.center_im).to_f64().value(); (dre * dre + dim * dim).sqrt() } /// 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. 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; }; 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) } /// Complex offset of the live view center from the reference center, in f32. fn dc_offset(&self) -> [f32; 2] { let dre = (&self.view.center_re - &self.ref_center_re) .to_f64() .value() as f32; let dim = (&self.view.center_im - &self.ref_center_im) .to_f64() .value() as f32; [dre, dim] } fn apply_reference(&mut self, points: Vec<[f32; 2]>, cre: Big, cim: Big, hh: f64) { self.reference = Arc::new(points); self.ref_center_re = cre; self.ref_center_im = cim; self.ref_half_height = hh; self.generation = self.generation.wrapping_add(1); } /// Recompute the reference orbit when needed. Native: dispatch to a worker /// thread and pick up completed results. Web: compute inline. fn ensure_reference(&mut self) { if self.should_request() { 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 { center_re: key.center_re.clone(), center_im: key.center_im.clone(), half_height: key.half_height, julia: key.julia, julia_c: key.julia_c, max_iter, precision, kind: key.kind, power: key.power, phoenix_p: key.phoenix_p, lambda_l: key.lambda_l, }); self.pending = true; } #[cfg(target_arch = "wasm32")] { let points = if key.julia { let jr = big_from_f64(key.julia_c.0, precision); let ji = big_from_f64(key.julia_c.1, precision); compute_reference( &key.center_re, &key.center_im, &jr, &ji, max_iter, precision, key.kind, key.power, key.phoenix_p, key.lambda_l, ) } else { compute_set_reference( &key.center_re, &key.center_im, max_iter, precision, key.kind, key.power, key.phoenix_p, key.lambda_l, ) }; self.apply_reference( points, key.center_re.clone(), key.center_im.clone(), key.half_height, ); } self.last_request = Some(key); } #[cfg(not(target_arch = "wasm32"))] if let Some(res) = self.worker.try_take_latest() { self.apply_reference(res.points, res.center_re, res.center_im, res.half_height); self.pending = false; } } fn make_uniforms(&self, aspect: f64) -> Uniforms { let (span_x, span_y) = self.view.span(aspect); Uniforms { span: [span_x as f32, span_y as f32], max_iter: self.max_iterations.min(MAX_REF_POINTS as u32 - 1), ref_len: self.reference.len() as u32, color_offset: self.color_offset, color_scale: self.color_scale, bailout_sq: BAILOUT_SQ, is_julia: matches!(self.mode, FractalMode::Julia) as u32, palette_id: self.palette, shadow_palette_id: self.shadow_palette, aa_level: if self.antialias { 2 } else { 1 }, 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 | self.shadow) as u32, shadow: self.shadow as u32, _pad: [0; _], } } /// Buddhabrot pass uniforms. Unlike `make_uniforms`, the view center is /// collapsed straight to f32 (no arbitrary-precision reference orbit) — /// Buddhabrot mode doesn't support deep zoom (see `fractal::buddhabrot`). fn make_buddhabrot_uniforms(&self, aspect: f64) -> BuddhabrotUniforms { let center = [ self.view.center_re.to_f64().value() as f32, self.view.center_im.to_f64().value() as f32, ]; BuddhabrotUniforms { center, half_height: self.view.half_height as f32, aspect: aspect as f32, 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], bailout_sq: BAILOUT_SQ, kind: self.kind as u32, power: self.power, r_cap: self.buddha_r_cap, g_cap: self.buddha_g_cap, b_cap: self.buddha_b_cap, seed: 0, // set by the callback's own dispatch counter samples_this_dispatch: 0, // set by the callback exposure: self.buddha_exposure, width: 0, // set by the callback from size_px height: 0, // set by the callback from size_px total_samples: 0.0, // tracked by the renderer across frames palette: self.buddha_palette, _pad: [0; 3], } } /// Render the current view to a PNG at `export_scale` × the on-screen size, /// then save it (native: file in cwd; web: browser download). Runs off the /// UI thread so a progress bar can animate; progress lands in `self.export`. fn do_export(&mut self, frame: &mut eframe::Frame) { if self.export.is_some() { return; // one export at a time } if self.buddhabrot { self.status = Some("PNG export isn't available in Buddhabrot mode yet".into()); return; } let Some(rs) = frame.wgpu_render_state() else { self.status = Some("export unavailable (no wgpu backend)".into()); return; }; if self.reference.is_empty() { self.status = Some("still computing reference…".into()); self.export_requested = true; // retry once the reference is ready return; } let scale = self.export_scale.max(1.0); let w = ((self.last_size_px.x * scale).round() as u32).clamp(16, MAX_EXPORT_DIM); let h = ((self.last_size_px.y * scale).round() as u32).clamp(16, MAX_EXPORT_DIM); let uniforms = self.make_uniforms(w as f64 / h as f64); let device = rs.device.clone(); let queue = rs.queue.clone(); let (pipeline, bind_group_layout, format) = { let guard = rs.renderer.read(); let Some(renderer) = guard.callback_resources.get::() else { self.status = Some("export unavailable".into()); return; }; renderer.export_handles() }; let reference = Arc::clone(&self.reference); let shared = Arc::new(Mutex::new(ExportShared { fraction: 0.0, phase: "Rendering", result: None, })); self.status = None; self.export = Some(Arc::clone(&shared)); // Progress budget: rendering fills [0, RENDER_END], encoding the rest. const RENDER_END: f32 = 0.6; #[cfg(not(target_arch = "wasm32"))] { let name = self .export_path .clone() .unwrap_or_else(|| format!("fractal-{}.png", unix_timestamp())); std::thread::spawn(move || { let er = ExportRender::new( &device, &queue, pipeline, &bind_group_layout, format, w, h, uniforms, reference.as_slice(), ); // Render the image tile by tile, waiting for each so progress // reflects real GPU work. for t in 0..er.tiles { er.render_tile(&device, &queue, t); let _ = device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None, }); let done = (t + 1) as f32 / er.tiles as f32; set_progress(&shared, "Rendering", RENDER_END * done); } er.copy_to_readback(&device, &queue); // Wait for the copy, then read the mapped bytes. let (tx, rx) = std::sync::mpsc::channel(); er.readback() .slice(..) .map_async(wgpu::MapMode::Read, move |res| { let _ = tx.send(res); }); let _ = device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None, }); let _ = rx.recv(); set_progress(&shared, "Encoding", RENDER_END); let png = { let data = er .readback() .slice(..) .get_mapped_range() .expect("map readback buffer"); let sh = Arc::clone(&shared); crate::fractal::encode_png_with_progress( &data, er.width, er.height, er.padded_bpr, er.swap_rb, |f| set_progress(&sh, "Encoding", RENDER_END + (0.97 - RENDER_END) * f), ) }; er.readback().unmap(); set_progress(&shared, "Saving", 0.98); let result = std::fs::write(&name, &png) .map(|_| format!("saved {name} ({w}×{h})")) .map_err(|e| format!("save failed: {e}")); finish_export(&shared, result); }); } #[cfg(target_arch = "wasm32")] { wasm_bindgen_futures::spawn_local(async move { let er = ExportRender::new( &device, &queue, pipeline, &bind_group_layout, format, w, h, uniforms, reference.as_slice(), ); // Render tile by tile, awaiting each submission so the browser // executes it and the UI can repaint between tiles. for t in 0..er.tiles { er.render_tile(&device, &queue, t); let (tx, rx) = futures_channel::oneshot::channel(); queue.on_submitted_work_done(move || { let _ = tx.send(()); }); let _ = rx.await; let done = (t + 1) as f32 / er.tiles as f32; set_progress(&shared, "Rendering", RENDER_END * done); } er.copy_to_readback(&device, &queue); let (tx, rx) = futures_channel::oneshot::channel(); er.readback() .slice(..) .map_async(wgpu::MapMode::Read, move |res| { let _ = tx.send(res); }); let _ = rx.await; set_progress(&shared, "Encoding", RENDER_END); let png = { let data = er .readback() .slice(..) .get_mapped_range() .expect("map readback buffer"); let sh = Arc::clone(&shared); crate::fractal::encode_png_with_progress( &data, er.width, er.height, er.padded_bpr, er.swap_rb, |f| set_progress(&sh, "Encoding", RENDER_END + (0.97 - RENDER_END) * f), ) }; er.readback().unmap(); set_progress(&shared, "Saving", 0.98); web_download_png(&png, "fractal.png"); finish_export(&shared, Ok(format!("downloaded {w}×{h}"))); }); } } /// Pick up a finished export (setting the status line) and keep repainting /// while one is in flight so its progress bar animates. fn poll_export(&mut self, ctx: &egui::Context) { if let Some(shared) = &self.export { let done = shared.lock().unwrap().result.take(); match done { Some(Ok(msg)) => { self.status = Some(msg); self.export = None; } Some(Err(e)) => { self.status = Some(e); self.export = None; } None => ctx.request_repaint(), } } } /// Floating top-left overlay with the panel toggle and fullscreen toggle. /// Always on top of the fractal, so both stay reachable when the controls /// panel is collapsed (the common case on a phone). fn overlay_buttons(&mut self, ui: &mut egui::Ui) { egui::Area::new(egui::Id::new("overlay_buttons")) .anchor(egui::Align2::LEFT_TOP, egui::vec2(8.0, 8.0)) .show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()) .shadow(egui::Shadow::NONE) .show(ui, |ui| { ui.horizontal(|ui| { let panel_label = if self.controls_open { "Hide" } else { "Menu" }; if ui .button(panel_label) .on_hover_text("Show/hide the controls panel") .clicked() { self.controls_open = !self.controls_open; } let fs_label = if self.fullscreen { "Windowed" } else { "Fullscreen" }; if ui .button(fs_label) .on_hover_text("Toggle fullscreen") .clicked() { self.fullscreen = !self.fullscreen; self.apply_fullscreen(ui.ctx()); } // FPS readout. Monospace + fixed width so the number // changing doesn't jitter the button row. ui.add( egui::Label::new( egui::RichText::new(format!("{:>3.0} FPS", self.fps)) .monospace(), ) .selectable(false), ) .on_hover_text( "Frames per second while rendering (interaction, \ animation, export). Frozen when idle.", ); }); }); }); } /// Push the desired fullscreen state to the platform. #[cfg(not(target_arch = "wasm32"))] fn apply_fullscreen(&mut self, ctx: &egui::Context) { ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(self.fullscreen)); } /// Push the desired fullscreen state to the browser. `request_fullscreen` /// must run inside a user gesture; the button click provides the transient /// activation that carries into this frame. #[cfg(target_arch = "wasm32")] fn apply_fullscreen(&mut self, _ctx: &egui::Context) { let Some(doc) = web_sys::window().and_then(|w| w.document()) else { return; }; if self.fullscreen { if let Some(el) = doc.document_element() { let _ = el.request_fullscreen(); } } else { doc.exit_fullscreen(); } } /// Refresh `self.fullscreen` from the real platform state, so the label is /// correct even when fullscreen is left by Esc/F11 or the browser UI. #[cfg(not(target_arch = "wasm32"))] fn sync_fullscreen(&mut self, ctx: &egui::Context) { if let Some(fs) = ctx.input(|i| i.viewport().fullscreen) { self.fullscreen = fs; } } #[cfg(target_arch = "wasm32")] fn sync_fullscreen(&mut self, _ctx: &egui::Context) { if let Some(doc) = web_sys::window().and_then(|w| w.document()) { self.fullscreen = doc.fullscreen_element().is_some(); } } /// Recompute the smoothed FPS. Counts frames over a ~0.5 s wall-clock window /// (using egui's monotonic `i.time`, which works on native and web) and /// divides once the window closes, so the readout is steady rather than /// jittering every frame. Only advances when egui repaints — i.e. while the /// app is doing work — so an idle app shows its last measured rate. fn update_fps(&mut self, ui: &egui::Ui) { let now = ui.input(|i| i.time); // Reset the window if time went backwards or hasn't started yet. if self.fps_window_start <= 0.0 || now < self.fps_window_start { self.fps_window_start = now; self.fps_frames = 0; } self.fps_frames += 1; let elapsed = now - self.fps_window_start; if elapsed >= 0.5 { self.fps = (self.fps_frames as f64 / elapsed) as f32; self.fps_frames = 0; self.fps_window_start = now; } } /// Advance any enabled animations by the frame's elapsed time, and request a /// 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; 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; let lambda_on = self.anim.lambda && self.kind == FractalKind::Lambda; if !(self.anim.color || self.anim.zoom || julia_on || phoenix_on || lambda_on) { return; } // Clamp dt so a stall (tab hidden, first frame) can't jump the animation. let dt = ui.input(|i| i.stable_dt as f64).clamp(0.0, 0.1); if self.anim.color { self.color_offset = (self.color_offset + self.anim.color_speed * dt as f32).rem_euclid(1.0); } if julia_on { self.anim.julia_angle += std::f64::consts::TAU * self.anim.julia_speed as f64 * dt; let (s, c) = self.anim.julia_angle.sin_cos(); self.julia_c = ( self.anim.julia_base.0 + self.anim.julia_radius * c, self.anim.julia_base.1 + self.anim.julia_radius * s, ); } if phoenix_on { self.anim.phoenix_angle += std::f64::consts::TAU * self.anim.phoenix_speed as f64 * dt; let (s, c) = self.anim.phoenix_angle.sin_cos(); self.phoenix_p = ( self.anim.phoenix_base.0 + self.anim.phoenix_radius * c, 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; let factor = (-(self.anim.zoom_speed as f64) * dt).exp(); let target = (self.view.half_height * factor).clamp(min_hh, max_hh); let f = target / self.view.half_height; if (f - 1.0).abs() > 1.0e-9 { self.view .zoom_at_pixel(0.0, 0.0, self.last_size_px.y.max(1.0) as f64, f); } } ui.ctx().request_repaint(); } fn controls_ui(&mut self, ui: &mut egui::Ui) { ui.heading("Fractal Explorer"); ui.separator(); // Fractal formula. Switching kinds jumps to a sensible default view, // since interesting regions differ between fractals. let prev_kind = self.kind; egui::ComboBox::from_label("fractal") .selected_text(kind_label(self.kind)) .show_ui(ui, |ui| { for &(kind, name) in KINDS { ui.selectable_value(&mut self.kind, kind, name); } }); if self.kind == FractalKind::Multibrot { ui.add(egui::Slider::new(&mut self.power, 2..=8).text("power")); } if self.kind == FractalKind::Phoenix { ui.horizontal(|ui| { ui.label("p ="); ui.add( egui::DragValue::new(&mut self.phoenix_p.0) .speed(0.001) .range(-2.0..=2.0), ); ui.add( egui::DragValue::new(&mut self.phoenix_p.1) .speed(0.001) .range(-2.0..=2.0), ); 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); } ui.checkbox(&mut self.buddhabrot, "Buddhabrot") .on_hover_text( "Monte-Carlo density of escaping orbits instead of the ordinary \ escape-time set. Plain f32 view (no deep zoom); the image \ progressively sharpens while the view stays still.", ); if self.buddhabrot { self.buddhabrot_ui(ui); ui.separator(); if ui.button("Reset view").clicked() { self.view = Self::default_view_for(self.mode, self.kind); } ui.add_space(8.0); ui.small("Drag to pan · scroll to zoom toward the cursor"); return; } ui.horizontal(|ui| { ui.radio_value(&mut self.mode, FractalMode::Mandelbrot, "Set"); ui.radio_value(&mut self.mode, FractalMode::Julia, "Julia"); }); if self.mode == FractalMode::Julia && self.kind != FractalKind::Lambda { ui.horizontal(|ui| { ui.label("c ="); ui.add( egui::DragValue::new(&mut self.julia_c.0) .speed(0.001) .range(-2.0..=2.0), ); ui.add( egui::DragValue::new(&mut self.julia_c.1) .speed(0.001) .range(-2.0..=2.0), ); ui.label("i"); }); if !JULIA_PRESETS[self.kind as usize].is_empty() { ui.horizontal_wrapped(|ui| { for &(name, re, im, iterations, phoenix) in JULIA_PRESETS[self.kind as usize] { if ui.small_button(name).clicked() { self.julia_c = (re, im); self.max_iterations = iterations.clamp(32, MAX_REF_POINTS as u32 - 1); if let Some(phoenix) = phoenix { self.phoenix_p = phoenix; } } } }); } } if self.mode == FractalMode::Mandelbrot && !SET_PRESETS[self.kind as usize].is_empty() { ui.label("places:"); ui.horizontal_wrapped(|ui| { for &(name, re, im, half_height, iter, phoenix) in SET_PRESETS[self.kind as usize] { if ui.small_button(name).clicked() { self.go_to_place(re, im, half_height, iter); if let Some(phoenix) = phoenix { self.phoenix_p = phoenix; } } } }); } ui.separator(); ui.checkbox(&mut self.auto_iterations, "Auto iterations") .on_hover_text("Scale the iteration count with zoom depth so deep zooms stay sharp."); if self.auto_iterations { ui.label(format!("iterations: {} (auto)", self.max_iterations)); } else { ui.add( egui::Slider::new(&mut self.max_iterations, 32..=100_000) .text("iterations") .logarithmic(true), ); } ui.add( egui::Slider::new(&mut self.color_scale, 0.01..=1.0) .text("color scale") .logarithmic(true), ); ui.add(egui::Slider::new(&mut self.color_offset, 0.0..=1.0).text("color offset")); ui.checkbox(&mut self.shadow, "Shadow"); if !self.shadow { egui::ComboBox::from_label("palette") .selected_text(PALETTE_NAMES[self.palette as usize]) .show_ui(ui, |ui| { for (i, name) in PALETTE_NAMES.iter().enumerate() { ui.selectable_value(&mut self.palette, i as u32, *name); } }); } else { egui::ComboBox::from_label("palette") .selected_text(SHADOW_PALETTE_NAMES[self.shadow_palette as usize]) .show_ui(ui, |ui| { for (i, name) in SHADOW_PALETTE_NAMES.iter().enumerate() { ui.selectable_value(&mut self.shadow_palette, i as u32, *name); } }); } if self.shadow && self.shadow_palette as usize == SHADOW_PALETTE_NAMES.len() - 1 { ui.horizontal(|ui| { ui.label("lights:"); if ui.button("+").clicked() { self.lights.push(Light::default()); } }); egui::Grid::new("lights") .striped(true) .num_columns(1) .show(ui, |ui| { self.lights.retain_mut(|light| { let delete = !light.widget(ui); ui.end_row(); delete }); }); } ui.separator(); ui.checkbox(&mut self.antialias, "Antialiasing (2×2)") .on_hover_text("Supersample each pixel for smoother edges (~4× slower)."); if !self.shadow { 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 the holomorphic kinds \ (Mandelbrot/Multibrot/Phoenix), approximate for the abs-based kinds \ (Burning Ship/Tricorn/Celtic/Perpendicular/Buffalo).", ); } ui.collapsing("Animation", |ui| { if !self.shadow { ui.checkbox(&mut self.anim.color, "Cycle colours") .on_hover_text("Scroll the palette offset over time."); if self.anim.color { ui.add( egui::Slider::new(&mut self.anim.color_speed, 0.01..=2.0) .text("cycles/s") .logarithmic(true), ); } } ui.checkbox(&mut self.anim.zoom, "Auto-zoom") .on_hover_text("Continuously zoom toward the current center."); if self.anim.zoom { ui.add( egui::Slider::new(&mut self.anim.zoom_speed, -2.0..=2.0).text("rate (+ = in)"), ); } // Julia c only affects Julia mode; Phoenix p only the Phoenix kind. if self.mode == FractalMode::Julia { if ui.checkbox(&mut self.anim.julia, "Morph c").changed() && self.anim.julia { self.anim.julia_base = self.julia_c; // orbit around the current c self.anim.julia_angle = 0.0; } if self.anim.julia { ui.add( egui::Slider::new(&mut self.anim.julia_speed, 0.005..=0.5) .text("c rev/s") .logarithmic(true), ); ui.add( egui::Slider::new(&mut self.anim.julia_radius, 0.005..=0.5) .text("c radius") .logarithmic(true), ); } } if self.kind == FractalKind::Phoenix { if ui.checkbox(&mut self.anim.phoenix, "Morph p").changed() && self.anim.phoenix { self.anim.phoenix_base = self.phoenix_p; self.anim.phoenix_angle = 0.0; } if self.anim.phoenix { ui.add( egui::Slider::new(&mut self.anim.phoenix_speed, 0.005..=0.5) .text("p rev/s") .logarithmic(true), ); ui.add( egui::Slider::new(&mut self.anim.phoenix_radius, 0.005..=0.5) .text("p radius") .logarithmic(true), ); } } 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(); // Editable center coordinates. Shown at full precision; parsed // losslessly on commit (Enter or focus loss). While a field is focused // we leave the user's text alone; otherwise we refresh it from the live // view, which panning and zooming keep changing. let bits = self.view.precision_bits(); let sig = sig_digits_for(bits); ui.label("center re:"); let re_resp = ui.add( egui::TextEdit::singleline(&mut self.center_re_edit) .desired_width(f32::INFINITY) .font(egui::TextStyle::Monospace), ); if re_resp.lost_focus() && let Some(v) = big_from_decimal_str( &self.center_re_edit, parse_bits_for(&self.center_re_edit, bits), ) { self.view.center_re = v; self.view.sync_precision(); } if !re_resp.has_focus() { self.center_re_edit = big_to_decimal_str(&self.view.center_re, sig); } ui.label("center im:"); let im_resp = ui.add( egui::TextEdit::singleline(&mut self.center_im_edit) .desired_width(f32::INFINITY) .font(egui::TextStyle::Monospace), ); if im_resp.lost_focus() && let Some(v) = big_from_decimal_str( &self.center_im_edit, parse_bits_for(&self.center_im_edit, bits), ) { self.view.center_im = v; self.view.sync_precision(); } if !im_resp.has_focus() { self.center_im_edit = big_to_decimal_str(&self.view.center_im, sig); } ui.label("magnification (×):"); let zoom_resp = ui.add( egui::TextEdit::singleline(&mut self.zoom_edit) .desired_width(f32::INFINITY) .font(egui::TextStyle::Monospace), ); if zoom_resp.changed() { self.zoom_edited = true; } if zoom_resp.lost_focus() { if self.zoom_edited && let Ok(m) = self.zoom_edit.trim().parse::() { let hh = DEFAULT_HALF_HEIGHT / m; if m > 0.0 && hh > 0.0 && hh.is_finite() { self.view.half_height = hh; self.view.sync_precision(); } } self.zoom_edited = false; } if !zoom_resp.has_focus() { self.zoom_edit = format_magnification(self.view.magnification()); } ui.label(format!("reference: {} pts", self.reference.len())); ui.label(format!("precision: {} bits", self.view.precision_bits())); if self.pending { ui.colored_label(egui::Color32::LIGHT_YELLOW, "computing reference…"); } ui.separator(); let exporting = self.export.is_some(); ui.horizontal(|ui| { if ui.button("Copy link").clicked() { let url = self.share_url(); ui.ctx().copy_text(url); self.status = Some("link copied".into()); } if ui .add_enabled(!exporting, egui::Button::new("Export PNG")) .clicked() { self.export_requested = true; } }); ui.horizontal(|ui| { ui.label("export scale"); ui.add( egui::DragValue::new(&mut self.export_scale) .range(1.0..=16.0) .speed(0.5), ); ui.label(format!( "→ {}×{}", (self.last_size_px.x * self.export_scale) as u32, (self.last_size_px.y * self.export_scale) as u32, )); }); if let Some(shared) = &self.export { let (fraction, phase) = { let s = shared.lock().unwrap(); (s.fraction, s.phase) }; ui.add( egui::ProgressBar::new(fraction) .animate(true) .text(format!("{phase} {:.0}%", fraction * 100.0)), ); } else if let Some(status) = &self.status { ui.small(status); } ui.separator(); if ui.button("Reset view").clicked() { self.view = Self::default_view_for(self.mode, self.kind); } ui.add_space(8.0); ui.small("Drag to pan · scroll to zoom toward the cursor"); } /// Controls for Buddhabrot mode: nested iteration caps (Nebulabrot R/G/B /// coloring), exposure, and the progressive-accumulation toggle. fn buddhabrot_ui(&mut self, ui: &mut egui::Ui) { ui.separator(); ui.add( egui::Slider::new(&mut self.buddha_r_cap, 5..=5_000) .text("red cap") .logarithmic(true), ); ui.add( egui::Slider::new(&mut self.buddha_g_cap, 5..=20_000) .text("green cap") .logarithmic(true), ); ui.add( egui::Slider::new(&mut self.buddha_b_cap, 5..=50_000) .text("blue cap") .logarithmic(true), ); ui.add( egui::Slider::new(&mut self.buddha_exposure, 0.02..=50.0) .text("exposure") .logarithmic(true), ); egui::ComboBox::from_label("colors") .selected_text(BUDDHA_PALETTE_NAMES[self.buddha_palette as usize]) .show_ui(ui, |ui| { for (i, name) in BUDDHA_PALETTE_NAMES.iter().enumerate() { ui.selectable_value(&mut self.buddha_palette, i as u32, *name); } }); ui.checkbox(&mut self.buddha_accumulate, "Keep sampling") .on_hover_text("Dispatch a fresh batch of random samples every frame."); if self.view.magnification() > 1.0e5 { ui.colored_label( egui::Color32::LIGHT_YELLOW, "deep zoom isn't supported here (f32 precision only)", ); } ui.small("PNG export isn't available in Buddhabrot mode yet."); } fn fractal_ui(&mut self, ui: &mut egui::Ui) { let size = ui.available_size(); let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click_and_drag()); if rect.width() < 1.0 || rect.height() < 1.0 { return; } let height_px = rect.height() as f64; let aspect = (rect.width() / rect.height()) as f64; self.last_size_px = rect.size(); // Tracks whether the view actually moved this frame, so progressive // rendering can drop to a cheap low-res pass only while interacting. let mut interacted = false; // Advance time-based animations (colours / Julia c / Phoenix p / zoom). // These render at full resolution/AA — only real pan/zoom drops to the // cheap low-res pass, so `interacted` is left untouched here. self.tick_animations(ui); // Touch: pinch to zoom (toward the gesture center) and two-finger pan. // Takes precedence over single-finger drag while two fingers are down. let multi_touch = ui.input(|i| i.multi_touch()); if let Some(mt) = multi_touch { let t = mt.translation_delta; if t.x != 0.0 || t.y != 0.0 { self.view.pan_pixels(t.x as f64, t.y as f64, height_px); interacted = true; } if mt.zoom_delta != 1.0 { let off = mt.center_pos - rect.center(); // zoom_delta > 1 = fingers spreading = zoom in (smaller span). let factor = 1.0 / mt.zoom_delta as f64; self.view .zoom_at_pixel(off.x as f64, off.y as f64, height_px, factor); interacted = true; } ui.ctx().request_repaint(); } else if response.dragged() { // Single-finger / mouse drag pans. let d = response.drag_delta(); if d.x != 0.0 || d.y != 0.0 { self.view.pan_pixels(d.x as f64, d.y as f64, height_px); interacted = true; } } // Mouse wheel / trackpad: zoom toward the cursor. let (scroll_y, hover) = ui.input(|i| (i.smooth_scroll_delta.y, i.pointer.hover_pos())); if scroll_y != 0.0 && let Some(pos) = hover && rect.contains(pos) { let off = pos - rect.center(); let factor = (-scroll_y as f64 * 0.0015).exp(); self.view .zoom_at_pixel(off.x as f64, off.y as f64, height_px, factor); interacted = true; ui.ctx().request_repaint(); } if self.buddhabrot { // No reference orbit / perturbation machinery: iterate directly in // f32 from the live view. Progressive accumulation means this // needs its own continuous repaint, separate from the escape-time // interaction-driven one above. let ppp = ui.ctx().pixels_per_point(); let size_px = [ ((rect.width() * ppp).round() as u32).max(1), ((rect.height() * ppp).round() as u32).max(1), ]; let uniforms = self.make_buddhabrot_uniforms(aspect); ui.painter().add(egui_wgpu::Callback::new_paint_callback( rect, BuddhabrotCallback { uniforms, accumulate: self.buddha_accumulate, size_px, }, )); if self.buddha_accumulate { ui.ctx().request_repaint(); } return; } // Keep the iteration count matched to the zoom depth while auto is on. if self.auto_iterations { self.max_iterations = self.auto_iteration_count(); } self.ensure_reference(); // Poll the worker roughly every 30 ms while a reference is computing, // instead of spinning a full-speed repaint. Once ready, changed inputs // (or the initial draw) drive repaints on their own. let poll = std::time::Duration::from_millis(30); if self.reference.is_empty() { // Nothing to draw until the first reference orbit is ready. ui.ctx().request_repaint_after(poll); return; } if self.pending { ui.ctx().request_repaint_after(poll); } // Progressive rendering: while the user is actively panning/zooming (an // interaction within the last `INTERACT_SETTLE` seconds), render at a // fraction of the resolution with AA off so each frame is cheap, then let // it snap to full resolution once input settles. `i.time` is monotonic on // both native and web (avoids `Instant`, which isn't available on wasm). let now = ui.input(|i| i.time); if interacted { self.last_interact_time = now; } let interacting = now - self.last_interact_time < INTERACT_SETTLE; if interacting { // Ensure a frame fires once the settle window elapses, so the view // is re-rendered at full resolution even if no further input arrives. ui.ctx() .request_repaint_after(std::time::Duration::from_secs_f64(INTERACT_SETTLE)); } // Cache-texture resolution: the widget size in physical pixels, divided // down while interacting (the linear blit upsamples it to the widget). let ppp = ui.ctx().pixels_per_point(); let downscale = if interacting { INTERACT_DOWNSCALE } else { 1 }; let size_px = [ (((rect.width() * ppp).round() as u32) / downscale).max(1), (((rect.height() * ppp).round() as u32) / downscale).max(1), ]; let mut uniforms = self.make_uniforms(aspect); if interacting { uniforms.aa_level = 1; // supersampling is wasted on the low-res pass } ui.painter().add(egui_wgpu::Callback::new_paint_callback( rect, FractalCallback { uniforms, lights: self.lights.clone(), reference: Arc::clone(&self.reference), generation: self.generation, size_px, }, )); } } impl eframe::App for FractalApp { fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { self.poll_export(ui.ctx()); self.update_fps(ui); // Track the real fullscreen state (e.g. the user pressing Esc/F11 or the // browser leaving fullscreen) so the toggle button label stays correct. self.sync_fullscreen(ui.ctx()); // Cap the panel width so it never swallows a narrow (phone) screen, and // make it collapsible + scrollable so every parameter stays reachable. let panel_max = (ui.available_width() * 0.6).clamp(160.0, 340.0); let mut open = self.controls_open; egui::Panel::right("controls") .resizable(true) .default_size(panel_max.min(280.0)) .max_size(panel_max) .show_collapsible(ui, &mut open, |ui| { egui::ScrollArea::vertical() .auto_shrink([false, false]) .show(ui, |ui| self.controls_ui(ui)); }); self.controls_open = open; egui::CentralPanel::default() .frame(egui::Frame::NONE) .show(ui, |ui| self.fractal_ui(ui)); // Floating overlay, always reachable (even when the panel is collapsed): // toggle the panel and toggle fullscreen. Essential on a phone. self.overlay_buttons(ui); if std::mem::take(&mut self.export_requested) { self.do_export(frame); } } #[cfg(target_arch = "wasm32")] fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> { Some(&mut *self) } } /// Update an export's progress (phase label + fraction). fn set_progress(shared: &Arc>, phase: &'static str, fraction: f32) { let mut s = shared.lock().unwrap(); s.phase = phase; s.fraction = fraction; } /// Mark an export finished with its outcome. fn finish_export(shared: &Arc>, result: Result) { let mut s = shared.lock().unwrap(); s.phase = "Done"; s.fraction = 1.0; s.result = Some(result); } #[cfg(not(target_arch = "wasm32"))] fn unix_timestamp() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0) } #[cfg(target_arch = "wasm32")] fn web_location_hash() -> Option { let hash = web_sys::window()?.location().hash().ok()?; if hash.trim_start_matches('#').is_empty() { None } else { Some(hash) } } #[cfg(target_arch = "wasm32")] fn web_download_png(bytes: &[u8], filename: &str) { use wasm_bindgen::JsCast as _; let Some(document) = web_sys::window().and_then(|w| w.document()) else { return; }; let array = js_sys::Uint8Array::from(bytes); let parts = js_sys::Array::new(); parts.push(&array); let options = web_sys::BlobPropertyBag::new(); options.set_type("image/png"); let Ok(blob) = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &options) else { return; }; let Ok(url) = web_sys::Url::create_object_url_with_blob(&blob) else { return; }; if let Some(anchor) = document .create_element("a") .ok() .and_then(|el| el.dyn_into::().ok()) { anchor.set_href(&url); anchor.set_download(filename); anchor.click(); } let _ = web_sys::Url::revoke_object_url(&url); }