Merge pull request #2368 from ZJHeepfixer/apple-silicon-window-first

EffectCraft — open the window before the GPU compositor is built
This commit is contained in:
BFlat
2026-10-30 09:02:42 +07:00
committed by GitHub
7 changed files with 264 additions and 18 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,
footage_watcher: None,
+8 -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);
@@ -469,6 +469,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 == crate::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::unique("status-since")).unwrap_or(now));
+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>,
/// Settings ▸ Import ▸ Reload Footage Changed on Disk: the worker that stamps the footage
+12 -7
View File
@@ -7,6 +7,8 @@ use effectcraft_ui_egui::EffectcraftApp;
use egui_kittest::Harness;
use serde_json::json;
use crate::ui_gpu::wait_for_compositor;
fn settle(h: &mut Harness<'_, EffectcraftApp>) {
for _ in 0..600 {
h.step();
@@ -50,22 +52,25 @@ fn environment_light_lights_the_scene_on_the_gpu() {
eprintln!("headless compositor adapter: {adapter}");
let gl = adapter.ends_with("(Gl)");
drop(probe);
let mut s = Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
s.execute("comp.open", json!({"comp": "3D Showcase"})).unwrap();
s.execute("time.set", json!({"time": 5.0})).unwrap();
s.execute("comp.settings", json!({"renderer": "classic3D"})).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, so the
// frames compared below are GPU frames, not ones the CPU composited meanwhile (see ui_gpu.rs).
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");
let s = &mut h.state_mut().session;
s.execute("file.openDemoProject", json!({})).unwrap();
s.execute("comp.open", json!({"comp": "3D Showcase"})).unwrap();
s.execute("time.set", json!({"time": 5.0})).unwrap();
s.execute("comp.settings", json!({"renderer": "classic3D"})).unwrap();
settle(&mut h);
let before = h.state_mut().viewer_pixels().expect("GPU frame before adding the light").pixels.clone();
h.state_mut().session.execute("layer.newLight", json!({"kind": "Environment", "color": [1.0, 0.0, 0.0], "intensity": 100.0})).unwrap();
+122 -7
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,117 @@ 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.
pub(crate) 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);
// The status line's 5 s expiry reads the app clock: a manual one makes the steps below
// stand for a known span of time.
app.use_manual_clock();
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.state_mut().clock_mut().advance(0.25);
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 +162,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.