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 <noreply@anthropic.com>
This commit is contained in:
vangel76 2026-10-11 12:36:52 +02:00
parent 9f896c1249
commit da99015b11
6 changed files with 231 additions and 44 deletions

1
Cargo.lock generated
View File

@ -1427,6 +1427,7 @@ dependencies = [
"image",
"log",
"muda",
"pollster 1.0.1",
"rfd",
"serde_json",
"winresource",

View File

@ -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

View File

@ -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

View File

@ -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<Backends>) {
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<Backends>, 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<Backends>, first: Backends) -> Option<Backends> {
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>) -> 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<Dx12Adapter> {
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>) -> 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);
}
}

View File

@ -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
}

View File

@ -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 |