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 1/3] 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 From 83bb74d21ce516d895e0ec50a8b9682b8556825e Mon Sep 17 00:00:00 2001 From: Jason Luker <176962611+ZJHeepfixer@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:29:25 -0700 Subject: [PATCH 2/3] =?UTF-8?q?UI:=20"Preparing=20the=20GPU=20compositor?= =?UTF-8?q?=E2=80=A6"=20stays=20until=20the=20compositor=20is=20attached?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The status line is the toast that clears itself after 5 s, so the preparing notice went blank while the viewer was still on the CPU for the rest of a cold-cache build (tens of seconds), though `init_gpu` says the status bar tells the user so. draw_toast now leaves that one status up while `gpu_initializing()`; `finish_gpu_build` clears it when the compositor lands, and the worker's repaint brings that frame, so the toast doesn't tick meanwhile. A click still dismisses it; every other status clears as before. Test: with the build held on its gate, 30 more steps (7.5 s) leave the status as it was; before this change it was empty by then. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/app/frame.rs | 7 +++++++ crates/ui-egui/tests/it/ui_gpu.rs | 11 +++++++++++ 2 files changed, 18 insertions(+) diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index 6c6c737c0..057c5ce22 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -461,6 +461,13 @@ impl EffectcraftApp { self.ui.status.clear(); self.toast = None; } + // "Preparing the GPU compositor…" stays until the compositor is attached (tens of + // seconds on a cold shader cache), not 5 s: `finish_gpu_build` clears it, and the + // worker's repaint brings that frame, so nothing needs to tick meanwhile. A click + // still dismisses it. + if self.ui.status == crate::GPU_PREPARING && self.gpu_initializing() { + return; + } ui.ctx().request_repaint_after(std::time::Duration::from_millis(500)); if !self.ui.status.is_empty() { let since: f64 = ui.data(|d| d.get_temp(egui::Id::unique("status-since")).unwrap_or(now)); diff --git a/crates/ui-egui/tests/it/ui_gpu.rs b/crates/ui-egui/tests/it/ui_gpu.rs index c5f7084a1..e74d0ebd5 100644 --- a/crates/ui-egui/tests/it/ui_gpu.rs +++ b/crates/ui-egui/tests/it/ui_gpu.rs @@ -47,6 +47,9 @@ fn the_interface_draws_and_composites_on_the_cpu_while_the_gpu_compositor_is_bui 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); + // The status line's 5 s expiry reads the app clock: a manual one makes the steps below + // stand for a known span of time. + app.use_manual_clock(); app.hooks.before_gpu_build = Some(Box::new(move || { let _ = gate.recv(); })); @@ -67,6 +70,14 @@ fn the_interface_draws_and_composites_on_the_cpu_while_the_gpu_compositor_is_bui 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"); + // Other statuses clear after 5 s; this one stays for as long as the build takes (each step + // is a quarter of a second). + for _ in 0..30 { + h.state_mut().clock_mut().advance(0.25); + h.step(); + } + assert!(h.state().gpu_initializing()); + assert_eq!(h.state().ui.status, effectcraft_ui_egui::GPU_PREPARING, "the status line still says so 7.5 s later"); release.send(()).unwrap(); settle(&mut h); assert!(!h.state().gpu_initializing()); From 9eb127d22353d870239635a1d03921a1a7d76551 Mon Sep 17 00:00:00 2001 From: Jason Luker <176962611+ZJHeepfixer@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:29:26 -0700 Subject: [PATCH 3/3] UI test: compare the viewer's GPU frame only once the compositor is attached With the compositor built on a worker thread, the parity test's first frame could be composited on the CPU while the build ran. Frame keys don't name the compositor (by design: the disk cache serves frames from either), so after the hand-over the viewer could keep showing that CPU frame, and the GPU-vs-CPU comparison would pass comparing the CPU with itself. The test now waits for the compositor, then opens the project and sets the time, so every frame it compares was requested with the compositor attached. (`viewer_on_gpu()` can't tell here: on the desktop GPU frames are read back into CPU images, and only the browser draws them from their texture.) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/tests/it/ui_gpu.rs | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/tests/it/ui_gpu.rs b/crates/ui-egui/tests/it/ui_gpu.rs index e74d0ebd5..52f89aa18 100644 --- a/crates/ui-egui/tests/it/ui_gpu.rs +++ b/crates/ui-egui/tests/it/ui_gpu.rs @@ -20,6 +20,20 @@ fn settle(h: &mut Harness<'_, EffectcraftApp>) { } } +/// Step until the GPU compositor's worker has handed its result over (attached, or why not). +/// The first step starts the build. +fn wait_for_compositor(h: &mut Harness<'_, EffectcraftApp>) { + h.step(); + for _ in 0..3000 { + if !h.state().gpu_initializing() { + return; + } + h.step(); + std::thread::sleep(std::time::Duration::from_millis(20)); + } + panic!("the GPU compositor's worker gave no result within a minute"); +} + /// Whether wgpu finds any adapter at all (egui_kittest's wgpu harness needs one; CI runners /// without a GPU skip). fn any_adapter() -> bool { @@ -148,21 +162,24 @@ fn viewer_draws_gpu_frames_that_match_the_cpu() { // egui-wgpu creates its GL device with WebGL2's limits, which the compositor declines. let gl = adapter.ends_with("(Gl)"); drop(probe); - let mut s = Session::default(); - s.execute("file.openDemoProject", json!({})).unwrap(); - s.execute("time.set", json!({"time": 2.0})).unwrap(); let mut h = Harness::builder() .with_size(egui::vec2(1400.0, 900.0)) .with_pixels_per_point(1.0) .wgpu_setup(same_adapter(adapter)) - .build_eframe(|_| EffectcraftApp::new(s)); - settle(&mut h); + .build_eframe(|_| EffectcraftApp::new(Session::default())); + // The project opens once the compositor (built on a worker thread) is attached. A frame the + // CPU composited while it was being built has the GPU frame's key, so the viewer would keep + // showing it, and the comparison below would be the CPU against itself. + wait_for_compositor(&mut h); if gl && h.state().gpu_adapter().is_none() { eprintln!("egui-wgpu's GL device has WebGL2 limits: the viewer stays on the CPU, skipping"); return; } assert!(h.state().gpu_adapter().is_some(), "GPU compositor on egui-wgpu's device"); eprintln!("viewer compositor adapter: {:?}", h.state().gpu_adapter()); + h.state_mut().session.execute("file.openDemoProject", json!({})).unwrap(); + h.state_mut().session.execute("time.set", json!({"time": 2.0})).unwrap(); + settle(&mut h); let gpu_px = h.state_mut().viewer_pixels().expect("read back the GPU frame"); let gpu_shot = h.render().expect("render"); // Mercury Software Only: the same frame from the CPU.