Skip to main content

ranim/cmd/render/
mod.rs

1// MARK: Render api
2use std::collections::VecDeque;
3
4use crate::cmd::render::file_writer::OutputFormatExt;
5use crate::{Output, Scene, SceneConfig, SceneConstructor};
6use file_writer::{FileWriter, FileWriterBuilder};
7use indicatif::{ProgressState, ProgressStyle};
8use ranim_core::color::{self, LinearSrgb};
9use ranim_core::{SceneEvaluator, TimeMark};
10use ranim_render::resource::RenderTextures;
11use ranim_render::{Renderer, utils::WgpuContext, world::RenderFrame};
12use std::path::{Path, PathBuf};
13use std::time::Duration;
14use std::time::Instant;
15use tracing::{Span, info, instrument, trace};
16use tracing_indicatif::span_ext::IndicatifSpanExt;
17
18pub(crate) mod file_writer;
19
20#[cfg(feature = "profiling")]
21use ranim_render::PUFFIN_GPU_PROFILER;
22
23/// Render the output basename template by replacing placeholders.
24///
25/// Supported placeholders:
26/// - `{name}`: the scene/output name
27/// - `{width}`: output width in pixels
28/// - `{height}`: output height in pixels
29/// - `{fps}`: output frame rate
30fn render_output_basename(template: &str, name: &str, width: u32, height: u32, fps: u32) -> String {
31    template
32        .replace("{name}", name)
33        .replace("{width}", &width.to_string())
34        .replace("{height}", &height.to_string())
35        .replace("{fps}", &fps.to_string())
36}
37
38/// Render a scene with all its outputs
39pub fn render_scene(scene: &Scene, buffer_count: usize) {
40    for (i, output) in scene.outputs.iter().enumerate() {
41        info!(
42            "Rendering output {}/{} ({})",
43            i + 1,
44            scene.outputs.len(),
45            output.format
46        );
47        render_scene_output(
48            scene.constructor,
49            scene.name.to_string(),
50            &scene.config,
51            output,
52            buffer_count,
53        );
54    }
55}
56
57/// Render a scene output
58pub fn render_scene_output(
59    constructor: impl SceneConstructor,
60    name: String,
61    scene_config: &SceneConfig,
62    output: &Output,
63    buffer_count: usize,
64) {
65    render_scene_output_with_progress(constructor, name, scene_config, output, buffer_count, None);
66}
67
68/// Render a scene output with optional progress callback.
69///
70/// The callback receives `(current_frame, total_frames)` each frame.
71pub fn render_scene_output_with_progress(
72    constructor: impl SceneConstructor,
73    name: String,
74    scene_config: &SceneConfig,
75    output: &Output,
76    buffer_count: usize,
77    on_progress: Option<Box<dyn Fn(u64, u64) + Send>>,
78) {
79    use std::time::Instant;
80
81    info!(
82        "Output: {}x{} {}fps {} dir={:?} save_frames={}",
83        output.width, output.height, output.fps, output.format, output.dir, output.save_frames
84    );
85
86    let t = Instant::now();
87    let scene = constructor.build_scene();
88    trace!("Build timeline cost: {:?}", t.elapsed());
89
90    // Default logic grid 120 Hz (per the time model design); render fps only
91    // decides which logic states are sampled.
92    let mut evaluator = scene.into_evaluator(DEFAULT_LOGIC_FPS);
93
94    let mut app = RanimRenderApp::new(name, scene_config, output, buffer_count);
95    app.render_scene_with_progress(&mut evaluator, on_progress);
96    if !evaluator.time_marks().is_empty() {
97        app.render_capture_marks(&mut evaluator);
98    }
99}
100
101/// Default logic grid resolution (Hz), per the time model design.
102const DEFAULT_LOGIC_FPS: f64 = 120.0;
103
104/// Handle to the background render thread, used to submit frame data from the main thread.
105///
106/// Dropping this handle closes the submission channel and terminates the worker thread loop.
107pub struct RenderThreadHandle {
108    submit_frame_tx: async_channel::Sender<RenderFrame>,
109    back_rx: async_channel::Receiver<RenderFrame>,
110    worker_rx: async_channel::Receiver<RenderWorker>,
111}
112
113impl RenderThreadHandle {
114    /// Reuses a [`RenderFrame`], applies `f` to it, and submits it to the background render thread.
115    pub fn sync_and_submit(&self, f: impl FnOnce(&mut RenderFrame)) {
116        let mut store = self.get_store();
117        f(&mut store);
118        self.submit_frame_tx.send_blocking(store).unwrap();
119    }
120
121    /// Retrieves a previously rendered [`RenderFrame`] from the background thread for reuse.
122    pub fn get_store(&self) -> RenderFrame {
123        self.back_rx.recv_blocking().unwrap()
124    }
125
126    /// Closes the submission channel, waits for the background thread to finish, and returns the [`RenderWorker`].
127    pub fn retrive(&self) -> RenderWorker {
128        self.submit_frame_tx.close(); // This terminates the worker thread loop
129        self.worker_rx.recv_blocking().unwrap()
130    }
131}
132
133/// Background rendering worker that owns the wgpu context, renderer, output textures, and video/image writers.
134pub struct RenderWorker {
135    ctx: WgpuContext,
136    renderer: Renderer,
137    render_textures: Vec<RenderTextures>,
138    clear_color: wgpu::Color,
139    // video writer
140    video_writer: Option<FileWriter>,
141    video_writer_builder: Option<FileWriterBuilder>,
142    save_frames: bool,
143    output_dir: PathBuf,
144    scene_name: String,
145    width: u32,
146    height: u32,
147    fps: u32,
148}
149
150impl RenderWorker {
151    /// Creates a new [`RenderWorker`].
152    ///
153    /// This checks for or downloads `ffmpeg`, initializes the wgpu context and renderer, and configures the video writer from [`Output`].
154    ///
155    /// # Panics
156    ///
157    /// Panics if `buffer_count < 1`.
158    pub fn new(
159        scene_name: String,
160        scene_config: &SceneConfig,
161        output: &Output,
162        buffer_count: usize,
163    ) -> Self {
164        assert!(buffer_count >= 1, "buffer_count must be at least 1");
165        info!("Checking ffmpeg...");
166        let t = Instant::now();
167        if let Ok(ffmpeg_path) = which::which("ffmpeg") {
168            info!("ffmpeg found at {ffmpeg_path:?}");
169        } else {
170            use std::path::Path;
171
172            info!(
173                "ffmpeg not found from path env, searching in {:?}...",
174                Path::new("./").canonicalize().unwrap()
175            );
176            if Path::new("./ffmpeg").exists() {
177                info!("ffmpeg found at current working directory")
178            } else {
179                info!("ffmpeg not found at current working directory, downloading...");
180                download_ffmpeg("./").expect("failed to download ffmpeg");
181            }
182        }
183        trace!("Check ffmmpeg cost: {:?}", t.elapsed());
184
185        let t = Instant::now();
186        info!("Creating wgpu context...");
187        let ctx = pollster::block_on(WgpuContext::new());
188        trace!("Create wgpu context cost: {:?}", t.elapsed());
189
190        let mut output_dir = PathBuf::from(&output.dir);
191        if !output_dir.is_absolute() {
192            output_dir = std::env::current_dir().unwrap().join(output_dir);
193        }
194        let renderer = Renderer::new(&ctx, output.width, output.height, 8);
195        let render_textures: Vec<RenderTextures> = (0..buffer_count)
196            .map(|_| renderer.new_render_textures(&ctx))
197            .collect();
198        let clear_color = color::try_color(&scene_config.clear_color)
199            .unwrap_or(color::color("#333333ff"))
200            .convert::<LinearSrgb>();
201        let [r, g, b, a] = clear_color.components.map(|x| x as f64);
202        let clear_color = wgpu::Color { r, g, b, a };
203        let (_, _, ext) = output.format.encoding_params();
204        Self {
205            ctx,
206            renderer,
207            render_textures,
208            clear_color,
209            video_writer: None,
210            video_writer_builder: Some(
211                FileWriterBuilder::default()
212                    .with_fps(output.fps)
213                    .with_size(output.width, output.height)
214                    .with_file_path(output_dir.join({
215                        let template = output
216                            .name_template
217                            .as_deref()
218                            .unwrap_or("{name}_{width}x{height}_{fps}");
219                        let base_name = render_output_basename(
220                            template,
221                            output.name.as_deref().unwrap_or(&scene_name),
222                            output.width,
223                            output.height,
224                            output.fps,
225                        );
226                        format!("{base_name}.{ext}")
227                    }))
228                    .with_output_format(output.format),
229            ),
230            save_frames: output.save_frames,
231            output_dir,
232            scene_name,
233            width: output.width,
234            height: output.height,
235            fps: output.fps,
236        }
237    }
238
239    /// Returns the directory path used to save individual frame images.
240    pub fn save_frame_dir(&self) -> PathBuf {
241        self.output_dir.join(format!(
242            "{}_{}x{}_{}-frames",
243            self.scene_name, self.width, self.height, self.fps
244        ))
245    }
246
247    /// Moves this [`RenderWorker`] into a newly spawned background thread and returns a [`RenderThreadHandle`].
248    ///
249    /// The background thread loops over incoming [`RenderFrame`]s, rendering and outputting frames using multi-buffered async readback.
250    pub fn yeet(self) -> RenderThreadHandle {
251        let (submit_frame_tx, submit_frame_rx) = async_channel::bounded(1);
252        let (back_tx, back_rx) = async_channel::bounded(1);
253        let (worker_tx, worker_rx) = async_channel::bounded(1);
254
255        back_tx.send_blocking(RenderFrame::default()).unwrap();
256        std::thread::spawn(move || {
257            let mut worker = self;
258            let n = worker.render_textures.len();
259            let mut frame_count = 0u64;
260            let mut cur = 0usize;
261            let mut pending: VecDeque<(usize, u64)> = VecDeque::new();
262
263            while let Ok(store) = submit_frame_rx.recv_blocking() {
264                // Drain oldest pending readback if all targets are occupied
265                if pending.len() >= n {
266                    let (prev, prev_fc) = pending.pop_front().unwrap();
267                    worker.render_textures[prev].finish_readback(&worker.ctx);
268                    worker.output_frame_from(prev, prev_fc);
269                }
270
271                // Render current frame and start async readback
272                worker.renderer.render_frame(
273                    &mut worker.render_textures[cur],
274                    worker.clear_color,
275                    &store,
276                );
277                worker.render_textures[cur].start_readback(&worker.ctx);
278
279                pending.push_back((cur, frame_count));
280                frame_count += 1;
281                cur = (cur + 1) % n;
282
283                // Return store early so main thread can eval next frame
284                // while GPU processes the readback
285                back_tx.send_blocking(store).unwrap();
286
287                // Now try to drain any completed readbacks while we wait
288                // for the next frame from the main thread
289                while let Some(&(prev, _)) = pending.front() {
290                    // Non-blocking: check if the oldest readback is ready
291                    if !worker.render_textures[prev].try_finish_readback(&worker.ctx) {
292                        break;
293                    }
294                    let (prev, prev_fc) = pending.pop_front().unwrap();
295                    worker.output_frame_from(prev, prev_fc);
296                }
297            }
298
299            // Flush all remaining pending frames
300            while let Some((prev, prev_fc)) = pending.pop_front() {
301                worker.render_textures[prev].finish_readback(&worker.ctx);
302                worker.output_frame_from(prev, prev_fc);
303            }
304
305            worker_tx.send_blocking(worker).unwrap();
306        });
307        RenderThreadHandle {
308            submit_frame_tx,
309            back_rx,
310            worker_rx,
311        }
312    }
313
314    /// Renders a single frame synchronously on this worker (typically used for captures).
315    pub fn render_store(&mut self, store: &RenderFrame) {
316        #[cfg(feature = "profiling")]
317        profiling::scope!("frame");
318
319        {
320            #[cfg(feature = "profiling")]
321            profiling::scope!("render");
322
323            self.renderer
324                .render_frame(&mut self.render_textures[0], self.clear_color, store);
325        }
326
327        #[cfg(feature = "profiling")]
328        profiling::finish_frame!();
329    }
330
331    /// Write and save (if [`Self::save_frames`] is true)
332    pub fn output_frame_from(&mut self, target_idx: usize, frame_number: u64) {
333        self.write_frame_from(target_idx);
334        if self.save_frames {
335            self.save_frame_from(target_idx, frame_number);
336        }
337    }
338
339    /// Write frame data from the given target to the video file.
340    pub fn write_frame_from(&mut self, target_idx: usize) {
341        let data = self.render_textures[target_idx]
342            .render_texture
343            .texture_data();
344        if let Some(video_writer) = self.video_writer.as_mut() {
345            video_writer.write_frame(data);
346        } else if let Some(builder) = self.video_writer_builder.as_ref() {
347            self.video_writer
348                .get_or_insert(builder.clone().build())
349                .write_frame(data);
350        }
351    }
352
353    /// Save frame from the given target as a PNG image.
354    pub fn save_frame_from(&mut self, target_idx: usize, frame_number: u64) {
355        let path = self.save_frame_dir().join(format!("{frame_number:04}.png"));
356        let dir = path.parent().unwrap();
357        if !dir.exists() || !dir.is_dir() {
358            std::fs::create_dir_all(dir).unwrap();
359        }
360        // Data is already in cpu buffer after finish_readback, this won't trigger GPU work
361        let buffer = self.render_textures[target_idx].get_rendered_texture_img_buffer(&self.ctx);
362        buffer.save(path).unwrap();
363    }
364
365    /// Capture frame to image file (sync path, uses target 0).
366    pub fn capture_frame(&mut self, path: impl AsRef<Path>) {
367        let path = path.as_ref();
368        let path = if !path.is_absolute() {
369            self.output_dir
370                .join(format!(
371                    "{}_{}x{}_{}",
372                    self.scene_name, self.width, self.height, self.fps
373                ))
374                .join(path)
375        } else {
376            path.to_path_buf()
377        };
378        let dir = path.parent().unwrap();
379        if !dir.exists() || !dir.is_dir() {
380            std::fs::create_dir_all(dir).unwrap();
381        }
382        let buffer = self.render_textures[0].get_rendered_texture_img_buffer(&self.ctx);
383        buffer.save(path).unwrap();
384    }
385}
386
387/// MARK: RanimRenderApp
388/// Application wrapper that drives frame-by-frame scene rendering, video output, and capture screenshots.
389pub struct RanimRenderApp {
390    render_worker: Option<RenderWorker>,
391    fps: u32,
392    store: RenderFrame,
393}
394
395impl RanimRenderApp {
396    /// Creates the application and initializes the internal [`RenderWorker`].
397    pub fn new(
398        scene_name: String,
399        scene_config: &SceneConfig,
400        output: &Output,
401        buffer_count: usize,
402    ) -> Self {
403        let render_worker = RenderWorker::new(scene_name, scene_config, output, buffer_count);
404        Self {
405            render_worker: Some(render_worker),
406            fps: output.fps,
407            store: RenderFrame::default(),
408        }
409    }
410
411    /// Renders the entire scene and reports progress via a tracing progress bar and an optional `on_progress` callback.
412    ///
413    /// The frame count is `evaluator.total_secs() * fps` rounded up, with an extra frame appended when necessary to sample the final state.
414    #[instrument(skip_all)]
415    pub fn render_scene_with_progress(
416        &mut self,
417        evaluator: &mut SceneEvaluator,
418        on_progress: Option<Box<dyn Fn(u64, u64) + Send>>,
419    ) {
420        let start = Instant::now();
421        #[cfg(feature = "profiling")]
422        let (_cpu_server, _gpu_server) = {
423            puffin::set_scopes_on(true);
424            // default global profiler
425            let cpu_server =
426                puffin_http::Server::new(&format!("0.0.0.0:{}", puffin_http::DEFAULT_PORT))
427                    .unwrap();
428            // custom gpu profiler in `PUFFIN_GPU_PROFILER`
429            let gpu_server = puffin_http::Server::new_custom(
430                &format!("0.0.0.0:{}", puffin_http::DEFAULT_PORT + 1),
431                |sink| PUFFIN_GPU_PROFILER.lock().unwrap().add_sink(sink),
432                |id| _ = PUFFIN_GPU_PROFILER.lock().unwrap().remove_sink(id),
433            )
434            .unwrap();
435            (cpu_server, gpu_server)
436        };
437
438        let worker_thread = self.render_worker.take().unwrap().yeet();
439
440        let total_secs = evaluator.total_secs();
441        let fps = self.fps as f64;
442        let raw_frames = total_secs * fps;
443        // Add an extra frame to sample the final state exactly,
444        // unless total_secs * fps is already an integer (last frame lands on total_secs).
445        let n = raw_frames.ceil() as u64;
446        let num_frames = if (raw_frames - raw_frames.round()).abs() < 1e-9 {
447            n
448        } else {
449            n + 1
450        };
451        let style =             ProgressStyle::with_template(
452                "[{elapsed_precise}] [{wide_bar:.cyan/blue}] frame {human_pos}/{human_len} (eta {eta}) {msg}",
453            )
454            .unwrap()
455            .with_key("eta", |state: &ProgressState, w: &mut dyn std::fmt::Write| {
456                write!(w, "{:.1}s", state.eta().as_secs_f64()).unwrap()
457            })
458            .progress_chars("#>-");
459
460        let span = Span::current();
461        span.pb_set_style(&style);
462        span.pb_set_length(num_frames);
463
464        (0..num_frames)
465            .map(|f| (f as f64 / fps).min(total_secs))
466            .enumerate()
467            .for_each(|(i, sec)| {
468                let mut frame_items = Vec::new();
469                evaluator.advance_to(sec);
470                evaluator.sample_into(&mut frame_items);
471                worker_thread.sync_and_submit(move |store| {
472                    store.update(frame_items.into_iter());
473                });
474
475                span.pb_inc(1);
476                if let Some(cb) = &on_progress {
477                    cb(i as u64 + 1, num_frames);
478                }
479                span.pb_set_message(
480                    format!(
481                        "rendering {:.1?}/{:.1?}",
482                        Duration::from_secs_f64(sec),
483                        Duration::from_secs_f64(total_secs)
484                    )
485                    .as_str(),
486                );
487            });
488        self.render_worker.replace(worker_thread.retrive());
489
490        info!(
491            "rendered {} frames({:?}) in {:?}",
492            num_frames,
493            Duration::from_secs_f64(evaluator.total_secs()),
494            start.elapsed(),
495        );
496        trace!("render timeline cost: {:?}", start.elapsed());
497    }
498
499    /// Renders and saves screenshots for every [`TimeMark::Capture`] mark on the timeline.
500    #[instrument(skip_all)]
501    pub fn render_capture_marks(&mut self, evaluator: &mut SceneEvaluator) {
502        let start = Instant::now();
503        let timemarks = evaluator
504            .time_marks()
505            .iter()
506            .filter(|mark| matches!(mark.1, TimeMark::Capture(_)))
507            .map(|(sec, mark)| (*sec, mark.clone()))
508            .collect::<Vec<_>>();
509
510        let style =             ProgressStyle::with_template(
511                "[{elapsed_precise}] [{wide_bar:.cyan/blue}] frame {human_pos}/{human_len} (eta {eta}) {msg}",
512            )
513            .unwrap()
514            .with_key("eta", |state: &ProgressState, w: &mut dyn std::fmt::Write| {
515                write!(w, "{:.1}s", state.eta().as_secs_f64()).unwrap()
516            })
517            .progress_chars("#>-");
518
519        let span = Span::current();
520        span.pb_set_style(&style);
521        span.pb_set_length(timemarks.len() as u64);
522        let _enter = span.enter();
523
524        let mut captured = 0usize;
525        for (sec, TimeMark::Capture(filename)) in timemarks {
526            // The render has advanced to the end; captures must seek back and
527            // replay (deterministic contract).
528            evaluator.seek(sec);
529            let mut frame_items = Vec::new();
530            evaluator.sample_into(&mut frame_items);
531            self.store.update(frame_items.into_iter());
532            let worker = self.render_worker.as_mut().unwrap();
533            worker.render_store(&self.store);
534            worker.capture_frame(filename);
535            span.pb_inc(1);
536            captured += 1;
537        }
538        info!("saved {} capture frames from time marks", captured);
539
540        trace!("save capture frames cost: {:?}", start.elapsed());
541    }
542}
543
544// MARK: Download ffmpeg
545const FFMPEG_RELEASE_URL: &str = "https://github.com/eugeneware/ffmpeg-static/releases/latest";
546
547/// Returns the directory of the current executable.
548#[allow(unused)]
549pub(crate) fn exe_dir() -> PathBuf {
550    std::env::current_exe()
551        .unwrap()
552        .parent()
553        .unwrap()
554        .to_path_buf()
555}
556
557/// Download latest release of ffmpeg from <https://github.com/eugeneware/ffmpeg-static/releases/latest> to <target_dir>/ffmpeg
558pub fn download_ffmpeg(target_dir: impl AsRef<Path>) -> Result<PathBuf, anyhow::Error> {
559    use anyhow::Context;
560    use itertools::Itertools;
561    use std::io::Read;
562    use tracing::info;
563
564    let target_dir = target_dir.as_ref();
565
566    let res = reqwest::blocking::get(FFMPEG_RELEASE_URL).context("failed to get release url")?;
567    let url = res.url().to_string();
568    let url = url.split("tag").collect_array::<2>().unwrap();
569    let url = format!("{}/download/{}", url[0], url[1]);
570    info!("ffmpeg release url: {url:?}");
571
572    #[cfg(all(target_os = "windows", target_arch = "x86_64"))]
573    let url = format!("{url}/ffmpeg-win32-x64.gz");
574    #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
575    let url = format!("{url}/ffmpeg-linux-x64.gz");
576    #[cfg(all(target_os = "linux", target_arch = "aarch64"))]
577    let url = format!("{url}/ffmpeg-linux-arm64.gz");
578    #[cfg(all(target_os = "macos", target_arch = "x86_64"))]
579    let url = format!("{url}/ffmpeg-darwin-x64.gz");
580    #[cfg(all(target_os = "macos", target_arch = "aarch64"))]
581    let url = format!("{url}/ffmpeg-darwin-arm64.gz");
582
583    info!("downloading ffmpeg from {url:?}...");
584
585    let res = reqwest::blocking::get(&url).context("get err")?;
586    let mut decoder = flate2::bufread::GzDecoder::new(std::io::BufReader::new(
587        std::io::Cursor::new(res.bytes().unwrap()),
588    ));
589    let mut bytes = Vec::new();
590    decoder
591        .read_to_end(&mut bytes)
592        .context("GzDecoder decode err")?;
593    let ffmpeg_path = target_dir.join("ffmpeg");
594    std::fs::write(&ffmpeg_path, bytes).unwrap();
595
596    #[cfg(target_family = "unix")]
597    {
598        use std::os::unix::fs::PermissionsExt;
599
600        std::fs::set_permissions(&ffmpeg_path, std::fs::Permissions::from_mode(0o755))?;
601    }
602    info!("ffmpeg downloaded to {target_dir:?}");
603    Ok(ffmpeg_path)
604}
605
606#[cfg(test)]
607mod tests {
608    use super::render_output_basename;
609
610    #[test]
611    fn test_render_output_basename_default_template() {
612        assert_eq!(
613            render_output_basename("{name}_{width}x{height}_{fps}", "my_scene", 1920, 1080, 60),
614            "my_scene_1920x1080_60"
615        );
616    }
617
618    #[test]
619    fn test_render_output_basename_custom_template() {
620        assert_eq!(
621            render_output_basename(
622                "{name}_{fps}fps_{width}x{height}",
623                "my_scene",
624                1920,
625                1080,
626                60
627            ),
628            "my_scene_60fps_1920x1080"
629        );
630    }
631
632    #[test]
633    fn test_render_output_basename_name_only() {
634        assert_eq!(
635            render_output_basename("{name}", "my_scene", 1920, 1080, 60),
636            "my_scene"
637        );
638    }
639}