Compare commits

...
7 Commits
Author SHA1 Message Date
surv 96e373b6e0 feat: improve colors when using distance estimate 2026-09-20 12:32:19 +02:00
surv dc7beedcb1 feat: improve UI/UX 2026-09-20 12:32:00 +02:00
surv cac558a1fb feat: Add complex multibrot fractal 2026-09-20 12:25:36 +02:00
surv 4e61121c75 feat: minor visual fix for PNG export 2026-09-20 11:51:41 +02:00
survandClaude Sonnet 5 a9a9a247ab feat: add keyboard shortcuts for pan/zoom/iterations/AA
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 11:51:40 +02:00
survandClaude Sonnet 5 f34e398223 feat: add fractal info and help overlays
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 11:51:19 +02:00
survandClaude Sonnet 5 6caa23accd feat: add a headless mode, driven by clap CLI args
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 20:50:34 +02:00
15 changed files with 943 additions and 144 deletions
+9 -4
View File
@@ -31,10 +31,15 @@ cargo install wasm-bindgen-cli --version 0.2.128 # must match the wasm-bindgen
python3 -m http.server -d dist 8080 python3 -m http.server -d dist 8080
``` ```
Native debug env vars (see `src/app.rs`, near the top of `FractalApp::new`): Native CLI flags (`src/cli.rs`, applied in `FractalApp::apply_cli`): `--kind`,
`MANDEL_KIND`, `MANDEL_POWER`, `MANDEL_JULIA="re,im"`, `MANDEL_SHARE="<fragment>"`, `--power`, `--julia re,im`, `--share <fragment>`,
`MANDEL_VIEW="re,im,half_height[,iterations]"`, `MANDEL_DE=1`, `--view re,im,half_height[,iterations]`, `--de`, `--buddhabrot`,
`MANDEL_BUDDHABROT=1`, `MANDEL_EXPORT=1` (+ `MANDEL_EXPORT_PATH=out.png`). `--buddha-palette`, `--export` (+ `--export-path out.png`). `--headless`
(`src/headless.rs`) skips the window entirely: it builds the same view from
the other flags, creates its own offscreen wgpu device, and renders straight
to a PNG (`--width`/`--height`, default 1920×1080) — implies `--export`'s
save behavior without needing a GPU-backed window/event loop. Not yet
supported with `--buddhabrot`. Run `mandelbrot --help` for the full list.
There's no GPU in most sandboxes: `cargo check`/`cargo test --test shader_valid` There's no GPU in most sandboxes: `cargo check`/`cargo test --test shader_valid`
are the fast, headless way to validate a change. `cargo test` also runs but are the fast, headless way to validate a change. `cargo test` also runs but
+1
View File
@@ -14,6 +14,7 @@ png = "0.18.1"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies] [target.'cfg(not(target_arch = "wasm32"))'.dependencies]
env_logger = "0.11.11" env_logger = "0.11.11"
clap = { version = "4.5.51", features = ["derive"] } clap = { version = "4.5.51", features = ["derive"] }
pollster = "1.0.1"
[target.'cfg(target_arch = "wasm32")'.dependencies] [target.'cfg(target_arch = "wasm32")'.dependencies]
futures-channel = { version = "0.3.34", default-features = false, features = ["alloc", "std"] } futures-channel = { version = "0.3.34", default-features = false, features = ["alloc", "std"] }
+500 -121
View File
@@ -2,16 +2,16 @@ use std::sync::{Arc, Mutex};
use eframe::CreationContext; use eframe::CreationContext;
use eframe::egui_wgpu; use eframe::egui_wgpu;
#[cfg(target_arch = "wasm32")]
use eframe::egui_wgpu::wgpu; use eframe::egui_wgpu::wgpu;
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
use crate::cli::Cli; use crate::cli::Cli;
use crate::fractal::{ use crate::fractal::{
BuddhabrotCallback, BuddhabrotRenderer, BuddhabrotUniforms, ExportRender, FractalCallback, BuddhabrotCallback, BuddhabrotRenderer, BuddhabrotUniforms, ExportRender, FractalCallback,
FractalKind, FractalRenderer, MAX_REF_POINTS, ShareState, Uniforms, FractalKind, FractalRenderer, MAX_REF_POINTS, ShareState, Uniforms, compute_reference,
compute_set_reference,
}; };
#[cfg(target_arch = "wasm32")]
use crate::fractal::{compute_reference, compute_set_reference};
use crate::lights::Light; use crate::lights::Light;
use crate::view::{ use crate::view::{
Big, DEFAULT_HALF_HEIGHT, ViewState, big_from_decimal_str, big_from_f64, big_to_decimal_str, Big, DEFAULT_HALF_HEIGHT, ViewState, big_from_decimal_str, big_from_f64, big_to_decimal_str,
@@ -43,6 +43,7 @@ const BUDDHA_PALETTE_NAMES: &[&str] = &["Nebula", "Yellow", "Grayscale"];
pub enum FractalMode { pub enum FractalMode {
Mandelbrot, Mandelbrot,
Julia, Julia,
Buddhabrot,
} }
/// Selectable fractal formulas, with UI labels. /// Selectable fractal formulas, with UI labels.
@@ -56,6 +57,7 @@ const KINDS: &[(FractalKind, &str)] = &[
(FractalKind::Buffalo, "Buffalo"), (FractalKind::Buffalo, "Buffalo"),
(FractalKind::Phoenix, "Phoenix"), (FractalKind::Phoenix, "Phoenix"),
(FractalKind::Lambda, "Lambda"), (FractalKind::Lambda, "Lambda"),
(FractalKind::ComplexMultibrot, "Complex Multibrot"),
]; ];
/// UI label for a fractal kind. /// UI label for a fractal kind.
@@ -67,10 +69,30 @@ fn kind_label(kind: FractalKind) -> &'static str {
.unwrap_or("Mandelbrot") .unwrap_or("Mandelbrot")
} }
/// The iteration formula for a kind, in human-readable notation (mirrors the
/// doc comments on `FractalKind`'s variants). `power` is only used by
/// Multibrot; `complex_power` only by Complex Multibrot.
fn kind_formula(kind: FractalKind, power: u32, complex_power: (f64, f64)) -> String {
match kind {
FractalKind::Mandelbrot => "z = z² + c".to_string(),
FractalKind::BurningShip => "z = (|Re(z)| + i|Im(z)|)² + c".to_string(),
FractalKind::Tricorn => "z = conj(z)² + c".to_string(),
FractalKind::Multibrot => format!("z = z^{power} + c"),
FractalKind::Celtic => "z = |Re(z²)| + i·Im(z²) + c".to_string(),
FractalKind::Perpendicular => "z = (x² − y²) − 2x|y|i + c".to_string(),
FractalKind::Buffalo => "z = |Re(z²)| − i|Im(z²)| + c".to_string(),
FractalKind::Phoenix => "z = z² + c + p·z_prev".to_string(),
FractalKind::Lambda => "z = λ·z(1 − z)".to_string(),
FractalKind::ComplexMultibrot => {
format!("z = z^({:.3}{:+.3}i) + c", complex_power.0, complex_power.1)
}
}
}
type JuliaPreset = (&'static str, f64, f64, u32, Option<(f64, f64)>); type JuliaPreset = (&'static str, f64, f64, u32, Option<(f64, f64)>);
/// Nice-looking Julia constants offered as presets. /// Nice-looking Julia constants offered as presets.
const JULIA_PRESETS: [&[JuliaPreset]; FractalKind::Lambda as usize + 1] = [ const JULIA_PRESETS: [&[JuliaPreset]; FractalKind::ComplexMultibrot as usize + 1] = [
&[ &[
("dendrite", -0.8, 0.156, 400, None), ("dendrite", -0.8, 0.156, 400, None),
("rabbit", -0.123, 0.745, 400, None), ("rabbit", -0.123, 0.745, 400, None),
@@ -89,6 +111,7 @@ const JULIA_PRESETS: [&[JuliaPreset]; FractalKind::Lambda as usize + 1] = [
("archipelago 2", -0.556, 0.253, 500, Some((-0.415, -0.267))), ("archipelago 2", -0.556, 0.253, 500, Some((-0.415, -0.267))),
], ],
&[], &[],
&[],
]; ];
type SetPreset = ( type SetPreset = (
@@ -103,7 +126,7 @@ type SetPreset = (
/// Curated beautiful locations offered as one-click presets. /// Curated beautiful locations offered as one-click presets.
/// Each is `(name, center_re, center_im, half_height, iterations)`; the centers /// Each is `(name, center_re, center_im, half_height, iterations)`; the centers
/// are decimals parsed at full precision so deep places stay sharp. /// are decimals parsed at full precision so deep places stay sharp.
const SET_PRESETS: [&[SetPreset]; FractalKind::Lambda as usize + 1] = [ const SET_PRESETS: [&[SetPreset]; FractalKind::ComplexMultibrot as usize + 1] = [
&[ &[
( (
"Seahorse Valley", "Seahorse Valley",
@@ -154,6 +177,7 @@ const SET_PRESETS: [&[SetPreset]; FractalKind::Lambda as usize + 1] = [
Some((-0.9, -0.49)), Some((-0.9, -0.49)),
)], )],
&[], &[],
&[],
]; ];
/// Parameters a reference orbit was (or will be) computed for. Used to decide /// Parameters a reference orbit was (or will be) computed for. Used to decide
@@ -169,6 +193,7 @@ struct RequestKey {
iter: u32, iter: u32,
kind: FractalKind, kind: FractalKind,
power: u32, power: u32,
complex_power: (f64, f64),
} }
/// Shared state for an in-progress PNG export. The worker (a background thread /// Shared state for an in-progress PNG export. The worker (a background thread
@@ -256,6 +281,8 @@ pub struct FractalApp {
kind: FractalKind, kind: FractalKind,
/// Exponent for the Multibrot kind. /// Exponent for the Multibrot kind.
power: u32, power: u32,
/// Complex exponent for the Complex Multibrot kind (`z^power + c`).
complex_power: (f64, f64),
julia_c: (f64, f64), julia_c: (f64, f64),
/// Distortion constant `p` for the Phoenix kind (`z^2 + c + p·z_{n-1}`). /// Distortion constant `p` for the Phoenix kind (`z^2 + c + p·z_{n-1}`).
phoenix_p: (f64, f64), phoenix_p: (f64, f64),
@@ -281,10 +308,6 @@ pub struct FractalApp {
/// List of enabled lights in the world /// List of enabled lights in the world
lights: Vec<Light>, lights: Vec<Light>,
/// Render as a Buddhabrot (Monte-Carlo orbit-density histogram) instead of
/// the ordinary escape-time set. Plain f32 view — no deep zoom, no
/// perturbation/reference-orbit machinery (see `fractal::buddhabrot`).
buddhabrot: bool,
/// Nested escape-iteration caps for the R/G/B histogram channels /// Nested escape-iteration caps for the R/G/B histogram channels
/// (Nebulabrot coloring); kept ordered r <= g <= b by the UI. /// (Nebulabrot coloring); kept ordered r <= g <= b by the UI.
buddha_r_cap: u32, buddha_r_cap: u32,
@@ -303,6 +326,11 @@ pub struct FractalApp {
/// Whether the app is in fullscreen (browser Fullscreen API on web, viewport /// 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 on native). Kept in sync with the real state each frame.
fullscreen: bool, 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. /// Time-based animation of colours / Julia c / Phoenix p / zoom.
anim: AnimState, anim: AnimState,
@@ -395,6 +423,29 @@ impl FractalApp {
guard.callback_resources.insert(buddhabrot_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 view = ViewState::default();
let ref_center_re = view.center_re.clone(); let ref_center_re = view.center_re.clone();
let ref_center_im = view.center_im.clone(); let ref_center_im = view.center_im.clone();
@@ -404,11 +455,12 @@ impl FractalApp {
let center_im_edit = big_to_decimal_str(&view.center_im, sig); let center_im_edit = big_to_decimal_str(&view.center_im, sig);
let zoom_edit = format_magnification(view.magnification()); let zoom_edit = format_magnification(view.magnification());
let mut app = Self { Self {
view, view,
mode: FractalMode::Mandelbrot, mode: FractalMode::Mandelbrot,
kind: FractalKind::Mandelbrot, kind: FractalKind::Mandelbrot,
power: 3, power: 3,
complex_power: (2.0, 0.5),
julia_c: (-0.8, 0.156), julia_c: (-0.8, 0.156),
phoenix_p: (-0.5, 0.0), phoenix_p: (-0.5, 0.0),
lambda_l: (-0.5, 0.0), lambda_l: (-0.5, 0.0),
@@ -422,7 +474,6 @@ impl FractalApp {
de_coloring: false, de_coloring: false,
shadow: false, shadow: false,
lights: vec![Light::default()], lights: vec![Light::default()],
buddhabrot: false,
buddha_r_cap: 50, buddha_r_cap: 50,
buddha_g_cap: 500, buddha_g_cap: 500,
buddha_b_cap: 2000, buddha_b_cap: 2000,
@@ -431,6 +482,8 @@ impl FractalApp {
buddha_accumulate: true, buddha_accumulate: true,
controls_open: true, controls_open: true,
fullscreen: false, fullscreen: false,
info_open: false,
help_open: false,
anim: AnimState::default(), anim: AnimState::default(),
fps: 0.0, fps: 0.0,
fps_frames: 0, fps_frames: 0,
@@ -455,63 +508,62 @@ impl FractalApp {
center_im_edit, center_im_edit,
zoom_edit, zoom_edit,
zoom_edited: false, zoom_edited: false,
};
// On the web, restore a shared view from the URL fragment (#...).
#[cfg(target_arch = "wasm32")]
if let Some(frag) = web_location_hash() {
if let Some(state) = ShareState::decode(&frag) {
app.apply_share(&state);
}
} }
}
// Debug/testing hooks, driven by CLI flags. /// Apply native CLI flags on top of the default state: fractal kind/mode,
#[cfg(not(target_arch = "wasm32"))] /// a restored share link or explicit view, coloring toggles, and export
{ /// options. Shared by the windowed app and headless rendering.
let cli = Cli::parse(); #[cfg(not(target_arch = "wasm32"))]
pub(crate) fn apply_cli(&mut self, cli: Cli) {
if let Some(k) = cli.kind { if let Some(k) = cli.kind {
app.kind = k.into(); self.kind = k.into();
if let Some(p) = cli.power { if let Some(p) = cli.power {
app.power = p.clamp(2, 8); self.power = p.clamp(2, 8);
}
app.view = Self::default_view_for(app.mode, app.kind);
} }
if let Some(jc) = cli.julia { if let Some(cp) = &cli.complex_power {
let p: Vec<&str> = jc.split(',').collect(); let p: Vec<&str> = cp.split(',').collect();
if let (Some(Ok(re)), Some(Ok(im))) = ( if let (Some(Ok(re)), Some(Ok(im))) = (
p.first().map(|s| s.trim().parse::<f64>()), p.first().map(|s| s.trim().parse::<f64>()),
p.get(1).map(|s| s.trim().parse::<f64>()), p.get(1).map(|s| s.trim().parse::<f64>()),
) { ) {
app.mode = FractalMode::Julia; self.complex_power = (re, im);
app.julia_c = (re, im);
app.view = Self::default_view_for(FractalMode::Julia, app.kind);
} }
} }
if let Some(frag) = cli.share self.view = Self::default_view_for(self.mode, self.kind);
&& let Some(state) = ShareState::decode(&frag) }
{ if let Some(jc) = cli.julia {
app.apply_share(&state); let p: Vec<&str> = jc.split(',').collect();
} if let (Some(Ok(re)), Some(Ok(im))) = (
if let Some(spec) = cli.view { p.first().map(|s| s.trim().parse::<f64>()),
app.apply_view_spec(&spec); p.get(1).map(|s| s.trim().parse::<f64>()),
} ) {
if cli.de { self.mode = FractalMode::Julia;
app.de_coloring = true; self.julia_c = (re, im);
} self.view = Self::default_view_for(FractalMode::Julia, self.kind);
if cli.buddhabrot {
app.buddhabrot = true;
}
if let Some(p) = cli.buddha_palette {
app.buddha_palette = p.min(BUDDHA_PALETTE_NAMES.len() as u32 - 1);
}
app.export_path = cli.export_path;
if cli.export {
app.export_requested = true;
} }
} }
if let Some(frag) = cli.share
app && let Some(state) = ShareState::decode(&frag)
{
self.apply_share(&state);
}
if let Some(spec) = cli.view {
self.apply_view_spec(&spec);
}
if cli.de {
self.de_coloring = true;
}
if cli.buddhabrot {
self.mode = FractalMode::Buddhabrot;
}
if let Some(p) = cli.buddha_palette {
self.buddha_palette = p.min(BUDDHA_PALETTE_NAMES.len() as u32 - 1);
}
self.export_path = cli.export_path;
if cli.export {
self.export_requested = true;
}
} }
/// Apply a view spec "re,im,half_height[,iterations]" (re/im are decimal, /// Apply a view spec "re,im,half_height[,iterations]" (re/im are decimal,
@@ -583,6 +635,7 @@ impl FractalApp {
julia_c: self.julia_c, julia_c: self.julia_c,
phoenix_p: self.phoenix_p, phoenix_p: self.phoenix_p,
lambda_l: self.lambda_l, lambda_l: self.lambda_l,
complex_power: self.complex_power,
color_scale: self.color_scale, color_scale: self.color_scale,
color_offset: self.color_offset, color_offset: self.color_offset,
palette: self.palette, palette: self.palette,
@@ -602,6 +655,7 @@ impl FractalApp {
self.julia_c = s.julia_c; self.julia_c = s.julia_c;
self.phoenix_p = s.phoenix_p; self.phoenix_p = s.phoenix_p;
self.lambda_l = s.lambda_l; self.lambda_l = s.lambda_l;
self.complex_power = s.complex_power;
self.color_scale = s.color_scale; self.color_scale = s.color_scale;
self.color_offset = s.color_offset; self.color_offset = s.color_offset;
self.palette = (s.palette as usize).min(PALETTE_NAMES.len() - 1) as u32; self.palette = (s.palette as usize).min(PALETTE_NAMES.len() - 1) as u32;
@@ -653,6 +707,7 @@ impl FractalApp {
FractalKind::Buffalo => (-0.5, -0.5, 1.5), FractalKind::Buffalo => (-0.5, -0.5, 1.5),
FractalKind::Phoenix => (0.0, 0.0, 1.6), FractalKind::Phoenix => (0.0, 0.0, 1.6),
FractalKind::Lambda => (0.0, 0.0, 1.6), FractalKind::Lambda => (0.0, 0.0, 1.6),
FractalKind::ComplexMultibrot => (0.0, 0.0, 1.5),
}; };
ViewState::with_center(big_from_f64(cr, 53), big_from_f64(ci, 53), hh) ViewState::with_center(big_from_f64(cr, 53), big_from_f64(ci, 53), hh)
} }
@@ -669,6 +724,7 @@ impl FractalApp {
iter: self.max_iterations, iter: self.max_iterations,
kind: self.kind, kind: self.kind,
power: self.power, power: self.power,
complex_power: self.complex_power,
} }
} }
@@ -694,6 +750,7 @@ impl FractalApp {
|| key.iter != self.max_iterations || key.iter != self.max_iterations
|| key.kind != self.kind || key.kind != self.kind
|| key.power != self.power || key.power != self.power
|| key.complex_power != self.complex_power
{ {
return true; return true;
} }
@@ -726,6 +783,14 @@ impl FractalApp {
self.generation = self.generation.wrapping_add(1); 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
}
/// Recompute the reference orbit when needed. Native: dispatch to a worker /// Recompute the reference orbit when needed. Native: dispatch to a worker
/// thread and pick up completed results. Web: compute inline. /// thread and pick up completed results. Web: compute inline.
fn ensure_reference(&mut self) { fn ensure_reference(&mut self) {
@@ -754,6 +819,7 @@ impl FractalApp {
power: key.power, power: key.power,
phoenix_p: key.phoenix_p, phoenix_p: key.phoenix_p,
lambda_l: key.lambda_l, lambda_l: key.lambda_l,
complex_power: key.complex_power,
}); });
self.pending = true; self.pending = true;
} }
@@ -773,6 +839,7 @@ impl FractalApp {
key.power, key.power,
key.phoenix_p, key.phoenix_p,
key.lambda_l, key.lambda_l,
key.complex_power,
) )
} else { } else {
compute_set_reference( compute_set_reference(
@@ -784,6 +851,7 @@ impl FractalApp {
key.power, key.power,
key.phoenix_p, key.phoenix_p,
key.lambda_l, key.lambda_l,
key.complex_power,
) )
}; };
self.apply_reference( self.apply_reference(
@@ -804,7 +872,65 @@ impl FractalApp {
} }
} }
fn make_uniforms(&self, aspect: f64) -> Uniforms { /// 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) {
if self.auto_iterations {
self.max_iterations = self.auto_iteration_count();
}
let mut key = self.current_key();
let precision = self.view.precision_bits();
let max_iter = key.iter.min(MAX_REF_POINTS as u32 - 1);
// Lambda in Set mode has a static fractal centered at origin.
if key.kind == FractalKind::Lambda && !key.julia {
key.center_re = big_from_f64(0.0, precision);
key.center_im = big_from_f64(0.0, precision);
}
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,
)
} 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,
)
};
self.apply_reference(
points,
key.center_re.clone(),
key.center_im.clone(),
key.half_height,
);
self.last_request = Some(key);
}
pub(crate) fn make_uniforms(&self, aspect: f64) -> Uniforms {
let (span_x, span_y) = self.view.span(aspect); let (span_x, span_y) = self.view.span(aspect);
Uniforms { Uniforms {
span: [span_x as f32, span_y as f32], span: [span_x as f32, span_y as f32],
@@ -822,6 +948,7 @@ impl FractalApp {
dc_offset: self.dc_offset(), dc_offset: self.dc_offset(),
phoenix_p: [self.phoenix_p.0 as f32, self.phoenix_p.1 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], 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 | self.shadow) as u32, de_coloring: (self.de_coloring | self.shadow) as u32,
shadow: self.shadow as u32, shadow: self.shadow as u32,
_pad: [0; _], _pad: [0; _],
@@ -842,6 +969,7 @@ impl FractalApp {
aspect: aspect as f32, aspect: aspect as f32,
phoenix_p: [self.phoenix_p.0 as f32, self.phoenix_p.1 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], 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, bailout_sq: BAILOUT_SQ,
kind: self.kind as u32, kind: self.kind as u32,
power: self.power, power: self.power,
@@ -855,7 +983,7 @@ impl FractalApp {
height: 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 total_samples: 0.0, // tracked by the renderer across frames
palette: self.buddha_palette, palette: self.buddha_palette,
_pad: [0; 3], _pad0: 0,
} }
} }
@@ -866,7 +994,7 @@ impl FractalApp {
if self.export.is_some() { if self.export.is_some() {
return; // one export at a time return; // one export at a time
} }
if self.buddhabrot { if self.mode == FractalMode::Buddhabrot {
self.status = Some("PNG export isn't available in Buddhabrot mode yet".into()); self.status = Some("PNG export isn't available in Buddhabrot mode yet".into());
return; return;
} }
@@ -905,9 +1033,6 @@ impl FractalApp {
self.status = None; self.status = None;
self.export = Some(Arc::clone(&shared)); self.export = Some(Arc::clone(&shared));
// Progress budget: rendering fills [0, RENDER_END], encoding the rest.
const RENDER_END: f32 = 0.6;
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
{ {
let name = self let name = self
@@ -926,51 +1051,11 @@ impl FractalApp {
uniforms, uniforms,
reference.as_slice(), reference.as_slice(),
); );
let sh = Arc::clone(&shared);
// Render the image tile by tile, waiting for each so progress let png =
// reflects real GPU work. crate::fractal::export_to_png_blocking(&device, &queue, &er, |phase, f| {
for t in 0..er.tiles { set_progress(&sh, phase, f)
er.render_tile(&device, &queue, t);
let _ = device.poll(wgpu::PollType::Wait {
submission_index: None,
timeout: None,
}); });
let done = (t + 1) as f32 / er.tiles as f32;
set_progress(&shared, "Rendering", RENDER_END * done);
}
er.copy_to_readback(&device, &queue);
// Wait for the copy, then read the mapped bytes.
let (tx, rx) = std::sync::mpsc::channel();
er.readback()
.slice(..)
.map_async(wgpu::MapMode::Read, move |res| {
let _ = tx.send(res);
});
let _ = device.poll(wgpu::PollType::Wait {
submission_index: None,
timeout: None,
});
let _ = rx.recv();
set_progress(&shared, "Encoding", RENDER_END);
let png = {
let data = er
.readback()
.slice(..)
.get_mapped_range()
.expect("map readback buffer");
let sh = Arc::clone(&shared);
crate::fractal::encode_png_with_progress(
&data,
er.width,
er.height,
er.padded_bpr,
er.swap_rb,
|f| set_progress(&sh, "Encoding", RENDER_END + (0.97 - RENDER_END) * f),
)
};
er.readback().unmap();
set_progress(&shared, "Saving", 0.98); set_progress(&shared, "Saving", 0.98);
let result = std::fs::write(&name, &png) let result = std::fs::write(&name, &png)
@@ -981,6 +1066,8 @@ impl FractalApp {
} }
#[cfg(target_arch = "wasm32")] #[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 { wasm_bindgen_futures::spawn_local(async move {
let er = ExportRender::new( let er = ExportRender::new(
&device, &device,
@@ -1093,6 +1180,13 @@ impl FractalApp {
self.fullscreen = !self.fullscreen; self.fullscreen = !self.fullscreen;
self.apply_fullscreen(ui.ctx()); 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 // FPS readout. Monospace + fixed width so the number
// changing doesn't jitter the button row. // changing doesn't jitter the button row.
ui.add( ui.add(
@@ -1111,6 +1205,180 @@ impl FractalApp {
}); });
} }
/// 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(kind_label(self.kind))
.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: {}",
kind_formula(self.kind, 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. /// Push the desired fullscreen state to the platform.
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
fn apply_fullscreen(&mut self, ctx: &egui::Context) { fn apply_fullscreen(&mut self, ctx: &egui::Context) {
@@ -1233,6 +1501,7 @@ impl FractalApp {
fn controls_ui(&mut self, ui: &mut egui::Ui) { fn controls_ui(&mut self, ui: &mut egui::Ui) {
ui.heading("Fractal Explorer"); ui.heading("Fractal Explorer");
ui.separator(); ui.separator();
ui.add_space(4.);
// Fractal formula. Switching kinds jumps to a sensible default view, // Fractal formula. Switching kinds jumps to a sensible default view,
// since interesting regions differ between fractals. // since interesting regions differ between fractals.
@@ -1279,20 +1548,42 @@ impl FractalApp {
ui.label("i"); 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 { if self.kind != prev_kind {
self.view = Self::default_view_for(self.mode, self.kind); self.view = Self::default_view_for(self.mode, self.kind);
} }
ui.checkbox(&mut self.buddhabrot, "Buddhabrot") ui.horizontal(|ui| {
.on_hover_text( ui.radio_value(&mut self.mode, FractalMode::Mandelbrot, "Set");
"Monte-Carlo density of escaping orbits instead of the ordinary \ 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 \ escape-time set. Plain f32 view (no deep zoom); the image \
progressively sharpens while the view stays still.", progressively sharpens while the view stays still.",
); );
});
if self.buddhabrot { if self.mode == FractalMode::Buddhabrot {
self.buddhabrot_ui(ui); self.buddhabrot_ui(ui);
ui.add_space(4.);
ui.separator(); ui.separator();
ui.add_space(4.);
if ui.button("Reset view").clicked() { if ui.button("Reset view").clicked() {
self.view = Self::default_view_for(self.mode, self.kind); self.view = Self::default_view_for(self.mode, self.kind);
} }
@@ -1301,11 +1592,6 @@ impl FractalApp {
return; return;
} }
ui.horizontal(|ui| {
ui.radio_value(&mut self.mode, FractalMode::Mandelbrot, "Set");
ui.radio_value(&mut self.mode, FractalMode::Julia, "Julia");
});
if self.mode == FractalMode::Julia && self.kind != FractalKind::Lambda { if self.mode == FractalMode::Julia && self.kind != FractalKind::Lambda {
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.label("c ="); ui.label("c =");
@@ -1323,6 +1609,7 @@ impl FractalApp {
}); });
if !JULIA_PRESETS[self.kind as usize].is_empty() { if !JULIA_PRESETS[self.kind as usize].is_empty() {
ui.label("places:");
ui.horizontal_wrapped(|ui| { ui.horizontal_wrapped(|ui| {
for &(name, re, im, iterations, phoenix) in JULIA_PRESETS[self.kind as usize] { for &(name, re, im, iterations, phoenix) in JULIA_PRESETS[self.kind as usize] {
if ui.small_button(name).clicked() { if ui.small_button(name).clicked() {
@@ -1353,7 +1640,9 @@ impl FractalApp {
}); });
} }
ui.add_space(4.);
ui.separator(); ui.separator();
ui.add_space(4.);
ui.checkbox(&mut self.auto_iterations, "Auto iterations") ui.checkbox(&mut self.auto_iterations, "Auto iterations")
.on_hover_text("Scale the iteration count with zoom depth so deep zooms stay sharp."); .on_hover_text("Scale the iteration count with zoom depth so deep zooms stay sharp.");
if self.auto_iterations { if self.auto_iterations {
@@ -1407,7 +1696,9 @@ impl FractalApp {
}); });
}); });
} }
ui.add_space(4.);
ui.separator(); ui.separator();
ui.add_space(4.);
ui.checkbox(&mut self.antialias, "Antialiasing (2×2)") ui.checkbox(&mut self.antialias, "Antialiasing (2×2)")
.on_hover_text("Supersample each pixel for smoother edges (~4× slower)."); .on_hover_text("Supersample each pixel for smoother edges (~4× slower).");
if !self.shadow { if !self.shadow {
@@ -1498,7 +1789,9 @@ impl FractalApp {
} }
}); });
ui.add_space(4.);
ui.separator(); ui.separator();
ui.add_space(4.);
// Editable center coordinates. Shown at full precision; parsed // Editable center coordinates. Shown at full precision; parsed
// losslessly on commit (Enter or focus loss). While a field is focused // 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 // we leave the user's text alone; otherwise we refresh it from the live
@@ -1574,7 +1867,9 @@ impl FractalApp {
ui.colored_label(egui::Color32::LIGHT_YELLOW, "computing reference…"); ui.colored_label(egui::Color32::LIGHT_YELLOW, "computing reference…");
} }
ui.add_space(4.);
ui.separator(); ui.separator();
ui.add_space(4.);
let exporting = self.export.is_some(); let exporting = self.export.is_some();
ui.horizontal(|ui| { ui.horizontal(|ui| {
if ui.button("Copy link").clicked() { if ui.button("Copy link").clicked() {
@@ -1594,10 +1889,11 @@ impl FractalApp {
ui.add( ui.add(
egui::DragValue::new(&mut self.export_scale) egui::DragValue::new(&mut self.export_scale)
.range(1.0..=16.0) .range(1.0..=16.0)
.speed(0.5), .speed(0.25)
.custom_formatter(|x, _| format!("x{:.1}", x)),
); );
ui.label(format!( ui.label(format!(
"→ {}×{}", "= {}×{}",
(self.last_size_px.x * self.export_scale) as u32, (self.last_size_px.x * self.export_scale) as u32,
(self.last_size_px.y * self.export_scale) as u32, (self.last_size_px.y * self.export_scale) as u32,
)); ));
@@ -1616,7 +1912,9 @@ impl FractalApp {
ui.small(status); ui.small(status);
} }
ui.add_space(4.);
ui.separator(); ui.separator();
ui.add_space(4.);
if ui.button("Reset view").clicked() { if ui.button("Reset view").clicked() {
self.view = Self::default_view_for(self.mode, self.kind); self.view = Self::default_view_for(self.mode, self.kind);
} }
@@ -1726,7 +2024,85 @@ impl FractalApp {
ui.ctx().request_repaint(); ui.ctx().request_repaint();
} }
if self.buddhabrot { // Keyboard: arrows pan, z/s zoom in/out, +/- adjust iterations, R
// resets the view, H/I toggle the Help/Info windows. 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 (left, right, up, down, zoom_in, zoom_out) = ui.input(|i| {
(
i.key_down(egui::Key::ArrowLeft),
i.key_down(egui::Key::ArrowRight),
i.key_down(egui::Key::ArrowUp),
i.key_down(egui::Key::ArrowDown),
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 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);
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 // No reference orbit / perturbation machinery: iterate directly in
// f32 from the live view. Progressive accumulation means this // f32 from the live view. Progressive accumulation means this
// needs its own continuous repaint, separate from the escape-time // needs its own continuous repaint, separate from the escape-time
@@ -1844,6 +2220,9 @@ impl eframe::App for FractalApp {
// Floating overlay, always reachable (even when the panel is collapsed): // Floating overlay, always reachable (even when the panel is collapsed):
// toggle the panel and toggle fullscreen. Essential on a phone. // toggle the panel and toggle fullscreen. Essential on a phone.
self.overlay_buttons(ui); 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) { if std::mem::take(&mut self.export_requested) {
self.do_export(frame); self.do_export(frame);
@@ -1872,7 +2251,7 @@ fn finish_export(shared: &Arc<Mutex<ExportShared>>, result: Result<String, Strin
} }
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
fn unix_timestamp() -> u64 { pub(crate) fn unix_timestamp() -> u64 {
std::time::SystemTime::now() std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs()) .map(|d| d.as_secs())
+22 -1
View File
@@ -17,6 +17,10 @@ pub struct Cli {
#[arg(long)] #[arg(long)]
pub power: Option<u32>, pub power: Option<u32>,
/// Complex exponent for the Complex Multibrot kind (z -> z^power + c).
#[arg(long, value_name = "RE,IM")]
pub complex_power: Option<String>,
/// Start in Julia mode with this seed constant. /// Start in Julia mode with this seed constant.
#[arg(long, value_name = "RE,IM")] #[arg(long, value_name = "RE,IM")]
pub julia: Option<String>, pub julia: Option<String>,
@@ -45,9 +49,23 @@ pub struct Cli {
#[arg(long)] #[arg(long)]
pub export: bool, pub export: bool,
/// Output path for --export (default: fractal-<timestamp>.png). /// Output path for --export/--headless (default: fractal-<timestamp>.png).
#[arg(long, value_name = "PATH")] #[arg(long, value_name = "PATH")]
pub export_path: Option<String>, pub export_path: Option<String>,
/// Run without opening a window: render the current view to a PNG and
/// exit. Combine with --kind/--julia/--share/--view etc. to pick what to
/// render. Not yet supported with --buddhabrot.
#[arg(long)]
pub headless: bool,
/// Output image width in pixels (--headless only).
#[arg(long, value_name = "PX", default_value_t = 1920)]
pub width: u32,
/// Output image height in pixels (--headless only).
#[arg(long, value_name = "PX", default_value_t = 1080)]
pub height: u32,
} }
#[derive(Copy, Clone, Debug, ValueEnum)] #[derive(Copy, Clone, Debug, ValueEnum)]
@@ -65,6 +83,8 @@ pub enum KindArg {
Buffalo, Buffalo,
Phoenix, Phoenix,
Lambda, Lambda,
#[value(alias = "cmulti")]
ComplexMultibrot,
} }
impl From<KindArg> for FractalKind { impl From<KindArg> for FractalKind {
@@ -79,6 +99,7 @@ impl From<KindArg> for FractalKind {
KindArg::Buffalo => FractalKind::Buffalo, KindArg::Buffalo => FractalKind::Buffalo,
KindArg::Phoenix => FractalKind::Phoenix, KindArg::Phoenix => FractalKind::Phoenix,
KindArg::Lambda => FractalKind::Lambda, KindArg::Lambda => FractalKind::Lambda,
KindArg::ComplexMultibrot => FractalKind::ComplexMultibrot,
} }
} }
} }
+7 -1
View File
@@ -46,7 +46,11 @@ pub struct BuddhabrotUniforms {
/// (yellow core, blue halo), 2 = grayscale. Display-only, like `exposure` /// (yellow core, blue halo), 2 = grayscale. Display-only, like `exposure`
/// — excluded from `ContentKey` so changing it doesn't reset accumulation. /// — excluded from `ContentKey` so changing it doesn't reset accumulation.
pub palette: u32, pub palette: u32,
pub _pad: [u32; 3], /// Padding so `complex_power` (a vec2, 8-byte aligned in the shader)
/// starts on an 8-byte boundary.
pub _pad0: u32,
/// Complex exponent for the Complex Multibrot kind; ignored by other kinds.
pub complex_power: [f32; 2],
} }
/// The subset of `BuddhabrotUniforms` that determines the *content* of the /// The subset of `BuddhabrotUniforms` that determines the *content* of the
@@ -62,6 +66,7 @@ struct ContentKey {
bailout_sq: f32, bailout_sq: f32,
kind: u32, kind: u32,
power: u32, power: u32,
complex_power: [f32; 2],
r_cap: u32, r_cap: u32,
g_cap: u32, g_cap: u32,
b_cap: u32, b_cap: u32,
@@ -78,6 +83,7 @@ impl From<&BuddhabrotUniforms> for ContentKey {
bailout_sq: u.bailout_sq, bailout_sq: u.bailout_sq,
kind: u.kind, kind: u.kind,
power: u.power, power: u.power,
complex_power: u.complex_power,
r_cap: u.r_cap, r_cap: u.r_cap,
g_cap: u.g_cap, g_cap: u.g_cap,
b_cap: u.b_cap, b_cap: u.b_cap,
+5 -4
View File
@@ -8,8 +8,9 @@ pub mod share;
pub use buddhabrot::{BuddhabrotCallback, BuddhabrotRenderer, BuddhabrotUniforms}; pub use buddhabrot::{BuddhabrotCallback, BuddhabrotRenderer, BuddhabrotUniforms};
pub use reference::{FractalKind, compute_reference, compute_set_reference}; pub use reference::{FractalKind, compute_reference, compute_set_reference};
pub use renderer::{ #[cfg(target_arch = "wasm32")]
ExportRender, FractalCallback, FractalRenderer, MAX_REF_POINTS, Uniforms, pub use renderer::encode_png_with_progress;
encode_png_with_progress, #[cfg(not(target_arch = "wasm32"))]
}; pub use renderer::export_to_png_blocking;
pub use renderer::{ExportRender, FractalCallback, FractalRenderer, MAX_REF_POINTS, Uniforms};
pub use share::ShareState; pub use share::ShareState;
+135 -3
View File
@@ -35,6 +35,30 @@ pub enum FractalKind {
Phoenix = 7, Phoenix = 7,
/// `z -> lambda·z(1 - z)` (logistic map). /// `z -> lambda·z(1 - z)` (logistic map).
Lambda = 8, Lambda = 8,
/// `z -> z^power + c`, where `power` is a complex constant (the
/// `complex_power` argument), via the principal branch `z^p = exp(p·ln z)`.
ComplexMultibrot = 9,
}
impl FractalKind {
pub fn description(&self) -> &str {
match self {
FractalKind::Mandelbrot => {
"The Mandelbrot set is the most famous fractal set, obtained with the simplest escape-time formula. This set represents all Julia fractals: each points of the Mandelbrot set is related to a specific Julia fractal."
}
FractalKind::BurningShip => {
"A variation of the famous Mandelbrot set, using absolute values on the real and imaginary part of each iterations."
}
FractalKind::Tricorn => "The Tricorn set is obtained using the same formula as the Mandelbrot set, taking the complex conjugate of the previous iteration.",
FractalKind::Multibrot => "Multibrot use the same formula as the Mandelbrot set, with a bigger exposant.",
FractalKind::Celtic => "",
FractalKind::Perpendicular => "",
FractalKind::Buffalo => "",
FractalKind::Phoenix => "",
FractalKind::Lambda => "",
FractalKind::ComplexMultibrot => "Like Multibrot, but the exponent itself is a complex number instead of a plain integer, via z^p = exp(p·ln z).",
}
}
} }
/// Reference orbit escapes once |Z|^2 exceeds this. Kept larger than the pixel /// Reference orbit escapes once |Z|^2 exceeds this. Kept larger than the pixel
@@ -57,6 +81,7 @@ pub fn compute_reference(
power: u32, power: u32,
phoenix_p: (f64, f64), phoenix_p: (f64, f64),
lambda_l: (f64, f64), lambda_l: (f64, f64),
complex_power: (f64, f64),
) -> Vec<[f32; 2]> { ) -> Vec<[f32; 2]> {
let cr = c_re.clone().with_precision(precision).value(); let cr = c_re.clone().with_precision(precision).value();
let ci = c_im.clone().with_precision(precision).value(); let ci = c_im.clone().with_precision(precision).value();
@@ -72,6 +97,9 @@ pub fn compute_reference(
// Lambda distortion constant `l` (a small fixed complex number). // Lambda distortion constant `l` (a small fixed complex number).
let lr = big_from_f64(lambda_l.0, precision); let lr = big_from_f64(lambda_l.0, precision);
let li = big_from_f64(lambda_l.1, precision); let li = big_from_f64(lambda_l.1, precision);
// Complex Multibrot exponent (a fixed complex number).
let cpow_re = big_from_f64(complex_power.0, precision);
let cpow_im = big_from_f64(complex_power.1, precision);
let mut points: Vec<[f32; 2]> = Vec::with_capacity(max_iter as usize + 1); let mut points: Vec<[f32; 2]> = Vec::with_capacity(max_iter as usize + 1);
@@ -142,6 +170,10 @@ pub fn compute_reference(
let lzi = &lr * &zi + &li * &zr; let lzi = &lr * &zi + &li * &zr;
(&lzr * &re2 - &lzi * &im2, re2 * lzi + lzr * im2) (&lzr * &re2 - &lzi * &im2, re2 * lzi + lzr * im2)
} }
FractalKind::ComplexMultibrot => {
let (pr, pi) = complex_pow_complex(&zr, &zi, &cpow_re, &cpow_im, precision);
(pr + &cr, pi + &ci)
}
}; };
// Shift the previous iterate (only the Phoenix arm reads it). // Shift the previous iterate (only the Phoenix arm reads it).
@@ -178,6 +210,32 @@ fn complex_pow(zr: &Big, zi: &Big, power: u32, precision: usize) -> (Big, Big) {
(rr, ri) (rr, ri)
} }
/// `true` if `x` is (numerically) zero. The f64 check is exact for a true
/// zero; only matters here to special-case `ln(0)`.
fn is_big_zero(x: &Big) -> bool {
x.to_f64().value() == 0.0
}
/// `(zr + i zi)^(pr + i pi)` for a complex exponent, via the principal branch
/// `z^p = exp(p·ln z)` where `ln z = ln|z| + i·arg(z)`. Used by
/// `ComplexMultibrot`; must be kept in sync with the shader's `cpow`.
/// `z = 0` is special-cased to `0` (the formula's `ln(0)` would otherwise
/// panic; this is the correct limit for the `Re(p) > 0` region the UI
/// exposes).
fn complex_pow_complex(zr: &Big, zi: &Big, pr: &Big, pi: &Big, precision: usize) -> (Big, Big) {
if is_big_zero(zr) && is_big_zero(zi) {
return (big_zero(precision), big_zero(precision));
}
let r2 = &zr.sqr() + &zi.sqr();
let ln_r = r2.ln() >> 1; // 0.5 * ln(r2) = ln(sqrt(r2)); exact halving.
let theta = zi.atan2(zr);
let exp_re = (pr * &ln_r - pi * &theta).with_precision(precision).value();
let exp_im = (pr * &theta + pi * &ln_r).with_precision(precision).value();
let mag = exp_re.exp();
let (sin_a, cos_a) = exp_im.sin_cos();
(&mag * &cos_a, &mag * &sin_a)
}
/// Convenience: parameter-plane ("Mandelbrot-set") reference (`z0 = 0`, /// Convenience: parameter-plane ("Mandelbrot-set") reference (`z0 = 0`,
/// `c = center`) for any `kind`. /// `c = center`) for any `kind`.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
@@ -190,10 +248,21 @@ pub fn compute_set_reference(
power: u32, power: u32,
phoenix_p: (f64, f64), phoenix_p: (f64, f64),
lambda_l: (f64, f64), lambda_l: (f64, f64),
complex_power: (f64, f64),
) -> Vec<[f32; 2]> { ) -> Vec<[f32; 2]> {
let zero = big_zero(precision); let zero = big_zero(precision);
compute_reference( compute_reference(
&zero, &zero, center_re, center_im, max_iter, precision, kind, power, phoenix_p, lambda_l, &zero,
&zero,
center_re,
center_im,
max_iter,
precision,
kind,
power,
phoenix_p,
lambda_l,
complex_power,
) )
} }
@@ -216,6 +285,7 @@ mod tests {
2, 2,
(0.0, 0.0), (0.0, 0.0),
(0.0, 0.0), (0.0, 0.0),
(0.0, 0.0),
); );
// Independent naive f64 orbit. // Independent naive f64 orbit.
@@ -255,6 +325,7 @@ mod tests {
2, 2,
(0.0, 0.0), (0.0, 0.0),
(0.0, 0.0), (0.0, 0.0),
(0.0, 0.0),
); );
assert_eq!(points.len(), 501, "interior orbit should not escape"); assert_eq!(points.len(), 501, "interior orbit should not escape");
} }
@@ -273,6 +344,7 @@ mod tests {
2, 2,
(0.0, 0.0), (0.0, 0.0),
(0.0, 0.0), (0.0, 0.0),
(0.0, 0.0),
); );
let (c_re, c_im) = (-1.75_f64, -0.03_f64); let (c_re, c_im) = (-1.75_f64, -0.03_f64);
@@ -302,6 +374,7 @@ mod tests {
3, 3,
(0.0, 0.0), (0.0, 0.0),
(0.0, 0.0), (0.0, 0.0),
(0.0, 0.0),
); );
let (c_re, c_im) = (0.3_f64, 0.2_f64); let (c_re, c_im) = (0.3_f64, 0.2_f64);
@@ -337,6 +410,7 @@ mod tests {
2, 2,
(0.0, 0.0), (0.0, 0.0),
(0.0, 0.0), (0.0, 0.0),
(0.0, 0.0),
); );
let (mut zr, mut zi) = (0.15_f64, -0.1_f64); let (mut zr, mut zi) = (0.15_f64, -0.1_f64);
@@ -366,6 +440,7 @@ mod tests {
2, 2,
(0.0, 0.0), (0.0, 0.0),
(0.0, 0.0), (0.0, 0.0),
(0.0, 0.0),
); );
let (c_re, c_im) = (-0.6_f64, 0.4_f64); let (c_re, c_im) = (-0.6_f64, 0.4_f64);
@@ -396,6 +471,7 @@ mod tests {
2, 2,
(0.0, 0.0), (0.0, 0.0),
(0.0, 0.0), (0.0, 0.0),
(0.0, 0.0),
); );
let (c_re, c_im) = (-0.7_f64, -0.2_f64); let (c_re, c_im) = (-0.7_f64, -0.2_f64);
@@ -426,6 +502,7 @@ mod tests {
2, 2,
(0.0, 0.0), (0.0, 0.0),
(0.0, 0.0), (0.0, 0.0),
(0.0, 0.0),
); );
let (c_re, c_im) = (-1.2_f64, -0.35_f64); let (c_re, c_im) = (-1.2_f64, -0.35_f64);
@@ -448,8 +525,17 @@ mod tests {
let cr = Big::try_from(0.5667_f64).unwrap(); let cr = Big::try_from(0.5667_f64).unwrap();
let ci = Big::try_from(0.0_f64).unwrap(); let ci = Big::try_from(0.0_f64).unwrap();
let p = (-0.5_f64, 0.0_f64); let p = (-0.5_f64, 0.0_f64);
let points = let points = compute_set_reference(
compute_set_reference(&cr, &ci, 60, 200, FractalKind::Phoenix, 2, p, (0.0, 0.0)); &cr,
&ci,
60,
200,
FractalKind::Phoenix,
2,
p,
(0.0, 0.0),
(0.0, 0.0),
);
let (c_re, c_im) = (0.5667_f64, 0.0_f64); let (c_re, c_im) = (0.5667_f64, 0.0_f64);
let (mut zr, mut zi) = (0.0_f64, 0.0_f64); let (mut zr, mut zi) = (0.0_f64, 0.0_f64);
@@ -469,4 +555,50 @@ mod tests {
zi = nzi; zi = nzi;
} }
} }
/// Complex Multibrot (power 2.5 + 0.3i) reference matches a naive f64
/// iteration of `z^p = exp(p·ln z)`.
#[test]
fn complex_multibrot_reference_matches_naive_f64() {
let cr = Big::try_from(0.1_f64).unwrap();
let ci = Big::try_from(-0.2_f64).unwrap();
let power = (2.5_f64, 0.3_f64);
let points = compute_set_reference(
&cr,
&ci,
60,
200,
FractalKind::ComplexMultibrot,
2,
(0.0, 0.0),
(0.0, 0.0),
power,
);
// Naive f64 complex power via z^p = exp(p * ln z), ln z = ln|z| + i*arg(z).
fn naive_cpow(zr: f64, zi: f64, pr: f64, pi: f64) -> (f64, f64) {
if zr == 0.0 && zi == 0.0 {
return (0.0, 0.0);
}
let ln_r = 0.5 * (zr * zr + zi * zi).ln();
let theta = zi.atan2(zr);
let exp_re = pr * ln_r - pi * theta;
let exp_im = pr * theta + pi * ln_r;
let mag = exp_re.exp();
(mag * exp_im.cos(), mag * exp_im.sin())
}
let (c_re, c_im) = (0.1_f64, -0.2_f64);
let (mut zr, mut zi) = (0.0_f64, 0.0_f64);
for point in &points {
let tol = 1e-4 * (1.0 + zr.abs().max(zi.abs()));
assert!((point[0] as f64 - zr).abs() < tol, "re: {point:?} vs {zr}");
assert!((point[1] as f64 - zi).abs() < tol, "im: {point:?} vs {zi}");
let (pr, pi) = naive_cpow(zr, zi, power.0, power.1);
let nzr = pr + c_re;
let nzi = pi + c_im;
zr = nzr;
zi = nzi;
}
}
} }
+57
View File
@@ -37,6 +37,7 @@ fn geom_differs(a: &Uniforms, b: &Uniforms) -> bool {
|| a.aa_level != b.aa_level || a.aa_level != b.aa_level
|| a.kind != b.kind || a.kind != b.kind
|| a.power != b.power || a.power != b.power
|| a.complex_power != b.complex_power
|| a.dc_offset != b.dc_offset || a.dc_offset != b.dc_offset
|| a.phoenix_p != b.phoenix_p || a.phoenix_p != b.phoenix_p
|| a.de_coloring != b.de_coloring || a.de_coloring != b.de_coloring
@@ -87,6 +88,9 @@ pub struct Uniforms {
/// Distortion constant `l` for the Lambda map (`l·z(1 - z)`); /// Distortion constant `l` for the Lambda map (`l·z(1 - z)`);
/// ignored by other kinds. /// ignored by other kinds.
pub lambda_l: [f32; 2], pub lambda_l: [f32; 2],
/// Complex exponent for the Complex Multibrot kind (`z^power + c`);
/// ignored by other kinds.
pub complex_power: [f32; 2],
/// 0 = escape-time coloring, 1 = distance-estimation shading. /// 0 = escape-time coloring, 1 = distance-estimation shading.
pub de_coloring: u32, pub de_coloring: u32,
// 0 = classic colors, 1 = shadows // 0 = classic colors, 1 = shadows
@@ -757,6 +761,59 @@ impl ExportRender {
} }
} }
/// Render `er` tile by tile (blocking on the GPU after each tile so progress
/// reflects real work), read it back, and encode the result as PNG bytes.
/// Blocks the calling thread throughout, so it's only for native targets:
/// the UI export path runs it on a background thread, headless rendering
/// runs it directly since it has no frame loop to share a thread with.
#[cfg(not(target_arch = "wasm32"))]
pub fn export_to_png_blocking(
device: &wgpu::Device,
queue: &wgpu::Queue,
er: &ExportRender,
mut on_progress: impl FnMut(&'static str, f32),
) -> Vec<u8> {
// Progress budget: rendering fills [0, RENDER_END], encoding the rest.
const RENDER_END: f32 = 0.6;
for t in 0..er.tiles {
er.render_tile(device, queue, t);
let _ = device.poll(wgpu::PollType::Wait {
submission_index: None,
timeout: None,
});
let done = (t + 1) as f32 / er.tiles as f32;
on_progress("Rendering", RENDER_END * done);
}
er.copy_to_readback(device, queue);
let (tx, rx) = std::sync::mpsc::channel();
er.readback()
.slice(..)
.map_async(wgpu::MapMode::Read, move |res| {
let _ = tx.send(res);
});
let _ = device.poll(wgpu::PollType::Wait {
submission_index: None,
timeout: None,
});
let _ = rx.recv();
on_progress("Encoding", RENDER_END);
let png = {
let data = er
.readback()
.slice(..)
.get_mapped_range()
.expect("map readback buffer");
encode_png_with_progress(&data, er.width, er.height, er.padded_bpr, er.swap_rb, |f| {
on_progress("Encoding", RENDER_END + (0.97 - RENDER_END) * f)
})
};
er.readback().unmap();
png
}
/// Convert a padded BGRA/RGBA readback into tightly-packed RGBA8 and encode it /// Convert a padded BGRA/RGBA readback into tightly-packed RGBA8 and encode it
/// as PNG bytes, reporting progress in `[0, 1]` via `on_progress` as rows are /// as PNG bytes, reporting progress in `[0, 1]` via `on_progress` as rows are
/// streamed to the compressor (encoding is the slow, subdividable phase). /// streamed to the compressor (encoding is the slow, subdividable phase).
+16 -2
View File
@@ -25,6 +25,8 @@ pub struct ShareState {
pub phoenix_p: (f64, f64), pub phoenix_p: (f64, f64),
/// Distortion constant for the Lambda kind (ignored by others). /// Distortion constant for the Lambda kind (ignored by others).
pub lambda_l: (f64, f64), pub lambda_l: (f64, f64),
/// Complex exponent for the Complex Multibrot kind (ignored by others).
pub complex_power: (f64, f64),
pub color_scale: f32, pub color_scale: f32,
pub color_offset: f32, pub color_offset: f32,
/// Palette index (`palette_id` in the shader). /// Palette index (`palette_id` in the shader).
@@ -49,6 +51,7 @@ impl ShareState {
FractalKind::Buffalo => "buffalo", FractalKind::Buffalo => "buffalo",
FractalKind::Phoenix => "phoenix", FractalKind::Phoenix => "phoenix",
FractalKind::Lambda => "lambda", FractalKind::Lambda => "lambda",
FractalKind::ComplexMultibrot => "cmulti",
} }
)); ));
s.push_str(&format!("&pw={}", self.power)); s.push_str(&format!("&pw={}", self.power));
@@ -60,8 +63,12 @@ impl ShareState {
s.push_str(&format!("&px={}&py={}", self.phoenix_p.0, self.phoenix_p.1)); s.push_str(&format!("&px={}&py={}", self.phoenix_p.0, self.phoenix_p.1));
s.push_str(&format!("&lx={}&ly={}", self.lambda_l.0, self.lambda_l.1)); s.push_str(&format!("&lx={}&ly={}", self.lambda_l.0, self.lambda_l.1));
s.push_str(&format!( s.push_str(&format!(
"&cs={}&co={}&pal={}", "&cpr={}&cpi={}",
self.color_scale, self.color_offset, self.palette self.complex_power.0, self.complex_power.1
));
s.push_str(&format!(
"&cs={}&co={}&pal={}&spal={}",
self.color_scale, self.color_offset, self.palette, self.shadow_palette
)); ));
s s
} }
@@ -89,6 +96,7 @@ impl ShareState {
"buffalo" => FractalKind::Buffalo, "buffalo" => FractalKind::Buffalo,
"phoenix" => FractalKind::Phoenix, "phoenix" => FractalKind::Phoenix,
"lambda" => FractalKind::Lambda, "lambda" => FractalKind::Lambda,
"cmulti" => FractalKind::ComplexMultibrot,
_ => FractalKind::Mandelbrot, _ => FractalKind::Mandelbrot,
}) })
.unwrap_or(FractalKind::Mandelbrot), .unwrap_or(FractalKind::Mandelbrot),
@@ -109,6 +117,10 @@ impl ShareState {
map.get("lx").and_then(|s| s.parse().ok()).unwrap_or(-0.5), map.get("lx").and_then(|s| s.parse().ok()).unwrap_or(-0.5),
map.get("ly").and_then(|s| s.parse().ok()).unwrap_or(0.0), map.get("ly").and_then(|s| s.parse().ok()).unwrap_or(0.0),
), ),
complex_power: (
map.get("cpr").and_then(|s| s.parse().ok()).unwrap_or(2.0),
map.get("cpi").and_then(|s| s.parse().ok()).unwrap_or(0.0),
),
color_scale: map.get("cs").and_then(|s| s.parse().ok()).unwrap_or(0.02), color_scale: map.get("cs").and_then(|s| s.parse().ok()).unwrap_or(0.02),
color_offset: map.get("co").and_then(|s| s.parse().ok()).unwrap_or(0.0), color_offset: map.get("co").and_then(|s| s.parse().ok()).unwrap_or(0.0),
palette: map.get("pal").and_then(|s| s.parse().ok()).unwrap_or(0), palette: map.get("pal").and_then(|s| s.parse().ok()).unwrap_or(0),
@@ -134,6 +146,7 @@ mod tests {
julia_c: (-0.123, 0.745), julia_c: (-0.123, 0.745),
phoenix_p: (-0.5, 0.1), phoenix_p: (-0.5, 0.1),
lambda_l: (-0.5, 0.0), lambda_l: (-0.5, 0.0),
complex_power: (2.5, 0.3),
color_scale: 0.02, color_scale: 0.02,
color_offset: 0.25, color_offset: 0.25,
palette: 3, palette: 3,
@@ -149,6 +162,7 @@ mod tests {
assert_eq!(d.iterations, s.iterations); assert_eq!(d.iterations, s.iterations);
assert_eq!(d.julia_c, s.julia_c); assert_eq!(d.julia_c, s.julia_c);
assert_eq!(d.phoenix_p, s.phoenix_p); assert_eq!(d.phoenix_p, s.phoenix_p);
assert_eq!(d.complex_power, s.complex_power);
assert_eq!(d.palette, s.palette); assert_eq!(d.palette, s.palette);
assert_eq!(d.shadow_palette, s.shadow_palette); assert_eq!(d.shadow_palette, s.shadow_palette);
} }
+82
View File
@@ -0,0 +1,82 @@
// Headless PNG rendering: parse the CLI, build the exact same view/state the
// windowed app would from it, then render straight to a file. No window, no
// event loop, no worker-thread debounce (nothing to debounce for a one-shot
// render); it just creates its own wgpu device, computes the reference orbit
// once, and renders through the same `ExportRender` path the "Export PNG"
// button uses.
use eframe::egui_wgpu::wgpu;
use crate::app::{FractalApp, unix_timestamp};
use crate::cli::Cli;
use crate::fractal::{ExportRender, FractalRenderer, export_to_png_blocking};
/// Cap on the output image dimension (px), to stay within GPU texture limits.
const MAX_DIM: u32 = 8192 * 16;
pub fn run(cli: Cli) -> Result<(), String> {
if cli.buddhabrot {
return Err("headless mode doesn't support --buddhabrot yet".into());
}
let width = cli.width.clamp(16, MAX_DIM);
let height = cli.height.clamp(16, MAX_DIM);
let export_path = cli
.export_path
.clone()
.unwrap_or_else(|| format!("fractal-{}.png", unix_timestamp()));
let mut app = FractalApp::default_state();
app.apply_cli(cli);
eprintln!("computing reference orbit…");
app.compute_reference_blocking();
let (device, queue) = pollster::block_on(request_device())?;
let format = wgpu::TextureFormat::Rgba8UnormSrgb;
let renderer = FractalRenderer::new(&device, format);
let (pipeline, bind_group_layout, format) = renderer.export_handles();
let uniforms = app.make_uniforms(width as f64 / height as f64);
let er = ExportRender::new(
&device,
&queue,
pipeline,
&bind_group_layout,
format,
width,
height,
uniforms,
app.reference_points(),
);
eprintln!("rendering {width}×{height}…");
let png = export_to_png_blocking(&device, &queue, &er, |phase, fraction| {
eprint!("\r{phase} {:>3.0}%", fraction * 100.0);
});
eprintln!();
std::fs::write(&export_path, &png).map_err(|e| format!("save failed: {e}"))?;
println!("saved {export_path} ({width}×{height})");
Ok(())
}
/// Set up a wgpu device with no surface/window attached, matching the limits
/// `main::wgpu_options` requests for the windowed app (the fractal fragment
/// shader needs storage buffers, which downlevel/WebGL-style limits disallow).
async fn request_device() -> Result<(wgpu::Device, wgpu::Queue), String> {
let instance = wgpu::Instance::default();
let adapter = instance
.request_adapter(&wgpu::RequestAdapterOptions::default())
.await
.map_err(|e| format!("no compatible GPU adapter: {e}"))?;
adapter
.request_device(&wgpu::DeviceDescriptor {
label: Some("headless fractal device"),
required_features: wgpu::Features::empty(),
required_limits: adapter.limits(),
..Default::default()
})
.await
.map_err(|e| format!("failed to create device: {e}"))
}
+15
View File
@@ -16,6 +16,8 @@ mod view;
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
mod cli; mod cli;
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
mod headless;
#[cfg(not(target_arch = "wasm32"))]
mod worker; mod worker;
use app::FractalApp; use app::FractalApp;
@@ -49,11 +51,24 @@ fn wgpu_options() -> eframe::egui_wgpu::WgpuConfiguration {
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
fn main() -> eframe::Result { fn main() -> eframe::Result {
use clap::Parser as _;
env_logger::builder() env_logger::builder()
.filter_level(log::LevelFilter::Info) .filter_level(log::LevelFilter::Info)
.parse_default_env() .parse_default_env()
.init(); .init();
let cli = cli::Cli::parse();
if cli.headless {
return match headless::run(cli) {
Ok(()) => Ok(()),
Err(e) => {
eprintln!("error: {e}");
std::process::exit(1);
}
};
}
let native_options = eframe::NativeOptions { let native_options = eframe::NativeOptions {
renderer: eframe::Renderer::Wgpu, renderer: eframe::Renderer::Wgpu,
wgpu_options: wgpu_options(), wgpu_options: wgpu_options(),
+26 -7
View File
@@ -47,14 +47,15 @@ struct Uniforms {
// Tonemap colour style: 0 = classic (R/G/B = raw caps), 1 = nebula // Tonemap colour style: 0 = classic (R/G/B = raw caps), 1 = nebula
// (yellow core, blue halo), 2 = grayscale. // (yellow core, blue halo), 2 = grayscale.
palette: u32, palette: u32,
// Padding to a 16-byte multiple. NOT vec3<u32> — that type aligns to 16 // Padding so `complex_power` (a vec2, 8-byte aligned) starts on an
// bytes in WGSL (unlike Rust's `[u32; 3]`, which aligns to 4), which // 8-byte boundary. NOT vec3<u32> — that type aligns to 16 bytes in WGSL
// silently added 32 bytes instead of 16 and mismatched the Rust struct's // (unlike Rust's `[u32; 3]`, which aligns to 4), which silently added 32
// size (a wgpu validation error at dispatch time: "size 96 where the // bytes instead of 16 and mismatched the Rust struct's size (a wgpu
// shader expects 112"). // validation error at dispatch time: "size 96 where the shader expects
// 112").
_pad0: u32, _pad0: u32,
_pad1: u32, // Complex exponent for the Complex Multibrot kind; unused by other kinds.
_pad2: u32, complex_power: vec2<f32>,
}; };
const PALETTE_NEBULA: u32 = 0u; const PALETTE_NEBULA: u32 = 0u;
@@ -70,6 +71,7 @@ const KIND_PERPENDICULAR: u32 = 5u;
const KIND_BUFFALO: u32 = 6u; const KIND_BUFFALO: u32 = 6u;
const KIND_PHOENIX: u32 = 7u; const KIND_PHOENIX: u32 = 7u;
const KIND_LAMBDA: u32 = 8u; const KIND_LAMBDA: u32 = 8u;
const KIND_COMPLEX_MULTIBROT: u32 = 9u;
@group(0) @binding(0) var<uniform> u: Uniforms; @group(0) @binding(0) var<uniform> u: Uniforms;
// Compute pass: read-write atomic histogram (3 planes of width*height, R/G/B). // Compute pass: read-write atomic histogram (3 planes of width*height, R/G/B).
@@ -103,6 +105,21 @@ fn complex_pow(z: vec2<f32>, p: u32) -> vec2<f32> {
return r; return r;
} }
// z^p for a complex exponent p, via the principal branch z^p = exp(p * ln z),
// ln z = ln|z| + i*arg(z). z = 0 maps to 0 (the correct limit for the
// Re(p) > 0 region the UI exposes; ln(0) would otherwise be -inf).
fn cpow(z: vec2<f32>, p: vec2<f32>) -> vec2<f32> {
let r2 = dot(z, z);
if r2 < 1e-30 {
return vec2<f32>(0.0, 0.0);
}
let ln_r = 0.5 * log(r2);
let theta = atan2(z.y, z.x);
let mag = exp(p.x * ln_r - p.y * theta);
let ang = p.x * theta + p.y * ln_r;
return mag * vec2<f32>(cos(ang), sin(ang));
}
// One iteration step z_n -> z_{n+1} for the current kind. `zp` is the // One iteration step z_n -> z_{n+1} for the current kind. `zp` is the
// previous iterate (z_{n-1}), used only by the Phoenix two-term recurrence. // previous iterate (z_{n-1}), used only by the Phoenix two-term recurrence.
// Must match `FractalKind` in reference.rs (the direct, non-perturbative form // Must match `FractalKind` in reference.rs (the direct, non-perturbative form
@@ -126,6 +143,8 @@ fn advance(z: vec2<f32>, zp: vec2<f32>, c: vec2<f32>) -> vec2<f32> {
} else if u.kind == KIND_LAMBDA { } else if u.kind == KIND_LAMBDA {
// l * z * (1 - z); c is unused (see file doc comment above). // l * z * (1 - z); c is unused (see file doc comment above).
return cmul(u.lambda_l, cmul(z, vec2<f32>(1.0 - z.x, -z.y))); return cmul(u.lambda_l, cmul(z, vec2<f32>(1.0 - z.x, -z.y)));
} else if u.kind == KIND_COMPLEX_MULTIBROT {
return cpow(z, u.complex_power) + c;
} }
return vec2<f32>(z.x * z.x - z.y * z.y, 2.0 * z.x * z.y) + c; // Mandelbrot return vec2<f32>(z.x * z.x - z.y * z.y, 2.0 * z.x * z.y) + c; // Mandelbrot
} }
+2 -1
View File
@@ -27,6 +27,7 @@ struct Uniforms {
dc_offset: vec2<f32>, dc_offset: vec2<f32>,
phoenix_p: vec2<f32>, phoenix_p: vec2<f32>,
lambda_l: vec2<f32>, lambda_l: vec2<f32>,
complex_power: vec2<f32>,
de_coloring: u32, de_coloring: u32,
shadow: u32, shadow: u32,
}; };
@@ -159,7 +160,7 @@ fn fs_main(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
let interior_frac = d.b; let interior_frac = d.b;
let t = fract(ci * u.color_scale + u.color_offset); let t = fract(ci * u.color_scale + u.color_offset);
var col = palette(u.palette_id, t) * de; var col = palette(u.palette_id, t) * sqrt(de);
// Anti-alias the set boundary: fade toward black by the fraction of the // Anti-alias the set boundary: fade toward black by the fraction of the
// pixel's sub-samples that landed in the interior. // pixel's sub-samples that landed in the interior.
col = col * (1.0 - interior_frac); col = col * (1.0 - interior_frac);
+62
View File
@@ -34,6 +34,9 @@ struct Uniforms {
// Distortion constant l for the Lambda map (l*z(1 - z_{n-1})); unused // Distortion constant l for the Lambda map (l*z(1 - z_{n-1})); unused
// by other kinds. // by other kinds.
lambda_l: vec2<f32>, lambda_l: vec2<f32>,
// Complex exponent for the Complex Multibrot kind (z^power + c); unused
// by other kinds.
complex_power: vec2<f32>,
// 0 = escape-time coloring, 1 = distance-estimation shading. // 0 = escape-time coloring, 1 = distance-estimation shading.
de_coloring: u32, de_coloring: u32,
// 0 = classic colors, 1 = shadows // 0 = classic colors, 1 = shadows
@@ -49,6 +52,7 @@ const KIND_PERPENDICULAR: u32 = 5u;
const KIND_BUFFALO: u32 = 6u; const KIND_BUFFALO: u32 = 6u;
const KIND_PHOENIX: u32 = 7u; const KIND_PHOENIX: u32 = 7u;
const KIND_LAMBDA: u32 = 8u; const KIND_LAMBDA: u32 = 8u;
const KIND_COMPLEX_MULTIBROT: u32 = 9u;
@group(0) @binding(0) var<uniform> u: Uniforms; @group(0) @binding(0) var<uniform> u: Uniforms;
@group(0) @binding(1) var<storage, read> ref_orbit: array<vec2<f32>>; @group(0) @binding(1) var<storage, read> ref_orbit: array<vec2<f32>>;
@@ -84,6 +88,27 @@ fn conj(a: vec2<f32>) -> vec2<f32> {
return vec2<f32>(a.x, -a.y); return vec2<f32>(a.x, -a.y);
} }
// Complex division a / b.
fn cdiv(a: vec2<f32>, b: vec2<f32>) -> vec2<f32> {
let d = dot(b, b);
return vec2<f32>(a.x * b.x + a.y * b.y, a.y * b.x - a.x * b.y) / d;
}
// z^p for a complex exponent p, via the principal branch z^p = exp(p * ln z),
// ln z = ln|z| + i*arg(z). z = 0 maps to 0 (the correct limit for the
// Re(p) > 0 region the UI exposes; ln(0) would otherwise be -inf).
fn cpow(z: vec2<f32>, p: vec2<f32>) -> vec2<f32> {
let r2 = dot(z, z);
if r2 < 1e-30 {
return vec2<f32>(0.0, 0.0);
}
let ln_r = 0.5 * log(r2);
let theta = atan2(z.y, z.x);
let mag = exp(p.x * ln_r - p.y * theta);
let ang = p.x * theta + p.y * ln_r;
return mag * vec2<f32>(cos(ang), sin(ang));
}
// |c + d| - |c|, evaluated exactly (no catastrophic cancellation even when the // |c + d| - |c|, evaluated exactly (no catastrophic cancellation even when the
// sum crosses zero). This is what makes the Burning Ship delta correct through // sum crosses zero). This is what makes the Burning Ship delta correct through
// the sign flips that happen all along the axes, where the ship's detail lives. // the sign flips that happen all along the axes, where the ship's detail lives.
@@ -123,6 +148,38 @@ fn multibrot_delta(z: vec2<f32>, e: vec2<f32>, p: u32) -> vec2<f32> {
return acc; return acc;
} }
// Number of terms kept in `complex_multibrot_delta`'s series. Truncation, not
// exactness: unlike `multibrot_delta` (a finite binomial sum for an integer
// power), a complex power has no finite expansion, so this converges rather
// than terminates. Fine as long as perturbation's usual invariant (|e| << |z|,
// kept true by rebasing) holds, since each extra term is O(w^k) smaller.
const COMPLEX_MULTIBROT_TERMS: u32 = 16u;
// Perturbation delta for z -> z^p with a complex p: (Z+e)^p - Z^p.
// = Z^p * ((1+w)^p - 1), w = e/Z, expanded as a Taylor series in w (never
// forming 1+w, which would round tiny w away in f32 — the same reason
// `multibrot_delta` never forms Z+e directly). Series: (1+w)^p - 1 =
// sum_{k=1}^N C(p,k) w^k, with the complex binomial coefficient built up
// incrementally: C(p,k) = C(p,k-1) * (p-(k-1)) / k.
//
// Z ~ 0 (the reference start, X_0 = 0 for Mandelbrot) makes w singular; there
// (0+e)^p - 0^p = e^p exactly, so that case is handled directly via `cpow`.
fn complex_multibrot_delta(z: vec2<f32>, e: vec2<f32>, p: vec2<f32>) -> vec2<f32> {
if dot(z, z) < 1e-20 {
return cpow(e, p);
}
let w = cdiv(e, z);
var wk = vec2<f32>(1.0, 0.0); // w^0
var coef = vec2<f32>(1.0, 0.0); // C(p,0)
var acc = vec2<f32>(0.0, 0.0);
for (var k: u32 = 1u; k <= COMPLEX_MULTIBROT_TERMS; k = k + 1u) {
coef = cdiv(cmul(coef, p - vec2<f32>(f32(k - 1u), 0.0)), vec2<f32>(f32(k), 0.0));
wk = cmul(wk, w);
acc = acc + cmul(coef, wk);
}
return cmul(cpow(z, p), acc);
}
// One perturbation step of the current fractal's delta: e -> f(Z+e) - f(Z), // One perturbation step of the current fractal's delta: e -> f(Z+e) - f(Z),
// where `z` is the reference orbit value X_m. `step_add` (dc) is added by the // where `z` is the reference orbit value X_m. `step_add` (dc) is added by the
// caller. Must match `FractalKind` on the CPU side. // caller. Must match `FractalKind` on the CPU side.
@@ -163,6 +220,8 @@ fn advance_delta(z: vec2<f32>, e: vec2<f32>) -> vec2<f32> {
// Lambda map: z^{n+1} = λ·z·(1-z). Delta: e = λ·e·(1-2z-e). // Lambda map: z^{n+1} = λ·z·(1-z). Delta: e = λ·e·(1-2z-e).
let one_minus_2z_minus_e = vec2<f32>(1.0 - 2.0 * z.x - e.x, -2.0 * z.y - e.y); let one_minus_2z_minus_e = vec2<f32>(1.0 - 2.0 * z.x - e.x, -2.0 * z.y - e.y);
return cmul(u.lambda_l, cmul(e, one_minus_2z_minus_e)); return cmul(u.lambda_l, cmul(e, one_minus_2z_minus_e));
} else if u.kind == KIND_COMPLEX_MULTIBROT {
return complex_multibrot_delta(z, e, u.complex_power);
} }
return 2.0 * cmul(z, e) + cmul(e, e); // Mandelbrot (and Phoenix square part) return 2.0 * cmul(z, e) + cmul(e, e); // Mandelbrot (and Phoenix square part)
} }
@@ -183,6 +242,9 @@ fn fprime(z: vec2<f32>) -> vec2<f32> {
} else if u.kind == KIND_LAMBDA { } else if u.kind == KIND_LAMBDA {
// Lambda: f'(z) = λ·(1-2z). // Lambda: f'(z) = λ·(1-2z).
return cmul(u.lambda_l, vec2<f32>(1.0 - 2.0 * z.x, -2.0 * z.y)); return cmul(u.lambda_l, vec2<f32>(1.0 - 2.0 * z.x, -2.0 * z.y));
} else if u.kind == KIND_COMPLEX_MULTIBROT {
// f'(z) = p * z^(p-1).
return cmul(u.complex_power, cpow(z, u.complex_power - vec2<f32>(1.0, 0.0)));
} }
return 2.0 * z; return 2.0 * z;
} }
+4
View File
@@ -26,6 +26,8 @@ pub struct RefRequest {
pub phoenix_p: (f64, f64), pub phoenix_p: (f64, f64),
/// Distortion constant for the Lambda map (ignored by other kinds). /// Distortion constant for the Lambda map (ignored by other kinds).
pub lambda_l: (f64, f64), pub lambda_l: (f64, f64),
/// Complex exponent for the Complex Multibrot kind (ignored by other kinds).
pub complex_power: (f64, f64),
} }
pub struct RefResult { pub struct RefResult {
@@ -107,6 +109,7 @@ fn compute(req: &RefRequest) -> Vec<[f32; 2]> {
req.power, req.power,
req.phoenix_p, req.phoenix_p,
req.lambda_l, req.lambda_l,
req.complex_power,
) )
} else { } else {
compute_set_reference( compute_set_reference(
@@ -118,6 +121,7 @@ fn compute(req: &RefRequest) -> Vec<[f32; 2]> {
req.power, req.power,
req.phoenix_p, req.phoenix_p,
req.lambda_l, req.lambda_l,
req.complex_power,
) )
} }
} }