From 73570fdab8cdd015bd32ec70d83c4c7611f13a6d Mon Sep 17 00:00:00 2001 From: supersurviveur Date: Fri, 25 Sep 2026 08:09:34 +0200 Subject: [PATCH] perf: Use multithreading for headless animation export --- CLAUDE.md | 7 ++ src/app.rs | 108 +++++++++++++++++++-------- src/fractal/mod.rs | 4 +- src/fractal/renderer.rs | 78 ++++++++++++++++++++ src/headless.rs | 160 +++++++++++++++++++++++++++++++--------- 5 files changed, 287 insertions(+), 70 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7f4885d..d465709 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,6 +64,13 @@ iteration count auto-scales with zoom depth per frame (same `auto_iteration_count` the interactive app uses while zooming). `--to-yaw`/`--to-pitch` (degrees, from `--yaw`/`--pitch`, yaw unwrapped so `--to-yaw 720` is two turns) orbit the 3D camera with `--rendering-kind 3d`. +Frames are pipelined across every core (`run_animation`): each frame's +state is a pure function of `t` (`apply_frame`), so all frames' +`FractalApp::reference_job`s are snapshotted up front and `RefJob::compute`d +by a worker pool. The main thread renders them on the GPU as they arrive +(out of order), and another pool PNG-encodes and writes them +(`encode_png`, `Compression::Fast`). Channels are bounded. Once orbits and +encoding are off the main thread, the GPU is usually the bottleneck. 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 diff --git a/src/app.rs b/src/app.rs index 5251d33..b35e5ca 100644 --- a/src/app.rs +++ b/src/app.rs @@ -147,8 +147,61 @@ const SET_PRESETS: [&[SetPreset]; FractalKind::ComplexMultibrot as usize + 1] = &[], ]; +/// A reference-orbit computation detached from the app (see +/// `FractalApp::reference_job`), so it can run on any thread. +#[cfg(not(target_arch = "wasm32"))] +#[derive(Clone)] +pub(crate) struct RefJob { + key: RequestKey, + precision: usize, + /// The frame's iteration count (auto-iterations resolved). + max_iterations: u32, +} + +#[cfg(not(target_arch = "wasm32"))] +impl RefJob { + /// Iterate the reference orbit at full precision (the expensive part). + pub(crate) fn compute(&self) -> Vec<[f32; 2]> { + let key = &self.key; + let precision = self.precision; + let morph = key.morph.map(|(k, w)| (k, w as f64)); + 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, + key.iter, + precision, + key.kind, + key.power, + key.phoenix_p, + key.lambda_l, + key.complex_power, + morph, + ) + } else { + compute_set_reference( + &key.center_re, + &key.center_im, + key.iter, + precision, + key.kind, + key.power, + key.phoenix_p, + key.lambda_l, + key.complex_power, + morph, + ) + } + } +} + /// Parameters a reference orbit was (or will be) computed for. Used to decide /// when the current reference is stale enough to recompute. +#[derive(Clone)] struct RequestKey { center_re: Big, center_im: Big, @@ -1233,6 +1286,17 @@ impl FractalApp { /// poll a background result on and only ever needs one reference. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn compute_reference_blocking(&mut self) { + let job = self.reference_job(); + let points = job.compute(); + self.finish_reference(job, points); + } + + /// Snapshot everything the reference orbit for the current view depends + /// on, as a self-contained job that can be computed on another thread + /// (headless animation computes many frames' orbits in parallel). Also + /// applies auto-iterations, like `compute_reference_blocking`. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn reference_job(&mut self) -> RefJob { if self.auto_iterations { self.max_iterations = self.auto_iteration_count(); } @@ -1240,45 +1304,25 @@ impl FractalApp { // One-shot render: no later frames for iteration headroom to serve. key.iter = self.max_iterations.min(MAX_REF_POINTS as u32 - 1); let precision = self.view.precision_bits(); - let max_iter = key.iter; // Lambda in Set mode has a static fractal centered at origin. if key.kind == FractalKind::Lambda && !key.julia && key.morph.is_none() { key.center_re = big_from_f64(0.0, precision); key.center_im = big_from_f64(0.0, precision); } + RefJob { + key, + precision, + max_iterations: self.max_iterations, + } + } - 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, - key.complex_power, - key.morph.map(|(k, w)| (k, w as f64)), - ) - } else { - compute_set_reference( - &key.center_re, - &key.center_im, - max_iter, - precision, - key.kind, - key.power, - key.phoenix_p, - key.lambda_l, - key.complex_power, - key.morph.map(|(k, w)| (k, w as f64)), - ) - }; + /// Install the orbit computed for `job` (from `reference_job`) as the + /// current reference, along with the iteration count it was made for. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn finish_reference(&mut self, job: RefJob, points: Vec<[f32; 2]>) { + self.max_iterations = job.max_iterations; + let key = job.key; self.apply_reference( points, key.center_re.clone(), diff --git a/src/fractal/mod.rs b/src/fractal/mod.rs index 1f8eecc..8037fd3 100644 --- a/src/fractal/mod.rs +++ b/src/fractal/mod.rs @@ -14,7 +14,7 @@ pub use reference::{compute_reference, compute_set_reference}; pub use renderer::PipelineKey; #[cfg(target_arch = "wasm32")] pub use renderer::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}; +#[cfg(not(target_arch = "wasm32"))] +pub use renderer::{encode_png, export_to_png_blocking, render_readback_blocking}; pub use share::ShareState; diff --git a/src/fractal/renderer.rs b/src/fractal/renderer.rs index 1e543db..ee042a3 100644 --- a/src/fractal/renderer.rs +++ b/src/fractal/renderer.rs @@ -1223,6 +1223,84 @@ pub fn export_to_png_blocking( png } +/// Render every tile of `er` in one go (no per-tile GPU stall, unlike +/// [`export_to_png_blocking`]), read it back, and return a copy of the padded +/// readback bytes (`er.padded_bpr` per row) for [`encode_png`]. Used by the +/// headless animation pipeline, which encodes on other threads. +#[cfg(not(target_arch = "wasm32"))] +pub fn render_readback_blocking( + device: &wgpu::Device, + queue: &wgpu::Queue, + er: &ExportRender, +) -> Vec { + for t in 0..er.tiles { + er.render_tile(device, queue, t); + } + 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(); + + let bytes = er + .readback() + .slice(..) + .get_mapped_range() + .expect("map readback buffer") + .to_vec(); + er.readback().unmap(); + bytes +} + +/// Like [`encode_png_with_progress`], but encodes the whole image at once +/// (no progress) at the given compression level. Non-streaming, so the fast +/// `fdeflate` levels don't pay the streaming-mode size penalty. +#[cfg(not(target_arch = "wasm32"))] +pub fn encode_png( + padded: &[u8], + width: u32, + height: u32, + padded_bpr: u32, + swap_rb: bool, + compression: png::Compression, +) -> Vec { + let row = (width * 4) as usize; + let mut pixels = Vec::with_capacity(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]; + if swap_rb { + pixels.extend( + src.as_chunks::<4>() + .0 + .iter() + .flat_map(|&[b, g, r, a]| [r, g, b, a]), + ); + } else { + pixels.extend_from_slice(src); + } + } + + 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); + encoder.set_compression(compression); + let mut writer = encoder.write_header().expect("png header"); + writer.write_image_data(&pixels).expect("png data"); + } + out +} + /// 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 /// streamed to the compressor (encoding is the slow, subdividable phase). diff --git a/src/headless.rs b/src/headless.rs index 388e6dd..fd12d88 100644 --- a/src/headless.rs +++ b/src/headless.rs @@ -5,12 +5,17 @@ // once, and renders through the same `ExportRender` path the "Export PNG" // button uses. +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Mutex, mpsc}; + use eframe::egui_wgpu::wgpu; -use crate::app::{FractalApp, parse_complex_pair, unix_timestamp}; +use crate::app::{FractalApp, RefJob, parse_complex_pair, unix_timestamp}; use crate::cli::Cli; use crate::fractal::{ - ExportRender, FractalKind, FractalRenderer, PipelineKey, ShareState, export_to_png_blocking, + ExportRender, FractalKind, FractalRenderer, PipelineKey, ShareState, encode_png, + export_to_png_blocking, render_readback_blocking, }; use crate::view::{ ViewState, big_from_decimal_str, interpolate_f64, interpolate_view, parse_view_spec, @@ -197,14 +202,9 @@ fn run_animation( let out_dir = export_path.unwrap_or_else(|| format!("frames-{}", unix_timestamp())); std::fs::create_dir_all(&out_dir).map_err(|e| format!("failed to create {out_dir}: {e}"))?; - let (device, queue) = pollster::block_on(request_device())?; - let format = wgpu::TextureFormat::Bgra8Unorm; - let renderer = FractalRenderer::new(&device, format); - // The shader specialization (kind, Julia, DE, morph) can change between - // frames during a kind morph; rebuild the pipeline only when it does. - let mut pipeline_cache: Option<(PipelineKey, _)> = None; - - for i in 0..frames { + // Everything about frame `i` is a pure function of its `t`, so the app can + // be put into any frame's state at any time, in any order. + let apply_frame = |app: &mut FractalApp, i: u32| { let raw_t = i as f64 / (frames - 1) as f64; let t = if targets.linear { raw_t @@ -228,38 +228,126 @@ fn run_animation( interpolate_f64(yaw0 as f64, yaw1 as f64, t) as f32, interpolate_f64(pitch0 as f64, pitch1 as f64, t) as f32, ); + }; - eprintln!("[{:>4}/{frames}] computing reference orbit…", i + 1); - app.compute_reference_blocking(); + // Snapshot every frame's reference-orbit job up front (cheap: just the + // parameters), so the orbits themselves can be computed in parallel. + let jobs: Vec = (0..frames) + .map(|i| { + apply_frame(&mut app, i); + app.reference_job() + }) + .collect(); - let uniforms = app.make_uniforms(width as f64 / height as f64); - let key = PipelineKey::from_uniforms(&uniforms); - if pipeline_cache.as_ref().is_none_or(|(k, _)| *k != key) { - pipeline_cache = Some((key, renderer.export_handles(&device, &uniforms))); + let (device, queue) = pollster::block_on(request_device())?; + let format = wgpu::TextureFormat::Bgra8Unorm; + let renderer = FractalRenderer::new(&device, format); + let aspect = width as f64 / height as f64; + + // Three-stage pipeline, connected by bounded channels (which also cap + // memory): `threads` workers compute reference orbits (CPU, the expensive + // part at deep zoom) → this thread renders each frame on the GPU → `threads` + // workers PNG-encode and write frames. Frames flow through out of order + // (at most ~`threads` apart); each is written under its own index. + let threads = std::thread::available_parallelism().map_or(4, |n| n.get()); + let next_job = AtomicUsize::new(0); + let saved = AtomicUsize::new(0); + let failed = AtomicBool::new(false); + let error: Mutex> = Mutex::new(None); + let fail = |e: String| { + failed.store(true, Ordering::Relaxed); + error.lock().unwrap().get_or_insert(e); + }; + + eprintln!("rendering {frames} frames ({width}×{height}) on {threads} threads…"); + let (png_tx, png_rx) = mpsc::sync_channel::<(usize, Vec, u32, bool)>(threads * 2); + let png_rx = Mutex::new(png_rx); + std::thread::scope(|scope| { + let (ref_tx, ref_rx) = mpsc::sync_channel::<(usize, Vec<[f32; 2]>)>(threads * 2); + for _ in 0..threads { + let ref_tx = ref_tx.clone(); + let (jobs, next_job, failed) = (&jobs, &next_job, &failed); + scope.spawn(move || { + loop { + let i = next_job.fetch_add(1, Ordering::Relaxed); + if i >= jobs.len() || failed.load(Ordering::Relaxed) { + break; + } + if ref_tx.send((i, jobs[i].compute())).is_err() { + break; + } + } + }); } - let (_, handles) = pipeline_cache.as_ref().unwrap(); - let er = ExportRender::new( - &device, - &queue, - handles, - width, - height, - uniforms, - app.reference_points(), - app.lights(), - ); + drop(ref_tx); - let png = export_to_png_blocking(&device, &queue, &er, |phase, fraction| { - eprint!( - "\r[{:>4}/{frames}] {phase} {:>3.0}%", - i + 1, - fraction * 100.0 + for _ in 0..threads { + let (png_rx, out_dir, saved, failed, fail) = + (&png_rx, &out_dir, &saved, &failed, &fail); + scope.spawn(move || { + loop { + // Hold the lock only for the receive, not the encode. + let Ok((i, padded, bpr, swap_rb)) = png_rx.lock().unwrap().recv() else { + break; + }; + if failed.load(Ordering::Relaxed) { + break; + } + let png = + encode_png(&padded, width, height, bpr, swap_rb, png::Compression::Fast); + let path = format!("{out_dir}/frame-{:05}.png", i + 1); + if let Err(e) = std::fs::write(&path, &png) { + fail(format!("save failed: {e}")); + break; + } + let done = saved.fetch_add(1, Ordering::Relaxed) + 1; + eprint!("\r[{done:>4}/{frames}] saved"); + } + }); + } + + // GPU stage, on this thread (it owns the app and the device). The + // shader specialization (kind, Julia, DE, morph) can change between + // frames during a kind morph; build each pipeline once. + let mut pipelines = HashMap::new(); + for (i, points) in ref_rx.iter() { + if failed.load(Ordering::Relaxed) { + break; + } + apply_frame(&mut app, i as u32); + app.finish_reference(jobs[i].clone(), points); + + let uniforms = app.make_uniforms(aspect); + let handles = pipelines + .entry(PipelineKey::from_uniforms(&uniforms)) + .or_insert_with(|| renderer.export_handles(&device, &uniforms)); + let er = ExportRender::new( + &device, + &queue, + handles, + width, + height, + uniforms, + app.reference_points(), + app.lights(), ); - }); - eprintln!(); + let padded = render_readback_blocking(&device, &queue, &er); + if png_tx.send((i, padded, er.padded_bpr, er.swap_rb)).is_err() { + break; + } + } + // Dropping the channel ends lets the workers drain and exit. + drop(png_tx); + drop(ref_rx); + }); + eprintln!(); - let path = format!("{out_dir}/frame-{:05}.png", i + 1); - std::fs::write(&path, &png).map_err(|e| format!("save failed: {e}"))?; + if let Some(e) = error.into_inner().unwrap() { + return Err(e); + } + let saved = saved.into_inner(); + if saved != frames as usize { + return Err(format!("only {saved} of {frames} frames were rendered")); } println!("saved {frames} frames to {out_dir}/ ({width}×{height})");