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.
