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::store::CoreItemStore;
10use ranim_core::{SealedRanimScene, TimeMark};
11use ranim_render::resource::{RenderPool, RenderTextures};
12use ranim_render::{Renderer, utils::WgpuContext};
13use std::time::Duration;
14use std::{
15    path::{Path, PathBuf},
16    time::Instant,
17};
18use tracing::{Span, info, instrument, trace};
19use tracing_indicatif::span_ext::IndicatifSpanExt;
20
21mod file_writer;
22
23#[cfg(feature = "profiling")]
24use ranim_render::PUFFIN_GPU_PROFILER;
25
26/// Render a scene with all its outputs
27pub fn render_scene(scene: &Scene, buffer_count: usize) {
28    for (i, output) in scene.outputs.iter().enumerate() {
29        info!(
30            "Rendering output {}/{} ({})",
31            i + 1,
32            scene.outputs.len(),
33            output.format
34        );
35        render_scene_output(
36            scene.constructor,
37            scene.name.to_string(),
38            &scene.config,
39            output,
40            buffer_count,
41        );
42    }
43}
44
45/// Render a scene output
46pub fn render_scene_output(
47    constructor: impl SceneConstructor,
48    name: String,
49    scene_config: &SceneConfig,
50    output: &Output,
51    buffer_count: usize,
52) {
53    use std::time::Instant;
54
55    let t = Instant::now();
56    let scene = constructor.build_scene();
57    trace!("Build timeline cost: {:?}", t.elapsed());
58
59    let mut app = RanimRenderApp::new(name, scene_config, output, buffer_count);
60    app.render_timeline(&scene);
61    if !scene.time_marks().is_empty() {
62        app.render_capture_marks(&scene);
63    }
64}
65
66/// drop it will close the channel and the thread loop will be terminated
67struct RenderThreadHandle {
68    submit_frame_tx: async_channel::Sender<CoreItemStore>,
69    back_rx: async_channel::Receiver<CoreItemStore>,
70    worker_rx: async_channel::Receiver<RenderWorker>,
71}
72
73impl RenderThreadHandle {
74    fn sync_and_submit(&self, f: impl FnOnce(&mut CoreItemStore)) {
75        let mut store = self.get_store();
76        f(&mut store);
77        self.submit_frame_tx.send_blocking(store).unwrap();
78    }
79    fn get_store(&self) -> CoreItemStore {
80        self.back_rx.recv_blocking().unwrap()
81    }
82    fn retrive(&self) -> RenderWorker {
83        self.submit_frame_tx.close(); // This terminates the worker thread loop
84        self.worker_rx.recv_blocking().unwrap()
85    }
86}
87
88struct RenderWorker {
89    ctx: WgpuContext,
90    renderer: Renderer,
91    render_textures: Vec<RenderTextures>,
92    pool: RenderPool,
93    clear_color: wgpu::Color,
94    // video writer
95    video_writer: Option<FileWriter>,
96    video_writer_builder: Option<FileWriterBuilder>,
97    save_frames: bool,
98    output_dir: PathBuf,
99    scene_name: String,
100    width: u32,
101    height: u32,
102    fps: u32,
103}
104
105impl RenderWorker {
106    fn new(
107        scene_name: String,
108        scene_config: &SceneConfig,
109        output: &Output,
110        buffer_count: usize,
111    ) -> Self {
112        assert!(buffer_count >= 1, "buffer_count must be at least 1");
113        info!("Checking ffmpeg...");
114        let t = Instant::now();
115        if let Ok(ffmpeg_path) = which::which("ffmpeg") {
116            info!("ffmpeg found at {ffmpeg_path:?}");
117        } else {
118            use std::path::Path;
119
120            info!(
121                "ffmpeg not found from path env, searching in {:?}...",
122                Path::new("./").canonicalize().unwrap()
123            );
124            if Path::new("./ffmpeg").exists() {
125                info!("ffmpeg found at current working directory")
126            } else {
127                info!("ffmpeg not found at current working directory, downloading...");
128                download_ffmpeg("./").expect("failed to download ffmpeg");
129            }
130        }
131        trace!("Check ffmmpeg cost: {:?}", t.elapsed());
132
133        let t = Instant::now();
134        info!("Creating wgpu context...");
135        let ctx = pollster::block_on(WgpuContext::new());
136        trace!("Create wgpu context cost: {:?}", t.elapsed());
137
138        let mut output_dir = PathBuf::from(&output.dir);
139        if !output_dir.is_absolute() {
140            output_dir = std::env::current_dir()
141                .unwrap()
142                .join("./output")
143                .join(output_dir);
144        }
145        let renderer = Renderer::new(&ctx, output.width, output.height, 8);
146        let render_textures: Vec<RenderTextures> = (0..buffer_count)
147            .map(|_| renderer.new_render_textures(&ctx))
148            .collect();
149        let clear_color = color::try_color(&scene_config.clear_color)
150            .unwrap_or(color::color("#333333ff"))
151            .convert::<LinearSrgb>();
152        let [r, g, b, a] = clear_color.components.map(|x| x as f64);
153        let clear_color = wgpu::Color { r, g, b, a };
154
155        let (_, _, ext) = output.format.encoding_params();
156        Self {
157            ctx,
158            renderer,
159            render_textures,
160            pool: RenderPool::new(),
161            clear_color,
162            video_writer: None,
163            video_writer_builder: Some(
164                FileWriterBuilder::default()
165                    .with_fps(output.fps)
166                    .with_size(output.width, output.height)
167                    .with_file_path(output_dir.join(format!(
168                        "{}_{}x{}_{}.{ext}",
169                        output.name.clone().unwrap_or(scene_name.clone()),
170                        output.width,
171                        output.height,
172                        output.fps
173                    )))
174                    .with_output_format(output.format),
175            ),
176            save_frames: output.save_frames,
177            output_dir,
178            scene_name,
179            width: output.width,
180            height: output.height,
181            fps: output.fps,
182        }
183    }
184
185    fn save_frame_dir(&self) -> PathBuf {
186        self.output_dir.join(format!(
187            "{}_{}x{}_{}-frames",
188            self.scene_name, self.width, self.height, self.fps
189        ))
190    }
191
192    fn yeet(self) -> RenderThreadHandle {
193        let (submit_frame_tx, submit_frame_rx) = async_channel::bounded(1);
194        let (back_tx, back_rx) = async_channel::bounded(1);
195        let (worker_tx, worker_rx) = async_channel::bounded(1);
196
197        back_tx.send_blocking(CoreItemStore::default()).unwrap();
198        std::thread::spawn(move || {
199            let mut worker = self;
200            let n = worker.render_textures.len();
201            let mut frame_count = 0u64;
202            let mut cur = 0usize;
203            let mut pending: VecDeque<(usize, u64)> = VecDeque::new();
204
205            while let Ok(store) = submit_frame_rx.recv_blocking() {
206                // Drain oldest pending readback if all targets are occupied
207                if pending.len() >= n {
208                    let (prev, prev_fc) = pending.pop_front().unwrap();
209                    worker.render_textures[prev].finish_readback(&worker.ctx);
210                    worker.output_frame_from(prev, prev_fc);
211                }
212
213                // Render current frame and start async readback
214                worker.renderer.render_store_with_pool(
215                    &worker.ctx,
216                    &mut worker.render_textures[cur],
217                    worker.clear_color,
218                    &store,
219                    &mut worker.pool,
220                );
221                worker.render_textures[cur].start_readback(&worker.ctx);
222                worker.pool.clean();
223
224                pending.push_back((cur, frame_count));
225                frame_count += 1;
226                cur = (cur + 1) % n;
227
228                // Return store early so main thread can eval next frame
229                // while GPU processes the readback
230                back_tx.send_blocking(store).unwrap();
231
232                // Now try to drain any completed readbacks while we wait
233                // for the next frame from the main thread
234                while let Some(&(prev, _)) = pending.front() {
235                    // Non-blocking: check if the oldest readback is ready
236                    if !worker.render_textures[prev].try_finish_readback(&worker.ctx) {
237                        break;
238                    }
239                    let (prev, prev_fc) = pending.pop_front().unwrap();
240                    worker.output_frame_from(prev, prev_fc);
241                }
242            }
243
244            // Flush all remaining pending frames
245            while let Some((prev, prev_fc)) = pending.pop_front() {
246                worker.render_textures[prev].finish_readback(&worker.ctx);
247                worker.output_frame_from(prev, prev_fc);
248            }
249
250            worker_tx.send_blocking(worker).unwrap();
251        });
252        RenderThreadHandle {
253            submit_frame_tx,
254            back_rx,
255            worker_rx,
256        }
257    }
258
259    fn render_store(&mut self, store: &CoreItemStore) {
260        #[cfg(feature = "profiling")]
261        profiling::scope!("frame");
262
263        {
264            #[cfg(feature = "profiling")]
265            profiling::scope!("render");
266
267            self.renderer.render_store_with_pool(
268                &self.ctx,
269                &mut self.render_textures[0],
270                self.clear_color,
271                store,
272                &mut self.pool,
273            );
274        }
275        self.pool.clean();
276
277        #[cfg(feature = "profiling")]
278        profiling::finish_frame!();
279    }
280
281    /// Write and save (if [`Self::save_frames`] is true)
282    fn output_frame_from(&mut self, target_idx: usize, frame_number: u64) {
283        self.write_frame_from(target_idx);
284        if self.save_frames {
285            self.save_frame_from(target_idx, frame_number);
286        }
287    }
288
289    /// Write frame data from the given target to the video file.
290    fn write_frame_from(&mut self, target_idx: usize) {
291        let data = self.render_textures[target_idx]
292            .render_texture
293            .texture_data();
294        if let Some(video_writer) = self.video_writer.as_mut() {
295            video_writer.write_frame(data);
296        } else if let Some(builder) = self.video_writer_builder.as_ref() {
297            self.video_writer
298                .get_or_insert(builder.clone().build())
299                .write_frame(data);
300        }
301    }
302
303    /// Save frame from the given target as a PNG image.
304    fn save_frame_from(&mut self, target_idx: usize, frame_number: u64) {
305        let path = self.save_frame_dir().join(format!("{frame_number:04}.png"));
306        let dir = path.parent().unwrap();
307        if !dir.exists() || !dir.is_dir() {
308            std::fs::create_dir_all(dir).unwrap();
309        }
310        // Data is already in cpu buffer after finish_readback, this won't trigger GPU work
311        let buffer = self.render_textures[target_idx].get_rendered_texture_img_buffer(&self.ctx);
312        buffer.save(path).unwrap();
313    }
314
315    /// Capture frame to image file (sync path, uses target 0).
316    pub fn capture_frame(&mut self, path: impl AsRef<Path>) {
317        let path = path.as_ref();
318        let path = if !path.is_absolute() {
319            self.output_dir
320                .join(format!(
321                    "{}_{}x{}_{}",
322                    self.scene_name, self.width, self.height, self.fps
323                ))
324                .join(path)
325        } else {
326            path.to_path_buf()
327        };
328        let dir = path.parent().unwrap();
329        if !dir.exists() || !dir.is_dir() {
330            std::fs::create_dir_all(dir).unwrap();
331        }
332        let buffer = self.render_textures[0].get_rendered_texture_img_buffer(&self.ctx);
333        buffer.save(path).unwrap();
334    }
335}
336
337/// MARK: RanimRenderApp
338struct RanimRenderApp {
339    render_worker: Option<RenderWorker>,
340    fps: u32,
341    store: CoreItemStore,
342}
343
344impl RanimRenderApp {
345    fn new(
346        scene_name: String,
347        scene_config: &SceneConfig,
348        output: &Output,
349        buffer_count: usize,
350    ) -> Self {
351        let render_worker = RenderWorker::new(scene_name, scene_config, output, buffer_count);
352        Self {
353            render_worker: Some(render_worker),
354            fps: output.fps,
355            store: CoreItemStore::default(),
356        }
357    }
358
359    #[instrument(skip_all)]
360    fn render_timeline(&mut self, timeline: &SealedRanimScene) {
361        let start = Instant::now();
362        #[cfg(feature = "profiling")]
363        let (_cpu_server, _gpu_server) = {
364            puffin::set_scopes_on(true);
365            // default global profiler
366            let cpu_server =
367                puffin_http::Server::new(&format!("0.0.0.0:{}", puffin_http::DEFAULT_PORT))
368                    .unwrap();
369            // custom gpu profiler in `PUFFIN_GPU_PROFILER`
370            let gpu_server = puffin_http::Server::new_custom(
371                &format!("0.0.0.0:{}", puffin_http::DEFAULT_PORT + 1),
372                |sink| PUFFIN_GPU_PROFILER.lock().unwrap().add_sink(sink),
373                |id| _ = PUFFIN_GPU_PROFILER.lock().unwrap().remove_sink(id),
374            )
375            .unwrap();
376            (cpu_server, gpu_server)
377        };
378
379        let worker_thread = self.render_worker.take().unwrap().yeet();
380
381        let total_secs = timeline.total_secs();
382        let fps = self.fps as f64;
383        let raw_frames = total_secs * fps;
384        // Add an extra frame to sample the final state exactly,
385        // unless total_secs * fps is already an integer (last frame lands on total_secs).
386        let n = raw_frames.ceil() as u64;
387        let num_frames = if (raw_frames - raw_frames.round()).abs() < 1e-9 {
388            n
389        } else {
390            n + 1
391        };
392        let style =             ProgressStyle::with_template(
393                "[{elapsed_precise}] [{wide_bar:.cyan/blue}] frame {human_pos}/{human_len} (eta {eta}) {msg}",
394            )
395            .unwrap()
396            .with_key("eta", |state: &ProgressState, w: &mut dyn std::fmt::Write| {
397                write!(w, "{:.1}s", state.eta().as_secs_f64()).unwrap()
398            })
399            .progress_chars("#>-");
400
401        let span = Span::current();
402        span.pb_set_style(&style);
403        span.pb_set_length(num_frames);
404
405        (0..num_frames)
406            .map(|f| (f as f64 / fps).min(total_secs))
407            .for_each(|sec| {
408                worker_thread.sync_and_submit(|store| {
409                    store.update(timeline.eval_at_sec(sec));
410                });
411
412                span.pb_inc(1);
413                span.pb_set_message(
414                    format!(
415                        "rendering {:.1?}/{:.1?}",
416                        Duration::from_secs_f64(sec),
417                        Duration::from_secs_f64(total_secs)
418                    )
419                    .as_str(),
420                );
421            });
422        self.render_worker.replace(worker_thread.retrive());
423
424        info!(
425            "rendered {} frames({:?}) in {:?}",
426            num_frames,
427            Duration::from_secs_f64(timeline.total_secs()),
428            start.elapsed(),
429        );
430        trace!("render timeline cost: {:?}", start.elapsed());
431    }
432
433    #[instrument(skip_all)]
434    fn render_capture_marks(&mut self, timeline: &SealedRanimScene) {
435        let start = Instant::now();
436        let timemarks = timeline
437            .time_marks()
438            .iter()
439            .filter(|mark| matches!(mark.1, TimeMark::Capture(_)))
440            .collect::<Vec<_>>();
441
442        let style =             ProgressStyle::with_template(
443                "[{elapsed_precise}] [{wide_bar:.cyan/blue}] frame {human_pos}/{human_len} (eta {eta}) {msg}",
444            )
445            .unwrap()
446            .with_key("eta", |state: &ProgressState, w: &mut dyn std::fmt::Write| {
447                write!(w, "{:.1}s", state.eta().as_secs_f64()).unwrap()
448            })
449            .progress_chars("#>-");
450
451        let span = Span::current();
452        span.pb_set_style(&style);
453        span.pb_set_length(timemarks.len() as u64);
454        let _enter = span.enter();
455
456        for (sec, TimeMark::Capture(filename)) in &timemarks {
457            let alpha = *sec / timeline.total_secs();
458
459            self.store.update(timeline.eval_at_alpha(alpha));
460            let worker = self.render_worker.as_mut().unwrap();
461            worker.render_store(&self.store);
462            worker.capture_frame(filename);
463            span.pb_inc(1);
464        }
465        info!("saved {} capture frames from time marks", timemarks.len());
466        trace!("save capture frames cost: {:?}", start.elapsed());
467    }
468}
469
470// MARK: Download ffmpeg
471const FFMPEG_RELEASE_URL: &str = "https://github.com/eugeneware/ffmpeg-static/releases/latest";
472
473#[allow(unused)]
474pub(crate) fn exe_dir() -> PathBuf {
475    std::env::current_exe()
476        .unwrap()
477        .parent()
478        .unwrap()
479        .to_path_buf()
480}
481
482/// Download latest release of ffmpeg from <https://github.com/eugeneware/ffmpeg-static/releases/latest> to <target_dir>/ffmpeg
483pub fn download_ffmpeg(target_dir: impl AsRef<Path>) -> Result<PathBuf, anyhow::Error> {
484    use anyhow::Context;
485    use itertools::Itertools;
486    use std::io::Read;
487    use tracing::info;
488
489    let target_dir = target_dir.as_ref();
490
491    let res = reqwest::blocking::get(FFMPEG_RELEASE_URL).context("failed to get release url")?;
492    let url = res.url().to_string();
493    let url = url.split("tag").collect_array::<2>().unwrap();
494    let url = format!("{}/download/{}", url[0], url[1]);
495    info!("ffmpeg release url: {url:?}");
496
497    #[cfg(all(target_os = "windows", target_arch = "x86_64"))]
498    let url = format!("{url}/ffmpeg-win32-x64.gz");
499    #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
500    let url = format!("{url}/ffmpeg-linux-x64.gz");
501    #[cfg(all(target_os = "linux", target_arch = "aarch64"))]
502    let url = format!("{url}/ffmpeg-linux-arm64.gz");
503    #[cfg(all(target_os = "macos", target_arch = "x86_64"))]
504    let url = format!("{url}/ffmpeg-darwin-x64.gz");
505    #[cfg(all(target_os = "macos", target_arch = "aarch64"))]
506    let url = format!("{url}/ffmpeg-darwin-arm64.gz");
507
508    info!("downloading ffmpeg from {url:?}...");
509
510    let res = reqwest::blocking::get(&url).context("get err")?;
511    let mut decoder = flate2::bufread::GzDecoder::new(std::io::BufReader::new(
512        std::io::Cursor::new(res.bytes().unwrap()),
513    ));
514    let mut bytes = Vec::new();
515    decoder
516        .read_to_end(&mut bytes)
517        .context("GzDecoder decode err")?;
518    let ffmpeg_path = target_dir.join("ffmpeg");
519    std::fs::write(&ffmpeg_path, bytes).unwrap();
520
521    #[cfg(target_family = "unix")]
522    {
523        use std::os::unix::fs::PermissionsExt;
524
525        std::fs::set_permissions(&ffmpeg_path, std::fs::Permissions::from_mode(0o755))?;
526    }
527    info!("ffmpeg downloaded to {target_dir:?}");
528    Ok(ffmpeg_path)
529}