From 45654c0846ecbe57da8e6ffef8259090980a620b Mon Sep 17 00:00:00 2001 From: supersurviveur Date: Sat, 19 Sep 2026 18:03:54 +0200 Subject: [PATCH] feat: add a headless mode, driven by clap CLI args Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 13 ++- Cargo.toml | 1 + src/app.rs | 244 +++++++++++++++++++++++----------------- src/cli.rs | 16 ++- src/fractal/mod.rs | 9 +- src/fractal/renderer.rs | 53 +++++++++ src/headless.rs | 82 ++++++++++++++ src/main.rs | 15 +++ 8 files changed, 319 insertions(+), 114 deletions(-) create mode 100644 src/headless.rs diff --git a/CLAUDE.md b/CLAUDE.md index 9827d15..2f7baf6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 ``` -Native debug env vars (see `src/app.rs`, near the top of `FractalApp::new`): -`MANDEL_KIND`, `MANDEL_POWER`, `MANDEL_JULIA="re,im"`, `MANDEL_SHARE=""`, -`MANDEL_VIEW="re,im,half_height[,iterations]"`, `MANDEL_DE=1`, -`MANDEL_BUDDHABROT=1`, `MANDEL_EXPORT=1` (+ `MANDEL_EXPORT_PATH=out.png`). +Native CLI flags (`src/cli.rs`, applied in `FractalApp::apply_cli`): `--kind`, +`--power`, `--julia re,im`, `--share `, +`--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 diff --git a/Cargo.toml b/Cargo.toml index 80d0dda..5c6dfa9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ 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"] } diff --git a/src/app.rs b/src/app.rs index 92b1cd5..f5c9d05 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2,16 +2,16 @@ 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, @@ -395,6 +395,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(); @@ -404,7 +425,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, @@ -455,63 +476,53 @@ impl FractalApp { 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::()), + p.get(1).map(|s| s.trim().parse::()), + ) { + self.mode = FractalMode::Julia; + self.julia_c = (re, im); + self.view = Self::default_view_for(FractalMode::Julia, self.kind); } } - - // Debug/testing hooks, driven by CLI flags. - #[cfg(not(target_arch = "wasm32"))] + if let Some(frag) = cli.share + && let Some(state) = ShareState::decode(&frag) { - let cli = Cli::parse(); - - if let Some(k) = cli.kind { - app.kind = k.into(); - if let Some(p) = cli.power { - app.power = p.clamp(2, 8); - } - app.view = Self::default_view_for(app.mode, app.kind); - } - if let Some(jc) = cli.julia { - let p: Vec<&str> = jc.split(',').collect(); - if let (Some(Ok(re)), Some(Ok(im))) = ( - p.first().map(|s| s.trim().parse::()), - p.get(1).map(|s| s.trim().parse::()), - ) { - app.mode = FractalMode::Julia; - app.julia_c = (re, im); - app.view = Self::default_view_for(FractalMode::Julia, app.kind); - } - } - if let Some(frag) = cli.share - && let Some(state) = ShareState::decode(&frag) - { - app.apply_share(&state); - } - if let Some(spec) = cli.view { - app.apply_view_spec(&spec); - } - if cli.de { - app.de_coloring = true; - } - if cli.buddhabrot { - app.buddhabrot = true; - } - if let Some(p) = cli.buddha_palette { - app.buddha_palette = p.min(BUDDHA_PALETTE_NAMES.len() as u32 - 1); - } - app.export_path = cli.export_path; - if cli.export { - app.export_requested = true; - } + 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, @@ -726,6 +737,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) { @@ -804,7 +823,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], @@ -905,9 +980,6 @@ 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 = self @@ -926,51 +998,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 +1013,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, @@ -1872,7 +1906,7 @@ fn finish_export(shared: &Arc>, result: Result u64 { +pub(crate) fn unix_timestamp() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) diff --git a/src/cli.rs b/src/cli.rs index 95568fe..3030481 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -45,9 +45,23 @@ pub struct Cli { #[arg(long)] pub export: bool, - /// Output path for --export (default: fractal-.png). + /// Output path for --export/--headless (default: fractal-.png). #[arg(long, value_name = "PATH")] pub export_path: Option, + + /// 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)] diff --git a/src/fractal/mod.rs b/src/fractal/mod.rs index fc3856d..b620602 100644 --- a/src/fractal/mod.rs +++ b/src/fractal/mod.rs @@ -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; diff --git a/src/fractal/renderer.rs b/src/fractal/renderer.rs index 0d37bf4..c8213ed 100644 --- a/src/fractal/renderer.rs +++ b/src/fractal/renderer.rs @@ -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 { + // 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). diff --git a/src/headless.rs b/src/headless.rs new file mode 100644 index 0000000..8e8f881 --- /dev/null +++ b/src/headless.rs @@ -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::Bgra8Unorm; + 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}")) +} diff --git a/src/main.rs b/src/main.rs index 9734236..5170446 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,6 +16,8 @@ mod view; #[cfg(not(target_arch = "wasm32"))] mod cli; #[cfg(not(target_arch = "wasm32"))] +mod headless; +#[cfg(not(target_arch = "wasm32"))] mod worker; use app::FractalApp; @@ -49,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(),