feat: add a headless mode, driven by clap CLI args

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-20 13:37:33 +02:00
co-authored by Claude Sonnet 5
parent cc0609ffbb
commit 45654c0846
8 changed files with 319 additions and 114 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"] }
+114 -80
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,
@@ -395,6 +395,27 @@ 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);
}
}
// 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,7 +425,7 @@ 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,
@@ -455,27 +476,20 @@ 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,
/// 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"))] #[cfg(not(target_arch = "wasm32"))]
{ pub(crate) fn apply_cli(&mut self, cli: Cli) {
let cli = Cli::parse();
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); self.view = Self::default_view_for(self.mode, self.kind);
} }
if let Some(jc) = cli.julia { if let Some(jc) = cli.julia {
let p: Vec<&str> = jc.split(',').collect(); let p: Vec<&str> = jc.split(',').collect();
@@ -483,37 +497,34 @@ impl FractalApp {
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.mode = FractalMode::Julia;
app.julia_c = (re, im); self.julia_c = (re, im);
app.view = Self::default_view_for(FractalMode::Julia, app.kind); self.view = Self::default_view_for(FractalMode::Julia, self.kind);
} }
} }
if let Some(frag) = cli.share if let Some(frag) = cli.share
&& let Some(state) = ShareState::decode(&frag) && let Some(state) = ShareState::decode(&frag)
{ {
app.apply_share(&state); self.apply_share(&state);
} }
if let Some(spec) = cli.view { if let Some(spec) = cli.view {
app.apply_view_spec(&spec); self.apply_view_spec(&spec);
} }
if cli.de { if cli.de {
app.de_coloring = true; self.de_coloring = true;
} }
if cli.buddhabrot { if cli.buddhabrot {
app.buddhabrot = true; self.buddhabrot = true;
} }
if let Some(p) = cli.buddha_palette { if let Some(p) = cli.buddha_palette {
app.buddha_palette = p.min(BUDDHA_PALETTE_NAMES.len() as u32 - 1); self.buddha_palette = p.min(BUDDHA_PALETTE_NAMES.len() as u32 - 1);
} }
app.export_path = cli.export_path; self.export_path = cli.export_path;
if cli.export { if cli.export {
app.export_requested = true; self.export_requested = true;
} }
} }
app
}
/// 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,
/// parsed at full precision). Used by the native debug env var. /// parsed at full precision). Used by the native debug env var.
#[allow(dead_code)] #[allow(dead_code)]
@@ -726,6 +737,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) {
@@ -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); 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],
@@ -905,9 +980,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 +998,11 @@ impl FractalApp {
uniforms, uniforms,
reference.as_slice(), 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 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); let sh = Arc::clone(&shared);
crate::fractal::encode_png_with_progress( let png =
&data, crate::fractal::export_to_png_blocking(&device, &queue, &er, |phase, f| {
er.width, set_progress(&sh, phase, f)
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 +1013,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,
@@ -1872,7 +1906,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())
+15 -1
View File
@@ -45,9 +45,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)]
+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;
+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 /// 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).
+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::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}"))
}
+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(),