diff --git a/Cargo.toml b/Cargo.toml index 78bf296..a78a366 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,6 @@ png = "0.18.1" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] env_logger = "0.11.11" -pollster = "1.0.1" [target.'cfg(target_arch = "wasm32")'.dependencies] console_error_panic_hook = "0.1.7" diff --git a/src/app.rs b/src/app.rs index d2e39f2..5704f2f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,4 +1,4 @@ -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use eframe::CreationContext; use eframe::egui_wgpu; @@ -73,6 +73,16 @@ struct RequestKey { iter: u32, } +/// Shared state for an in-progress PNG export. The worker (a background thread +/// on native, an async task on web) writes `fraction`/`phase` as it goes and +/// sets `result` once when finished; the UI reads it each frame to draw a +/// progress bar and, on completion, to report the outcome. +struct ExportShared { + fraction: f32, + phase: &'static str, + result: Option>, +} + /// Top-level egui application. pub struct FractalApp { view: ViewState, @@ -108,6 +118,8 @@ pub struct FractalApp { last_size_px: egui::Vec2, /// Set when the user requests a PNG export (handled after the panels draw). export_requested: bool, + /// Progress/handle for an in-flight PNG export, if any. + export: Option>>, /// Short status line (saved path, "link copied", errors). status: Option, @@ -186,6 +198,7 @@ impl FractalApp { export_scale: 2.0, last_size_px: egui::vec2(1280.0, 720.0), export_requested: false, + export: None, status: None, center_re_edit, center_im_edit, @@ -466,14 +479,19 @@ impl FractalApp { } /// Render the current view to a PNG at `export_scale` × the on-screen size, - /// then save it (native: file in cwd; web: browser download). + /// then save it (native: file in cwd; web: browser download). Runs off the + /// UI thread so a progress bar can animate; progress lands in `self.export`. fn do_export(&mut self, frame: &mut eframe::Frame) { + if self.export.is_some() { + return; // one export at a time + } let Some(rs) = frame.wgpu_render_state() else { self.status = Some("export unavailable (no wgpu backend)".into()); return; }; if self.reference.is_empty() { self.status = Some("still computing reference…".into()); + self.export_requested = true; // retry once the reference is ready return; } @@ -495,26 +513,98 @@ impl FractalApp { (buffer, bpr, renderer.needs_rb_swap()) }; + let shared = Arc::new(Mutex::new(ExportShared { + fraction: 0.0, + phase: "Rendering", + result: None, + })); + self.status = None; + self.export = Some(Arc::clone(&shared)); + #[cfg(not(target_arch = "wasm32"))] { - let png = pollster::block_on(read_and_encode(&device, buffer, w, h, padded_bpr, swap)); let name = std::env::var("MANDEL_EXPORT_PATH") .unwrap_or_else(|_| format!("fractal-{}.png", unix_timestamp())); - match std::fs::write(&name, &png) { - Ok(_) => self.status = Some(format!("saved {name} ({w}×{h})")), - Err(e) => self.status = Some(format!("save failed: {e}")), - } + std::thread::spawn(move || { + // Wait for the GPU render + copy, then read the mapped bytes. + let (tx, rx) = std::sync::mpsc::channel(); + buffer.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", 0.02); + let png = { + let data = buffer + .slice(..) + .get_mapped_range() + .expect("map readback buffer"); + let sh = Arc::clone(&shared); + crate::fractal::encode_png_with_progress(&data, w, h, padded_bpr, swap, |f| { + set_progress(&sh, "Encoding", 0.02 + 0.93 * f); + }) + }; + buffer.unmap(); + + set_progress(&shared, "Saving", 0.97); + let result = std::fs::write(&name, &png) + .map(|_| format!("saved {name} ({w}×{h})")) + .map_err(|e| format!("save failed: {e}")); + finish_export(&shared, result); + }); } #[cfg(target_arch = "wasm32")] { - self.status = Some(format!("exporting {w}×{h}…")); wasm_bindgen_futures::spawn_local(async move { - let png = read_and_encode(&device, buffer, w, h, padded_bpr, swap).await; + let (tx, rx) = futures_channel::oneshot::channel(); + buffer.slice(..).map_async(wgpu::MapMode::Read, move |res| { + let _ = tx.send(res); + }); + let _ = rx.await; + + set_progress(&shared, "Encoding", 0.02); + let png = { + let data = buffer + .slice(..) + .get_mapped_range() + .expect("map readback buffer"); + let sh = Arc::clone(&shared); + crate::fractal::encode_png_with_progress(&data, w, h, padded_bpr, swap, |f| { + set_progress(&sh, "Encoding", 0.02 + 0.93 * f); + }) + }; + buffer.unmap(); + + set_progress(&shared, "Saving", 0.97); web_download_png(&png, "fractal.png"); + finish_export(&shared, Ok(format!("downloaded {w}×{h}"))); }); } } + /// Pick up a finished export (setting the status line) and keep repainting + /// while one is in flight so its progress bar animates. + fn poll_export(&mut self, ctx: &egui::Context) { + if let Some(shared) = &self.export { + let done = shared.lock().unwrap().result.take(); + match done { + Some(Ok(msg)) => { + self.status = Some(msg); + self.export = None; + } + Some(Err(e)) => { + self.status = Some(e); + self.export = None; + } + None => ctx.request_repaint(), + } + } + } + fn controls_ui(&mut self, ui: &mut egui::Ui) { ui.heading("Fractal Explorer"); ui.separator(); @@ -654,13 +744,17 @@ impl FractalApp { } ui.separator(); + let exporting = self.export.is_some(); ui.horizontal(|ui| { if ui.button("Copy link").clicked() { let url = self.share_url(); ui.ctx().copy_text(url); self.status = Some("link copied".into()); } - if ui.button("Export PNG").clicked() { + if ui + .add_enabled(!exporting, egui::Button::new("Export PNG")) + .clicked() + { self.export_requested = true; } }); @@ -677,7 +771,17 @@ impl FractalApp { (self.last_size_px.y * self.export_scale) as u32, )); }); - if let Some(status) = &self.status { + if let Some(shared) = &self.export { + let (fraction, phase) = { + let s = shared.lock().unwrap(); + (s.fraction, s.phase) + }; + ui.add( + egui::ProgressBar::new(fraction) + .animate(true) + .text(format!("{phase} {:.0}%", fraction * 100.0)), + ); + } else if let Some(status) = &self.status { ui.small(status); } @@ -773,6 +877,8 @@ impl FractalApp { impl eframe::App for FractalApp { fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { + self.poll_export(ui.ctx()); + egui::Panel::right("controls") .default_size(280.0) .show(ui, |ui| self.controls_ui(ui)); @@ -792,40 +898,19 @@ impl eframe::App for FractalApp { } } -/// Map the readback buffer, unpad it, and encode a PNG. Awaited on both targets -/// (blocked on via pollster natively; spawned on the web). -async fn read_and_encode( - device: &wgpu::Device, - buffer: wgpu::Buffer, - width: u32, - height: u32, - padded_bpr: u32, - swap_rb: bool, -) -> Vec { - let (tx, rx) = futures_channel::oneshot::channel(); - buffer.slice(..).map_async(wgpu::MapMode::Read, move |res| { - let _ = tx.send(res); - }); +/// Update an export's progress (phase label + fraction). +fn set_progress(shared: &Arc>, phase: &'static str, fraction: f32) { + let mut s = shared.lock().unwrap(); + s.phase = phase; + s.fraction = fraction; +} - #[cfg(not(target_arch = "wasm32"))] - let _ = device.poll(wgpu::PollType::Wait { - submission_index: None, - timeout: None, - }); - #[cfg(target_arch = "wasm32")] - let _ = device; - - let _ = rx.await; - - let png = { - let data = buffer - .slice(..) - .get_mapped_range() - .expect("map readback buffer"); - crate::fractal::encode_png(&data, width, height, padded_bpr, swap_rb) - }; - buffer.unmap(); - png +/// Mark an export finished with its outcome. +fn finish_export(shared: &Arc>, result: Result) { + let mut s = shared.lock().unwrap(); + s.phase = "Done"; + s.fraction = 1.0; + s.result = Some(result); } #[cfg(not(target_arch = "wasm32"))] diff --git a/src/fractal/mod.rs b/src/fractal/mod.rs index 5a83257..607cd3c 100644 --- a/src/fractal/mod.rs +++ b/src/fractal/mod.rs @@ -6,5 +6,7 @@ pub mod renderer; pub mod share; pub use reference::{compute_mandelbrot_reference, compute_reference}; -pub use renderer::{FractalCallback, FractalRenderer, Uniforms, MAX_REF_POINTS, encode_png}; +pub use renderer::{ + FractalCallback, FractalRenderer, MAX_REF_POINTS, Uniforms, encode_png_with_progress, +}; pub use share::ShareState; diff --git a/src/fractal/renderer.rs b/src/fractal/renderer.rs index 5cf3cfc..e254f17 100644 --- a/src/fractal/renderer.rs +++ b/src/fractal/renderer.rs @@ -418,40 +418,48 @@ impl FractalRenderer { } /// Convert a padded BGRA/RGBA readback into tightly-packed RGBA8 and encode it -/// as PNG bytes. -pub fn encode_png( +/// as PNG bytes, reporting progress in `[0, 1]` via `on_progress` as rows are +/// streamed to the compressor (encoding is the slow, subdividable phase). +pub fn encode_png_with_progress( padded: &[u8], width: u32, height: u32, padded_bpr: u32, swap_rb: bool, + mut on_progress: impl FnMut(f32), ) -> Vec { - let row = (width * 4) as usize; - let mut rgba = vec![0u8; row * height as usize]; - for y in 0..height as usize { - let src_off = y * padded_bpr as usize; - let src = &padded[src_off..src_off + row]; - let dst = &mut rgba[y * row..y * row + row]; - if swap_rb { - for x in 0..width as usize { - dst[x * 4] = src[x * 4 + 2]; - dst[x * 4 + 1] = src[x * 4 + 1]; - dst[x * 4 + 2] = src[x * 4]; - dst[x * 4 + 3] = src[x * 4 + 3]; - } - } else { - dst.copy_from_slice(src); - } - } + use std::io::Write as _; + let row = (width * 4) as usize; let mut out = Vec::new(); { let mut encoder = png::Encoder::new(&mut out, width, height); encoder.set_color(png::ColorType::Rgba); encoder.set_depth(png::BitDepth::Eight); let mut writer = encoder.write_header().expect("png header"); - writer.write_image_data(&rgba).expect("png data"); + let mut stream = writer.stream_writer().expect("png stream"); + let mut line = vec![0u8; row]; + for y in 0..height as usize { + let src_off = y * padded_bpr as usize; + let src = &padded[src_off..src_off + row]; + if swap_rb { + for x in 0..width as usize { + line[x * 4] = src[x * 4 + 2]; + line[x * 4 + 1] = src[x * 4 + 1]; + line[x * 4 + 2] = src[x * 4]; + line[x * 4 + 3] = src[x * 4 + 3]; + } + stream.write_all(&line).expect("png data"); + } else { + stream.write_all(src).expect("png data"); + } + if y % 64 == 0 { + on_progress(y as f32 / height as f32); + } + } + stream.finish().expect("png finish"); } + on_progress(1.0); out }