diff --git a/Cargo.lock b/Cargo.lock index 700f386f..eed2cfd3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3078,6 +3078,7 @@ dependencies = [ "lightcraft-ui-egui", "log", "muda", + "pollster 1.0.1", "rfd", "serde_json", "winresource", diff --git a/apps/lightcraft/Cargo.toml b/apps/lightcraft/Cargo.toml index e4d7f2b3..5873dce7 100644 --- a/apps/lightcraft/Cargo.toml +++ b/apps/lightcraft/Cargo.toml @@ -23,9 +23,11 @@ lightcraft-sysmem = { workspace = true } lightcraft-ui-egui = { workspace = true } lightcraft-codecs = { workspace = true } lightcraft-raster = { workspace = true } -eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] } +eframe = { workspace = true, features = ["default_fonts", "wgpu", "glow", "accesskit", "wayland", "x11"] } egui = { workspace = true } serde_json = { workspace = true } +# probing the window's renderer before it opens (`window_gpu`) +pollster = { workspace = true } log = { workspace = true } [build-dependencies] diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index 376b64d6..2d2c79bc 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -28,6 +28,7 @@ mod dialog_filter; mod logging; #[cfg(target_os = "macos")] mod native_menu; +mod window_gpu; use dialog_filter::FileDialogExt as _; use lightcraft_engine::Session; @@ -71,7 +72,7 @@ impl eframe::App for App { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { self.0.ui(ui); } - fn on_exit(&mut self) { + fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) { if let Err(e) = self.1.save(&self.0) { log::error!("{e}"); } @@ -93,15 +94,16 @@ const EXIT_WAIT: std::time::Duration = std::time::Duration::from_secs(2); /// alone on Windows unless `LIGHTCRAFT_GPU_BACKEND` / `WGPU_BACKEND` say otherwise (issue #136: /// with Vulkan in the set, wgpu loads the Vulkan driver even when it then picks DX12) — and DX12 /// shaders compiled with FXC, never a stray `dxcompiler.dll` (issue #471). +/// The fallback chain on top of it (DX12 → Vulkan → GL → glow) is in `window_gpu`. +#[cfg(test)] fn window_wgpu_options() -> eframe::egui_wgpu::WgpuConfiguration { - let mut c = eframe::egui_wgpu::WgpuConfiguration::default(); - if let eframe::egui_wgpu::WgpuSetup::CreateNew(n) = &mut c.wgpu_setup { - n.instance_descriptor.backends = lightcraft_engine::gpu::backend::window_backends(); - n.instance_descriptor.backend_options = lightcraft_engine::gpu::backend::backend_options(); - // no indirect-call validation pipeline: Intel Metal fails to compile it and the window never opens (#250) - n.instance_descriptor.flags = lightcraft_engine::gpu::backend::instance_flags_from_env(n.instance_descriptor.flags); - } - c + window_gpu::base_options() +} + +/// Where the window's renderer files live (`window_gpu::Files`): nothing for `--memory` sessions +/// and `LIGHTCRAFT_NO_PREFS`. +fn window_files(in_memory: bool) -> window_gpu::Files { + window_gpu::Files::new(config_dir().filter(|_| !in_memory && std::env::var_os("LIGHTCRAFT_NO_PREFS").is_none())) } /// Written while the GPU compute device is created, removed once that returned @@ -773,7 +775,14 @@ fn main() -> eframe::Result { let gpu_crash = gpu_crash_check(in_memory); let gpu_on = prefs.as_ref().is_none_or(|u| u.settings.gpu) && gpu_crash.is_none(); lightcraft_engine::gpu::set_enabled(gpu_on); + // the window's renderer: probed (Windows) and with a fallback chain, so a driver that can't + // build egui's pipeline never kills the start-up + let win_files = std::sync::Arc::new(window_files(in_memory)); + let window_gpu::Chosen { renderer, wgpu_options, which: window_renderer, notice: window_notice } = window_gpu::choose(&win_files); + win_files.arm(window_renderer); + let win_files_app = win_files.clone(); let options = eframe::NativeOptions { + renderer, viewport: egui::ViewportBuilder::default() .with_title("LightCraft") .with_inner_size([1600.0, 1000.0]) @@ -785,73 +794,88 @@ fn main() -> eframe::Result { .with_icon(app_icon()) // Wayland matches the window to packaging/linux/ai.storyteller.lightcraft.desktop by this id .with_app_id(APP_ID), - wgpu_options: window_wgpu_options(), + wgpu_options, ..Default::default() }; // the app's copy: `log_file` is still named in the message of a start that fails let app_log_file = log_file.clone(); - let started = eframe::run_native( - "LightCraft", - options, - Box::new(move |cc| { - let (mut session, problem) = open_session(in_memory, library_dir, seed_demo && files.is_empty()); - // AI masks: the SAM 3 checkpoint (facebook/sam3) in /models/sam3, or LIGHTCRAFT_SAM3_DIR - // (never required: without it, AI masks offer to download it; see docs/ai-masks.md) - session.segmenter.dir = - std::env::var_os("LIGHTCRAFT_SAM3_DIR").map(std::path::PathBuf::from).or_else(|| config_dir().map(|d| d.join("models").join("sam3"))); - // the user's own download locations, one base URL per line (LIGHTCRAFT_SAM3_MIRRORS too) - session.segmenter.mirrors_file = config_dir().map(|d| d.join("models").join("sam3-mirrors.txt")); - let mut app = LightcraftApp::new(session, services(cc.egui_ctx.clone(), app_log_file.as_deref())); - if let Some(ui) = prefs { - app.ui = ui; - } - lightcraft_ui_egui::i18n::set_language(app.ui.language); - app.integrated_titlebar = cfg!(target_os = "macos"); - app.notices.extend(prefs_warning); - app.notices.extend(launch_notices); - // what's on disk now: only changes are written - let writer = PrefsWriter { - path: if in_memory { None } else { prefs_path() }, - written: serde_json::to_vec_pretty(&app.ui).unwrap_or_default(), - library: app.ui.settings.library_path.clone(), - keep_file: keep_prefs_file, - ..Default::default() - }; - // after the snapshot above, so the writer saves the switched-off preference - if let Some(notice) = gpu_crash { - app.ui.settings.gpu = false; - app.notices.push(notice); - } - if let Some(port) = control_port { - let rx = control_server::start(port, cc.egui_ctx.clone()); - app = app.with_control(rx); - } - if let Some(mut p) = problem { - // imported once the user has chosen where (into the library, or the temporary session) - p.pending_import = files; - app.library_problem = Some(p); - } else if !files.is_empty() { - // listed and read on the import worker once the window shows (issue #374: never - // here, before the first frame, where a large folder kept the window from - // appearing) - if let Err(e) = lightcraft_ui_egui::import::start_paths(&mut app, files) { - log::warn!("import at launch: {e}"); + let started = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + eframe::run_native( + "LightCraft", + options, + Box::new(move |cc| { + // the window's renderer is up (eframe calls this after creating it) + win_files_app.started(window_renderer); + let (mut session, problem) = open_session(in_memory, library_dir, seed_demo && files.is_empty()); + // AI masks: the SAM 3 checkpoint (facebook/sam3) in /models/sam3, or LIGHTCRAFT_SAM3_DIR + // (never required: without it, AI masks offer to download it; see docs/ai-masks.md) + session.segmenter.dir = std::env::var_os("LIGHTCRAFT_SAM3_DIR") + .map(std::path::PathBuf::from) + .or_else(|| config_dir().map(|d| d.join("models").join("sam3"))); + // the user's own download locations, one base URL per line (LIGHTCRAFT_SAM3_MIRRORS too) + session.segmenter.mirrors_file = config_dir().map(|d| d.join("models").join("sam3-mirrors.txt")); + let mut app = LightcraftApp::new(session, services(cc.egui_ctx.clone(), app_log_file.as_deref())); + if let Some(ui) = prefs { + app.ui = ui; } - app.ui.view = lightcraft_ui_egui::state::ViewMode::PhotoGrid; - } - // native menu bar generated from the command registry (macOS; elsewhere the menus are - // drawn in the window's top bar). LIGHTCRAFT_NO_NATIVE_MENU=1 keeps the in-window menus. - #[cfg(target_os = "macos")] - let menu = (std::env::var_os("LIGHTCRAFT_NO_NATIVE_MENU").is_none()).then(|| native_menu::NativeMenu::install(&mut app, &cc.egui_ctx)); - Ok(Box::new(App( - app, - writer, + lightcraft_ui_egui::i18n::set_language(app.ui.language); + app.integrated_titlebar = cfg!(target_os = "macos"); + app.notices.extend(prefs_warning); + app.notices.extend(launch_notices); + app.notices.extend(window_notice); + // what's on disk now: only changes are written + let writer = PrefsWriter { + path: if in_memory { None } else { prefs_path() }, + written: serde_json::to_vec_pretty(&app.ui).unwrap_or_default(), + library: app.ui.settings.library_path.clone(), + keep_file: keep_prefs_file, + ..Default::default() + }; + // after the snapshot above, so the writer saves the switched-off preference + if let Some(notice) = gpu_crash { + app.ui.settings.gpu = false; + app.notices.push(notice); + } + if let Some(port) = control_port { + let rx = control_server::start(port, cc.egui_ctx.clone()); + app = app.with_control(rx); + } + if let Some(mut p) = problem { + // imported once the user has chosen where (into the library, or the temporary session) + p.pending_import = files; + app.library_problem = Some(p); + } else if !files.is_empty() { + // listed and read on the import worker once the window shows (issue #374: never + // here, before the first frame, where a large folder kept the window from + // appearing) + if let Err(e) = lightcraft_ui_egui::import::start_paths(&mut app, files) { + log::warn!("import at launch: {e}"); + } + app.ui.view = lightcraft_ui_egui::state::ViewMode::PhotoGrid; + } + // native menu bar generated from the command registry (macOS; elsewhere the menus are + // drawn in the window's top bar). LIGHTCRAFT_NO_NATIVE_MENU=1 keeps the in-window menus. #[cfg(target_os = "macos")] - menu, - ))) - }), - ); + let menu = + (std::env::var_os("LIGHTCRAFT_NO_NATIVE_MENU").is_none()).then(|| native_menu::NativeMenu::install(&mut app, &cc.egui_ctx)); + Ok(Box::new(App( + app, + writer, + #[cfg(target_os = "macos")] + menu, + ))) + }), + ) + })); + // a panic that still escaped the renderer's start (the probe and error handler should + // prevent it): an error like any other failed start + let started = started.unwrap_or_else(|_| Err(eframe::Error::AppCreation("the window's renderer panicked while starting".into()))); if let Err(e) = &started { + // the renderer never came up: start again, which tries the next one in the chain + if window_gpu::relaunch_after_failure(&win_files) { + log::error!("LightCraft could not open its window with {}: {e}; trying the next renderer", window_renderer.name()); + return Ok(()); + } startup_failed(&e.to_string(), log_file.as_deref()); } started diff --git a/apps/lightcraft/src/window_gpu.rs b/apps/lightcraft/src/window_gpu.rs new file mode 100644 index 00000000..66e5a78d --- /dev/null +++ b/apps/lightcraft/src/window_gpu.rs @@ -0,0 +1,316 @@ +//! The window's renderer and its fallback chain (crash report: on some Windows machines FXC fails +//! to compile egui's own shaders on DX12 — "The parameter is incorrect. (0x80070057)" in +//! `egui_pipeline` — and wgpu's default error handler panicked, killing the app at start-up). +//! +//! - **Windows:** each renderer of [`backend::window_renderer_order`] (DX12, Vulkan, GL, then +//! eframe's glow) is *probed* before the window opens: a device is created with an error handler +//! that logs instead of panicking, and egui's pipeline is built inside error scopes. The first +//! that builds cleanly is handed to eframe as an existing device (so its error handler stays). +//! - **Everywhere:** a marker file names the renderer being started and is removed once the +//! window's renderer is up. Left behind — the start died or panicked — the next launch tries that +//! renderer last; [`relaunch_after_failure`] starts that next launch right away. +//! - The renderer that worked is remembered (`/window-renderer`, Windows only) and tried +//! first next time. `LIGHTCRAFT_GPU_BACKEND=dx12|vulkan|gl|glow` (or `WGPU_BACKEND`) forces one. + +use eframe::egui_wgpu::{WgpuConfiguration, WgpuSetup, WgpuSetupExisting}; +use eframe::wgpu; +use lightcraft_engine::gpu::backend::{self, Backends, WindowRenderer}; +use std::path::PathBuf; + +/// Environment variable counting automatic relaunches after a failed start (bounds the chain). +const RETRY_VAR: &str = "LIGHTCRAFT_WINDOW_RETRY"; +/// Never more relaunches than renderers to try. +const MAX_RETRIES: u32 = 4; + +/// The window's renderer for this start. +pub struct Chosen { + pub renderer: eframe::Renderer, + pub wgpu_options: WgpuConfiguration, + pub which: WindowRenderer, + /// For the user: the last start failed with another renderer, or one failed now. + pub notice: Option, +} + +/// Files under the settings folder (`None` dir: write nothing — `--memory`, `LIGHTCRAFT_NO_PREFS`). +pub struct Files { + dir: Option, +} + +impl Files { + pub fn new(dir: Option) -> Self { + Self { dir } + } + fn marker(&self) -> Option { + self.dir.as_ref().map(|d| d.join("window-init.marker")) + } + fn remembered(&self) -> Option { + self.dir.as_ref().map(|d| d.join("window-renderer")) + } + fn read(p: Option) -> Option { + std::fs::read_to_string(p?).ok().as_deref().and_then(WindowRenderer::parse) + } + fn write(p: Option, r: WindowRenderer) { + let Some(p) = p else { return }; + if let Some(d) = p.parent() { + let _ = std::fs::create_dir_all(d); + } + if let Err(e) = std::fs::write(&p, format!("{}\n", r.name())) { + log::warn!("window: could not write {}: {e}", p.display()); + } + } + /// The renderer the last start died in (marker left behind), removing the marker. + pub fn take_failed(&self) -> Option { + let m = self.marker()?; + let r = Self::read(Some(m.clone())); + let _ = std::fs::remove_file(m); + r + } + /// Before the window opens: record what is being started. + pub fn arm(&self, r: WindowRenderer) { + Self::write(self.marker(), r); + } + /// The window's renderer is up: clear the marker and (Windows) remember it. + pub fn started(&self, r: WindowRenderer) { + if let Some(m) = self.marker() { + let _ = std::fs::remove_file(m); + } + if cfg!(windows) { + Self::write(self.remembered(), r); + } + } + /// The marker is still there: the window never came up. + pub fn armed(&self) -> bool { + self.marker().is_some_and(|m| m.exists()) + } +} + +/// The renderer for this start: see the module docs. +pub fn choose(files: &Files) -> Chosen { + let forced = + backend::forced_window_renderer(std::env::var("LIGHTCRAFT_GPU_BACKEND").ok().as_deref(), std::env::var("WGPU_BACKEND").ok().as_deref()); + let failed = files.take_failed(); + let remembered = if cfg!(windows) { Files::read(files.remembered()) } else { None }; + let compiled = wgpu::Instance::enabled_backend_features(); + let os = std::env::consts::OS; + let order = backend::window_renderer_order(forced, remembered, failed, os, compiled, true); + let mut notice = failed.map(|f| format!("The window could not start with its {} renderer last time.", f.name())); + let mut problems = Vec::new(); + let chosen = if cfg!(windows) { + pick_probed(&order, &mut problems) + } else if forced == Some(WindowRenderer::Glow) || (forced.is_none() && failed.is_some_and(|f| f != WindowRenderer::Glow)) { + // elsewhere eframe's own wgpu set-up stays the default; glow after a failed start + Some((WindowRenderer::Glow, None)) + } else { + Some((WindowRenderer::Wgpu(backend::window_backends()), None)) + }; + let (which, existing) = chosen.unwrap_or((WindowRenderer::Glow, None)); + if !problems.is_empty() { + notice = Some(format!("{} {}", notice.unwrap_or_default(), problems.join(" ")).trim().to_string()); + } + let notice = notice.map(|n| { + format!( + "{n} {app} now draws its window with {}. To choose another, start {app} with {var}=dx12, vulkan, gl or glow \ + (see docs/gpu-pipeline.md → Troubleshooting).", + which.name(), + app = "LightCraft", + var = "LIGHTCRAFT_GPU_BACKEND", + ) + }); + log::info!("window: renderer {} (order {:?})", which.name(), order.iter().map(|r| r.name()).collect::>()); + let mut wgpu_options = base_options(); + let renderer = match (which, existing) { + (WindowRenderer::Glow, _) => eframe::Renderer::Glow, + (WindowRenderer::Wgpu(_), Some(e)) => { + wgpu_options.wgpu_setup = WgpuSetup::Existing(e); + eframe::Renderer::Wgpu + } + (WindowRenderer::Wgpu(b), None) => { + if let WgpuSetup::CreateNew(n) = &mut wgpu_options.wgpu_setup { + n.instance_descriptor.backends = b; + } + eframe::Renderer::Wgpu + } + }; + Chosen { renderer, wgpu_options, which, notice } +} + +/// eframe's defaults with the app's backends and DX12 shader compiler (issues #136, #471). +pub fn base_options() -> WgpuConfiguration { + let mut c = WgpuConfiguration::default(); + if let WgpuSetup::CreateNew(n) = &mut c.wgpu_setup { + n.instance_descriptor.backends = backend::window_backends(); + n.instance_descriptor.backend_options = backend::backend_options(); + // no indirect-call validation pipeline: Intel Metal fails to compile it and the window never opens (#250) + n.instance_descriptor.flags = backend::instance_flags_from_env(n.instance_descriptor.flags); + } + c +} + +/// The first renderer of `order` that probes cleanly (glow is taken without a probe). +fn pick_probed(order: &[WindowRenderer], problems: &mut Vec) -> Option<(WindowRenderer, Option)> { + for &r in order { + match r { + WindowRenderer::Glow => return Some((r, None)), + WindowRenderer::Wgpu(b) => { + let probed = std::panic::catch_unwind(|| probe(b)).unwrap_or_else(|_| Err("panicked".into())); + match probed { + Ok(e) => return Some((r, Some(e))), + Err(e) => { + log::error!("window: the {} renderer failed: {e}", r.name()); + problems.push(format!("The {} renderer failed ({e}).", r.name())); + } + } + } + } + } + None +} + +/// Create a device on `backends` and build egui's pipeline on it; any wgpu error is returned. +fn probe(backends: Backends) -> Result { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { + backends, + flags: backend::instance_flags_from_env(wgpu::InstanceFlags::from_build_config().with_env()), + backend_options: backend::backend_options(), + memory_budget_thresholds: wgpu::MemoryBudgetThresholds::default(), + display: None, + }); + let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::from_env().unwrap_or(wgpu::PowerPreference::HighPerformance), + ..Default::default() + })) + .map_err(|e| format!("no adapter: {e}"))?; + let base = if adapter.get_info().backend == wgpu::Backend::Gl { wgpu::Limits::downlevel_webgl2_defaults() } else { wgpu::Limits::default() }; + let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { + label: Some("egui wgpu device"), + required_limits: wgpu::Limits { max_texture_dimension_2d: 8192, ..base }, + ..Default::default() + })) + .map_err(|e| format!("{}: no device: {e}", adapter.get_info().name))?; + install_error_handler(&device); + check_pipeline(&device, |d| { + drop(eframe::egui_wgpu::Renderer::new(d, wgpu::TextureFormat::Bgra8Unorm, eframe::egui_wgpu::RendererOptions::default())); + })?; + Ok(WgpuSetupExisting { instance, adapter, device, queue }) +} + +/// wgpu's default handler for errors outside an error scope panics ("Handling wgpu errors as +/// fatal by default"); this one logs. +pub fn install_error_handler(device: &wgpu::Device) { + device.on_uncaptured_error(std::sync::Arc::new(|e| log::error!("window: wgpu error: {e}"))); +} + +/// Run `build` inside validation, internal and out-of-memory error scopes: the first error is +/// returned instead of reaching the uncaptured-error handler. +pub fn check_pipeline(device: &wgpu::Device, build: impl FnOnce(&wgpu::Device)) -> Result<(), String> { + let oom = device.push_error_scope(wgpu::ErrorFilter::OutOfMemory); + let internal = device.push_error_scope(wgpu::ErrorFilter::Internal); + let validation = device.push_error_scope(wgpu::ErrorFilter::Validation); + build(device); + let errors = [pollster::block_on(validation.pop()), pollster::block_on(internal.pop()), pollster::block_on(oom.pop())]; + match errors.into_iter().flatten().next() { + Some(e) => Err(e.to_string()), + None => Ok(()), + } +} + +/// The start failed (error or panic) before the window's renderer came up: start the app again, +/// which then tries the next renderer. `false`: not relaunched (it came up, nothing written to +/// disk, or the retries are used up). +pub fn relaunch_after_failure(files: &Files) -> bool { + if !files.armed() { + return false; + } + let tries = std::env::var(RETRY_VAR).ok().and_then(|v| v.parse::().ok()).unwrap_or(0); + if tries >= MAX_RETRIES { + return false; + } + let Ok(exe) = std::env::current_exe() else { return false }; + log::warn!("window: the renderer failed to start; starting again with the next one"); + std::process::Command::new(exe) + .args(std::env::args_os().skip(1)) + .env(RETRY_VAR, (tries + 1).to_string()) + .spawn() + .map_err(|e| log::error!("window: could not start again: {e}")) + .is_ok() +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + fn dir(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("app-window-gpu-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + d + } + + /// The marker names the renderer being started; left behind, the next start reads it as the + /// failed one (and removes it); a good start removes it and remembers the renderer (Windows). + #[test] + fn marker_records_the_failed_renderer() { + let d = dir("marker"); + let f = Files::new(Some(d.clone())); + let dx = WindowRenderer::Wgpu(Backends::DX12); + f.arm(dx); + assert!(f.armed()); + assert_eq!(f.take_failed(), Some(dx)); + assert!(!f.armed()); + assert_eq!(f.take_failed(), None); + f.arm(WindowRenderer::Glow); + f.started(WindowRenderer::Glow); + assert!(!f.armed()); + assert_eq!(Files::read(f.remembered()), cfg!(windows).then_some(WindowRenderer::Glow)); + // nothing written without a settings folder + let none = Files::new(None); + none.arm(dx); + assert!(!none.armed() && !relaunch_after_failure(&none)); + let _ = std::fs::remove_dir_all(&d); + } + + /// The crash report: a pipeline that fails validation must come back as an error, never as + /// wgpu's default panic. Needs an adapter; skipped where there is none (CI without a GPU). + #[test] + fn pipeline_errors_are_captured_not_panics() { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { + backends: backend::window_backends(), + ..wgpu::InstanceDescriptor::new_without_display_handle() + }); + let Ok(adapter) = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())) else { + eprintln!("no GPU adapter: skipped"); + return; + }; + let Ok((device, _queue)) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())) else { + eprintln!("no GPU device: skipped"); + return; + }; + install_error_handler(&device); + // a shader that does not validate, then a pipeline using it (like a failed FXC compile) + let r = check_pipeline(&device, |d| { + let m = d.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("broken"), + source: wgpu::ShaderSource::Wgsl("@vertex fn vs_main() -> @builtin(position) vec4 { return undefined_thing; }".into()), + }); + let _ = d.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("egui_pipeline"), + layout: None, + vertex: wgpu::VertexState { module: &m, entry_point: Some("vs_main"), compilation_options: Default::default(), buffers: &[] }, + primitive: Default::default(), + depth_stencil: None, + multisample: Default::default(), + fragment: None, + multiview_mask: None, + cache: None, + }); + }); + assert!(r.is_err(), "{r:?}"); + // egui's own pipeline builds cleanly here + check_pipeline(&device, |d| { + drop(eframe::egui_wgpu::Renderer::new(d, wgpu::TextureFormat::Bgra8Unorm, eframe::egui_wgpu::RendererOptions::default())); + }) + .unwrap(); + // outside a scope: logged by our handler, not a panic + let _ = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: None, source: wgpu::ShaderSource::Wgsl("not wgsl".into()) }); + } +} diff --git a/crates/gpu/src/backend.rs b/crates/gpu/src/backend.rs index 81bfe719..bcbd4fd7 100644 --- a/crates/gpu/src/backend.rs +++ b/crates/gpu/src/backend.rs @@ -40,6 +40,8 @@ pub fn parse_backends(s: &str) -> Option { "" => None, "auto" | "default" => Some(BackendChoice::Auto), "off" | "none" | "cpu" | "0" | "false" | "no" => Some(BackendChoice::Off), + // the window's OpenGL renderer (see `WindowRenderer`): compute keeps its default + "glow" => Some(BackendChoice::Auto), _ => { let b = Backends::from_comma_list(&s); (!b.is_empty()).then_some(BackendChoice::Use(b)) @@ -211,6 +213,87 @@ pub fn read_init_marker(path: &std::path::Path) -> Option { Some(text.trim().to_string()) } +/// What draws the desktop window: egui-wgpu on one wgpu backend, or eframe's OpenGL renderer +/// (glow) as the last resort when no wgpu backend can build the window's pipeline. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum WindowRenderer { + /// egui-wgpu on exactly one backend. + Wgpu(Backends), + /// eframe's glow (OpenGL) renderer. + Glow, +} + +impl WindowRenderer { + /// The name used in `LIGHTCRAFT_GPU_BACKEND`, the remembered-renderer file and the logs. + pub fn name(self) -> &'static str { + match self { + WindowRenderer::Glow => "glow", + WindowRenderer::Wgpu(b) if b == Backends::DX12 => "dx12", + WindowRenderer::Wgpu(b) if b == Backends::VULKAN => "vulkan", + WindowRenderer::Wgpu(b) if b == Backends::METAL => "metal", + WindowRenderer::Wgpu(b) if b == Backends::GL => "gl", + WindowRenderer::Wgpu(_) => "wgpu", + } + } + + /// One renderer name (`dx12`, `vulkan`, `metal`, `gl`, `glow`); anything else (lists, `auto`, + /// `off`, unknown names) is `None`. + pub fn parse(s: &str) -> Option { + let s = s.trim().to_ascii_lowercase(); + if s == "glow" { + return Some(WindowRenderer::Glow); + } + match parse_backends(&s)? { + BackendChoice::Use(b) if [Backends::DX12, Backends::VULKAN, Backends::METAL, Backends::GL].contains(&b) => Some(WindowRenderer::Wgpu(b)), + _ => None, + } + } +} + +/// The renderer the environment forces for the window: `LIGHTCRAFT_GPU_BACKEND` (`app`), else +/// `WGPU_BACKEND` (`wgpu`), when it names exactly one renderer. +pub fn forced_window_renderer(app: Option<&str>, wgpu: Option<&str>) -> Option { + app.and_then(WindowRenderer::parse).or_else(|| wgpu.and_then(WindowRenderer::parse)) +} + +/// The order in which the window's renderers are tried on `os`: the forced one (environment) +/// first, then the one remembered from the last good start, then the platform chain — on Windows +/// DX12, Vulkan, GL, then glow. DX12 stays first: with Vulkan loaded, a broken Vulkan driver can +/// crash the process uncatchably (issue #136), so Vulkan is touched only after DX12 failed. +/// `failed` (the renderer the last start died in) goes last, as a last resort only. Backends not +/// in `compiled`, and glow without `glow`, are left out; the list is never empty. +pub fn window_renderer_order( + forced: Option, + remembered: Option, + failed: Option, + os: &str, + compiled: Backends, + glow: bool, +) -> Vec { + let platform = match os { + "windows" => vec![Backends::DX12, Backends::VULKAN, Backends::GL], + "macos" | "ios" => vec![Backends::METAL], + _ => vec![Backends::VULKAN, Backends::GL], + }; + let chain = platform.into_iter().map(WindowRenderer::Wgpu).chain([WindowRenderer::Glow]); + let usable = |r: &WindowRenderer| match r { + WindowRenderer::Glow => glow, + WindowRenderer::Wgpu(b) => !b.is_empty() && compiled.contains(*b), + }; + let not_failed = |r: &WindowRenderer| Some(*r) != failed; + let candidates = forced.into_iter().chain(remembered.filter(not_failed)).chain(chain.filter(not_failed)).chain(failed); + let mut out: Vec = Vec::new(); + for r in candidates { + if usable(&r) && !out.contains(&r) { + out.push(r); + } + } + if out.is_empty() { + out.push(WindowRenderer::Wgpu(default_window_backends(os) & compiled)); + } + out +} + #[cfg(test)] #[allow(clippy::unwrap_used)] mod tests { @@ -310,4 +393,44 @@ mod tests { assert_eq!(read_init_marker(&m), None); let _ = std::fs::remove_dir_all(&dir); } + + /// The window's renderer fallback: forced, remembered, the platform chain, the failed one last. + #[test] + fn window_renderer_fallback_order() { + use WindowRenderer::{Glow, Wgpu}; + let all = Backends::DX12 | Backends::VULKAN | Backends::GL | Backends::METAL; + let (dx, vk, gl) = (Wgpu(Backends::DX12), Wgpu(Backends::VULKAN), Wgpu(Backends::GL)); + assert_eq!(window_renderer_order(None, None, None, "windows", all, true), [dx, vk, gl, Glow]); + assert_eq!(window_renderer_order(None, None, None, "windows", all, false), [dx, vk, gl]); + // a build without GL + assert_eq!(window_renderer_order(None, None, None, "windows", Backends::DX12 | Backends::VULKAN, true), [dx, vk, Glow]); + // remembered from the last good start goes first + assert_eq!(window_renderer_order(None, Some(vk), None, "windows", all, true), [vk, dx, gl, Glow]); + // the environment wins over the remembered one + assert_eq!(window_renderer_order(Some(gl), Some(vk), None, "windows", all, true), [gl, vk, dx, Glow]); + assert_eq!(window_renderer_order(Some(Glow), None, None, "windows", all, true)[0], Glow); + // the last start died in DX12: tried last only (also when it was remembered) + assert_eq!(window_renderer_order(None, Some(dx), Some(dx), "windows", all, true), [vk, gl, Glow, dx]); + // a forced backend that is not in the build is skipped + assert_eq!(window_renderer_order(Some(Wgpu(Backends::METAL)), None, None, "windows", Backends::DX12, false), [dx]); + // never empty + assert!(!window_renderer_order(None, None, None, "windows", Backends::empty(), false).is_empty()); + assert_eq!(window_renderer_order(None, None, None, "macos", all, true), [Wgpu(Backends::METAL), Glow]); + } + + #[test] + fn window_renderer_names_round_trip() { + for n in ["dx12", "vulkan", "metal", "gl", "glow"] { + assert_eq!(WindowRenderer::parse(n).map(WindowRenderer::name), Some(n)); + } + assert_eq!(WindowRenderer::parse(" DX12 "), Some(WindowRenderer::Wgpu(Backends::DX12))); + for n in ["auto", "off", "vulkan,gl", "", "bogus"] { + assert_eq!(WindowRenderer::parse(n), None, "{n}"); + } + assert_eq!(forced_window_renderer(Some("glow"), Some("dx12")), Some(WindowRenderer::Glow)); + assert_eq!(forced_window_renderer(Some("auto"), Some("vulkan")), Some(WindowRenderer::Wgpu(Backends::VULKAN))); + assert_eq!(forced_window_renderer(None, None), None); + // `glow` only concerns the window: compute keeps its default, without a warning + assert_eq!(parse_backends("glow"), Some(BackendChoice::Auto)); + } } diff --git a/docs/gpu-pipeline.md b/docs/gpu-pipeline.md index 8d747235..690cfcfa 100644 --- a/docs/gpu-pipeline.md +++ b/docs/gpu-pipeline.md @@ -85,6 +85,16 @@ harmless: rendering is then on the CPU until the box is checked again. The senti compute device; a crash while the window's renderer starts is avoided by the backend defaults above or worked around with `LIGHTCRAFT_GPU_BACKEND`. +**The window's renderer and its fallback (Windows).** Before the window opens, each renderer is probed in +order: DX12, Vulkan, GL, then eframe's OpenGL renderer (glow). A probe creates a device and builds egui's +pipeline inside wgpu error scopes, so a driver that cannot compile egui's shaders ("The parameter is +incorrect. (0x80070057)" in `egui_pipeline`) is logged and skipped instead of killing the app. The renderer +that worked is remembered in the settings folder (`window-renderer`); a start that dies before the window is up +leaves `window-init.marker`, and the next start (relaunched automatically, at most 4 times) tries that +renderer last. `LIGHTCRAFT_GPU_BACKEND=dx12 | vulkan | gl | glow` (or `WGPU_BACKEND`) forces one; DX12 stays +first because a broken Vulkan driver can crash the process uncatchably (#136). Compute keeps its own +device and falls back to the CPU as before. + **Troubleshooting a crash at startup (Windows).** Start LightCraft from a `.cmd` file or a terminal with `set LIGHTCRAFT_GPU_BACKEND=dx12` (the default since #136), or `=off` to keep the GPU out of rendering; `set VK_LOADER_DRIVERS_DISABLE=*igvk64*` (Vulkan loader) hides a specific Vulkan driver