Compare commits
4
Commits
main
...
4e61121c75
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e61121c75 | ||
|
|
a9a9a247ab | ||
|
|
f34e398223 | ||
|
|
6caa23accd |
@@ -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
|
||||||
|
|||||||
@@ -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"] }
|
||||||
|
|||||||
+396
-82
@@ -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,
|
||||||
@@ -67,6 +67,23 @@ 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.
|
||||||
|
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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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.
|
||||||
@@ -303,6 +320,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 +417,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 +447,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,
|
||||||
@@ -431,6 +474,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,27 +500,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 +521,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 +761,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 +847,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 +1004,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 +1022,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 +1037,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 +1151,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 +1176,173 @@ 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 = if self.buddhabrot {
|
||||||
|
"Buddhabrot mode — orbit density (random c, z₀ = 0)"
|
||||||
|
} else {
|
||||||
|
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();
|
||||||
|
|
||||||
|
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, 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) {
|
||||||
@@ -1594,10 +1826,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,
|
||||||
));
|
));
|
||||||
@@ -1726,6 +1959,84 @@ impl FractalApp {
|
|||||||
ui.ctx().request_repaint();
|
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 {
|
if self.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
|
||||||
@@ -1844,6 +2155,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 +2186,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
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -37,6 +37,26 @@ pub enum FractalKind {
|
|||||||
Lambda = 8,
|
Lambda = 8,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 => "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 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
|
||||||
/// bailout so pixels escaping alongside the reference can still reach their
|
/// bailout so pixels escaping alongside the reference can still reach their
|
||||||
/// bailout before the stored orbit runs out.
|
/// bailout before the stored orbit runs out.
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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
@@ -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(),
|
||||||
|
|||||||
Reference in New Issue
Block a user