Files
OneCAD-Simulation/crates/gpu/src/select.rs
T
jyvikoandClaude Opus 5.5 c6bc4d2ba0 ci: prove the lavapipe GPU path draws (FIELDCRAFT_REQUIRE_GPU=1)
The drawing tests skipped silently without an adapter. Under
FIELDCRAFT_REQUIRE_GPU=1 they fail instead, and the snapshot and MCP
screenshot tests require a GPU renderer. CI runs them with
FIELDCRAFT_GPU=software on lavapipe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 07:52:10 -04:00

140 lines
5.5 KiB
Rust

//! Which adapter draws a headless picture. `FIELDCRAFT_GPU` chooses the policy:
//!
//! - `auto` (the default) walks a hardware GPU, then a software adapter (lavapipe, llvmpipe,
//! WARP, SwiftShader), then the CPU rasterizer;
//! - `hardware` and `software` are strict: an error when there is no such adapter;
//! - `cpu` forces the CPU rasterizer.
//!
//! Backends are Metal on macOS, DX12 and Vulkan on Windows, Vulkan and GL elsewhere;
//! `WGPU_BACKEND` narrows them. A discrete GPU outranks an integrated one, then a virtual one,
//! then any other; a CPU device is the software tier.
use crate::{GpuError, Result};
/// The adapter policy (`FIELDCRAFT_GPU`).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Policy {
#[default]
Auto,
Hardware,
Software,
Cpu,
}
/// The environment variable that chooses the [`Policy`].
pub const ENV: &str = "FIELDCRAFT_GPU";
impl Policy {
pub fn parse(s: &str) -> Option<Policy> {
Some(match s.trim().to_ascii_lowercase().as_str() {
"" | "auto" => Policy::Auto,
"hardware" => Policy::Hardware,
"software" => Policy::Software,
"cpu" => Policy::Cpu,
_ => return None,
})
}
/// The policy `FIELDCRAFT_GPU` names; [`Policy::Auto`] when it is unset.
pub fn from_env() -> Result<Policy> {
match std::env::var(ENV) {
Ok(v) => Policy::parse(&v).ok_or_else(|| GpuError::Policy(v.chars().take(40).collect())),
Err(_) => Ok(Policy::Auto),
}
}
}
pub use crate::Tier;
/// Set to `1` where a GPU adapter must exist (CI with lavapipe): tests that draw then fail
/// instead of skipping when none is found, so the GPU path is proven to draw.
pub const REQUIRE_ENV: &str = "FIELDCRAFT_REQUIRE_GPU";
/// Whether [`REQUIRE_ENV`] is set to `1`.
pub fn gpu_required() -> bool {
std::env::var(REQUIRE_ENV).is_ok_and(|v| v.trim() == "1")
}
/// An adapter's tier and its rank within it (lower is better).
pub fn rank(t: wgpu::DeviceType) -> (Tier, u8) {
match t {
wgpu::DeviceType::DiscreteGpu => (Tier::Hardware, 0),
wgpu::DeviceType::IntegratedGpu => (Tier::Hardware, 1),
wgpu::DeviceType::VirtualGpu => (Tier::Hardware, 2),
wgpu::DeviceType::Other => (Tier::Hardware, 3),
wgpu::DeviceType::Cpu => (Tier::Software, 0),
}
}
/// The index of the adapter `policy` picks among adapters of `types`: `Ok(None)` for the CPU
/// rasterizer, an error when a strict policy finds no adapter of its tier.
pub fn pick(types: &[wgpu::DeviceType], policy: Policy) -> Result<Option<usize>> {
let best = |tier: Tier| types.iter().enumerate().filter(|(_, t)| rank(**t).0 == tier).min_by_key(|(_, t)| rank(**t).1).map(|(i, _)| i);
match policy {
Policy::Cpu => Ok(None),
Policy::Auto => Ok(best(Tier::Hardware).or_else(|| best(Tier::Software))),
Policy::Hardware => best(Tier::Hardware).map(Some).ok_or_else(|| GpuError::NoAdapter(format!("{ENV}=hardware: no hardware GPU adapter"))),
Policy::Software => best(Tier::Software)
.map(Some)
.ok_or_else(|| GpuError::NoAdapter(format!("{ENV}=software: no software GPU adapter (lavapipe, llvmpipe, WARP)"))),
}
}
/// The backends tried on this platform, narrowed by `WGPU_BACKEND`.
pub fn backends() -> wgpu::Backends {
let platform = if cfg!(target_os = "macos") || cfg!(target_os = "ios") {
wgpu::Backends::METAL
} else if cfg!(windows) {
wgpu::Backends::DX12 | wgpu::Backends::VULKAN
} else {
wgpu::Backends::VULKAN | wgpu::Backends::GL
};
match wgpu::Backends::from_env() {
Some(env) => platform & env,
None => platform,
}
}
/// The adapter `policy` picks on this machine and its tier; `Ok(None)` for the CPU rasterizer.
pub fn adapter(policy: Policy) -> Result<Option<(wgpu::Adapter, Tier)>> {
if policy == Policy::Cpu {
return Ok(None);
}
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { backends: backends(), ..wgpu::InstanceDescriptor::new_without_display_handle() });
let mut adapters = pollster::block_on(instance.enumerate_adapters(backends()));
let types: Vec<_> = adapters.iter().map(|a| a.get_info().device_type).collect();
let Some(i) = pick(&types, policy)? else { return Ok(None) };
let tier = types.get(i).map_or(Tier::Hardware, |t| rank(*t).0);
Ok((i < adapters.len()).then(|| (adapters.swap_remove(i), tier)))
}
#[cfg(test)]
mod tests {
use wgpu::DeviceType::*;
use super::*;
#[test]
fn policies_walk_the_tiers() {
assert_eq!(pick(&[Cpu, IntegratedGpu, DiscreteGpu, VirtualGpu], Policy::Auto), Ok(Some(2)));
assert_eq!(pick(&[Cpu, Other, IntegratedGpu], Policy::Auto), Ok(Some(2)));
assert_eq!(pick(&[Cpu, DiscreteGpu], Policy::Software), Ok(Some(0)));
// Auto falls to a software adapter, then to the CPU rasterizer.
assert_eq!(pick(&[Cpu], Policy::Auto), Ok(Some(0)));
assert_eq!(pick(&[], Policy::Auto), Ok(None));
assert_eq!(pick(&[DiscreteGpu], Policy::Cpu), Ok(None));
// The strict policies refuse.
assert!(pick(&[Cpu], Policy::Hardware).is_err());
assert!(pick(&[DiscreteGpu], Policy::Software).is_err());
assert!(pick(&[], Policy::Hardware).is_err());
}
#[test]
fn policy_names() {
assert_eq!(Policy::parse("Software"), Some(Policy::Software));
assert_eq!(Policy::parse(""), Some(Policy::Auto));
assert_eq!(Policy::parse("cpu"), Some(Policy::Cpu));
assert_eq!(Policy::parse("gpu"), None);
}
}