UI: open the window before the GPU compositor is built

`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 <noreply@anthropic.com>
This commit is contained in:
Jason Luker
2026-10-29 15:43:42 -07:00
co-authored by Claude Fable 5.1
parent f20dfcbda2
commit 57adb3e3ed
6 changed files with 212 additions and 6 deletions
+3
View File
@@ -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,
+1 -1
View File
@@ -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);
+110 -3
View File
@@ -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<Result<effectcraft_gpu::Gpu, String>>;
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<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());
@@ -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)
+4
View File
@@ -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<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 {
+5
View File
@@ -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<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`);
+89 -2
View File
@@ -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