Compare commits

..
5 Commits
Author SHA1 Message Date
survandClaude Sonnet 5 9555353404 feat: add keyboard shortcuts for pan/zoom/iterations/AA
Arrow keys pan, Z/S zoom in/out, +/- adjust iteration count, R resets
the view, H/I toggle the Help/Info windows, and A toggles 2x2
antialiasing. Disabled while a text field has focus.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018GFBP9qrNg6NGJWzzP36xe
2026-09-19 18:29:16 +02:00
survandClaude Sonnet 5 bc2a4b3915 feat: add fractal info and help overlays
Adds a bottom-left "Info" button showing the current fractal's formula,
constants, zoom, and iteration count, plus a "Help" window (About +
mouse/touch controls) reachable from the top-left overlay. Both stay
usable even when the controls panel is collapsed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018GFBP9qrNg6NGJWzzP36xe
2026-09-19 18:18:31 +02:00
survandClaude Sonnet 5 7c7ca37c4b feat: add a headless mode, driven by clap CLI args
--headless renders the current view straight to a PNG and exits, with
no window/event loop: it builds its own offscreen wgpu device and reuses
the same reference-orbit + ExportRender path as the "Export PNG" button.
--width/--height set the output resolution (default 1920x1080); not yet
supported with --buddhabrot.

Refactors FractalApp::new's state construction and CLI handling into
default_state()/apply_cli() so headless mode can reuse them, and factors
the PNG tile-render/readback/encode loop into a shared
export_to_png_blocking() used by both the windowed export path and
headless.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018GFBP9qrNg6NGJWzzP36xe
2026-09-19 18:03:54 +02:00
survandClaude Sonnet 5 ff5ac0f262 feat: add clap CLI arg parser, replacing MANDEL_* debug env vars
Prep work for a headless render mode: native debug flags (kind, power,
julia, share, view, de, buddhabrot, export, ...) are now parsed with
clap instead of read from env vars, with proper --help/--version.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018GFBP9qrNg6NGJWzzP36xe
2026-09-19 17:51:03 +02:00
surv 3797a33230 feat: Add a CLAUDE.md file 2026-09-19 12:13:13 +02:00
8 changed files with 871 additions and 122 deletions
+139
View File
@@ -0,0 +1,139 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this is
A deep-zoom fractal explorer (Rust + wgpu + egui + WGSL). It zooms past the
~10¹³× limit of plain `f64` using **perturbation theory**: one high-precision
reference orbit is computed on the CPU (arbitrary precision via `dashu-float`),
and every pixel is rendered on the GPU as a cheap `f32` delta from it, with
rebasing to avoid glitches. The `f32` GPU tier reaches roughly 10³⁰×. Runs
natively (Vulkan/Metal/DX12) and in the browser (WebGPU only — WebGL2 can't do
storage buffers, which the fragment shader needs for the reference orbit).
## Commands
```sh
cargo run --release # native, run (release matters: fractal math is hot)
cargo test # reference-orbit math, share-link round-trip, WGSL validation
cargo test --test shader_valid # just the WGSL parse/validate tests (naga, no GPU needed)
cargo clippy
cargo fmt # rustfmt.toml just pins edition = "2024"
```
Web build (WebGPU):
```sh
rustup target add wasm32-unknown-unknown
cargo install wasm-bindgen-cli --version 0.2.128 # must match the wasm-bindgen crate version
./build-web.sh # -> ./dist
python3 -m http.server -d dist 8080
```
Native CLI flags (`src/cli.rs`, applied in `FractalApp::apply_cli`): `--kind`,
`--power`, `--julia re,im`, `--share <fragment>`,
`--view re,im,half_height[,iterations]`, `--de`, `--buddhabrot`,
`--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`
are the fast, headless way to validate a change. `cargo test` also runs but
doesn't touch the GPU — the reference-orbit tests are pure CPU math (see
below), and `shader_valid` parses/validates WGSL with `naga` statically instead
of creating a pipeline.
## Architecture
### The perturbation pipeline (the core mechanism, spans several files)
For a pixel at parameter `c = C_ref + dc`, its orbit is written as
`y_n = X_n + e_n`, where `X_n` is the (shared, high-precision) reference orbit
and `e_n` is a small `f32` delta. Whenever `|y_n| < |e_n|` (or the reference
runs out), rebase: `e ← y_n − X_0`, restart the reference index at 0. This is
what makes deep zoom cheap — one expensive high-precision orbit, then every
pixel is a handful of `f32` complex multiplies.
- `src/view.rs` — `ViewState`; center is arbitrary-precision `FBig` (`Big`
type alias), pixel scale stays `f64` (still in-range at 10³⁰×). Precision
(bits) scales with zoom depth (`precision_for`).
- `src/fractal/reference.rs` — `FractalKind` enum (Mandelbrot, Burning Ship,
Tricorn, Multibrot, Celtic, Perpendicular, Buffalo, Phoenix, Lambda) and
`compute_reference`/`compute_set_reference`: iterate the chosen formula at
high precision on the CPU, emitting `Z_n` as `f32` pairs — that's the
reference orbit the GPU perturbs from.
- `src/shaders/mandelbrot.wgsl` — the perturbation fragment shader.
`advance_delta(z, e)` is the per-kind delta step (`z` = reference point,
`e` = current delta); the caller adds `step_add` (= `dc`) afterward — this
relies on `c` being additive in every current kind's formula (a kind where
it isn't, e.g. a rational map with `c` in a denominator, would need its own
step function that consumes `dc` internally instead, plus extra per-step
reference data since the orbit point alone wouldn't be enough to recover an
exact delta). `fprime(z)` is the derivative used for distance-estimation
(DE) shading; exact for holomorphic kinds, an approximation (`~2Z`) for the
abs-based ones. A `KIND_*` constant here must match the matching
`FractalKind` variant's discriminant exactly.
- `src/fractal/renderer.rs` — `FractalRenderer` (wgpu pipelines, uniform +
storage buffers, bind groups), `Uniforms` (repr(C) layout that must match
the WGSL `Uniforms` struct field-for-field, including padding), and
`FractalCallback` (the `egui_wgpu::CallbackTrait` impl: `prepare()` uploads
changed buffers and decides whether to re-run the iterate pass, the cheap
colourise pass, or just blit the cached texture). Also `ExportRender`, a
self-contained tiled renderer used for PNG export off the UI thread.
- `src/worker.rs` — native background thread for reference-orbit computation
(coalesces bursts of requests so a fast drag doesn't compute every
intermediate view). The wasm32 build computes inline instead (see the
`#[cfg(target_arch = "wasm32")]` branch in `app.rs::ensure_reference`) —
**any signature change to `compute_reference`/`compute_set_reference` or
`RefRequest`/`RefResult` must be applied to both call sites.**
- `src/app.rs` — `FractalApp` (the egui app + all UI). Key methods:
`should_request`/`ensure_reference` (decide when the reference is stale and
dispatch/collect it), `make_uniforms` (assemble the per-frame `Uniforms`),
`tick_animations` (drives the "morph c/p/λ" and auto-zoom animations),
`default_view_for` (per-kind starting view). `KINDS`, `JULIA_PRESETS`, and
`SET_PRESETS` are sized as `[T; FractalKind::<last variant> as usize + 1]` —
adding a new `FractalKind` means bumping all three (and adding an empty
`&[]` slot to the two preset arrays if the kind has none).
- `src/fractal/share.rs` — `ShareState`: encodes the full view (mode, kind,
full-precision decimal center, zoom, iterations, per-kind constants,
coloring) as a `#`-fragment URL for bookmarking/sharing deep-zoom locations.
### Adding a new `FractalKind`
Touches, in order: `reference.rs` (enum variant + CPU iteration formula, and a
test comparing against a naive `f64` iteration), `mandelbrot.wgsl` (matching
`KIND_*` const + `advance_delta`/`fprime` arms), `buddhabrot.wgsl` (matching
arm in `advance()`, if the kind makes sense as a Buddhabrot), `renderer.rs`
`Uniforms` (only if the kind needs a new per-kind constant, e.g. Phoenix's
`phoenix_p`), `share.rs` (encode/decode string tag), `app.rs` (`KINDS` label,
`JULIA_PRESETS`/`SET_PRESETS` slot, `default_view_for` entry, and optionally a
UI control for its constant + an animation toggle, following the
Phoenix/Lambda pattern). If `c` doesn't enter the formula additively (e.g. a
rational map with `c` in a denominator), the `advance_delta`/`step_add` split
doesn't work — that needs its own step function plus extra per-step reference
data uploaded in a second GPU buffer alongside the orbit.
### Buddhabrot is a separate pipeline
`src/fractal/buddhabrot.rs` + `src/shaders/buddhabrot.wgsl` implement the
Monte-Carlo orbit-density histogram. It does **not** use the perturbation/
reference-orbit machinery: a Buddhabrot sample's orbit scatters across the
whole image rather than staying in one pixel, so it's plain `f32` iteration
from the live view (no deep zoom) via a compute pass that accumulates into a
histogram buffer, tone-mapped by a fragment pass every frame. Its own
`KIND_*` iteration formulas in `advance()` must be kept in sync with
`reference.rs` by hand (there's no shared code path).
### Two-pass render + caching (`renderer.rs`)
The interactive path splits iteration (expensive, perturbation) from
colourising (cheap, palette remap) into separate offscreen textures, so
palette/color-scale/offset tweaks skip re-iteration entirely (`geom_differs`
vs `color_differs` in `renderer.rs` decide which pass reruns). While the user
is actively panning/zooming, the app renders downscaled with AA off
(`INTERACT_DOWNSCALE`) and snaps back to full resolution once input settles
(`INTERACT_SETTLE`).
+2
View File
@@ -13,6 +13,8 @@ png = "0.18.1"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
env_logger = "0.11.11"
clap = { version = "4.5.51", features = ["derive"] }
pollster = "1.0.1"
[target.'cfg(target_arch = "wasm32")'.dependencies]
futures-channel = { version = "0.3.34", default-features = false, features = ["alloc", "std"] }
+474 -117
View File
@@ -2,19 +2,23 @@ use std::sync::{Arc, Mutex};
use eframe::CreationContext;
use eframe::egui_wgpu;
#[cfg(target_arch = "wasm32")]
use eframe::egui_wgpu::wgpu;
#[cfg(not(target_arch = "wasm32"))]
use crate::cli::Cli;
use crate::fractal::{
BuddhabrotCallback, BuddhabrotRenderer, BuddhabrotUniforms, ExportRender, FractalCallback,
FractalKind, FractalRenderer, MAX_REF_POINTS, ShareState, Uniforms,
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::view::{
Big, DEFAULT_HALF_HEIGHT, ViewState, big_from_decimal_str, big_from_f64, big_to_decimal_str,
precision_for,
};
#[cfg(not(target_arch = "wasm32"))]
use clap::Parser;
const BAILOUT_SQ: f32 = 1.0e6;
/// Cap on exported image dimension (px), to stay within GPU texture limits.
@@ -63,6 +67,34 @@ fn kind_label(kind: FractalKind) -> &'static str {
.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.
fn kind_formula(kind: FractalKind, power: u32) -> 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(),
}
}
/// Shorten a long decimal string for compact display, keeping the leading
/// (most significant) digits and marking the cut with "…".
fn truncate_digits(s: &str, max_len: usize) -> String {
if s.chars().count() <= max_len {
s.to_string()
} else {
let head: String = s.chars().take(max_len).collect();
format!("{head}…")
}
}
type JuliaPreset = (&'static str, f64, f64, u32, Option<(f64, f64)>);
/// Nice-looking Julia constants offered as presets.
@@ -299,6 +331,11 @@ pub struct FractalApp {
/// 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,
@@ -338,6 +375,9 @@ pub struct FractalApp {
export_requested: bool,
/// Progress/handle for an in-flight PNG export, if any.
export: Option<Arc<Mutex<ExportShared>>>,
/// Output path for `--export` (native CLI only); falls back to a
/// timestamped name when unset.
export_path: Option<String>,
/// Short status line (saved path, "link copied", errors).
status: Option<String>,
@@ -388,6 +428,27 @@ impl FractalApp {
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);
}
}
// 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();
@@ -397,7 +458,7 @@ impl FractalApp {
let center_im_edit = big_to_decimal_str(&view.center_im, sig);
let zoom_edit = format_magnification(view.magnification());
let mut app = Self {
Self {
view,
mode: FractalMode::Mandelbrot,
kind: FractalKind::Mandelbrot,
@@ -424,6 +485,8 @@ impl FractalApp {
buddha_accumulate: true,
controls_open: true,
fullscreen: false,
info_open: false,
help_open: false,
anim: AnimState::default(),
fps: 0.0,
fps_frames: 0,
@@ -442,78 +505,59 @@ impl FractalApp {
last_interact_time: -1.0e9,
export_requested: false,
export: None,
export_path: None,
status: None,
center_re_edit,
center_im_edit,
zoom_edit,
zoom_edited: false,
};
}
}
// On the web, restore a shared view from the URL fragment (#...).
#[cfg(target_arch = "wasm32")]
if let Some(frag) = web_location_hash() {
if let Some(state) = ShareState::decode(&frag) {
app.apply_share(&state);
/// 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(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);
}
}
// Debug/testing hooks.
#[cfg(not(target_arch = "wasm32"))]
if let Some(frag) = cli.share
&& let Some(state) = ShareState::decode(&frag)
{
if let Ok(k) = std::env::var("MANDEL_KIND") {
app.kind = match k.trim().to_ascii_lowercase().as_str() {
"burningship" | "burning_ship" | "ship" => FractalKind::BurningShip,
"tricorn" | "mandelbar" => FractalKind::Tricorn,
"multibrot" | "multi" => FractalKind::Multibrot,
"celtic" => FractalKind::Celtic,
"perpendicular" | "perp" => FractalKind::Perpendicular,
"buffalo" => FractalKind::Buffalo,
"phoenix" => FractalKind::Phoenix,
_ => FractalKind::Mandelbrot,
};
if let Ok(p) = std::env::var("MANDEL_POWER")
&& let Ok(p) = p.trim().parse::<u32>()
{
app.power = p.clamp(2, 8);
}
app.view = Self::default_view_for(app.mode, app.kind);
}
if let Ok(jc) = std::env::var("MANDEL_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>()),
) {
app.mode = FractalMode::Julia;
app.julia_c = (re, im);
app.view = Self::default_view_for(FractalMode::Julia, app.kind);
}
}
if let Ok(frag) = std::env::var("MANDEL_SHARE")
&& let Some(state) = ShareState::decode(&frag)
{
app.apply_share(&state);
}
if let Ok(spec) = std::env::var("MANDEL_VIEW") {
app.apply_view_spec(&spec);
}
if std::env::var("MANDEL_DE").is_ok() {
app.de_coloring = true;
}
if std::env::var("MANDEL_BUDDHABROT").is_ok() {
app.buddhabrot = true;
}
if let Ok(p) = std::env::var("MANDEL_BUDDHA_PALETTE")
&& let Ok(p) = p.trim().parse::<u32>()
{
app.buddha_palette = p.min(BUDDHA_PALETTE_NAMES.len() as u32 - 1);
}
if std::env::var("MANDEL_EXPORT").is_ok() {
app.export_requested = true;
}
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.buddhabrot = true;
}
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;
}
app
}
/// Apply a view spec "re,im,half_height[,iterations]" (re/im are decimal,
@@ -728,6 +772,14 @@ impl FractalApp {
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
/// thread and pick up completed results. Web: compute inline.
fn ensure_reference(&mut self) {
@@ -806,7 +858,63 @@ 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,
)
} else {
compute_set_reference(
&key.center_re,
&key.center_im,
max_iter,
precision,
key.kind,
key.power,
key.phoenix_p,
key.lambda_l,
)
};
self.apply_reference(
points,
key.center_re.clone(),
key.center_im.clone(),
key.half_height,
);
self.last_request = Some(key);
}
pub(crate) fn make_uniforms(&self, aspect: f64) -> Uniforms {
let (span_x, span_y) = self.view.span(aspect);
Uniforms {
span: [span_x as f32, span_y as f32],
@@ -907,13 +1015,12 @@ impl FractalApp {
self.status = None;
self.export = Some(Arc::clone(&shared));
// Progress budget: rendering fills [0, RENDER_END], encoding the rest.
const RENDER_END: f32 = 0.6;
#[cfg(not(target_arch = "wasm32"))]
{
let name = std::env::var("MANDEL_EXPORT_PATH")
.unwrap_or_else(|_| format!("fractal-{}.png", unix_timestamp()));
let name = self
.export_path
.clone()
.unwrap_or_else(|| format!("fractal-{}.png", unix_timestamp()));
std::thread::spawn(move || {
let er = ExportRender::new(
&device,
@@ -926,51 +1033,11 @@ impl FractalApp {
uniforms,
reference.as_slice(),
);
// Render the image tile by tile, waiting for each so progress
// reflects real GPU work.
for t in 0..er.tiles {
er.render_tile(&device, &queue, t);
let _ = device.poll(wgpu::PollType::Wait {
submission_index: None,
timeout: None,
let sh = Arc::clone(&shared);
let png =
crate::fractal::export_to_png_blocking(&device, &queue, &er, |phase, f| {
set_progress(&sh, phase, f)
});
let done = (t + 1) as f32 / er.tiles as f32;
set_progress(&shared, "Rendering", RENDER_END * done);
}
er.copy_to_readback(&device, &queue);
// Wait for the copy, then read the mapped bytes.
let (tx, rx) = std::sync::mpsc::channel();
er.readback()
.slice(..)
.map_async(wgpu::MapMode::Read, move |res| {
let _ = tx.send(res);
});
let _ = device.poll(wgpu::PollType::Wait {
submission_index: None,
timeout: None,
});
let _ = rx.recv();
set_progress(&shared, "Encoding", RENDER_END);
let png = {
let data = er
.readback()
.slice(..)
.get_mapped_range()
.expect("map readback buffer");
let sh = Arc::clone(&shared);
crate::fractal::encode_png_with_progress(
&data,
er.width,
er.height,
er.padded_bpr,
er.swap_rb,
|f| set_progress(&sh, "Encoding", RENDER_END + (0.97 - RENDER_END) * f),
)
};
er.readback().unmap();
set_progress(&shared, "Saving", 0.98);
let result = std::fs::write(&name, &png)
@@ -981,6 +1048,8 @@ impl FractalApp {
}
#[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,
@@ -1093,6 +1162,13 @@ impl FractalApp {
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(
@@ -1111,6 +1187,206 @@ 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("ⓘ Info")
.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)"
}
};
ui.label(mode_label);
ui.separator();
ui.label(format!("formula: {}", kind_formula(self.kind, self.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
));
}
ui.separator();
if self.buddhabrot {
ui.label("rendering: Buddhabrot (Monte-Carlo orbit density)");
ui.label(format!(
"sample caps: R {} / G {} / B {}",
self.buddha_r_cap, self.buddha_g_cap, self.buddha_b_cap
));
} else {
ui.label(format!(
"magnification: {}×",
format_magnification(self.view.magnification())
));
ui.label(format!(
"iterations: {}{}",
self.max_iterations,
if self.auto_iterations { " (auto)" } else { "" }
));
ui.label(format!("precision: {} bits", self.view.precision_bits()));
ui.label(format!(
"center re: {}",
truncate_digits(&self.center_re_edit, 24)
));
ui.label(format!(
"center im: {}",
truncate_digits(&self.center_im_edit, 24)
));
}
});
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, Celtic, Perpendicular, Buffalo, Phoenix, Lambda), \
and can zoom in far beyond what ordinary floating-point math \
allows by using perturbation theory: one very precise \
reference orbit is computed once, and every pixel on screen \
is then rendered cheaply as a small offset from that shared \
orbit.",
);
ui.add_space(4.0);
ui.label(
"Mandelbrot mode plots one point per pixel in parameter space \
(does c escape?). Julia mode fixes c and instead varies the \
starting point z₀ across the plane. 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.label(
"Shortcuts are ignored while a text field (e.g. the center/zoom \
edit boxes) has focus. Esc / F11 toggle fullscreen via the \
browser or OS, as usual.",
);
ui.separator();
ui.heading("Tips");
ui.label(
"• The controls panel (top-left \"Menu\" button) holds the \
fractal formula, coloring, animation, and export options.\n\
• The bottom-left \"Info\" button shows details about what's \
currently on screen.\n\
• \"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) {
@@ -1726,6 +2002,84 @@ impl FractalApp {
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. 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.buddhabrot {
// No reference orbit / perturbation machinery: iterate directly in
// f32 from the live view. Progressive accumulation means this
@@ -1844,6 +2198,9 @@ impl eframe::App for FractalApp {
// 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);
@@ -1872,7 +2229,7 @@ fn finish_export(shared: &Arc<Mutex<ExportShared>>, result: Result<String, Strin
}
#[cfg(not(target_arch = "wasm32"))]
fn unix_timestamp() -> u64 {
pub(crate) fn unix_timestamp() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
+98
View File
@@ -0,0 +1,98 @@
// Native command-line arguments. Currently mirrors the old `MANDEL_*` debug
// env vars one-for-one; this is the foundation a future headless (no-window,
// render-to-file) mode will build on.
use clap::{Parser, ValueEnum};
use crate::fractal::FractalKind;
#[derive(Parser, Debug, Default)]
#[command(name = "mandelbrot", about = "Deep-zoom fractal explorer", version)]
pub struct Cli {
/// Fractal formula to render.
#[arg(long, value_enum)]
pub kind: Option<KindArg>,
/// Exponent for the Multibrot kind (z -> z^power + c), clamped to [2, 8].
#[arg(long)]
pub power: Option<u32>,
/// Start in Julia mode with this seed constant.
#[arg(long, value_name = "RE,IM")]
pub julia: Option<String>,
/// Restore a view from a share-link fragment (the part after '#').
#[arg(long, value_name = "FRAGMENT")]
pub share: Option<String>,
/// Jump to a view on startup.
#[arg(long, value_name = "RE,IM,HALF_HEIGHT[,ITERATIONS]")]
pub view: Option<String>,
/// Enable distance-estimation shading.
#[arg(long)]
pub de: bool,
/// Switch to the Buddhabrot renderer.
#[arg(long)]
pub buddhabrot: bool,
/// Buddhabrot tonemap palette index.
#[arg(long, value_name = "INDEX")]
pub buddha_palette: Option<u32>,
/// Render a PNG export on startup.
#[arg(long)]
pub export: bool,
/// Output path for --export/--headless (default: fractal-<timestamp>.png).
#[arg(long, value_name = "PATH")]
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)]
pub enum KindArg {
Mandelbrot,
#[value(alias = "ship")]
BurningShip,
#[value(alias = "mandelbar")]
Tricorn,
#[value(alias = "multi")]
Multibrot,
Celtic,
#[value(alias = "perp")]
Perpendicular,
Buffalo,
Phoenix,
Lambda,
}
impl From<KindArg> for FractalKind {
fn from(k: KindArg) -> Self {
match k {
KindArg::Mandelbrot => FractalKind::Mandelbrot,
KindArg::BurningShip => FractalKind::BurningShip,
KindArg::Tricorn => FractalKind::Tricorn,
KindArg::Multibrot => FractalKind::Multibrot,
KindArg::Celtic => FractalKind::Celtic,
KindArg::Perpendicular => FractalKind::Perpendicular,
KindArg::Buffalo => FractalKind::Buffalo,
KindArg::Phoenix => FractalKind::Phoenix,
KindArg::Lambda => FractalKind::Lambda,
}
}
}
+5 -4
View File
@@ -8,8 +8,9 @@ pub mod share;
pub use buddhabrot::{BuddhabrotCallback, BuddhabrotRenderer, BuddhabrotUniforms};
pub use reference::{FractalKind, compute_reference, compute_set_reference};
pub use renderer::{
ExportRender, FractalCallback, FractalRenderer, MAX_REF_POINTS, Uniforms,
encode_png_with_progress,
};
#[cfg(target_arch = "wasm32")]
pub use renderer::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;
+53
View File
@@ -757,6 +757,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
/// as PNG bytes, reporting progress in `[0, 1]` via `on_progress` as rows are
/// streamed to the compressor (encoding is the slow, subdividable phase).
+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}"))
}
+18 -1
View File
@@ -9,10 +9,14 @@
// and calls the wasm `main`, which boots eframe onto the page's <canvas>.
mod app;
mod lights;
mod fractal;
mod lights;
mod view;
#[cfg(not(target_arch = "wasm32"))]
mod cli;
#[cfg(not(target_arch = "wasm32"))]
mod headless;
#[cfg(not(target_arch = "wasm32"))]
mod worker;
@@ -47,11 +51,24 @@ fn wgpu_options() -> eframe::egui_wgpu::WgpuConfiguration {
#[cfg(not(target_arch = "wasm32"))]
fn main() -> eframe::Result {
use clap::Parser as _;
env_logger::builder()
.filter_level(log::LevelFilter::Info)
.parse_default_env()
.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 {
renderer: eframe::Renderer::Wgpu,
wgpu_options: wgpu_options(),