use std::sync::{Arc, Mutex}; use eframe::CreationContext; use eframe::egui_wgpu; #[cfg(target_arch = "wasm32")] use eframe::egui_wgpu::wgpu; use glam::Vec4; use glam::Vec4Swizzles; use crate::camera::Camera; #[cfg(not(target_arch = "wasm32"))] use crate::cli::Cli; use crate::fractal::{ BuddhabrotCallback, BuddhabrotRenderer, BuddhabrotUniforms, ExportRender, FractalCallback, FractalKind, FractalRenderer, MAX_REF_POINTS, ShareState, Uniforms, compute_reference, compute_set_reference, }; use crate::lights::{Light, gpu_lights}; use crate::view::parse_half_height_spec; use crate::view::parse_re_im_spec; use crate::view::{ Big, DEFAULT_HALF_HEIGHT, ViewState, big_from_decimal_str, big_from_f64, big_to_decimal_str, interpolate_view, parse_view_spec, 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 `shadow_palette_id` in the shader. /// Append new entries: share links store the index. const SHADOW_PALETTE_NAMES: &[&str] = &["Grayscale", "Red & Blue", "Custom lights", "Classic"]; /// Shadow palette lit by the user's `lights` list. const SHADOW_PALETTE_CUSTOM_LIGHTS: u32 = 2; /// Shadow palette that paints the classic escape-time palette, lit. const SHADOW_PALETTE_CLASSIC: u32 = 3; /// 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, Buddhabrot, } type JuliaPreset = (&'static str, f64, f64, u32, Option<(f64, f64)>); /// Nice-looking Julia constants offered as presets. const JULIA_PRESETS: [&[JuliaPreset]; FractalKind::ComplexMultibrot 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::ComplexMultibrot 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)), )], &[], &[], ]; /// A reference-orbit computation detached from the app (see /// `FractalApp::reference_job`), so it can run on any thread. #[cfg(not(target_arch = "wasm32"))] #[derive(Clone)] pub(crate) struct RefJob { key: RequestKey, precision: usize, /// The frame's iteration count (auto-iterations resolved). max_iterations: u32, } #[cfg(not(target_arch = "wasm32"))] impl RefJob { /// Iterate the reference orbit at full precision (the expensive part). pub(crate) fn compute(&self) -> Vec<[f32; 2]> { let key = &self.key; let precision = self.precision; let morph = key.morph.map(|(k, w)| (k, w as f64)); 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, key.iter, precision, key.kind, key.power, key.phoenix_p, key.lambda_l, key.complex_power, morph, ) } else { compute_set_reference( &key.center_re, &key.center_im, key.iter, precision, key.kind, key.power, key.phoenix_p, key.lambda_l, key.complex_power, morph, ) } } } /// Parameters a reference orbit was (or will be) computed for. Used to decide /// when the current reference is stale enough to recompute. #[derive(Clone)] 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, complex_power: (f64, f64), /// Kind-switch morph `(from_kind, weight)`, if one is running. morph: Option<(FractalKind, f32)>, } /// An in-progress kind-switch animation: the iteration formula is blended per /// step from `from` to the current kind, `(1 - w)·f_kind + w·f_from`, while the /// camera glides from `from_view` to the new kind's default view. struct KindMorph { from: FractalKind, /// Linear progress in [0, 1]; eased with smoothstep. progress: f32, from_view: ViewState, to_view: ViewState, /// Whether the morph still drives the camera. Cleared as soon as the user /// pans/zooms, so they can take over mid-morph. camera: bool, } impl KindMorph { /// Smoothstep-eased progress. fn eased(&self) -> f32 { let p = self.progress.clamp(0.0, 1.0); p * p * (3.0 - 2.0 * p) } /// Weight of the old kind's formula: 1 at the start, 0 at the end. fn weight(&self) -> f32 { 1.0 - self.eased() } } /// 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>, } /// Drift of a complex constant around a circle in its plane (Julia `c`, /// Phoenix `p`, Lambda `λ`). #[derive(Clone)] struct ConstOrbit { on: bool, /// Revolutions per second. speed: f32, /// Circle radius. radius: f64, /// Circle center, captured when the animation is enabled. base: (f64, f64), angle: f64, } impl Default for ConstOrbit { fn default() -> Self { Self { on: false, speed: 0.05, radius: 0.08, base: (0.0, 0.0), angle: 0.0, } } } impl ConstOrbit { /// Start orbiting around `current`. fn enable(&mut self, current: (f64, f64)) { self.base = current; self.angle = 0.0; } /// Advance by `dt` seconds and return the new value. fn step(&mut self, dt: f64) -> (f64, f64) { self.angle += std::f64::consts::TAU * self.speed as f64 * dt; let (s, c) = self.angle.sin_cos(); (self.base.0 + self.radius * c, self.base.1 + self.radius * s) } /// Checkbox + speed/radius sliders; (re)centers the orbit on `current` /// when switched on. fn ui(&mut self, ui: &mut egui::Ui, name: &str, current: (f64, f64)) { if ui.checkbox(&mut self.on, format!("Morph {name}")).changed() && self.on { self.enable(current); } if self.on { ui.add( egui::Slider::new(&mut self.speed, 0.005..=0.5) .text(format!("{name} rev/s")) .logarithmic(true), ); ui.add( egui::Slider::new(&mut self.radius, 0.005..=0.5) .text(format!("{name} radius")) .logarithmic(true), ); } } } /// Sine oscillation of one real parameter around a base value (used for each /// component of the Complex Multibrot exponent, independently). #[derive(Clone)] struct AxisOsc { on: bool, /// Oscillation center, captured when the animation is enabled. base: f64, amplitude: f64, /// Oscillations per second. speed: f32, phase: f64, } impl Default for AxisOsc { fn default() -> Self { Self { on: false, base: 0.0, amplitude: 0.5, speed: 0.05, phase: 0.0, } } } impl AxisOsc { fn enable(&mut self, current: f64) { self.base = current; self.phase = 0.0; } fn step(&mut self, dt: f64) -> f64 { self.phase += std::f64::consts::TAU * self.speed as f64 * dt; self.base + self.amplitude * self.phase.sin() } fn ui(&mut self, ui: &mut egui::Ui, name: &str, current: f64) { if ui .checkbox(&mut self.on, format!("Animate {name}")) .changed() && self.on { self.enable(current); } if self.on { ui.add( egui::Slider::new(&mut self.amplitude, 0.01..=4.0) .text(format!("{name} amplitude")) .logarithmic(true), ); ui.add( egui::Slider::new(&mut self.speed, 0.005..=0.5) .text(format!("{name} Hz")) .logarithmic(true), ); } } } /// 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: ConstOrbit, /// Drift the Phoenix distortion `p` around a circle. phoenix: ConstOrbit, /// Drift the Lambda distortion `λ` around a circle. lambda: ConstOrbit, /// Oscillate the Complex Multibrot exponent's real part. cpow_re: AxisOsc, /// Oscillate the Complex Multibrot exponent's imaginary part. cpow_im: AxisOsc, /// Continuously zoom toward the current center. zoom: bool, /// e-folds per second; positive zooms in, negative zooms out. zoom_speed: f32, /// Morph the iteration formula (and camera) when switching fractal kinds, /// instead of cutting straight to the new kind. kind_morph: bool, /// Kind-switch morph duration, in seconds. kind_morph_duration: f32, /// Step through every fractal kind in turn (each switch morphs if /// `kind_morph` is on). kind_cycle: bool, /// Seconds to rest on each kind before switching to the next. kind_cycle_hold: f32, /// Seconds spent on the current kind since the last cycle step. kind_cycle_timer: f32, /// Orbit the 3D camera: spin the yaw and bob the pitch. cam_orbit: bool, /// Yaw rate, degrees per second. cam_yaw_speed: f32, /// Pitch bob amplitude, degrees (0 = constant pitch). cam_pitch_amp: f32, /// Pitch bob frequency, Hz. cam_pitch_speed: f32, /// Pitch the bob oscillates around, captured when the orbit is enabled. cam_pitch_base: f32, cam_pitch_phase: f32, /// Linear 2D <-> 3D transition progress in [0, 1], advanced at a constant /// rate; `camera_state` is its smoothstep-eased value. camera_progress: f32, /// Camera state in [0, 1]: 0 = top-down 2D view, 1 = full 3D camera. camera_state: f32, } impl Default for AnimState { fn default() -> Self { Self { color: false, color_speed: 0.15, julia: ConstOrbit::default(), phoenix: ConstOrbit::default(), lambda: ConstOrbit::default(), cpow_re: AxisOsc::default(), cpow_im: AxisOsc::default(), zoom: false, zoom_speed: 0.5, kind_morph: true, kind_morph_duration: 1.5, kind_cycle: false, kind_cycle_hold: 3.0, kind_cycle_timer: 0.0, cam_orbit: false, cam_yaw_speed: 15.0, cam_pitch_amp: 0.0, cam_pitch_speed: 0.05, cam_pitch_base: 0.0, cam_pitch_phase: 0.0, camera_progress: 0., camera_state: 0., } } } /// Parse a "re,im" pair of plain `f64`s (per-kind constants on the CLI). #[cfg(not(target_arch = "wasm32"))] pub(crate) fn parse_complex_pair(spec: &str) -> Option<(f64, f64)> { let (re, im) = spec.split_once(',')?; Some((re.trim().parse().ok()?, im.trim().parse().ok()?)) } /// Top-level egui application. pub struct FractalApp { view: ViewState, mode: FractalMode, /// Iteration formula. kind: FractalKind, /// Exponent for the Multibrot kind. power: u32, /// Complex exponent for the Complex Multibrot kind (`z^power + c`). complex_power: (f64, f64), 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 // Use 3D raymarching rendering rendering_mode: u32, /// Per-axis scale of the 3D view's height-field texture relative to the /// widget: higher shows sharper, more distant terrain but costs GPU time /// and memory. render_scale_3d: f32, /// List of enabled lights in the world lights: Vec, /// 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, /// Whether the "Fractal Info" popup (formula/constants/zoom for the /// current view) is open. info_open: bool, /// Whether the Help window (about + mouse/touch controls) is open. help_open: bool, /// Time-based animation of colours / Julia c / Phoenix p / zoom. anim: AnimState, /// Kind-switch morph in progress, if any. morph: Option, /// 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, /// Kind and kind-switch morph the current `reference` was computed with. /// The shader iterates with these (not the live kind/morph) so its delta /// formula always matches the orbit, even while the worker lags a frame /// behind — otherwise a kind switch flashes the new kind, unblended, for /// the frame(s) before the morphed reference arrives. `None` until the /// first reference lands. ref_kind: Option, ref_morph: Option<(FractalKind, f32)>, /// 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-path` (native CLI only); falls back to a /// timestamped name when unset. #[cfg(not(target_arch = "wasm32"))] 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, /// The camera used to render 3D fractals camera: Camera, /// Screen dimension. screen_dim: [f32; 2], } /// 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_zoom(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 mut app = Self::default_state(); // 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); } } cc.egui_ctx.set_zoom_factor(1.1); // Debug/testing hooks, driven by CLI flags. #[cfg(not(target_arch = "wasm32"))] app.apply_cli(Cli::parse()); app } /// Build the app's default state (no window, no GPU, no CLI applied yet). /// Shared by the windowed app (`new`, which then layers CLI/share-link /// overrides on top) and headless rendering. pub(crate) fn default_state() -> Self { 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_zoom(view.zoom()); Self { view, mode: FractalMode::Mandelbrot, kind: FractalKind::Mandelbrot, power: 3, complex_power: (2.0, 0.5), 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, rendering_mode: 0, lights: vec![Light::default()], 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, info_open: false, help_open: false, anim: AnimState::default(), morph: None, 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, ref_kind: None, ref_morph: None, last_request: None, #[cfg(not(target_arch = "wasm32"))] worker: crate::worker::RefWorker::spawn(), pending: false, export_scale: 2.0, render_scale_3d: 2.0, last_size_px: egui::vec2(1280.0, 720.0), last_interact_time: -1.0e9, export_requested: false, export: None, #[cfg(not(target_arch = "wasm32"))] export_path: None, status: None, center_re_edit, center_im_edit, zoom_edit, zoom_edited: false, camera: Camera::new(), screen_dim: [0., 0.], } } /// Apply native CLI flags on top of the default state: fractal kind/mode, /// a restored share link or explicit view, coloring toggles, and export /// options. Shared by the windowed app and headless rendering. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn apply_cli(&mut self, cli: Cli) { if let Some(k) = cli.kind { self.kind = k.into(); if let Some(p) = cli.power { self.power = p.clamp(2, 8); } self.view = Self::default_view_for(self.mode, self.kind); } if let Some(k) = cli.rendering_kind { use crate::cli::RenderingKindArg; match k { RenderingKindArg::Classic => self.rendering_mode = 0, RenderingKindArg::Shadow => self.rendering_mode = 1, RenderingKindArg::Dimension3 => self.rendering_mode = 2, } // Start fully in 3D rather than transitioning in from top-down // (headless renders a single frame, with no transition to run). let p = if self.rendering_mode == 2 { 1.0 } else { 0.0 }; self.anim.camera_progress = p; self.anim.camera_state = p; } if cli.yaw.is_some() || cli.pitch.is_some() { let yaw = cli.yaw.map_or(self.camera.yaw, f32::to_radians); let pitch = cli.pitch.map_or(self.camera.pitch, f32::to_radians); self.camera.set_angles(yaw, pitch); self.camera.rotate(0.0, 0.0); // wrap yaw } 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::()), ) { self.mode = FractalMode::Julia; self.julia_c = (re, im); self.view = Self::default_view_for(FractalMode::Julia, self.kind); } } if let Some(pp) = &cli.phoenix_p { let p: Vec<&str> = pp.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::()), ) { self.phoenix_p = (re, im); } } if let Some(ll) = &cli.lambda_l { let p: Vec<&str> = ll.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::()), ) { self.lambda_l = (re, im); } } if let Some(frag) = cli.share && let Some(state) = ShareState::decode(&frag) { self.apply_share(&state); } // After --share so it can override the link's exponent. if let Some(cp) = cli.complex_power.as_deref().and_then(parse_complex_pair) { self.complex_power = cp; } if let Some(spec) = cli.view { self.apply_view_spec(&spec); } if let Some(iterations) = cli.iterations { self.auto_iterations = false; self.max_iterations = iterations; } if let Some(half_height) = cli.half_height { self.apply_half_height_spec(&half_height); } if let Some(position) = cli.position { self.apply_re_im_spec(&position); } if cli.de { self.de_coloring = true; } if cli.buddhabrot { self.mode = FractalMode::Buddhabrot; } if let Some(p) = cli.palette { self.buddha_palette = p.min(BUDDHA_PALETTE_NAMES.len() as u32 - 1); self.palette = p.min(PALETTE_NAMES.len() as u32 - 1); } self.export_path = cli.export_path; } /// 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 Some((view, iterations)) = parse_view_spec(spec) else { return false; }; self.view = view; if let Some(v) = iterations { self.auto_iterations = false; self.max_iterations = v.clamp(32, MAX_REF_POINTS as u32 - 1); } true } /// Apply a half_height spec. Used by the native debug env var. #[allow(dead_code)] pub fn apply_half_height_spec(&mut self, spec: &str) -> bool { let Some(half_height) = parse_half_height_spec(spec) else { return false; }; self.view.half_height = half_height; true } /// Apply a view spec "re,im" (re/im are decimal, /// parsed at full precision). Used by the native debug env var. #[allow(dead_code)] pub fn apply_re_im_spec(&mut self, spec: &str) -> bool { let Some((re, im)) = parse_re_im_spec(spec, self.view.precision_bits()) else { return false; }; self.view.center_re = re; self.view.center_im = im; true } /// The current view (center + half-height). Used by headless animation /// to snapshot the start of a camera path. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn view_state(&self) -> &ViewState { &self.view } /// Jump straight to `view` for the next frame, keeping every other /// parameter (kind, colors, iteration count, ...) as-is. Used by /// headless animation to step through interpolated keyframes. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn set_view(&mut self, view: ViewState) { self.view = view; } /// Force `max_iterations` to auto-scale with zoom depth on every /// subsequent `compute_reference_blocking` call. Used by headless /// animation so iteration count keeps pace with the camera zooming in, /// the same way it does while dragging/zooming interactively. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn set_auto_iterations(&mut self, v: bool) { self.auto_iterations = v; } /// Set `max_iterations`. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn set_max_iterations(&mut self, i: u32) { self.auto_iterations = false; self.max_iterations = i; } /// Per-kind constants `(julia_c, phoenix_p, lambda_l, complex_power)`. /// Used by headless animation to snapshot their start values. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn constants(&self) -> [(f64, f64); 4] { [ self.julia_c, self.phoenix_p, self.lambda_l, self.complex_power, ] } /// Set the per-kind constants, in the order `constants` returns them. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn set_constants(&mut self, [c, p, l, cp]: [(f64, f64); 4]) { self.julia_c = c; self.phoenix_p = p; self.lambda_l = l; self.complex_power = cp; } /// 3D camera `(yaw, pitch)`, radians. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn camera_angles(&self) -> (f32, f32) { (self.camera.yaw, self.camera.pitch) } /// Set the 3D camera angles (radians; yaw unwrapped, see /// `Camera::set_angles`). #[cfg(not(target_arch = "wasm32"))] pub(crate) fn set_camera_angles(&mut self, yaw: f32, pitch: f32) { self.camera.set_angles(yaw, pitch); } /// Size the 3D camera and raymarcher for a `width`×`height` render with /// no window (they normally follow the widget rect each frame). #[cfg(not(target_arch = "wasm32"))] pub(crate) fn set_output_size(&mut self, width: u32, height: u32) { self.screen_dim = [width as f32, height as f32]; self.camera.set_aspect_ratio(width as f32 / height as f32); } /// The current fractal kind. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn kind(&self) -> FractalKind { self.kind } /// Render a fraction `t` in [0, 1] of the way through a kind morph from /// `from` to `to`: the per-step formula blend, without touching the /// camera. `t >= 1` (or `from == to`) is plain `to`. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn set_kind_morph(&mut self, from: FractalKind, to: FractalKind, t: f64) { self.kind = to; self.morph = (from != to && t < 1.0).then(|| { // `KindMorph` eases its progress with smoothstep; invert that so // the blend follows `t` (already eased or not by the caller). let e = t.clamp(0.0, 1.0); let progress = 0.5 - ((1.0 - 2.0 * e).asin() / 3.0).sin(); KindMorph { from, progress: progress as f32, from_view: self.view.clone(), to_view: self.view.clone(), camera: false, } }); } /// Get `max_iterations`. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn max_iterations(&mut self) -> u32 { self.max_iterations } /// 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); self.morph = None; // 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, complex_power: self.complex_power, 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.morph = None; 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.complex_power = s.complex_power; 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) = kind.default_set_view(); ViewState::with_center(big_from_f64(cr, 53), big_from_f64(ci, 53), hh) } /// The request key for the current state. Its `iter` is the reference /// length to compute, which carries headroom over `max_iterations` (see /// [`reference_iterations`]). 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: reference_iterations(self.max_iterations), kind: self.kind, power: self.power, complex_power: self.complex_power, morph: self.morph.as_ref().map(|m| (m.from, m.weight())), } } /// 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 // The reference is computed with headroom, so it keeps serving // while auto-iterations creep up during a zoom (the shader clamps // to `max_iterations`); only recompute once it's too short, or // far longer than needed. || self.max_iterations > key.iter || self.max_iterations.saturating_mul(4) < key.iter || key.kind != self.kind || key.power != self.power || key.complex_power != self.complex_power || key.morph != self.morph.as_ref().map(|m| (m.from, m.weight())) { return true; } // Lambda in Set mode is a static fractal; don't trigger recompute on // center drift. (Not while morphing: the other kind's formula does // depend on the center.) if self.kind == FractalKind::Lambda && matches!(self.mode, FractalMode::Mandelbrot) && self.morph.is_none() { // 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, kind: FractalKind, morph: Option<(FractalKind, f32)>, ) { self.reference = Arc::new(points); self.ref_kind = Some(kind); self.ref_morph = morph; self.ref_center_re = cre; self.ref_center_im = cim; self.ref_half_height = hh; self.generation = self.generation.wrapping_add(1); } /// The current reference orbit, as uploaded to the GPU. Used by headless /// rendering to build its own `ExportRender` without going through /// `egui_wgpu`'s callback machinery. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn reference_points(&self) -> &[[f32; 2]] { &self.reference } /// The configured shadow-style lights, for headless export's `ExportRender` /// (which has no `FractalCallback` to source them from). #[cfg(not(target_arch = "wasm32"))] pub(crate) fn lights(&self) -> &[Light] { &self.lights } /// 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.morph.is_none() { 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, complex_power: key.complex_power, morph: key.morph, }); 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, key.complex_power, key.morph.map(|(k, w)| (k, w as f64)), ) } else { compute_set_reference( &key.center_re, &key.center_im, max_iter, precision, key.kind, key.power, key.phoenix_p, key.lambda_l, key.complex_power, key.morph.map(|(k, w)| (k, w as f64)), ) }; self.apply_reference( points, key.center_re.clone(), key.center_im.clone(), key.half_height, key.kind, key.morph, ); } 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, res.kind, res.morph, ); self.pending = false; } } /// Compute the reference orbit for the current view synchronously, on the /// calling thread — unlike `ensure_reference`, which dispatches to the /// native worker (or, on wasm, computes inline but still runs once per /// frame poll). Used by headless rendering, which has no frame loop to /// poll a background result on and only ever needs one reference. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn compute_reference_blocking(&mut self) { let job = self.reference_job(); let points = job.compute(); self.finish_reference(job, points); } /// Snapshot everything the reference orbit for the current view depends /// on, as a self-contained job that can be computed on another thread /// (headless animation computes many frames' orbits in parallel). Also /// applies auto-iterations, like `compute_reference_blocking`. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn reference_job(&mut self) -> RefJob { if self.auto_iterations { self.max_iterations = self.auto_iteration_count(); } let mut key = self.current_key(); // One-shot render: no later frames for iteration headroom to serve. key.iter = self.max_iterations.min(MAX_REF_POINTS as u32 - 1); let precision = self.view.precision_bits(); // Lambda in Set mode has a static fractal centered at origin. if key.kind == FractalKind::Lambda && !key.julia && key.morph.is_none() { key.center_re = big_from_f64(0.0, precision); key.center_im = big_from_f64(0.0, precision); } RefJob { key, precision, max_iterations: self.max_iterations, } } /// Install the orbit computed for `job` (from `reference_job`) as the /// current reference, along with the iteration count it was made for. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn finish_reference(&mut self, job: RefJob, points: Vec<[f32; 2]>) { self.max_iterations = job.max_iterations; let key = job.key; self.apply_reference( points, key.center_re.clone(), key.center_im.clone(), key.half_height, key.kind, key.morph, ); self.last_request = Some(key); } /// Whether the classic escape-time palette (and its scale / offset / /// palette controls) is in use: always in classic mode, and in shadow/3D /// modes under the "Classic" shading palette. fn uses_classic_palette(&self) -> bool { self.rendering_mode == 0 || self.shadow_palette == SHADOW_PALETTE_CLASSIC } /// The rendering mode the shaders should use this frame: 3D for as long /// as the 2D <-> 3D camera transition is in flight (the raymarcher, and /// the DE heights it reads, stay on until the camera is back top-down), /// otherwise the selected mode. fn effective_rendering_mode(&self) -> u32 { if self.anim.camera_state > 0.0 { 2 } else { self.rendering_mode } } /// 3D-mode zoom toward the screen point `off` (points from the widget /// center): unproject it through the camera onto the z = 0 fractal plane, /// then zoom the 2D view about the matching fractal-texture pixel. fn zoom_3d_at(&mut self, off: egui::Vec2, rect: egui::Rect, height_px: f64, factor: f64) { let ndc = (off / rect.size()) * 2.; let camera_ndc_pos = self .camera .orthographic(self.anim.camera_state, self.render_scale_3d) .inverse() * Vec4::new(ndc.x, ndc.y, 0., 1.); let view_direction = self.camera.direction(self.anim.camera_state); let z_move = camera_ndc_pos.z / view_direction.z; let ndc_pos = camera_ndc_pos.xyz() + view_direction * -z_move; let pos = egui::Vec2::new(ndc_pos.x / self.camera.aspect_ratio, ndc_pos.y) * rect.size() - rect.center().to_vec2(); self.view .zoom_at_pixel(pos.x as f64, pos.y as f64, height_px, factor); } pub(crate) fn make_uniforms(&self, aspect: f64) -> Uniforms { let (span_x, span_y) = self.view.span(aspect); let mode = self.effective_rendering_mode(); 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.ref_kind.unwrap_or(self.kind) as u32, power: self.power, morph_from: self.ref_morph.map_or(0, |(k, _)| k as u32), 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], complex_power: [self.complex_power.0 as f32, self.complex_power.1 as f32], de_coloring: (self.de_coloring || mode > 0) as u32, rendering_mode: mode, camera_direction: self.camera.direction(self.anim.camera_state).to_array(), morph_w: self.ref_morph.map_or(0.0, |(_, w)| w), camera_inv_proj: self .camera .orthographic(self.anim.camera_state, self.render_scale_3d) .inverse() .to_cols_array(), screen_dim: self.screen_dim, light_count: gpu_lights(&self.lights).1, cm_coef: complex_binomials(self.complex_power), _pad3: [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], complex_power: [self.complex_power.0 as f32, self.complex_power.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, _pad0: 0, } } /// 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.mode == FractalMode::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 handles = { let guard = rs.renderer.read(); let Some(renderer) = guard.callback_resources.get::() else { self.status = Some("export unavailable".into()); return; }; renderer.export_handles(&device, &uniforms) }; let reference = Arc::clone(&self.reference); let lights = self.lights.clone(); let shared = Arc::new(Mutex::new(ExportShared { fraction: 0.0, phase: "Rendering", result: None, })); self.status = None; self.export = Some(Arc::clone(&shared)); #[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, &handles, w, h, uniforms, reference.as_slice(), &lights, ); let sh = Arc::clone(&shared); let png = crate::fractal::export_to_png_blocking(&device, &queue, &er, |phase, f| { set_progress(&sh, phase, f) }); 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")] { // Progress budget: rendering fills [0, RENDER_END], encoding the rest. const RENDER_END: f32 = 0.6; wasm_bindgen_futures::spawn_local(async move { let er = ExportRender::new( &device, &queue, &handles, w, h, uniforms, reference.as_slice(), &lights, ); // 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()); } if ui .button("Help") .on_hover_text("About this app, and mouse/touch controls") .clicked() { self.help_open = !self.help_open; } // 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.", ); }); }); }); } /// Floating bottom-left overlay: a single button that toggles the /// "Fractal Info" window. Kept separate from `overlay_buttons` (top-left) /// so it stays out of the way of the panel toggle / fullscreen controls, /// but is still reachable even when the controls panel is collapsed. fn info_button(&mut self, ui: &mut egui::Ui) { egui::Area::new(egui::Id::new("info_button")) .anchor(egui::Align2::LEFT_BOTTOM, egui::vec2(8.0, -8.0)) .show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()) .shadow(egui::Shadow::NONE) .show(ui, |ui| { if ui .button("Fractal infos") .on_hover_text("Show details about the current fractal") .clicked() { self.info_open = !self.info_open; } }); }); } /// Window with details about what's currently on screen: formula, active /// per-kind constants, zoom depth, iteration count. Reads live state, so /// it stays correct as the user pans/zooms/switches kinds. fn info_window(&mut self, ctx: &egui::Context) { let mut open = self.info_open; egui::Window::new("Fractal Info") .id(egui::Id::new("info_window")) .open(&mut open) .collapsible(false) .resizable(false) .anchor(egui::Align2::LEFT_BOTTOM, egui::vec2(8.0, -44.0)) .show(ctx, |ui| { ui.label(egui::RichText::new(self.kind.label()).strong().heading()); let mode_label = match self.mode { FractalMode::Mandelbrot => { "Mandelbrot mode — parameter space (c varies per pixel, z₀ = 0)" } FractalMode::Julia => { "Julia mode — dynamical plane for a fixed c (z₀ varies per pixel)" } FractalMode::Buddhabrot => "Buddhabrot mode — orbit density (random c, z₀ = 0)", }; ui.label(mode_label); ui.separator(); ui.label(format!( "formula: {}", self.kind.formula(self.power, self.complex_power) )); if self.mode == FractalMode::Julia { ui.label(format!("c = {:.6} {:+.6}i", self.julia_c.0, self.julia_c.1)); } if self.kind == FractalKind::Phoenix { ui.label(format!( "p = {:.6} {:+.6}i", self.phoenix_p.0, self.phoenix_p.1 )); } if self.kind == FractalKind::Lambda { ui.label(format!( "λ = {:.6} {:+.6}i", self.lambda_l.0, self.lambda_l.1 )); } if self.kind == FractalKind::ComplexMultibrot { ui.label(format!( "power = {:.6} {:+.6}i", self.complex_power.0, self.complex_power.1 )); } ui.separator(); ui.label(self.kind.description()); }); self.info_open = open; } /// Help window: what the app does, plus a reference for mouse/touch and /// keyboard controls. fn help_window(&mut self, ctx: &egui::Context) { let mut open = self.help_open; egui::Window::new("Help") .id(egui::Id::new("help_window")) .open(&mut open) .collapsible(false) .default_width(360.0) .show(ctx, |ui| { egui::ScrollArea::vertical() .max_height(480.0) .show(ui, |ui| { ui.heading("About"); ui.label( "A deep-zoom fractal explorer. It renders the Mandelbrot set \ and several related fractals (Burning Ship, Tricorn, \ Multibrot, Complex Multibrot, Celtic, Perpendicular, Buffalo, \ Phoenix, Lambda).", ); ui.add_space(4.0); ui.label( "Each fractals can be rendered in different modes: \n\ • Mandelbrot mode fixes z₀=0 and then for each pixel, set c as it's position \ in the complex plane. \n\ • Julia mode fixes c and instead varies the \ starting point z₀ across the plane. \n\ • Buddhabrot mode switches to a different, Monte-Carlo rendering of orbit density \ instead of the ordinary escape-time set.", ); ui.separator(); ui.heading("Mouse & touch"); egui::Grid::new("help_mouse_grid") .num_columns(2) .spacing([12.0, 6.0]) .show(ui, |ui| { ui.label("Drag"); ui.label("Pan the view"); ui.end_row(); ui.label("Scroll / trackpad"); ui.label("Zoom toward the cursor"); ui.end_row(); ui.label("Pinch (touch)"); ui.label("Zoom toward the gesture center"); ui.end_row(); ui.label("Two-finger drag (touch)"); ui.label("Pan the view"); ui.end_row(); }); ui.separator(); ui.heading("Keyboard"); egui::Grid::new("help_keyboard_grid") .num_columns(2) .spacing([12.0, 6.0]) .show(ui, |ui| { ui.label("Arrow keys"); ui.label("Pan the view"); ui.end_row(); ui.label("Z / S"); ui.label("Zoom in / out toward the center"); ui.end_row(); ui.label("+ / -"); ui.label("Increase / decrease iterations"); ui.end_row(); ui.label("R"); ui.label("Reset to the default view"); ui.end_row(); ui.label("H"); ui.label("Toggle this Help window"); ui.end_row(); ui.label("I"); ui.label("Toggle the Info window"); ui.end_row(); ui.label("A"); ui.label("Toggle antialiasing (2×2)"); ui.end_row(); }); ui.separator(); ui.heading("Tips"); ui.label( "• \"Copy link\" (in the panel) encodes the exact view so it \ can be reopened later or sent to someone else.", ); }); }); self.help_open = open; } /// 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.on && self.mode == FractalMode::Julia; let phoenix_on = self.anim.phoenix.on && self.kind == FractalKind::Phoenix; let lambda_on = self.anim.lambda.on && self.kind == FractalKind::Lambda; let cmulti = self.kind == FractalKind::ComplexMultibrot; let cpow_on = cmulti && (self.anim.cpow_re.on || self.anim.cpow_im.on); let cam_on = self.anim.cam_orbit && self.rendering_mode == 2; let cycle_on = self.anim.kind_cycle && self.mode != FractalMode::Buddhabrot; // 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); // Animate the 2D <-> 3D camera transition over a fixed duration with // smoothstep easing: it lands on exactly 0 or 1 (no asymptotic tail, // no snap), so the shader's mode switch (`camera_state > 0.0` in // `make_uniforms`) happens only once the camera is exactly top-down. const CAMERA_DURATION: f32 = 0.6; // seconds let target = if self.rendering_mode == 2 { 1.0 } else { 0.0 }; let p = self.anim.camera_progress; if p != target { let step = dt as f32 / CAMERA_DURATION; self.anim.camera_progress = if target > p { (p + step).min(target) } else { (p - step).max(target) }; ui.ctx().request_repaint(); } let p = self.anim.camera_progress; self.anim.camera_state = p * p * (3.0 - 2.0 * p); // Kind-switch morph: advance the per-iteration formula blend, and glide // the camera to the new kind's default view unless the user took over. if let Some(m) = &mut self.morph { m.progress += dt as f32 / self.anim.kind_morph_duration.max(0.05); if m.camera { self.view = interpolate_view(&m.from_view, &m.to_view, m.eased() as f64); } if m.progress >= 1.0 { if m.camera { self.view = m.to_view.clone(); } self.morph = None; } ui.ctx().request_repaint(); } if !(self.anim.color || self.anim.zoom || julia_on || phoenix_on || lambda_on || cpow_on || cam_on || cycle_on) { return; } 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.julia_c = self.anim.julia.step(dt); } if phoenix_on { self.phoenix_p = self.anim.phoenix.step(dt); } if lambda_on { self.lambda_l = self.anim.lambda.step(dt); } if cmulti && self.anim.cpow_re.on { self.complex_power.0 = self.anim.cpow_re.step(dt).clamp(-8.0, 8.0); } if cmulti && self.anim.cpow_im.on { self.complex_power.1 = self.anim.cpow_im.step(dt).clamp(-8.0, 8.0); } if cam_on { let dyaw = self.anim.cam_yaw_speed.to_radians() * dt as f32; self.anim.cam_pitch_phase += std::f32::consts::TAU * self.anim.cam_pitch_speed * dt as f32; // With no bob, leave pitch alone so it stays draggable mid-orbit. let dpitch = if self.anim.cam_pitch_amp > 0.0 { self.anim.cam_pitch_base + self.anim.cam_pitch_amp.to_radians() * self.anim.cam_pitch_phase.sin() - self.camera.pitch } else { 0.0 }; // `rotate` wraps yaw and clamps pitch. self.camera.rotate(dyaw, dpitch); } if cycle_on && self.morph.is_none() { self.anim.kind_cycle_timer += dt as f32; if self.anim.kind_cycle_timer >= self.anim.kind_cycle_hold { self.anim.kind_cycle_timer = 0.0; let prev = self.kind; let i = FractalKind::ALL .iter() .position(|&k| k == prev) .unwrap_or(0); self.kind = FractalKind::ALL[(i + 1) % FractalKind::ALL.len()]; self.switch_kind(prev); } } 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(); } /// React to `self.kind` having just changed from `prev`: jump (or, with /// kind morphing on, glide) to the new kind's default view. fn switch_kind(&mut self, prev: FractalKind) { let to_view = Self::default_view_for(self.mode, self.kind); // Buddhabrot has its own pipeline without the blended formula, so // it keeps the instant switch. self.morph = (self.anim.kind_morph && self.mode != FractalMode::Buddhabrot).then(|| KindMorph { from: prev, progress: 0.0, from_view: self.view.clone(), to_view: to_view.clone(), camera: true, }); if self.morph.is_none() { self.view = to_view; } } fn controls_ui(&mut self, ui: &mut egui::Ui) { ui.heading("Fractal Explorer"); ui.separator(); ui.add_space(4.); // 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(self.kind.label()) .show_ui(ui, |ui| { for kind in FractalKind::ALL { ui.selectable_value(&mut self.kind, kind, kind.label()); } }); 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 == FractalKind::ComplexMultibrot { ui.horizontal(|ui| { ui.label("power ="); ui.add( egui::DragValue::new(&mut self.complex_power.0) .speed(0.01) .range(-8.0..=8.0), ); ui.add( egui::DragValue::new(&mut self.complex_power.1) .speed(0.01) .range(-8.0..=8.0), ); ui.label("i"); }); } if self.kind != prev_kind { self.switch_kind(prev_kind); } let prev_mode = self.mode; ui.horizontal(|ui| { ui.radio_value(&mut self.mode, FractalMode::Mandelbrot, "Set"); ui.radio_value(&mut self.mode, FractalMode::Julia, "Julia"); ui.radio_value(&mut self.mode, FractalMode::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.mode != prev_mode { self.morph = None; } if self.mode == FractalMode::Buddhabrot { self.buddhabrot_ui(ui); ui.add_space(4.); ui.separator(); ui.add_space(4.); if ui.button("Reset view").clicked() { self.view = Self::default_view_for(self.mode, self.kind); self.morph = None; } ui.add_space(8.0); ui.small("Drag to pan · scroll to zoom toward the cursor"); return; } 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.label("places:"); 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.label("rendering:"); ui.horizontal(|ui| { ui.radio_value(&mut self.rendering_mode, 0, "Classic"); ui.radio_value(&mut self.rendering_mode, 1, "Shadow"); ui.radio_value(&mut self.rendering_mode, 2, "3D"); }); if self.rendering_mode == 2 { let old_scale = self.render_scale_3d; ui.add(egui::Slider::new(&mut self.render_scale_3d, 1.0..=4.0).text("3D render scale")) .on_hover_text( "Resolution multiplier of the 3D height field. Higher shows more \ distant detail but costs GPU time and memory.", ); // The 3D camera zooms in by the render scale (`Camera::orthographic`): // zoom the view out by the same ratio so the fractal keeps its // on-screen size and the extra texels become surrounding terrain. if self.render_scale_3d != old_scale { let factor = (self.render_scale_3d / old_scale) as f64; self.view .zoom_at_pixel(0.0, 0.0, self.last_size_px.y.max(1.0) as f64, factor); } } ui.add_space(4.); ui.separator(); ui.add_space(4.); 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.checkbox(&mut self.antialias, "Antialiasing (2×2)") .on_hover_text("Supersample each pixel for smoother edges (~4× slower)."); if self.rendering_mode == 0 { 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.add_space(4.); ui.separator(); ui.add_space(4.); if self.rendering_mode != 0 { egui::ComboBox::from_label("shading") .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.uses_classic_palette() { 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")); 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); } }); } if self.rendering_mode != 0 && self.shadow_palette == SHADOW_PALETTE_CUSTOM_LIGHTS { 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.add_space(4.); ui.separator(); ui.add_space(4.); ui.collapsing("Animation", |ui| { if self.uses_classic_palette() { 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)"), ); } ui.checkbox(&mut self.anim.kind_morph, "Morph kind switch") .on_hover_text( "When picking another fractal, blend the old and new formulas \ at every iteration step and glide to the new default view.", ); if self.anim.kind_morph { ui.add( egui::Slider::new(&mut self.anim.kind_morph_duration, 0.2..=10.0) .text("morph s") .logarithmic(true), ); } if self.mode != FractalMode::Buddhabrot { if ui .checkbox(&mut self.anim.kind_cycle, "Cycle kinds") .on_hover_text("Step through every fractal kind in turn.") .changed() { self.anim.kind_cycle_timer = 0.0; } if self.anim.kind_cycle { ui.add( egui::Slider::new(&mut self.anim.kind_cycle_hold, 0.5..=30.0) .text("hold s") .logarithmic(true), ); } } if self.rendering_mode == 2 { if ui .checkbox(&mut self.anim.cam_orbit, "Orbit camera") .on_hover_text( "Spin the 3D camera around the view, optionally bobbing its pitch.", ) .changed() && self.anim.cam_orbit { self.anim.cam_pitch_base = self.camera.pitch; self.anim.cam_pitch_phase = 0.0; } if self.anim.cam_orbit { ui.add( egui::Slider::new(&mut self.anim.cam_yaw_speed, -90.0..=90.0) .text("yaw °/s"), ); ui.add( egui::Slider::new(&mut self.anim.cam_pitch_amp, 0.0..=30.0) .text("pitch bob °"), ); if self.anim.cam_pitch_amp > 0.0 { ui.add( egui::Slider::new(&mut self.anim.cam_pitch_speed, 0.005..=0.5) .text("bob Hz") .logarithmic(true), ); } } } // Julia c only affects Julia mode; Phoenix p / λ / complex power // only their own kinds. if self.mode == FractalMode::Julia { self.anim.julia.ui(ui, "c", self.julia_c); } if self.kind == FractalKind::Phoenix { self.anim.phoenix.ui(ui, "p", self.phoenix_p); } if self.kind == FractalKind::Lambda { self.anim.lambda.ui(ui, "λ", self.lambda_l); } if self.kind == FractalKind::ComplexMultibrot { self.anim.cpow_re.ui(ui, "Re(power)", self.complex_power.0); self.anim.cpow_im.ui(ui, "Im(power)", self.complex_power.1); } }); ui.add_space(4.); ui.separator(); ui.add_space(4.); // 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("zoom:"); 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(hh) = self.zoom_edit.trim().parse::() && 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_zoom(self.view.zoom()); } 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.add_space(4.); ui.separator(); ui.add_space(4.); 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.25) .custom_formatter(|x, _| format!("x{:.1}", x)), ); 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.add_space(4.); ui.separator(); ui.add_space(4.); if ui.button("Reset view").clicked() { self.view = Self::default_view_for(self.mode, self.kind); self.morph = None; } 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. // In 3D mode the same gestures orbit/dolly the raymarch camera // instead of panning/zooming the 2D fractal view. const ROT_SENS: f32 = 0.002; // radians per dragged pixel let multi_touch = ui.input(|i| i.multi_touch()); if self.rendering_mode == 2 { if let Some(mt) = multi_touch { let t = mt.translation_delta; // Orbiting only moves the camera, which the colourise pass // handles alone, so (like mouse-drag orbiting) it doesn't count // as interaction: that would drop to the low-res pass and // re-iterate the fractal twice. if t.x != 0.0 || t.y != 0.0 { self.camera.rotate(-t.x * ROT_SENS, -t.y * ROT_SENS); } if mt.zoom_delta != 1.0 { let off = mt.center_pos - rect.center(); self.zoom_3d_at(off, rect, height_px, 1. / (mt.zoom_delta as f64)); interacted = true; } ui.ctx().request_repaint(); } else if response.dragged() { let d = response.drag_delta(); if d.x != 0.0 || d.y != 0.0 { self.camera.rotate(-d.x * ROT_SENS, -d.y * ROT_SENS); } } } else 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 (2D), or dolly the // camera's ortho volume (3D). 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 factor = (-scroll_y as f64 * 0.0015).exp(); let off = pos - rect.center(); if self.rendering_mode == 2 { self.zoom_3d_at(off, rect, height_px, factor); } else { self.view .zoom_at_pixel(off.x as f64, off.y as f64, height_px, factor); } interacted = true; ui.ctx().request_repaint(); } // Keyboard: arrows pan, z/s zoom in/out, +/- adjust iterations, R // resets the view, H/I toggle the Help/Info windows. In 3D mode, // ZQSD move the camera (forward/left/back/right), space/ctrl move it // up/down, and the arrow keys look around instead of panning. // Skipped while a text field (e.g. the center/zoom edit boxes) has // focus. if !ui.ctx().egui_wants_keyboard_input() { let dt = ui.input(|i| i.stable_dt as f64).clamp(0.0, 0.1); let not_modifier_ctrl = ui.input(|i| !i.modifiers.ctrl) || self.rendering_mode != 2; if self.rendering_mode == 2 { let (look_l, look_r, look_u, look_d) = ui.input(|i| { ( i.key_down(egui::Key::ArrowLeft) && i.modifiers.ctrl, i.key_down(egui::Key::ArrowRight) && i.modifiers.ctrl, i.key_down(egui::Key::ArrowUp) && i.modifiers.ctrl, i.key_down(egui::Key::ArrowDown) && i.modifiers.ctrl, ) }); // Units/sec move speed and radians/sec look speed. const LOOK_SPEED: f32 = 0.5; let mut dyaw = 0.0f32; let mut dpitch = 0.0f32; if look_r { dyaw += LOOK_SPEED * dt as f32; } if look_l { dyaw -= LOOK_SPEED * dt as f32; } if look_u { dpitch += LOOK_SPEED * dt as f32; } if look_d { dpitch -= LOOK_SPEED * dt as f32; } if dyaw != 0.0 || dpitch != 0.0 { self.camera.rotate(dyaw, dpitch); } if look_l || look_r || look_u || look_d { ui.ctx().request_repaint(); } } let (left, right, up, down, zoom_in, zoom_out) = ui.input(|i| { ( i.key_down(egui::Key::ArrowLeft) && not_modifier_ctrl, i.key_down(egui::Key::ArrowRight) && not_modifier_ctrl, i.key_down(egui::Key::ArrowUp) && not_modifier_ctrl, i.key_down(egui::Key::ArrowDown) && not_modifier_ctrl, i.key_down(egui::Key::Z), i.key_down(egui::Key::S), ) }); // Pixels/sec pan speed — matches a brisk mouse drag regardless of // frame rate. See `pan_pixels`'s screen-space (+x right, +y down) // convention: Right/Down pan the *camera* right/down, which is // the opposite delta sign from a drag that would show the same // content (a drag grabs the canvas; these keys move the camera). const PAN_SPEED_PX: f64 = 700.0; let mut dx = 0.0; let mut dy = 0.0; if left { dx += PAN_SPEED_PX * dt; } if right { dx -= PAN_SPEED_PX * dt; } if down { dy -= PAN_SPEED_PX * dt; } if up { dy += PAN_SPEED_PX * dt; } if self.rendering_mode == 2 { let cos = self.camera.yaw.cos() as f64; let sin = self.camera.yaw.sin() as f64; (dx, dy) = (dx * cos + sin * dy, -dx * sin + cos * dy); } if dx != 0.0 || dy != 0.0 { self.view.pan_pixels(dx, dy, height_px); interacted = true; } // e-folds/sec, same scale as the auto-zoom animation. const ZOOM_SPEED: f64 = 1.0; if zoom_in != zoom_out { let rate = if zoom_in { ZOOM_SPEED } else { -ZOOM_SPEED }; let factor = (-rate * dt).exp(); self.view.zoom_at_pixel(0.0, 0.0, height_px, factor); interacted = true; } if left || right || up || down || zoom_in || zoom_out { ui.ctx().request_repaint(); } if ui.input(|i| i.key_pressed(egui::Key::R)) { self.view = Self::default_view_for(self.mode, self.kind); self.morph = None; self.camera = Camera::new(); interacted = true; } if ui.input(|i| i.key_pressed(egui::Key::H)) { self.help_open = !self.help_open; } if ui.input(|i| i.key_pressed(egui::Key::I)) { self.info_open = !self.info_open; } if ui.input(|i| i.key_pressed(egui::Key::A)) { self.antialias = !self.antialias; } if ui.input(|i| i.key_pressed(egui::Key::Plus) || i.key_pressed(egui::Key::Equals)) { self.auto_iterations = false; self.max_iterations = ((self.max_iterations as f64 * 1.25).round() as u32) .clamp(32, MAX_REF_POINTS as u32 - 1); } if ui.input(|i| i.key_pressed(egui::Key::Minus)) { self.auto_iterations = false; self.max_iterations = ((self.max_iterations as f64 / 1.25).round() as u32) .clamp(32, MAX_REF_POINTS as u32 - 1); } } if self.mode == FractalMode::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; // The user is steering the camera: stop the kind-switch morph from // overriding it (the formula blend itself carries on). if let Some(m) = &mut self.morph { m.camera = false; } } 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 mut size_px = [ (((rect.width() * ppp).round() as u32) / downscale).max(1), (((rect.height() * ppp).round() as u32) / downscale).max(1), ]; if self.effective_rendering_mode() == 2 { let s = self.render_scale_3d; size_px = size_px.map(|v| ((v as f32 * s).round() as u32).max(1)); } self.screen_dim = [rect.width(), rect.height()]; self.camera.set_aspect_ratio(aspect as f32); let mut uniforms = self.make_uniforms(aspect); // Supersampling is wasted on the low-res pass, and on a kind-switch // morph (every frame re-iterates, and the blend moves on next frame). // `ref_morph` too: the last morphed reference outlives `morph` by a // frame or so, until the worker delivers the plain one. if interacting || self.morph.is_some() || self.ref_morph.is_some() { uniforms.aa_level = 1; } ui.painter().add(egui_wgpu::Callback::new_paint_callback( rect, FractalCallback { uniforms, lights: gpu_lights(&self.lights).0, 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); self.info_button(ui); self.info_window(ui.ctx()); self.help_window(ui.ctx()); 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) } } /// Reference-orbit length to request for `max_iterations`: 1.5× headroom /// (capped at the GPU buffer size). Auto-iterations grows with every zoom /// frame, and without headroom each tiny increase re-ran the whole /// high-precision orbit (plus a re-upload) on every frame of a zoom. fn reference_iterations(max_iterations: u32) -> u32 { let cap = MAX_REF_POINTS as u32 - 1; (max_iterations.saturating_add(max_iterations / 2)).min(cap) } /// Complex binomial coefficients `C(p, k)` for k = 1..16, packed two per row /// (odd k in `[0..2]`, even k in `[2..4]`) for `Uniforms::cm_coef`: the /// Complex Multibrot delta series' coefficients, which only depend on the /// power, so the shader doesn't rebuild them (with a complex division per /// term) on every iteration of every pixel. Built up in f64 via /// `C(p,k) = C(p,k-1) * (p - (k-1)) / k`. fn complex_binomials(p: (f64, f64)) -> [[f32; 4]; 8] { let mut out = [[0.0f32; 4]; 8]; let (mut cr, mut ci) = (1.0f64, 0.0f64); // C(p, 0) for k in 1..=16usize { // (cr + i ci) * ((p.0 - (k-1)) + i p.1) / k let (ar, ai) = (p.0 - (k - 1) as f64, p.1); let kf = k as f64; (cr, ci) = ((cr * ar - ci * ai) / kf, (cr * ai + ci * ar) / kf); let row = &mut out[(k - 1) / 2]; let col = if k % 2 == 1 { 0 } else { 2 }; row[col] = cr as f32; row[col + 1] = ci as f32; } out } /// 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"))] pub(crate) 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); }