forked from 1b-agent/opencadstudio-upstream
Tell the user when the scene is drawn without a GPU
A day of pan/zoom that had suddenly become unusable on a 1.2M-entity drawing turned out to be no regression at all: an unattended NVIDIA userspace update had been applied without a reboot, the Vulkan ICD could no longer talk to the still-loaded kernel module, and wgpu silently fell through to Mesa's llvmpipe. Nothing in the app said so. iced picks the adapter and names it only under RUST_LOG, so the only evidence was 32 `llvmpipe-N` threads in /proc. The same silence covers WARP on Windows, SwiftShader in browsers, and the case where wgpu finds no adapter at all and iced quietly draws the chrome with tiny-skia while the shader widget is dropped, leaving a blank viewport. This commit makes all of that visible, on every platform, without new dependencies. Detection (src/scene/pipeline/mod.rs) ------------------------------------- `GpuStatus` is derived from two signals: * `record_gpu_adapter`, run from `MultiPipeline::new` (once per device, next to the existing uncaptured-error handler), reads `Device::adapter_info()`, prints one `[gpu] adapter: <name> (<backend>, <device type>, driver …)` line — stderr natively, `console.warn` on the web, which has no stderr — and keeps a copy. * `note_scene_draw`, run from `ViewportPane::draw`, counts how often the viewport asked for a primitive. Every renderer makes that call; only wgpu goes on to `prepare` and create a device. Three draws with no device ever recorded therefore mean the primitive is being dropped. The verdict is one of `Unknown`, `Hardware`, `Software(adapter)` or `NoRenderer`. `Software` is `DeviceType::Cpu` — what Vulkan reports for llvmpipe, DX12 for WARP and GL for a renderer string that gives it away — plus a name match on the fallbacks browsers expose (SwiftShader, llvmpipe, lavapipe, "Microsoft Basic Render"), because the WebGPU backend reports every adapter as `Other` (gfx-rs/wgpu#8819). Metal has no software device, so on macOS only `NoRenderer` can occur (a VM without a GPU); it is caught by the draw count like everywhere else. `gpu_status_if_changed` costs one atomic load per app message: a generation counter moves only when a device is recorded or the draw count crosses its threshold, so the app re-derives the verdict a handful of times per session. Notification (src/app/startup.rs, view/modal.rs, ui/statusbar) ------------------------------------------------------------- `refresh_gpu_status` runs after every message. On a degraded verdict it speaks through three channels, because many users never read the command line: * A popup, `ModalKind::GpuWarning`, queued once per verdict through the startup-modal queue so it waits its turn behind the file open. It names the adapter, states the consequence (slow navigation, unstable or missing rendering) and gives the remedy that usually applies on the running platform: a driver update not yet followed by a reboot on Linux, a missing driver or a Remote Desktop / VM session on Windows, a VM without a Metal device on macOS, hardware acceleration switched off in the browser. "Don't show again for this device" stores `GpuStatus::identity()` in `settings.gpu_warning_silenced`; keying it by verdict means silencing "software rendering on llvmpipe" does not silence a later, different failure. * A status-bar pill in the theme's warning colours, `⚠ Software rendering` or `⚠ No GPU renderer`. It is not a `StatusPill`, so it cannot be hidden, and it stays for the session; clicking it reopens the popup, silenced or not. * A command-line entry through the new `CommandLine::push_warning`, which is styled like an error but deliberately does not touch `last_error` / `error_revision`: automation reads those to decide whether the command it just ran failed, and a graphics warning is not a failed command. `subscription()` requests frames only while the verdict is `Unknown` on a document tab (the Start page draws no viewport), so the probe costs one frame on a working GPU, three on none, then stops. Verification ------------ * Live on the machine that had the incident, with the Vulkan loader restricted to Mesa (`VK_DRIVER_FILES=…/lvp_icd.json WGPU_BACKEND=vulkan`): popup → OK → pill → click reopens; "Don't show again" → restart → pill only, no popup. * `gpu_tests::the_adapter_is_reported_once_per_device` (ignored, needs an adapter) asserts the classification on a real device; it was run on both llvmpipe (`Cpu`) and an NVIDIA GT 1030 (`DiscreteGpu`). * Unit tests cover the name heuristic, the verdict function, the identity rule, the warning text with an adapter name that itself contains parentheses and commas, the settings round trip and the command-line semantics. New strings are in all 21 locales as the `gpu` Fluent group. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
1 parent
5f161bb037
commit
02b0f4c7d9
34 files changed
+875
No files matched your search
@@ -727,6 +727,14 @@ pub(super) struct OpenCADStudio {
|
||||
/// of OS windows).
|
||||
active_modal: Option<ModalKind>,
|
||||
pending_startup_modals: std::collections::VecDeque<ModalKind>,
|
||||
/// What is drawing the scene, once the first frame has told us. Drives
|
||||
/// the graphics warning (popup, status-bar pill, command line).
|
||||
gpu_status: crate::scene::pipeline::GpuStatus,
|
||||
/// The pipeline's status generation this app has already looked at.
|
||||
gpu_status_generation: u64,
|
||||
/// `GpuStatus::identity()` of the verdict whose popup the user silenced
|
||||
/// with "Don't show again for this device". Persisted in the settings.
|
||||
gpu_warning_silenced: String,
|
||||
/// Plot modal geometry preserved while the Plot Style editor is open as
|
||||
/// a child dialog. None means Plotstyle was opened directly (e.g. command).
|
||||
plotstyle_parent_plot_geometry: Option<(iced::Vector, iced::Vector)>,
|
||||
@@ -1739,6 +1747,10 @@ pub enum ModalKind {
|
||||
/// Add / remove the annotation scales a single selected object has a
|
||||
/// per-object representation for.
|
||||
AnnoObjectScale,
|
||||
/// The scene is drawn by a software rasterizer, or not at all: what that
|
||||
/// means and what usually fixes it. Queued once per verdict; the status
|
||||
/// bar's ⚠ pill reopens it.
|
||||
GpuWarning,
|
||||
}
|
||||
|
||||
/// A property group controlled by a layer state's restore mask.
|
||||
@@ -2861,6 +2873,12 @@ pub enum Message {
|
||||
PropConstraintLinkClick(Vec<acadrust::Handle>),
|
||||
// ── About window ────────────────────────────────────────────────────
|
||||
AboutOpen,
|
||||
// ── Graphics warning ────────────────────────────────────────────────
|
||||
/// The status bar's ⚠ pill: reopen the graphics warning.
|
||||
GpuWarningOpen,
|
||||
/// "Don't show again for this device": close the warning and remember
|
||||
/// the verdict it described, so only a different one prompts again.
|
||||
GpuWarningSilence,
|
||||
/// Close whatever in-canvas modal dialog is open (Plan B).
|
||||
CloseModal,
|
||||
// ── Attribute editor dialog ───────────────────────────────────────────
|
||||
@@ -3613,6 +3631,9 @@ impl OpenCADStudio {
|
||||
recent_colors: Vec::new(),
|
||||
active_modal: None,
|
||||
pending_startup_modals: std::collections::VecDeque::new(),
|
||||
gpu_status: crate::scene::pipeline::GpuStatus::Unknown,
|
||||
gpu_status_generation: 0,
|
||||
gpu_warning_silenced: String::new(),
|
||||
plotstyle_parent_plot_geometry: None,
|
||||
find_replace: FindReplaceState::default(),
|
||||
aec_drop_acknowledged: false,
|
||||
|
||||
@@ -238,6 +238,11 @@ pub struct UserSettings {
|
||||
pub default_assoc_prompted: bool,
|
||||
/// App version whose donation prompt has been displayed.
|
||||
pub donation_prompt_version: String,
|
||||
/// The graphics verdict (`GpuStatus::identity()`) whose warning popup the
|
||||
/// user chose not to see again. Empty = always show. Keyed by verdict so
|
||||
/// silencing "software rendering on llvmpipe" does not silence a later,
|
||||
/// different failure.
|
||||
pub gpu_warning_silenced: String,
|
||||
/// Ids of plugins the user turned off in the Plugin Manager. Disabled
|
||||
/// plugins keep their manifest listed but drop their ribbon tab and command
|
||||
/// dispatch.
|
||||
@@ -398,6 +403,7 @@ impl Default for UserSettings {
|
||||
otrack: false,
|
||||
default_assoc_prompted: false,
|
||||
donation_prompt_version: String::new(),
|
||||
gpu_warning_silenced: String::new(),
|
||||
disabled_plugins: Vec::new(),
|
||||
plugin_repos: Vec::new(),
|
||||
literal_spaces: false,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::{ModalKind, OpenCADStudio};
|
||||
use crate::scene::pipeline::{GpuAdapter, GpuStatus};
|
||||
|
||||
impl OpenCADStudio {
|
||||
pub(super) fn queue_startup_prompts(&mut self) {
|
||||
@@ -23,6 +24,51 @@ impl OpenCADStudio {
|
||||
}
|
||||
}
|
||||
|
||||
/// Notice when the scene is drawn by a software rasterizer, or not at
|
||||
/// all, and say so everywhere the user might look: the command line,
|
||||
/// the log, a one-time popup, and a status-bar pill that stays.
|
||||
///
|
||||
/// Runs after every message; the pipeline records the adapter when iced
|
||||
/// first builds a device and counts the viewport's draws in the meantime,
|
||||
/// so the verdict lands on the first message after the first frame
|
||||
/// (`subscription` keeps frames ticking until it does). A working GPU
|
||||
/// gets no word here — the `[gpu] adapter:` log line already names it.
|
||||
pub(super) fn refresh_gpu_status(&mut self) {
|
||||
let Some(status) =
|
||||
crate::scene::pipeline::gpu_status_if_changed(&mut self.gpu_status_generation)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if status == self.gpu_status {
|
||||
return;
|
||||
}
|
||||
self.gpu_status = status;
|
||||
let Some(identity) = self.gpu_status.identity() else {
|
||||
return;
|
||||
};
|
||||
match &self.gpu_status {
|
||||
GpuStatus::Software(adapter) => {
|
||||
self.command_line.push_warning(&software_rasterizer_warning(adapter));
|
||||
}
|
||||
GpuStatus::NoRenderer => {
|
||||
crate::scene::pipeline::report_gpu_line(
|
||||
"[gpu] no adapter: wgpu found nothing to draw with; the interface is on \
|
||||
iced's software renderer and the scene is not drawn at all",
|
||||
);
|
||||
self.command_line
|
||||
.push_warning(&crate::tr!("gpu", "command-line-no-renderer"));
|
||||
}
|
||||
GpuStatus::Unknown | GpuStatus::Hardware(_) => {}
|
||||
}
|
||||
// The popup: once per verdict, unless the user silenced this one.
|
||||
let already_queued = self.active_modal == Some(ModalKind::GpuWarning)
|
||||
|| self.pending_startup_modals.contains(&ModalKind::GpuWarning);
|
||||
if self.gpu_warning_silenced != identity && !already_queued {
|
||||
self.pending_startup_modals.push_back(ModalKind::GpuWarning);
|
||||
self.show_next_startup_modal();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn mark_startup_modal_shown(&mut self) {
|
||||
if self.active_modal.is_some()
|
||||
&& self.active_modal.as_ref() == self.pending_startup_modals.front()
|
||||
@@ -38,10 +84,77 @@ impl OpenCADStudio {
|
||||
}
|
||||
}
|
||||
|
||||
/// The command-line warning for a software rasterizer, naming the adapter so
|
||||
/// the user can tell llvmpipe from SwiftShader from WARP.
|
||||
pub(super) fn software_rasterizer_warning(adapter: &GpuAdapter) -> String {
|
||||
let adapter = &adapter.name;
|
||||
crate::tf!("GPU unavailable — rendering in software on the CPU ({adapter}). Large drawings will navigate slowly; check the graphics driver (a driver update without a reboot is the usual cause).")
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
/// What usually puts a machine in this state, per platform. The cause is
|
||||
/// almost always outside the app, and it differs: on Linux it is a driver
|
||||
/// updated under a running kernel module, on Windows a driver that is
|
||||
/// missing or a session (Remote Desktop, a VM) that has no GPU to offer, on
|
||||
/// macOS a VM without a Metal device, in a browser hardware acceleration
|
||||
/// switched off.
|
||||
pub(crate) fn gpu_platform_hint() -> String {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
crate::tr!("gpu", "hint-web")
|
||||
} else if cfg!(target_os = "windows") {
|
||||
crate::tr!("gpu", "hint-windows")
|
||||
} else if cfg!(target_os = "macos") {
|
||||
crate::tr!("gpu", "hint-macos")
|
||||
} else {
|
||||
crate::tr!("gpu", "hint-linux")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app::{config::AppConfig, Message};
|
||||
use iced::wgpu;
|
||||
|
||||
#[test]
|
||||
fn the_software_warning_names_the_adapter_and_every_platform_has_a_hint() {
|
||||
let cpu = GpuAdapter {
|
||||
name: "llvmpipe (LLVM 20.1.8, 256 bits)".to_string(),
|
||||
backend: wgpu::Backend::Vulkan,
|
||||
device_type: wgpu::DeviceType::Cpu,
|
||||
};
|
||||
let warning = software_rasterizer_warning(&cpu);
|
||||
// The name carries parentheses and commas of its own; the localized
|
||||
// template must still place it whole. The loader follows the system
|
||||
// locale here, so the assertions stay language-neutral.
|
||||
assert!(warning.contains("(llvmpipe (LLVM 20.1.8, 256 bits))"), "{warning}");
|
||||
assert!(warning.contains("GPU"), "{warning}");
|
||||
assert!(!warning.contains("{adapter}") && !warning.contains("__ocs_"), "{warning}");
|
||||
// Every platform has a hint, and it is a sentence, not a missing key.
|
||||
let hint = gpu_platform_hint();
|
||||
assert!(hint.len() > 20 && !hint.contains("hint-"), "{hint}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn silencing_the_popup_is_per_verdict_and_survives_a_settings_round_trip() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.gpu_status = GpuStatus::NoRenderer;
|
||||
let _ = app.update(Message::GpuWarningSilence);
|
||||
assert_eq!(app.gpu_warning_silenced, "no-renderer");
|
||||
assert!(app.active_modal.is_none());
|
||||
|
||||
let saved = serde_json::to_string(&app.current_config()).unwrap();
|
||||
let mut restarted = OpenCADStudio::new_for_test();
|
||||
restarted.apply_config(serde_json::from_str(&saved).unwrap());
|
||||
assert_eq!(restarted.gpu_warning_silenced, "no-renderer");
|
||||
// A different verdict is not covered by that choice.
|
||||
let software = GpuStatus::Software(GpuAdapter {
|
||||
name: "llvmpipe".to_string(),
|
||||
backend: wgpu::Backend::Vulkan,
|
||||
device_type: wgpu::DeviceType::Cpu,
|
||||
});
|
||||
assert_ne!(software.identity().unwrap(), restarted.gpu_warning_silenced);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn donation_prompt_persists_once_per_version() {
|
||||
|
||||
@@ -414,6 +414,7 @@ impl OpenCADStudio {
|
||||
otrack: self.snapper.otrack_enabled,
|
||||
default_assoc_prompted: self.default_assoc_prompted,
|
||||
donation_prompt_version: self.donation_prompt_version.clone(),
|
||||
gpu_warning_silenced: self.gpu_warning_silenced.clone(),
|
||||
disabled_plugins: {
|
||||
let mut v: Vec<String> = self.disabled_plugins.iter().cloned().collect();
|
||||
v.sort();
|
||||
@@ -493,6 +494,7 @@ impl OpenCADStudio {
|
||||
self.snapper.otrack_enabled = s.otrack;
|
||||
self.default_assoc_prompted = s.default_assoc_prompted;
|
||||
self.donation_prompt_version = s.donation_prompt_version.clone();
|
||||
self.gpu_warning_silenced = s.gpu_warning_silenced.clone();
|
||||
self.disabled_plugins = s.disabled_plugins.iter().cloned().collect();
|
||||
self.plugin_repos = s.plugin_repos.clone();
|
||||
self.command_line.literal_spaces = s.literal_spaces;
|
||||
|
||||
@@ -288,6 +288,7 @@ impl OpenCADStudio {
|
||||
}
|
||||
}
|
||||
let task = self.update_inner(msg);
|
||||
self.refresh_gpu_status();
|
||||
self.show_next_startup_modal();
|
||||
self.sync_open_command_history();
|
||||
// Close the document-level first-touch transaction started by
|
||||
@@ -6577,6 +6578,16 @@ impl OpenCADStudio {
|
||||
Task::none()
|
||||
}
|
||||
|
||||
Message::GpuWarningOpen => {
|
||||
self.active_modal = Some(super::ModalKind::GpuWarning);
|
||||
Task::none()
|
||||
}
|
||||
Message::GpuWarningSilence => {
|
||||
self.gpu_warning_silenced = self.gpu_status.identity().unwrap_or_default();
|
||||
self.close_active_modal();
|
||||
Task::none()
|
||||
}
|
||||
|
||||
Message::CloseModal => {
|
||||
if self.active_modal == Some(super::ModalKind::RecoveryPrompt) {
|
||||
return self.update(Message::RecoveryDecline);
|
||||
|
||||
@@ -2225,6 +2225,7 @@ bg={bg_ms:.1}ms n={view_count}"
|
||||
status_menu_data,
|
||||
tab.scene.sketch_constraint_set(tab.current_sketch_scope()).and_then(|s| s.dof),
|
||||
tab.scene.sketch_constraint_set(tab.current_sketch_scope()).map(|s| s.conflicts.len()).unwrap_or(0),
|
||||
&self.gpu_status,
|
||||
)
|
||||
})
|
||||
.width(Fill)
|
||||
@@ -2479,6 +2480,18 @@ impl OpenCADStudio {
|
||||
} else {
|
||||
Subscription::none()
|
||||
};
|
||||
// Graphics verdict: a device appears inside the first frame and a
|
||||
// dropped scene shows up as draws without one, but neither arrives
|
||||
// as a message. Tick until the verdict is in — one frame on a
|
||||
// working GPU, three on none — then stop. The Start page draws no
|
||||
// viewport, so there is nothing to learn there.
|
||||
let gpu_probe = if matches!(self.gpu_status, crate::scene::pipeline::GpuStatus::Unknown)
|
||||
&& !self.tabs[self.active_tab].is_start
|
||||
{
|
||||
window::frames().map(Message::Tick)
|
||||
} else {
|
||||
Subscription::none()
|
||||
};
|
||||
let thumbnail_capture = if self.thumbnail_capture_clean {
|
||||
window::frames().map(|_| Message::ThumbnailCaptureFrame)
|
||||
} else {
|
||||
@@ -2685,6 +2698,7 @@ impl OpenCADStudio {
|
||||
grip_dwell,
|
||||
hover_dwell,
|
||||
nav_settle,
|
||||
gpu_probe,
|
||||
thumbnail_capture,
|
||||
caret_blink,
|
||||
web_fonts,
|
||||
|
||||
@@ -50,6 +50,7 @@ impl OpenCADStudio {
|
||||
Some(K::SaveDialog) => crate::tr!("modal", "save-drawing-as"),
|
||||
Some(K::Recovery) => crate::tr!("modal", "recovery-report"),
|
||||
Some(K::RecoveryPrompt) => crate::tr!("modal", "recovery-prompt"),
|
||||
Some(K::GpuWarning) => crate::tr!("gpu", "title"),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
@@ -1378,6 +1379,9 @@ impl OpenCADStudio {
|
||||
super::super::ModalKind::DonationPrompt => {
|
||||
sized_flow(ex, 540, 360, donation_dialog_window)
|
||||
}
|
||||
super::super::ModalKind::GpuWarning => {
|
||||
sized_flow(ex, 520, 400, |flow| gpu_warning_window(&self.gpu_status, flow))
|
||||
}
|
||||
super::super::ModalKind::AecDropWarning => {
|
||||
let src_label = self
|
||||
.tabs
|
||||
@@ -1921,6 +1925,57 @@ fn layer_delete_warning_window(
|
||||
.into()
|
||||
}
|
||||
|
||||
/// What the graphics verdict means and what usually fixes it. Two
|
||||
/// situations share the dialog — a software rasterizer (slow, but drawing)
|
||||
/// and no renderer at all (a blank viewport) — and the remedy hint is per
|
||||
/// platform. "OK" closes it for this session; the status-bar pill brings it
|
||||
/// back. "Don't show again" silences this verdict only, so a different
|
||||
/// failure on the same machine still prompts.
|
||||
fn gpu_warning_window(
|
||||
status: &crate::scene::pipeline::GpuStatus,
|
||||
sizing: crate::ui::modal::ModalSizing,
|
||||
) -> Element<'static, Message> {
|
||||
use crate::scene::pipeline::GpuStatus;
|
||||
let (headline, consequences) = match status {
|
||||
GpuStatus::Software(adapter) => (
|
||||
crate::tr!("gpu", "software-headline", adapter = adapter.name.clone()),
|
||||
Some(crate::tr!("gpu", "software-consequences")),
|
||||
),
|
||||
_ => (crate::tr!("gpu", "no-renderer-headline"), None),
|
||||
};
|
||||
let mut body = column![text(headline).size(13)].spacing(8);
|
||||
if let Some(consequences) = consequences {
|
||||
body = body.push(text(consequences).size(13));
|
||||
}
|
||||
body = body.push(
|
||||
text(crate::app::startup::gpu_platform_hint())
|
||||
.size(13)
|
||||
.style(dialog_muted_text_style),
|
||||
);
|
||||
|
||||
container(
|
||||
column![
|
||||
body,
|
||||
Space::new().height(20),
|
||||
row![
|
||||
dialog_button(t!("OK"), Message::CloseModal, button::primary),
|
||||
Space::new().width(8),
|
||||
dialog_button(
|
||||
crate::tr!("gpu", "silence"),
|
||||
Message::GpuWarningSilence,
|
||||
button::secondary
|
||||
),
|
||||
],
|
||||
]
|
||||
.spacing(0),
|
||||
)
|
||||
.style(dialog_body_style)
|
||||
.center_x(sizing.width)
|
||||
.center_y(sizing.height)
|
||||
.padding([24, 28])
|
||||
.into()
|
||||
}
|
||||
|
||||
fn donation_dialog_window(sizing: crate::ui::modal::ModalSizing) -> Element<'static, Message> {
|
||||
container(
|
||||
column![
|
||||
|
||||
@@ -3576,6 +3576,7 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static
|
||||
"Start a new editor process." => Some(("common", "start-a-new-editor-process")),
|
||||
"Open read-only: saving is disabled." => Some(("common", "open-read-only-saving-is-disabled")),
|
||||
"GPU backend (dx12, vulkan, gl, metal). Sets WGPU_BACKEND." => Some(("common", "gpu-backend-dx12-vulkan-gl-metal-sets-wgpu-backend")),
|
||||
"GPU unavailable — rendering in software on the CPU ({adapter}). Large drawings will navigate slowly; check the graphics driver (a driver update without a reboot is the usual cause)." => Some(("common", "gpu-unavailable-rendering-in-software-on-the-cpu")),
|
||||
"Safe mode: use the GL backend." => Some(("common", "safe-mode-use-the-gl-backend")),
|
||||
"Use the renderer for GPUs without shader storage buffers." => Some(("common", "use-the-renderer-for-gpus-without-shader-storage-buffers")),
|
||||
"Run the headless JSON automation server (stdin/stdout, or --port)." => Some(("common", "run-the-headless-json-automation-server-stdin-stdout-or-port")),
|
||||
|
||||
@@ -3,6 +3,43 @@ use crate::scene::model::{mesh_model::MeshLodSet, mesh_model::MeshModel, wire_mo
|
||||
use acadrust::{types::Transform, Handle};
|
||||
use iced::futures::executor::block_on;
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires a GPU adapter"]
|
||||
fn the_adapter_is_reported_once_per_device() {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let adapter = block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default()))
|
||||
.expect("GPU adapter");
|
||||
let (device, _queue) = block_on(adapter.request_device(&wgpu::DeviceDescriptor {
|
||||
required_limits: adapter.limits(),
|
||||
..Default::default()
|
||||
}))
|
||||
.expect("GPU device");
|
||||
let mut seen = 0;
|
||||
// Drain whatever an earlier test on this process recorded.
|
||||
let _ = gpu_status_if_changed(&mut seen);
|
||||
|
||||
record_gpu_adapter(&device);
|
||||
let info = adapter.get_info();
|
||||
let status = gpu_status_if_changed(&mut seen).expect("a new device moves the verdict");
|
||||
let reported = match &status {
|
||||
GpuStatus::Hardware(reported) | GpuStatus::Software(reported) => reported,
|
||||
other => panic!("a device must yield an adapter verdict, got {other:?}"),
|
||||
};
|
||||
assert_eq!(reported.name, info.name);
|
||||
assert_eq!(reported.backend, info.backend);
|
||||
assert_eq!(reported.device_type, info.device_type);
|
||||
assert_eq!(
|
||||
matches!(status, GpuStatus::Software(_)),
|
||||
info.device_type == wgpu::DeviceType::Cpu,
|
||||
"{status:?}"
|
||||
);
|
||||
assert!(
|
||||
gpu_status_if_changed(&mut seen).is_none(),
|
||||
"nothing changed, so the per-message check must stay silent"
|
||||
);
|
||||
assert_eq!(gpu_status(), status);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires a GPU adapter"]
|
||||
fn block_edits_preserve_cache_coordinates_and_arena_partition() {
|
||||
|
||||
@@ -5650,9 +5650,228 @@ fn install_gpu_error_handler(device: &wgpu::Device) {
|
||||
}));
|
||||
}
|
||||
|
||||
/// The adapter wgpu ended up drawing on.
|
||||
///
|
||||
/// iced chooses it and names it only under `RUST_LOG`, so a fallback to a
|
||||
/// software rasterizer used to be invisible: an NVIDIA userspace update
|
||||
/// applied without a reboot left the Vulkan ICD unable to talk to the loaded
|
||||
/// kernel module, wgpu quietly took Mesa's `llvmpipe`, and a day of pan/zoom
|
||||
/// on the CPU read as a regression in the drawing code. `record_gpu_adapter`
|
||||
/// now reports one `[gpu] adapter:` line per device and keeps a copy for the
|
||||
/// app, which turns it into a [`GpuStatus`] the user can see.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct GpuAdapter {
|
||||
pub name: String,
|
||||
pub backend: wgpu::Backend,
|
||||
pub device_type: wgpu::DeviceType,
|
||||
}
|
||||
|
||||
impl GpuAdapter {
|
||||
/// A software rasterizer: every frame is drawn by the CPU, which on a
|
||||
/// large drawing is the difference between interactive and unusable.
|
||||
///
|
||||
/// `DeviceType::Cpu` is what Vulkan says for llvmpipe, DX12 for WARP and
|
||||
/// GL for anything whose renderer string gives it away. WebGPU never says
|
||||
/// it — wgpu reports every browser adapter as `Other` (gfx-rs/wgpu#8819)
|
||||
/// — so the names browsers give their fallbacks are matched as well, the
|
||||
/// same way wgpu's own GL backend classifies them.
|
||||
pub fn is_software(&self) -> bool {
|
||||
if self.device_type == wgpu::DeviceType::Cpu {
|
||||
return true;
|
||||
}
|
||||
let name = self.name.to_ascii_lowercase();
|
||||
SOFTWARE_RASTERIZER_NAMES
|
||||
.iter()
|
||||
.any(|marker| name.contains(marker))
|
||||
}
|
||||
}
|
||||
|
||||
/// Renderer names that mean "no GPU", lower-cased: Mesa's llvmpipe (also
|
||||
/// exposed as lavapipe under Vulkan), Google's SwiftShader (Chrome's WebGL /
|
||||
/// WebGPU fallback), and Microsoft's WARP, which DXGI names as a driver.
|
||||
const SOFTWARE_RASTERIZER_NAMES: [&str; 5] = [
|
||||
"llvmpipe",
|
||||
"lavapipe",
|
||||
"softpipe",
|
||||
"swiftshader",
|
||||
"microsoft basic render",
|
||||
];
|
||||
|
||||
/// What is drawing the scene, as far as the app can tell.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum GpuStatus {
|
||||
/// No scene frame has completed yet, so nothing is known.
|
||||
Unknown,
|
||||
/// A GPU, through wgpu. The normal case; nothing to say.
|
||||
Hardware(GpuAdapter),
|
||||
/// A CPU rasterizer, through wgpu — llvmpipe, WARP, SwiftShader. Every
|
||||
/// frame works, slowly, and the driver that should be here is not.
|
||||
Software(GpuAdapter),
|
||||
/// wgpu found no adapter at all. iced then draws the interface with
|
||||
/// tiny-skia and drops the scene primitive with a `log::warn!` nobody
|
||||
/// sees, so the viewport stays blank while everything else works.
|
||||
NoRenderer,
|
||||
}
|
||||
|
||||
impl GpuStatus {
|
||||
/// True when the user should be told: the scene is slow or missing.
|
||||
pub fn is_degraded(&self) -> bool {
|
||||
matches!(self, Self::Software(_) | Self::NoRenderer)
|
||||
}
|
||||
|
||||
/// A stable name for "this situation", so the popup can be silenced for
|
||||
/// one adapter without silencing the next one.
|
||||
pub fn identity(&self) -> Option<String> {
|
||||
match self {
|
||||
Self::Software(adapter) => Some(format!("software:{}", adapter.name)),
|
||||
Self::NoRenderer => Some("no-renderer".to_string()),
|
||||
Self::Unknown | Self::Hardware(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The adapter of the most recently created device.
|
||||
static GPU_ADAPTER: std::sync::Mutex<Option<GpuAdapter>> = std::sync::Mutex::new(None);
|
||||
|
||||
/// How many times the viewport has asked the scene for a primitive. Counted
|
||||
/// in `ViewportPane::draw`, which every renderer calls; only wgpu goes on to
|
||||
/// `prepare` and build a device. Draws without a device are therefore the
|
||||
/// evidence that the scene is being dropped.
|
||||
static SCENE_DRAWS: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
|
||||
|
||||
/// Draws the viewport may request before a still-missing device counts as
|
||||
/// `NoRenderer`. Under wgpu the device exists before the first draw's frame
|
||||
/// ends, so one would do; the margin covers a frame that was drawn but never
|
||||
/// presented (an outdated surface, an occluded window).
|
||||
const SCENE_DRAWS_BEFORE_NO_RENDERER: u32 = 3;
|
||||
|
||||
/// Bumped whenever the verdict of [`gpu_status`] can have changed: a device
|
||||
/// was created, or the draw count reached its threshold. The app compares it
|
||||
/// against what it last saw, so the per-message check is one atomic load.
|
||||
static GPU_STATUS_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
|
||||
/// Called by the viewport's `shader::Program::draw`, whichever renderer runs it.
|
||||
pub(crate) fn note_scene_draw() {
|
||||
use std::sync::atomic::Ordering;
|
||||
if SCENE_DRAWS.fetch_add(1, Ordering::Relaxed) + 1 == SCENE_DRAWS_BEFORE_NO_RENDERER {
|
||||
GPU_STATUS_GENERATION.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// One line for whoever reads logs: stderr natively, the browser console on
|
||||
/// the web, where wasm has no stderr to write to.
|
||||
pub(crate) fn report_gpu_line(line: &str) {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
web_sys::console::warn_1(&wasm_bindgen::JsValue::from_str(line));
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
eprintln!("{line}");
|
||||
}
|
||||
|
||||
fn record_gpu_adapter(device: &wgpu::Device) {
|
||||
let info = device.adapter_info();
|
||||
report_gpu_line(&format!(
|
||||
"[gpu] adapter: {} ({:?}, {:?}, driver: {} {})",
|
||||
info.name, info.backend, info.device_type, info.driver, info.driver_info
|
||||
));
|
||||
let adapter = GpuAdapter {
|
||||
name: info.name,
|
||||
backend: info.backend,
|
||||
device_type: info.device_type,
|
||||
};
|
||||
if let Ok(mut slot) = GPU_ADAPTER.lock() {
|
||||
*slot = Some(adapter);
|
||||
}
|
||||
GPU_STATUS_GENERATION.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// The verdict from what has been recorded so far. Pure, so it can be tested
|
||||
/// without a device: `adapter` is the last device's adapter, `scene_draws`
|
||||
/// how often the viewport asked to be drawn.
|
||||
fn gpu_status_from(adapter: Option<GpuAdapter>, scene_draws: u32) -> GpuStatus {
|
||||
match adapter {
|
||||
Some(adapter) if adapter.is_software() => GpuStatus::Software(adapter),
|
||||
Some(adapter) => GpuStatus::Hardware(adapter),
|
||||
None if scene_draws >= SCENE_DRAWS_BEFORE_NO_RENDERER => GpuStatus::NoRenderer,
|
||||
None => GpuStatus::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
/// The current verdict.
|
||||
pub(crate) fn gpu_status() -> GpuStatus {
|
||||
let adapter = GPU_ADAPTER.lock().ok().and_then(|slot| slot.clone());
|
||||
gpu_status_from(adapter, SCENE_DRAWS.load(std::sync::atomic::Ordering::Relaxed))
|
||||
}
|
||||
|
||||
/// The verdict, but only when it may have moved since `seen` — the app calls
|
||||
/// this after every message, and almost every call is one atomic load.
|
||||
pub(crate) fn gpu_status_if_changed(seen: &mut u64) -> Option<GpuStatus> {
|
||||
let generation = GPU_STATUS_GENERATION.load(std::sync::atomic::Ordering::Relaxed);
|
||||
if generation == *seen {
|
||||
return None;
|
||||
}
|
||||
*seen = generation;
|
||||
Some(gpu_status())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod gpu_status_tests {
|
||||
use super::*;
|
||||
|
||||
fn adapter(name: &str, device_type: wgpu::DeviceType) -> GpuAdapter {
|
||||
GpuAdapter {
|
||||
name: name.to_string(),
|
||||
backend: wgpu::Backend::Vulkan,
|
||||
device_type,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn software_is_the_device_type_or_a_known_fallback_name() {
|
||||
assert!(adapter("llvmpipe (LLVM 21.1.8, 256 bits)", wgpu::DeviceType::Cpu).is_software());
|
||||
// WebGPU reports every adapter as Other, so the name has to carry it.
|
||||
assert!(adapter(
|
||||
"ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero)), SwiftShader driver)",
|
||||
wgpu::DeviceType::Other
|
||||
)
|
||||
.is_software());
|
||||
assert!(adapter("Microsoft Basic Render Driver", wgpu::DeviceType::Other).is_software());
|
||||
assert!(!adapter("NVIDIA GeForce GT 1030", wgpu::DeviceType::DiscreteGpu).is_software());
|
||||
assert!(!adapter("Apple M2", wgpu::DeviceType::IntegratedGpu).is_software());
|
||||
assert!(!adapter("", wgpu::DeviceType::Other).is_software());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_verdict_needs_either_a_device_or_enough_dropped_draws() {
|
||||
assert_eq!(gpu_status_from(None, 0), GpuStatus::Unknown);
|
||||
assert_eq!(
|
||||
gpu_status_from(None, SCENE_DRAWS_BEFORE_NO_RENDERER - 1),
|
||||
GpuStatus::Unknown
|
||||
);
|
||||
assert_eq!(
|
||||
gpu_status_from(None, SCENE_DRAWS_BEFORE_NO_RENDERER),
|
||||
GpuStatus::NoRenderer
|
||||
);
|
||||
let gpu = adapter("NVIDIA GeForce GT 1030", wgpu::DeviceType::DiscreteGpu);
|
||||
assert_eq!(gpu_status_from(Some(gpu.clone()), 100), GpuStatus::Hardware(gpu));
|
||||
let cpu = adapter("llvmpipe", wgpu::DeviceType::Cpu);
|
||||
assert_eq!(gpu_status_from(Some(cpu.clone()), 0), GpuStatus::Software(cpu));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_degraded_verdicts_carry_an_identity() {
|
||||
assert!(GpuStatus::Unknown.identity().is_none());
|
||||
assert!(GpuStatus::Hardware(adapter("x", wgpu::DeviceType::DiscreteGpu)).identity().is_none());
|
||||
assert_eq!(GpuStatus::NoRenderer.identity().as_deref(), Some("no-renderer"));
|
||||
let software = GpuStatus::Software(adapter("llvmpipe", wgpu::DeviceType::Cpu));
|
||||
assert!(software.is_degraded());
|
||||
assert_eq!(software.identity().as_deref(), Some("software:llvmpipe"));
|
||||
}
|
||||
}
|
||||
|
||||
impl iced::widget::shader::Pipeline for MultiPipeline {
|
||||
fn new(device: &wgpu::Device, queue: &wgpu::Queue, format: wgpu::TextureFormat) -> Self {
|
||||
install_gpu_error_handler(device);
|
||||
record_gpu_adapter(device);
|
||||
Self {
|
||||
block_geometry: Default::default(),
|
||||
inners: vec![Pipeline::new(device, queue, format)],
|
||||
|
||||
@@ -81,6 +81,9 @@ impl<'a, Msg: std::fmt::Debug + Clone> shader::Program<Msg> for ViewportPane<'a>
|
||||
_cursor: mouse::Cursor,
|
||||
bounds: Rectangle,
|
||||
) -> Self::Primitive {
|
||||
// Every renderer calls this; only wgpu goes on to `prepare` the
|
||||
// primitive. The count is how the app notices when nobody does.
|
||||
crate::scene::pipeline::note_scene_draw();
|
||||
match self.pane {
|
||||
Some(idx) => self.scene.build_viewport_for_pane(
|
||||
bounds,
|
||||
|
||||
@@ -363,6 +363,14 @@ impl CommandLine {
|
||||
}
|
||||
self.push(EntryKind::Error, text);
|
||||
}
|
||||
/// Append a warning about the session that no command caused (the
|
||||
/// renderer fell back to the CPU, say). Styled like an error so it is
|
||||
/// seen, but it must not touch `last_error` / `error_revision`:
|
||||
/// automation reads those to decide whether the command it just ran
|
||||
/// failed, and a warning is not a failed command.
|
||||
pub fn push_warning(&mut self, msg: &str) {
|
||||
self.push(EntryKind::Error, format!("*{}* {msg}", t!("Warning")));
|
||||
}
|
||||
pub fn push_info(&mut self, msg: &str) {
|
||||
let text = if msg.starts_with("i ") {
|
||||
msg.to_string()
|
||||
@@ -1050,6 +1058,18 @@ mod tests {
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_warning_is_shown_without_counting_as_a_failed_command() {
|
||||
let mut line = CommandLine::new();
|
||||
let revision = line.error_revision;
|
||||
line.push_warning("GPU unavailable");
|
||||
let last = line.history.last().expect("warning must be appended");
|
||||
assert_eq!(last.kind, super::EntryKind::Error);
|
||||
assert!(last.text.ends_with("GPU unavailable"), "{}", last.text);
|
||||
assert_eq!(line.error_revision, revision);
|
||||
assert!(line.last_error.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_status_distinguishes_off_ready_and_busy() {
|
||||
assert_eq!(mcp_status(false, false).0, "MCP control is off");
|
||||
|
||||
@@ -135,6 +135,8 @@ impl StatusBar {
|
||||
// the pill entirely, so an ordinarily/fully-constrained drawing sees
|
||||
// no new clutter.
|
||||
sketch_conflicts: usize,
|
||||
// What is drawing the scene. Only a degraded verdict shows anything.
|
||||
gpu_status: &'a crate::scene::pipeline::GpuStatus,
|
||||
) -> Element<'a, Message> {
|
||||
let StatusMenuData {
|
||||
layout_names,
|
||||
@@ -243,6 +245,20 @@ impl StatusBar {
|
||||
// when the width can't hold them all on one line.
|
||||
let vis = |p: StatusPill| config.is_visible(p);
|
||||
let mut pills: Vec<Element<'_, Message>> = Vec::new();
|
||||
// Not a `StatusPill` and not hideable: the scene is on a software
|
||||
// rasterizer or not drawn at all, and the popup that said so has been
|
||||
// dismissed. This stays for the session and reopens it. Many people
|
||||
// never read the command line, so this is the one place the fact
|
||||
// remains visible.
|
||||
if let Some(label) = gpu_pill_label(gpu_status) {
|
||||
let detail = match gpu_status {
|
||||
crate::scene::pipeline::GpuStatus::Software(adapter) => {
|
||||
format!("{}\n{}", adapter.name, crate::tr!("gpu", "pill-tip"))
|
||||
}
|
||||
_ => crate::tr!("gpu", "pill-tip"),
|
||||
};
|
||||
pills.push(tip(warning_pill(label, Message::GpuWarningOpen), detail.into()).into());
|
||||
}
|
||||
if vis(StatusPill::Coords) {
|
||||
let coords_label = format_coords(cursor_world, last_point, coords_mode, picking);
|
||||
pills.push(
|
||||
@@ -1079,6 +1095,27 @@ fn status_pill(label: impl Into<String>) -> Element<'static, Message> {
|
||||
}
|
||||
|
||||
/// A success-colored status pill used when no degrees of freedom remain.
|
||||
/// The status-bar text for a degraded graphics verdict; `None` when there is
|
||||
/// nothing to say, which is the case the bar must not clutter.
|
||||
fn gpu_pill_label(status: &crate::scene::pipeline::GpuStatus) -> Option<String> {
|
||||
use crate::scene::pipeline::GpuStatus;
|
||||
match status {
|
||||
GpuStatus::Software(_) => Some(crate::tr!("gpu", "pill-software")),
|
||||
GpuStatus::NoRenderer => Some(crate::tr!("gpu", "pill-no-renderer")),
|
||||
GpuStatus::Unknown | GpuStatus::Hardware(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A pill in the theme's warning colours: the only status-bar item that
|
||||
/// means "something is wrong", so it must not look like a toggle.
|
||||
fn warning_pill(label: impl Into<String>, msg: Message) -> Element<'static, Message> {
|
||||
button(text(label.into()).size(12))
|
||||
.on_press(msg)
|
||||
.style(button::warning)
|
||||
.padding([4, 8])
|
||||
.into()
|
||||
}
|
||||
|
||||
fn success_pill(label: impl Into<String>) -> Element<'static, Message> {
|
||||
container(text(label.into()).size(12))
|
||||
.style(|theme: &Theme| {
|
||||
|
||||
Reference in new issue
Block a user