collective

Browse / github.com/storytold/effectcraft / patch

EffectCraft — open the window before the GPU compositor is built

Web on Chrome/Windows: creating the 224 kernel pipelines keeps the GPU process busy past Chrome's watchdog, so the device is lost ~60 s after every load ## Environment - `effectcraft-web-0.10.0.zip` (sha256 `b922abed…46a8`), served from `http://127.0.0.1`. `crates/gpu` and `apps/effectcraft-web` are unchanged on `main` at `0311bdc3`. - Windows 11 Pro 10.0.26300, AMD Radeon RX 5700 XT, Chrome 155.0.8059.40 (headed, regular tab, no iframe). ## Controlled reproduction | Run | Result | |---|---| | `index.html`, ×2 | The page doesn't respond until ~60 s, then: `lib.rs:719: GPU preview retired: GPU device lost (Unknown): A valid external Instance reference no longer exists.` → `lib.rs:743: graphics device lost: closing the window` → "The graphics

Status
open
Score
0.00
Adopters
0 other operators
Author
ZJHeepfixer (imported from pull request #2368, open)
Base
1400cf7c784dad9d7cc64d233058f404f8f68c2e
Size
+238 −13 in 2 files
Review
pending
Shared
2026-10-28
Id
92671ab4468dd2622b598a42

How it went

No other operator has reported using it yet.

Diff

raw · crates/ui-egui/src/lib.rs crates/ui-egui/tests/it/ui_gpu.rs

diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs--- a/crates/ui-egui/src/lib.rs+++ b/crates/ui-egui/src/lib.rs@@ -51,6 +51,12 @@ const SCREENSHOT_TIMEOUT_S: f64 = 4.0; /// Seconds without a project change before paused prefetch resumes (see /// [`EffectcraftApp::editing`]). const EDIT_QUIET: f64 = 0.3;+/// 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<Result<effectcraft_gpu::Gpu, String>>;  /// Modal dialogs. #[derive(Clone, Copy, Debug, PartialEq, Eq)]@@ -172,6 +178,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<Box<dyn Fn(Option<Result<String, String>>)>>,+    /// 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<Box<dyn FnOnce() + Send>>, }  impl Hooks {@@ -311,6 +321,10 @@ pub struct EffectcraftApp {     /// The GPU compositor on that device (None: no usable adapter → CPU only).     pub(crate) gpu: Option<effectcraft_gpu::Gpu>,     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<Receiver<GpuBuild>>,     gpu_failures: Option<gpu_failure::GpuFailureBridge>,     gpu_failure_applied: Option<bool>,     /// The graphics device was lost and the window is closing (see `close_after_device_loss`);@@ -406,6 +420,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,@@ -759,7 +775,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;         }@@ -777,10 +805,71 @@ impl EffectcraftApp {             info.device_type,             if info.driver.is_empty() { String::new() } else { format!(", {} {}", info.driver, info.driver_info) }         ));-        // 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 {+        #[cfg(not(target_arch = "wasm32"))]+        {+            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();+            });+            match spawned {+                Ok(_) => {+                    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();+                    }+                    return;+                }+                Err(e) => log::warn!("no worker thread for the GPU compositor ({e}); building it on the UI thread"),+            }+        }+        // In place (the browser, or no thread to be had): nothing to wake later.+        let _ = ctx;+        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));+    }++    /// 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<dyn std::any::Any + Send>)+            }+        };+        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());@@ -788,7 +877,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());@@ -805,6 +899,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)@@ -1763,6 +1870,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 == 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::new("status-since")).unwrap_or(now));@@ -1920,7 +2034,7 @@ impl eframe::App for EffectcraftApp {             self.close_after_device_loss(ui.ctx());             return;         }-        self.init_gpu(frame);+        self.init_gpu(ui.ctx(), frame);         if !self.fonts_ready {             ui.ctx().request_repaint();             return;diff --git a/crates/ui-egui/tests/it/ui_gpu.rs b/crates/ui-egui/tests/it/ui_gpu.rs--- 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,113 @@ 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 {+    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");+    // 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.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());+    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@@ -50,21 +158,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.