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