mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-31 07:38:24 +00:00
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:
1 parent
f20dfcbda2
commit
57adb3e3ed
6 files changed
+212
-6
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user