From 57adb3e3ed0dd99e76eb0ce6f293ec358b8e1612 Mon Sep 17 00:00:00 2001 From: Jason Luker <176962611+ZJHeepfixer@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:45:06 -0700 Subject: [PATCH] UI: open the window before the GPU compositor is built MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `init_gpu` built the compositor on the UI thread in the first frame, and eframe shows the window only after that frame has painted. The compositor's ~235 compute pipelines take tens of seconds to compile on a cold Metal shader cache (first launch, after an OS update, after an update that touched a shader): about 40 s of no window at all, measured 39–41 s on an M2 Max. On the desktop the compositor is now built on a worker thread (`gpu-compositor`), inside the same `catch_unwind`, and attached when its result arrives; the worker requests a repaint, so nothing polls. Until then the viewer composites on the CPU, as it already does without a GPU, and the status line and `help.systemInfo` say "Preparing the GPU compositor…". A device failure during the build discards the result; a thread that can't be spawned, and the browser, build in place as before. `Hooks::before_gpu_build` lets tests hold or fail the build; `gpu_initializing()` exposes the pending state. Measured (release, cold cache, A/B interleaved): time to the window 39.0/40.9 s → 1.9/0.5 s, and the control channel answers right after; with a heavy project opened at launch 38.3 s → 0.64 s (first frame 88 ms after the app was created). Warm: 776 → 355 ms. The compile itself is not faster; #1705 (effect kernels on first use) is the complementary half. Tests: the interface draws, composites a CPU frame and reports the preparing state while the build is held on a gate, then attaches the compositor; a build that panics leaves the viewer on the CPU with the reason; the existing GPU/CPU parity test waits for the asynchronous hand-over. Co-Authored-By: Claude Fable 5.1 --- crates/ui-egui/src/app.rs | 3 + crates/ui-egui/src/app/frame.rs | 2 +- crates/ui-egui/src/app/gpu.rs | 113 +++++++++++++++++++++++++++++- crates/ui-egui/src/hooks.rs | 4 ++ crates/ui-egui/src/lib.rs | 5 ++ crates/ui-egui/tests/it/ui_gpu.rs | 91 +++++++++++++++++++++++- 6 files changed, 212 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 115c5f33a..cbcb5bcec 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -15,6 +15,7 @@ mod screenshot; mod viewer_frames; mod window; +pub use gpu::{GPU_PREPARING, GpuBuild}; #[cfg(test)] pub(crate) use screenshot::{MAX_PENDING_SHOTS, SCREENSHOT_SETTLE_PASSES, SCREENSHOT_SETTLE_S, SCREENSHOT_TIMEOUT_S}; @@ -74,6 +75,8 @@ impl EffectcraftApp { wgpu: None, gpu: None, gpu_checked: false, + #[cfg(not(target_arch = "wasm32"))] + gpu_build: None, gpu_failures: None, gpu_failure_applied: None, device_lost_close: None, diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index a3263a677..6c6c737c0 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -127,7 +127,7 @@ impl EffectcraftApp { self.now = self.clock.now(); // The compositor exists before the first paint and while hidden (device-loss handling // must run there too). - self.init_gpu(frame); + self.init_gpu(ctx, frame); self.apply_gpu_failure(ctx); if self.gpu_failure_applied == Some(true) { self.close_after_device_loss(ctx); diff --git a/crates/ui-egui/src/app/gpu.rs b/crates/ui-egui/src/app/gpu.rs index 9febfa413..225a8727c 100644 --- a/crates/ui-egui/src/app/gpu.rs +++ b/crates/ui-egui/src/app/gpu.rs @@ -1,5 +1,12 @@ use super::{Arc, EffectcraftApp, RenderSource, gpu_failure, menus, panels}; +/// The status line while the GPU compositor's worker thread is still building its pipelines +/// (see [`EffectcraftApp::init_gpu`]). +pub const GPU_PREPARING: &str = "Preparing the GPU compositor… (compositing on the CPU until it is ready)"; +/// What the compositor's worker thread hands back: the compositor, why the device can't run it, +/// or the payload of a panic in the graphics backend. +pub type GpuBuild = std::thread::Result>; + impl EffectcraftApp { pub fn render_source(&self) -> RenderSource { RenderSource { @@ -124,7 +131,19 @@ impl EffectcraftApp { /// Set up the GPU compositor on egui-wgpu's device (once). Without an adapter that runs /// compute shaders (WebGL2, software GL) everything stays on the CPU. - pub(crate) fn init_gpu(&mut self, frame: &eframe::Frame) { + /// + /// On the desktop the compositor is built on a worker thread. Its compute pipelines (about + /// 235 of them) take tens of seconds to compile on a cold shader cache (first launch, after + /// an OS update, after an update that changed a shader), and eframe shows the window only + /// once the first frame has painted, so building them here kept the window from appearing + /// at all. Until the worker is done the viewer composites on the CPU, as it does without a + /// GPU, and the status bar says so; the browser has no threads and builds it in place. + pub(crate) fn init_gpu(&mut self, ctx: &egui::Context, frame: &eframe::Frame) { + #[cfg(not(target_arch = "wasm32"))] + if self.gpu_build.is_some() { + self.finish_gpu_build(frame.wgpu_render_state()); + return; + } if self.gpu_checked { return; } @@ -142,10 +161,80 @@ impl EffectcraftApp { info.device_type, if info.driver.is_empty() { String::new() } else { format!(", {} {}", info.driver, info.driver_info) } )); + #[cfg(not(target_arch = "wasm32"))] + if self.spawn_gpu_build(ctx, rs) { + return; + } + // In place (the browser, or no thread to be had): nothing to wake later. + let _ = ctx; // Backend validation can panic on shader compilation even when device creation // succeeded. Keep the document and the egui renderer, and use CPU compositing. - let gpu = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| effectcraft_gpu::Gpu::new(&rs.adapter, rs.device.clone(), rs.queue.clone()))); - match gpu { + let built = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| effectcraft_gpu::Gpu::new(&rs.adapter, rs.device.clone(), rs.queue.clone()))); + self.attach_gpu(built, Some(rs)); + } + + /// Start building the compositor on a `gpu-compositor` worker thread, which hands its result + /// back to [`Self::finish_gpu_build`] and wakes the UI. False when no thread could be spawned + /// (the caller then builds it in place). + #[cfg(not(target_arch = "wasm32"))] + fn spawn_gpu_build(&mut self, ctx: &egui::Context, rs: &eframe::egui_wgpu::RenderState) -> bool { + let (tx, rx) = std::sync::mpsc::channel(); + let (adapter, device, queue) = (rs.adapter.clone(), rs.device.clone(), rs.queue.clone()); + let gate = self.hooks.before_gpu_build.take(); + let wake = ctx.clone(); + let spawned = std::thread::Builder::new().name("gpu-compositor".into()).spawn(move || { + // Backend validation can panic on shader compilation even when device creation + // succeeded: the panic stays on this thread and arrives as a result. + let built = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + if let Some(gate) = gate { + gate(); + } + effectcraft_gpu::Gpu::new(&adapter, device, queue) + })); + // No receiver: the window closed, or the device failed, while this was running. + let _ = tx.send(built); + wake.request_repaint(); + }); + if let Err(e) = spawned { + log::warn!("no worker thread for the GPU compositor ({e}); building it on the UI thread"); + return false; + } + self.gpu_build = Some(rx); + self.session.accel_note = Some(GPU_PREPARING.into()); + if self.ui.status.is_empty() { + self.ui.status = GPU_PREPARING.into(); + } + true + } + + /// Take the worker's result when it has arrived ([`Self::init_gpu`]). `rs`: the window's + /// device, which a compositor that built is attached to. + #[cfg(not(target_arch = "wasm32"))] + fn finish_gpu_build(&mut self, rs: Option<&eframe::egui_wgpu::RenderState>) { + let Some(rx) = &self.gpu_build else { return }; + let built = match rx.try_recv() { + Ok(built) => built, + Err(std::sync::mpsc::TryRecvError::Empty) => return, + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + Err(Box::new("the compositor's worker thread ended without a result") as Box) + } + }; + self.gpu_build = None; + if self.ui.status == GPU_PREPARING { + self.ui.status.clear(); + } + // The device failed while the compositor was being built: it stays detached, and + // `apply_gpu_failure` has already said why the viewer is on the CPU. + if self.gpu_failure_applied.is_some() || self.gpu_failures.as_ref().is_some_and(|bridge| bridge.failure().is_some()) { + return; + } + self.attach_gpu(built, rs); + } + + /// Attach a built compositor to the viewer, or record why there is none. Keeps the document + /// and the egui renderer either way; without a compositor the viewer composites on the CPU. + fn attach_gpu(&mut self, built: GpuBuild, rs: Option<&eframe::egui_wgpu::RenderState>) { + match built { Ok(Ok(g)) => { if let Some(bridge) = &self.gpu_failures { bridge.forward_to(g.context().device_failure_notifier()); @@ -153,7 +242,12 @@ impl EffectcraftApp { return; } } + let Some(rs) = rs else { + self.session.accel_note = Some("GPU compositor unavailable: the window has no wgpu device".into()); + return; + }; log::info!("GPU compositor: {}", effectcraft_engine::render::Accelerator::name(&g)); + self.session.accel_note = None; self.session.accel = Some(Arc::new(g.clone())); self.gpu = Some(g); self.wgpu = Some(rs.clone()); @@ -170,6 +264,19 @@ impl EffectcraftApp { } } + /// Whether the GPU compositor is still being built on its worker thread (the viewer is on + /// the CPU meanwhile). Always false in the browser, which builds it with the first frame. + pub fn gpu_initializing(&self) -> bool { + #[cfg(not(target_arch = "wasm32"))] + { + self.gpu_build.is_some() + } + #[cfg(target_arch = "wasm32")] + { + false + } + } + /// The viewer shows a frame composited on the GPU (drawn from its wgpu texture). pub fn viewer_on_gpu(&self) -> bool { matches!((&self.viewer_shown, &self.viewer_native), (Some(s), Some(n)) if s.1 == n.1) diff --git a/crates/ui-egui/src/hooks.rs b/crates/ui-egui/src/hooks.rs index 8d7dcb704..457aeb99a 100644 --- a/crates/ui-egui/src/hooks.rs +++ b/crates/ui-egui/src/hooks.rs @@ -62,6 +62,10 @@ pub struct Hooks { /// Tells the user the graphics device was lost and the window is closing, once. `saved` is /// the auto-save of unsaved changes (`None`: nothing was unsaved), its path or why it failed. pub device_lost: Option>)>>, + /// Runs on the worker thread right before the GPU compositor is built (desktop). Tests hold + /// it to look at the interface while the compositor is still being prepared, or make it + /// panic to check that a failing build never reaches the UI thread. + pub before_gpu_build: Option>, } impl Hooks { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0b259bfde..ae00f7ac9 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -51,6 +51,7 @@ mod app; mod clock; mod hooks; +pub use app::{GPU_PREPARING, GpuBuild}; #[cfg(test)] use app::{MAX_PENDING_SHOTS, SCREENSHOT_SETTLE_PASSES, SCREENSHOT_SETTLE_S, SCREENSHOT_TIMEOUT_S}; pub use clock::AppClock; @@ -310,6 +311,10 @@ pub struct EffectcraftApp { /// The GPU compositor on that device (None: no usable adapter → CPU only). pub(crate) gpu: Option, gpu_checked: bool, + /// The GPU compositor being built on a worker thread (desktop): the window opens at once and + /// composites on the CPU until the worker's result lands ([`Self::init_gpu`]). + #[cfg(not(target_arch = "wasm32"))] + gpu_build: Option>, gpu_failures: Option, gpu_failure_applied: Option, /// The graphics device was lost and the window is closing (see `close_after_device_loss`); diff --git a/crates/ui-egui/tests/it/ui_gpu.rs b/crates/ui-egui/tests/it/ui_gpu.rs index 355c35bf5..c5f7084a1 100644 --- a/crates/ui-egui/tests/it/ui_gpu.rs +++ b/crates/ui-egui/tests/it/ui_gpu.rs @@ -6,10 +6,11 @@ use effectcraft_ui_egui::EffectcraftApp; use egui_kittest::Harness; use serde_json::json; +/// Step until the GPU compositor's worker is done and a frame has landed in the viewer. fn settle(h: &mut Harness<'_, EffectcraftApp>) { - for _ in 0..600 { + for _ in 0..3000 { h.step(); - if h.state().frames.inflight() == 0 && h.state().frames.last_ms.lock().map(|v| *v > 0.0).unwrap_or(false) { + if !h.state().gpu_initializing() && h.state().frames.inflight() == 0 && h.state().frames.last_ms.lock().map(|v| *v > 0.0).unwrap_or(false) { break; } std::thread::sleep(std::time::Duration::from_millis(20)); @@ -19,6 +20,92 @@ fn settle(h: &mut Harness<'_, EffectcraftApp>) { } } +/// Whether wgpu finds any adapter at all (egui_kittest's wgpu harness needs one; CI runners +/// without a GPU skip). +fn any_adapter() -> bool { + let instance = eframe::wgpu::Instance::new(eframe::wgpu::InstanceDescriptor::new_without_display_handle()); + !pollster::block_on(instance.enumerate_adapters(eframe::wgpu::Backends::all())).is_empty() +} + +fn small_session() -> Session { + let mut s = Session::default(); + let comp = s.execute("comp.new", json!({"name": "First", "width": 64, "height": 64, "duration": 1})).unwrap()["comp"].as_u64().unwrap(); + s.execute("layer.newSolid", json!({"comp": comp, "color": "#ff0000"})).unwrap(); + s +} + +/// The window opens before the GPU compositor exists: its pipelines compile on a worker thread +/// (tens of seconds on a cold shader cache), and until then the interface draws, says so, and +/// composites on the CPU. The worker is held on a gate so the "preparing" state is observable. +#[test] +fn the_interface_draws_and_composites_on_the_cpu_while_the_gpu_compositor_is_built() { + if !any_adapter() { + eprintln!("no wgpu adapter: skipping"); + return; + } + let (release, gate) = std::sync::mpsc::channel::<()>(); + let s = small_session(); + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).wgpu().build_eframe(move |_| { + let mut app = EffectcraftApp::new(s); + app.hooks.before_gpu_build = Some(Box::new(move || { + let _ = gate.recv(); + })); + app + }); + // The first frames paint, and a CPU frame reaches the viewer, while the worker still waits. + for _ in 0..600 { + h.step(); + if h.state().is_ready() && !h.output().shapes.is_empty() && h.state().frames.last_ms.lock().map(|v| *v > 0.0).unwrap_or(false) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert!(h.state().gpu_initializing(), "the compositor is still being built"); + assert!(!h.output().shapes.is_empty(), "the interface draws before the compositor exists"); + assert!(h.state().session.accel.is_none(), "nothing renders on a compositor that doesn't exist yet"); + assert_eq!(h.state().ui.status, effectcraft_ui_egui::GPU_PREPARING, "the status line is honest about it"); + assert_eq!(h.state().session.accel_note.as_deref(), Some(effectcraft_ui_egui::GPU_PREPARING)); + assert!(h.state().frames.last_ms.lock().map(|v| *v > 0.0).unwrap_or(false), "a frame was composited on the CPU meanwhile"); + assert!(h.state_mut().viewer_pixels().is_some(), "the viewer shows that frame"); + release.send(()).unwrap(); + settle(&mut h); + assert!(!h.state().gpu_initializing()); + assert_ne!(h.state().ui.status, effectcraft_ui_egui::GPU_PREPARING, "the preparing status goes away"); + match h.state().gpu_adapter() { + Some(adapter) => { + eprintln!("compositor attached after the first frames: {adapter}"); + assert!(h.state().session.accel.is_some()); + assert!(h.state().session.accel_note.is_none()); + } + // An adapter that can't run the compositor (software GL): the reason is recorded. + None => assert!(h.state().session.accel_note.as_deref().is_some_and(|n| n != effectcraft_ui_egui::GPU_PREPARING)), + } +} + +/// A graphics backend that panics while the compositor's pipelines compile panics on the worker +/// thread, not under the UI: the window keeps drawing on the CPU and says why. +#[test] +fn a_compositor_build_that_panics_leaves_the_viewer_on_the_cpu() { + if !any_adapter() { + eprintln!("no wgpu adapter: skipping"); + return; + } + let s = small_session(); + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).wgpu().build_eframe(move |_| { + let mut app = EffectcraftApp::new(s); + app.hooks.before_gpu_build = Some(Box::new(|| panic!("synthetic shader compiler panic"))); + app + }); + settle(&mut h); + assert!(!h.state().gpu_initializing()); + assert!(h.state().gpu_adapter().is_none()); + assert!(h.state().session.accel.is_none()); + let note = h.state().session.accel_note.clone().unwrap_or_default(); + assert!(note.contains("synthetic shader compiler panic"), "{note}"); + assert!(!h.output().shapes.is_empty(), "the interface still draws"); + assert!(h.state_mut().viewer_pixels().is_some(), "the viewer shows a CPU frame"); +} + /// egui_kittest's wgpu setup on the adapter the headless compositor picked (`name (Backend)`), /// like the desktop app's window. Left alone, egui_kittest prefers a CPU adapter, which on /// Windows is WARP on DirectX 12, where FXC fails to compile the compositor's kernels: the