Files
mandelbrot/src/app.rs
T

3024 lines
117 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Result<String, String>>,
}
/// 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<Light>,
/// 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<KindMorph>,
/// 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<Vec<[f32; 2]>>,
/// 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<FractalKind>,
ref_morph: Option<(FractalKind, f32)>,
/// Parameters of the most recent reference request (drift baseline / dedupe).
last_request: Option<RequestKey>,
#[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<Arc<Mutex<ExportShared>>>,
/// Output path for `--export-path` (native CLI only); falls back to a
/// timestamped name when unset.
#[cfg(not(target_arch = "wasm32"))]
export_path: Option<String>,
/// Short status line (saved path, "link copied", errors).
status: Option<String>,
/// 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::<f64>()),
p.get(1).map(|s| s.trim().parse::<f64>()),
) {
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::<f64>()),
p.get(1).map(|s| s.trim().parse::<f64>()),
) {
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::<f64>()),
p.get(1).map(|s| s.trim().parse::<f64>()),
) {
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::<FractalRenderer>() 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::<f64>()
&& 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<Mutex<ExportShared>>, 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<Mutex<ExportShared>>, result: Result<String, String>) {
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<String> {
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::<web_sys::HtmlAnchorElement>().ok())
{
anchor.set_href(&url);
anchor.set_download(filename);
anchor.click();
}
let _ = web_sys::Url::revoke_object_url(&url);
}