diff --git a/src/app.rs b/src/app.rs index 5704f2f..11a46cd 100644 --- a/src/app.rs +++ b/src/app.rs @@ -4,7 +4,9 @@ use eframe::CreationContext; use eframe::egui_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")] use crate::fractal::{compute_mandelbrot_reference, compute_reference}; use crate::view::{ @@ -502,16 +504,15 @@ impl FractalApp { let device = rs.device.clone(); let queue = rs.queue.clone(); - let (buffer, padded_bpr, swap) = { + let (pipeline, bind_group_layout, format) = { let guard = rs.renderer.read(); let Some(renderer) = guard.callback_resources.get::() else { self.status = Some("export unavailable".into()); return; }; - renderer.upload_reference(&queue, &self.reference); - let (buffer, bpr) = renderer.render_to_readback(&device, &queue, w, h, uniforms); - (buffer, bpr, renderer.needs_rb_swap()) + renderer.export_handles() }; + let reference = Arc::clone(&self.reference); let shared = Arc::new(Mutex::new(ExportShared { fraction: 0.0, @@ -521,14 +522,42 @@ impl FractalApp { self.status = None; 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"))] { let name = std::env::var("MANDEL_EXPORT_PATH") .unwrap_or_else(|_| format!("fractal-{}.png", unix_timestamp())); 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(); - buffer.slice(..).map_async(wgpu::MapMode::Read, move |res| { + er.readback().slice(..).map_async(wgpu::MapMode::Read, move |res| { let _ = tx.send(res); }); let _ = device.poll(wgpu::PollType::Wait { @@ -537,20 +566,26 @@ impl FractalApp { }); let _ = rx.recv(); - set_progress(&shared, "Encoding", 0.02); + set_progress(&shared, "Encoding", RENDER_END); let png = { - let data = buffer + let data = er + .readback() .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); - }) + crate::fractal::encode_png_with_progress( + &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) .map(|_| format!("saved {name} ({w}×{h})")) .map_err(|e| format!("save failed: {e}")); @@ -560,26 +595,58 @@ impl FractalApp { #[cfg(target_arch = "wasm32")] { 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(); - buffer.slice(..).map_async(wgpu::MapMode::Read, move |res| { + er.readback().slice(..).map_async(wgpu::MapMode::Read, move |res| { let _ = tx.send(res); }); let _ = rx.await; - set_progress(&shared, "Encoding", 0.02); + set_progress(&shared, "Encoding", RENDER_END); let png = { - let data = buffer + let data = er + .readback() .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); - }) + crate::fractal::encode_png_with_progress( + &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"); finish_export(&shared, Ok(format!("downloaded {w}×{h}"))); }); diff --git a/src/fractal/mod.rs b/src/fractal/mod.rs index 607cd3c..ccbe6e0 100644 --- a/src/fractal/mod.rs +++ b/src/fractal/mod.rs @@ -7,6 +7,7 @@ pub mod share; pub use reference::{compute_mandelbrot_reference, compute_reference}; 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; diff --git a/src/fractal/renderer.rs b/src/fractal/renderer.rs index e254f17..b245778 100644 --- a/src/fractal/renderer.rs +++ b/src/fractal/renderer.rs @@ -61,6 +61,7 @@ struct RenderedState { pub struct FractalRenderer { pipeline: wgpu::RenderPipeline, + bind_group_layout: wgpu::BindGroupLayout, uniform_buffer: wgpu::Buffer, ref_buffer: wgpu::Buffer, bind_group: wgpu::BindGroup, @@ -242,6 +243,7 @@ impl FractalRenderer { Self { pipeline, + bind_group_layout, uniform_buffer, ref_buffer, bind_group, @@ -306,37 +308,85 @@ impl FractalRenderer { self.rendered = None; } - /// Upload a reference orbit to the storage buffer (used by PNG export to - /// guarantee the buffer is current before an offscreen render). - pub fn upload_reference(&self, queue: &wgpu::Queue, points: &[[f32; 2]]) { - let count = points.len().min(MAX_REF_POINTS); - if count > 0 { - queue.write_buffer(&self.ref_buffer, 0, bytemuck::cast_slice(&points[..count])); - } - } - - /// 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!( + /// Handles needed to build a standalone [`ExportRender`] off the UI thread: + /// the (immutable) pipeline and its bind-group layout, plus the target + /// format. Cloned so the caller can drop the render-state lock before use. + pub fn export_handles(&self) -> (wgpu::RenderPipeline, wgpu::BindGroupLayout, wgpu::TextureFormat) { + ( + self.pipeline.clone(), + self.bind_group_layout.clone(), self.target_format, - wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb ) } +} - /// Render the current fractal (using `uniforms` and the already-uploaded - /// reference orbit) into an offscreen texture at `width`x`height`, then copy - /// it into a mappable buffer. Returns the buffer and its padded row stride. - /// The caller maps the buffer (blocking on native, async on web). - pub fn render_to_readback( - &self, +/// A self-contained render of one export image. It owns its own uniform and +/// reference buffers (a snapshot of the view at export time), so it is unaffected +/// by panning/zooming on the main thread, and can run on a background thread. +/// The image is rendered in horizontal tiles so progress can be reported as the +/// GPU works through it. +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, queue: &wgpu::Queue, + pipeline: wgpu::RenderPipeline, + bind_group_layout: &wgpu::BindGroupLayout, + target_format: wgpu::TextureFormat, width: u32, height: u32, uniforms: Uniforms, - ) -> (wgpu::Buffer, u32) { - queue.write_buffer(&self.uniform_buffer, 0, bytemuck::bytes_of(&uniforms)); + reference: &[[f32; 2]], + ) -> Self { + let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("export uniforms"), + size: std::mem::size_of::() 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 { label: Some("export target"), @@ -348,16 +398,14 @@ impl FractalRenderer { mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, - format: self.target_format, + format: target_format, usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, view_formats: &[], }); let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; - let unpadded_bpr = width * 4; - let padded_bpr = unpadded_bpr.div_ceil(align) * align; - + let padded_bpr = (width * 4).div_ceil(align) * align; let readback = device.create_buffer(&wgpu::BufferDescriptor { label: Some("export readback"), size: (padded_bpr * height) as u64, @@ -365,18 +413,62 @@ impl FractalRenderer { 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 { - label: Some("export"), + label: Some("export tile"), }); { let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("export pass"), + label: Some("export tile pass"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: &view, + view: &self.view, depth_slice: None, resolve_target: None, ops: wgpu::Operations { - load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), + load, store: wgpu::StoreOp::Store, }, })], @@ -385,35 +477,48 @@ impl FractalRenderer { occlusion_query_set: 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_bind_group(0, &self.bind_group, &[]); 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( wgpu::TexelCopyTextureInfo { - texture: &texture, + texture: &self.texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All, }, wgpu::TexelCopyBufferInfo { - buffer: &readback, + buffer: &self.readback, layout: wgpu::TexelCopyBufferLayout { offset: 0, - bytes_per_row: Some(padded_bpr), - rows_per_image: Some(height), + bytes_per_row: Some(self.padded_bpr), + rows_per_image: Some(self.height), }, }, wgpu::Extent3d { - width, - height, + width: self.width, + height: self.height, depth_or_array_layers: 1, }, ); - 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 } }