diff --git a/apps/lightcraft-cli/tests/cli.rs b/apps/lightcraft-cli/tests/cli.rs index 5a4b7aab..9d41873a 100644 --- a/apps/lightcraft-cli/tests/cli.rs +++ b/apps/lightcraft-cli/tests/cli.rs @@ -183,6 +183,42 @@ fn snapshot_subcommand_renders_the_ui_headlessly() { assert_eq!((d.width, d.height), (640, 480)); } +/// Issue #136: with the GPU switched off from the environment the UI starts and renders on the CPU, +/// without creating a GPU device (no driver is loaded), and says why. +#[test] +fn snapshot_starts_without_a_gpu() { + let script = tmp("nogpu.jsonl"); + std::fs::write( + &script, + format!( + "{}\n{}\n{}\n", + json!({"method": "ui.set", "params": {"view": "photoGrid"}}), + json!({"method": "engine.execute", "params": {"command": "app.gpu"}}), + json!({"method": "ui.screenshot"}), + ), + ) + .unwrap(); + for (var, value, reason) in [("LIGHTCRAFT_GPU", "0", "LIGHTCRAFT_GPU=0"), ("LIGHTCRAFT_GPU_BACKEND", "off", "LIGHTCRAFT_GPU_BACKEND=off")] { + let out = tmp(&format!("nogpu-{var}.png")); + let o = Command::new(BIN) + .env_remove("LIGHTCRAFT_GPU") + .env_remove("LIGHTCRAFT_GPU_BACKEND") + .env(var, value) + .args(["snapshot", "--demo", "--script", script.to_str().unwrap(), "-o", out.to_str().unwrap(), "--size", "480x320"]) + .output() + .unwrap(); + assert!(o.status.success(), "{var}: {}", String::from_utf8_lossy(&o.stderr)); + let replies: Vec = String::from_utf8_lossy(&o.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect(); + assert!(replies.iter().all(|r| r["ok"] == true), "{replies:?}"); + let gpu = &replies[1]["result"]; + assert_eq!(gpu["available"], false, "{var}: {gpu}"); + assert_eq!(gpu["adapter"], Value::Null, "{var}: no device was created: {gpu}"); + assert!(gpu["reason"].as_str().is_some_and(|r| r.contains(reason)), "{var}: {gpu}"); + let d = lightcraft_codecs::decode(&std::fs::read(&out).unwrap(), Default::default()).unwrap(); + assert_eq!((d.width, d.height), (480, 320)); + } +} + fn run_cli(args: &[&str], stdin: Option<&str>) -> (bool, Vec, String) { let mut child = Command::new(BIN).arg("run").args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap(); if let Some(text) = stdin { diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index 49fd24ff..2bdfa16f 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -71,6 +71,43 @@ impl eframe::App for App { } } +/// The window's renderer (egui-wgpu): eframe's defaults, with LightCraft's backend choice — DX12 +/// 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). +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(); + } + c +} + +/// Written while the GPU compute device is created, removed once that returned +/// (`lightcraft_gpu::backend::set_init_marker`). Not with `LIGHTCRAFT_NO_PREFS` (tests, scripts). +fn gpu_marker_path() -> Option { + if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() { + return None; + } + config_dir().map(|d| d.join("gpu-init.marker")) +} + +/// Arm the GPU crash sentinel; if the previous launch left it behind (it died inside the GPU +/// driver while creating the device), the notice to show — GPU rendering then starts off. +fn gpu_crash_check() -> Option { + let marker = gpu_marker_path(); + let left = marker.as_deref().and_then(lightcraft_engine::gpu::backend::take_init_marker); + lightcraft_engine::gpu::backend::set_init_marker(marker); + left.map(|what| gpu_crash_notice(&what)) +} + +fn gpu_crash_notice(what: &str) -> String { + format!( + "LightCraft closed unexpectedly while starting the GPU last time ({what}), so GPU rendering is now off and photos render on the CPU. \ + To try the GPU again, turn on Settings ▸ Performance ▸ Use the GPU for rendering; to try another graphics backend, start LightCraft with \ + LIGHTCRAFT_GPU_BACKEND=dx12, vulkan or off (see docs/gpu-pipeline.md → Troubleshooting)." + ) +} + fn config_dir() -> Option { if cfg!(target_os = "macos") { std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join("Library/Application Support/LightCraft")) @@ -374,6 +411,10 @@ OPTIONS: --control PORT serve the JSON-lines control channel on 127.0.0.1:PORT (env LIGHTCRAFT_CONTROL_PORT; see docs/control-protocol.md) --version, --help + +ENVIRONMENT: + LIGHTCRAFT_GPU_BACKEND=dx12|vulkan|metal|auto|off graphics backend (default: DX12 on Windows, Metal on macOS, + Vulkan on Linux; off = render on the CPU); else WGPU_BACKEND. LIGHTCRAFT_GPU=0: CPU rendering. "; fn main() -> eframe::Result { @@ -416,6 +457,11 @@ fn main() -> eframe::Result { .map(Into::into) }); let library_dir = library_dir.or_else(lightcraft_engine::library::default_dir); + // GPU compute: off if the preference says so, or if the last launch died creating the device + // (issue #136) — before anything can create it + let gpu_crash = gpu_crash_check(); + let gpu_on = prefs.as_ref().is_none_or(|u| u.settings.gpu) && gpu_crash.is_none(); + lightcraft_engine::gpu::set_enabled(gpu_on); let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_title("LightCraft") @@ -428,6 +474,7 @@ 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(), ..Default::default() }; eframe::run_native( @@ -449,6 +496,11 @@ fn main() -> eframe::Result { 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); @@ -506,6 +558,35 @@ mod tests { let _ = std::fs::remove_dir_all(&d); } + /// Issue #136: a GPU init marker left by a crashed launch is reported once and removed. + #[test] + fn gpu_crash_marker_is_reported_once() { + let d = dir("gpu-marker"); + let m = d.join("gpu-init.marker"); + assert_eq!(lightcraft_engine::gpu::backend::take_init_marker(&m), None); + std::fs::write(&m, "GPU device creation started (backends VULKAN | DX12)").unwrap(); + let what = lightcraft_engine::gpu::backend::take_init_marker(&m).unwrap(); + let notice = gpu_crash_notice(&what); + assert!(notice.contains("GPU rendering is now off") && notice.contains("Use the GPU for rendering"), "{notice}"); + assert!(!m.exists()); + let _ = std::fs::remove_dir_all(&d); + } + + /// Issue #136: Windows windows render with DX12 alone (no Vulkan driver loaded) by default. + #[test] + fn window_backends_follow_the_platform_default() { + let eframe::egui_wgpu::WgpuSetup::CreateNew(n) = window_wgpu_options().wgpu_setup else { panic!("expected CreateNew") }; + let b = n.instance_descriptor.backends; + if std::env::var_os("LIGHTCRAFT_GPU_BACKEND").is_none() && std::env::var_os("WGPU_BACKEND").is_none() { + if cfg!(windows) { + assert_eq!(b, eframe::wgpu::Backends::DX12); + } else if cfg!(target_os = "macos") { + assert_eq!(b, eframe::wgpu::Backends::METAL); + } + } + assert!(!b.is_empty()); + } + #[test] fn ui_json_is_written_atomically() { let d = dir("atomic"); diff --git a/crates/gpu/src/backend.rs b/crates/gpu/src/backend.rs new file mode 100644 index 00000000..5c50ca04 --- /dev/null +++ b/crates/gpu/src/backend.rs @@ -0,0 +1,234 @@ +//! Which wgpu backends LightCraft lets wgpu use — for its compute device and for the desktop +//! window (eframe) — and the crash sentinel around device creation (issue #136). +//! +//! wgpu loads the system driver of *every* backend in the instance's set while it enumerates +//! adapters, even when it then picks another one: with Vulkan in the set, a broken Vulkan driver +//! can crash the process (an access violation inside the driver DLL, which nothing can catch) +//! although DX12 would have been used. So Windows defaults to DX12 alone. macOS uses Metal, Linux +//! Vulkan (plus GL for the window when the build has it). +//! +//! Overrides, read once per process: +//! - `LIGHTCRAFT_GPU_BACKEND` = `dx12` | `vulkan` | `metal` | `gl` (comma lists allowed), `auto` +//! (the platform default), or `off` (no GPU compute; the window still needs one backend and +//! keeps the platform default); +//! - else `WGPU_BACKEND` (wgpu's own variable, same names) for both the window and compute. +//! +//! Backends this build doesn't contain (e.g. `gl`: the GL backend isn't compiled in) are ignored +//! with a warning and the platform default is used. + +use std::path::PathBuf; +use std::sync::Mutex; + +pub use wgpu::Backends; + +/// What the environment asks for. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BackendChoice { + /// The platform default. + Auto, + /// No GPU compute. + Off, + /// These backends. + Use(Backends), +} + +/// Parse a backend list (`"dx12"`, `"vulkan,gl"`, `"auto"`, `"off"`…). `None`: nothing usable in +/// it (empty or unknown names). +pub fn parse_backends(s: &str) -> Option { + let s = s.trim().to_ascii_lowercase(); + match s.as_str() { + "" => None, + "auto" | "default" => Some(BackendChoice::Auto), + "off" | "none" | "cpu" | "0" | "false" | "no" => Some(BackendChoice::Off), + _ => { + let b = Backends::from_comma_list(&s); + (!b.is_empty()).then_some(BackendChoice::Use(b)) + } + } +} + +/// The choice from `LIGHTCRAFT_GPU_BACKEND` (`lightcraft`) or else `WGPU_BACKEND` (`wgpu`): the +/// first one that parses wins; an unknown value is skipped (with a warning). +pub fn choose(lightcraft: Option<&str>, wgpu: Option<&str>) -> BackendChoice { + for (name, v) in [("LIGHTCRAFT_GPU_BACKEND", lightcraft), ("WGPU_BACKEND", wgpu)] { + let Some(v) = v else { continue }; + match parse_backends(v) { + // WGPU_BACKEND is wgpu's variable: it selects backends, it never turns the GPU off + Some(BackendChoice::Off) if name == "WGPU_BACKEND" => log::warn!("gpu: WGPU_BACKEND={v} ignored (use LIGHTCRAFT_GPU_BACKEND=off)"), + Some(c) => return c, + None => log::warn!("gpu: {name}={v} names no known backend; ignored"), + } + } + BackendChoice::Auto +} + +/// The backends compute devices use when nothing is asked for on `os` (`std::env::consts::OS`). +pub fn default_compute_backends(os: &str) -> Backends { + match os { + // never touch the Vulkan driver unless asked: see the module docs (issue #136) + "windows" => Backends::DX12, + "macos" | "ios" => Backends::METAL, + _ => Backends::PRIMARY, + } +} + +/// The backends the window uses when nothing is asked for on `os`. +pub fn default_window_backends(os: &str) -> Backends { + match os { + "windows" => Backends::DX12, + "macos" | "ios" => Backends::METAL, + // eframe's default (GL only when the build has it) + _ => Backends::PRIMARY | Backends::GL, + } +} + +/// Resolve `choice` against the backends this build contains (`compiled`): `None` = GPU off. +/// Asked-for backends that aren't compiled in fall back to `default`. +pub fn resolve(choice: BackendChoice, default: Backends, compiled: Backends) -> Option { + match choice { + BackendChoice::Off => None, + BackendChoice::Auto => Some(default), + BackendChoice::Use(b) if b.intersects(compiled) => Some(b & compiled), + BackendChoice::Use(b) => { + log::warn!("gpu: backend {b:?} is not in this build ({compiled:?}); using {default:?}"); + Some(default) + } + } +} + +fn env_choice() -> BackendChoice { + static C: std::sync::OnceLock = std::sync::OnceLock::new(); + *C.get_or_init(|| { + let lc = std::env::var("LIGHTCRAFT_GPU_BACKEND").ok(); + let wg = std::env::var("WGPU_BACKEND").ok(); + choose(lc.as_deref(), wg.as_deref()) + }) +} + +/// The backends of the compute device (`None`: `LIGHTCRAFT_GPU_BACKEND=off`). +pub fn compute_backends() -> Option { + resolve(env_choice(), default_compute_backends(std::env::consts::OS), wgpu::Instance::enabled_backend_features()) +} + +/// The backends for the desktop window's renderer (eframe/egui-wgpu). The window always needs +/// one: `off` keeps the platform default. +pub fn window_backends() -> Backends { + let default = default_window_backends(std::env::consts::OS); + let choice = match env_choice() { + BackendChoice::Off => BackendChoice::Auto, + c => c, + }; + resolve(choice, default, wgpu::Instance::enabled_backend_features()).unwrap_or(default) +} + +/// `LIGHTCRAFT_GPU_BACKEND=off`. +pub(crate) fn env_off() -> bool { + env_choice() == BackendChoice::Off +} + +static MARKER: Mutex> = Mutex::new(None); + +/// Write this file before the compute device is created and remove it once creation returned +/// (successfully or not). A file still there at the next launch means the process died inside +/// the driver: see [`take_init_marker`]. `None` (the default) writes nothing. +pub fn set_init_marker(path: Option) { + *MARKER.lock().unwrap_or_else(|e| e.into_inner()) = path; +} + +/// Run device creation `f` between writing and removing the init marker (if one is set). +pub(crate) fn with_init_marker(backends: Backends, f: impl FnOnce() -> T) -> T { + let marker = MARKER.lock().unwrap_or_else(|e| e.into_inner()).clone(); + if let Some(m) = &marker { + if let Some(d) = m.parent() { + let _ = std::fs::create_dir_all(d); + } + let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); + let _ = std::fs::write(m, format!("GPU device creation started (backends {backends:?}, unix time {secs})\n")); + } + let r = f(); + if let Some(m) = &marker { + let _ = std::fs::remove_file(m); + } + r +} + +/// If the init marker `path` is present — the last process died while creating the GPU device — +/// remove it and return what it recorded. +pub fn take_init_marker(path: &std::path::Path) -> Option { + let text = std::fs::read_to_string(path).ok()?; + let _ = std::fs::remove_file(path); + Some(text.trim().to_string()) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + #[test] + fn parses_backend_names() { + assert_eq!(parse_backends("dx12"), Some(BackendChoice::Use(Backends::DX12))); + assert_eq!(parse_backends(" D3D12 "), Some(BackendChoice::Use(Backends::DX12))); + assert_eq!(parse_backends("vulkan"), Some(BackendChoice::Use(Backends::VULKAN))); + assert_eq!(parse_backends("vk,gl"), Some(BackendChoice::Use(Backends::VULKAN | Backends::GL))); + assert_eq!(parse_backends("metal"), Some(BackendChoice::Use(Backends::METAL))); + assert_eq!(parse_backends("auto"), Some(BackendChoice::Auto)); + for off in ["off", "OFF", "none", "cpu", "0"] { + assert_eq!(parse_backends(off), Some(BackendChoice::Off), "{off}"); + } + assert_eq!(parse_backends(""), None); + assert_eq!(parse_backends("directx9"), None); + } + + #[test] + fn lightcraft_variable_wins_over_wgpu_backend() { + assert_eq!(choose(None, None), BackendChoice::Auto); + assert_eq!(choose(None, Some("dx12")), BackendChoice::Use(Backends::DX12)); + assert_eq!(choose(Some("vulkan"), Some("dx12")), BackendChoice::Use(Backends::VULKAN)); + assert_eq!(choose(Some("off"), Some("dx12")), BackendChoice::Off); + // unknown LightCraft value: WGPU_BACKEND still applies + assert_eq!(choose(Some("bogus"), Some("gl")), BackendChoice::Use(Backends::GL)); + // WGPU_BACKEND never turns the GPU off + assert_eq!(choose(None, Some("off")), BackendChoice::Auto); + } + + /// Issue #136: Windows must not load the Vulkan driver unless asked to. + #[test] + fn windows_defaults_to_dx12_only() { + assert_eq!(default_compute_backends("windows"), Backends::DX12); + assert_eq!(default_window_backends("windows"), Backends::DX12); + assert!(!default_window_backends("windows").contains(Backends::VULKAN)); + assert_eq!(default_compute_backends("macos"), Backends::METAL); + assert!(default_compute_backends("linux").contains(Backends::VULKAN)); + assert!(default_window_backends("linux").contains(Backends::VULKAN | Backends::GL)); + } + + #[test] + fn resolves_against_the_build() { + let compiled = Backends::DX12 | Backends::VULKAN; + let def = Backends::DX12; + assert_eq!(resolve(BackendChoice::Auto, def, compiled), Some(def)); + assert_eq!(resolve(BackendChoice::Off, def, compiled), None); + assert_eq!(resolve(BackendChoice::Use(Backends::VULKAN), def, compiled), Some(Backends::VULKAN)); + assert_eq!(resolve(BackendChoice::Use(Backends::VULKAN | Backends::GL), def, compiled), Some(Backends::VULKAN)); + // GL isn't compiled in: the default, not an empty set (no adapter at all) + assert_eq!(resolve(BackendChoice::Use(Backends::GL), def, compiled), Some(def)); + } + + #[test] + fn init_marker_is_written_during_creation_and_removed_after() { + let dir = std::env::temp_dir().join(format!("lc-gpu-marker-{}", std::process::id())); + let m = dir.join("gpu-init.marker"); + let _ = std::fs::remove_dir_all(&dir); + set_init_marker(Some(m.clone())); + let seen = with_init_marker(Backends::DX12, || m.exists()); + set_init_marker(None); + assert!(seen, "the marker exists while the device is created"); + assert!(!m.exists(), "and is removed afterwards"); + // a marker left behind by a crashed process is reported once + std::fs::write(&m, "GPU device creation started (backends DX12)").unwrap(); + assert!(take_init_marker(&m).unwrap().contains("DX12")); + assert_eq!(take_init_marker(&m), None); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/gpu/src/ctx.rs b/crates/gpu/src/ctx.rs index 0659bddf..0d69914d 100644 --- a/crates/gpu/src/ctx.rs +++ b/crates/gpu/src/ctx.rs @@ -276,14 +276,15 @@ fn module_source(m: &Module, consts: &str) -> String { impl Gpu { /// Create a device on the best available adapter (no software fallback), or why there is none. - pub fn new() -> Result { + pub fn new(backends: wgpu::Backends) -> Result { let mut desc = wgpu::InstanceDescriptor::new_without_display_handle(); - desc.backends = wgpu::Backends::PRIMARY; + // only these drivers are loaded (`crate::backend`: never Vulkan on Windows unless asked) + desc.backends = backends; let instance = wgpu::Instance::new(desc); let adapter = pollster::block_on( instance.request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, ..Default::default() }), ) - .map_err(|e| format!("no GPU adapter ({e})"))?; + .map_err(|e| format!("no GPU adapter among {backends:?} ({e})"))?; let info = adapter.get_info(); if info.device_type == wgpu::DeviceType::Cpu { log::info!("gpu: only a software adapter ({}), using the CPU pipeline", info.name); diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 449b1e5a..70d6246a 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -6,8 +6,11 @@ //! same settings on both and bound the difference in 8-bit sRGB. Stages without a kernel run on the //! CPU inside the same render (per-stage hybrid); see `docs/gpu-pipeline.md`. //! -//! Use [`render`]: it returns `None` when the GPU is unavailable, disabled (`LIGHTCRAFT_GPU=0` or -//! [`set_enabled`]), the render does not fit the device, or the device reported an error, ran out +//! Which backends wgpu may load (DX12 only on Windows; `LIGHTCRAFT_GPU_BACKEND`, `WGPU_BACKEND`) and +//! the crash sentinel around device creation: [`backend`] (issue #136). +//! +//! Use [`render`]: it returns `None` when the GPU is unavailable, disabled (`LIGHTCRAFT_GPU=0`, +//! `LIGHTCRAFT_GPU_BACKEND=off` or [`set_enabled`]), the render does not fit the device, or the device reported an error, ran out //! of memory or returned an incomplete image — callers then render on the CPU. [`unavailable_reason`] //! and [`last_fallback`] say why (`ui.inspect` → `perf.gpuReason` / `perf.gpuFallback`). //! The browser build has no GPU path yet (WebGPU device creation is asynchronous): everything here @@ -22,6 +25,8 @@ use lightcraft_develop::DevelopSettings; use lightcraft_pipeline::{RenderRequest, Rendered, SourceInfo, StageCache}; use lightcraft_raster::Rgb32f; +#[cfg(not(target_arch = "wasm32"))] +pub mod backend; #[cfg(not(target_arch = "wasm32"))] mod ctx; #[cfg(not(target_arch = "wasm32"))] @@ -61,6 +66,10 @@ fn record_fallback(reason: String) { /// Never blocks. pub fn unavailable_reason() -> Option { if env_disabled() { + #[cfg(not(target_arch = "wasm32"))] + if backend::env_off() { + return Some("disabled by LIGHTCRAFT_GPU_BACKEND=off".into()); + } return Some("disabled by LIGHTCRAFT_GPU=0".into()); } if !ENABLED.load(Ordering::Relaxed) { @@ -136,9 +145,15 @@ pub fn enabled() -> bool { ENABLED.load(Ordering::Relaxed) && !BROKEN.load(Ordering::Relaxed) && !env_disabled() } +/// `LIGHTCRAFT_GPU=0` (or `LIGHTCRAFT_GPU_BACKEND=off`): no GPU for the whole process. fn env_disabled() -> bool { static OFF: std::sync::OnceLock = std::sync::OnceLock::new(); - *OFF.get_or_init(|| std::env::var("LIGHTCRAFT_GPU").is_ok_and(|v| matches!(v.trim(), "0" | "off" | "false" | "no"))) + *OFF.get_or_init(|| { + let off = std::env::var("LIGHTCRAFT_GPU").is_ok_and(|v| matches!(v.trim(), "0" | "off" | "false" | "no")); + #[cfg(not(target_arch = "wasm32"))] + let off = off || backend::env_off(); + off + }) } #[cfg(not(target_arch = "wasm32"))] @@ -146,10 +161,18 @@ static GPU: std::sync::OnceLock> = std::sync::OnceLock: #[cfg(not(target_arch = "wasm32"))] pub(crate) fn device() -> Option<&'static ctx::Gpu> { - if env_disabled() { - return None; + // turned off (preference, environment): don't even load the driver (issue #136) + if env_disabled() || !ENABLED.load(Ordering::Relaxed) { + return existing_device(); } - GPU.get_or_init(|| std::panic::catch_unwind(ctx::Gpu::new).unwrap_or_else(|_| Err("device creation panicked".into()))).as_ref().ok() + GPU.get_or_init(|| { + let Some(backends) = backend::compute_backends() else { return Err("disabled by LIGHTCRAFT_GPU_BACKEND=off".into()) }; + backend::with_init_marker(backends, || { + std::panic::catch_unwind(|| ctx::Gpu::new(backends)).unwrap_or_else(|_| Err("device creation panicked".into())) + }) + }) + .as_ref() + .ok() } /// The device if it has been created (never creates it). @@ -158,11 +181,12 @@ pub(crate) fn existing_device() -> Option<&'static ctx::Gpu> { GPU.get().and_then(|g| g.as_ref().ok()) } -/// Create the device and compile the kernels on a background thread now (app start), so the -/// first render doesn't wait ~0.3–0.4 s for it and the UI thread never does. +/// Create the device and compile the kernels on a background thread now (once the window is up), +/// so the first render doesn't wait ~0.3–0.4 s for it and the UI thread never does. Does nothing +/// while GPU rendering is turned off. pub fn warm_up() { #[cfg(not(target_arch = "wasm32"))] - if !env_disabled() && GPU.get().is_none() { + if !env_disabled() && ENABLED.load(Ordering::Relaxed) && GPU.get().is_none() { let _ = std::thread::Builder::new().name("lc-gpu-init".into()).spawn(|| { let _ = device(); }); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4f489f88..8addc61b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -201,8 +201,6 @@ pub struct LightcraftApp { impl LightcraftApp { pub fn new(session: Session, services: Services) -> Self { - // GPU device + kernels off the UI thread, before the first photo is opened - lightcraft_engine::gpu::warm_up(); Self { session, ui: UiState::default(), @@ -597,6 +595,11 @@ impl LightcraftApp { if self.gpu_applied != Some(self.ui.settings.gpu) { self.gpu_applied = Some(self.ui.settings.gpu); let _ = self.session.execute("app.gpu", &serde_json::json!({"enabled": self.ui.settings.gpu})); + // GPU device + kernels off the UI thread, once the window is up and only when GPU + // rendering is on: a broken driver must not keep the window from appearing (issue #136) + if self.ui.settings.gpu { + lightcraft_engine::gpu::warm_up(); + } } let mb = self.ui.settings.memory_mb; // automatic at startup: leave the engine's default alone diff --git a/docs/gpu-pipeline.md b/docs/gpu-pipeline.md index e82ff01c..7ea22d06 100644 --- a/docs/gpu-pipeline.md +++ b/docs/gpu-pipeline.md @@ -4,12 +4,59 @@ The CPU pipeline (`crates/pipeline`) is the reference ("oracle"). `lightcraft-gp evaluates the same stages with wgpu compute shaders (WGSL) on Metal / Vulkan / DX12. Everything is pure Rust (wgpu, naga); the drivers are the system's. No GL backend is compiled in. +## Backends, environment variables and troubleshooting (issue #136) +wgpu loads the driver of **every** backend in an instance's set while it enumerates adapters — even +when it then picks another one. A Vulkan driver that crashes there (issue #136: an access violation +in Intel's `igvk64.dll` on a UHD 630 under Windows 11) takes the process down before any window +appears, and a native crash cannot be caught. So LightCraft only lets wgpu touch the backends it +means to use (`lightcraft_gpu::backend`), for its compute device *and* for the desktop window +(eframe/egui-wgpu, `window_wgpu_options` in `apps/lightcraft/src/main.rs`): + +| platform | default (window and compute) | +|---|---| +| Windows | DX12 only — the Vulkan driver is never loaded unless asked for | +| macOS | Metal | +| Linux / BSD | Vulkan (the window also lists GL, eframe's default; no GL backend is compiled in) | + +Overrides (read once at launch): +- `LIGHTCRAFT_GPU_BACKEND=dx12 | vulkan | metal | gl | auto | off` (comma lists allowed, e.g. + `vulkan,dx12`): the backends for both the window and GPU rendering; `off` turns GPU rendering off + (CPU pipeline) — the window still needs a backend and keeps the platform default. A backend this + build doesn't contain (`gl`) is ignored with a warning. +- else `WGPU_BACKEND` (wgpu's own variable, same names) — before #136 only the window honoured it, + the compute device always used Vulkan + DX12 (+ Metal). +- `LIGHTCRAFT_GPU=0`: GPU rendering off for the process (the window is unaffected). + +**Off the startup path.** The compute device is created on a background thread once the window is +up (`gpu::warm_up` from the first frame's settings), and never while GPU rendering is off: with +Settings ▸ Performance ▸ *Use the GPU for rendering* unchecked (applied before the window opens), +`LIGHTCRAFT_GPU=0` or `LIGHTCRAFT_GPU_BACKEND=off`, no GPU driver is loaded for rendering at all. + +**Crash sentinel.** The desktop app writes `gpu-init.marker` into its settings folder (next to +`ui.json`: `%APPDATA%\LightCraft`, `~/Library/Application Support/LightCraft`, +`~/.config/lightcraft`) just before the compute device is created and removes it as soon as creation +returns, successfully or not. If the marker is still there at the next launch, the process died inside +the driver: LightCraft starts with GPU rendering off (the preference is saved unchecked), removes the +marker and says so in a notice. Checking *Use the GPU for rendering* again tries the GPU once more +(and re-arms the sentinel). Not with `LIGHTCRAFT_NO_PREFS` (tests, scripts). Killing the app during +the ~0.3 s of device creation, or two instances starting at the same moment, can trip it falsely — +harmless: rendering is then on the CPU until the box is checked again. The sentinel only covers the +compute device; a crash while the window's renderer starts is avoided by the backend defaults above +or worked around with `LIGHTCRAFT_GPU_BACKEND`. + +**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 +from every program started with it. Help ▸ System Info and `app.gpu` show the adapter and backend in +use (e.g. `Intel(R) UHD Graphics 630 (Dx12)`). + ## Where it is used - `lightcraft_engine::media::develop` (called by every `RenderJob`): loupe / before / compare views, `render_now` (CLI, MCP, control channel renders) and exports render on the GPU when one is available; grid/filmstrip thumbnails (many small jobs in parallel) stay on the CPU. - Anything the GPU path cannot do returns `None` and the CPU renders instead: no adapter (CI - machines, software-only adapters), `LIGHTCRAFT_GPU=0` (whole process), the `app.gpu {enabled}` + machines, software-only adapters), `LIGHTCRAFT_GPU=0` or `LIGHTCRAFT_GPU_BACKEND=off` (whole + process; see [Backends](#backends-environment-variables-and-troubleshooting-issue-136)), the `app.gpu {enabled}` command (runtime preference; `ui.inspect` → `perf.gpu` shows the adapter), a buffer larger than the device's storage-buffer limit, or a render the device did not complete correctly (see [Limits, failures and the CPU fallback](#limits-failures-and-the-cpu-fallback)). diff --git a/docs/parity.md b/docs/parity.md index 4eb48c4e..a5747e72 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -495,7 +495,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-BEHAV-BG-TASKS | Background tasks | P0 | ✅ | `crates/preview/src/lib.rs` (`JobPool`), `crates/ui-egui/src/export_task.rs`, `crates/ui-egui/src/import.rs` | renders off the UI thread; exports started from the UI run on a worker thread with a progress panel and Cancel (`ui.inspect` → `export`); imports show a progress window. No combined activity centre | | LR-BEHAV-OFFLINE | Offline editing | P1 | ✅ | | local-first: everything works offline | | LR-BEHAV-WEB-SAFETY | Library safety in the browser build | P1 | 🟡 | `cmd:file.backupLibrary`, `cmd:file.restoreLibrary`, `apps/lightcraft-web/src/safety.rs`, `apps/lightcraft-web/src/backup.rs`, `apps/lightcraft-web/src/files.rs` | web only (experimental): Back Up Library (zip of the catalog and every stored original) and a non-destructive Restore; failed saves show as unsaved and are retried; a photo that can't be stored isn't added; one tab per library (Web Locks); notices for storage that isn't persistent and for a library that can't open; `?reset` asks first; a panic shows a message. Missing: `?safe` start, deleting originals of removed photos, zip64 (backups > 4 GB) | -| LR-BEHAV-GPU | GPU acceleration | P0 | ✅ | `crates/gpu/src/render.rs`, `cmd:app.gpu`, `docs/gpu-pipeline.md` | CPU fallback per render on device limits, out of memory, device errors / loss, incomplete or black results (`perf.gpuReason`, `perf.gpuFallback`); short submissions for slow iGPUs; no tiling beyond the buffer limit (CPU) | +| LR-BEHAV-GPU | GPU acceleration | P0 | ✅ | `crates/gpu/src/render.rs`, `cmd:app.gpu`, `docs/gpu-pipeline.md` | CPU fallback per render on device limits, out of memory, device errors / loss, incomplete or black results (`perf.gpuReason`, `perf.gpuFallback`); short submissions for slow iGPUs; backend choice (DX12 only on Windows, `LIGHTCRAFT_GPU_BACKEND` / `WGPU_BACKEND`), device created after the window opens, crash sentinel (issue #136); no tiling beyond the buffer limit (CPU) | | LR-BEHAV-RENDER-FIDELITY | Rendering matches Lightroom | P1 | 🟡 | `crates/pipeline/src/lib.rs`, `crates/pipeline/src/tone.rs` | every slider exists and works, but the character of the result (tone curve shape, highlight recovery, texture / clarity / dehaze, noise reduction, sharpening, default look) is tuned by eye; there is no systematic side-by-side comparison against Lightroom on the same CC0 raws. Needs a fidelity suite: Lightroom reference renders kept only in the local `plan/` (never committed), compared per slider and preset with a perceptual metric | | LR-BEHAV-DRAGDROP | Drag and drop | P1 | ✅ | `crates/ui-egui/src/lib.rs`, `crates/ui-egui/src/panels/grid.rs` (`drag_feedback`), `crates/ui-egui/src/panels/left.rs` (`drop_target`) | files → app (import); grid photos → an album row (adds the selection, with a count badge while dragging) | | LR-BEHAV-TOAST | Toast notifications | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` | | diff --git a/docs/whats-new.md b/docs/whats-new.md index 5ae3fd53..df9ef881 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -10,6 +10,12 @@ - 23 new built-in presets: Portrait, Landscape, Urban, Food, Seasons, Vintage and B&W toners. ### Reliability +- LightCraft no longer crashes at launch on Windows PCs whose Vulkan driver is broken (issue #136, e.g. some Intel UHD + 630 drivers): on Windows the window and GPU rendering use DirectX 12 only and never load the Vulkan driver unless + asked to. `LIGHTCRAFT_GPU_BACKEND=dx12 | vulkan | metal | off` (or wgpu's `WGPU_BACKEND`, which GPU rendering + ignored before) chooses the graphics backend; `off` renders on the CPU. The GPU now starts after the window + is up, and only when Settings ▸ Performance ▸ Use the GPU for rendering is on; if LightCraft ever dies while + starting the GPU, the next launch starts with GPU rendering off and says how to turn it back on. - Exports and renders never write over a photo's original (issue #93): exporting into the photo's own folder with the same name and "Overwrite" (or Export with Previous repeating it), an exact output path from the control channel or MCP, a merge preview path or `lightcraft-cli render IMG.jpg -o IMG.jpg` is refused with a clear