diff --git a/crates/gpu/src/adv3d.rs b/crates/gpu/src/adv3d.rs index f3b9fee2e..6ae11445a 100644 --- a/crates/gpu/src/adv3d.rs +++ b/crates/gpu/src/adv3d.rs @@ -15,7 +15,6 @@ use effectcraft_render::three_d::Dof; use effectcraft_render::three_d::adv::{Prepared, Rendered, Scene, Target}; use effectcraft_render::three_d::bokeh; -use wgpu::util::DeviceExt; use crate::context::{Enc, GpuContext, GpuImage}; @@ -443,14 +442,11 @@ fn raster_into(e: &mut Enc, s: &Scene) -> Option<(wgpu::Texture, wgpu::Texture)> b }) .collect(); - let vbuf = dev.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("adv3d vertices"), contents: &vb, usage: wgpu::BufferUsages::VERTEX }); + let vbuf = g.buffer_init(Some("adv3d vertices"), &vb, wgpu::BufferUsages::VERTEX); let ib: Vec = s.indices.iter().flat_map(|i| i.to_le_bytes()).collect(); - let ibuf = dev.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("adv3d indices"), contents: &ib, usage: wgpu::BufferUsages::INDEX }); - let ub = dev.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("adv3d globals"), contents: &bufs[0], usage: wgpu::BufferUsages::UNIFORM }); - let sbs: Vec = bufs[1..] - .iter() - .map(|b| dev.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("adv3d data"), contents: b, usage: wgpu::BufferUsages::STORAGE })) - .collect(); + let ibuf = g.buffer_init(Some("adv3d indices"), &ib, wgpu::BufferUsages::INDEX); + let ub = g.buffer_init(Some("adv3d globals"), &bufs[0], wgpu::BufferUsages::UNIFORM); + let sbs: Vec = bufs[1..].iter().map(|b| g.buffer_init(Some("adv3d data"), b, wgpu::BufferUsages::STORAGE)).collect(); let mut entries = vec![wgpu::BindGroupEntry { binding: 0, resource: ub.as_entire_binding() }]; for (i, b) in sbs.iter().enumerate() { entries.push(wgpu::BindGroupEntry { binding: i as u32 + 1, resource: b.as_entire_binding() }); @@ -736,7 +732,7 @@ fn dispatch(e: &mut Enc, kernel: &str, u0: [u32; 4], f0: [f32; 4], b: Binds, gro let g = e.g; let post = &pipes(g).post; let Some((_, pipe)) = post.kernels.iter().find(|(k, _)| *k == kernel) else { return }; - let ub = g.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: None, contents: ¶ms(u0, f0), usage: wgpu::BufferUsages::UNIFORM }); + let ub = g.buffer_init(None, ¶ms(u0, f0), wgpu::BufferUsages::UNIFORM); let mut entries = vec![ wgpu::BindGroupEntry { binding: 0, resource: ub.as_entire_binding() }, wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(b.color.unwrap_or(&post.dummy_color)) }, @@ -859,11 +855,7 @@ fn depth_of_field(e: &mut Enc, acc: &wgpu::Buffer, depth: &wgpu::Buffer, (w, h): for v in [f32::INFINITY.to_bits(), 0, 0, 0] { init.extend_from_slice(&v.to_le_bytes()); } - let range = g.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("adv3d radius range"), - contents: &init, - usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC, - }); + let range = g.buffer_init(Some("adv3d radius range"), &init, wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC); let f0 = [dof.focus as f32, dof.aperture as f32, dof.blur_level as f32, scale as f32]; let b = Binds { bufs: [None, Some(depth), None, None, Some(&radius), Some(&range), None], ..Default::default() }; dispatch(e, "radius_of", [w, h, 0, 0], f0, b, pixel_groups(w, h)); diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index c5065eae5..788757bfe 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -5,7 +5,6 @@ use std::sync::{Arc, Mutex, Weak}; use effectcraft_effects::Buf; use effectcraft_raster::Image; -use wgpu::util::DeviceExt; /// Compute entry points in `kernels.wgsl` (one pipeline each). const ENTRIES: &[&str] = &[ @@ -325,7 +324,34 @@ fn init_resource(_device: &wgpu::Device, _label: &str, create: impl FnOnce() Ok(create()) } +/// A buffer holding `contents` (padded to 4 bytes), written through the queue. Unlike +/// `DeviceExt::create_buffer_init`, which maps the buffer at creation and panics when the +/// allocation failed (a driver reports it as an internal error, not out-of-memory: Adreno's +/// `ERROR_INITIALIZATION_FAILED`, #591), a failed allocation only yields an invalid buffer and +/// an error for the caller's error scope. +pub(crate) fn buffer_init(device: &wgpu::Device, queue: &wgpu::Queue, label: Option<&str>, contents: &[u8], usage: wgpu::BufferUsages) -> wgpu::Buffer { + let align = wgpu::COPY_BUFFER_ALIGNMENT as usize; + let padded = contents.len().div_ceil(align).saturating_mul(align); + let buffer = + device.create_buffer(&wgpu::BufferDescriptor { label, size: padded as u64, usage: usage | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false }); + if padded > 0 { + if padded == contents.len() { + queue.write_buffer(&buffer, 0, contents); + } else { + let mut data = contents.to_vec(); + data.resize(padded, 0); + queue.write_buffer(&buffer, 0, &data); + } + } + buffer +} + impl GpuContext { + /// [`buffer_init`] on this context's device. + pub(crate) fn buffer_init(&self, label: Option<&str>, contents: &[u8], usage: wgpu::BufferUsages) -> wgpu::Buffer { + buffer_init(&self.device, &self.queue, label, contents, usage) + } + /// Build on an existing device (the desktop app shares egui-wgpu's). `Err` when the /// adapter cannot run the compositor (no compute shaders, e.g. WebGL2; no float storage /// textures) or the device was created with limits below what its pipelines bind. @@ -549,9 +575,8 @@ impl GpuContext { view_formats: &[], }) })?; - let dummy_buf = init_resource(&device, "dummy storage buffer", || { - device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("dummy"), contents: &[0u8; 16], usage: wgpu::BufferUsages::STORAGE }) - })?; + let dummy_buf = + init_resource(&device, "dummy storage buffer", || buffer_init(&device, &queue, Some("dummy"), &[0u8; 16], wgpu::BufferUsages::STORAGE))?; let dummy_tex = init_resource(&device, "dummy texture view", || dummy.create_view(&Default::default()))?; let max_dim = device.limits().max_texture_dimension_2d; Ok(GpuContext { @@ -996,7 +1021,7 @@ impl<'g> Enc<'g> { self.missing = true; return; }; - let ub = g.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: None, contents: &p.bytes(), usage: wgpu::BufferUsages::UNIFORM }); + let ub = g.buffer_init(None, &p.bytes(), wgpu::BufferUsages::UNIFORM); let sv = src.texture.create_view(&Default::default()); let av = aux.map(|a| a.texture.create_view(&Default::default())); let ov = out.texture.create_view(&Default::default()); @@ -1229,7 +1254,7 @@ impl<'g> Enc<'g> { while bytes.len() < 16 || !bytes.len().is_multiple_of(4) { bytes.push(0); } - self.g.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("data"), contents: &bytes, usage: wgpu::BufferUsages::STORAGE }) + self.g.buffer_init(Some("data"), &bytes, wgpu::BufferUsages::STORAGE) } /// Convert to the viewer's premultiplied RGBA8 texture (egui-wgpu's native texture format), diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 723da308d..252145c14 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -264,17 +264,22 @@ impl Gpu { } } - /// Run work in a native thread-local out-of-memory scope. A failed result is discarded - /// and cached allocations are released; callers can retry with a fresh CPU renderer. + /// Run work in native thread-local out-of-memory and internal-error scopes. A failed + /// result is discarded and cached allocations are released; callers can retry with a fresh + /// CPU renderer. Some drivers report a failed allocation as an internal error rather than + /// out-of-memory (Adreno's `ERROR_INITIALIZATION_FAILED`, #591), so both count. /// Browser callers use the host/owned-device failure bridge and bounded readbacks instead /// of blocking their event loop on an error-scope future. pub fn within_memory(&self, f: impl FnOnce() -> T) -> Result { self.ctx.check_health()?; #[cfg(not(target_arch = "wasm32"))] { - let scope = self.ctx.device.push_error_scope(wgpu::ErrorFilter::OutOfMemory); + let oom = self.ctx.device.push_error_scope(wgpu::ErrorFilter::OutOfMemory); + let internal = self.ctx.device.push_error_scope(wgpu::ErrorFilter::Internal); let out = f(); - match pollster::block_on(scope.pop()) { + let internal = pollster::block_on(internal.pop()); + let oom = pollster::block_on(oom.pop()); + match internal.or(oom) { Some(e) => { self.ctx.clear_uploads(); Err(e.to_string()) diff --git a/crates/gpu/src/particles.rs b/crates/gpu/src/particles.rs index 015edc823..7934dee0c 100644 --- a/crates/gpu/src/particles.rs +++ b/crates/gpu/src/particles.rs @@ -6,7 +6,6 @@ use std::sync::{Mutex, OnceLock}; use effectcraft_effects::psim::{ParticleSystem, SimParticle, SimRequest}; -use wgpu::util::DeviceExt; use crate::context::GpuContext; @@ -152,7 +151,7 @@ pub(crate) fn simulate(g: &GpuContext, req: &SimRequest) -> Option = req.births.iter().flat_map(|b| b.to_le_bytes()).collect(); - let births = d.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("births"), contents: &births, usage: wgpu::BufferUsages::STORAGE }); + let births = g.buffer_init(Some("births"), &births, wgpu::BufferUsages::STORAGE); let prev = prev.unwrap_or_else(|| { d.create_buffer(&wgpu::BufferDescriptor { label: Some("particles"), size: STATE_BYTES, usage: wgpu::BufferUsages::STORAGE, mapped_at_creation: false }) }); @@ -162,11 +161,7 @@ pub(crate) fn simulate(g: &GpuContext, req: &SimRequest) -> Option, gpu_failure_applied: Option, + /// The graphics device was lost and the window is closing (see `close_after_device_loss`); + /// `true` keeps crash recovery for the next launch. + device_lost_close: Option, /// Commands from the native menu bar. pub command_inbox: Option>, /// Pointer position in comp pixels (Info panel). @@ -347,6 +350,7 @@ impl EffectcraftApp { gpu_checked: false, gpu_failures: None, gpu_failure_applied: None, + device_lost_close: None, command_inbox: None, pointer_comp: None, dialog_state: Default::default(), @@ -643,6 +647,26 @@ impl EffectcraftApp { ctx.request_repaint(); } + /// After the graphics device is lost, eframe's renderer cannot draw again: any mesh makes + /// egui-wgpu panic on the main thread when it cannot stage its index buffer (#591). Draw + /// nothing from then on, auto-save a project with unsaved changes (keeping the crash + /// recovery marker, so the next launch offers it) and close the window. + fn close_after_device_loss(&mut self, ctx: &egui::Context) { + if self.device_lost_close.is_some() { + return; + } + let keep_recovery = self.session.is_dirty(); + if keep_recovery { + match self.session.autosave_now().and_then(|path| self.session.autosave_wait().map(|()| path)) { + Ok(path) => log::warn!("graphics device lost: auto-saved {path}; the next launch offers to recover it"), + Err(e) => log::error!("graphics device lost: {e}"), + } + } + self.device_lost_close = Some(keep_recovery); + log::warn!("graphics device lost: closing the window"); + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + /// 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) { @@ -1672,8 +1696,11 @@ impl EffectcraftApp { impl eframe::App for EffectcraftApp { fn on_exit(&mut self) { - // Clean exit: no crash recovery next launch. - self.session.end_recovery(); + // Clean exit: no crash recovery next launch (unless the graphics device was lost with + // unsaved changes, which are in the auto-save it offers). + if self.device_lost_close != Some(true) { + self.session.end_recovery(); + } } fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) { @@ -1732,6 +1759,10 @@ impl eframe::App for EffectcraftApp { } fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { + if self.gpu_failure_applied == Some(true) { + self.close_after_device_loss(ui.ctx()); + return; + } self.init_gpu(frame); if !self.fonts_ready { ui.ctx().request_repaint(); diff --git a/crates/ui-egui/tests/ui_gpu.rs b/crates/ui-egui/tests/ui_gpu.rs index 92d18a44f..58536f16f 100644 --- a/crates/ui-egui/tests/ui_gpu.rs +++ b/crates/ui-egui/tests/ui_gpu.rs @@ -86,3 +86,30 @@ fn viewer_draws_gpu_frames_that_match_the_cpu() { cpu_shot.save(format!("{d}/ui_cpu_viewer.png")).unwrap(); } } + +/// Once the graphics device is lost, eframe's renderer cannot stage buffers and egui-wgpu +/// panicked on the main thread drawing the next frame (#591). The app now draws nothing, +/// auto-saves unsaved changes and closes its window instead. +#[test] +fn a_lost_device_draws_nothing_autosaves_and_closes() { + let dir = std::env::temp_dir().join(format!("ec-device-lost-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let mut s = Session { autosave_folder_override: Some(dir.clone()), ..Default::default() }; + s.execute("comp.new", json!({"name": "Unsaved", "width": 64, "height": 64, "duration": 1})).unwrap(); + assert!(s.is_dirty()); + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + assert!(!h.output().shapes.is_empty(), "the UI draws before the loss"); + let bridge = effectcraft_ui_egui::gpu_failure::GpuFailureBridge::new(&h.ctx); + h.state_mut().set_gpu_failure_bridge(bridge.clone()); + bridge.report("GPU device lost (Unknown): Device is lost", true); + h.step(); + let closing = h.output().viewport_output.get(&egui::ViewportId::ROOT).is_some_and(|v| v.commands.contains(&egui::ViewportCommand::Close)); + assert!(closing, "the window closes"); + assert!(h.output().shapes.is_empty(), "nothing for the lost renderer to stage"); + h.step(); + assert!(h.output().shapes.is_empty()); + let saved = h.state().session.autosave.last_path.clone().expect("unsaved changes auto-saved"); + assert!(std::path::Path::new(&saved).is_file(), "{saved}"); + let _ = std::fs::remove_dir_all(&dir); +}