ranim/cmd/preview/
mod.rs

1mod depth_visual;
2mod timeline;
3
4use crate::{
5    Scene, SceneConstructor,
6    core::{
7        SealedRanimScene,
8        color::{self, LinearSrgb},
9        store::CoreItemStore,
10    },
11    render::{
12        Renderer,
13        resource::{RenderPool, RenderTextures},
14        utils::WgpuContext,
15    },
16};
17use async_channel::{Receiver, Sender, unbounded};
18use depth_visual::DepthVisualPipeline;
19use eframe::egui;
20use timeline::TimelineState;
21use tracing::{error, info};
22use web_time::Instant;
23
24#[cfg(target_arch = "wasm32")]
25use wasm_bindgen::prelude::*;
26
27// Copied from original lib.rs
28pub struct TimelineInfoState {
29    pub ctx: egui::Context,
30    pub canvas: egui::Rect,
31    pub response: egui::Response,
32    pub painter: egui::Painter,
33    pub text_height: f32,
34    pub font_id: egui::FontId,
35}
36
37impl TimelineInfoState {
38    pub fn point_from_ms(&self, state: &TimelineState, ms: i64) -> f32 {
39        let ms = ms as f32;
40        let offset = state.offset_points;
41        let width_sec = state.width_sec as f32;
42        let canvas_width = self.canvas.width();
43
44        let ms_per_pixel = width_sec * 1000.0 / canvas_width;
45        let x = ms / ms_per_pixel;
46        self.canvas.min.x + x - offset
47    }
48}
49
50pub enum RanimPreviewAppCmd {
51    ReloadScene(Scene, Sender<()>),
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum ViewMode {
56    Output,
57    Depth,
58}
59
60#[derive(Debug, Clone, Copy, PartialEq)]
61pub struct Resolution {
62    pub width: u32,
63    pub height: u32,
64}
65
66impl Resolution {
67    pub const fn new(width: u32, height: u32) -> Self {
68        Self { width, height }
69    }
70
71    pub fn ratio(&self) -> f32 {
72        self.width as f32 / self.height as f32
73    }
74
75    /// Calculate and return the simplified aspect ratio (e.g., (16, 9) for 1920x1080)
76    pub fn aspect_ratio(&self) -> (u32, u32) {
77        fn gcd(a: u32, b: u32) -> u32 {
78            if b == 0 { a } else { gcd(b, a % b) }
79        }
80        let g = gcd(self.width, self.height);
81        (self.width / g, self.height / g)
82    }
83
84    pub fn aspect_ratio_str(&self) -> String {
85        let (w, h) = self.aspect_ratio();
86        format!("{w}:{h}")
87    }
88}
89
90// Common resolutions
91impl Resolution {
92    // 16:9
93    pub const HD: Self = Self::new(1280, 720);
94    pub const FHD: Self = Self::new(1920, 1080);
95    pub const QHD: Self = Self::new(2560, 1440);
96    pub const UHD: Self = Self::new(3840, 2160);
97    // 16:10
98    pub const WXGA: Self = Self::new(1280, 800);
99    pub const WUXGA: Self = Self::new(1920, 1200);
100    // 4:3
101    pub const SVGA: Self = Self::new(800, 600);
102    pub const XGA: Self = Self::new(1024, 768);
103    pub const SXGA: Self = Self::new(1280, 960);
104    // 1:1
105    pub const _1K_SQUARE: Self = Self::new(1080, 1080);
106    pub const _2K_SQUARE: Self = Self::new(2160, 2160);
107    // 21:9
108    pub const UW_QHD: Self = Self::new(3440, 1440);
109}
110
111pub struct RanimPreviewApp {
112    cmd_rx: Receiver<RanimPreviewAppCmd>,
113    pub cmd_tx: Sender<RanimPreviewAppCmd>,
114    #[allow(unused)]
115    title: String,
116    clear_color: wgpu::Color,
117    resolution: Resolution,
118    timeline: SealedRanimScene,
119    need_eval: bool,
120    last_sec: f64,
121    store: CoreItemStore,
122    pool: RenderPool,
123    timeline_state: TimelineState,
124    play_prev_t: Option<Instant>,
125
126    // Rendering
127    renderer: Option<Renderer>,
128    render_textures: Option<RenderTextures>,
129    texture_id: Option<egui::TextureId>,
130    depth_texture_id: Option<egui::TextureId>,
131    view_mode: ViewMode,
132    wgpu_ctx: Option<WgpuContext>,
133    last_render_time: Option<std::time::Duration>,
134    last_eval_time: Option<std::time::Duration>,
135
136    // Depth Visual
137    depth_visual_pipeline: Option<DepthVisualPipeline>,
138    depth_visual_texture: Option<wgpu::Texture>,
139    depth_visual_view: Option<wgpu::TextureView>,
140
141    // Resolution changed flag
142    resolution_dirty: bool,
143}
144
145impl RanimPreviewApp {
146    pub fn new(scene_constructor: impl SceneConstructor, title: String) -> Self {
147        let t = Instant::now();
148        info!("building scene...");
149        let timeline = scene_constructor.build_scene();
150        info!("Scene built, cost: {:?}", t.elapsed());
151
152        info!("Getting timelines info...");
153        let timeline_infos = timeline.get_timeline_infos();
154        info!("Total {} timelines", timeline_infos.len());
155
156        let (cmd_tx, cmd_rx) = unbounded();
157
158        Self {
159            cmd_rx,
160            cmd_tx,
161            title,
162            clear_color: wgpu::Color::TRANSPARENT,
163            resolution: Resolution::QHD,
164            timeline_state: TimelineState::new(timeline.total_secs(), timeline_infos),
165            timeline,
166            need_eval: false,
167            last_sec: -1.0,
168            store: CoreItemStore::default(),
169            pool: RenderPool::new(),
170            play_prev_t: None,
171            renderer: None,
172            render_textures: None,
173            texture_id: None,
174            depth_texture_id: None,
175            view_mode: ViewMode::Output,
176            wgpu_ctx: None,
177            last_render_time: None,
178            last_eval_time: None,
179            depth_visual_pipeline: None,
180            depth_visual_texture: None,
181            depth_visual_view: None,
182            resolution_dirty: false,
183        }
184    }
185
186    /// Set clear color str
187    pub fn set_clear_color_str(&mut self, color: &str) {
188        let bg = color::try_color(color)
189            .unwrap_or(color::color("#333333ff"))
190            .convert::<LinearSrgb>();
191        let [r, g, b, a] = bg.components.map(|x| x as f64);
192        let clear_color = wgpu::Color { r, g, b, a };
193        self.set_clear_color(clear_color);
194    }
195
196    /// Set clear color
197    pub fn set_clear_color(&mut self, color: wgpu::Color) {
198        self.clear_color = color;
199    }
200
201    /// Set preview resolution
202    pub fn set_resolution(&mut self, resolution: Resolution) {
203        if self.resolution != resolution {
204            self.resolution = resolution;
205            self.resolution_dirty = true;
206        }
207    }
208
209    /// Calculate OIT layers based on resolution to stay within GPU buffer limits
210    fn calculate_oit_layers(&self, ctx: &WgpuContext, width: u32, height: u32) -> usize {
211        const BYTES_PER_PIXEL_PER_LAYER: usize = 8; // 4 bytes color + 4 bytes depth
212        const MAX_OIT_LAYERS: usize = 8;
213
214        let limits = ctx.device.limits();
215        let max_buffer_size = limits.max_storage_buffer_binding_size as usize;
216        let pixel_count = (width * height) as usize;
217        let max_layers_by_buffer = max_buffer_size / (pixel_count * BYTES_PER_PIXEL_PER_LAYER);
218        let oit_layers = max_layers_by_buffer.clamp(1, MAX_OIT_LAYERS);
219
220        if oit_layers < MAX_OIT_LAYERS {
221            tracing::warn!(
222                "OIT layers reduced from {} to {} due to GPU buffer size limit ({}MB @ {}x{})",
223                MAX_OIT_LAYERS,
224                oit_layers,
225                max_buffer_size / 1024 / 1024,
226                width,
227                height
228            );
229        }
230
231        oit_layers
232    }
233
234    fn handle_events(&mut self) {
235        if let Ok(cmd) = self.cmd_rx.try_recv() {
236            match cmd {
237                RanimPreviewAppCmd::ReloadScene(scene, tx) => {
238                    let timeline = scene.constructor.build_scene();
239                    let timeline_infos = timeline.get_timeline_infos();
240                    let old_cur_second = self.timeline_state.current_sec;
241                    self.timeline_state = TimelineState::new(timeline.total_secs(), timeline_infos);
242                    self.timeline_state.current_sec =
243                        old_cur_second.clamp(0.0, self.timeline_state.total_sec);
244                    self.timeline = timeline;
245                    self.store.update(std::iter::empty());
246                    self.pool.clean();
247                    self.need_eval = true;
248
249                    self.set_clear_color_str(&scene.config.clear_color);
250
251                    if let Err(err) = tx.try_send(()) {
252                        error!("Failed to send reloaded signal: {err:?}");
253                    }
254                }
255            }
256        }
257    }
258
259    fn prepare_renderer(&mut self, frame: &eframe::Frame) {
260        // Check if we need to recreate renderer
261        let needs_init = self.renderer.is_none();
262        let needs_resize = self.resolution_dirty && self.renderer.is_some();
263
264        if !needs_init && !needs_resize {
265            return;
266        }
267
268        let Some(render_state) = frame.wgpu_render_state() else {
269            tracing::info!("frame.wgpu_render_state() is none");
270            tracing::info!("{:?}", frame.info());
271            return;
272        };
273
274        if needs_init {
275            tracing::info!("preparing renderer...");
276        } else if needs_resize {
277            tracing::info!("recreating renderer for resolution change...");
278        }
279
280        // Construct WgpuContext using eframe's resources.
281        // NOTE: We assume ranim-render doesn't strictly depend on the instance for the operations we do here.
282        let ctx = WgpuContext {
283            instance: wgpu::Instance::default(), // Dummy instance
284            adapter: wgpu::Adapter::clone(&render_state.adapter),
285            device: wgpu::Device::clone(&render_state.device),
286            queue: wgpu::Queue::clone(&render_state.queue),
287        };
288
289        let (width, height) = (self.resolution.width, self.resolution.height);
290        let oit_layers = self.calculate_oit_layers(&ctx, width, height);
291        let renderer = Renderer::new(&ctx, width, height, oit_layers);
292        let render_textures = renderer.new_render_textures(&ctx);
293
294        // Init Depth Visual Pipeline
295        if self.depth_visual_pipeline.is_none() {
296            self.depth_visual_pipeline = Some(DepthVisualPipeline::new(&ctx));
297        }
298
299        // Create Depth Visual Texture
300        let depth_visual_texture = ctx.device.create_texture(&wgpu::TextureDescriptor {
301            label: Some("Depth Visual Texture"),
302            size: wgpu::Extent3d {
303                width: render_textures.width(),
304                height: render_textures.height(),
305                depth_or_array_layers: 1,
306            },
307            mip_level_count: 1,
308            sample_count: 1,
309            dimension: wgpu::TextureDimension::D2,
310            format: wgpu::TextureFormat::Rgba8Unorm,
311            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
312            view_formats: &[],
313        });
314        let depth_visual_view =
315            depth_visual_texture.create_view(&wgpu::TextureViewDescriptor::default());
316
317        // Register texture with egui
318        let texture_view = &render_textures.linear_render_view;
319        let texture_id = render_state.renderer.write().register_native_texture(
320            &render_state.device,
321            texture_view,
322            wgpu::FilterMode::Linear,
323        );
324        let depth_id = render_state.renderer.write().register_native_texture(
325            &render_state.device,
326            &depth_visual_view,
327            wgpu::FilterMode::Nearest,
328        );
329
330        self.texture_id = Some(texture_id);
331        self.depth_texture_id = Some(depth_id);
332        self.depth_visual_texture = Some(depth_visual_texture);
333        self.depth_visual_view = Some(depth_visual_view);
334        self.render_textures = Some(render_textures);
335        self.renderer = Some(renderer);
336        self.wgpu_ctx = Some(ctx);
337        self.resolution_dirty = false;
338        self.need_eval = true; // Force re-render with new resolution
339    }
340
341    fn render_animation(&mut self) {
342        if let (Some(ctx), Some(renderer), Some(render_textures)) = (
343            self.wgpu_ctx.as_ref(),
344            self.renderer.as_mut(),
345            self.render_textures.as_mut(),
346        ) {
347            if self.last_sec == self.timeline_state.current_sec && !self.need_eval {
348                return;
349            }
350            self.need_eval = false;
351            self.last_sec = self.timeline_state.current_sec;
352
353            let start_eval = Instant::now();
354            self.store
355                .update(self.timeline.eval_at_sec(self.timeline_state.current_sec));
356            self.last_eval_time = Some(start_eval.elapsed());
357
358            let start = Instant::now();
359            renderer.render_store_with_pool(
360                ctx,
361                render_textures,
362                self.clear_color,
363                &self.store,
364                &mut self.pool,
365            );
366
367            if let (Some(pipeline), Some(view)) = (
368                self.depth_visual_pipeline.as_ref(),
369                self.depth_visual_view.as_ref(),
370            ) {
371                let mut encoder =
372                    ctx.device
373                        .create_command_encoder(&wgpu::CommandEncoderDescriptor {
374                            label: Some("Depth Visual Encoder"),
375                        });
376
377                let bind_group = ctx.device.create_bind_group(&wgpu::BindGroupDescriptor {
378                    label: Some("Depth Visual Bind Group"),
379                    layout: &pipeline.bind_group_layout,
380                    entries: &[wgpu::BindGroupEntry {
381                        binding: 0,
382                        resource: wgpu::BindingResource::TextureView(
383                            &render_textures.depth_texture_view,
384                        ),
385                    }],
386                });
387
388                {
389                    let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
390                        label: Some("Depth Visual Pass"),
391                        color_attachments: &[Some(wgpu::RenderPassColorAttachment {
392                            view,
393                            resolve_target: None,
394                            depth_slice: None,
395                            ops: wgpu::Operations {
396                                load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
397                                store: wgpu::StoreOp::Store,
398                            },
399                        })],
400                        depth_stencil_attachment: None,
401                        timestamp_writes: None,
402                        occlusion_query_set: None,
403                    });
404                    rpass.set_pipeline(&pipeline.pipeline);
405                    rpass.set_bind_group(0, &bind_group, &[]);
406                    rpass.draw(0..3, 0..1);
407                }
408                ctx.queue.submit(Some(encoder.finish()));
409            }
410
411            self.last_render_time = Some(start.elapsed());
412            self.pool.clean();
413        }
414    }
415}
416
417impl eframe::App for RanimPreviewApp {
418    fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
419        self.prepare_renderer(frame);
420        self.handle_events();
421
422        if let Some(play_prev_t) = self.play_prev_t {
423            let elapsed = play_prev_t.elapsed().as_secs_f64();
424            self.timeline_state.current_sec =
425                (self.timeline_state.current_sec + elapsed).min(self.timeline_state.total_sec);
426            if self.timeline_state.current_sec == self.timeline_state.total_sec {
427                self.play_prev_t = None;
428            } else {
429                self.play_prev_t = Some(Instant::now());
430                ctx.request_repaint(); // Animation loop
431            }
432        }
433
434        self.render_animation();
435
436        egui::TopBottomPanel::top("top_panel").show(ctx, |ui| {
437            ui.horizontal(|ui| {
438                ui.heading(&self.title);
439
440                // Resolution selector
441                {
442                    let resolution = self.resolution;
443                    egui::ComboBox::from_label("Resolution")
444                        .selected_text(format!(
445                            "{}x{} ({})",
446                            resolution.width,
447                            resolution.height,
448                            resolution.aspect_ratio_str()
449                        ))
450                        .show_ui(ui, |ui| {
451                            // 16:9
452                            ui.label(egui::RichText::new("16:9").strong());
453                            ui.selectable_value(
454                                &mut self.resolution,
455                                Resolution::HD,
456                                "1280x720 (HD)",
457                            );
458                            ui.selectable_value(
459                                &mut self.resolution,
460                                Resolution::FHD,
461                                "1920x1080 (FHD)",
462                            );
463                            ui.selectable_value(
464                                &mut self.resolution,
465                                Resolution::QHD,
466                                "2560x1440 (QHD)",
467                            );
468                            ui.selectable_value(
469                                &mut self.resolution,
470                                Resolution::UHD,
471                                "3840x2160 (UHD)",
472                            );
473                            ui.separator();
474                            // 16:10
475                            ui.label(egui::RichText::new("16:10").strong());
476                            ui.selectable_value(
477                                &mut self.resolution,
478                                Resolution::WXGA,
479                                "1280x800 (WXGA)",
480                            );
481                            ui.selectable_value(
482                                &mut self.resolution,
483                                Resolution::WUXGA,
484                                "1920x1200 (WUXGA)",
485                            );
486                            ui.separator();
487                            // 4:3
488                            ui.label(egui::RichText::new("4:3").strong());
489                            ui.selectable_value(
490                                &mut self.resolution,
491                                Resolution::SVGA,
492                                "800x600 (SVGA)",
493                            );
494                            ui.selectable_value(
495                                &mut self.resolution,
496                                Resolution::XGA,
497                                "1024x768 (XGA)",
498                            );
499                            ui.selectable_value(
500                                &mut self.resolution,
501                                Resolution::SXGA,
502                                "1280x960 (SXGA)",
503                            );
504                            ui.separator();
505                            // 1:1
506                            ui.label(egui::RichText::new("1:1").strong());
507                            ui.selectable_value(
508                                &mut self.resolution,
509                                Resolution::_1K_SQUARE,
510                                "1080x1080",
511                            );
512                            ui.selectable_value(
513                                &mut self.resolution,
514                                Resolution::_2K_SQUARE,
515                                "2160x2160",
516                            );
517                            ui.separator();
518                            // 21:9
519                            ui.label(egui::RichText::new("21:9").strong());
520                            ui.selectable_value(
521                                &mut self.resolution,
522                                Resolution::UW_QHD,
523                                "3440x1440 (UW-QHD)",
524                            );
525                        });
526                    if self.resolution != resolution {
527                        self.resolution_dirty = true;
528                    }
529                }
530
531                ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
532                    let dark_mode = ui.visuals().dark_mode;
533                    let button_text = if dark_mode { "☀ Light" } else { "🌙 Dark" };
534                    if ui.button(button_text).clicked() {
535                        if dark_mode {
536                            ctx.set_visuals(egui::Visuals::light());
537                        } else {
538                            ctx.set_visuals(egui::Visuals::dark());
539                        }
540                    }
541
542                    ui.separator();
543                    ui.selectable_value(&mut self.view_mode, ViewMode::Output, "Output");
544                    ui.selectable_value(&mut self.view_mode, ViewMode::Depth, "Depth");
545                    ui.separator();
546
547                    if let Some(duration) = self.last_render_time {
548                        ui.label(format!("Render: {:.2}ms", duration.as_secs_f64() * 1000.0));
549                        ui.separator();
550                    }
551                    if let Some(duration) = self.last_eval_time {
552                        ui.label(format!("Eval: {:.2}ms", duration.as_secs_f64() * 1000.0));
553                        ui.separator();
554                    }
555                });
556            });
557        });
558
559        egui::TopBottomPanel::bottom("bottom_panel")
560            .resizable(true)
561            .max_height(600.0)
562            .show(ctx, |ui| {
563                ui.label("Timeline");
564
565                ui.horizontal(|ui| {
566                    if ui.button("<<").clicked() {
567                        self.timeline_state.current_sec = 0.0;
568                    }
569                    #[allow(clippy::collapsible_else_if)]
570                    if self.play_prev_t.is_none() {
571                        if ui.button("play").clicked() {
572                            self.play_prev_t = Some(Instant::now());
573                        }
574                    } else {
575                        if ui.button("pause").clicked() {
576                            self.play_prev_t = None;
577                        }
578                    }
579                    if ui.button(">>").clicked() {
580                        self.timeline_state.current_sec = self.timeline_state.total_sec;
581                    }
582                    ui.style_mut().spacing.slider_width = ui.available_width() - 70.0;
583                    ui.add(
584                        egui::Slider::new(
585                            &mut self.timeline_state.current_sec,
586                            0.0..=self.timeline_state.total_sec,
587                        )
588                        .text("sec"),
589                    );
590                });
591
592                self.timeline_state.ui_main_timeline(ui);
593            });
594
595        egui::CentralPanel::default().show(ctx, |ui| {
596            let texture_id = match self.view_mode {
597                ViewMode::Output => self.texture_id,
598                ViewMode::Depth => self.depth_texture_id,
599            };
600
601            if let Some(tid) = texture_id {
602                // Maintain aspect ratio
603                // TODO: We could update renderer size here if we want dynamic resolution
604                let available_size = ui.available_size();
605                let aspect_ratio = self
606                    .render_textures
607                    .as_ref()
608                    .map(|rt| rt.ratio())
609                    .unwrap_or(1280.0 / 7.0);
610                let mut size = available_size;
611
612                if size.x / size.y > aspect_ratio {
613                    size.x = size.y * aspect_ratio;
614                } else {
615                    size.y = size.x / aspect_ratio;
616                }
617
618                ui.centered_and_justified(|ui| {
619                    ui.image(egui::load::SizedTexture::new(tid, size));
620                });
621            } else {
622                ui.centered_and_justified(|ui| {
623                    ui.spinner();
624                });
625            }
626        });
627    }
628}
629
630pub fn run_app(app: RanimPreviewApp, #[cfg(target_arch = "wasm32")] container_id: String) {
631    #[cfg(not(target_arch = "wasm32"))]
632    {
633        let native_options = eframe::NativeOptions {
634            viewport: egui::ViewportBuilder::default()
635                .with_title(&app.title)
636                .with_inner_size([1280.0, 720.0]),
637            renderer: eframe::Renderer::Wgpu,
638            ..Default::default()
639        };
640
641        // We need to clone title because run_native takes String (or &str) and app is moved into closure
642        let title = app.title.clone();
643
644        eframe::run_native(
645            &title,
646            native_options,
647            Box::new(|_cc| {
648                // If we wanted to access wgpu context on creation, we could do it here from _cc.wgpu_render_state
649                Ok(Box::new(app))
650            }),
651        )
652        .unwrap();
653    }
654
655    #[cfg(target_arch = "wasm32")]
656    {
657        use wasm_bindgen::JsCast;
658        let web_options = eframe::WebOptions {
659            ..Default::default()
660        };
661
662        // Handling canvas creation if not found to ensure compatibility
663        let document = web_sys::window().unwrap().document().unwrap();
664        let canvas = document
665            .get_element_by_id(&container_id)
666            .and_then(|c| c.dyn_into::<web_sys::HtmlCanvasElement>().ok());
667
668        let canvas = if let Some(canvas) = canvas {
669            canvas
670        } else {
671            let canvas = document.create_element("canvas").unwrap();
672            canvas.set_id(&container_id);
673            document.body().unwrap().append_child(&canvas).unwrap();
674            canvas.dyn_into::<web_sys::HtmlCanvasElement>().unwrap()
675        };
676
677        wasm_bindgen_futures::spawn_local(async {
678            eframe::WebRunner::new()
679                .start(canvas, web_options, Box::new(|_cc| Ok(Box::new(app))))
680                .await
681                .expect("failed to start eframe");
682        });
683    }
684}
685
686pub fn preview_constructor_with_name(scene: impl SceneConstructor, name: &str) {
687    let app = RanimPreviewApp::new(scene, name.to_string());
688    run_app(
689        app,
690        #[cfg(target_arch = "wasm32")]
691        format!("ranim-app-{name}"),
692    );
693}
694
695/// Preview a scene
696pub fn preview_scene(scene: &Scene) {
697    preview_scene_with_name(scene, &scene.name);
698}
699
700/// Preview a scene with a custom name
701pub fn preview_scene_with_name(scene: &Scene, name: &str) {
702    let mut app = RanimPreviewApp::new(scene.constructor, name.to_string());
703    app.set_clear_color_str(&scene.config.clear_color);
704    run_app(
705        app,
706        #[cfg(target_arch = "wasm32")]
707        format!("ranim-app-{name}"),
708    );
709}
710
711// WASM support needs refactoring, mostly keeping it commented or adapting basic entry point.
712#[cfg(target_arch = "wasm32")]
713mod wasm {
714    use super::*;
715
716    #[wasm_bindgen(start)]
717    pub async fn wasm_start() {
718        console_error_panic_hook::set_once();
719        wasm_tracing::set_as_global_default();
720    }
721
722    /// WASM wrapper: preview a scene (accepts owned [`Scene`] from `find_scene`)
723    #[wasm_bindgen]
724    pub fn preview_scene(scene: &Scene) {
725        super::preview_scene(scene);
726    }
727}