// MARK: Render apiuse std::collections::VecDeque; use crate::cmd::render::file_writer::OutputFormatExt;use crate::{Output, Scene, SceneConfig, SceneConstructor};use file_writer::{FileWriter, FileWriterBuilder};use indicatif::{ProgressState, ProgressStyle};use ranim_core::color::{self, LinearSrgb};use ranim_core::{SceneEvaluator, TimeMark};use ranim_render::resource::RenderTextures;use ranim_render::{Renderer, utils::WgpuContext, world::RenderFrame};use std::path::{Path, PathBuf};use std::time::Duration;use std::time::Instant;use tracing::{Span, info, instrument, trace};use tracing_indicatif::span_ext::IndicatifSpanExt; pub(crate) mod audio;pub(crate) mod file_writer; /// Render the output basename template by replacing placeholders.////// Supported placeholders:/// - `{name}`: the scene/output name/// - `{width}`: output width in pixels/// - `{height}`: output height in pixels/// - `{fps}`: output frame ratefn render_output_basename(template: &str, name: &str, width: u32, height: u32, fps: u32) -> String { template .replace("{name}", name) .replace("{width}", &width.to_string()) .replace("{height}", &height.to_string()) .replace("{fps}", &fps.to_string())} /// Render a scene with all its outputspub fn render_scene(scene: &Scene, buffer_count: usize) { for (i, output) in scene.outputs.iter().enumerate() { info!( "Rendering output {}/{} ({})", i + 1, scene.outputs.len(), output.format ); render_scene_output( scene.constructor, scene.name.to_string(), &scene.config, output, buffer_count, ); }} /// Render a scene outputpub fn render_scene_output( constructor: impl SceneConstructor, name: String, scene_config: &SceneConfig, output: &Output, buffer_count: usize,) { render_scene_output_with_progress(constructor, name, scene_config, output, buffer_count, None);} /// A single rendering job: one [`Output`] plus whether capture marks should/// be processed after the main pass.////// The CLI currently only creates full-scene jobs. Time-range and still-frame/// rendering will extend this type together with the debug rendering commands.pub struct RenderJob { /// Output settings used for this job. pub output: Output, /// Render and save every [`TimeMark::Capture`] after the main pass. /// /// This is enabled by `ranim output` (declared outputs) and disabled by /// the ad-hoc `ranim render` command. pub capture_marks: bool,} impl RenderJob { /// Creates a job that renders the whole scene. pub fn full(output: Output, capture_marks: bool) -> Self { Self { output, capture_marks, } }} /// Render one output with optional progress callback.////// The callback receives `(current_frame, total_frames)` each frame.pub fn render_scene_output_with_progress( constructor: impl SceneConstructor, name: String, scene_config: &SceneConfig, output: &Output, buffer_count: usize, on_progress: Option<Box<dyn Fn(u64, u64) + Send>>,) { render_scene_job( constructor, name, scene_config, RenderJob::full(output.clone(), true), buffer_count, on_progress, );} /// Render a single ad-hoc output for a scene.////// This is the low-level rendering entry point used by `ranim render`: it/// renders the whole scene once with the given [`Output`], but unlike/// [`render_scene_output_with_progress`] it does not process capture marks.pub fn render_scene_once( constructor: impl SceneConstructor, name: String, scene_config: &SceneConfig, output: &Output, buffer_count: usize,) { render_scene_job( constructor, name, scene_config, RenderJob::full(output.clone(), false), buffer_count, None, );} /// Build the scene and run a [`RenderJob`].pub fn render_scene_job( constructor: impl SceneConstructor, name: String, scene_config: &SceneConfig, job: RenderJob, buffer_count: usize, on_progress: Option<Box<dyn Fn(u64, u64) + Send>>,) { use std::time::Instant; let RenderJob { output, capture_marks, } = job; info!( "Output: {}x{} {}fps {} dir={:?} save_frames={}", output.width, output.height, output.fps, output.format, output.dir, output.save_frames ); let t = Instant::now(); let scene = constructor.build_scene(); trace!("Build timeline cost: {:?}", t.elapsed()); // Default logic grid 120 Hz (per the time model design); render fps only // decides which logic states are sampled. let mut evaluator = scene.into_evaluator(DEFAULT_LOGIC_FPS); let mut app = RanimRenderApp::new(name, scene_config, &output, buffer_count); app.render_scene_with_progress(&mut evaluator, on_progress); if capture_marks && !evaluator.time_marks().is_empty() { app.render_capture_marks(&mut evaluator); } // The audio plane was baked in one offline pass at seal: mux the baked // buffer into the finished file. if evaluator.has_audio() { match audio::audio_codec(output.format) { Some(_) => { let pcm = evaluator.audio(); // Finalize the encoder before reading the file back for muxing. app.finish_video(); let video_path = app.video_path(); if !video_path.exists() { tracing::warn!("no video file was written, skipping audio muxing"); } else if let Err(err) = audio::mux_audio_into_video(video_path, pcm, output.format) { tracing::error!("failed to mux audio into {}: {err:#}", video_path.display()); } } None => tracing::warn!( "the scene has audio, but the {:?} format cannot store it; rendering silently", output.format ), } }} /// Default logic grid resolution (Hz), per the time model design.const DEFAULT_LOGIC_FPS: f64 = 120.0; /// Handle to the background render thread, used to submit frame data from the main thread.////// Dropping this handle closes the submission channel and terminates the worker thread loop.pub struct RenderThreadHandle { submit_frame_tx: async_channel::Sender<RenderFrame>, back_rx: async_channel::Receiver<RenderFrame>, worker_rx: async_channel::Receiver<RenderWorker>,} impl RenderThreadHandle { /// Reuses a [`RenderFrame`], applies `f` to it, and submits it to the background render thread. pub fn sync_and_submit(&self, f: impl FnOnce(&mut RenderFrame)) { let mut store = self.get_store(); f(&mut store); self.submit_frame_tx.send_blocking(store).unwrap(); } /// Retrieves a previously rendered [`RenderFrame`] from the background thread for reuse. pub fn get_store(&self) -> RenderFrame { self.back_rx.recv_blocking().unwrap() } /// Closes the submission channel, waits for the background thread to finish, and returns the [`RenderWorker`]. pub fn retrive(&self) -> RenderWorker { self.submit_frame_tx.close(); // This terminates the worker thread loop self.worker_rx.recv_blocking().unwrap() }} /// Background rendering worker that owns the wgpu context, renderer, output textures, and video/image writers.pub struct RenderWorker { ctx: WgpuContext, renderer: Renderer, render_textures: Vec<RenderTextures>, clear_color: wgpu::Color, // video writer video_writer: Option<FileWriter>, video_writer_builder: Option<FileWriterBuilder>, video_path: PathBuf, save_frames: bool, output_dir: PathBuf, scene_name: String, width: u32, height: u32, fps: u32,} impl RenderWorker { /// Creates a new [`RenderWorker`]. /// /// This checks for or downloads `ffmpeg`, initializes the wgpu context and renderer, and configures the video writer from [`Output`]. /// /// # Panics /// /// Panics if `buffer_count < 1`. pub fn new( scene_name: String, scene_config: &SceneConfig, output: &Output, buffer_count: usize, ) -> Self { assert!(buffer_count >= 1, "buffer_count must be at least 1"); info!("Checking ffmpeg..."); let t = Instant::now(); if let Ok(ffmpeg_path) = which::which("ffmpeg") { info!("ffmpeg found at {ffmpeg_path:?}"); } else { use std::path::Path; info!( "ffmpeg not found from path env, searching in {:?}...", Path::new("./").canonicalize().unwrap() ); if Path::new("./ffmpeg").exists() { info!("ffmpeg found at current working directory") } else { info!("ffmpeg not found at current working directory, downloading..."); download_ffmpeg("./").expect("failed to download ffmpeg"); } } trace!("Check ffmmpeg cost: {:?}", t.elapsed()); let t = Instant::now(); info!("Creating wgpu context..."); let ctx = pollster::block_on(WgpuContext::new()); trace!("Create wgpu context cost: {:?}", t.elapsed()); let mut output_dir = PathBuf::from(&output.dir); if !output_dir.is_absolute() { output_dir = std::env::current_dir().unwrap().join(output_dir); } let renderer = Renderer::new(&ctx, output.width, output.height, 8); let render_textures: Vec<RenderTextures> = (0..buffer_count) .map(|_| renderer.new_render_textures(&ctx)) .collect(); let clear_color = color::try_color(&scene_config.clear_color) .unwrap_or(color::color("#333333ff")) .convert::<LinearSrgb>(); let [r, g, b, a] = clear_color.components.map(|x| x as f64); let clear_color = wgpu::Color { r, g, b, a }; let (_, _, ext) = output.format.encoding_params(); let video_path = output_dir.join({ let template = output .name_template .as_deref() .unwrap_or("{name}_{width}x{height}_{fps}"); let base_name = render_output_basename( template, output.name.as_deref().unwrap_or(&scene_name), output.width, output.height, output.fps, ); format!("{base_name}.{ext}") }); Self { ctx, renderer, render_textures, clear_color, video_writer: None, video_writer_builder: Some( FileWriterBuilder::default() .with_fps(output.fps) .with_size(output.width, output.height) .with_file_path(video_path.clone()) .with_output_format(output.format), ), video_path, save_frames: output.save_frames, output_dir, scene_name, width: output.width, height: output.height, fps: output.fps, } } /// Returns the directory path used to save individual frame images. pub fn save_frame_dir(&self) -> PathBuf { self.output_dir.join(format!( "{}_{}x{}_{}-frames", self.scene_name, self.width, self.height, self.fps )) } /// Returns the path of the video file being written. pub fn video_path(&self) -> &Path { &self.video_path } /// Finalize the video file: flush the encoder and wait for it to exit. /// /// After this call the file on disk is complete (valid container index) /// and no further frames can be written. pub fn finish_video(&mut self) { self.video_writer = None; } /// Moves this [`RenderWorker`] into a newly spawned background thread and returns a [`RenderThreadHandle`]. /// /// The background thread loops over incoming [`RenderFrame`]s, rendering and outputting frames using multi-buffered async readback. pub fn yeet(self) -> RenderThreadHandle { let (submit_frame_tx, submit_frame_rx) = async_channel::bounded(1); let (back_tx, back_rx) = async_channel::bounded(1); let (worker_tx, worker_rx) = async_channel::bounded(1); back_tx.send_blocking(RenderFrame::default()).unwrap(); std::thread::spawn(move || { let mut worker = self; let n = worker.render_textures.len(); let mut frame_count = 0u64; let mut cur = 0usize; let mut pending: VecDeque<(usize, u64)> = VecDeque::new(); while let Ok(store) = submit_frame_rx.recv_blocking() { // Drain oldest pending readback if all targets are occupied if pending.len() >= n { let (prev, prev_fc) = pending.pop_front().unwrap(); worker.render_textures[prev].finish_readback(&worker.ctx); worker.output_frame_from(prev, prev_fc); } // Render current frame and start async readback worker.renderer.render_frame( &mut worker.render_textures[cur], worker.clear_color, &store, ); worker.render_textures[cur].start_readback(&worker.ctx); pending.push_back((cur, frame_count)); frame_count += 1; cur = (cur + 1) % n; // Return store early so main thread can eval next frame // while GPU processes the readback back_tx.send_blocking(store).unwrap(); // Now try to drain any completed readbacks while we wait // for the next frame from the main thread while let Some(&(prev, _)) = pending.front() { // Non-blocking: check if the oldest readback is ready if !worker.render_textures[prev].try_finish_readback(&worker.ctx) { break; } let (prev, prev_fc) = pending.pop_front().unwrap(); worker.output_frame_from(prev, prev_fc); } } // Flush all remaining pending frames while let Some((prev, prev_fc)) = pending.pop_front() { worker.render_textures[prev].finish_readback(&worker.ctx); worker.output_frame_from(prev, prev_fc); } worker_tx.send_blocking(worker).unwrap(); }); RenderThreadHandle { submit_frame_tx, back_rx, worker_rx, } } /// Renders a single frame synchronously on this worker (typically used for captures). pub fn render_store(&mut self, store: &RenderFrame) { self.renderer .render_frame(&mut self.render_textures[0], self.clear_color, store); } /// Write and save (if [`Self::save_frames`] is true) pub fn output_frame_from(&mut self, target_idx: usize, frame_number: u64) { self.write_frame_from(target_idx); if self.save_frames { self.save_frame_from(target_idx, frame_number); } } /// Write frame data from the given target to the video file. pub fn write_frame_from(&mut self, target_idx: usize) { let data = self.render_textures[target_idx] .render_texture .texture_data(); if let Some(video_writer) = self.video_writer.as_mut() { video_writer.write_frame(data); } else if let Some(builder) = self.video_writer_builder.as_ref() { self.video_writer .get_or_insert(builder.clone().build()) .write_frame(data); } } /// Save frame from the given target as a PNG image. pub fn save_frame_from(&mut self, target_idx: usize, frame_number: u64) { let path = self.save_frame_dir().join(format!("{frame_number:04}.png")); let dir = path.parent().unwrap(); if !dir.exists() || !dir.is_dir() { std::fs::create_dir_all(dir).unwrap(); } // Data is already in cpu buffer after finish_readback, this won't trigger GPU work let buffer = self.render_textures[target_idx].get_rendered_texture_img_buffer(&self.ctx); buffer.save(path).unwrap(); } /// Capture frame to image file (sync path, uses target 0). pub fn capture_frame(&mut self, path: impl AsRef<Path>) { let path = path.as_ref(); let path = if !path.is_absolute() { self.output_dir .join(format!( "{}_{}x{}_{}", self.scene_name, self.width, self.height, self.fps )) .join(path) } else { path.to_path_buf() }; let dir = path.parent().unwrap(); if !dir.exists() || !dir.is_dir() { std::fs::create_dir_all(dir).unwrap(); } let buffer = self.render_textures[0].get_rendered_texture_img_buffer(&self.ctx); buffer.save(path).unwrap(); }} /// MARK: RanimRenderApp/// Application wrapper that drives frame-by-frame scene rendering, video output, and capture screenshots.pub struct RanimRenderApp { render_worker: Option<RenderWorker>, fps: u32, store: RenderFrame,} impl RanimRenderApp { /// Creates the application and initializes the internal [`RenderWorker`]. pub fn new( scene_name: String, scene_config: &SceneConfig, output: &Output, buffer_count: usize, ) -> Self {Lines 1–500 of 722. Load more
// MARK: Render api use std::collections::VecDeque; use crate::cmd::render::file_writer::OutputFormatExt; use crate::{Output, Scene, SceneConfig, SceneConstructor}; use file_writer::{FileWriter, FileWriterBuilder}; use indicatif::{ProgressState, ProgressStyle}; use ranim_core::color::{self, LinearSrgb}; use ranim_core::{SceneEvaluator, TimeMark}; use ranim_render::resource::RenderTextures; use ranim_render::{Renderer, utils::WgpuContext, world::RenderFrame}; use std::path::{Path, PathBuf}; use std::time::Duration; use std::time::Instant; use tracing::{Span, info, instrument, trace}; use tracing_indicatif::span_ext::IndicatifSpanExt; pub(crate) mod audio; pub(crate) mod file_writer; /// Render the output basename template by replacing placeholders. /// /// Supported placeholders: /// - `{name}`: the scene/output name /// - `{width}`: output width in pixels /// - `{height}`: output height in pixels /// - `{fps}`: output frame rate fn render_output_basename(template: &str, name: &str, width: u32, height: u32, fps: u32) -> String { template .replace("{name}", name) .replace("{width}", &width.to_string()) .replace("{height}", &height.to_string()) .replace("{fps}", &fps.to_string()) } /// Render a scene with all its outputs pub fn render_scene(scene: &Scene, buffer_count: usize) { for (i, output) in scene.outputs.iter().enumerate() { info!( "Rendering output {}/{} ({})", i + 1, scene.outputs.len(), output.format ); render_scene_output( scene.constructor, scene.name.to_string(), &scene.config, output, buffer_count, ); } } /// Render a scene output pub fn render_scene_output( constructor: impl SceneConstructor, name: String, scene_config: &SceneConfig, output: &Output, buffer_count: usize, ) { render_scene_output_with_progress(constructor, name, scene_config, output, buffer_count, None); } /// A single rendering job: one [`Output`] plus whether capture marks should /// be processed after the main pass. /// /// The CLI currently only creates full-scene jobs. Time-range and still-frame /// rendering will extend this type together with the debug rendering commands. pub struct RenderJob { /// Output settings used for this job. pub output: Output, /// Render and save every [`TimeMark::Capture`] after the main pass. /// /// This is enabled by `ranim output` (declared outputs) and disabled by /// the ad-hoc `ranim render` command. pub capture_marks: bool, } impl RenderJob { /// Creates a job that renders the whole scene. pub fn full(output: Output, capture_marks: bool) -> Self { Self { output, capture_marks, } } } /// Render one output with optional progress callback. /// /// The callback receives `(current_frame, total_frames)` each frame. pub fn render_scene_output_with_progress( constructor: impl SceneConstructor, name: String, scene_config: &SceneConfig, output: &Output, buffer_count: usize, on_progress: Option<Box<dyn Fn(u64, u64) + Send>>, ) { render_scene_job( constructor, name, scene_config, RenderJob::full(output.clone(), true), buffer_count, on_progress, ); } /// Render a single ad-hoc output for a scene. /// /// This is the low-level rendering entry point used by `ranim render`: it /// renders the whole scene once with the given [`Output`], but unlike /// [`render_scene_output_with_progress`] it does not process capture marks. pub fn render_scene_once( constructor: impl SceneConstructor, name: String, scene_config: &SceneConfig, output: &Output, buffer_count: usize, ) { render_scene_job( constructor, name, scene_config, RenderJob::full(output.clone(), false), buffer_count, None, ); } /// Build the scene and run a [`RenderJob`]. pub fn render_scene_job( constructor: impl SceneConstructor, name: String, scene_config: &SceneConfig, job: RenderJob, buffer_count: usize, on_progress: Option<Box<dyn Fn(u64, u64) + Send>>, ) { use std::time::Instant; let RenderJob { output, capture_marks, } = job; info!( "Output: {}x{} {}fps {} dir={:?} save_frames={}", output.width, output.height, output.fps, output.format, output.dir, output.save_frames ); let t = Instant::now(); let scene = constructor.build_scene(); trace!("Build timeline cost: {:?}", t.elapsed()); // Default logic grid 120 Hz (per the time model design); render fps only // decides which logic states are sampled. let mut evaluator = scene.into_evaluator(DEFAULT_LOGIC_FPS); let mut app = RanimRenderApp::new(name, scene_config, &output, buffer_count); app.render_scene_with_progress(&mut evaluator, on_progress); if capture_marks && !evaluator.time_marks().is_empty() { app.render_capture_marks(&mut evaluator); } // The audio plane was baked in one offline pass at seal: mux the baked // buffer into the finished file. if evaluator.has_audio() { match audio::audio_codec(output.format) { Some(_) => { let pcm = evaluator.audio(); // Finalize the encoder before reading the file back for muxing. app.finish_video(); let video_path = app.video_path(); if !video_path.exists() { tracing::warn!("no video file was written, skipping audio muxing"); } else if let Err(err) = audio::mux_audio_into_video(video_path, pcm, output.format) { tracing::error!("failed to mux audio into {}: {err:#}", video_path.display()); } } None => tracing::warn!( "the scene has audio, but the {:?} format cannot store it; rendering silently", output.format ), } } } /// Default logic grid resolution (Hz), per the time model design. const DEFAULT_LOGIC_FPS: f64 = 120.0; /// Handle to the background render thread, used to submit frame data from the main thread. /// /// Dropping this handle closes the submission channel and terminates the worker thread loop. pub struct RenderThreadHandle { submit_frame_tx: async_channel::Sender<RenderFrame>, back_rx: async_channel::Receiver<RenderFrame>, worker_rx: async_channel::Receiver<RenderWorker>, } impl RenderThreadHandle { /// Reuses a [`RenderFrame`], applies `f` to it, and submits it to the background render thread. pub fn sync_and_submit(&self, f: impl FnOnce(&mut RenderFrame)) { let mut store = self.get_store(); f(&mut store); self.submit_frame_tx.send_blocking(store).unwrap(); } /// Retrieves a previously rendered [`RenderFrame`] from the background thread for reuse. pub fn get_store(&self) -> RenderFrame { self.back_rx.recv_blocking().unwrap() } /// Closes the submission channel, waits for the background thread to finish, and returns the [`RenderWorker`]. pub fn retrive(&self) -> RenderWorker { self.submit_frame_tx.close(); // This terminates the worker thread loop self.worker_rx.recv_blocking().unwrap() } } /// Background rendering worker that owns the wgpu context, renderer, output textures, and video/image writers. pub struct RenderWorker { ctx: WgpuContext, renderer: Renderer, render_textures: Vec<RenderTextures>, clear_color: wgpu::Color, // video writer video_writer: Option<FileWriter>, video_writer_builder: Option<FileWriterBuilder>, video_path: PathBuf, save_frames: bool, output_dir: PathBuf, scene_name: String, width: u32, height: u32, fps: u32, } impl RenderWorker { /// Creates a new [`RenderWorker`]. /// /// This checks for or downloads `ffmpeg`, initializes the wgpu context and renderer, and configures the video writer from [`Output`]. /// /// # Panics /// /// Panics if `buffer_count < 1`. pub fn new( scene_name: String, scene_config: &SceneConfig, output: &Output, buffer_count: usize, ) -> Self { assert!(buffer_count >= 1, "buffer_count must be at least 1"); info!("Checking ffmpeg..."); let t = Instant::now(); if let Ok(ffmpeg_path) = which::which("ffmpeg") { info!("ffmpeg found at {ffmpeg_path:?}"); } else { use std::path::Path; info!( "ffmpeg not found from path env, searching in {:?}...", Path::new("./").canonicalize().unwrap() ); if Path::new("./ffmpeg").exists() { info!("ffmpeg found at current working directory") } else { info!("ffmpeg not found at current working directory, downloading..."); download_ffmpeg("./").expect("failed to download ffmpeg"); } } trace!("Check ffmmpeg cost: {:?}", t.elapsed()); let t = Instant::now(); info!("Creating wgpu context..."); let ctx = pollster::block_on(WgpuContext::new()); trace!("Create wgpu context cost: {:?}", t.elapsed()); let mut output_dir = PathBuf::from(&output.dir); if !output_dir.is_absolute() { output_dir = std::env::current_dir().unwrap().join(output_dir); } let renderer = Renderer::new(&ctx, output.width, output.height, 8); let render_textures: Vec<RenderTextures> = (0..buffer_count) .map(|_| renderer.new_render_textures(&ctx)) .collect(); let clear_color = color::try_color(&scene_config.clear_color) .unwrap_or(color::color("#333333ff")) .convert::<LinearSrgb>(); let [r, g, b, a] = clear_color.components.map(|x| x as f64); let clear_color = wgpu::Color { r, g, b, a }; let (_, _, ext) = output.format.encoding_params(); let video_path = output_dir.join({ let template = output .name_template .as_deref() .unwrap_or("{name}_{width}x{height}_{fps}"); let base_name = render_output_basename( template, output.name.as_deref().unwrap_or(&scene_name), output.width, output.height, output.fps, ); format!("{base_name}.{ext}") }); Self { ctx, renderer, render_textures, clear_color, video_writer: None, video_writer_builder: Some( FileWriterBuilder::default() .with_fps(output.fps) .with_size(output.width, output.height) .with_file_path(video_path.clone()) .with_output_format(output.format), ), video_path, save_frames: output.save_frames, output_dir, scene_name, width: output.width, height: output.height, fps: output.fps, } } /// Returns the directory path used to save individual frame images. pub fn save_frame_dir(&self) -> PathBuf { self.output_dir.join(format!( "{}_{}x{}_{}-frames", self.scene_name, self.width, self.height, self.fps )) } /// Returns the path of the video file being written. pub fn video_path(&self) -> &Path { &self.video_path } /// Finalize the video file: flush the encoder and wait for it to exit. /// /// After this call the file on disk is complete (valid container index) /// and no further frames can be written. pub fn finish_video(&mut self) { self.video_writer = None; } /// Moves this [`RenderWorker`] into a newly spawned background thread and returns a [`RenderThreadHandle`]. /// /// The background thread loops over incoming [`RenderFrame`]s, rendering and outputting frames using multi-buffered async readback. pub fn yeet(self) -> RenderThreadHandle { let (submit_frame_tx, submit_frame_rx) = async_channel::bounded(1); let (back_tx, back_rx) = async_channel::bounded(1); let (worker_tx, worker_rx) = async_channel::bounded(1); back_tx.send_blocking(RenderFrame::default()).unwrap(); std::thread::spawn(move || { let mut worker = self; let n = worker.render_textures.len(); let mut frame_count = 0u64; let mut cur = 0usize; let mut pending: VecDeque<(usize, u64)> = VecDeque::new(); while let Ok(store) = submit_frame_rx.recv_blocking() { // Drain oldest pending readback if all targets are occupied if pending.len() >= n { let (prev, prev_fc) = pending.pop_front().unwrap(); worker.render_textures[prev].finish_readback(&worker.ctx); worker.output_frame_from(prev, prev_fc); } // Render current frame and start async readback worker.renderer.render_frame( &mut worker.render_textures[cur], worker.clear_color, &store, ); worker.render_textures[cur].start_readback(&worker.ctx); pending.push_back((cur, frame_count)); frame_count += 1; cur = (cur + 1) % n; // Return store early so main thread can eval next frame // while GPU processes the readback back_tx.send_blocking(store).unwrap(); // Now try to drain any completed readbacks while we wait // for the next frame from the main thread while let Some(&(prev, _)) = pending.front() { // Non-blocking: check if the oldest readback is ready if !worker.render_textures[prev].try_finish_readback(&worker.ctx) { break; } let (prev, prev_fc) = pending.pop_front().unwrap(); worker.output_frame_from(prev, prev_fc); } } // Flush all remaining pending frames while let Some((prev, prev_fc)) = pending.pop_front() { worker.render_textures[prev].finish_readback(&worker.ctx); worker.output_frame_from(prev, prev_fc); } worker_tx.send_blocking(worker).unwrap(); }); RenderThreadHandle { submit_frame_tx, back_rx, worker_rx, } } /// Renders a single frame synchronously on this worker (typically used for captures). pub fn render_store(&mut self, store: &RenderFrame) { self.renderer .render_frame(&mut self.render_textures[0], self.clear_color, store); } /// Write and save (if [`Self::save_frames`] is true) pub fn output_frame_from(&mut self, target_idx: usize, frame_number: u64) { self.write_frame_from(target_idx); if self.save_frames { self.save_frame_from(target_idx, frame_number); } } /// Write frame data from the given target to the video file. pub fn write_frame_from(&mut self, target_idx: usize) { let data = self.render_textures[target_idx] .render_texture .texture_data(); if let Some(video_writer) = self.video_writer.as_mut() { video_writer.write_frame(data); } else if let Some(builder) = self.video_writer_builder.as_ref() { self.video_writer .get_or_insert(builder.clone().build()) .write_frame(data); } } /// Save frame from the given target as a PNG image. pub fn save_frame_from(&mut self, target_idx: usize, frame_number: u64) { let path = self.save_frame_dir().join(format!("{frame_number:04}.png")); let dir = path.parent().unwrap(); if !dir.exists() || !dir.is_dir() { std::fs::create_dir_all(dir).unwrap(); } // Data is already in cpu buffer after finish_readback, this won't trigger GPU work let buffer = self.render_textures[target_idx].get_rendered_texture_img_buffer(&self.ctx); buffer.save(path).unwrap(); } /// Capture frame to image file (sync path, uses target 0). pub fn capture_frame(&mut self, path: impl AsRef<Path>) { let path = path.as_ref(); let path = if !path.is_absolute() { self.output_dir .join(format!( "{}_{}x{}_{}", self.scene_name, self.width, self.height, self.fps )) .join(path) } else { path.to_path_buf() }; let dir = path.parent().unwrap(); if !dir.exists() || !dir.is_dir() { std::fs::create_dir_all(dir).unwrap(); } let buffer = self.render_textures[0].get_rendered_texture_img_buffer(&self.ctx); buffer.save(path).unwrap(); } } /// MARK: RanimRenderApp /// Application wrapper that drives frame-by-frame scene rendering, video output, and capture screenshots. pub struct RanimRenderApp { render_worker: Option<RenderWorker>, fps: u32, store: RenderFrame, } impl RanimRenderApp { /// Creates the application and initializes the internal [`RenderWorker`]. pub fn new( scene_name: String, scene_config: &SceneConfig, output: &Output, buffer_count: usize, ) -> Self {