feat: add PNG encoding progress bar

This commit is contained in:
2026-09-15 10:42:39 +02:00
parent a7a34adff2
commit f9a51fd2d7
4 changed files with 160 additions and 66 deletions
-1
View File
@@ -14,7 +14,6 @@ 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"
pollster = "1.0.1"
[target.'cfg(target_arch = "wasm32")'.dependencies] [target.'cfg(target_arch = "wasm32")'.dependencies]
console_error_panic_hook = "0.1.7" console_error_panic_hook = "0.1.7"
+129 -44
View File
@@ -1,4 +1,4 @@
use std::sync::Arc; use std::sync::{Arc, Mutex};
use eframe::CreationContext; use eframe::CreationContext;
use eframe::egui_wgpu; use eframe::egui_wgpu;
@@ -73,6 +73,16 @@ struct RequestKey {
iter: u32, 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<Result<String, String>>,
}
/// Top-level egui application. /// Top-level egui application.
pub struct FractalApp { pub struct FractalApp {
view: ViewState, view: ViewState,
@@ -108,6 +118,8 @@ pub struct FractalApp {
last_size_px: egui::Vec2, last_size_px: egui::Vec2,
/// Set when the user requests a PNG export (handled after the panels draw). /// Set when the user requests a PNG export (handled after the panels draw).
export_requested: bool, export_requested: bool,
/// Progress/handle for an in-flight PNG export, if any.
export: Option<Arc<Mutex<ExportShared>>>,
/// Short status line (saved path, "link copied", errors). /// Short status line (saved path, "link copied", errors).
status: Option<String>, status: Option<String>,
@@ -186,6 +198,7 @@ impl FractalApp {
export_scale: 2.0, export_scale: 2.0,
last_size_px: egui::vec2(1280.0, 720.0), last_size_px: egui::vec2(1280.0, 720.0),
export_requested: false, export_requested: false,
export: None,
status: None, status: None,
center_re_edit, center_re_edit,
center_im_edit, center_im_edit,
@@ -466,14 +479,19 @@ impl FractalApp {
} }
/// Render the current view to a PNG at `export_scale` × the on-screen size, /// 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) { 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 { let Some(rs) = frame.wgpu_render_state() else {
self.status = Some("export unavailable (no wgpu backend)".into()); self.status = Some("export unavailable (no wgpu backend)".into());
return; return;
}; };
if self.reference.is_empty() { if self.reference.is_empty() {
self.status = Some("still computing reference…".into()); self.status = Some("still computing reference…".into());
self.export_requested = true; // retry once the reference is ready
return; return;
} }
@@ -495,26 +513,98 @@ impl FractalApp {
(buffer, bpr, renderer.needs_rb_swap()) (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"))] #[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") let name = std::env::var("MANDEL_EXPORT_PATH")
.unwrap_or_else(|_| format!("fractal-{}.png", unix_timestamp())); .unwrap_or_else(|_| format!("fractal-{}.png", unix_timestamp()));
match std::fs::write(&name, &png) { std::thread::spawn(move || {
Ok(_) => self.status = Some(format!("saved {name} ({w}×{h})")), // Wait for the GPU render + copy, then read the mapped bytes.
Err(e) => self.status = Some(format!("save failed: {e}")), 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")] #[cfg(target_arch = "wasm32")]
{ {
self.status = Some(format!("exporting {w}×{h}…"));
wasm_bindgen_futures::spawn_local(async move { 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"); 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) { fn controls_ui(&mut self, ui: &mut egui::Ui) {
ui.heading("Fractal Explorer"); ui.heading("Fractal Explorer");
ui.separator(); ui.separator();
@@ -654,13 +744,17 @@ impl FractalApp {
} }
ui.separator(); ui.separator();
let exporting = self.export.is_some();
ui.horizontal(|ui| { ui.horizontal(|ui| {
if ui.button("Copy link").clicked() { if ui.button("Copy link").clicked() {
let url = self.share_url(); let url = self.share_url();
ui.ctx().copy_text(url); ui.ctx().copy_text(url);
self.status = Some("link copied".into()); 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; self.export_requested = true;
} }
}); });
@@ -677,7 +771,17 @@ impl FractalApp {
(self.last_size_px.y * self.export_scale) as u32, (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); ui.small(status);
} }
@@ -773,6 +877,8 @@ impl FractalApp {
impl eframe::App for FractalApp { impl eframe::App for FractalApp {
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
self.poll_export(ui.ctx());
egui::Panel::right("controls") egui::Panel::right("controls")
.default_size(280.0) .default_size(280.0)
.show(ui, |ui| self.controls_ui(ui)); .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 /// Update an export's progress (phase label + fraction).
/// (blocked on via pollster natively; spawned on the web). fn set_progress(shared: &Arc<Mutex<ExportShared>>, phase: &'static str, fraction: f32) {
async fn read_and_encode( let mut s = shared.lock().unwrap();
device: &wgpu::Device, s.phase = phase;
buffer: wgpu::Buffer, s.fraction = fraction;
width: u32, }
height: u32,
padded_bpr: u32,
swap_rb: bool,
) -> Vec<u8> {
let (tx, rx) = futures_channel::oneshot::channel();
buffer.slice(..).map_async(wgpu::MapMode::Read, move |res| {
let _ = tx.send(res);
});
#[cfg(not(target_arch = "wasm32"))] /// Mark an export finished with its outcome.
let _ = device.poll(wgpu::PollType::Wait { fn finish_export(shared: &Arc<Mutex<ExportShared>>, result: Result<String, String>) {
submission_index: None, let mut s = shared.lock().unwrap();
timeout: None, s.phase = "Done";
}); s.fraction = 1.0;
#[cfg(target_arch = "wasm32")] s.result = Some(result);
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
} }
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
+3 -1
View File
@@ -6,5 +6,7 @@ pub mod renderer;
pub mod share; pub mod share;
pub use reference::{compute_mandelbrot_reference, compute_reference}; 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; pub use share::ShareState;
+28 -20
View File
@@ -418,40 +418,48 @@ impl FractalRenderer {
} }
/// 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. /// as PNG bytes, reporting progress in `[0, 1]` via `on_progress` as rows are
pub fn encode_png( /// streamed to the compressor (encoding is the slow, subdividable phase).
pub fn encode_png_with_progress(
padded: &[u8], padded: &[u8],
width: u32, width: u32,
height: u32, height: u32,
padded_bpr: u32, padded_bpr: u32,
swap_rb: bool, swap_rb: bool,
mut on_progress: impl FnMut(f32),
) -> Vec<u8> { ) -> Vec<u8> {
let row = (width * 4) as usize; use std::io::Write as _;
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);
}
}
let row = (width * 4) as usize;
let mut out = Vec::new(); let mut out = Vec::new();
{ {
let mut encoder = png::Encoder::new(&mut out, width, height); let mut encoder = png::Encoder::new(&mut out, width, height);
encoder.set_color(png::ColorType::Rgba); encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight); encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().expect("png header"); 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 out
} }