diff --git a/apps/effectcraft-cli/tests/cli.rs b/apps/effectcraft-cli/tests/cli.rs index 30a0271b..f1d28c5e 100644 --- a/apps/effectcraft-cli/tests/cli.rs +++ b/apps/effectcraft-cli/tests/cli.rs @@ -593,3 +593,33 @@ solid.property("ADBE Transform Group").property("ADBE Opacity").addToMotionGraph assert_eq!(reply["error"], "command `renderQueue.add` is not available right now: select or open a composition", "{reply}"); let _ = std::fs::remove_dir_all(&root); } + +/// `WGPU_ADAPTER_NAME` that matches nothing is a clean `--gpu` error listing the adapters +/// found (wgpu's own helper panics). Read once per process, hence a subprocess. +#[test] +fn gpu_adapter_name_mismatch_is_a_clean_error() { + let out = bin() + .args(["render-frame", "--gpu", "--max-side", "64", "--out", tmp("nomatch.png").to_str().unwrap(), "--json"]) + .env("WGPU_ADAPTER_NAME", "NoSuchAdapter-xyz") + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&out.stdout); + assert_eq!(out.status.code(), Some(1), "stdout: {stdout}\nstderr: {}", String::from_utf8_lossy(&out.stderr)); + let v: Value = serde_json::from_str(stdout.trim()).unwrap(); + let e = v["error"].as_str().unwrap(); + assert!(e.contains("WGPU_ADAPTER_NAME=\"NoSuchAdapter-xyz\""), "{e}"); + assert!(e.contains("available: [") || e.contains("no adapters on the enabled backends"), "{e}"); +} + +/// An exported-but-empty `WGPU_ADAPTER_NAME` behaves like unset: it never produces the +/// adapter-name error (whether a GPU exists on this machine is not asserted). +#[test] +fn gpu_adapter_name_empty_is_unset() { + let out = bin() + .args(["render-frame", "--gpu", "--max-side", "64", "--out", tmp("emptyname.png").to_str().unwrap(), "--json"]) + .env("WGPU_ADAPTER_NAME", " ") + .output() + .unwrap(); + let all = format!("{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr)); + assert!(!all.contains("WGPU_ADAPTER_NAME"), "{all}"); +} diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index 3c372222..7229aa5a 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -140,6 +140,74 @@ pub struct TransferStats { pub readback_bytes: u64, } +/// `WGPU_ADAPTER_NAME` trimmed; empty or whitespace-only counts as unset (an empty +/// substring would otherwise match the first adapter and skip the power preference). +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] +fn wanted_adapter_name(var: Option<&str>) -> Option<&str> { + var.map(str::trim).filter(|w| !w.is_empty()) +} + +/// Index of the first adapter whose name contains `want`, compared case-insensitively. +/// Only the name is matched, as in wgpu (the backend is chosen with `WGPU_BACKEND`). +/// `Err` lists each distinct adapter once as `name (backend)`, so a typo or an +/// OS/driver-specific name is easy to spot. +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] +fn match_adapter_name(want: &str, adapters: &[(String, String)]) -> Result { + if adapters.is_empty() { + return Err(format!("WGPU_ADAPTER_NAME={want:?}: no adapters on the enabled backends")); + } + let w = want.to_lowercase(); + adapters.iter().position(|(n, _)| n.to_lowercase().contains(&w)).ok_or_else(|| { + let mut list: Vec = Vec::new(); + for l in adapters.iter().map(|(n, b)| format!("{n} ({b})")) { + if !list.contains(&l) { + list.push(l); + } + } + format!( + "no adapter name matches WGPU_ADAPTER_NAME={want:?} (matched against the name only; \ + select a backend with WGPU_BACKEND); available: [{}]", + list.join(", ") + ) + }) +} + +/// The adapter named by `WGPU_ADAPTER_NAME` (case-insensitive substring, like wgpu's +/// `initialize_adapter_from_env`, which panics when none matches). `None` when unset or empty. +#[cfg(not(target_arch = "wasm32"))] +async fn adapter_by_name(instance: &wgpu::Instance) -> Option> { + let var = std::env::var("WGPU_ADAPTER_NAME").ok(); + let want = wanted_adapter_name(var.as_deref())?; + let mut all = instance.enumerate_adapters(wgpu::Backends::all()).await; + let adapters: Vec<(String, String)> = all + .iter() + .map(|a| { + let i = a.get_info(); + (i.name, format!("{:?}", i.backend)) + }) + .collect(); + Some(match_adapter_name(want, &adapters).map(|i| all.swap_remove(i))) +} +/// `WGPU_POWER_PREF` (`low` / `high` / `none`), default HighPerformance. wgpu ignores an +/// unknown value silently; warn so a typo is noticed. +#[cfg(not(target_arch = "wasm32"))] +fn power_pref_from_env() -> wgpu::PowerPreference { + wgpu::PowerPreference::from_env().unwrap_or_else(|| { + if let Ok(v) = std::env::var("WGPU_POWER_PREF") { + log::warn!("gpu: ignoring WGPU_POWER_PREF={v:?} (expected low, high or none)"); + } + wgpu::PowerPreference::HighPerformance + }) +} +#[cfg(target_arch = "wasm32")] +fn power_pref_from_env() -> wgpu::PowerPreference { + wgpu::PowerPreference::HighPerformance +} +#[cfg(target_arch = "wasm32")] +async fn adapter_by_name(_: &wgpu::Instance) -> Option> { + None +} + impl Transfers { fn count(&self, up: bool, bytes: usize) { use std::sync::atomic::Ordering::Relaxed; @@ -774,19 +842,28 @@ impl GpuContext { let mut desc = wgpu::InstanceDescriptor::new_without_display_handle(); #[cfg(not(target_arch = "wasm32"))] { - // Match eframe's backend override so CLI and headless acceptance tests - // exercise the backend requested for the native viewer. - desc.backends = wgpu::Backends::from_env().unwrap_or(desc.backends); + // Honour the same wgpu env vars as the viewer (WGPU_BACKEND, WGPU_DX12_COMPILER, + // WGPU_VALIDATION, WGPU_DEBUG, WGPU_POWER_PREF), plus WGPU_ADAPTER_NAME (CLI/headless + // only for now; the viewer ignores it). Not full parity: the viewer also changes + // instance flags and has a GL fallback. + // Note: with_env() also resets memory_budget_thresholds to their default (unused here). + desc = desc.with_env(); } #[cfg(target_arch = "wasm32")] { desc.backends = wgpu::Backends::BROWSER_WEBGPU; } let instance = wgpu::Instance::new(desc); - let adapter = instance - .request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, ..Default::default() }) - .await - .map_err(|e| format!("no adapter: {e}"))?; + let (adapter, by_name) = match adapter_by_name(&instance).await { + Some(a) => (a?, true), + None => ( + instance + .request_adapter(&wgpu::RequestAdapterOptions { power_preference: power_pref_from_env(), ..Default::default() }) + .await + .map_err(|e| format!("no adapter: {e}"))?, + false, + ), + }; let limits = adapter.limits(); let required_limits = wgpu::Limits { max_texture_dimension_2d: limits.max_texture_dimension_2d.min(16384), @@ -796,7 +873,10 @@ impl GpuContext { let (device, queue) = adapter .request_device(&wgpu::DeviceDescriptor { label: Some("effectcraft gpu"), required_limits, ..Default::default() }) .await - .map_err(|e| format!("no device: {e}"))?; + .map_err(|e| match by_name { + true => format!("no device on {} (picked by WGPU_ADAPTER_NAME): {e}", adapter.get_info().name), + false => format!("no device: {e}"), + })?; let context = GpuContext::new(&adapter, device, queue)?; // This path owns the device; install handlers only after construction succeeds. crate::readback::install(&context.device, &context.readbacks); @@ -1392,3 +1472,66 @@ impl<'g> Enc<'g> { pub(crate) fn mip_levels(w: u32, h: u32) -> u32 { u32::BITS - w.max(h).max(1).leading_zeros() } + +#[cfg(test)] +mod adapter_name_tests { + use super::{match_adapter_name, wanted_adapter_name}; + + fn adapters() -> Vec<(String, String)> { + [("NVIDIA GeForce GTX 1070", "Vulkan"), ("Intel(R) UHD Graphics 630", "Dx12"), ("llvmpipe (LLVM 19.1.7, 256 bits)", "Gl")] + .map(|(n, b)| (n.into(), b.into())) + .to_vec() + } + + #[test] + fn matches_case_insensitive_substring() { + assert_eq!(match_adapter_name("intel", &adapters()), Ok(1)); + assert_eq!(match_adapter_name("GTX 1070", &adapters()), Ok(0)); + assert_eq!(match_adapter_name("LLVMPIPE", &adapters()), Ok(2)); + } + + #[test] + fn first_match_wins() { + assert_eq!(match_adapter_name("i", &adapters()), Ok(0)); + } + + #[test] + fn empty_or_blank_name_is_unset_and_value_is_trimmed() { + assert_eq!(wanted_adapter_name(None), None); + assert_eq!(wanted_adapter_name(Some("")), None); + assert_eq!(wanted_adapter_name(Some(" ")), None); + assert_eq!(wanted_adapter_name(Some(" intel ")), Some("intel")); + assert_eq!(match_adapter_name(wanted_adapter_name(Some(" intel ")).unwrap(), &adapters()), Ok(1)); + } + + #[test] + fn mismatch_lists_name_and_backend() { + let e = match_adapter_name("NoSuch", &adapters()).unwrap_err(); + assert!(e.contains("\"NoSuch\""), "{e}"); + assert!(e.contains("Intel(R) UHD Graphics 630 (Dx12)"), "{e}"); + assert!(e.contains("NVIDIA GeForce GTX 1070 (Vulkan)"), "{e}"); + } + + #[test] + fn backend_is_not_part_of_the_match_and_message_says_so() { + let e = match_adapter_name("dx12", &adapters()).unwrap_err(); + assert!(e.contains("name only"), "{e}"); + assert!(e.contains("WGPU_BACKEND"), "{e}"); + } + + #[test] + fn mismatch_lists_identical_name_and_backend_once() { + let mut a = adapters(); + a.insert(2, ("Intel(R) UHD Graphics 630".into(), "Dx12".into())); + a.push(("Intel(R) UHD Graphics 630".into(), "Vulkan".into())); + let e = match_adapter_name("NoSuch", &a).unwrap_err(); + assert_eq!(e.matches("Intel(R) UHD Graphics 630 (Dx12)").count(), 1, "{e}"); + assert_eq!(e.matches("Intel(R) UHD Graphics 630 (Vulkan)").count(), 1, "{e}"); + } + + #[test] + fn no_adapters_has_its_own_message() { + let e = match_adapter_name("x", &[]).unwrap_err(); + assert!(e.contains("no adapters on the enabled backends"), "{e}"); + } +}