diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index c36b09f701..306fb08371 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -180,6 +180,9 @@ fn write_control_file(path: &str, port: u16, token: &str) -> std::io::Result<()> /// for a discrete GPU, and on hybrid-graphics laptops (NVIDIA Optimus) the discrete one can lose /// or corrupt its memory across suspend and screen lock, leaving the window illegible (issue #8). /// It also saves battery. Machines with one GPU are unaffected. +/// - On Linux, draw on a GPU that a monitor is plugged into. On a desktop whose monitors all hang +/// off the discrete GPU, drawing on the integrated one leaves the window black under Wayland +/// compositors on NVIDIA. Among the GPUs that drive a display, the integrated one still wins. /// - On Windows, use Direct3D 12, falling back to OpenGL, and never load Vulkan drivers unless /// `WGPU_BACKEND` asks for them. Creating a Vulkan instance loads every installed Vulkan driver /// into the process, and a faulty one (an Intel driver in issue #37) crashed PdfCraft before @@ -188,12 +191,83 @@ fn configure_gpu(native: &mut eframe::NativeOptions) { let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &mut native.wgpu_options.wgpu_setup else { return }; if std::env::var_os("WGPU_POWER_PREF").is_none() { setup.power_preference = eframe::wgpu::PowerPreference::LowPower; + #[cfg(target_os = "linux")] + { + let displays = linux_display_gpus(std::path::Path::new("/sys/class/drm")); + // Without sysfs (containers, remote sessions) the power preference alone decides. + if !displays.is_empty() { + setup.native_adapter_selector = Some(std::sync::Arc::new(move |adapters, surface| { + let usable: Vec<&eframe::wgpu::Adapter> = adapters.iter().filter(|a| surface.is_none_or(|s| a.is_surface_supported(s))).collect(); + let infos: Vec<(u32, u32, eframe::wgpu::DeviceType)> = usable + .iter() + .map(|a| { + let info = a.get_info(); + (info.vendor, info.device, info.device_type) + }) + .collect(); + pick_adapter(&infos, &displays) + .and_then(|i| usable.get(i)) + .map(|a| (*a).clone()) + .ok_or_else(|| "no GPU can draw to this window".to_string()) + })); + } + } } if cfg!(target_os = "windows") && std::env::var_os("WGPU_BACKEND").is_none() { setup.instance_descriptor.backends = eframe::wgpu::Backends::DX12 | eframe::wgpu::Backends::GL; } } +/// PCI `(vendor, device)` ids of the GPUs with a connected monitor, read from the DRM connectors +/// under `drm` (`card1-DP-3/status` is `connected`, `card1/device/{vendor,device}` hold `0x10de`). +#[cfg(target_os = "linux")] +fn linux_display_gpus(drm: &std::path::Path) -> Vec<(u32, u32)> { + let read_hex = |p: std::path::PathBuf| -> Option { + let s = std::fs::read_to_string(p).ok()?; + u32::from_str_radix(s.trim().trim_start_matches("0x"), 16).ok() + }; + let mut gpus = Vec::new(); + let Ok(entries) = std::fs::read_dir(drm) else { return gpus }; + // A machine has a handful of connectors; the cap only bounds a pathological sysfs. + for entry in entries.flatten().take(256) { + let name = entry.file_name(); + let Some((card, _connector)) = name.to_str().and_then(|n| n.split_once('-')) else { continue }; + let connected = std::fs::read_to_string(entry.path().join("status")).is_ok_and(|s| s.trim() == "connected"); + if !connected { + continue; + } + let device = drm.join(card).join("device"); + if let (Some(v), Some(d)) = (read_hex(device.join("vendor")), read_hex(device.join("device"))) + && !gpus.contains(&(v, d)) + { + gpus.push((v, d)); + } + } + gpus +} + +/// Index of the adapter to draw with: one that drives a display (by PCI ids) first, then the most +/// frugal kind — integrated, discrete, other, virtual, software. +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +fn pick_adapter(adapters: &[(u32, u32, eframe::wgpu::DeviceType)], displays: &[(u32, u32)]) -> Option { + use eframe::wgpu::DeviceType; + adapters + .iter() + .enumerate() + .min_by_key(|(_, (vendor, device, kind))| { + let drives_display = displays.contains(&(*vendor, *device)); + let frugality = match kind { + DeviceType::IntegratedGpu => 0, + DeviceType::DiscreteGpu => 1, + DeviceType::Other => 2, + DeviceType::VirtualGpu => 3, + DeviceType::Cpu => 4, + }; + (!drives_display, frugality) + }) + .map(|(i, _)| i) +} + #[cfg(test)] mod tests { #[test] @@ -212,4 +286,57 @@ mod tests { assert!(!backends.contains(eframe::wgpu::Backends::VULKAN), "issue #37: {backends:?}"); } } + + const NVIDIA: (u32, u32) = (0x10de, 0x2684); + const AMD_IGPU: (u32, u32) = (0x1002, 0x164e); + + fn adapters() -> Vec<(u32, u32, eframe::wgpu::DeviceType)> { + use eframe::wgpu::DeviceType; + vec![(NVIDIA.0, NVIDIA.1, DeviceType::DiscreteGpu), (AMD_IGPU.0, AMD_IGPU.1, DeviceType::IntegratedGpu), (0, 0, DeviceType::Cpu)] + } + + #[test] + fn pick_adapter_prefers_the_gpu_driving_the_monitors() { + // A desktop whose monitors are all on the discrete GPU: the integrated one shows black. + assert_eq!(super::pick_adapter(&adapters(), &[NVIDIA]), Some(0)); + } + + #[test] + fn pick_adapter_keeps_the_integrated_gpu_on_hybrid_laptops() { + // Issue #8: the panel is on the integrated GPU, an external monitor on the discrete one. + assert_eq!(super::pick_adapter(&adapters(), &[NVIDIA, AMD_IGPU]), Some(1)); + assert_eq!(super::pick_adapter(&adapters(), &[AMD_IGPU]), Some(1)); + } + + #[test] + fn pick_adapter_falls_back_to_low_power_without_a_match() { + assert_eq!(super::pick_adapter(&adapters(), &[(0x8086, 0x1234)]), Some(1)); + assert_eq!(super::pick_adapter(&[], &[NVIDIA]), None); + } + + #[cfg(target_os = "linux")] + #[test] + fn linux_display_gpus_reads_connected_connectors() -> std::io::Result<()> { + let dir = std::env::temp_dir().join(format!("pdfcraft-drm-{}", std::process::id())); + let card = |name: &str, (vendor, device): (u32, u32)| -> std::io::Result<()> { + std::fs::create_dir_all(dir.join(name).join("device"))?; + std::fs::write(dir.join(name).join("device/vendor"), format!("{vendor:#06x}\n"))?; + std::fs::write(dir.join(name).join("device/device"), format!("{device:#06x}\n")) + }; + let connector = |name: &str, status: &str| -> std::io::Result<()> { + std::fs::create_dir_all(dir.join(name))?; + std::fs::write(dir.join(name).join("status"), format!("{status}\n")) + }; + card("card1", NVIDIA)?; + card("card2", AMD_IGPU)?; + connector("card1-DP-3", "connected")?; + connector("card1-DP-4", "connected")?; + connector("card2-HDMI-A-1", "disconnected")?; + connector("card2-Writeback-1", "unknown")?; + let gpus = super::linux_display_gpus(&dir); + std::fs::remove_dir_all(&dir)?; + assert_eq!(gpus, vec![NVIDIA]); + assert!(super::linux_display_gpus(std::path::Path::new("/nonexistent/drm")).is_empty()); + Ok(()) + } }