perf: Use multithreading for headless animation export
This commit is contained in:
@@ -64,6 +64,13 @@ iteration count auto-scales with zoom depth per frame (same
|
|||||||
`auto_iteration_count` the interactive app uses while zooming).
|
`auto_iteration_count` the interactive app uses while zooming).
|
||||||
`--to-yaw`/`--to-pitch` (degrees, from `--yaw`/`--pitch`, yaw unwrapped so
|
`--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`.
|
`--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`
|
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
|
are the fast, headless way to validate a change. `cargo test` also runs but
|
||||||
|
|||||||
+76
-32
@@ -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
|
/// Parameters a reference orbit was (or will be) computed for. Used to decide
|
||||||
/// when the current reference is stale enough to recompute.
|
/// when the current reference is stale enough to recompute.
|
||||||
|
#[derive(Clone)]
|
||||||
struct RequestKey {
|
struct RequestKey {
|
||||||
center_re: Big,
|
center_re: Big,
|
||||||
center_im: Big,
|
center_im: Big,
|
||||||
@@ -1233,6 +1286,17 @@ impl FractalApp {
|
|||||||
/// poll a background result on and only ever needs one reference.
|
/// poll a background result on and only ever needs one reference.
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub(crate) fn compute_reference_blocking(&mut self) {
|
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 {
|
if self.auto_iterations {
|
||||||
self.max_iterations = self.auto_iteration_count();
|
self.max_iterations = self.auto_iteration_count();
|
||||||
}
|
}
|
||||||
@@ -1240,45 +1304,25 @@ impl FractalApp {
|
|||||||
// One-shot render: no later frames for iteration headroom to serve.
|
// One-shot render: no later frames for iteration headroom to serve.
|
||||||
key.iter = self.max_iterations.min(MAX_REF_POINTS as u32 - 1);
|
key.iter = self.max_iterations.min(MAX_REF_POINTS as u32 - 1);
|
||||||
let precision = self.view.precision_bits();
|
let precision = self.view.precision_bits();
|
||||||
let max_iter = key.iter;
|
|
||||||
|
|
||||||
// Lambda in Set mode has a static fractal centered at origin.
|
// Lambda in Set mode has a static fractal centered at origin.
|
||||||
if key.kind == FractalKind::Lambda && !key.julia && key.morph.is_none() {
|
if key.kind == FractalKind::Lambda && !key.julia && key.morph.is_none() {
|
||||||
key.center_re = big_from_f64(0.0, precision);
|
key.center_re = big_from_f64(0.0, precision);
|
||||||
key.center_im = 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 {
|
/// Install the orbit computed for `job` (from `reference_job`) as the
|
||||||
let jr = big_from_f64(key.julia_c.0, precision);
|
/// current reference, along with the iteration count it was made for.
|
||||||
let ji = big_from_f64(key.julia_c.1, precision);
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
compute_reference(
|
pub(crate) fn finish_reference(&mut self, job: RefJob, points: Vec<[f32; 2]>) {
|
||||||
&key.center_re,
|
self.max_iterations = job.max_iterations;
|
||||||
&key.center_im,
|
let key = job.key;
|
||||||
&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)),
|
|
||||||
)
|
|
||||||
};
|
|
||||||
self.apply_reference(
|
self.apply_reference(
|
||||||
points,
|
points,
|
||||||
key.center_re.clone(),
|
key.center_re.clone(),
|
||||||
|
|||||||
+2
-2
@@ -14,7 +14,7 @@ pub use reference::{compute_reference, compute_set_reference};
|
|||||||
pub use renderer::PipelineKey;
|
pub use renderer::PipelineKey;
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
pub use renderer::encode_png_with_progress;
|
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};
|
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;
|
pub use share::ShareState;
|
||||||
|
|||||||
@@ -1223,6 +1223,84 @@ pub fn export_to_png_blocking(
|
|||||||
png
|
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<u8> {
|
||||||
|
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<u8> {
|
||||||
|
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
|
/// 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
|
/// as PNG bytes, reporting progress in `[0, 1]` via `on_progress` as rows are
|
||||||
/// streamed to the compressor (encoding is the slow, subdividable phase).
|
/// streamed to the compressor (encoding is the slow, subdividable phase).
|
||||||
|
|||||||
+114
-26
@@ -5,12 +5,17 @@
|
|||||||
// once, and renders through the same `ExportRender` path the "Export PNG"
|
// once, and renders through the same `ExportRender` path the "Export PNG"
|
||||||
// button uses.
|
// button uses.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||||
|
use std::sync::{Mutex, mpsc};
|
||||||
|
|
||||||
use eframe::egui_wgpu::wgpu;
|
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::cli::Cli;
|
||||||
use crate::fractal::{
|
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::{
|
use crate::view::{
|
||||||
ViewState, big_from_decimal_str, interpolate_f64, interpolate_view, parse_view_spec,
|
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()));
|
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}"))?;
|
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())?;
|
// Everything about frame `i` is a pure function of its `t`, so the app can
|
||||||
let format = wgpu::TextureFormat::Bgra8Unorm;
|
// be put into any frame's state at any time, in any order.
|
||||||
let renderer = FractalRenderer::new(&device, format);
|
let apply_frame = |app: &mut FractalApp, i: u32| {
|
||||||
// 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 {
|
|
||||||
let raw_t = i as f64 / (frames - 1) as f64;
|
let raw_t = i as f64 / (frames - 1) as f64;
|
||||||
let t = if targets.linear {
|
let t = if targets.linear {
|
||||||
raw_t
|
raw_t
|
||||||
@@ -228,16 +228,99 @@ fn run_animation(
|
|||||||
interpolate_f64(yaw0 as f64, yaw1 as f64, t) as f32,
|
interpolate_f64(yaw0 as f64, yaw1 as f64, t) as f32,
|
||||||
interpolate_f64(pitch0 as f64, pitch1 as f64, t) as f32,
|
interpolate_f64(pitch0 as f64, pitch1 as f64, t) as f32,
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
eprintln!("[{:>4}/{frames}] computing reference orbit…", i + 1);
|
// Snapshot every frame's reference-orbit job up front (cheap: just the
|
||||||
app.compute_reference_blocking();
|
// parameters), so the orbits themselves can be computed in parallel.
|
||||||
|
let jobs: Vec<RefJob> = (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 (device, queue) = pollster::block_on(request_device())?;
|
||||||
let key = PipelineKey::from_uniforms(&uniforms);
|
let format = wgpu::TextureFormat::Bgra8Unorm;
|
||||||
if pipeline_cache.as_ref().is_none_or(|(k, _)| *k != key) {
|
let renderer = FractalRenderer::new(&device, format);
|
||||||
pipeline_cache = Some((key, renderer.export_handles(&device, &uniforms)));
|
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<Option<String>> = 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<u8>, 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;
|
||||||
}
|
}
|
||||||
let (_, handles) = pipeline_cache.as_ref().unwrap();
|
if ref_tx.send((i, jobs[i].compute())).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
drop(ref_tx);
|
||||||
|
|
||||||
|
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(
|
let er = ExportRender::new(
|
||||||
&device,
|
&device,
|
||||||
&queue,
|
&queue,
|
||||||
@@ -248,18 +331,23 @@ fn run_animation(
|
|||||||
app.reference_points(),
|
app.reference_points(),
|
||||||
app.lights(),
|
app.lights(),
|
||||||
);
|
);
|
||||||
|
let padded = render_readback_blocking(&device, &queue, &er);
|
||||||
let png = export_to_png_blocking(&device, &queue, &er, |phase, fraction| {
|
if png_tx.send((i, padded, er.padded_bpr, er.swap_rb)).is_err() {
|
||||||
eprint!(
|
break;
|
||||||
"\r[{:>4}/{frames}] {phase} {:>3.0}%",
|
}
|
||||||
i + 1,
|
}
|
||||||
fraction * 100.0
|
// Dropping the channel ends lets the workers drain and exit.
|
||||||
);
|
drop(png_tx);
|
||||||
|
drop(ref_rx);
|
||||||
});
|
});
|
||||||
eprintln!();
|
eprintln!();
|
||||||
|
|
||||||
let path = format!("{out_dir}/frame-{:05}.png", i + 1);
|
if let Some(e) = error.into_inner().unwrap() {
|
||||||
std::fs::write(&path, &png).map_err(|e| format!("save failed: {e}"))?;
|
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})");
|
println!("saved {frames} frames to {out_dir}/ ({width}×{height})");
|
||||||
|
|||||||
Reference in New Issue
Block a user