From da99015b11a912744161a32d09ccccb9b62bfd1b Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 12:36:52 +0200 Subject: [PATCH] Windows: never load Vulkan by default; OpenGL fallback when DirectX 12 can't start (#120) Enumerating Vulkan adapters crashes inside Intel's igvk64.dll on some UHD Graphics drivers, before any Rust error handling runs. Since #22 the Windows instance is DirectX 12 only; this keeps Vulkan out of every default path and adds two fallbacks so DX12-only can't leave a PC without a window: - A DirectX 12 probe before eframe starts: no hardware adapter that meets eframe's limits -> OpenGL (plus DX12/WARP as last resort when WARP is all DX12 offers). - If run_native fails with a wgpu error before the app was created, start once more with the other of DX12/GL (eframe reuses its event loop). WGPU_BACKEND still overrides everything, and nothing else is tried then. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + README.md | 12 ++- apps/gridcraft/Cargo.toml | 4 + apps/gridcraft/src/graphics.rs | 179 +++++++++++++++++++++++++++++---- apps/gridcraft/src/main.rs | 74 +++++++++----- docs/hardware-parity.md | 5 +- 6 files changed, 231 insertions(+), 44 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f772f83..da94bb8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1427,6 +1427,7 @@ dependencies = [ "image", "log", "muda", + "pollster 1.0.1", "rfd", "serde_json", "winresource", diff --git a/README.md b/README.md index 770b3fe..1fcb850 100644 --- a/README.md +++ b/README.md @@ -230,8 +230,16 @@ $env:WGPU_BACKEND = "vulkan" Remove-Item Env:WGPU_BACKEND # restore the default for later launches ``` -An explicit `gl` override can bring the driver crash back on affected systems. The macOS, Linux -and web backend defaults are unchanged. +An explicit `gl` override can bring the driver crash back on affected systems, and `vulkan` can +bring back a crash in some Intel UHD Graphics drivers (`igvk64.dll`, #120). The macOS, Linux and +web backend defaults are unchanged. + +**If the app is slow on an older Windows PC, or DirectX 12 can't start:** when DirectX 12 finds no +graphics card with a driver for it, it only offers Microsoft's software renderer, which draws every +frame on the processor. In that case GridCraft uses OpenGL instead, without setting anything; if +the first renderer fails to start, it tries the other one once. The log file records the choice +(lines starting with `graphics:`). `WGPU_BACKEND` still overrides it (and then nothing else is +tried), e.g. `$env:WGPU_BACKEND = "gl"` or `"dx12"`. ### macOS diff --git a/apps/gridcraft/Cargo.toml b/apps/gridcraft/Cargo.toml index 5cf5e07..94dc2b0 100644 --- a/apps/gridcraft/Cargo.toml +++ b/apps/gridcraft/Cargo.toml @@ -30,6 +30,10 @@ arboard = { workspace = true } [target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies] arboard = { workspace = true, features = ["wayland-data-control"] } +[target.'cfg(target_os = "windows")'.dependencies] +# Blocks on the start-up DirectX 12 adapter probe (`graphics.rs`); already in the tree via eframe. +pollster = "1" + [target.'cfg(target_os = "macos")'.dependencies] muda = { version = "0.21", default-features = false } # Finder double-click, Open With, Dock drops and `open -a GridCraft file.xlsx` arrive as a diff --git a/apps/gridcraft/src/graphics.rs b/apps/gridcraft/src/graphics.rs index ccd0c45..14344b4 100644 --- a/apps/gridcraft/src/graphics.rs +++ b/apps/gridcraft/src/graphics.rs @@ -1,47 +1,192 @@ //! Windows graphics instance configuration, applied before wgpu can initialize any driver. +//! +//! Vulkan is never initialized unless `WGPU_BACKEND` asks for it: enumerating Vulkan adapters +//! crashes inside Intel's `igvk64.dll` on some UHD Graphics drivers (#120), and that access +//! violation happens in the driver, where no Rust error handling can catch it. -use eframe::{NativeOptions, egui_wgpu::WgpuSetup, wgpu::Backends}; +use eframe::{ + NativeOptions, + egui_wgpu::WgpuSetup, + wgpu::{Backends, DeviceType}, +}; -/// Restrict the wgpu instance to DirectX 12 unless `backend_override` (normally -/// `Backends::from_env()`, i.e. `WGPU_BACKEND`) names other backends. -pub fn configure(options: &mut NativeOptions, backend_override: Option) { - if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup { - // eframe's default includes GL. Creating that backend can crash inside AMD's - // atio6axx.dll before adapter selection or any Rust error handling runs, so the - // window never appears. Choosing an adapter afterwards is too late: only leaving GL - // out of the instance avoids it. This matches the verified WGPU_BACKEND=dx12 - // workaround without mutating process environment variables. - create.instance_descriptor.backends = backend_override.unwrap_or(Backends::DX12); +/// What the start-up probe learned about one DirectX 12 adapter. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Dx12Adapter { + /// `Cpu` is WARP (Microsoft's software rasteriser): it works, but every frame is drawn on the + /// processor, which makes the app lag on older machines. + pub device_type: DeviceType, + /// Whether the adapter offers the limits eframe will ask for when it creates the device. + pub meets_limits: bool, +} + +/// The backends to initialize on Windows. +/// +/// An explicit `backend_override` (normally `Backends::from_env()`, i.e. `WGPU_BACKEND`) always +/// wins. Otherwise DirectX 12 only, as long as `dx12_adapters` (what a DirectX 12-only probe found) +/// contains a usable hardware GPU. Without one, OpenGL is the way to the GPU: graphics chips whose +/// driver has no DirectX 12 support still have an OpenGL driver, and GL can't reach the AMD driver +/// crash described in [`configure_backends`], because an AMD GPU with a driver shows up as a +/// DirectX 12 adapter and keeps us on DirectX 12. When DirectX 12 offers only WARP, it stays +/// enabled next to GL as a last resort for machines without an OpenGL driver either (virtual +/// machines): wgpu prefers any GPU over a software adapter, so it's only picked when GL has +/// nothing better. Vulkan is never part of the default. +pub fn choose_backends(backend_override: Option, dx12_adapters: &[Dx12Adapter]) -> Backends { + if let Some(backends) = backend_override { + return backends; } + let hardware = |a: &&Dx12Adapter| a.device_type != DeviceType::Cpu; + if dx12_adapters.iter().filter(hardware).any(|a| a.meets_limits) { + Backends::DX12 + } else if !dx12_adapters.is_empty() && !dx12_adapters.iter().any(|a| hardware(&a)) { + Backends::DX12 | Backends::GL + } else { + // No DirectX 12 adapter at all, or only GPUs that can't run the app: GL alone, so a + // DirectX 12 GPU that would fail device creation isn't picked over the OpenGL one. + Backends::GL + } +} + +/// The backends for a second start when the first one failed with a graphics error before the +/// app was created (no adapter, no device, no surface): the other one of DirectX 12 and OpenGL. +/// `None` when `WGPU_BACKEND` chose the backends (the user asked for exactly those) or the first +/// start already tried both. Never Vulkan (#120). +pub fn fallback_backends(backend_override: Option, first: Backends) -> Option { + if backend_override.is_some() { + return None; + } + let rest = (Backends::DX12 | Backends::GL).difference(first); + (!rest.is_empty()).then_some(rest) +} + +/// Restricts the wgpu instance eframe creates to `backends`. +pub fn configure_backends(options: &mut NativeOptions, backends: Backends) { + if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup { + // eframe's default includes Vulkan and GL. Creating the GL backend can crash inside AMD's + // atio6axx.dll, and enumerating Vulkan adapters inside Intel's igvk64.dll (#120), before + // adapter selection or any Rust error handling runs, so the window never appears. + // Choosing an adapter afterwards is too late: only leaving the backend out of the + // instance avoids it. This matches the verified WGPU_BACKEND=dx12 workaround without + // mutating process environment variables. + create.instance_descriptor.backends = backends; + } +} + +/// Chooses the backends ([`choose_backends`]), applies them to `options` and returns them. +/// Without a `backend_override` this first probes DirectX 12 with a short-lived instance of its +/// own, which is dropped before eframe creates the real one. +#[cfg(target_os = "windows")] +pub fn configure(options: &mut NativeOptions, backend_override: Option) -> Backends { + let backends = match backend_override { + Some(backends) => { + log::info!("graphics: WGPU_BACKEND override, backends {backends:?}"); + backends + } + None => { + let adapters = probe_dx12(options); + let backends = choose_backends(None, &adapters); + log::info!("graphics: {} DirectX 12 adapter(s) {adapters:?}, backends {backends:?}", adapters.len()); + backends + } + }; + configure_backends(options, backends); + backends +} + +/// Lists the DirectX 12 adapters, checking each against the limits eframe's device descriptor +/// will require. Empty when this build has no DirectX 12 backend. +#[cfg(target_os = "windows")] +fn probe_dx12(options: &NativeOptions) -> Vec { + use eframe::wgpu::{Instance, InstanceDescriptor}; + // `Instance::new` panics when the build has no backend for the platform. + if !Instance::enabled_backend_features().contains(Backends::DX12) { + return Vec::new(); + } + let WgpuSetup::CreateNew(create) = &options.wgpu_options.wgpu_setup else { + return Vec::new(); + }; + let instance = Instance::new(InstanceDescriptor { backends: Backends::DX12, ..InstanceDescriptor::new_without_display_handle() }); + pollster::block_on(instance.enumerate_adapters(Backends::DX12)) + .iter() + .map(|adapter| { + let info = adapter.get_info(); + let required = (create.device_descriptor)(adapter).required_limits; + let meets_limits = required.check_limits(&adapter.limits()); + log::info!("graphics: DirectX 12 adapter {:?} ({:?}, driver {:?}), limits ok: {meets_limits}", info.name, info.device_type, info.driver); + Dx12Adapter { device_type: info.device_type, meets_limits } + }) + .collect() } #[cfg(test)] mod tests { use super::*; - fn configured_backends(backend_override: Option) -> Backends { + fn configured_backends(backends: Backends) -> Backends { let mut options = NativeOptions::default(); - configure(&mut options, backend_override); + configure_backends(&mut options, backends); let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else { panic!("default native options must create a wgpu instance"); }; create.instance_descriptor.backends } + const fn adapter(device_type: DeviceType, meets_limits: bool) -> Dx12Adapter { + Dx12Adapter { device_type, meets_limits } + } + #[test] - fn windows_default_initializes_only_dx12() { - // eframe's own default (PRIMARY | GL) would initialize OpenGL as well. + fn windows_default_never_initializes_vulkan() { + // eframe's own default (PRIMARY | GL) would initialize Vulkan and OpenGL as well. let WgpuSetup::CreateNew(eframe_default) = NativeOptions::default().wgpu_options.wgpu_setup else { panic!("default native options must create a wgpu instance"); }; assert!(eframe_default.instance_descriptor.backends.contains(Backends::GL)); - assert_eq!(configured_backends(None), Backends::DX12); + let gpu = [adapter(DeviceType::IntegratedGpu, true)]; + assert_eq!(configured_backends(choose_backends(None, &gpu)), Backends::DX12); + // #120: whatever the DirectX 12 probe finds, and whatever the retry picks, no Vulkan. + let probes: [&[Dx12Adapter]; 4] = [&[], &gpu, &[adapter(DeviceType::Cpu, true)], &[adapter(DeviceType::IntegratedGpu, false)]]; + for adapters in probes { + let first = choose_backends(None, adapters); + assert!(!first.contains(Backends::VULKAN), "{adapters:?}"); + if let Some(second) = fallback_backends(None, first) { + assert!(!second.contains(Backends::VULKAN), "{adapters:?}"); + } + } } #[test] fn explicit_backend_override_is_preserved() { + let warp_only = [adapter(DeviceType::Cpu, true)]; for backends in [Backends::VULKAN, Backends::GL, Backends::DX12, Backends::VULKAN | Backends::DX12, Backends::empty()] { - assert_eq!(configured_backends(Some(backends)), backends); + // Whatever the probe would have found, the override wins (the probe doesn't run then), + // and a failed start isn't retried with something the user didn't ask for. + assert_eq!(choose_backends(Some(backends), &[]), backends); + assert_eq!(choose_backends(Some(backends), &warp_only), backends); + assert_eq!(configured_backends(backends), backends); + assert_eq!(fallback_backends(Some(backends), backends), None); } } + + #[test] + fn gl_when_dx12_has_no_usable_gpu() { + // No DirectX 12 driver, so DirectX 12 offers only WARP (or nothing) and lags. + let warp_only = [adapter(DeviceType::Cpu, true)]; + assert_eq!(choose_backends(None, &warp_only), Backends::GL | Backends::DX12); + assert_eq!(choose_backends(None, &[]), Backends::GL); + // A GPU that can't create eframe's device: GL alone, so it isn't picked over the GL one. + let weak_gpu = [adapter(DeviceType::IntegratedGpu, false), adapter(DeviceType::Cpu, true)]; + assert_eq!(choose_backends(None, &weak_gpu), Backends::GL); + // Any usable GPU keeps DirectX 12 only, next to WARP or a weaker GPU. + let mixed = [adapter(DeviceType::Cpu, true), adapter(DeviceType::IntegratedGpu, false), adapter(DeviceType::DiscreteGpu, true)]; + assert_eq!(choose_backends(None, &mixed), Backends::DX12); + } + + #[test] + fn failed_start_retries_with_the_other_backend() { + assert_eq!(fallback_backends(None, Backends::DX12), Some(Backends::GL)); + assert_eq!(fallback_backends(None, Backends::GL), Some(Backends::DX12)); + // Both were already tried: nothing left to fall back to. + assert_eq!(fallback_backends(None, Backends::DX12 | Backends::GL), None); + } } diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index 3c73a95..e16e310 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -260,37 +260,65 @@ fn main() -> eframe::Result<()> { viewport = viewport.with_icon(i); } let options = eframe::NativeOptions { viewport, ..Default::default() }; - // Before eframe creates the wgpu instance: default Windows to DirectX 12 only (see graphics.rs). + // Before eframe creates the wgpu instance: Windows starts on DirectX 12, or OpenGL when + // DirectX 12 has no GPU driver, and never touches Vulkan unless asked (see graphics.rs). #[cfg(target_os = "windows")] - let options = { + let backend_override = eframe::wgpu::Backends::from_env(); + #[cfg(target_os = "windows")] + let (options, first_backends) = { let mut options = options; - graphics::configure(&mut options, eframe::wgpu::Backends::from_env()); - options + let backends = graphics::configure(&mut options, backend_override); + (options, backends) }; + #[cfg(target_os = "windows")] + let retry_options = options.clone(); // Registered before the event loop starts, so it catches the Finder event that launched us as // well as later ones. Lives until the event loop returns; the app creator only borrows it. #[cfg(target_os = "macos")] let apple_events = apple_events::AppleEvents::install(); #[cfg(target_os = "macos")] let apple_events = &apple_events; - eframe::run_native( - "GridCraft", - options, - Box::new(move |cc| { - SheetApp::setup_context_for_language(&cc.egui_ctx, app.ui.dark, app.ui.language); - if let Some(port) = control_port { - match control_server::start(port, cc.egui_ctx.clone()) { - Ok(rx) => app.control_rx = Some(rx), - Err(e) => log::error!("control channel on port {port}: {e}"), + // The app waits here until a start gets as far as creating it; a start that fails before + // (no graphics adapter, device or surface) leaves it here for the next attempt. + let pending = std::cell::Cell::new(Some(app)); + let run = |options: eframe::NativeOptions| { + eframe::run_native( + "GridCraft", + options, + Box::new(|cc| { + let Some(mut app) = pending.take() else { + return Err("the app was already created".into()); + }; + SheetApp::setup_context_for_language(&cc.egui_ctx, app.ui.dark, app.ui.language); + if let Some(port) = control_port { + match control_server::start(port, cc.egui_ctx.clone()) { + Ok(rx) => app.control_rx = Some(rx), + Err(e) => log::error!("control channel on port {port}: {e}"), + } } - } - Ok(Box::new(App( - app, - #[cfg(target_os = "macos")] - None, - #[cfg(target_os = "macos")] - apple_events.connect(&cc.egui_ctx), - ))) - }), - ) + Ok(Box::new(App( + app, + #[cfg(target_os = "macos")] + None, + #[cfg(target_os = "macos")] + apple_events.connect(&cc.egui_ctx), + ))) + }), + ) + }; + let result = run(options); + // A graphics error before the app existed: start once more with the other backend (eframe + // reuses its event loop for the second run). Errors after that, or from winit, are returned. + #[cfg(target_os = "windows")] + if let Err(eframe::Error::Wgpu(e)) = &result + && let Some(app) = pending.take() + && let Some(backends) = graphics::fallback_backends(backend_override, first_backends) + { + log::error!("graphics: the renderer didn't start on {first_backends:?} ({e}); trying {backends:?}"); + pending.set(Some(app)); + let mut options = retry_options; + graphics::configure_backends(&mut options, backends); + return run(options); + } + result } diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 083977d..8b15d75 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created from a source audit) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Windows renderer fallback, #120) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 The hardware Excel uses, per platform, against GridCraft. Part of [target-app-parity.md](target-app-parity.md). **Hardware: ~40% ready, 25–40 h** (estimated). @@ -8,7 +8,7 @@ The hardware Excel uses, per platform, against GridCraft. Part of | Hardware feature | Excel | GridCraft (macOS · Windows · Linux · BSD · web) | Gap and estimate | |---|---|---|---| | Multi-core recalculation | Multi-threaded recalculation on all cores (Windows and Mac), configurable thread count | Single-threaded everywhere (no threading in `crates/calc`) | Parallel recalc of independent dependency chains on native builds; WASM stays single-threaded or uses workers later: 12–18 h | -| GPU rendering of the grid and charts | DirectX / Metal accelerated drawing | wgpu: Metal · DirectX 12 (default since 10-08) · Vulkan/GL · Vulkan/GL · WebGPU or WebGL2 | At parity in kind. Driver problems: Intel UHD crash on Windows (#120), screen flashing on Windows 11 (#76): 3–6 h | +| GPU rendering of the grid and charts | DirectX / Metal accelerated drawing | wgpu: Metal · DirectX 12 (default since 10-08; OpenGL when DirectX 12 has no GPU driver or fails to start; Vulkan only on request, so the Intel UHD `igvk64.dll` crash (#120) can't happen) · Vulkan/GL · Vulkan/GL · WebGPU or WebGL2 | At parity in kind. Driver problems: screen flashing on Windows 11 (#76): 2–4 h | | Pen and touch (Draw tab) | Pen pressure and tilt, eraser end, touch mode, Ink to Shape, ink replay | Pen, highlighter, eraser, Ink to Shape with mouse/pen input; **no pressure or tilt**, no touch mode | Pressure-sensitive strokes where winit reports force; touch-friendly ribbon spacing: 4–6 h | | Trackpad and mouse | Smooth scrolling, pinch zoom, horizontal scroll, Magic Mouse | Smooth scrolling and zoom via egui | Verify pinch zoom on each platform: 1–2 h | | Multiple windows and monitors | One window per workbook, New Window for the same workbook, per-monitor DPI | One window; multiple workbooks switch inside it (#162; PR #171) | Native window per workbook, New Window, Arrange All across monitors: 6–8 h | @@ -20,4 +20,5 @@ The hardware Excel uses, per platform, against GridCraft. Part of | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | Windows: OpenGL fallback when DirectX 12 has no GPU or fails to start; Vulkan never by default (#120) | | 2026-10-10 | major | Created: single-threaded calc, wgpu rendering, no pen pressure, single window |