From 0a0d7cd0a93bfcf56ff7bb0d8fe0796667130efa Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Sun, 21 Jun 2026 01:29:46 -0400 Subject: [PATCH] Export speed #1: reuse the Vello renderer + ImageCache across frames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The export pump built a fresh `vello::Renderer` (full wgpu pipeline init, ~tens of ms) AND an empty `ImageCache` on every egui repaint — i.e. per output frame. That was the dominant per-frame cost (and the code comment already flagged it). Now the renderer + image cache are built once on the first export pump, reused for every frame, and dropped when the export finishes. Also fixes a latent bug: the throwaway export ImageCache had no container path, so with Phase 4 Tier 1 (lazy image bytes) images stored only in the container wouldn't render in exports. The persistent cache now gets the container path. Audit found the original "per-frame seek" theory was wrong — export decodes the source sequentially; readback is already async/triple-buffered. Remaining wins: cache swscale contexts (rebuilt per frame), GPU YUV conversion, decouple from the repaint cadence. --- .../lightningbeam-editor/src/main.rs | 98 +++++++++++-------- 1 file changed, 57 insertions(+), 41 deletions(-) diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index 139630c..7ab6f99 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -1072,6 +1072,11 @@ struct EditorApp { preferences_dialog: preferences::dialog::PreferencesDialog, /// Export orchestrator for background exports export_orchestrator: Option, + /// Vello renderer + image cache reused across all frames of an in-progress export + /// (built once on the first export pump, dropped when export ends) — building a new + /// renderer per frame was the dominant export cost. + export_renderer: Option, + export_image_cache: Option, /// GPU-rendered effect thumbnail generator effect_thumbnail_generator: Option, @@ -1333,6 +1338,8 @@ impl EditorApp { large_media_prompt: None, preferences_dialog: preferences::dialog::PreferencesDialog::default(), export_orchestrator: None, + export_renderer: None, + export_image_cache: None, effect_thumbnail_generator: None, // Initialized when GPU available // Debug test mode (F5) @@ -6208,17 +6215,17 @@ impl eframe::App for EditorApp { self.render_large_media_prompt(ctx); // Render video frames incrementally (if video export in progress) - if let Some(orchestrator) = &mut self.export_orchestrator { - if orchestrator.is_exporting() { - // Get GPU resources from eframe's wgpu render state - if let Some(render_state) = frame.wgpu_render_state() { - let device = &render_state.device; - let queue = &render_state.queue; + let exporting = self.export_orchestrator.as_ref().map_or(false, |o| o.is_exporting()); + if exporting { + if let Some(render_state) = frame.wgpu_render_state() { + let device = &render_state.device; + let queue = &render_state.queue; - // Create temporary renderer and image cache for export - // Note: Creating a new renderer per frame is inefficient but simple - // TODO: Reuse renderer across frames by storing it in EditorApp - let mut temp_renderer = vello::Renderer::new( + // Build the renderer + image cache ONCE per export (reused every frame). + // The image cache is given the container path so lazily-paged image assets + // (Phase 4 Tier 1) decode during export. + if self.export_renderer.is_none() { + self.export_renderer = vello::Renderer::new( device, vello::RendererOptions { use_cpu: false, @@ -6227,43 +6234,52 @@ impl eframe::App for EditorApp { pipeline_cache: None, }, ).ok(); + let mut ic = lightningbeam_core::renderer::ImageCache::new(); + ic.set_container_path(self.current_file_path.clone()); + self.export_image_cache = Some(ic); + } - let mut temp_image_cache = lightningbeam_core::renderer::ImageCache::new(); - - if let Some(renderer) = &mut temp_renderer { - // Drive incremental video export. - if let Ok(has_more) = orchestrator.render_next_video_frame( - self.action_executor.document_mut(), - device, - queue, - renderer, - &mut temp_image_cache, - &self.video_manager, - Some(&self.raster_store), - ) { - if has_more { - ctx.request_repaint(); - } + if let (Some(renderer), Some(image_cache), Some(orchestrator)) = ( + self.export_renderer.as_mut(), + self.export_image_cache.as_mut(), + self.export_orchestrator.as_mut(), + ) { + // Drive incremental video export. + if let Ok(has_more) = orchestrator.render_next_video_frame( + self.action_executor.document_mut(), + device, + queue, + renderer, + image_cache, + &self.video_manager, + Some(&self.raster_store), + ) { + if has_more { + ctx.request_repaint(); } + } - // Drive single-frame image export (two-frame async: render then readback). - match orchestrator.render_image_frame( - self.action_executor.document_mut(), - device, - queue, - renderer, - &mut temp_image_cache, - &self.video_manager, - self.selection.raster_floating.as_ref(), - Some(&self.raster_store), - ) { - Ok(false) => { ctx.request_repaint(); } // readback pending - Ok(true) => {} // done or cancelled - Err(e) => { eprintln!("Image export failed: {e}"); } - } + // Drive single-frame image export (two-frame async: render then readback). + match orchestrator.render_image_frame( + self.action_executor.document_mut(), + device, + queue, + renderer, + image_cache, + &self.video_manager, + self.selection.raster_floating.as_ref(), + Some(&self.raster_store), + ) { + Ok(false) => { ctx.request_repaint(); } // readback pending + Ok(true) => {} // done or cancelled + Err(e) => { eprintln!("Image export failed: {e}"); } } } } + } else if self.export_renderer.is_some() { + // Export finished — free the renderer + cache. + self.export_renderer = None; + self.export_image_cache = None; } // Poll export orchestrator for progress