feat: add rendering export progress bar

This commit is contained in:
2026-09-15 11:01:30 +02:00
parent f9a51fd2d7
commit 72c3ea2ee8
3 changed files with 234 additions and 61 deletions
+89 -22
View File
@@ -4,7 +4,9 @@ use eframe::CreationContext;
use eframe::egui_wgpu; use eframe::egui_wgpu;
use eframe::egui_wgpu::wgpu; use eframe::egui_wgpu::wgpu;
use crate::fractal::{FractalCallback, FractalRenderer, MAX_REF_POINTS, ShareState, Uniforms}; use crate::fractal::{
ExportRender, FractalCallback, FractalRenderer, MAX_REF_POINTS, ShareState, Uniforms,
};
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
use crate::fractal::{compute_mandelbrot_reference, compute_reference}; use crate::fractal::{compute_mandelbrot_reference, compute_reference};
use crate::view::{ use crate::view::{
@@ -502,16 +504,15 @@ impl FractalApp {
let device = rs.device.clone(); let device = rs.device.clone();
let queue = rs.queue.clone(); let queue = rs.queue.clone();
let (buffer, padded_bpr, swap) = { let (pipeline, bind_group_layout, format) = {
let guard = rs.renderer.read(); let guard = rs.renderer.read();
let Some(renderer) = guard.callback_resources.get::<FractalRenderer>() else { let Some(renderer) = guard.callback_resources.get::<FractalRenderer>() else {
self.status = Some("export unavailable".into()); self.status = Some("export unavailable".into());
return; return;
}; };
renderer.upload_reference(&queue, &self.reference); renderer.export_handles()
let (buffer, bpr) = renderer.render_to_readback(&device, &queue, w, h, uniforms);
(buffer, bpr, renderer.needs_rb_swap())
}; };
let reference = Arc::clone(&self.reference);
let shared = Arc::new(Mutex::new(ExportShared { let shared = Arc::new(Mutex::new(ExportShared {
fraction: 0.0, fraction: 0.0,
@@ -521,14 +522,42 @@ 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 = 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()));
std::thread::spawn(move || { std::thread::spawn(move || {
// Wait for the GPU render + copy, then read the mapped bytes. let er = ExportRender::new(
&device,
&queue,
pipeline,
&bind_group_layout,
format,
w,
h,
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 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(); let (tx, rx) = std::sync::mpsc::channel();
buffer.slice(..).map_async(wgpu::MapMode::Read, move |res| { er.readback().slice(..).map_async(wgpu::MapMode::Read, move |res| {
let _ = tx.send(res); let _ = tx.send(res);
}); });
let _ = device.poll(wgpu::PollType::Wait { let _ = device.poll(wgpu::PollType::Wait {
@@ -537,20 +566,26 @@ impl FractalApp {
}); });
let _ = rx.recv(); let _ = rx.recv();
set_progress(&shared, "Encoding", 0.02); set_progress(&shared, "Encoding", RENDER_END);
let png = { let png = {
let data = buffer let data = er
.readback()
.slice(..) .slice(..)
.get_mapped_range() .get_mapped_range()
.expect("map readback buffer"); .expect("map readback buffer");
let sh = Arc::clone(&shared); let sh = Arc::clone(&shared);
crate::fractal::encode_png_with_progress(&data, w, h, padded_bpr, swap, |f| { crate::fractal::encode_png_with_progress(
set_progress(&sh, "Encoding", 0.02 + 0.93 * f); &data,
}) er.width,
er.height,
er.padded_bpr,
er.swap_rb,
|f| set_progress(&sh, "Encoding", RENDER_END + (0.97 - RENDER_END) * f),
)
}; };
buffer.unmap(); er.readback().unmap();
set_progress(&shared, "Saving", 0.97); set_progress(&shared, "Saving", 0.98);
let result = std::fs::write(&name, &png) let result = std::fs::write(&name, &png)
.map(|_| format!("saved {name} ({w}×{h})")) .map(|_| format!("saved {name} ({w}×{h})"))
.map_err(|e| format!("save failed: {e}")); .map_err(|e| format!("save failed: {e}"));
@@ -560,26 +595,58 @@ impl FractalApp {
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
{ {
wasm_bindgen_futures::spawn_local(async move { wasm_bindgen_futures::spawn_local(async move {
let er = ExportRender::new(
&device,
&queue,
pipeline,
&bind_group_layout,
format,
w,
h,
uniforms,
reference.as_slice(),
);
// Render tile by tile, awaiting each submission so the browser
// executes it and the UI can repaint between tiles.
for t in 0..er.tiles {
er.render_tile(&device, &queue, t);
let (tx, rx) = futures_channel::oneshot::channel();
queue.on_submitted_work_done(move || {
let _ = tx.send(());
});
let _ = rx.await;
let done = (t + 1) as f32 / er.tiles as f32;
set_progress(&shared, "Rendering", RENDER_END * done);
}
er.copy_to_readback(&device, &queue);
let (tx, rx) = futures_channel::oneshot::channel(); let (tx, rx) = futures_channel::oneshot::channel();
buffer.slice(..).map_async(wgpu::MapMode::Read, move |res| { er.readback().slice(..).map_async(wgpu::MapMode::Read, move |res| {
let _ = tx.send(res); let _ = tx.send(res);
}); });
let _ = rx.await; let _ = rx.await;
set_progress(&shared, "Encoding", 0.02); set_progress(&shared, "Encoding", RENDER_END);
let png = { let png = {
let data = buffer let data = er
.readback()
.slice(..) .slice(..)
.get_mapped_range() .get_mapped_range()
.expect("map readback buffer"); .expect("map readback buffer");
let sh = Arc::clone(&shared); let sh = Arc::clone(&shared);
crate::fractal::encode_png_with_progress(&data, w, h, padded_bpr, swap, |f| { crate::fractal::encode_png_with_progress(
set_progress(&sh, "Encoding", 0.02 + 0.93 * f); &data,
}) er.width,
er.height,
er.padded_bpr,
er.swap_rb,
|f| set_progress(&sh, "Encoding", RENDER_END + (0.97 - RENDER_END) * f),
)
}; };
buffer.unmap(); er.readback().unmap();
set_progress(&shared, "Saving", 0.97); set_progress(&shared, "Saving", 0.98);
web_download_png(&png, "fractal.png"); web_download_png(&png, "fractal.png");
finish_export(&shared, Ok(format!("downloaded {w}×{h}"))); finish_export(&shared, Ok(format!("downloaded {w}×{h}")));
}); });
+2 -1
View File
@@ -7,6 +7,7 @@ pub mod share;
pub use reference::{compute_mandelbrot_reference, compute_reference}; pub use reference::{compute_mandelbrot_reference, compute_reference};
pub use renderer::{ pub use renderer::{
FractalCallback, FractalRenderer, MAX_REF_POINTS, Uniforms, encode_png_with_progress, ExportRender, FractalCallback, FractalRenderer, MAX_REF_POINTS, Uniforms,
encode_png_with_progress,
}; };
pub use share::ShareState; pub use share::ShareState;
+143 -38
View File
@@ -61,6 +61,7 @@ struct RenderedState {
pub struct FractalRenderer { pub struct FractalRenderer {
pipeline: wgpu::RenderPipeline, pipeline: wgpu::RenderPipeline,
bind_group_layout: wgpu::BindGroupLayout,
uniform_buffer: wgpu::Buffer, uniform_buffer: wgpu::Buffer,
ref_buffer: wgpu::Buffer, ref_buffer: wgpu::Buffer,
bind_group: wgpu::BindGroup, bind_group: wgpu::BindGroup,
@@ -242,6 +243,7 @@ impl FractalRenderer {
Self { Self {
pipeline, pipeline,
bind_group_layout,
uniform_buffer, uniform_buffer,
ref_buffer, ref_buffer,
bind_group, bind_group,
@@ -306,37 +308,85 @@ impl FractalRenderer {
self.rendered = None; self.rendered = None;
} }
/// Upload a reference orbit to the storage buffer (used by PNG export to /// Handles needed to build a standalone [`ExportRender`] off the UI thread:
/// guarantee the buffer is current before an offscreen render). /// the (immutable) pipeline and its bind-group layout, plus the target
pub fn upload_reference(&self, queue: &wgpu::Queue, points: &[[f32; 2]]) { /// format. Cloned so the caller can drop the render-state lock before use.
let count = points.len().min(MAX_REF_POINTS); pub fn export_handles(&self) -> (wgpu::RenderPipeline, wgpu::BindGroupLayout, wgpu::TextureFormat) {
if count > 0 { (
queue.write_buffer(&self.ref_buffer, 0, bytemuck::cast_slice(&points[..count])); self.pipeline.clone(),
} self.bind_group_layout.clone(),
}
/// True if the render target stores bytes as BGRA (so a PNG needs R/B
/// swapped). Surfaces are usually `Bgra8UnormSrgb`.
pub fn needs_rb_swap(&self) -> bool {
matches!(
self.target_format, self.target_format,
wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb
) )
} }
}
/// Render the current fractal (using `uniforms` and the already-uploaded /// A self-contained render of one export image. It owns its own uniform and
/// reference orbit) into an offscreen texture at `width`x`height`, then copy /// reference buffers (a snapshot of the view at export time), so it is unaffected
/// it into a mappable buffer. Returns the buffer and its padded row stride. /// by panning/zooming on the main thread, and can run on a background thread.
/// The caller maps the buffer (blocking on native, async on web). /// The image is rendered in horizontal tiles so progress can be reported as the
pub fn render_to_readback( /// GPU works through it.
&self, pub struct ExportRender {
pipeline: wgpu::RenderPipeline,
bind_group: wgpu::BindGroup,
texture: wgpu::Texture,
view: wgpu::TextureView,
readback: wgpu::Buffer,
/// Padded bytes-per-row of the readback buffer.
pub padded_bpr: u32,
pub width: u32,
pub height: u32,
/// Number of horizontal tiles the render is split into.
pub tiles: u32,
pub swap_rb: bool,
}
impl ExportRender {
/// Allocate the export's dedicated GPU resources and upload the snapshot.
#[allow(clippy::too_many_arguments)]
pub fn new(
device: &wgpu::Device, device: &wgpu::Device,
queue: &wgpu::Queue, queue: &wgpu::Queue,
pipeline: wgpu::RenderPipeline,
bind_group_layout: &wgpu::BindGroupLayout,
target_format: wgpu::TextureFormat,
width: u32, width: u32,
height: u32, height: u32,
uniforms: Uniforms, uniforms: Uniforms,
) -> (wgpu::Buffer, u32) { reference: &[[f32; 2]],
queue.write_buffer(&self.uniform_buffer, 0, bytemuck::bytes_of(&uniforms)); ) -> Self {
let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("export uniforms"),
size: std::mem::size_of::<Uniforms>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
queue.write_buffer(&uniform_buffer, 0, bytemuck::bytes_of(&uniforms));
let count = reference.len().min(MAX_REF_POINTS);
let ref_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("export reference orbit"),
size: (count.max(1) * std::mem::size_of::<[f32; 2]>()) as u64,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
if count > 0 {
queue.write_buffer(&ref_buffer, 0, bytemuck::cast_slice(&reference[..count]));
}
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("export bind group"),
layout: bind_group_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: uniform_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: ref_buffer.as_entire_binding(),
},
],
});
let texture = device.create_texture(&wgpu::TextureDescriptor { let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("export target"), label: Some("export target"),
@@ -348,16 +398,14 @@ impl FractalRenderer {
mip_level_count: 1, mip_level_count: 1,
sample_count: 1, sample_count: 1,
dimension: wgpu::TextureDimension::D2, dimension: wgpu::TextureDimension::D2,
format: self.target_format, format: target_format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[], view_formats: &[],
}); });
let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
let unpadded_bpr = width * 4; let padded_bpr = (width * 4).div_ceil(align) * align;
let padded_bpr = unpadded_bpr.div_ceil(align) * align;
let readback = device.create_buffer(&wgpu::BufferDescriptor { let readback = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("export readback"), label: Some("export readback"),
size: (padded_bpr * height) as u64, size: (padded_bpr * height) as u64,
@@ -365,18 +413,62 @@ impl FractalRenderer {
mapped_at_creation: false, mapped_at_creation: false,
}); });
// ~128px bands, kept to a sane range so progress is smooth without too
// many submissions.
let tiles = (height / 128).clamp(8, 64).min(height.max(1));
let swap_rb = matches!(
target_format,
wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb
);
Self {
pipeline,
bind_group,
texture,
view,
readback,
padded_bpr,
width,
height,
tiles,
swap_rb,
}
}
/// Pixel row range `[y0, y1)` covered by tile `t`.
fn tile_rows(&self, t: u32) -> (u32, u32) {
let band = self.height.div_ceil(self.tiles);
let y0 = (t * band).min(self.height);
let y1 = (y0 + band).min(self.height);
(y0, y1)
}
/// Render one horizontal tile into the export texture and submit it. Tile 0
/// clears the whole attachment; later tiles preserve earlier ones.
pub fn render_tile(&self, device: &wgpu::Device, queue: &wgpu::Queue, t: u32) {
let (y0, y1) = self.tile_rows(t);
if y1 <= y0 {
return;
}
let load = if t == 0 {
wgpu::LoadOp::Clear(wgpu::Color::BLACK)
} else {
wgpu::LoadOp::Load
};
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("export"), label: Some("export tile"),
}); });
{ {
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("export pass"), label: Some("export tile pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment { color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view, view: &self.view,
depth_slice: None, depth_slice: None,
resolve_target: None, resolve_target: None,
ops: wgpu::Operations { ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), load,
store: wgpu::StoreOp::Store, store: wgpu::StoreOp::Store,
}, },
})], })],
@@ -385,35 +477,48 @@ impl FractalRenderer {
occlusion_query_set: None, occlusion_query_set: None,
multiview_mask: None, multiview_mask: None,
}); });
// Full-viewport triangle (so pixel→plane mapping matches the whole
// image), scissored to this tile's rows.
pass.set_scissor_rect(0, y0, self.width, y1 - y0);
pass.set_pipeline(&self.pipeline); pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &self.bind_group, &[]); pass.set_bind_group(0, &self.bind_group, &[]);
pass.draw(0..3, 0..1); pass.draw(0..3, 0..1);
} }
queue.submit(std::iter::once(encoder.finish()));
}
/// Copy the finished texture into the mappable readback buffer and submit.
pub fn copy_to_readback(&self, device: &wgpu::Device, queue: &wgpu::Queue) {
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("export copy"),
});
encoder.copy_texture_to_buffer( encoder.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo { wgpu::TexelCopyTextureInfo {
texture: &texture, texture: &self.texture,
mip_level: 0, mip_level: 0,
origin: wgpu::Origin3d::ZERO, origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All, aspect: wgpu::TextureAspect::All,
}, },
wgpu::TexelCopyBufferInfo { wgpu::TexelCopyBufferInfo {
buffer: &readback, buffer: &self.readback,
layout: wgpu::TexelCopyBufferLayout { layout: wgpu::TexelCopyBufferLayout {
offset: 0, offset: 0,
bytes_per_row: Some(padded_bpr), bytes_per_row: Some(self.padded_bpr),
rows_per_image: Some(height), rows_per_image: Some(self.height),
}, },
}, },
wgpu::Extent3d { wgpu::Extent3d {
width, width: self.width,
height, height: self.height,
depth_or_array_layers: 1, depth_or_array_layers: 1,
}, },
); );
queue.submit(std::iter::once(encoder.finish())); queue.submit(std::iter::once(encoder.finish()));
(readback, padded_bpr) }
/// The mappable readback buffer (valid after [`copy_to_readback`]).
pub fn readback(&self) -> &wgpu::Buffer {
&self.readback
} }
} }