1mod audio;
2mod depth_visual;
3#[cfg(feature = "subsecond")]
4mod hot_reload;
5mod playback;
6mod profiler;
7mod timeline;
8
9use std::sync::Arc;
10
11use crate::{
12 Output, Scene, SceneConstructor,
13 app::render_setup_of,
14 core::{
15 SceneEvaluator,
16 color::{self, LinearSrgb},
17 },
18 render::{
19 RanimRenderApp, RenderAppExt, install_render_app, resource::RenderTextures,
20 utils::WgpuContext,
21 },
22};
23use crate::{OutputFormat, cmd::render::file_writer::OutputFormatExt};
24use async_channel::{Receiver, unbounded};
25use bevy_app::SubApp;
26use bevy_ecs::prelude::Mut;
27use depth_visual::DepthVisualPipeline;
28use eframe::egui;
29use playback::PlaybackEngine;
30use ranim_core::frame::EvaluatedFrame;
31use timeline::TimelineState;
32use tracing::error;
33use tracing::info;
34use web_time::Instant;
35
36pub(crate) const DEFAULT_LOGIC_FPS: f64 = 120.0;
38
39enum ExportProgress {
40 Progress(u64, u64),
42 Done,
43 Error(String),
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum ViewMode {
48 Output,
49 Depth,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq)]
53pub struct Resolution {
54 pub width: u32,
55 pub height: u32,
56}
57
58impl Resolution {
59 pub const fn new(width: u32, height: u32) -> Self {
60 Self { width, height }
61 }
62
63 pub fn ratio(&self) -> f32 {
64 self.width as f32 / self.height as f32
65 }
66
67 pub fn aspect_ratio(&self) -> (u32, u32) {
69 fn gcd(a: u32, b: u32) -> u32 {
70 if b == 0 { a } else { gcd(b, a % b) }
71 }
72 let g = gcd(self.width, self.height);
73 (self.width / g, self.height / g)
74 }
75
76 pub fn aspect_ratio_str(&self) -> String {
77 let (w, h) = self.aspect_ratio();
78 format!("{w}:{h}")
79 }
80}
81
82impl Resolution {
84 pub const HD: Self = Self::new(1280, 720);
86 pub const FHD: Self = Self::new(1920, 1080);
87 pub const QHD: Self = Self::new(2560, 1440);
88 pub const UHD: Self = Self::new(3840, 2160);
89 pub const WXGA: Self = Self::new(1280, 800);
91 pub const WUXGA: Self = Self::new(1920, 1200);
92 pub const SVGA: Self = Self::new(800, 600);
94 pub const XGA: Self = Self::new(1024, 768);
95 pub const SXGA: Self = Self::new(1280, 960);
96 pub const _1K_SQUARE: Self = Self::new(1080, 1080);
98 pub const _2K_SQUARE: Self = Self::new(2160, 2160);
99 pub const UW_QHD: Self = Self::new(3440, 1440);
101}
102
103pub struct RanimPreviewApp {
104 #[allow(unused)]
105 title: String,
106 clear_color: wgpu::Color,
107 resolution: Resolution,
108 evaluator: SceneEvaluator,
109 need_eval: bool,
110 last_sec: f64,
111 timeline_state: TimelineState,
112 playback_engine: PlaybackEngine,
113
114 render_textures: Option<RenderTextures>,
116 texture_id: Option<egui::TextureId>,
117 depth_texture_id: Option<egui::TextureId>,
118 view_mode: ViewMode,
119 wgpu_ctx: Option<WgpuContext>,
120 last_render_time: Option<std::time::Duration>,
121 last_eval_time: Option<std::time::Duration>,
122
123 depth_visual_pipeline: Option<DepthVisualPipeline>,
125 depth_visual_texture: Option<wgpu::Texture>,
126 depth_visual_view: Option<wgpu::TextureView>,
127
128 resolution_dirty: bool,
130
131 export_dialog_open: bool,
133 export_config: Output,
134 export_progress_rx: Option<Receiver<ExportProgress>>,
135 export_current_frame: u64,
136 export_total_frames: u64,
137
138 playback_speed: f64,
140 looping: bool,
141
142 profiler_open: bool,
144 gpu_pass_times: Vec<(String, f64)>,
147 cpu_spans: Vec<(String, f64)>,
150 upload_stats:
152 std::collections::BTreeMap<&'static str, crate::render::upload_probe::UploadStats>,
153 progress_samples: Vec<Option<profiler::ProgressSample>>,
157 progress_total_sec: f64,
158 profiler_metric: profiler::ProfilerMetric,
159 profiler_stacked: bool,
162
163 app: crate::RanimApp,
165 scene: Scene,
167 #[cfg(feature = "subsecond")]
168 hot_reload: Option<hot_reload::HotReload>,
169}
170
171impl RanimPreviewApp {
172 pub fn new(app: crate::RanimApp, scene: Scene) -> Self {
173 let mut app = app;
174 app.init_resource::<EvaluatedFrame>();
176
177 let t = Instant::now();
178
179 info!("building scene...");
180 let timeline = scene.build_scene();
181 info!("Scene built, cost: {:?}", t.elapsed());
182
183 info!("Getting timelines info...");
184 let animation_infos = timeline.get_animation_infos();
185 let total_secs = timeline.total_secs();
186 let evaluator = timeline.into_evaluator(DEFAULT_LOGIC_FPS);
187 info!("Total {} root animations", animation_infos.len());
188
189 let playback_engine = PlaybackEngine::new(&evaluator);
190
191 let clear_color = scene.config.clear_color.clone();
192 let mut preview = Self {
193 title: scene.name.clone(),
194 clear_color: wgpu::Color::TRANSPARENT,
195 resolution: Resolution::QHD,
196 timeline_state: TimelineState::new(total_secs, animation_infos),
197 evaluator,
198 need_eval: false,
199 last_sec: -1.0,
200 playback_engine,
201 render_textures: None,
202 texture_id: None,
203 depth_texture_id: None,
204 view_mode: ViewMode::Output,
205 wgpu_ctx: None,
206 last_render_time: None,
207 last_eval_time: None,
208 depth_visual_pipeline: None,
209 depth_visual_texture: None,
210 depth_visual_view: None,
211 resolution_dirty: false,
212 export_dialog_open: false,
213 export_config: Output::default(),
214 export_progress_rx: None,
215 export_current_frame: 0,
216 export_total_frames: 0,
217 playback_speed: 1.0,
218 looping: false,
219 profiler_open: false,
220 gpu_pass_times: Vec::new(),
221 cpu_spans: Vec::new(),
222 upload_stats: std::collections::BTreeMap::new(),
223 progress_samples: Vec::new(),
224 progress_total_sec: -1.0,
225 profiler_metric: profiler::ProfilerMetric::default(),
226 profiler_stacked: true,
227 app,
228 scene,
229 #[cfg(feature = "subsecond")]
230 hot_reload: None,
231 };
232 preview.set_clear_color_str(&clear_color);
233 preview
234 }
235
236 #[cfg(feature = "subsecond")]
239 fn reload_scene(&mut self) {
240 self.pause();
241 let position = self.timeline_state.current_sec;
242 let start = Instant::now();
243 info!("rebuilding scene after hotpatch...");
244 let rebuilt = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
245 let timeline = self.scene.build_scene();
246 let infos = timeline.get_animation_infos();
247 let total = timeline.total_secs();
248 let evaluator = timeline.into_evaluator(DEFAULT_LOGIC_FPS);
249 let playback = PlaybackEngine::new(&evaluator);
250 let mut state = TimelineState::new(total, infos);
251 state.current_sec = position.clamp(0.0, total);
252 (evaluator, playback, state)
253 }));
254 let (evaluator, playback, state) = match rebuilt {
255 Ok(rebuilt) => rebuilt,
256 Err(_) => {
257 error!(
258 "scene rebuild panicked; keeping the previous scene. Fix the scene or restart preview"
259 );
260 return;
261 }
262 };
263 self.evaluator = evaluator;
264 self.playback_engine = playback;
265 self.timeline_state = state;
266 self.app
269 .world_mut()
270 .resource_mut::<EvaluatedFrame>()
271 .clear();
272 self.resolution_dirty = true;
273 self.need_eval = true;
274 self.last_sec = -1.0;
275 self.last_eval_time = None;
276 self.last_render_time = None;
277 self.progress_samples.clear();
278 self.progress_total_sec = -1.0;
279 self.gpu_pass_times.clear();
280 self.cpu_spans.clear();
281 self.upload_stats.clear();
282 info!("Scene reloaded, cost: {:?}", start.elapsed());
283 }
284
285 pub fn set_clear_color_str(&mut self, color: &str) {
287 let bg = color::try_color(color)
288 .unwrap_or(color::color("#333333ff"))
289 .convert::<LinearSrgb>();
290 let [r, g, b, a] = bg.components.map(|x| x as f64);
291 let clear_color = wgpu::Color { r, g, b, a };
292 self.set_clear_color(clear_color);
293 }
294
295 pub fn set_clear_color(&mut self, color: wgpu::Color) {
297 self.clear_color = color;
298 }
299
300 pub fn set_resolution(&mut self, resolution: Resolution) {
302 if self.resolution != resolution {
303 self.resolution = resolution;
304 self.resolution_dirty = true;
305 }
306 }
307
308 fn calculate_oit_layers(&self, ctx: &WgpuContext, width: u32, height: u32) -> usize {
310 const BYTES_PER_PIXEL_PER_LAYER: usize = 8; const MAX_OIT_LAYERS: usize = 8;
312
313 let limits = ctx.device.limits();
314 let max_buffer_size = limits.max_storage_buffer_binding_size as usize;
315 let pixel_count = (width * height) as usize;
316 let max_layers_by_buffer = max_buffer_size / (pixel_count * BYTES_PER_PIXEL_PER_LAYER);
317 let oit_layers = max_layers_by_buffer.clamp(1, MAX_OIT_LAYERS);
318
319 if oit_layers < MAX_OIT_LAYERS {
320 tracing::warn!(
321 "OIT layers reduced from {} to {} due to GPU buffer size limit ({}MB @ {}x{})",
322 MAX_OIT_LAYERS,
323 oit_layers,
324 max_buffer_size / 1024 / 1024,
325 width,
326 height
327 );
328 }
329
330 oit_layers
331 }
332
333 fn prepare_renderer(&mut self, frame: &eframe::Frame) {
334 let needs_init = self.app.get_sub_app(RanimRenderApp).is_none();
337 let needs_resize = self.resolution_dirty && !needs_init;
338
339 if !needs_init && !needs_resize {
340 return;
341 }
342
343 let Some(render_state) = frame.wgpu_render_state() else {
344 tracing::info!("frame.wgpu_render_state() is none");
345 tracing::info!("{:?}", frame.info());
346 return;
347 };
348
349 if needs_init {
350 tracing::info!("preparing renderer...");
351 } else if needs_resize {
352 tracing::info!("recreating renderer for resolution change...");
353 }
354
355 let ctx = WgpuContext {
358 instance: wgpu::Instance::default(), adapter: wgpu::Adapter::clone(&render_state.adapter),
360 device: wgpu::Device::clone(&render_state.device),
361 queue: wgpu::Queue::clone(&render_state.queue),
362 };
363
364 let (width, height) = (self.resolution.width, self.resolution.height);
365 let oit_layers = self.calculate_oit_layers(&ctx, width, height);
366 let setup = render_setup_of(&self.app);
367 self.app.remove_sub_app(RanimRenderApp);
370 install_render_app(&mut self.app, &ctx, width, height, oit_layers, setup);
371 let render_textures = RenderTextures::new(&ctx, width, height);
372
373 if self.depth_visual_pipeline.is_none() {
375 self.depth_visual_pipeline = Some(DepthVisualPipeline::new(&ctx));
376 }
377
378 let depth_visual_texture = ctx.device.create_texture(&wgpu::TextureDescriptor {
380 label: Some("Depth Visual Texture"),
381 size: wgpu::Extent3d {
382 width: render_textures.width(),
383 height: render_textures.height(),
384 depth_or_array_layers: 1,
385 },
386 mip_level_count: 1,
387 sample_count: 1,
388 dimension: wgpu::TextureDimension::D2,
389 format: wgpu::TextureFormat::Rgba8Unorm,
390 usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
391 view_formats: &[],
392 });
393 let depth_visual_view =
394 depth_visual_texture.create_view(&wgpu::TextureViewDescriptor::default());
395
396 let texture_view = &render_textures.linear_render_view;
398 let mut egui_renderer = render_state.renderer.write();
399 if let Some(id) = self.texture_id.take() {
400 egui_renderer.free_texture(&id);
401 }
402 if let Some(id) = self.depth_texture_id.take() {
403 egui_renderer.free_texture(&id);
404 }
405 let texture_id = egui_renderer.register_native_texture(
406 &render_state.device,
407 texture_view,
408 wgpu::FilterMode::Linear,
409 );
410 let depth_id = egui_renderer.register_native_texture(
411 &render_state.device,
412 &depth_visual_view,
413 wgpu::FilterMode::Nearest,
414 );
415
416 self.texture_id = Some(texture_id);
417 self.depth_texture_id = Some(depth_id);
418 self.depth_visual_texture = Some(depth_visual_texture);
419 self.depth_visual_view = Some(depth_visual_view);
420 self.render_textures = Some(render_textures);
421 self.wgpu_ctx = Some(ctx);
422 self.resolution_dirty = false;
423 self.need_eval = true; }
425
426 pub(crate) fn render_app(&self) -> Option<&SubApp> {
430 self.app.get_sub_app(RanimRenderApp)
431 }
432
433 pub(crate) fn render_app_mut(&mut self) -> Option<&mut SubApp> {
434 self.app.get_sub_app_mut(RanimRenderApp)
435 }
436
437 fn is_playing(&self) -> bool {
438 self.playback_engine.is_playing()
439 }
440
441 fn play(&mut self) {
443 let total = self.timeline_state.total_sec;
444 self.timeline_state.current_sec =
445 self.playback_engine
446 .play(self.timeline_state.current_sec, total, self.playback_speed);
447 }
448
449 fn pause(&mut self) {
451 if self.playback_engine.is_playing() {
452 self.timeline_state.current_sec = self
453 .playback_engine
454 .pause()
455 .clamp(0.0, self.timeline_state.total_sec);
456 }
457 }
458
459 fn scrub_to(&mut self, sec: f64) {
461 let sec = sec.clamp(0.0, self.timeline_state.total_sec);
462 self.timeline_state.current_sec = sec;
463 self.playback_engine.scrub_to(sec);
464 }
465
466 fn tick_playback(&mut self) -> bool {
469 let scrub_audible = self.playback_engine.tick_scrub();
470 let Some(pos) = self.playback_engine.pos_secs() else {
471 return scrub_audible;
472 };
473 let total = self.timeline_state.total_sec;
474 self.timeline_state.current_sec = pos.min(total);
475 if pos >= total {
476 if self.looping {
477 self.play();
478 } else {
479 self.pause();
480 }
481 }
482 self.playback_engine.is_playing() || scrub_audible
483 }
484
485 fn render_animation(&mut self) {
486 if self.wgpu_ctx.is_none()
487 || self.render_textures.is_none()
488 || self.app.get_sub_app(RanimRenderApp).is_none()
489 {
490 return;
491 }
492 if self.last_sec == self.timeline_state.current_sec && !self.need_eval {
493 return;
494 }
495 self.need_eval = false;
496 self.last_sec = self.timeline_state.current_sec;
497
498 let start_eval = Instant::now();
499 {
500 let _span = crate::render::cpu_probe::span("eval");
501 let target = self.timeline_state.current_sec;
503 self.app
504 .world_mut()
505 .resource_scope(|_world, mut store: Mut<EvaluatedFrame>| {
506 self.evaluator.sample_at(target, &mut store);
507 });
508 }
509 self.last_eval_time = Some(start_eval.elapsed());
510
511 let start = Instant::now();
512 let clear_color = self.clear_color;
513 self.app
514 .sub_app_mut(RanimRenderApp)
515 .set_frame_target(self.render_textures.as_mut().unwrap(), clear_color);
516 self.app.update();
517
518 let ctx = self.wgpu_ctx.as_ref().unwrap();
519 let render_textures = self.render_textures.as_ref().unwrap();
520 if let (Some(pipeline), Some(view)) = (
521 self.depth_visual_pipeline.as_ref(),
522 self.depth_visual_view.as_ref(),
523 ) {
524 let _span = crate::render::cpu_probe::span("depth_visual");
525 let mut encoder = ctx
526 .device
527 .create_command_encoder(&wgpu::CommandEncoderDescriptor {
528 label: Some("Depth Visual Encoder"),
529 });
530
531 let bind_group = ctx.device.create_bind_group(&wgpu::BindGroupDescriptor {
532 label: Some("Depth Visual Bind Group"),
533 layout: &pipeline.bind_group_layout,
534 entries: &[wgpu::BindGroupEntry {
535 binding: 0,
536 resource: wgpu::BindingResource::TextureView(
537 &render_textures.depth_texture_view,
538 ),
539 }],
540 });
541
542 {
543 let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
544 label: Some("Depth Visual Pass"),
545 color_attachments: &[Some(wgpu::RenderPassColorAttachment {
546 view,
547 resolve_target: None,
548 depth_slice: None,
549 ops: wgpu::Operations {
550 load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
551 store: wgpu::StoreOp::Store,
552 },
553 })],
554 depth_stencil_attachment: None,
555 timestamp_writes: None,
556 occlusion_query_set: None,
557 multiview_mask: None,
558 });
559 rpass.set_pipeline(&pipeline.pipeline);
560 rpass.set_bind_group(0, &bind_group, &[]);
561 rpass.draw(0..3, 0..1);
562 }
563 ctx.queue.submit(Some(encoder.finish()));
564 }
565
566 self.last_render_time = Some(start.elapsed());
567
568 if let Some(scopes) = self
570 .app
571 .get_sub_app_mut(RanimRenderApp)
572 .and_then(|render_app| render_app.take_last_gpu_scopes())
573 {
574 let mut passes = Vec::new();
575 profiler::flatten_scopes(&scopes, &mut passes);
576 self.gpu_pass_times = passes;
577 }
578 if crate::render::upload_probe::mode().enabled() {
579 self.upload_stats = crate::render::upload_probe::take_stats();
580 } else {
581 self.upload_stats.clear();
582 }
583 self.cpu_spans = crate::render::cpu_probe::take_frame()
584 .into_iter()
585 .map(|(label, ms)| (label.to_string(), ms))
586 .collect();
587 profiler::record_sample(self);
588 }
589
590 fn start_export(&mut self, ctx: egui::Context) {
591 let (progress_tx, progress_rx) = unbounded();
592 self.export_progress_rx = Some(progress_rx);
593
594 let scene = self.scene.clone();
595 let setup = render_setup_of(&self.app);
596 let output = self.export_config.clone();
597
598 std::thread::spawn(move || {
599 let progress_tx_cb = progress_tx.clone();
600 let ctx_cb = ctx.clone();
601 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
602 crate::cmd::render::render_scene_output_with_progress(
603 &scene,
604 &setup,
605 &output,
606 2,
607 Some(Box::new(move |current, total| {
608 let _ =
609 progress_tx_cb.send_blocking(ExportProgress::Progress(current, total));
610 ctx_cb.request_repaint();
611 })),
612 );
613
614 let _ = progress_tx.send_blocking(ExportProgress::Done);
615 ctx.request_repaint();
616 }));
617
618 if let Err(e) = result {
619 let msg = if let Some(s) = e.downcast_ref::<&str>() {
620 s.to_string()
621 } else if let Some(s) = e.downcast_ref::<String>() {
622 s.clone()
623 } else {
624 "Unknown export error".to_string()
625 };
626 let _ = progress_tx.send_blocking(ExportProgress::Error(msg));
627 ctx.request_repaint();
628 }
629 });
630 }
631}
632
633impl eframe::App for RanimPreviewApp {
634 fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
635 let ctx = ui.ctx().clone();
636 #[cfg(feature = "subsecond")]
637 if self.export_progress_rx.is_none()
638 && self
639 .hot_reload
640 .as_ref()
641 .is_some_and(|reload| reload.take_pending())
642 {
643 self.reload_scene();
644 }
645 self.prepare_renderer(frame);
646
647 if ctx.input(|i| i.key_pressed(egui::Key::Space)) {
649 if self.is_playing() {
650 self.pause();
651 } else {
652 self.play();
653 }
654 }
655
656 {
658 let frame_dur = 1.0 / self.export_config.fps as f64;
659 if ctx.input(|i| i.key_pressed(egui::Key::ArrowLeft)) {
660 self.pause();
661 self.scrub_to(self.timeline_state.current_sec - frame_dur);
662 }
663 if ctx.input(|i| i.key_pressed(egui::Key::ArrowRight)) {
664 self.pause();
665 self.scrub_to(self.timeline_state.current_sec + frame_dur);
666 }
667 }
668
669 if self.tick_playback() {
670 ctx.request_repaint();
671 }
672
673 self.render_animation();
674
675 egui::Panel::top("top_panel").show(ui, |ui| {
676 ui.horizontal(|ui| {
677 ui.heading(&self.title);
678
679 {
681 let resolution = self.resolution;
682 egui::ComboBox::from_label("Resolution")
683 .selected_text(format!(
684 "{}x{} ({})",
685 resolution.width,
686 resolution.height,
687 resolution.aspect_ratio_str()
688 ))
689 .show_ui(ui, |ui| {
690 ui.label(egui::RichText::new("16:9").strong());
692 ui.selectable_value(
693 &mut self.resolution,
694 Resolution::HD,
695 "1280x720 (HD)",
696 );
697 ui.selectable_value(
698 &mut self.resolution,
699 Resolution::FHD,
700 "1920x1080 (FHD)",
701 );
702 ui.selectable_value(
703 &mut self.resolution,
704 Resolution::QHD,
705 "2560x1440 (QHD)",
706 );
707 ui.selectable_value(
708 &mut self.resolution,
709 Resolution::UHD,
710 "3840x2160 (UHD)",
711 );
712 ui.separator();
713 ui.label(egui::RichText::new("16:10").strong());
715 ui.selectable_value(
716 &mut self.resolution,
717 Resolution::WXGA,
718 "1280x800 (WXGA)",
719 );
720 ui.selectable_value(
721 &mut self.resolution,
722 Resolution::WUXGA,
723 "1920x1200 (WUXGA)",
724 );
725 ui.separator();
726 ui.label(egui::RichText::new("4:3").strong());
728 ui.selectable_value(
729 &mut self.resolution,
730 Resolution::SVGA,
731 "800x600 (SVGA)",
732 );
733 ui.selectable_value(
734 &mut self.resolution,
735 Resolution::XGA,
736 "1024x768 (XGA)",
737 );
738 ui.selectable_value(
739 &mut self.resolution,
740 Resolution::SXGA,
741 "1280x960 (SXGA)",
742 );
743 ui.separator();
744 ui.label(egui::RichText::new("1:1").strong());
746 ui.selectable_value(
747 &mut self.resolution,
748 Resolution::_1K_SQUARE,
749 "1080x1080",
750 );
751 ui.selectable_value(
752 &mut self.resolution,
753 Resolution::_2K_SQUARE,
754 "2160x2160",
755 );
756 ui.separator();
757 ui.label(egui::RichText::new("21:9").strong());
759 ui.selectable_value(
760 &mut self.resolution,
761 Resolution::UW_QHD,
762 "3440x1440 (UW-QHD)",
763 );
764 });
765 if self.resolution != resolution {
766 self.resolution_dirty = true;
767 }
768 }
769
770 ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
771 let dark_mode = ui.visuals().dark_mode;
772 let button_text = if dark_mode {
773 format!("{} Light", egui_phosphor::regular::SUN)
774 } else {
775 format!("{} Dark", egui_phosphor::regular::MOON)
776 };
777 if ui.button(button_text).clicked() {
778 if dark_mode {
779 ctx.set_visuals(egui::Visuals::light());
780 } else {
781 ctx.set_visuals(egui::Visuals::dark());
782 }
783 }
784
785 ui.separator();
786 {
787 let exporting = self.export_progress_rx.is_some();
788 if ui
789 .add_enabled(!exporting, egui::Button::new("Export"))
790 .clicked()
791 {
792 self.export_dialog_open = true;
793 }
794 ui.separator();
795 }
796 ui.selectable_value(&mut self.view_mode, ViewMode::Output, "Output");
797 ui.selectable_value(&mut self.view_mode, ViewMode::Depth, "Depth");
798 ui.separator();
799
800 {
801 let mut btn = egui::Button::new(format!(
802 "{} Profiler",
803 egui_phosphor::regular::CHART_LINE_UP
804 ));
805 if self.profiler_open {
806 btn = btn.fill(ui.visuals().selection.bg_fill);
807 }
808 if ui.add(btn).clicked() {
809 self.profiler_open = !self.profiler_open;
810 }
811 }
812 ui.separator();
813
814 if let Some(duration) = self.last_render_time {
815 ui.label(format!("Render: {:.2}ms", duration.as_secs_f64() * 1000.0));
816 ui.separator();
817 }
818 if let Some(duration) = self.last_eval_time {
819 ui.label(format!("Eval: {:.2}ms", duration.as_secs_f64() * 1000.0));
820 ui.separator();
821 }
822 });
823 });
824 });
825
826 egui::Panel::bottom("bottom_panel")
827 .resizable(true)
828 .default_size(240.0)
829 .min_size(150.0)
830 .max_size(600.0)
831 .show(ui, |ui| {
832 let pre_panel_sec = self.timeline_state.current_sec;
833 ui.label("Timeline");
834
835 ui.horizontal(|ui| {
836 let fps = self.export_config.fps as f64;
837 let frame_dur = 1.0 / fps;
838
839 if ui
841 .button(egui_phosphor::regular::SKIP_BACK)
842 .on_hover_text("Jump to start")
843 .clicked()
844 {
845 self.pause();
846 self.scrub_to(0.0);
847 }
848
849 if ui
851 .button(egui_phosphor::regular::CARET_LEFT)
852 .on_hover_text("Step back one frame")
853 .clicked()
854 {
855 self.pause();
856 self.scrub_to(self.timeline_state.current_sec - frame_dur);
857 }
858
859 let is_playing = self.is_playing();
861 let play_label = if is_playing {
862 egui_phosphor::regular::PAUSE
863 } else {
864 egui_phosphor::regular::PLAY
865 };
866 let play_tooltip = if is_playing { "Pause" } else { "Play" };
867 if ui.button(play_label).on_hover_text(play_tooltip).clicked() {
868 if is_playing {
869 self.pause();
870 } else {
871 self.play();
872 }
873 }
874
875 if ui
877 .button(egui_phosphor::regular::CARET_RIGHT)
878 .on_hover_text("Step forward one frame")
879 .clicked()
880 {
881 self.pause();
882 self.scrub_to(self.timeline_state.current_sec + frame_dur);
883 }
884
885 if ui
887 .button(egui_phosphor::regular::SKIP_FORWARD)
888 .on_hover_text("Jump to end")
889 .clicked()
890 {
891 self.pause();
892 self.scrub_to(self.timeline_state.total_sec);
893 }
894
895 ui.separator();
896
897 let mut loop_btn = egui::Button::new(egui_phosphor::regular::ARROWS_CLOCKWISE);
899 if self.looping {
900 loop_btn = loop_btn.fill(ui.visuals().selection.bg_fill);
901 }
902 if ui
903 .add(loop_btn)
904 .on_hover_text(if self.looping {
905 "Looping: ON"
906 } else {
907 "Looping: OFF"
908 })
909 .clicked()
910 {
911 self.looping = !self.looping;
912 }
913
914 ui.separator();
915
916 let drag_speed = (self.playback_speed * 0.02).max(0.01);
918 let speed_response = ui
919 .add(
920 egui::DragValue::new(&mut self.playback_speed)
921 .speed(drag_speed)
922 .range(0.1..=10.0)
923 .suffix("x"),
924 )
925 .on_hover_text("Playback speed");
926 if speed_response.changed() {
927 self.playback_engine.set_speed(self.playback_speed);
928 }
929
930 ui.separator();
931
932 const TIME_INPUT_WIDTH: f32 = 86.0;
933 let slider_width =
934 (ui.available_width() - TIME_INPUT_WIDTH - ui.spacing().item_spacing.x)
935 .max(40.0);
936 ui.scope(|ui| {
937 ui.style_mut().spacing.slider_width = slider_width;
938 ui.add(
939 egui::Slider::new(
940 &mut self.timeline_state.current_sec,
941 0.0..=self.timeline_state.total_sec,
942 )
943 .show_value(false),
944 )
945 .on_hover_text("Timeline position");
946 });
947 ui.add_sized(
948 [TIME_INPUT_WIDTH, 18.0],
949 egui::DragValue::new(&mut self.timeline_state.current_sec)
950 .range(0.0..=self.timeline_state.total_sec)
951 .speed(0.001)
952 .fixed_decimals(3)
953 .suffix(" s"),
954 )
955 .on_hover_text("Drag or enter the current time");
956 });
957
958 self.timeline_state.ui_main_timeline(ui);
959
960 if self.timeline_state.current_sec != pre_panel_sec {
963 self.scrub_to(self.timeline_state.current_sec);
964 }
965 });
966
967 egui::CentralPanel::default().show(ui, |ui| {
968 let texture_id = match self.view_mode {
969 ViewMode::Output => self.texture_id,
970 ViewMode::Depth => self.depth_texture_id,
971 };
972
973 if let Some(tid) = texture_id {
974 let available_size = ui.available_size();
977 let aspect_ratio = self
978 .render_textures
979 .as_ref()
980 .map(|rt| rt.ratio())
981 .unwrap_or(1280.0 / 7.0);
982 let mut size = available_size;
983
984 if size.x / size.y > aspect_ratio {
985 size.x = size.y * aspect_ratio;
986 } else {
987 size.y = size.x / aspect_ratio;
988 }
989
990 ui.centered_and_justified(|ui| {
991 ui.image(egui::load::SizedTexture::new(tid, size));
992 });
993 } else {
994 ui.centered_and_justified(|ui| {
995 ui.spinner();
996 });
997 }
998 });
999
1000 {
1002 if let Some(rx) = &self.export_progress_rx {
1004 let mut done = false;
1005 let mut error_msg = None;
1006
1007 while let Ok(msg) = rx.try_recv() {
1008 match msg {
1009 ExportProgress::Progress(current, total) => {
1010 self.export_current_frame = current;
1011 self.export_total_frames = total;
1012 }
1013 ExportProgress::Done => {
1014 done = true;
1015 }
1016 ExportProgress::Error(err) => {
1017 error_msg = Some(err);
1018 done = true;
1019 }
1020 }
1021 }
1022
1023 if done {
1024 self.export_progress_rx = None;
1025 #[cfg(feature = "subsecond")]
1028 ctx.request_repaint();
1029 self.export_current_frame = 0;
1030 self.export_total_frames = 0;
1031 if let Some(err) = error_msg {
1032 error!("Export failed: {err}");
1033 } else {
1034 info!("Export completed");
1035 }
1036 } else {
1037 ctx.request_repaint();
1038 }
1039 }
1040
1041 let exporting = self.export_progress_rx.is_some();
1043 if self.export_dialog_open || exporting {
1044 let mut open = self.export_dialog_open;
1045 egui::Window::new("Export")
1046 .open(&mut open)
1047 .resizable(false)
1048 .show(&ctx, |ui| {
1049 ui.add_enabled_ui(!exporting, |ui| {
1050 egui::Grid::new("export_grid")
1051 .num_columns(2)
1052 .show(ui, |ui| {
1053 ui.label("Width:");
1054 ui.add(
1055 egui::DragValue::new(&mut self.export_config.width)
1056 .range(1..=7680),
1057 );
1058 ui.end_row();
1059
1060 ui.label("Height:");
1061 ui.add(
1062 egui::DragValue::new(&mut self.export_config.height)
1063 .range(1..=4320),
1064 );
1065 ui.end_row();
1066
1067 ui.label("FPS:");
1068 ui.add(
1069 egui::DragValue::new(&mut self.export_config.fps)
1070 .range(1..=240),
1071 );
1072 ui.end_row();
1073
1074 ui.label("Format:");
1075 egui::ComboBox::from_id_salt("export_format")
1076 .selected_text(format!("{}", self.export_config.format))
1077 .show_ui(ui, |ui| {
1078 ui.selectable_value(
1079 &mut self.export_config.format,
1080 OutputFormat::Mp4,
1081 "mp4",
1082 );
1083 ui.selectable_value(
1084 &mut self.export_config.format,
1085 OutputFormat::Webm,
1086 "webm",
1087 );
1088 ui.selectable_value(
1089 &mut self.export_config.format,
1090 OutputFormat::Mov,
1091 "mov",
1092 );
1093 ui.selectable_value(
1094 &mut self.export_config.format,
1095 OutputFormat::Gif,
1096 "gif",
1097 );
1098 });
1099 ui.end_row();
1100
1101 ui.label("Output dir:");
1102 ui.text_edit_singleline(&mut self.export_config.dir);
1103 ui.end_row();
1104
1105 ui.label("");
1107 {
1108 let mut output_dir =
1109 std::path::PathBuf::from(&self.export_config.dir);
1110 if !output_dir.is_absolute() {
1111 output_dir = std::env::current_dir()
1112 .unwrap_or_default()
1113 .join(&output_dir);
1114 }
1115 let (_, _, ext) =
1116 self.export_config.format.encoding_params();
1117 let name = self
1118 .export_config
1119 .name
1120 .as_deref()
1121 .unwrap_or(&self.title);
1122 let file_path = output_dir.join(format!(
1123 "{}_{}x{}_{}.{ext}",
1124 name,
1125 self.export_config.width,
1126 self.export_config.height,
1127 self.export_config.fps,
1128 ));
1129 ui.label(
1130 egui::RichText::new(format!(
1131 "-> {}",
1132 file_path.display()
1133 ))
1134 .small()
1135 .color(ui.visuals().weak_text_color()),
1136 );
1137 }
1138 ui.end_row();
1139
1140 ui.label("Save frames:");
1141 ui.checkbox(&mut self.export_config.save_frames, "");
1142 ui.end_row();
1143 });
1144 }); ui.add_space(8.0);
1147
1148 if exporting {
1150 let current = self.export_current_frame;
1151 let total = self.export_total_frames;
1152 if total > 0 {
1153 let progress = current as f32 / total as f32;
1154 ui.add(egui::ProgressBar::new(progress).text(format!(
1155 "{current}/{total} frames ({:.0}%)",
1156 progress * 100.0
1157 )));
1158 } else {
1159 ui.horizontal(|ui| {
1160 ui.spinner();
1161 ui.label("Preparing...");
1162 });
1163 }
1164 } else if ui.button("Start Export").clicked() {
1165 self.start_export(ctx.clone());
1166 }
1167 });
1168 if !exporting {
1170 self.export_dialog_open = open;
1171 }
1172 }
1173 }
1174
1175 if self.profiler_open {
1177 profiler::ui_profiler_window(self, &ctx);
1178 }
1179 }
1180}
1181
1182pub fn run_app(app: RanimPreviewApp) {
1183 let title = app.title.clone();
1184 let build_app = |cc: &eframe::CreationContext| {
1185 #[cfg(feature = "subsecond")]
1186 let app = {
1187 let mut app = app;
1188 app.hot_reload = Some(hot_reload::HotReload::new(cc.egui_ctx.clone()));
1189 app
1190 };
1191 let mut fonts = egui::FontDefinitions::default();
1192 egui_phosphor::add_to_fonts(&mut fonts, egui_phosphor::Variant::Regular);
1193 cc.egui_ctx.set_fonts(fonts);
1194 Ok(Box::new(app) as Box<dyn eframe::App>)
1195 };
1196
1197 let wgpu_options = eframe::egui_wgpu::WgpuConfiguration {
1202 wgpu_setup: eframe::egui_wgpu::WgpuSetup::CreateNew(
1203 eframe::egui_wgpu::WgpuSetupCreateNew {
1204 device_descriptor: Arc::new(|adapter| wgpu::DeviceDescriptor {
1205 label: Some("ranim device"),
1206 required_limits: adapter.limits(),
1207 required_features: adapter.features() & profiler::gpu_timer_features(),
1211 ..Default::default()
1212 }),
1213 ..eframe::egui_wgpu::WgpuSetupCreateNew::without_display_handle()
1214 },
1215 ),
1216 ..Default::default()
1217 };
1218
1219 let native_options = eframe::NativeOptions {
1220 viewport: egui::ViewportBuilder::default()
1221 .with_title(&title)
1222 .with_inner_size([1280.0, 720.0]),
1223 renderer: eframe::Renderer::Wgpu,
1224 wgpu_options,
1225 ..Default::default()
1226 };
1227 eframe::run_native(&title, native_options, Box::new(build_app)).unwrap();
1228}
1229
1230pub fn preview_scene(app: crate::RanimApp, scene: Scene) {
1232 run_app(RanimPreviewApp::new(app, scene));
1233}
1234
1235#[cfg(all(test, feature = "subsecond"))]
1236mod hot_reload_tests {
1237 use super::*;
1238 use crate::{App, RanimAppExt, RanimScene};
1239 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
1240
1241 fn preview(duration: Arc<AtomicUsize>, fail: Arc<AtomicBool>) -> RanimPreviewApp {
1242 let scene = Scene::new("reload", move |r: &mut RanimScene| {
1243 assert!(!fail.load(Ordering::Relaxed), "test rebuild failure");
1244 let mut sequence = ranim_core::animation::compose::sequence::AnimSequence::new();
1245 sequence.forward(duration.load(Ordering::Relaxed) as f64);
1246 r.play(sequence);
1247 });
1248 RanimPreviewApp::new(App::new().build(), scene)
1249 }
1250
1251 #[test]
1252 fn rebuild_replaces_timeline_clamps_time_and_preserves_host_settings() {
1253 let duration = Arc::new(AtomicUsize::new(10));
1254 let mut preview = preview(Arc::clone(&duration), Arc::new(AtomicBool::new(false)));
1255 preview.timeline_state.current_sec = 8.0;
1256 preview.playback_speed = 2.0;
1257 preview.looping = true;
1258 preview.resolution = Resolution::HD;
1259 preview.progress_total_sec = 10.0;
1260 duration.store(3, Ordering::Relaxed);
1261
1262 preview.reload_scene();
1263
1264 assert_eq!(preview.evaluator.total_secs(), 3.0);
1265 assert_eq!(preview.timeline_state.total_sec, 3.0);
1266 assert_eq!(preview.timeline_state.current_sec, 3.0);
1267 assert_eq!(preview.playback_speed, 2.0);
1268 assert!(preview.looping);
1269 assert_eq!(preview.resolution, Resolution::HD);
1270 assert!(!preview.is_playing());
1271 assert!(preview.need_eval);
1272 assert!(preview.resolution_dirty);
1273 assert_eq!(preview.progress_total_sec, -1.0);
1274 }
1275
1276 #[test]
1277 fn rebuild_pauses_playback_and_keeps_absolute_position() {
1278 let mut preview = preview(
1279 Arc::new(AtomicUsize::new(10)),
1280 Arc::new(AtomicBool::new(false)),
1281 );
1282 preview.timeline_state.current_sec = 4.0;
1283 preview.play();
1284 preview.reload_scene();
1285 assert!(!preview.is_playing());
1286 assert!(preview.timeline_state.current_sec >= 4.0);
1287 assert!(preview.timeline_state.current_sec <= 10.0);
1288 }
1289
1290 #[test]
1291 fn failed_rebuild_keeps_previous_scene_and_allows_retry() {
1292 let duration = Arc::new(AtomicUsize::new(10));
1293 let fail = Arc::new(AtomicBool::new(false));
1294 let mut preview = preview(Arc::clone(&duration), Arc::clone(&fail));
1295 preview.timeline_state.current_sec = 4.0;
1296 fail.store(true, Ordering::Relaxed);
1297 preview.reload_scene();
1298 assert_eq!(preview.evaluator.total_secs(), 10.0);
1299 assert_eq!(preview.timeline_state.current_sec, 4.0);
1300
1301 fail.store(false, Ordering::Relaxed);
1302 duration.store(0, Ordering::Relaxed);
1303 preview.reload_scene();
1304 assert_eq!(preview.evaluator.total_secs(), 0.0);
1305 assert_eq!(preview.timeline_state.current_sec, 0.0);
1306 }
1307}