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
@@ -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;@@ -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.