Export speed #1: reuse the Vello renderer + ImageCache across frames

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.
This commit is contained in:
Skyler Lehmkuhl 2026-06-21 01:29:46 -04:00
parent 5f3cb41c18
commit 0a0d7cd0a9
1 changed files with 57 additions and 41 deletions

View File

@ -1072,6 +1072,11 @@ struct EditorApp {
preferences_dialog: preferences::dialog::PreferencesDialog, preferences_dialog: preferences::dialog::PreferencesDialog,
/// Export orchestrator for background exports /// Export orchestrator for background exports
export_orchestrator: Option<export::ExportOrchestrator>, export_orchestrator: Option<export::ExportOrchestrator>,
/// 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<vello::Renderer>,
export_image_cache: Option<lightningbeam_core::renderer::ImageCache>,
/// GPU-rendered effect thumbnail generator /// GPU-rendered effect thumbnail generator
effect_thumbnail_generator: Option<EffectThumbnailGenerator>, effect_thumbnail_generator: Option<EffectThumbnailGenerator>,
@ -1333,6 +1338,8 @@ impl EditorApp {
large_media_prompt: None, large_media_prompt: None,
preferences_dialog: preferences::dialog::PreferencesDialog::default(), preferences_dialog: preferences::dialog::PreferencesDialog::default(),
export_orchestrator: None, export_orchestrator: None,
export_renderer: None,
export_image_cache: None,
effect_thumbnail_generator: None, // Initialized when GPU available effect_thumbnail_generator: None, // Initialized when GPU available
// Debug test mode (F5) // Debug test mode (F5)
@ -6208,17 +6215,17 @@ impl eframe::App for EditorApp {
self.render_large_media_prompt(ctx); self.render_large_media_prompt(ctx);
// Render video frames incrementally (if video export in progress) // Render video frames incrementally (if video export in progress)
if let Some(orchestrator) = &mut self.export_orchestrator { let exporting = self.export_orchestrator.as_ref().map_or(false, |o| o.is_exporting());
if orchestrator.is_exporting() { if exporting {
// Get GPU resources from eframe's wgpu render state
if let Some(render_state) = frame.wgpu_render_state() { if let Some(render_state) = frame.wgpu_render_state() {
let device = &render_state.device; let device = &render_state.device;
let queue = &render_state.queue; let queue = &render_state.queue;
// Create temporary renderer and image cache for export // Build the renderer + image cache ONCE per export (reused every frame).
// Note: Creating a new renderer per frame is inefficient but simple // The image cache is given the container path so lazily-paged image assets
// TODO: Reuse renderer across frames by storing it in EditorApp // (Phase 4 Tier 1) decode during export.
let mut temp_renderer = vello::Renderer::new( if self.export_renderer.is_none() {
self.export_renderer = vello::Renderer::new(
device, device,
vello::RendererOptions { vello::RendererOptions {
use_cpu: false, use_cpu: false,
@ -6227,17 +6234,23 @@ impl eframe::App for EditorApp {
pipeline_cache: None, pipeline_cache: None,
}, },
).ok(); ).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), Some(image_cache), Some(orchestrator)) = (
self.export_renderer.as_mut(),
if let Some(renderer) = &mut temp_renderer { self.export_image_cache.as_mut(),
self.export_orchestrator.as_mut(),
) {
// Drive incremental video export. // Drive incremental video export.
if let Ok(has_more) = orchestrator.render_next_video_frame( if let Ok(has_more) = orchestrator.render_next_video_frame(
self.action_executor.document_mut(), self.action_executor.document_mut(),
device, device,
queue, queue,
renderer, renderer,
&mut temp_image_cache, image_cache,
&self.video_manager, &self.video_manager,
Some(&self.raster_store), Some(&self.raster_store),
) { ) {
@ -6252,7 +6265,7 @@ impl eframe::App for EditorApp {
device, device,
queue, queue,
renderer, renderer,
&mut temp_image_cache, image_cache,
&self.video_manager, &self.video_manager,
self.selection.raster_floating.as_ref(), self.selection.raster_floating.as_ref(),
Some(&self.raster_store), Some(&self.raster_store),
@ -6263,7 +6276,10 @@ impl eframe::App for EditorApp {
} }
} }
} }
} } 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 // Poll export orchestrator for progress