facett-core/src/render/gpu/adapter_wgpu.rs
//! **wgpu glue for the adapter-selection policy** (feature `wgpu`).
//!
//! The *policy* is pure and lives in [`crate::render::adapter`]; this module is
//! the thin, untestable-without-a-GPU edge that
//!
//! 1. converts a `wgpu::AdapterInfo` into the feature-free [`AdapterFacts`],
//! 2. enumerates + selects over real adapters ([`select_adapter`]),
//! 3. replaces the naive `instance.request_adapter(&Default::default())` every
//! headless probe in this workspace hand-rolled ([`request_best_adapter`]),
//! 4. builds the `egui_wgpu::WgpuConfiguration` an eframe host installs
//! ([`facett_wgpu_options`]).
//!
//! ## Why (3) matters
//!
//! `request_adapter` with `PowerPreference::default()` is `LowPower`, which on a
//! workstation with an integrated GPU deliberately prefers the *slow* device, and
//! wgpu's own fallback ordering will happily hand back `llvmpipe` when the
//! surface-compatibility filter rejects the real card. Every one of those call
//! sites should go through here.
use super::pass_clock::PASS_CLOCK_FEATURES;
use crate::render::adapter::{
classify_unavailable, env_override, env_prefers_low_power, record_gpu_unavailable,
record_selection, AdapterFacts, AdapterKind, AdapterSelection, GpuUnavailable,
};
/// Map wgpu's `DeviceType` onto the feature-free [`AdapterKind`].
#[must_use]
pub fn kind_of(device_type: wgpu::DeviceType) -> AdapterKind {
match device_type {
wgpu::DeviceType::DiscreteGpu => AdapterKind::DiscreteGpu,
wgpu::DeviceType::IntegratedGpu => AdapterKind::IntegratedGpu,
wgpu::DeviceType::VirtualGpu => AdapterKind::VirtualGpu,
wgpu::DeviceType::Cpu => AdapterKind::Cpu,
wgpu::DeviceType::Other => AdapterKind::Other,
}
}
/// Convert a live `wgpu::AdapterInfo` into the plain facts the policy ranks.
#[must_use]
pub fn facts_of(info: &wgpu::AdapterInfo) -> AdapterFacts {
AdapterFacts {
name: info.name.clone(),
vendor: info.vendor,
device: info.device,
kind: kind_of(info.device_type),
backend: format!("{:?}", info.backend),
driver: info.driver.clone(),
}
}
/// Facts for a whole enumeration, in enumeration order.
#[must_use]
pub fn facts_of_all(adapters: &[wgpu::Adapter]) -> Vec<AdapterFacts> {
adapters.iter().map(|a| facts_of(&a.get_info())).collect()
}
/// Rank a live adapter list and return the decision (honouring
/// `FACETT_GPU_ADAPTER`). Does **not** record it — [`select_adapter`] does.
#[must_use]
pub fn decide_for(adapters: &[wgpu::Adapter]) -> Option<AdapterSelection> {
let facts = facts_of_all(adapters);
AdapterSelection::decide(&facts, env_override().as_deref())
}
/// **THE selector.** Pick the adapter facett should render on, log it loudly and
/// record it for `state_json`. Returns the chosen adapter and the decision.
pub fn select_adapter(adapters: &[wgpu::Adapter]) -> Option<(wgpu::Adapter, AdapterSelection)> {
let sel = decide_for(adapters)?;
let chosen = adapters[sel.index].clone();
record_selection(sel.clone());
Some((chosen, sel))
}
/// Drop-in replacement for `instance.request_adapter(&Default::default())` in the
/// workspace's headless probes/tests.
///
/// Enumerates **every** adapter over `backends` and applies the policy, instead of
/// letting wgpu's internal preference order hand back an integrated/software
/// device. Falls back to a plain `request_adapter` if enumeration comes back empty
/// (some backends only support the request path).
pub fn request_best_adapter(
instance: &wgpu::Instance,
backends: wgpu::Backends,
) -> Option<wgpu::Adapter> {
let adapters = pollster::block_on(instance.enumerate_adapters(backends));
if !adapters.is_empty() && let Some((a, _)) = select_adapter(&adapters) {
return Some(a);
}
let power_preference = if env_prefers_low_power() {
wgpu::PowerPreference::LowPower
} else {
wgpu::PowerPreference::HighPerformance
};
pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
power_preference,
force_fallback_adapter: false,
compatible_surface: None,
apply_limit_buckets: false,
}))
.ok()
}
/// **Refuse a measurement taken on a software rasteriser** — the adapter as an ASSERTED
/// fact, not a printed one.
///
/// A GPU test that silently lands on llvmpipe does not fail. It produces *different
/// pixels* and then passes or fails for a reason that has nothing to do with the code, on
/// a box where the real card being busy is not a rare condition. MEASURED 2026-08-31 in a
/// whole-workspace run: `facett-map3d`'s `postfx_bypass` printed
/// `device: llvmpipe (LLVM 21.1.8, 256 bits) (Vulkan, Cpu)` and failed — and its own
/// header already printed the adapter, which is precisely the shape of a hazard that is
/// documented and not enforced. (Re-run on the 4090 the verdict was the same to two
/// decimal places, 6.65 vs 6.66 — so llvmpipe did not decide THAT failure. Nobody could
/// know that without running the experiment by hand, which is the cost this removes.)
///
/// Why it can happen at all: every headless probe that hand-rolls
/// `request_adapter(&Default::default())` asks for `PowerPreference::LowPower` and takes
/// whatever wgpu's internal ordering hands back. [`request_best_adapter`] is the
/// replacement — it enumerates and applies the policy, which ranks software last. This
/// function is the assertion for the sites that get their device from somewhere else
/// (`egui_kittest::wgpu::create_render_state`, say) and can only inspect it afterwards.
///
/// # Panics
///
/// If `info` is a software rasteriser and `$FACETT_ALLOW_SOFTWARE_GPU` is not set to
/// `1`/`true`/`on`/`yes`. A genuinely CPU-only host sets that once and gets its old
/// behaviour back, and the variable's name is in the panic so it is discoverable from the
/// failure alone.
pub fn refuse_software_adapter(who: &str, info: &wgpu::AdapterInfo) {
let facts = facts_of(info);
if !crate::render::adapter::is_software_rasteriser(&facts) {
return;
}
let allowed = matches!(
std::env::var("FACETT_ALLOW_SOFTWARE_GPU").ok().as_deref(),
Some("1" | "true" | "on" | "yes")
);
assert!(
allowed,
"{who}: this measurement was taken on a SOFTWARE rasteriser — {}. Whatever it \
reports is about llvmpipe, not about the GPU lane, and a pass here is as \
meaningless as a fail. Set FACETT_GPU_ADAPTER=discrete (facett's own adapter policy \
ranks software last, so this only happens when the real card cannot give a \
device), or FACETT_ALLOW_SOFTWARE_GPU=1 if this host really has no \
card and you want the old behaviour back.",
facts.describe()
);
}
/// The **backends facett asks for** — WebGPU-class only (GFX_V2 Decision 0).
///
/// Native: `PRIMARY` (Vulkan, DX12, Metal). Wasm: `BROWSER_WEBGPU`. **`Backends::GL`
/// is deliberately absent.** WebGL2 has no compute shaders, no atomics, no storage
/// buffers and no indirect draws, so every item on the GFX_V2 roadmap — GPU culling
/// into an indirect buffer, GPU label collision, OIT, compute tessellation — is
/// *impossible* under it. While GL was in the mask we were either maintaining two
/// renderers or holding the good one back, and paying a standing tax for it (the
/// zero-area-sliver workaround exists only because slivers degrade to LINES on GL).
///
/// A host with no WebGPU-class driver now gets **no adapter**, loudly — the caller
/// surfaces that as a typed error. It does NOT silently soft-render: a wrong picture
/// that looks like a picture is the false-green class this suite keeps getting burned
/// by. The **CPU/egui-painter path is a different thing and stays** — that is how
/// headless tests and no-GPU hosts render, and it is not a WebGL fallback.
#[must_use]
pub fn preferred_backends() -> wgpu::Backends {
#[cfg(target_arch = "wasm32")]
{
wgpu::Backends::BROWSER_WEBGPU
}
#[cfg(not(target_arch = "wasm32"))]
{
wgpu::Backends::PRIMARY
}
}
/// Build the `egui_wgpu::WgpuConfiguration` an eframe host installs so the whole
/// app renders on the device the policy chose.
///
/// - `HighPerformance` power preference (or `LowPower` when `WGPU_POWER_PREF=low`),
/// - a `native_adapter_selector` driven by the pure policy — this is what rejects
/// the ASPEED/Matrox BMC console and llvmpipe,
/// - the decision logged loudly and recorded for `state_json`.
#[must_use]
pub fn facett_wgpu_options() -> egui_wgpu::WgpuConfiguration {
use egui_wgpu::{WgpuConfiguration, WgpuSetup, WgpuSetupCreateNew};
// `without_display_handle` — eframe's winit integration fills the display handle in
// for us; everything else here is ours to set. (The old note here said the handle is
// "needed only for Wayland+GLES". GLES is gone from this workspace entirely — see
// `tests/webgl_free_law.rs` — so the parenthesis named a backend that no longer exists
// here; the call is kept because eframe owns the handle, not because of GL.)
let mut setup = WgpuSetupCreateNew::without_display_handle();
setup.instance_descriptor.backends = preferred_backends();
setup.power_preference = if env_prefers_low_power() {
wgpu::PowerPreference::LowPower
} else {
wgpu::PowerPreference::HighPerformance
};
// ── Request the pass-clock's timestamp features, WHERE THE ADAPTER HAS THEM. ──
// egui-wgpu's default `device_descriptor` requests `Features::empty()`, and a
// feature not requested at device creation is absent from `device.features()`
// forever — so without this closure the per-lane GPU pass clock
// (`pass_clock::PassClock`) would honestly report "unavailable" on every box,
// including the Vulkan/4090 targets it exists for. The intersection with
// `adapter.features()` keeps bring-up from FAILING on an adapter that lacks them
// (llvmpipe on some Mesa builds): there the clock reports `None`, which is the
// designed answer, not an error. The limits mirror egui-wgpu's own default
// (`max_texture_dimension_2d: 8192` for 4k+ displays).
//
// ── LAW 4 residue, removed 2026-08-24 (GFX_V2 §V2.9b named it) ──────────────
// This closure used to open by asking whether the adapter's backend was GL and, if so,
// taking wgpu's WebGL2 downlevel limits. That branch was UNREACHABLE.
// `preferred_backends()` is `PRIMARY` on native and `BROWSER_WEBGPU` on wasm — the GL
// mask appears in neither — and since GFX_V2 V2.8b the workspace does not even COMPILE
// wgpu-hal's GLES backend (`tests/webgl_free_law.rs` holds that). So the branch could
// not fire, and its cost was not cycles: a reader finds live-looking WebGL2 handling in
// the adapter policy and concludes there is a GL path to maintain. Dead code that
// describes a lane the product does not have is a documentation defect with a compiler
// behind it.
//
// `webgl_free_law::no_source_branches_on_the_gl_backend` is what keeps it gone.
setup.device_descriptor = std::sync::Arc::new(|adapter: &wgpu::Adapter| wgpu::DeviceDescriptor {
label: Some("facett wgpu device"),
required_features: adapter.features() & PASS_CLOCK_FEATURES,
required_limits: wgpu::Limits { max_texture_dimension_2d: 8192, ..wgpu::Limits::default() },
..Default::default()
});
setup.native_adapter_selector = Some(std::sync::Arc::new(
|adapters: &[wgpu::Adapter], surface: Option<&wgpu::Surface<'_>>| match select_adapter(
adapters,
) {
Some((a, _)) => {
// The ONE moment the real window surface and the chosen adapter meet
// before the first configure: record what the surface can PRESENT with,
// so the host can pick a present mode it knows is supported instead of
// guessing (`surface.configure` with an unsupported mode is a wgpu
// validation panic, not a fallback — see `present_mode_for`).
if let Some(s) = surface {
record_surface_present_modes(s.get_capabilities(&a).present_modes);
}
Ok(a)
}
// GFX_V2 Decision 0: fail LOUDLY and TYPED. eframe only accepts a
// String here, so the typed value is recorded first (for `state_json`
// and the stderr banner) and its Display — which carries the
// `facet-gpu-<n>` code and the remedy — becomes the message eframe
// surfaces. Never a silent soft-render fallback.
None => Err(gpu_unavailable_for(adapters).to_string()),
},
));
WgpuConfiguration {
wgpu_setup: WgpuSetup::CreateNew(setup),
// eframe's OWN default is `SurfaceConfig::LOW_LATENCY` (`AutoVsync` + a
// presentation queue of ONE frame); `egui_wgpu::WgpuConfiguration::default()`
// is `desired_maximum_frame_latency: None`, which wgpu resolves to 2. Spelling
// this struct out with `..Default::default()` therefore silently gave every
// facett host one extra queued frame of input-to-photon latency that a plain
// `eframe::NativeOptions::default()` never had. Restored explicitly, and
// pinned by `wgpu_options_keep_eframes_low_latency_queue`.
surface: egui_wgpu::SurfaceConfig::LOW_LATENCY,
..Default::default()
}
}
/// Process-global record of the present modes the window surface reported for the
/// adapter facett opened — written once by the adapter selector, read by the host
/// when it settles its present mode.
static PRESENT_MODES: std::sync::OnceLock<std::sync::Mutex<Option<Vec<wgpu::PresentMode>>>> =
std::sync::OnceLock::new();
fn present_modes_slot() -> &'static std::sync::Mutex<Option<Vec<wgpu::PresentMode>>> {
PRESENT_MODES.get_or_init(|| std::sync::Mutex::new(None))
}
/// Record the surface's supported present modes (the selector calls this; a host
/// that brings its surface up some other way may call it too).
pub fn record_surface_present_modes(modes: Vec<wgpu::PresentMode>) {
if let Ok(mut g) = present_modes_slot().lock() {
*g = Some(modes);
}
}
/// The present modes the window surface supports for the opened adapter — `None`
/// until a surface has been seen (headless, or before bring-up).
#[must_use]
pub fn surface_present_modes() -> Option<Vec<wgpu::PresentMode>> {
present_modes_slot().lock().ok().and_then(|g| g.clone())
}
/// **The present mode a host may SAFELY configure**: `preferred` when the surface
/// reports it, else [`wgpu::PresentMode::AutoVsync`] — the mode wgpu resolves
/// against the surface itself and that every surface accepts.
///
/// Pure, so the fallback is a unit-tested fact rather than a crash report:
/// `wgpu::Surface::configure` with a mode absent from the capabilities is a
/// validation PANIC inside eframe's paint, so "try Mailbox" without this gate is
/// "crash on the boxes that lack it" (Mailbox is common on Vulkan/NVIDIA and X11,
/// and absent on many Wayland/Mesa and all FIFO-only stacks). The `Auto*` modes are
/// not in a capability list (they are wgpu-side policies), so asking for one is
/// always honoured as-is.
#[must_use]
pub fn present_mode_for(supported: Option<&[wgpu::PresentMode]>, preferred: wgpu::PresentMode) -> wgpu::PresentMode {
match preferred {
wgpu::PresentMode::AutoVsync | wgpu::PresentMode::AutoNoVsync => preferred,
_ => match supported {
Some(modes) if modes.contains(&preferred) => preferred,
_ => wgpu::PresentMode::AutoVsync,
},
}
}
/// Classify a failed bring-up over the CURRENT adapter list, record it, and hand
/// back the typed error.
///
/// The distinction is the whole value: "nothing enumerated" means no driver or no
/// device, while "all rejected" means hardware exists and the policy refused it
/// (llvmpipe, an ASPEED/Matrox BMC console). Collapsing both into one message —
/// as the previous bare `"no wgpu adapters enumerated"` string did, even when
/// adapters plainly HAD been enumerated — sends the operator down the wrong path.
/// ⚠ In practice this only ever returns [`GpuUnavailable::NothingEnumerated`]: its one
/// caller is the selector below, which is reached only when `select_adapter` returned
/// `None`, and that happens **only for an empty list** (see
/// [`GpuUnavailable::AllRejected`]'s note). The branch is kept because the distinction is
/// what a stricter policy would need; `the_policy_never_refuses_a_non_empty_adapter_list`
/// in [`crate::render::adapter`] states that invariant so it cannot change silently.
#[must_use]
pub fn gpu_unavailable_for(adapters: &[wgpu::Adapter]) -> GpuUnavailable {
// ONE classification, and it is the PURE one — so the reachability of each branch is
// testable without a GPU. This edge only converts wgpu types into facts.
let err = classify_unavailable(format!("{:?}", preferred_backends()), facts_of_all(adapters));
record_gpu_unavailable(err.clone());
err
}
#[cfg(test)]
mod tests {
use super::*;
/// The wgpu→facts mapping must be exhaustive and lossless for the fields the
/// policy ranks on. RED if a wgpu upgrade adds a device type we silently drop
/// into the wrong tier.
#[test]
fn device_type_mapping_is_exact() {
assert_eq!(kind_of(wgpu::DeviceType::DiscreteGpu), AdapterKind::DiscreteGpu);
assert_eq!(kind_of(wgpu::DeviceType::IntegratedGpu), AdapterKind::IntegratedGpu);
assert_eq!(kind_of(wgpu::DeviceType::VirtualGpu), AdapterKind::VirtualGpu);
assert_eq!(kind_of(wgpu::DeviceType::Cpu), AdapterKind::Cpu);
assert_eq!(kind_of(wgpu::DeviceType::Other), AdapterKind::Other);
}
fn info(name: &str, dt: wgpu::DeviceType, vendor: u32) -> wgpu::AdapterInfo {
wgpu::AdapterInfo {
name: name.to_owned(),
vendor,
device: 0,
device_type: dt,
device_pci_bus_id: String::new(),
driver: String::new(),
driver_info: String::new(),
backend: wgpu::Backend::Vulkan,
subgroup_min_size: 0,
subgroup_max_size: 0,
transient_saves_memory: Some(false),
limit_bucket: None,
}
}
/// **RED-when-broken:** the real oden enumeration expressed in *wgpu* types
/// (the shape eframe hands the selector) must still resolve to the 4090. This
/// is the same assertion as the pure test but through the conversion, so a
/// broken `facts_of` is caught too. No GPU needed — `AdapterInfo` is a plain
/// struct.
#[test]
fn oden_enumeration_resolves_to_the_discrete_gpu() {
use crate::render::adapter::{choose_adapter_index, VENDOR_ASPEED, VENDOR_NVIDIA};
let infos = [
info("ASPEED Graphics Family", wgpu::DeviceType::Other, VENDOR_ASPEED),
info("llvmpipe (LLVM 21.1.8, 256 bits)", wgpu::DeviceType::Cpu, 0x1_0005),
info("NVIDIA GeForce RTX 4090", wgpu::DeviceType::DiscreteGpu, VENDOR_NVIDIA),
];
let facts: Vec<_> = infos.iter().map(facts_of).collect();
assert_eq!(facts[2].backend, "Vulkan");
let pick = choose_adapter_index(&facts).expect("must pick");
assert_eq!(facts[pick].name, "NVIDIA GeForce RTX 4090");
let sel = AdapterSelection::decide(&facts, None).expect("decides");
assert!(!sel.software);
assert_eq!(sel.index, 2);
}
/// The eframe configuration must be a freshly-created setup with the policy
/// selector installed and high-performance requested. If someone drops the
/// selector, the app silently falls back to whatever wgpu picks (software on
/// this box) — this goes red first, with no GPU.
#[test]
fn wgpu_options_install_the_policy() {
use egui_wgpu::WgpuSetup;
let cfg = facett_wgpu_options();
match cfg.wgpu_setup {
WgpuSetup::CreateNew(setup) => {
assert!(
setup.native_adapter_selector.is_some(),
"the adapter-selection policy must be installed"
);
assert_eq!(setup.power_preference, wgpu::PowerPreference::HighPerformance);
assert!(
setup.instance_descriptor.backends.contains(wgpu::Backends::VULKAN),
"Vulkan must be among the requested backends"
);
}
_ => panic!("expected CreateNew"),
}
}
/// The facett configuration must carry eframe's own `LOW_LATENCY` surface config
/// (present queue depth 1). Seen RED on the code this replaces: the struct was
/// `..Default::default()`, i.e. `desired_maximum_frame_latency: None` → wgpu's 2.
#[test]
fn wgpu_options_keep_eframes_low_latency_queue() {
let cfg = facett_wgpu_options();
assert_eq!(
cfg.surface.desired_maximum_frame_latency,
Some(1),
"one queued frame, as eframe's NativeOptions::default() has — not wgpu's 2"
);
assert_eq!(cfg.surface.present_mode, wgpu::PresentMode::AutoVsync, "the safe boot mode");
}
/// A host asks for Mailbox; the answer is Mailbox ONLY where the surface lists it,
/// and the always-valid `AutoVsync` everywhere else — never `Immediate` (tearing),
/// never a raw `Fifo` guess, never a mode the surface never reported.
#[test]
fn present_mode_for_falls_back_to_vsync_when_mailbox_is_unsupported() {
use wgpu::PresentMode as P;
assert_eq!(present_mode_for(Some(&[P::Fifo, P::Mailbox, P::Immediate]), P::Mailbox), P::Mailbox);
assert_eq!(present_mode_for(Some(&[P::Fifo, P::Immediate]), P::Mailbox), P::AutoVsync);
assert_eq!(present_mode_for(Some(&[P::Fifo]), P::Mailbox), P::AutoVsync);
assert_eq!(present_mode_for(None, P::Mailbox), P::AutoVsync, "no surface seen = no claim");
assert_eq!(present_mode_for(Some(&[]), P::Mailbox), P::AutoVsync);
// The wgpu policies are honoured as-is: they are resolved against the surface by wgpu.
assert_eq!(present_mode_for(Some(&[P::Fifo]), P::AutoVsync), P::AutoVsync);
assert_eq!(present_mode_for(None, P::AutoNoVsync), P::AutoNoVsync);
// A concrete mode the surface lists is honoured too (an operator forcing Immediate).
assert_eq!(present_mode_for(Some(&[P::Fifo, P::Immediate]), P::Immediate), P::Immediate);
}
}