Compare commits

...

3 Commits

Author SHA1 Message Date
bherbruck 9dda74755e Graphics trial: close at once when it gives up, leave a planned restart without tearing down the GPU, and start again when a trial draws no window
The question's 15 s timeout set the quit in the UI pass but asked for no further
frame, so an idle window stayed open on the settings that didn't work until the
pointer moved. Request the frame.

A restart for graphics settings now ends the process once the next start has begun,
as after a lost device, instead of tearing down the swap chain, device and
pipelines of the API being left.

A start on trial that draws no frame within 60 s starts again with the saved settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 10:55:19 -05:00
Karl Von Richter 605e0a935c Preferences: choose the graphics API (Direct3D 12, Vulkan or OpenGL), tried at the next start and kept only when you confirm (#4015)
Preferences > Graphics gets a Graphics API choice: Automatic, Direct3D 12,
Vulkan and OpenGL on Windows, Vulkan and OpenGL on Linux (hidden on macOS).
A change there, and a change to anti-aliasing, is written to graphics.json
as a trial. The next start tries it and asks "Keep these graphics
settings?"; Go back, or no answer within 15 s, starts again with the saved
settings, and a start that finds the trial already started drops it with a
note. ui.prefs saves graphics_api and msaa at once, without a trial.

On computers whose Direct3D 12 GPUs are all AMD integrated, Preferences
warns before Direct3D 12, and a crash on it goes back to Automatic.

Builds on #3322's saved backend, hang check and restart.

Closes #4015.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 20:05:29 +10:00
Karl Von Richter 5a09a9c49e Windows: start on Vulkan on AMD integrated graphics, and restart on another graphics API when the GPU hangs (#3321)
On Windows PCs whose only graphics are AMD integrated GPUs, Automatic starts Vulkan instead of
Direct3D 12, whose driver hung there a few seconds after start.

A GPU that hangs after the window is up (frames that each wait out their acquire, or a
main-window surface that keeps refusing its configuration, while the user is there) closes the
app, keeping unsaved changes for recovery, and starts it again on the next API, kept in
graphics.json: D3D12 -> Vulkan -> OpenGL -> the software renderer. Only stalls after the user's
input count, so waking a display doesn't restart a healthy app. The restart doesn't inherit
WGPU_BACKEND or a start-up retry's backend (#6682), which would win over the saved one, and later
launches don't inherit its notice or the device-lost one (#4229). graphics.json is written to a
new file, then renamed over the old one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 20:05:13 +10:00
14 changed files with 1482 additions and 99 deletions
+2 -1
View File
@@ -76,7 +76,8 @@ fn command(
reason: &str,
) -> std::process::Command {
let mut command = std::process::Command::new(exe);
command.args(args).env(LOST_ENV, lost_at.to_string());
// The notice of a hang that restarted this launch isn't the next one's (`App::watch_for_a_hang`).
command.args(args).env(LOST_ENV, lost_at.to_string()).env_remove(startup::HUNG_ENV);
match self::reason(Some(reason)) {
Some(reason) => command.env(REASON_ENV, reason),
// Not one inherited from an earlier loss.
+254 -42
View File
@@ -93,6 +93,12 @@ struct App {
come_forward: come_forward::ComeForward,
/// The graphics device: lost, it can't draw again (`App::watch_device`, #4172).
gpu: Option<LostWatch>,
/// The window's backend, and whether it is the software renderer.
renderer: Option<(eframe::wgpu::Backend, bool)>,
/// A GPU that hangs after the window is up (`App::watch_for_a_hang`).
hang: startup::HangWatch,
/// The GPU hung: how the next launch starts, once this one has closed.
restart: Option<startup::Restart>,
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
space_mouse: space_mouse::SpaceMouse,
#[cfg(target_os = "macos")]
@@ -213,12 +219,14 @@ impl eframe::App for App {
solidcraft_ui_egui::ai_connect::begin(&mut self.solve);
solidcraft_ui_egui::agent_chat::begin(&mut self.solve);
}
startup::first_frame_drawn();
if let Some(dir) = self.starting.take() {
startup::started(&dir);
}
}
}
self.watch_device();
self.watch_for_a_hang(ctx);
// The design on screen is the one whose commands a crash report replays.
self.solve.session.crash_journal = solidcraft_engine::crash::enabled();
// The OS closing the window (Alt+F4, Cmd+Q, the window manager): ask about unsaved
@@ -319,11 +327,22 @@ impl Drop for App {
// The window's GPU surface is still to be dropped, and on a lost device that may
// never return (#9044).
device_lost::end();
} else if let Some((restart, (backend, _))) = self.restart.zip(self.renderer) {
restart_after_hang(restart, backend);
} else if self.solve.graphics.restart {
// Preferences: to try graphics settings, or to go back from them (#4015). The next
// start has begun: this one leaves at once. Tearing down a window's swap chain, device
// and pipelines can hang or crash in a graphics driver (#9044), on the very API that
// the user is leaving, and a crash here would be taken for one of the new start.
relaunch(None);
control_server::remove_token_file();
device_lost::end();
}
// An update asked to restart into the new version or to run its installer.
if let Some((program, args)) = solidcraft_ui_egui::update::take_launch(&mut self.solve) {
let mut command = std::process::Command::new(&program);
command.args(&args);
// This launch's notices aren't the new version's.
command.args(&args).env_remove(startup::HUNG_ENV);
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
@@ -364,6 +383,109 @@ impl App {
gpu.reason = reason;
self.solve.quit_requested = true;
}
/// The GPU hung (frames wait out their acquire, or the surface refuses its configuration, while
/// the user is there, #3321): close, keeping unsaved changes for recovery as a forced quit
/// does (the window, white, can't ask about them), and start again on another renderer once
/// closed ([`restart_after_hang`]). Windows only: elsewhere a hidden window's frames can wait
/// as long (Wayland, macOS).
fn watch_for_a_hang(&mut self, ctx: &egui::Context) {
if !cfg!(target_os = "windows") {
return;
}
let (now, input) = ctx.input(|i| {
let input = i.events.iter().any(|e| {
matches!(
e,
egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. } | egui::Event::Key { .. } | egui::Event::MouseWheel { .. }
)
});
(i.time, input)
});
let stalled = eframe::egui_wgpu::stalled_frames();
let refused = eframe::egui_wgpu::refused_for().map(|d| d.as_secs_f64());
if !self.hang.hung(now, input, stalled, refused) {
return;
}
let why = match refused {
Some(s) if s > 0.0 => format!("the surface refused its configuration for {s:.0} s"),
_ => format!("{stalled} frames in a row waited out their acquire"),
};
let trial = self.solve.graphics.trial.is_some();
let Some(restart) = self.renderer.and_then(|(backend, cpu)| startup::after_hang(backend, cpu, trial)) else {
log::error!("graphics: {why}, on the software renderer");
return;
};
log::error!("graphics: {why}; the GPU hung, restarting: {restart:?}");
eprintln!("SolidCraft graphics: the GPU stopped responding; restarting ({restart:?})");
if let startup::Restart::Backend(backend) = restart {
remember_backend(backend);
}
self.restart = Some(restart);
self.solve.quit_requested = true;
}
}
/// Start SolidCraft again after the GPU hung on `hung`: on the backend [`remember_backend`]
/// saved, or as it was (settings on trial, which the next start drops).
fn restart_after_hang(restart: startup::Restart, hung: eframe::wgpu::Backend) {
match restart {
startup::Restart::Backend(_) => relaunch(Some(hung)),
startup::Restart::Again => relaunch(None),
}
}
/// Start SolidCraft again once this one has closed, with the same arguments. `hung`: the backend
/// the GPU hung on, which the new start's notice names; `WGPU_BACKEND` is then not passed on, as
/// it would win over the backend [`remember_backend`] saved and start the one that hung again. A
/// start-up retry's backend never is: it would win over the saved or tried settings.
fn relaunch(hung: Option<eframe::wgpu::Backend>) {
let Ok(exe) = std::env::current_exe() else { return };
let mut command = std::process::Command::new(exe);
// This launch's notices and start-up retry aren't the next one's.
command
.args(std::env::args_os().skip(1))
.env_remove(startup::HUNG_ENV)
.env_remove(startup::RETRY_BACKEND_ENV)
.env_remove(startup::FALLBACK_NOTE_ENV)
.env_remove(device_lost::LOST_ENV);
if let Some(hung) = hung {
command.env(startup::HUNG_ENV, format!("{hung:?}")).env_remove(startup::WGPU_BACKEND_ENV);
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
// Not tied to this process: its own process group, no console.
command.creation_flags(0x0000_0200 | 0x0000_0008);
}
if let Err(e) = command.spawn() {
log::error!("graphics: starting again: {e}");
}
}
/// Keep `backend` as `graphics.json`'s `backend` (its other settings stay). Written to a new file
/// that then replaces the old one: this runs as the GPU hangs, when the machine may reset next,
/// and a half-written file would lose the anti-aliasing setting too.
fn remember_backend(backend: &str) {
let Some(path) = graphics_path() else { return };
let mut graphics = graphics().filter(serde_json::Value::is_object).unwrap_or_else(|| serde_json::json!({}));
graphics["backend"] = serde_json::json!(backend);
if let Err(e) = write_replacing(&path, graphics.to_string().as_bytes()) {
log::error!("graphics: keeping the {backend} backend in {}: {e}", path.display());
}
}
/// Write `bytes` to `path` all at once: to a file next to it, flushed to disk, then renamed over it.
fn write_replacing(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
use std::io::Write;
let mut name = path.file_name().unwrap_or_default().to_os_string();
name.push(".new");
let new = path.with_file_name(name);
let mut file = std::fs::File::create(&new)?;
file.write_all(bytes)?;
file.sync_all()?;
drop(file);
std::fs::rename(&new, path)
}
/// Where the next file dialog opens (#7756): the folder of the file last chosen in one, or of the
@@ -785,7 +907,8 @@ fn graphics() -> Option<serde_json::Value> {
graphics_path().and_then(|p| std::fs::read_to_string(p).ok()).and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
}
/// The graphics backend preference (`graphics.json`'s `backend`: `auto`, `dx12`, `vulkan`, `gl`).
/// The graphics backend preference (`graphics.json`'s `backend`: `auto`, `dx12`, `vulkan`, `gl`,
/// or `cpu`, the software renderer).
fn backend_preference() -> Option<String> {
graphics().and_then(|v| v.get("backend").and_then(serde_json::Value::as_str).map(str::to_string))
}
@@ -849,7 +972,7 @@ const DEPTH_BITS: u8 = 24;
const FLOAT_DEPTH_BITS: u8 = 32;
/// The section cap marks where it shows in the stencil.
const STENCIL_BITS: u8 = 8;
const MSAA: u16 = 4;
const MSAA: u16 = startup::DEFAULT_MSAA;
/// `custom_titlebar`: no OS decorations, the application bar is the title bar.
fn app_viewport(custom_titlebar: bool) -> Result<egui::ViewportBuilder, eframe::Error> {
@@ -970,28 +1093,6 @@ fn main() -> eframe::Result {
safe_mode = true;
notes.push(format!("The last {failed_starts} launches stopped before the window opened, so this one is in safe mode."));
}
let cpu_renderer = safe_mode || startup::cpu_renderer_env(std::env::var(startup::RENDERER_ENV).ok().as_deref());
// The launch before this one closed because its graphics device was lost (`App::watch_device`).
let after_loss = device_lost::recent(std::env::var(device_lost::LOST_ENV).ok().as_deref(), device_lost::now_secs());
if after_loss {
let reason = std::env::var(device_lost::REASON_ENV).ok();
if let Some(reason) = device_lost::reason(reason.as_deref()) {
log::warn!("graphics: the last launch lost its device ({reason})");
}
notes.push(device_lost::note(cpu_renderer, reason.as_deref()));
}
if safe_mode {
notes.push("Safe mode: default settings (yours are kept for the next normal start), no recovery offer, software rendering.".into());
}
let fallback_note = std::env::var(startup::FALLBACK_NOTE_ENV).ok().map(|s| s.chars().take(4096).collect::<String>());
if let Some(note) = &fallback_note {
log::warn!("{note}");
eprintln!("SolidCraft: {note}");
notes.push(note.clone());
}
let saved_samples = if safe_mode { None } else { msaa() };
let wgpu_options = wgpu_options(cpu_renderer, if safe_mode { None } else { backend_preference() }.as_deref());
let depth_bits = if !safe_mode && startup::float_depth_everywhere(&wgpu_options) { FLOAT_DEPTH_BITS } else { DEPTH_BITS };
// Crashes leave a report in the crash folder (solidcraft_engine::crash, docs/crash-reports.md).
let crashed = solidcraft_engine::crash::default_dir()
.map(|dir| {
@@ -1003,6 +1104,73 @@ fn main() -> eframe::Result {
solidcraft_engine::crash::install(dir, build)
})
.unwrap_or_default();
// The graphics API and anti-aliasing: the saved ones, or ones tried from Preferences (#4015).
let mut graphics = startup::graphics_start(graphics().as_ref(), safe_mode);
if let Some(file) = graphics.rewrite.take() {
let written = graphics_path().is_some_and(|path| match write_replacing(&path, file.to_string().as_bytes()) {
Ok(()) => true,
Err(e) => {
log::error!("graphics: writing {}: {e}", path.display());
false
}
});
// A trial that can't be marked as started would be tried at every start.
if !written && matches!(graphics.trial, startup::Trial::Trying(_)) {
graphics.trial = startup::Trial::None;
}
}
notes.extend(graphics.note());
// The last launch crashed on an API Preferences warns about on this system: back to Automatic.
let last_crashed = crashed.iter().any(|r| startup::launch_crashed(&r.kind));
if !safe_mode
&& graphics.trial == startup::Trial::None
&& let Some(auto) = startup::after_crash(&graphics.saved, last_crashed, || {
startup::apis_that_freeze(cfg!(target_os = "windows"), startup::hardware_adapters_once)
})
{
notes.push(startup::after_crash_note(&graphics.saved.backend));
remember_backend(&auto.backend);
graphics.saved = auto;
}
let current = graphics.current().clone();
let trying = matches!(graphics.trial, startup::Trial::Trying(_));
// The software renderer: safe mode, SOLIDCRAFT_RENDERER, or kept after the GPU hung on every
// API (`startup::after_hang`).
let cpu_renderer = safe_mode
|| startup::cpu_renderer_env(std::env::var(startup::RENDERER_ENV).ok().as_deref())
|| startup::cpu_renderer_env(Some(&current.backend));
// The launch before this one closed because its graphics device was lost (`App::watch_device`).
let after_loss = device_lost::recent(std::env::var(device_lost::LOST_ENV).ok().as_deref(), device_lost::now_secs());
if after_loss {
let reason = std::env::var(device_lost::REASON_ENV).ok();
if let Some(reason) = device_lost::reason(reason.as_deref()) {
log::warn!("graphics: the last launch lost its device ({reason})");
}
notes.push(device_lost::note(cpu_renderer, reason.as_deref()));
}
// The launch before this one closed because its GPU hung (`App::watch_for_a_hang`).
if let Some(hung) = std::env::var(startup::HUNG_ENV).ok().filter(|v| !v.trim().is_empty()) {
notes.push(startup::hang_note(&hung, safe_mode, backend_preference().as_deref()));
}
if safe_mode {
notes.push("Safe mode: default settings (yours are kept for the next normal start), no recovery offer, software rendering.".into());
}
let fallback_note = std::env::var(startup::FALLBACK_NOTE_ENV).ok().map(|s| s.chars().take(4096).collect::<String>());
if let Some(note) = &fallback_note {
log::warn!("{note}");
eprintln!("SolidCraft: {note}");
notes.push(note.clone());
}
// Anti-aliasing on trial is asked for; a saved one only once Graphics changed it (`msaa`).
let saved_samples = if safe_mode {
None
} else if trying {
Some(current.msaa)
} else {
msaa()
};
let wgpu_options = wgpu_options(cpu_renderer, (!safe_mode).then_some(current.backend.as_str()));
let depth_bits = if !safe_mode && startup::float_depth_everywhere(&wgpu_options) { FLOAT_DEPTH_BITS } else { DEPTH_BITS };
// A crash in native code (a graphics driver) says where it happened: the exception or signal
// and the module on Windows and macOS, and on every system the startup step the app was in.
#[cfg(target_os = "windows")]
@@ -1074,6 +1242,15 @@ fn main() -> eframe::Result {
#[cfg(target_os = "macos")]
let _driver_client = DriverClient;
mark("run_native");
if trying {
// A new graphics API that hangs before the window draws leaves nothing to click on.
startup::watch_trial_start(startup::TRIAL_START_LIMIT, || {
eprintln!("SolidCraft graphics: the settings on trial drew no window in {} s; starting again", startup::TRIAL_START_LIMIT.as_secs());
relaunch(None);
control_server::remove_token_file();
device_lost::end();
});
}
let result = eframe::run_native(
"SolidCraft",
options,
@@ -1122,8 +1299,21 @@ fn main() -> eframe::Result {
});
if !safe_mode {
app.preferences.graphics_adapter =
graphics().and_then(|v| v.get("adapter").and_then(serde_json::Value::as_str).map(str::to_string)).unwrap_or_default();
crate::graphics().and_then(|v| v.get("adapter").and_then(serde_json::Value::as_str).map(str::to_string)).unwrap_or_default();
}
// Preferences shows the graphics settings this start uses, saved or on trial (#4015).
app.preferences.graphics_api = current.backend.clone();
app.preferences.msaa = u32::from(current.msaa);
let saved = solidcraft_ui_egui::prefs::GraphicsChoice { api: graphics.saved.backend.clone(), msaa: u32::from(graphics.saved.msaa) };
app.graphics = solidcraft_ui_egui::prefs::Graphics {
apis: startup::GRAPHICS_APIS.to_vec(),
freezes: startup::apis_that_freeze(cfg!(target_os = "windows"), startup::hardware_adapters_once),
env_override: std::env::var(startup::WGPU_BACKEND_ENV).ok().filter(|v| !v.trim().is_empty()),
trial: trying.then(|| solidcraft_ui_egui::prefs::GraphicsTrial { previous: saved.clone(), shown: 0.0, last_frame: None }),
saved,
restart: false,
};
let mut renderer = None;
if let Some(rs) = &cc.wgpu_render_state {
app.services.graphics_adapters = rs
.available_adapters
@@ -1147,6 +1337,7 @@ fn main() -> eframe::Result {
let scene_samples = scene_samples(info.backend, u32::from(samples), &supported);
eprintln!("SolidCraft graphics: {:?}, MSAA {scene_samples}x (requested {}x)", rs.adapter.get_info().backend, samples);
solidcraft_engine::crash::phase("viewport pipelines (shader compile)");
renderer = Some((rs.adapter.get_info().backend, cpu_renderer));
let scene_samples = app.set_wgpu_split(rs, depth_bits, STENCIL_BITS, scene_samples);
app.msaa_supported = supported;
app.preferences.msaa = u32::from(samples);
@@ -1252,6 +1443,9 @@ fn main() -> eframe::Result {
take_focus_unwanted: control_port.is_some(),
come_forward: come_forward::ComeForward::default(),
gpu,
renderer,
hang: startup::HangWatch::default(),
restart: None,
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
space_mouse,
#[cfg(target_os = "macos")]
@@ -1263,7 +1457,7 @@ fn main() -> eframe::Result {
);
control_server::remove_token_file();
match result {
Err(e) if !created.load(std::sync::atomic::Ordering::SeqCst) => no_window(e, cpu_renderer, started, settings.as_deref()),
Err(e) if !created.load(std::sync::atomic::Ordering::SeqCst) => no_window(e, cpu_renderer, started, trying, settings.as_deref()),
r => r,
}
}
@@ -1279,8 +1473,8 @@ fn startup_step(step: &'static str, detail: &str) {
}
/// The renderer settings: with `cpu`, the software adapter (WARP, lavapipe, llvmpipe). The
/// backends come from `startup::choose_backends` (on Windows D3D12 first, and only the backend
/// in use is started); `preference` is `graphics.json`'s `backend`.
/// backends come from `startup::choose_backends` (on Windows D3D12 first, Vulkan on AMD
/// integrated GPUs, and only the backend in use is started); `preference` is `graphics.json`'s `backend`.
fn wgpu_options(cpu: bool, preference: Option<&str>) -> eframe::egui_wgpu::WgpuConfiguration {
let mut options = eframe::egui_wgpu::WgpuConfiguration::default();
if let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &mut options.wgpu_setup {
@@ -1288,7 +1482,7 @@ fn wgpu_options(cpu: bool, preference: Option<&str>) -> eframe::egui_wgpu::WgpuC
// A retry applies only to this launch. WGPU_BACKEND always has priority.
let retry = std::env::var(startup::RETRY_BACKEND_ENV).ok();
let preference = if cpu { preference } else { retry.as_deref().or(preference) };
if let Some(backends) = startup::choose_backends(cfg!(target_os = "windows"), env_set, cpu, preference, startup::has_hardware_adapter) {
if let Some(backends) = startup::choose_backends(cfg!(target_os = "windows"), env_set, cpu, preference, startup::hardware_adapters_once) {
eprintln!("SolidCraft graphics: starting {backends:?}");
setup.instance_descriptor.backends = backends;
}
@@ -1312,35 +1506,53 @@ fn wgpu_options(cpu: bool, preference: Option<&str>) -> eframe::egui_wgpu::WgpuC
}
/// A failed renderer returns here before app creation. A fresh process avoids reusing winit's
/// event loop or a failed device; each retry advances through the APIs, then software once.
fn no_window(error: eframe::Error, tried_cpu: bool, failed: eframe::wgpu::Backends, settings: Option<&std::path::Path>) -> eframe::Result {
/// event loop or a failed device; each retry advances through the APIs, then software once. With
/// graphics settings on trial (`trial`), once more as usual instead: that start drops them and
/// goes back to the saved ones.
fn no_window(
error: eframe::Error,
tried_cpu: bool,
failed: eframe::wgpu::Backends,
trial: bool,
settings: Option<&std::path::Path>,
) -> eframe::Result {
log::error!("the window could not open: {error}");
eprintln!("SolidCraft: the window could not open: {error}");
// The retry below runs as a child that this process waits for, for as long as it runs: not a
// start that never draws.
startup::first_frame_drawn();
// This failure was handled, so neither the marker nor the journal should call it a crash.
if let Some(dir) = settings {
startup::started(dir);
}
solidcraft_engine::crash::end_session();
let explicit = std::env::var(startup::WGPU_BACKEND_ENV).is_ok_and(|v| !v.trim().is_empty());
if let Some(next) = startup::fallback_backend(cfg!(target_os = "windows"), explicit, tried_cpu, failed)
let next = if trial { None } else { startup::fallback_backend(cfg!(target_os = "windows"), explicit, tried_cpu, failed) };
if (trial || next.is_some())
&& let Ok(exe) = std::env::current_exe()
{
let previous = std::env::var(startup::FALLBACK_NOTE_ENV).unwrap_or_default();
let note = startup::fallback_note(&previous, failed, next, &error.to_string());
log::warn!("{note}");
eprintln!("SolidCraft: {note}");
let mut command = std::process::Command::new(exe);
command.args(std::env::args_os().skip(1)).env(startup::FALLBACK_NOTE_ENV, note);
if next == "cpu" {
command.env(startup::RENDERER_ENV, "cpu").env_remove(startup::RETRY_BACKEND_ENV);
command.args(std::env::args_os().skip(1)).env_remove(startup::HUNG_ENV);
if let Some(next) = next {
let previous = std::env::var(startup::FALLBACK_NOTE_ENV).unwrap_or_default();
let note = startup::fallback_note(&previous, failed, next, &error.to_string());
log::warn!("{note}");
eprintln!("SolidCraft: {note}");
command.env(startup::FALLBACK_NOTE_ENV, note);
if next == "cpu" {
command.env(startup::RENDERER_ENV, "cpu").env_remove(startup::RETRY_BACKEND_ENV);
} else {
command.env(startup::RETRY_BACKEND_ENV, next);
}
} else {
command.env(startup::RETRY_BACKEND_ENV, next);
eprintln!("SolidCraft: trying again with the saved graphics settings");
command.env_remove(startup::RETRY_BACKEND_ENV).env_remove(startup::FALLBACK_NOTE_ENV);
}
match command.status() {
Ok(status) if status.success() => return Ok(()),
// The final try showed its own message.
Ok(_) => return Err(error),
Err(e) => log::error!("could not start again on {next}: {e}"),
Err(e) => log::error!("could not start again on {}: {e}", next.unwrap_or("the saved graphics settings")),
}
}
let message = if explicit {
+569 -19
View File
@@ -11,6 +11,13 @@
//! - **A renderer that cannot start**: on Windows, try the remaining graphics APIs in order,
//! then the software renderer (`SOLIDCRAFT_RENDERER=cpu`). An explicit `WGPU_BACKEND` fails
//! cleanly without switching. Exhausted retries show a message box with the error.
//! - **A GPU that hangs after the window is up** (Windows, [`HangWatch`]): frames that each wait
//! out their acquire while the user is there. The app closes, keeping unsaved changes for
//! recovery, and starts again on the next API (D3D12 → Vulkan → OpenGL → the software
//! renderer), kept in `graphics.json`.
//! - **Graphics settings tried from Preferences** (the graphics API, anti-aliasing; [`graphics_start`]):
//! the start that tries them asks to keep them. One that never says so (a crash, a hang, the
//! question timed out) leaves the trial marked as started, and the next start drops it.
use std::path::{Path, PathBuf};
@@ -109,6 +116,46 @@ pub fn begin(dir: &Path) -> u32 {
before
}
/// How long a start that tries graphics settings has to draw its first frame (`watch_trial_start`).
pub const TRIAL_START_LIMIT: std::time::Duration = std::time::Duration::from_secs(60);
static FIRST_FRAME: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// The first frame was drawn (or the start handed over to a retry): `watch_trial_start` stands down.
pub fn first_frame_drawn() {
FIRST_FRAME.store(true, std::sync::atomic::Ordering::SeqCst);
}
/// A start that tries a new graphics API can hang inside the driver before any window draws (a
/// black or frozen window, no question, nothing to click). If [`first_frame_drawn`] isn't called
/// within `limit`, run `give_up` once (it starts again with the saved settings: the trial is
/// marked as started, so the next start drops it). Returns the thread, for tests.
pub fn watch_trial_start(limit: std::time::Duration, give_up: impl FnOnce() + Send + 'static) -> Option<std::thread::JoinHandle<()>> {
watch_start(&FIRST_FRAME, limit, give_up)
}
fn watch_start(
drawn: &'static std::sync::atomic::AtomicBool,
limit: std::time::Duration,
give_up: impl FnOnce() + Send + 'static,
) -> Option<std::thread::JoinHandle<()>> {
std::thread::Builder::new()
.name("solidcraft-trial-start".into())
.spawn(move || {
let begun = std::time::Instant::now();
while begun.elapsed() < limit {
if drawn.load(std::sync::atomic::Ordering::SeqCst) {
return;
}
std::thread::sleep(std::time::Duration::from_millis(50).min(limit));
}
if !drawn.load(std::sync::atomic::Ordering::SeqCst) {
give_up();
}
})
.ok()
}
/// The window is up: this launch did not fail to start.
pub fn started(dir: &Path) {
let _ = std::fs::remove_file(dir.join(MARKER));
@@ -188,8 +235,14 @@ pub const WGPU_BACKEND_ENV: &str = "WGPU_BACKEND";
/// together and prefer Vulkan, and starting Vulkan loads every installed Vulkan driver: Intel's
/// (`igvk64.dll`) crashed SolidCraft at startup that way, inside the driver, before anything
/// could be reported. D3D12 is Windows' own graphics API, the one its drivers are tested with.
/// `has_hardware(backends)` says whether those backends have an adapter that isn't software
/// (it is only asked about the backends being tried, one at a time).
/// `hardware(backends)` lists those backends' adapters that aren't software (it is only asked
/// about the backends being tried, one at a time).
///
/// One exception: when D3D12's only hardware adapters are AMD integrated GPUs and Vulkan has a
/// hardware adapter, Vulkan. On those (Ryzen 5400U, 5600U, 5700G) D3D12 hung the driver a few
/// seconds into the first frames and Windows reset it (a black screen, then a crash, #1789,
/// #2806); 0.11.0, which preferred Vulkan, worked there, and `WGPU_BACKEND=vulkan` runs it
/// smoothly (#3321).
///
/// - `WGPU_BACKEND` set (`env_set`): `None`, whatever it says wins.
/// - The software renderer (`cpu`, safe mode): D3D12 alone on Windows (WARP); `None` elsewhere.
@@ -202,7 +255,7 @@ pub fn choose_backends(
env_set: bool,
cpu: bool,
preference: Option<&str>,
mut has_hardware: impl FnMut(eframe::wgpu::Backends) -> bool,
mut hardware: impl FnMut(eframe::wgpu::Backends) -> Vec<HardwareAdapter>,
) -> Option<eframe::wgpu::Backends> {
use eframe::wgpu::Backends;
if env_set {
@@ -221,7 +274,150 @@ pub fn choose_backends(
if !windows {
return None;
}
Some(WINDOWS_ORDER.into_iter().find(|b| has_hardware(*b)).unwrap_or(Backends::DX12))
for b in WINDOWS_ORDER {
let found = hardware(b);
if found.is_empty() {
continue;
}
if b == Backends::DX12 && found.iter().all(HardwareAdapter::hangs_on_d3d12) && !hardware(Backends::VULKAN).is_empty() {
log::warn!("graphics: Vulkan rather than D3D12 on an AMD integrated GPU (#3321)");
return Some(Backends::VULKAN);
}
return Some(b);
}
Some(Backends::DX12)
}
/// A hardware (not software) adapter a backend offers, as [`choose_backends`] needs it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct HardwareAdapter {
/// The PCI vendor id.
pub vendor: u32,
pub device_type: eframe::wgpu::DeviceType,
}
/// AMD's PCI vendor id.
const AMD: u32 = 0x1002;
impl HardwareAdapter {
/// An AMD integrated GPU, whose D3D12 driver hung on SolidCraft's first frames (#3321).
pub fn hangs_on_d3d12(&self) -> bool {
self.vendor == AMD && self.device_type == eframe::wgpu::DeviceType::IntegratedGpu
}
}
/// Frames in a row that stalled ([`eframe::egui_wgpu::stalled_frames`]: each waited out wgpu's
/// one-second acquire) after which the GPU is taken as hung.
pub const STALLED_FRAMES_FOR_RESTART: u32 = 5;
/// Seconds the window's surface may refuse its configuration ([`eframe::egui_wgpu::refused_for`])
/// while the user is there before the GPU is taken as hung: after a reset, a surface that stays
/// unsupported leaves a blank window. Refusals that clear up (a resize, a display waking up)
/// take a moment, not this long.
pub const REFUSED_S_FOR_RESTART: f64 = 10.0;
/// Stalled frames count only this many seconds after the user's input: a hung GPU leaves a white
/// window the user clicks at, while a switched-off display can hold frames back with nobody there.
pub const STALL_INPUT_WINDOW_S: f64 = 10.0;
/// Set on the launch that replaces one whose GPU hung: the hung backend, for the notice.
pub const HUNG_ENV: &str = "SOLIDCRAFT_GPU_HUNG";
/// Watches for a hung GPU after the window is up (#3321). The failed-start count misses it: frames
/// still come, a second apart, so the start counts as good.
#[derive(Debug, Default)]
pub struct HangWatch {
last_input: Option<f64>,
/// When the user came back: the first input after none for [`STALL_INPUT_WINDOW_S`].
arrived: f64,
/// The stalled streak when the user came back. Those frames stalled with nobody there (a
/// display switched off holds them back too), and the input that wakes the display comes
/// before the next frame shows whether the GPU is fine, so only stalls after it count.
stalled_before: u32,
hung: bool,
}
impl HangWatch {
/// Per frame, at `now` seconds: whether the user gave input this frame, the frames in a row
/// that stalled, and the seconds the surface has refused its configuration. True once, the
/// frame the GPU is taken as hung.
pub fn hung(&mut self, now: f64, input: bool, stalled: u32, refused_s: Option<f64>) -> bool {
if stalled < self.stalled_before {
// The streak broke: every stall since came after the user was back.
self.stalled_before = 0;
}
if input {
if !self.user_there(now) {
self.arrived = now;
self.stalled_before = stalled;
}
self.last_input = Some(now);
}
if self.hung || !self.user_there(now) {
return false;
}
// A surface refused since before the user came back counts from then.
let refused = refused_s.map_or(0.0, |r| r.min(now - self.arrived));
self.hung = stalled.saturating_sub(self.stalled_before) >= STALLED_FRAMES_FOR_RESTART || refused >= REFUSED_S_FOR_RESTART;
self.hung
}
fn user_there(&self, now: f64) -> bool {
self.last_input.is_some_and(|t| (0.0..=STALL_INPUT_WINDOW_S).contains(&(now - t)))
}
}
/// How a launch whose GPU hung starts again.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Restart {
/// On this backend (`graphics.json`'s `backend`), kept for later launches too.
Backend(&'static str),
/// As it was: the hang came with graphics settings on trial, which the next start drops
/// ([`graphics_start`]).
Again,
}
/// The restart after the GPU hung on `backend` (`cpu`: the software renderer, which has nothing
/// to fall back to; `trial`: with graphics settings on trial, which the next start drops). Each
/// hang moves on to the next of [`WINDOWS_ORDER`], then to the software renderer, kept in
/// `graphics.json` for later launches too, so a GPU hangs at most once on each: a safe-mode
/// restart would leave the backend that hung saved for the next normal start. D3D12 goes to
/// Vulkan, which runs the GPUs whose D3D12 driver hung (#1789); it isn't probed here, as loading
/// Vulkan's drivers into this process is what crashed once (`WINDOWS_ORDER`): the new launch
/// finds out, and falls back to software if it has no GPU.
pub fn after_hang(backend: eframe::wgpu::Backend, cpu: bool, trial: bool) -> Option<Restart> {
match backend {
_ if trial => Some(Restart::Again),
_ if cpu => None,
eframe::wgpu::Backend::Dx12 => Some(Restart::Backend("vulkan")),
eframe::wgpu::Backend::Vulkan => Some(Restart::Backend("gl")),
_ => Some(Restart::Backend(SOFTWARE_BACKEND)),
}
}
/// `graphics.json`'s `backend` for the software renderer, kept after the GPU hung on every API.
pub const SOFTWARE_BACKEND: &str = "cpu";
/// The notice on the launch after the GPU hung on `hung` (a backend's name): this one is in safe
/// mode, or on the `backend` preference.
pub fn hang_note(hung: &str, safe_mode: bool, backend: Option<&str>) -> String {
let now = match backend {
_ if safe_mode => "in safe mode".to_string(),
Some(b) => format!("on {}, which it keeps using (the backend in graphics.json)", api_name(b)),
None => "with another renderer".to_string(),
};
let unsaved = if safe_mode { "the next normal start offers unsaved changes" } else { "unsaved changes are offered for recovery" };
format!("The graphics driver stopped responding on {}, so SolidCraft restarted {now}; {unsaved}.", api_name(hung))
}
/// A backend's name (`Dx12`, `vulkan`, …) as people know the graphics API.
fn api_name(backend: &str) -> &str {
match backend.trim().to_ascii_lowercase().as_str() {
"" | "auto" => "Automatic",
"dx12" => "Direct3D 12",
"vulkan" => "Vulkan",
"gl" => "OpenGL",
"metal" => "Metal",
"cpu" | "software" => "the software renderer",
_ => backend,
}
}
/// Internal, process-local retry choice; never written to the graphics preference.
@@ -252,6 +448,164 @@ pub fn fallback_note(previous: &str, failed: eframe::wgpu::Backends, next: &str,
line.split_whitespace().collect::<Vec<_>>().join(" ").chars().take(4096).collect()
}
/// The graphics APIs Preferences offers on this system besides Automatic, as `graphics.json`'s
/// `backend` names them (#4015). None on macOS, which has Metal alone.
pub const GRAPHICS_APIS: &[&str] = if cfg!(target_os = "windows") {
&["dx12", "vulkan", "gl"]
} else if cfg!(target_os = "macos") {
&[]
} else {
&["vulkan", "gl"]
};
/// The APIs Preferences warns about on this system: D3D12 when its GPUs are all AMD integrated
/// ones, whose driver it froze, at times until the computer was restarted (#3321).
pub fn apis_that_freeze(windows: bool, hardware: impl FnOnce(eframe::wgpu::Backends) -> Vec<HardwareAdapter>) -> Vec<&'static str> {
if !windows {
return Vec::new();
}
let found = hardware(eframe::wgpu::Backends::DX12);
if !found.is_empty() && found.iter().all(HardwareAdapter::hangs_on_d3d12) { vec!["dx12"] } else { Vec::new() }
}
/// The saved settings to start with after the last launch crashed (`crashed`) on `saved`: an API
/// Preferences warns about on this system (`freezes`, [`apis_that_freeze`]) goes back to Automatic.
/// AMD's integrated GPUs crashed on D3D12 once a design opened, after the start and its trial had
/// gone fine and the user kept it (#4015). `None`: they stay.
pub fn after_crash(saved: &GraphicsChoice, crashed: bool, freezes: impl FnOnce() -> Vec<&'static str>) -> Option<GraphicsChoice> {
let chosen: Vec<&str> = saved.backend.split(',').map(str::trim).filter(|b| !b.is_empty() && *b != "auto").collect();
if !crashed || chosen.is_empty() {
return None;
}
let freezes = freezes();
chosen.iter().any(|b| freezes.contains(b)).then(|| GraphicsChoice { backend: "auto".into(), msaa: saved.msaa })
}
/// The notice when [`after_crash`] put `backend` (a backend's name) back to Automatic.
pub fn after_crash_note(backend: &str) -> String {
format!(
"SolidCraft closed unexpectedly on {}, which can freeze this computer's graphics, so it went back to Automatic. Preferences can choose it again.",
api_name(backend)
)
}
/// Whether a crash report `kind` ([`solidcraft_engine::crash::Summary`]) means the launch ended:
/// a panic nothing caught or an unclean exit, not a command or kernel call recovered from.
pub fn launch_crashed(kind: &str) -> bool {
matches!(kind, "panic" | "unclean_exit")
}
/// [`hardware_adapters`], keeping D3D12's: the start asks for them ([`choose_backends`]), then
/// Preferences ([`apis_that_freeze`]).
pub fn hardware_adapters_once(backends: eframe::wgpu::Backends) -> Vec<HardwareAdapter> {
static D3D12: std::sync::OnceLock<Vec<HardwareAdapter>> = std::sync::OnceLock::new();
if backends == eframe::wgpu::Backends::DX12 { D3D12.get_or_init(|| hardware_adapters(backends)).clone() } else { hardware_adapters(backends) }
}
/// Anti-aliasing samples when `graphics.json` has none (or one that isn't 1, 2, 4 or 8).
pub const DEFAULT_MSAA: u16 = 4;
/// Graphics settings a launch starts with (`graphics.json`'s `backend` and `msaa`).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GraphicsChoice {
/// `auto`, or the backends to start (`dx12`, `vulkan`, `gl`, or a comma list).
pub backend: String,
pub msaa: u16,
}
impl GraphicsChoice {
/// The choice in a JSON object, with `fallback`'s values where it has none (or a bad one).
fn read(json: Option<&serde_json::Value>, fallback: &GraphicsChoice) -> GraphicsChoice {
let backend = json.and_then(|v| v.get("backend")).and_then(serde_json::Value::as_str).map(str::trim).filter(|b| !b.is_empty());
let msaa = json
.and_then(|v| v.get("msaa"))
.and_then(serde_json::Value::as_u64)
.and_then(|m| u16::try_from(m).ok())
.filter(|m| [1, 2, 4, 8].contains(m));
GraphicsChoice { backend: backend.map_or_else(|| fallback.backend.clone(), str::to_string), msaa: msaa.unwrap_or(fallback.msaa) }
}
/// As the notice names it, with only what differs from `other` ("Vulkan, 8× anti-aliasing").
fn describe(&self, other: &GraphicsChoice) -> String {
let mut parts = Vec::new();
if self.backend != other.backend || self.msaa == other.msaa {
parts.push(api_name(&self.backend).to_string());
}
if self.msaa != other.msaa {
parts.push(format!("{}× anti-aliasing", self.msaa));
}
parts.join(", ")
}
}
/// Settings tried from Preferences, as a launch finds them (#4015).
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Trial {
None,
/// This launch tries them: it starts with them and asks whether to keep them.
Trying(GraphicsChoice),
/// The launch that tried them ended without keeping them: they are dropped.
Failed(GraphicsChoice),
}
/// `graphics.json` as a launch reads it.
#[derive(Clone, Debug, PartialEq)]
pub struct GraphicsStart {
/// The settings kept for every launch.
pub saved: GraphicsChoice,
pub trial: Trial,
/// The file to write back before the window opens, when it changes: the trial marked as
/// started, or dropped.
pub rewrite: Option<serde_json::Value>,
}
impl GraphicsStart {
/// The settings this launch starts with.
pub fn current(&self) -> &GraphicsChoice {
match &self.trial {
Trial::Trying(tried) => tried,
_ => &self.saved,
}
}
/// The notice when a trial was dropped.
pub fn note(&self) -> Option<String> {
let Trial::Failed(tried) = &self.trial else { return None };
Some(format!(
"The graphics settings tried at the last start ({}) weren't kept, so SolidCraft went back to {}.",
tried.describe(&self.saved),
self.saved.describe(tried)
))
}
}
/// Read `graphics.json` (`file`) at start. Preferences writes settings to try as its `trial`
/// object, next to the saved `backend` and `msaa`. The first launch to find it tries them and
/// marks it `started`; the app removes the trial when the user keeps the settings (they become
/// the saved ones) or goes back. A trial already started belongs to a launch that never got
/// that far, so it is dropped. Safe mode leaves a trial that hasn't started for the next start.
pub fn graphics_start(file: Option<&serde_json::Value>, safe_mode: bool) -> GraphicsStart {
let defaults = GraphicsChoice { backend: "auto".into(), msaa: DEFAULT_MSAA };
let saved = GraphicsChoice::read(file, &defaults);
let Some(trial) = file.and_then(|f| f.get("trial")).filter(|t| t.is_object()) else {
return GraphicsStart { saved, trial: Trial::None, rewrite: None };
};
let tried = GraphicsChoice::read(Some(trial), &saved);
let started = trial.get("started").and_then(serde_json::Value::as_bool).unwrap_or(false);
let mut rewrite = file.cloned().unwrap_or_default();
if started {
if let Some(o) = rewrite.as_object_mut() {
o.remove("trial");
}
return GraphicsStart { saved, trial: Trial::Failed(tried), rewrite: Some(rewrite) };
}
if safe_mode {
return GraphicsStart { saved, trial: Trial::None, rewrite: None };
}
rewrite["trial"]["started"] = serde_json::json!(true);
GraphicsStart { saved, trial: Trial::Trying(tried), rewrite: Some(rewrite) }
}
/// The device feature for a 32-bit float depth buffer with a stencil (reversed depth, #1434).
pub const FLOAT_DEPTH_STENCIL: eframe::wgpu::Features = eframe::wgpu::Features::DEPTH32FLOAT_STENCIL8;
@@ -272,8 +626,8 @@ pub fn float_depth_everywhere(options: &eframe::egui_wgpu::WgpuConfiguration) ->
/// The order backends are tried in on Windows.
pub const WINDOWS_ORDER: [eframe::wgpu::Backends; 3] = [eframe::wgpu::Backends::DX12, eframe::wgpu::Backends::VULKAN, eframe::wgpu::Backends::GL];
/// Whether `backends` (started on their own, nothing else) have a hardware adapter.
pub fn has_hardware_adapter(backends: eframe::wgpu::Backends) -> bool {
/// The hardware adapters of `backends` (started on their own, nothing else).
pub fn hardware_adapters(backends: eframe::wgpu::Backends) -> Vec<HardwareAdapter> {
let mut desc = eframe::wgpu::InstanceDescriptor::new_without_display_handle();
desc.backends = backends;
desc.flags = eframe::wgpu::InstanceFlags::from_build_config().with_env();
@@ -282,7 +636,12 @@ pub fn has_hardware_adapter(backends: eframe::wgpu::Backends) -> bool {
let adapters = pollster::block_on(instance.enumerate_adapters(backends));
let found: Vec<String> = adapters.iter().map(|a| format!("{} ({:?})", a.get_info().name, a.get_info().device_type)).collect();
log::info!("graphics: {backends:?} adapters: {found:?}");
adapters.iter().any(|a| a.get_info().device_type != eframe::wgpu::DeviceType::Cpu)
adapters
.iter()
.map(|a| a.get_info())
.filter(|i| i.device_type != eframe::wgpu::DeviceType::Cpu)
.map(|i| HardwareAdapter { vendor: i.vendor, device_type: i.device_type })
.collect()
}
/// The environment variable that switches wgpu's indirect-call validation on or off.
@@ -403,6 +762,34 @@ mod tests {
["Integrated", "Discrete small", "Discrete large"].into_iter().map(|name| AdapterChoice { name: name.into(), compatible: true }).collect()
}
/// A start on trial that draws no frame in time gives up once; one that draws stands down
/// (#4015).
#[test]
fn a_trial_start_that_draws_nothing_gives_up_once() {
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::time::Duration;
static HUNG: AtomicBool = AtomicBool::new(false);
static HUNG_GAVE_UP: AtomicU32 = AtomicU32::new(0);
watch_start(&HUNG, Duration::from_millis(150), || {
HUNG_GAVE_UP.fetch_add(1, Ordering::SeqCst);
})
.map(|t| t.join())
.unwrap()
.unwrap();
assert_eq!(HUNG_GAVE_UP.load(Ordering::SeqCst), 1);
static DRAWN: AtomicBool = AtomicBool::new(false);
static DRAWN_GAVE_UP: AtomicU32 = AtomicU32::new(0);
let watch = watch_start(&DRAWN, Duration::from_millis(400), || {
DRAWN_GAVE_UP.fetch_add(1, Ordering::SeqCst);
})
.unwrap();
std::thread::sleep(Duration::from_millis(60));
DRAWN.store(true, Ordering::SeqCst);
watch.join().unwrap();
assert_eq!(DRAWN_GAVE_UP.load(Ordering::SeqCst), 0, "the window drew: nothing to give up");
}
#[test]
fn max_file_size_parses_sizes_and_refuses_bad_ones() {
assert_eq!(max_file_size(Some("2GB")), Ok(2 << 30));
@@ -562,21 +949,32 @@ mod tests {
let _ = std::fs::remove_dir_all(&d);
}
/// A discrete NVIDIA GPU, and AMD's integrated one (#3321).
const NVIDIA: HardwareAdapter = HardwareAdapter { vendor: 0x10de, device_type: eframe::wgpu::DeviceType::DiscreteGpu };
const AMD_IGPU: HardwareAdapter = HardwareAdapter { vendor: AMD, device_type: eframe::wgpu::DeviceType::IntegratedGpu };
/// A probe where the backends `with` have `gpus`, and the others none.
fn probe_with(
with: &'static [eframe::wgpu::Backends],
gpus: &'static [HardwareAdapter],
) -> impl FnMut(eframe::wgpu::Backends) -> Vec<HardwareAdapter> {
use eframe::wgpu::Backends;
move |b: Backends| {
assert!(b == Backends::DX12 || b == Backends::VULKAN || b == Backends::GL, "one backend at a time: {b:?}");
if with.contains(&b) { gpus.to_vec() } else { Vec::new() }
}
}
#[test]
fn windows_starts_d3d12_first_and_vulkan_only_when_d3d12_has_no_gpu() {
use eframe::wgpu::Backends;
let probe = |with: &'static [Backends]| {
move |b: Backends| {
assert!(b == Backends::DX12 || b == Backends::VULKAN || b == Backends::GL, "one backend at a time: {b:?}");
with.contains(&b)
}
};
let probe = |with: &'static [Backends]| probe_with(with, &[NVIDIA]);
// D3D12 works: Vulkan and OpenGL are never even asked about (starting Vulkan loads
// its drivers, which is what crashed).
let mut asked = Vec::new();
let chosen = choose_backends(true, false, false, None, |b| {
asked.push(b);
b == Backends::DX12
if b == Backends::DX12 { vec![NVIDIA] } else { Vec::new() }
});
assert_eq!(chosen, Some(Backends::DX12));
assert_eq!(asked, [Backends::DX12]);
@@ -591,6 +989,158 @@ mod tests {
assert_eq!(choose_backends(true, false, false, Some(""), probe(&[Backends::GL])), Some(Backends::GL));
}
/// AMD integrated GPUs hung under D3D12 at the first frames, the driver reset and the app
/// crashed (#1789, #2806); they run on Vulkan (#3321).
#[test]
fn an_amd_integrated_gpu_starts_vulkan_rather_than_d3d12() {
use eframe::wgpu::Backends;
let all = &[Backends::DX12, Backends::VULKAN, Backends::GL];
assert_eq!(choose_backends(true, false, false, None, probe_with(all, &[AMD_IGPU])), Some(Backends::VULKAN));
// Without a Vulkan GPU: D3D12 still, rather than OpenGL or software.
assert_eq!(choose_backends(true, false, false, None, probe_with(&[Backends::DX12], &[AMD_IGPU])), Some(Backends::DX12));
// Another GPU beside it (a laptop's discrete one): D3D12, which can pick that one.
assert_eq!(choose_backends(true, false, false, None, probe_with(all, &[AMD_IGPU, NVIDIA])), Some(Backends::DX12));
// A discrete AMD GPU keeps D3D12.
let discrete = &[HardwareAdapter { vendor: AMD, device_type: eframe::wgpu::DeviceType::DiscreteGpu }];
assert_eq!(choose_backends(true, false, false, None, probe_with(all, discrete)), Some(Backends::DX12));
// The preference and WGPU_BACKEND still win, and other systems are left to wgpu.
let never = |b: Backends| -> Vec<HardwareAdapter> { panic!("no probing expected, asked about {b:?}") };
assert_eq!(choose_backends(true, false, false, Some("dx12"), never), Some(Backends::DX12));
assert_eq!(choose_backends(true, true, false, None, never), None);
assert_eq!(choose_backends(false, false, false, None, never), None);
}
/// After the window was up, D3D12 hung the GPU: frames came a second apart and never showed,
/// a white window with no crash (#1789, #3321). Five such frames while the user is there
/// restart the app, D3D12 on Vulkan and anything else in safe mode.
#[test]
fn a_gpu_that_hangs_after_the_window_is_up_restarts_on_another_renderer() {
use eframe::wgpu::Backend;
let stalled = STALLED_FRAMES_FOR_RESTART;
let mut watch = HangWatch::default();
assert!(!watch.hung(0.0, true, 0, None));
assert!(!watch.hung(4.0, false, stalled - 1, None), "not yet");
assert!(watch.hung(5.0, false, stalled, None), "input 5 s ago");
assert!(!watch.hung(6.0, true, stalled + 1, None), "once");
// Nobody at the computer (a display switched off holds frames back too): no restart.
let mut away = HangWatch::default();
assert!(!away.hung(100.0, false, stalled, None));
assert!(!away.hung(200.0, false, 100, None));
// The input that wakes the display comes before a frame shows the GPU is fine: the
// stalls from before don't count, and the frames on time that follow end the streak.
let mut woke = HangWatch::default();
assert!(!woke.hung(0.0, true, 0, None));
assert!(!woke.hung(STALL_INPUT_WINDOW_S + 1.0, false, 30, None));
assert!(!woke.hung(STALL_INPUT_WINDOW_S + 2.0, true, 30, None), "back at a healthy app");
assert!(!woke.hung(STALL_INPUT_WINDOW_S + 3.0, true, 0, None));
let later: Vec<bool> = (1..=stalled).map(|n| woke.hung(STALL_INPUT_WINDOW_S + 3.0 + f64::from(n), true, n, None)).collect();
assert_eq!(later.last(), Some(&true), "then it hangs: {later:?}");
// Back at a GPU that hung while nobody was there: five more stalls.
let mut back = HangWatch::default();
assert!(!back.hung(100.0, true, 30, None));
assert!(!back.hung(103.0, true, 30 + stalled - 1, None));
assert!(back.hung(104.0, true, 30 + stalled, None));
// A surface refused for good after a reset never reaches the acquire: a blank window.
let mut refused = HangWatch::default();
assert!(!refused.hung(0.0, true, 0, Some(60.0)), "refused since before the user came");
assert!(!refused.hung(REFUSED_S_FOR_RESTART - 1.0, true, 0, Some(60.0 + REFUSED_S_FOR_RESTART - 1.0)));
assert!(refused.hung(REFUSED_S_FOR_RESTART, true, 0, Some(60.0 + REFUSED_S_FOR_RESTART)));
let mut resized = HangWatch::default();
assert!(!resized.hung(0.0, true, 0, Some(0.5)), "a moment's refusal");
// Each API hangs at most once: D3D12, then Vulkan, then OpenGL, then software.
assert_eq!(after_hang(Backend::Dx12, false, false), Some(Restart::Backend("vulkan")));
assert_eq!(after_hang(Backend::Vulkan, false, false), Some(Restart::Backend("gl")));
assert_eq!(after_hang(Backend::Gl, false, false), Some(Restart::Backend(SOFTWARE_BACKEND)));
assert_eq!(after_hang(Backend::Dx12, true, false), None, "software: nothing to fall back to");
assert!(cpu_renderer_env(Some(SOFTWARE_BACKEND)), "the saved software renderer starts as such");
// Settings on trial: start again as it was, and the next start drops them (#4015).
assert_eq!(after_hang(Backend::Vulkan, false, true), Some(Restart::Again));
assert_eq!(after_hang(Backend::Dx12, true, true), Some(Restart::Again));
let note = hang_note("Dx12", false, Some("vulkan"));
assert!(note.contains("on Direct3D 12") && note.contains("on Vulkan") && note.contains("recovery"), "{note}");
assert!(hang_note("Vulkan", true, Some("gl")).contains("in safe mode"));
assert!(hang_note("Gl", false, Some("cpu")).contains("on the software renderer, which it keeps using"));
}
/// Settings tried from Preferences (#4015): the first start tries them and marks the trial
/// started; one that finds it started drops it and says so; safe mode leaves it waiting.
#[test]
fn graphics_settings_on_trial_are_tried_once_and_dropped_unless_kept() {
use serde_json::json;
let saved = GraphicsChoice { backend: "dx12".into(), msaa: 4 };
let vulkan_8x = GraphicsChoice { backend: "vulkan".into(), msaa: 8 };
// No file, or one without a trial: the saved settings (defaults where missing), unchanged.
let none = graphics_start(None, false);
assert_eq!(none.current(), &GraphicsChoice { backend: "auto".into(), msaa: DEFAULT_MSAA });
assert_eq!((none.trial, none.rewrite), (Trial::None, None));
let plain = graphics_start(Some(&json!({"backend": "dx12", "msaa": 16})), false);
assert_eq!(plain.current(), &saved, "16 isn't a sample count: the default");
assert_eq!(plain.rewrite, None);
// The first start tries the trial and marks it started.
let file = json!({"backend": "dx12", "msaa": 4, "trial": {"backend": "vulkan", "msaa": 8}});
let first = graphics_start(Some(&file), false);
assert_eq!(first.trial, Trial::Trying(vulkan_8x.clone()));
assert_eq!(first.current(), &vulkan_8x);
assert_eq!(first.saved, saved);
assert_eq!(first.note(), None);
let marked = first.rewrite.clone().unwrap();
assert_eq!(marked["trial"]["started"], json!(true));
assert_eq!((marked["backend"].clone(), marked["msaa"].clone()), (json!("dx12"), json!(4)), "the saved settings stay");
// That start never kept them (it crashed, hung, or the question timed out): the next one
// drops the trial, starts with the saved settings and says what happened.
let next = graphics_start(Some(&marked), false);
assert_eq!(next.trial, Trial::Failed(vulkan_8x));
assert_eq!(next.current(), &saved);
assert_eq!(next.rewrite, Some(json!({"backend": "dx12", "msaa": 4})));
let note = next.note().unwrap();
assert!(note.contains("(Vulkan, 8× anti-aliasing)") && note.contains("back to Direct3D 12, 4× anti-aliasing"), "{note}");
// Only what differs is named; Automatic by name.
let api_only = graphics_start(Some(&json!({"msaa": 4, "trial": {"backend": "gl", "started": true}})), false);
assert_eq!(
api_only.note().unwrap(),
"The graphics settings tried at the last start (OpenGL) weren't kept, so SolidCraft went back to Automatic."
);
// Safe mode leaves a trial that hasn't started for the next normal start, but still drops
// one that has.
let safe = graphics_start(Some(&file), true);
assert_eq!((safe.current(), &safe.trial, &safe.rewrite), (&saved, &Trial::None, &None));
assert!(matches!(graphics_start(Some(&marked), true).trial, Trial::Failed(_)));
// Every API Preferences offers is a backend the start understands.
for api in GRAPHICS_APIS {
assert!(!eframe::wgpu::Backends::from_comma_list(api).is_empty(), "{api}");
}
}
#[test]
fn preferences_warn_about_d3d12_only_where_it_froze_the_gpu() {
let with = |adapters: Vec<HardwareAdapter>| move |_: eframe::wgpu::Backends| adapters;
assert_eq!(apis_that_freeze(true, with(vec![AMD_IGPU])), vec!["dx12"]);
assert!(apis_that_freeze(true, with(vec![AMD_IGPU, NVIDIA])).is_empty(), "a graphics card besides");
assert!(apis_that_freeze(true, with(vec![NVIDIA])).is_empty());
assert!(apis_that_freeze(true, with(Vec::new())).is_empty(), "WARP alone");
assert!(apis_that_freeze(false, |_| panic!("only Windows has D3D12")).is_empty());
}
#[test]
fn a_crash_on_an_api_that_freezes_this_gpu_goes_back_to_automatic() {
let d3d12 = GraphicsChoice { backend: "dx12".into(), msaa: 1 };
let freezes = || vec!["dx12"];
assert_eq!(after_crash(&d3d12, true, freezes), Some(GraphicsChoice { backend: "auto".into(), msaa: 1 }), "anti-aliasing stays");
assert_eq!(after_crash(&d3d12, false, freezes), None, "no crash");
assert_eq!(after_crash(&d3d12, true, Vec::new), None, "D3D12 doesn't freeze this GPU");
let vulkan = GraphicsChoice { backend: "vulkan".into(), msaa: 4 };
assert_eq!(after_crash(&vulkan, true, freezes), None);
let auto = GraphicsChoice { backend: "auto".into(), msaa: 4 };
assert_eq!(after_crash(&auto, true, || panic!("Automatic isn't probed")), None);
assert!(launch_crashed("unclean_exit") && launch_crashed("panic"));
assert!(!launch_crashed("command_panic") && !launch_crashed("kernel_panic_recovered"));
assert!(after_crash_note("dx12").contains("on Direct3D 12"));
}
#[test]
fn failed_windows_backends_fall_back_in_order_and_stop_after_software() {
use eframe::wgpu::Backends as B;
@@ -617,7 +1167,7 @@ mod tests {
assert_eq!(
choose_backends(windows, true, cpu, Some("vulkan"), |_| {
probed = true;
false
Vec::new()
}),
None
);
@@ -642,7 +1192,7 @@ mod tests {
#[test]
fn wgpu_backend_the_preference_and_the_software_renderer_override_the_order() {
use eframe::wgpu::Backends;
let never = |b: Backends| -> bool { panic!("no probing expected, asked about {b:?}") };
let never = |b: Backends| -> Vec<HardwareAdapter> { panic!("no probing expected, asked about {b:?}") };
// WGPU_BACKEND: wgpu reads it itself, nothing is probed.
assert_eq!(choose_backends(true, true, false, Some("gl"), never), None);
assert_eq!(choose_backends(true, true, true, None, never), None);
@@ -655,7 +1205,7 @@ mod tests {
assert_eq!(choose_backends(true, false, false, Some("dx12,vulkan"), never), Some(Backends::DX12 | Backends::VULKAN));
assert_eq!(choose_backends(false, false, false, Some("vulkan"), never), Some(Backends::VULKAN));
// An unknown preference is the default order.
assert_eq!(choose_backends(true, false, false, Some("glide"), |b| b == Backends::DX12), Some(Backends::DX12));
assert_eq!(choose_backends(true, false, false, Some("glide"), probe_with(&[Backends::DX12], &[NVIDIA])), Some(Backends::DX12));
// Elsewhere: wgpu's own choice.
assert_eq!(choose_backends(false, false, false, None, never), None);
assert_eq!(choose_backends(false, false, false, Some("auto"), never), None);
@@ -668,10 +1218,10 @@ mod tests {
// D3D12 never loads its OpenGL driver into the test process, and AMD's (`atio6axx.dll`)
// aborted the process as it exited (#1156).
if cfg!(target_os = "windows") {
let _ = choose_backends(true, false, false, None, has_hardware_adapter);
let _ = choose_backends(true, false, false, None, hardware_adapters);
} else {
for b in WINDOWS_ORDER {
let _ = has_hardware_adapter(b);
let _ = hardware_adapters(b);
}
}
}
@@ -0,0 +1,86 @@
{
"ar": {
"settings.graphics_api_env": "يحدده متغير البيئة WGPU_BACKEND ({value})",
"settings.graphics_api_freezes": "على بطاقات AMD الرسومية المدمجة، قد تؤدي {api} إلى تجمد الشاشة، وأحيانًا حتى إعادة تشغيل الكمبيوتر. يستخدم الخيار التلقائي Vulkan هنا.",
"settings.graphics_applies_on_restart": "تُطبّق هذه الإعدادات عند تشغيل SolidCraft مجددًا. سيسألك حينها إن كنت تريد الاحتفاظ بها، ويعود إلى الإعدادات الحالية إن لم توافق.",
"settings.graphics_msaa": "تنعيم الحواف بمقدار {samples}×",
"settings.graphics_restart_now": "إعادة التشغيل الآن",
"settings.graphics_trial_body": "بدأ SolidCraft باستخدام {settings}. دون إجابة، سيعود خلال {seconds} ثانية إلى {previous}.",
"settings.graphics_trial_first": "احتفظ بإعدادات الرسومات قيد التجربة أو ارجع عنها أولًا",
"settings.graphics_trial_keep": "احتفاظ",
"settings.graphics_trial_revert": "رجوع",
"settings.graphics_trial_title": "هل تريد الاحتفاظ بإعدادات الرسومات هذه؟",
"ui.graphics_api": "واجهة برمجة الرسومات",
"ui.graphics_api_auto": "تلقائي"
},
"en": {
"settings.graphics_api_env": "Set by the WGPU_BACKEND environment variable ({value})",
"settings.graphics_api_freezes": "On AMD integrated graphics, {api} can freeze the screen, sometimes until the computer is restarted. Automatic uses Vulkan here.",
"settings.graphics_applies_on_restart": "These apply when SolidCraft starts again. It then asks whether to keep them, and goes back to the current ones if you don't.",
"settings.graphics_msaa": "{samples}× anti-aliasing",
"settings.graphics_restart_now": "Restart now",
"settings.graphics_trial_body": "SolidCraft started with {settings}. Without an answer, in {seconds} s it goes back to {previous}.",
"settings.graphics_trial_first": "Keep or go back from the graphics settings being tried first",
"settings.graphics_trial_keep": "Keep",
"settings.graphics_trial_revert": "Go back",
"settings.graphics_trial_title": "Keep these graphics settings?",
"ui.graphics_api": "Graphics API",
"ui.graphics_api_auto": "Automatic"
},
"he": {
"settings.graphics_api_env": "נקבע על ידי משתנה הסביבה WGPU_BACKEND ({value})",
"settings.graphics_api_freezes": "בגרפיקה המשולבת של AMD, ‏{api} עלול להקפיא את המסך, לפעמים עד להפעלה מחדש של המחשב. האפשרות האוטומטית משתמשת כאן ב־Vulkan.",
"settings.graphics_applies_on_restart": "הגדרות אלה יחולו בהפעלה הבאה של SolidCraft. אז תופיע שאלה אם לשמור אותן, ואם לא, ההגדרות הנוכחיות ישוחזרו.",
"settings.graphics_msaa": "החלקת קצוות פי {samples}",
"settings.graphics_restart_now": "הפעלה מחדש כעת",
"settings.graphics_trial_body": "SolidCraft הופעל עם {settings}. ללא תשובה, בעוד {seconds} שניות הוא יחזור אל {previous}.",
"settings.graphics_trial_first": "יש לשמור את הגדרות הגרפיקה שבניסיון או לחזור מהן תחילה",
"settings.graphics_trial_keep": "שמירה",
"settings.graphics_trial_revert": "חזרה",
"settings.graphics_trial_title": "לשמור את הגדרות הגרפיקה האלה?",
"ui.graphics_api": "ממשק הגרפיקה",
"ui.graphics_api_auto": "אוטומטי"
},
"ja": {
"settings.graphics_api_env": "環境変数 WGPU_BACKEND で指定されています ({value})",
"settings.graphics_api_freezes": "AMD の内蔵グラフィックスでは、{api} で画面が固まり、コンピューターを再起動するまで戻らないことがあります。自動ではここで Vulkan を使います。",
"settings.graphics_applies_on_restart": "これらは SolidCraft の次回起動時に適用されます。起動後に設定を維持するか確認し、維持しない場合は現在の設定に戻ります。",
"settings.graphics_msaa": "アンチエイリアス {samples}×",
"settings.graphics_restart_now": "今すぐ再起動",
"settings.graphics_trial_body": "SolidCraft は {settings} で起動しました。応答がない場合、{seconds} 秒後に {previous} に戻ります。",
"settings.graphics_trial_first": "先に、試用中のグラフィックス設定を維持するか戻すかを選んでください",
"settings.graphics_trial_keep": "維持",
"settings.graphics_trial_revert": "元に戻す",
"settings.graphics_trial_title": "このグラフィックス設定を維持しますか?",
"ui.graphics_api": "グラフィックス API",
"ui.graphics_api_auto": "自動"
},
"vi": {
"settings.graphics_api_env": "Được đặt bởi biến môi trường WGPU_BACKEND ({value})",
"settings.graphics_api_freezes": "Trên đồ họa tích hợp AMD, {api} có thể làm màn hình bị treo, đôi khi cho đến khi khởi động lại máy tính. Tự động sử dụng Vulkan ở đây.",
"settings.graphics_applies_on_restart": "Các thiết lập này có hiệu lực khi SolidCraft khởi động lại. Khi đó ứng dụng sẽ hỏi có giữ chúng không và trở về thiết lập hiện tại nếu bạn không giữ.",
"settings.graphics_msaa": "Khử răng cưa {samples}×",
"settings.graphics_restart_now": "Khởi động lại ngay",
"settings.graphics_trial_body": "SolidCraft đã khởi động với {settings}. Nếu không trả lời, sau {seconds} giây ứng dụng sẽ trở về {previous}.",
"settings.graphics_trial_first": "Trước tiên hãy giữ hoặc quay lại từ các thiết lập đồ họa đang thử",
"settings.graphics_trial_keep": "Giữ",
"settings.graphics_trial_revert": "Quay lại",
"settings.graphics_trial_title": "Giữ các thiết lập đồ họa này?",
"ui.graphics_api": "API đồ họa",
"ui.graphics_api_auto": "Tự động"
},
"zh": {
"settings.graphics_api_env": "由环境变量 WGPU_BACKEND 设置({value})",
"settings.graphics_api_freezes": "在 AMD 集成显卡上,{api} 可能导致屏幕冻结,有时需要重启电脑才能恢复。自动选项在这里使用 Vulkan。",
"settings.graphics_applies_on_restart": "这些设置在 SolidCraft 下次启动时生效。启动后会询问是否保留;如果不保留,将恢复为当前设置。",
"settings.graphics_msaa": "{samples}× 抗锯齿",
"settings.graphics_restart_now": "立即重启",
"settings.graphics_trial_body": "SolidCraft 已使用 {settings} 启动。如果没有回应,将在 {seconds} 秒后恢复为 {previous}。",
"settings.graphics_trial_first": "请先决定保留还是撤销正在试用的图形设置",
"settings.graphics_trial_keep": "保留",
"settings.graphics_trial_revert": "恢复",
"settings.graphics_trial_title": "保留这些图形设置吗?",
"ui.graphics_api": "图形 API",
"ui.graphics_api_auto": "自动"
}
}
+5
View File
@@ -698,6 +698,8 @@ pub struct SolveApp {
/// Settings put back at start because they kept SolidCraft from starting, one line each,
/// shown until dismissed (`prefs::show_reset_notice`).
pub settings_reset: Vec<prefs::SettingReset>,
/// The graphics settings that apply at start, and settings on trial (`prefs::Graphics`).
pub graphics: prefs::Graphics,
/// The last command was a big operation: autosave after it.
pub big_operation: bool,
/// Autosave interval (minutes; 0 = only after big operations). A preference.
@@ -889,6 +891,7 @@ impl SolveApp {
msaa_compatibility: false,
graphics_fallback: None,
settings_reset: Vec::new(),
graphics: Default::default(),
big_operation: false,
autosave_minutes: 5.0,
custom_titlebar: false,
@@ -3085,6 +3088,8 @@ impl SolveApp {
// Preferences and Keyboard Shortcuts open from Home too (its links, the gear, Ctrl+,).
prefs::show(self, ui.ctx());
browser::editors(self, ui);
// A start that tries graphics settings asks about them over Home too (#4015).
prefs_notice::graphics_trial(self, ui.ctx());
return;
}
}
+272 -26
View File
@@ -1112,7 +1112,7 @@ preference_settings! {
nav_bridge_enabled: bool = false, PAGE_MOUSE3D;
/// The loopback port the device app listens on (0: none).
nav_bridge_port: u16 = 8765, PAGE_MOUSE3D;
/// Anti-aliasing samples (applies on restart).
/// Anti-aliasing samples (applies on restart, on trial: [`Graphics`]).
msaa: u32 = 4, PAGE_GRAPHICS;
/// Share shading and Continuity; false preserves their independent legacy behavior.
single_surface_analysis: bool = true, PAGE_GRAPHICS => check_id("prefs.graphics_page.single_surface_analysis");
@@ -1121,6 +1121,8 @@ preference_settings! {
window_system: String = "auto".into(), PAGE_GRAPHICS;
/// Exact graphics adapter name; empty selects automatically on the next start.
graphics_adapter: String = String::new(), PAGE_GRAPHICS;
/// The graphics API: `auto`, or one of [`Graphics::apis`] (applies on restart, on trial).
graphics_api: String = "auto".into(), PAGE_GRAPHICS;
/// Soft contact shading in the GPU viewport; optional and applied immediately.
ambient_occlusion: bool = false, PAGE_GRAPHICS;
/// Shade the viewport in linear light with a sky fill, so dark parts keep their shape
@@ -1805,7 +1807,7 @@ impl Snapshot {
}
}
fn restore(self, app: &mut SolveApp) {
let graphics = (app.preferences.msaa, app.preferences.window_system.clone(), app.preferences.graphics_adapter.clone());
let graphics = (selected_graphics(app), app.preferences.window_system.clone(), app.preferences.graphics_adapter.clone());
let units_changed = app.preferences.default_units != self.prefs.default_units;
// Update bookkeeping isn't a setting: a check that finished while the dialog was open stays.
let update = std::mem::take(&mut app.preferences.update);
@@ -1832,8 +1834,8 @@ impl Snapshot {
app.toolbar_custom.load(&self.toolbar);
app.sbox.pinned = self.favourites;
app.settings_profiles.active = self.profile.filter(|name| app.settings_profiles.names().contains(name));
if (app.preferences.msaa, app.preferences.window_system.clone(), app.preferences.graphics_adapter.clone()) != graphics {
save_graphics(app);
if (selected_graphics(app), app.preferences.window_system.clone(), app.preferences.graphics_adapter.clone()) != graphics {
save_graphics(app, true);
}
}
@@ -2213,7 +2215,7 @@ fn ui_font_row(app: &mut SolveApp, ui: &mut egui::Ui) {
/// Restore Defaults for one page.
fn defaults(app: &mut SolveApp, page: usize) {
let (msaa, window_system, adapter) = (app.preferences.msaa, app.preferences.window_system.clone(), app.preferences.graphics_adapter.clone());
let graphics = (selected_graphics(app), app.preferences.window_system.clone(), app.preferences.graphics_adapter.clone());
let units = app.preferences.default_units.clone();
reset_preferences(&mut app.preferences, page);
sync_surface_analysis(app);
@@ -2245,8 +2247,8 @@ fn defaults(app: &mut SolveApp, page: usize) {
app.ui.show_grid = true;
app.ui.ground_shadow = true;
app.ui.perspective = false;
if app.preferences.msaa != msaa || app.preferences.window_system != window_system || app.preferences.graphics_adapter != adapter {
save_graphics(app);
if (selected_graphics(app), app.preferences.window_system.clone(), app.preferences.graphics_adapter.clone()) != graphics {
save_graphics(app, true);
}
}
PAGE_SKETCH => {
@@ -2604,23 +2606,165 @@ fn window_system_row(app: &mut SolveApp, ui: &mut egui::Ui, label_width: f32) {
}
}
/// Write the anti-aliasing and window system settings where the host reads them at start (neither
/// can change while the window is open).
fn save_graphics(app: &SolveApp) {
if let Some(path) = &app.services.graphics_path {
// Other settings in the file (the graphics `backend`) stay as they are.
let mut graphics = std::fs::read_to_string(path)
.ok()
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
.filter(serde_json::Value::is_object)
.unwrap_or_else(|| json!({}));
graphics["msaa"] = json!(app.preferences.msaa);
graphics["window_system"] = json!(app.preferences.window_system);
graphics["adapter"] = json!(app.preferences.graphics_adapter);
let _ = std::fs::write(path, graphics.to_string());
/// Graphics settings that apply when SolidCraft starts: the API and the anti-aliasing (#4015).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GraphicsChoice {
/// `auto`, or a backend as `graphics.json` names it (`dx12`, `vulkan`, `gl`).
pub api: String,
pub msaa: u32,
}
impl Default for GraphicsChoice {
fn default() -> Self {
let p = Prefs::default();
GraphicsChoice { api: p.graphics_api, msaa: p.msaa }
}
}
impl GraphicsChoice {
/// As the trial question names it, with only what differs from `other` ("Vulkan, 8×
/// anti-aliasing").
pub fn describe(&self, other: &GraphicsChoice) -> String {
let mut parts = Vec::new();
if self.api != other.api || self.msaa == other.msaa {
parts.push(graphics_api_label(&self.api));
}
if self.msaa != other.msaa {
parts.push(crate::i18n::format("settings.graphics_msaa", &[("samples", &self.msaa.to_string())]));
}
parts.join(", ")
}
}
/// The graphics settings that apply at start, as the host reports them (#4015). The window
/// can't change its API or samples while it is open, so a change in Preferences is tried at the
/// next start: the host starts with it, [`crate::prefs_notice`] asks whether to keep it and goes
/// back unless it is kept, and a start that never gets that far is dropped by the next one.
#[derive(Clone, Debug, Default)]
pub struct Graphics {
/// The APIs offered besides Automatic, as `graphics.json` names them. None: no choice
/// (macOS, which has Metal alone; the browser).
pub apis: Vec<&'static str>,
/// Of those, the ones known to freeze this GPU (D3D12 on AMD integrated graphics): a warning
/// shows while one is chosen, as no trial start helps once the whole screen is frozen.
pub freezes: Vec<&'static str>,
/// `WGPU_BACKEND`, which decides the API when it is set.
pub env_override: Option<String>,
/// The settings kept for every start (`graphics.json`'s `backend` and `msaa`).
pub saved: GraphicsChoice,
/// This start tries settings, now in the preferences.
pub trial: Option<GraphicsTrial>,
/// Start again once closed: to try settings, or to go back from them.
pub restart: bool,
}
/// Settings on trial at this start.
#[derive(Clone, Debug, PartialEq)]
pub struct GraphicsTrial {
/// What a "go back" returns to: the saved settings.
pub previous: GraphicsChoice,
/// Seconds the question has waited ([`crate::prefs_notice::TRIAL_SECONDS`]).
pub shown: f64,
/// The `egui` input time of the last frame that drew the question.
pub last_frame: Option<f64>,
}
/// An API's name as people know it; Automatic in the interface language.
pub fn graphics_api_label(api: &str) -> String {
match api {
"auto" => crate::i18n::tr("ui.graphics_api_auto").to_string(),
"dx12" => "Direct3D 12".into(),
"vulkan" => "Vulkan".into(),
"gl" => "OpenGL".into(),
"metal" => "Metal".into(),
other => other.to_string(),
}
}
/// The warning under the graphics API while the chosen one is known to freeze this GPU
/// ([`Graphics::freezes`]); none while `WGPU_BACKEND` decides.
fn freeze_warning(app: &SolveApp) -> Option<String> {
let api = app.preferences.graphics_api.as_str();
(app.graphics.env_override.is_none() && app.graphics.freezes.contains(&api))
.then(|| crate::i18n::format("settings.graphics_api_freezes", &[("api", &graphics_api_label(api))]))
}
/// The graphics settings chosen in the preferences.
pub fn selected_graphics(app: &SolveApp) -> GraphicsChoice {
GraphicsChoice { api: app.preferences.graphics_api.clone(), msaa: app.preferences.msaa }
}
/// `graphics.json` with the chosen settings. `trial`: chosen in the Preferences window, so they
/// are tried at the next start (its `trial` object) and the saved `backend` and `msaa` stay as
/// they are; none when they are the saved ones. Else (the control channel, a setting put back at
/// start, kept settings) they are saved at once when they aren't the saved ones. Other entries
/// stay.
pub fn graphics_file(old: Option<serde_json::Value>, selected: &GraphicsChoice, saved: &GraphicsChoice, trial: bool) -> serde_json::Value {
let mut graphics = old.filter(serde_json::Value::is_object).unwrap_or_else(|| json!({}));
if let Some(o) = graphics.as_object_mut() {
o.remove("trial");
}
if selected == saved {
return graphics;
}
if trial {
graphics["trial"] = json!({"backend": selected.api, "msaa": selected.msaa});
} else {
graphics["backend"] = json!(selected.api);
graphics["msaa"] = json!(selected.msaa);
}
graphics
}
/// Write the graphics settings where the host reads them at start ([`graphics_file`], the window
/// system and the graphics card).
fn save_graphics(app: &mut SolveApp, trial: bool) {
let selected = selected_graphics(app);
if let Some(path) = &app.services.graphics_path {
let old = std::fs::read_to_string(path).ok().and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok());
let mut file = graphics_file(old, &selected, &app.graphics.saved, trial);
// The window system and the graphics card apply at the next start too, without a trial;
// written once they aren't the defaults.
let defaults = Prefs::default();
for (key, value, default) in [
("window_system", &app.preferences.window_system, &defaults.window_system),
("adapter", &app.preferences.graphics_adapter, &defaults.graphics_adapter),
] {
if value != default || file.get(key).is_some() {
file[key] = json!(value);
}
}
if let Err(e) = std::fs::write(path, file.to_string()) {
log::warn!("graphics: writing {path}: {e}");
}
}
if !trial {
app.graphics.saved = selected;
}
}
/// Keep the settings on trial: they become the saved ones.
pub fn keep_graphics(app: &mut SolveApp) {
app.graphics.trial = None;
save_graphics(app, false);
}
/// Go back from the settings on trial: the saved ones return, and SolidCraft starts again with
/// them once closed (asking about unsaved designs first).
pub fn revert_graphics(app: &mut SolveApp) {
let Some(trial) = app.graphics.trial.take() else { return };
app.preferences.graphics_api = trial.previous.api;
app.preferences.msaa = trial.previous.msaa;
save_graphics(app, false);
restart_for_graphics(app);
}
/// Close (asking about unsaved designs), then start again: the host reads the graphics settings.
fn restart_for_graphics(app: &mut SolveApp) {
app.graphics.restart = true;
crate::documents::request_quit(app);
}
/// At start, reflect a confirmed startup fallback in Graphics, or reset an unsupported
/// saved count to the supported default. The compatibility hint keeps higher choices
/// available for another try; a probe result alone does not overwrite the saved choice.
@@ -2637,7 +2781,10 @@ pub fn check_msaa(app: &mut SolveApp, requested: u32) {
let reset = supported.iter().copied().filter(|&n| n <= default).max().unwrap_or(1);
let supported = supported.clone();
app.preferences.msaa = reset;
save_graphics(app);
// Settings on trial are kept or dropped as a whole.
if app.graphics.trial.is_none() {
save_graphics(app, false);
}
app.settings_reset.push(SettingReset::Msaa { requested, reset, supported });
}
@@ -2766,8 +2913,9 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
if app.preferences.msaa != before.msaa
|| app.preferences.window_system != before.window_system
|| app.preferences.graphics_adapter != before.graphics_adapter
|| app.preferences.graphics_api != before.graphics_api
{
save_graphics(app);
save_graphics(app, true);
}
if app.preferences.up_axis != before.up_axis {
up_axis_changed(app);
@@ -4028,6 +4176,11 @@ fn prepare_preferences(
if !WINDOW_SYSTEMS.contains(&new.window_system.as_str()) {
return Err(format!("`window_system` must be one of {}", WINDOW_SYSTEMS.join(", ")));
}
let api_offered = new.graphics_api == "auto" || app.graphics.apis.contains(&new.graphics_api.as_str());
if new.graphics_api != app.preferences.graphics_api && !api_offered {
let apis: Vec<&str> = std::iter::once("auto").chain(app.graphics.apis.iter().copied()).collect();
return Err(format!("`graphics_api` must be one of: {}", apis.join(", ")));
}
if !NAV_PRESETS.iter().any(|p| p.id == new.nav) || !MSAA.contains(&new.msaa) {
return Err(format!("`nav` must be one of {} and `msaa` 1, 2, 4 or 8", NAV_PRESETS.iter().map(|p| p.id).collect::<Vec<_>>().join(", ")));
}
@@ -4134,7 +4287,8 @@ fn apply_preferences(app: &mut SolveApp, new: Prefs, p: &serde_json::Value) -> R
let up_changed = new.up_axis != app.preferences.up_axis;
let graphics_changed = new.msaa != app.preferences.msaa
|| new.window_system != app.preferences.window_system
|| new.graphics_adapter != app.preferences.graphics_adapter;
|| new.graphics_adapter != app.preferences.graphics_adapter
|| new.graphics_api != app.preferences.graphics_api;
if p.get("layout").is_some() {
app.ui.layout = new.layout;
}
@@ -4147,8 +4301,10 @@ fn apply_preferences(app: &mut SolveApp, new: Prefs, p: &serde_json::Value) -> R
if units_changed {
default_units_changed(app);
}
// Kept at once, without a question: a script that changes them knows what it asked for.
if graphics_changed {
save_graphics(app);
app.graphics.trial = None;
save_graphics(app, false);
}
if up_changed {
up_axis_changed(app);
@@ -8784,7 +8940,9 @@ mod tests {
let mut app = SolveApp::new(solidcraft_engine::Session::default(), services);
// WebGPU's guarantee for the surface format: 8x saved, the device can only do 1x and 4x.
app.msaa_supported = vec![1, 4];
// As the host reads them from the file at start.
app.preferences.msaa = 8;
app.preferences.graphics_api = "vulkan".into();
std::fs::write(&path, r#"{"msaa":8,"backend":"vulkan"}"#).unwrap();
check_msaa(&mut app, 8);
assert_eq!(app.preferences.msaa, 4);
@@ -8820,6 +8978,90 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
/// D3D12 froze AMD integrated GPUs past what a trial start can undo (#3321): choosing it there
/// warns first, and nowhere else.
#[test]
fn choosing_an_api_that_freezes_this_gpu_warns() {
let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default());
app.graphics = Graphics { apis: vec!["dx12", "vulkan", "gl"], freezes: vec!["dx12"], ..Default::default() };
app.preferences.graphics_api = "vulkan".into();
assert_eq!(freeze_warning(&app), None);
app.preferences.graphics_api = "dx12".into();
assert_eq!(
freeze_warning(&app).as_deref(),
Some(
"On AMD integrated graphics, Direct3D 12 can freeze the screen, sometimes until the computer is restarted. Automatic uses Vulkan here."
)
);
app.graphics.env_override = Some("dx12".into());
assert_eq!(freeze_warning(&app), None, "WGPU_BACKEND decides, and its own note says so");
app.graphics = Graphics { apis: vec!["dx12", "vulkan", "gl"], ..Default::default() };
assert_eq!(freeze_warning(&app), None);
}
/// The graphics API and anti-aliasing (#4015): a change in the Preferences window is saved as
/// a trial for the next start, and Cancel takes it back; the control channel saves at once.
/// Keep makes the settings on trial the saved ones; Go back returns to those and restarts.
#[test]
fn graphics_settings_are_tried_from_the_window_and_saved_at_once_from_scripts() {
let dir = std::env::temp_dir().join(format!("sc_graphics_trial_{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let path = dir.join("graphics.json");
let read = || serde_json::from_str::<serde_json::Value>(&std::fs::read_to_string(&path).unwrap_or_default()).unwrap_or_default();
let services = crate::Services { graphics_path: Some(path.to_string_lossy().to_string()), ..Default::default() };
let mut app = SolveApp::new(solidcraft_engine::Session::default(), services);
let dx12 = GraphicsChoice { api: "dx12".into(), msaa: 4 };
let vulkan = GraphicsChoice { api: "vulkan".into(), msaa: 8 };
app.graphics = Graphics { apis: vec!["dx12", "vulkan", "gl"], saved: dx12.clone(), ..Default::default() };
(app.preferences.graphics_api, app.preferences.msaa) = ("dx12".into(), 4);
std::fs::write(&path, r#"{"backend":"dx12","msaa":4,"other":1}"#).unwrap();
// The window: a trial, next to the saved settings; back to them, no trial.
app.prefs_window.open = true;
let snapshot = Snapshot::take(&app);
(app.preferences.graphics_api, app.preferences.msaa) = ("vulkan".into(), 8);
save_graphics(&mut app, true);
assert_eq!(read(), json!({"backend": "dx12", "msaa": 4, "other": 1, "trial": {"backend": "vulkan", "msaa": 8}}));
assert_eq!(app.graphics.saved, dx12, "only tried");
snapshot.restore(&mut app);
assert_eq!(read(), json!({"backend": "dx12", "msaa": 4, "other": 1}), "Cancel takes the trial back");
// The control channel: only an API this system offers; saved at once, any trial dropped.
assert!(set(&mut app, &json!({"graphics_api": "metal"})).is_err());
app.graphics.trial = Some(GraphicsTrial { previous: dx12.clone(), shown: 0.0, last_frame: None });
set(&mut app, &json!({"graphics_api": "vulkan", "msaa": 8})).unwrap();
assert_eq!((app.graphics.saved.clone(), app.graphics.trial.clone()), (vulkan.clone(), None));
assert_eq!(read(), json!({"backend": "vulkan", "msaa": 8, "other": 1}));
// On trial: a device that can't do the samples changes the selection but saves nothing,
// as the trial is kept or dropped as a whole.
std::fs::write(&path, r#"{"backend":"dx12","msaa":4,"trial":{"backend":"vulkan","msaa":8,"started":true}}"#).unwrap();
app.graphics.saved = dx12.clone();
app.graphics.trial = Some(GraphicsTrial { previous: dx12.clone(), shown: 0.0, last_frame: None });
app.msaa_supported = vec![1, 4];
check_msaa(&mut app, 8);
assert_eq!(app.preferences.msaa, 4);
assert_eq!(read()["trial"]["started"], json!(true));
// Keep: the tried API (with the samples the device does) is saved, the trial goes.
keep_graphics(&mut app);
assert_eq!(read(), json!({"backend": "vulkan", "msaa": 4}));
assert!(app.graphics.trial.is_none() && !app.graphics.restart);
// Go back: the saved settings return, and SolidCraft starts again once closed.
std::fs::write(&path, r#"{"backend":"dx12","msaa":4,"trial":{"backend":"gl","msaa":4,"started":true}}"#).unwrap();
app.graphics.saved = dx12.clone();
app.graphics.trial = Some(GraphicsTrial { previous: dx12.clone(), shown: 0.0, last_frame: None });
app.preferences.graphics_api = "gl".into();
revert_graphics(&mut app);
assert_eq!((app.preferences.graphics_api.as_str(), app.preferences.msaa), ("dx12", 4));
assert_eq!(read(), json!({"backend": "dx12", "msaa": 4}));
assert!(app.graphics.trial.is_none() && app.graphics.restart && app.quit_requested, "no unsaved design: closes at once");
assert_eq!(GraphicsChoice { api: "gl".into(), msaa: 4 }.describe(&dx12), "OpenGL");
assert_eq!(vulkan.describe(&dx12), "Vulkan, 8× anti-aliasing");
assert_eq!(GraphicsChoice { api: "auto".into(), msaa: 2 }.describe(&GraphicsChoice { api: "auto".into(), msaa: 4 }), "2× anti-aliasing");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn control_channel_rejects_msaa_the_device_cannot_do_and_bad_saved_msaa_loads_as_default() {
let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default());
@@ -10715,9 +10957,12 @@ mod graphics_preset_tests {
assert!(tree.nodes.iter().any(|(_, node)| node.role() == Role::RadioButton && node.label() == Some("8×") && node.is_disabled()));
click_graphics(&mut h, "Anti-aliasing", "prefs:msaa:2");
assert_eq!(h.app.preferences.msaa, 2);
// A change in the window is tried at the next start; the saved level stays (#4015).
let saved: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
assert_eq!(saved["msaa"], 2);
assert_eq!(saved["msaa"], 4, "{saved}");
assert_eq!(saved["backend"], "vulkan");
// The trial holds the chosen API (this harness's default) with the new level.
assert_eq!(saved["trial"], json!({"backend":h.app.preferences.graphics_api,"msaa":2}), "{saved}");
assert_eq!(
h.app.viewport.gpu.as_ref().map(|gpu| gpu.samples),
active_samples,
@@ -10740,6 +10985,7 @@ mod graphics_preset_tests {
let saved: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
assert_eq!(saved["msaa"], 4, "Cancel restores startup persistence too");
assert_eq!(saved["backend"], "vulkan");
assert!(saved.get("trial").is_none(), "Cancel removes the trial: {saved}");
std::fs::remove_dir_all(dir).unwrap();
}
}
+62 -4
View File
@@ -41,6 +41,19 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
}
});
});
// While settings are on trial, the question about them comes first (#4015).
let on_trial = app.graphics.trial.is_some();
if !app.graphics.apis.is_empty() {
ui_kit::settings_section(ui, "api", app.tr("ui.graphics_api"), |ui| {
// The dropdown takes the whole width, as the graphics card's does; the note below
// says when it applies.
graphics_api(app, ui, on_trial);
if let Some(warning) = freeze_warning(app) {
ui_kit::text(ui, &warning, ui_kit::Text::Error("graphics_api_freezes"));
}
restart_now(app, ui, on_trial);
});
}
ui_kit::settings_section(ui, "quality", app.tr("prefs.curved_surfaces"), |ui| {
use solidcraft_engine::doc::display_quality::DisplayQuality;
let current = DisplayQuality::parse(&app.preferences.display_quality).unwrap_or_default();
@@ -95,13 +108,15 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = egui::vec2(t.space_10, t.space_10);
for (m, figure) in MSAA.into_iter().zip(figures) {
let enabled = app.msaa_supported.is_empty() || app.msaa_supported.contains(&m);
let supported = app.msaa_supported.is_empty() || app.msaa_supported.contains(&m);
let enabled = supported && !on_trial;
let label = if m == 1 { app.tr("ui.colour_vision_off").to_owned() } else { format!("{m}×") };
let hint = app.tr(if on_trial { "settings.graphics_trial_first" } else { "settings.graphics_applies_on_restart" });
let response = ui_kit::picture_card(
ui,
&ui_kit::Card {
label: &label,
sub: Some(if enabled { "" } else { app.tr("prefs.not_on_gpu") }),
sub: Some(if supported { "" } else { app.tr("prefs.not_on_gpu") }),
selected: app.preferences.msaa == m,
enabled,
options: None,
@@ -111,8 +126,8 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
},
(t.space_24 * 5.0 + t.space_10).min(ui.max_rect().width()),
)
.backing_on_hover_text(app.tr("settings.msaa_applies_on_restart"))
.backing_on_disabled_hover_text(app.tr("settings.msaa_applies_on_restart"));
.backing_on_hover_text(hint)
.backing_on_disabled_hover_text(if supported { hint } else { app.tr("prefs.not_on_gpu") });
crate::scenario::publish_target(&format!("prefs:msaa:{m}"), response.rect);
crate::scenario::publish_target(&format!("prefs:option:{label}"), response.rect);
if enabled && response.clicked() {
@@ -123,6 +138,10 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
if app.msaa_compatibility {
ui_kit::text(ui, app.tr("prefs.anti_aliasing_amd_dx12"), ui_kit::Text::Hint("msaa_compatibility"));
}
// Without an API to choose (macOS), the restart for the anti-aliasing comes here.
if app.graphics.apis.is_empty() {
restart_now(app, ui, on_trial);
}
ui_kit::settings_form(ui, help_label_width, |ui| {
ui_kit::settings_row_help(ui, app.tr("prefs.graphics_page.supersampling"), app.tr("settings.supersampling_note"), |ui| {
let mut selected = SUPERSAMPLING.iter().position(|f| *f == app.preferences.supersampling).unwrap_or(0);
@@ -548,3 +567,42 @@ fn field_of_view_row(app: &mut SolveApp, ui: &mut egui::Ui) {
response
});
}
/// The graphics API (#4015): Automatic or one of [`Graphics::apis`], tried at the next start.
/// `WGPU_BACKEND` decides it when set, and settings on trial are kept or dropped first.
fn graphics_api(app: &mut SolveApp, ui: &mut egui::Ui, on_trial: bool) {
if let Some(value) = app.graphics.env_override.clone() {
let note = crate::i18n::format("settings.graphics_api_env", &[("value", &value)]);
ui.add_enabled_ui(false, |ui| ui_kit::dropdown_menu(ui, "pref_graphics_api", &value, |_| {})).response.on_disabled_hover_text(note);
return;
}
let apis: Vec<&str> = std::iter::once("auto").chain(app.graphics.apis.iter().copied()).collect();
ui.add_enabled_ui(!on_trial, |ui| {
ui_kit::dropdown_menu(ui, "pref_graphics_api", &graphics_api_label(&app.preferences.graphics_api), |ui| {
for api in apis {
if ui_kit::list_picker(ui, api, &graphics_api_label(api), app.preferences.graphics_api == api).clicked() {
app.preferences.graphics_api = api.into();
}
}
})
})
.response
.on_disabled_hover_text(app.tr("settings.graphics_trial_first"));
}
/// The graphics API and anti-aliasing apply at start ([`Graphics`]): a note, and Restart now
/// once the chosen ones differ from the saved ones.
fn restart_now(app: &mut SolveApp, ui: &mut egui::Ui, on_trial: bool) {
if app.services.graphics_path.is_none() {
return;
}
ui_kit::text(ui, app.tr("settings.graphics_applies_on_restart"), ui_kit::Text::Hint("graphics_restart"));
if !on_trial
&& selected_graphics(app) != app.graphics.saved
&& ui_kit::button(ui, app.tr("settings.graphics_restart_now"), ui_kit::Button::Normal).clicked()
{
// The choice stays (as OK keeps it) and is tried by the start that follows.
close(app, false);
restart_for_graphics(app);
}
}
+57 -1
View File
@@ -1,12 +1,20 @@
//! The notice for settings put back at start because they would keep SolidCraft from starting
//! on this computer (`SolveApp::settings_reset`), shown until dismissed.
//! on this computer (`SolveApp::settings_reset`), shown until dismissed; and the question about
//! graphics settings on trial (`prefs::Graphics`, #4015).
use crate::SolveApp;
use crate::ui_kit;
/// Seconds the question about graphics settings on trial waits before going back.
pub const TRIAL_SECONDS: f64 = 15.0;
/// The most one frame adds to that wait, so a window that drew nothing for a while (hidden)
/// doesn't give up the moment it draws again, while a hung GPU's frames, a second apart, count.
const TRIAL_FRAME_SECONDS: f64 = 1.5;
/// Draw the notice while any setting was reset. OK dismisses it; "Open Preferences" dismisses it
/// and opens the Graphics page.
pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
graphics_trial(app, ctx);
if app.settings_reset.is_empty() {
return;
}
@@ -25,3 +33,51 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
crate::prefs::open_at(app, crate::prefs::PAGE_GRAPHICS);
}
}
/// While this start tries graphics settings: Keep saves them; Go back (or the window's close)
/// returns to the saved ones and starts again. Unanswered for [`TRIAL_SECONDS`], it gives up
/// and closes at once (unsaved changes stay for recovery, as after a crash), for a window the
/// new settings left white or black can't show the question or ask about unsaved designs. The
/// next start finds the trial unkept and goes back, saying so.
pub fn graphics_trial(app: &mut SolveApp, ctx: &egui::Context) {
// A restart whose quit the user cancelled (Cancel at "save changes?") is off.
if app.graphics.restart && app.docs.quitting.is_none() && !app.quit_requested {
app.graphics.restart = false;
}
let now = ctx.input(|i| i.time);
let Some(trial) = &mut app.graphics.trial else { return };
if let Some(last) = trial.last_frame {
trial.shown += (now - last).clamp(0.0, TRIAL_FRAME_SECONDS);
}
trial.last_frame = Some(now);
let left = TRIAL_SECONDS - trial.shown;
if left <= 0.0 {
log::warn!("graphics: the settings on trial were not kept in {TRIAL_SECONDS} s; starting again with the saved ones");
eprintln!("SolidCraft graphics: the settings on trial weren't kept in {TRIAL_SECONDS} s; starting again");
app.graphics.trial = None;
app.graphics.restart = true;
app.quit_requested = true;
// The host closes the window in its next frame, and an idle window draws none: without
// this it waited for the pointer to move, still on the settings that didn't work.
ctx.request_repaint();
return;
}
ctx.request_repaint_after(std::time::Duration::from_millis(250));
let previous = trial.previous.clone();
let current = crate::prefs::selected_graphics(app);
let body = crate::i18n::format(
"settings.graphics_trial_body",
&[("settings", &current.describe(&previous)), ("previous", &previous.describe(&current)), ("seconds", &format!("{}", left.ceil()))],
);
let (closed, action) = ui_kit::Window::new(egui::Id::new("sc_graphics_trial"), "settings", app.tr("settings.graphics_trial_title"))
.actions(app.tr("settings.graphics_trial_keep"), app.tr("settings.graphics_trial_revert"), None)
.show(ctx, true, |ui| {
ui_kit::text(ui, &body, ui_kit::Text::Report("graphics_trial", false));
});
match action {
ui_kit::Action::Ok => crate::prefs::keep_graphics(app),
ui_kit::Action::Cancel => crate::prefs::revert_graphics(app),
_ if closed => crate::prefs::revert_graphics(app),
_ => {}
}
}
+55
View File
@@ -220,6 +220,61 @@ fn settings_reset_notice_dismisses_or_opens_the_graphics_page() {
assert_eq!(h.app.prefs_window.section, crate::prefs::PAGE_GRAPHICS);
}
/// Graphics settings on trial (#4015): the question's Keep and Go back, and an unanswered one
/// giving up after its time: it closes for a restart and leaves the trial for the next start to
/// drop.
#[test]
fn graphics_trial_question_keeps_goes_back_or_gives_up() {
use crate::prefs::{GraphicsChoice, GraphicsTrial};
let dx12 = GraphicsChoice { api: "dx12".into(), msaa: 4 };
let on_trial = |h: &mut Harness| {
h.app.quit_requested = false;
h.app.graphics.restart = false;
h.app.graphics.saved = dx12.clone();
h.app.graphics.trial = Some(GraphicsTrial { previous: dx12.clone(), shown: 0.0, last_frame: None });
(h.app.preferences.graphics_api, h.app.preferences.msaa) = ("vulkan".into(), 8);
};
let mut h = Harness::new();
h.app.home.open = false;
on_trial(&mut h);
card_is_drawn(&mut h);
assert!(h.app.graphics.trial.as_ref().and_then(|t| t.last_frame).is_some(), "the countdown starts when the question shows");
press(&mut h, "Keep");
assert_eq!(h.app.graphics.saved, GraphicsChoice { api: "vulkan".into(), msaa: 8 });
assert!(h.app.graphics.trial.is_none() && !h.app.graphics.restart && !h.app.quit_requested);
on_trial(&mut h);
press(&mut h, "Go back");
assert_eq!((h.app.preferences.graphics_api.as_str(), h.app.preferences.msaa), ("dx12", 4));
assert!(h.app.graphics.trial.is_none() && h.app.graphics.restart && h.app.quit_requested);
// A window that drew nothing for a long while (hidden behind others) doesn't give up the
// moment it draws again.
on_trial(&mut h);
h.frames(2);
if let Some(trial) = &mut h.app.graphics.trial {
trial.last_frame = trial.last_frame.map(|t| t - 1000.0);
}
h.frames(1);
let shown = h.app.graphics.trial.as_ref().map(|t| t.shown);
assert!(shown.is_some_and(|s| s < 2.0) && !h.app.quit_requested, "still asking: {shown:?}");
if let Some(trial) = &mut h.app.graphics.trial {
trial.shown = crate::prefs_notice::TRIAL_SECONDS;
}
h.frames(1);
assert!(h.app.graphics.trial.is_none() && h.app.graphics.restart && h.app.quit_requested, "gave up: closes for a restart");
assert!(h.ctx.has_requested_repaint(), "an idle window still draws the frame that closes it");
assert_eq!(h.app.preferences.graphics_api, "vulkan", "nothing saved: the next start drops the trial");
// A start from the desktop opens on Home: the question shows there too.
h.app.home.open = true;
on_trial(&mut h);
card_is_drawn(&mut h);
press(&mut h, "Keep");
assert!(h.app.graphics.trial.is_none() && !h.app.graphics.restart);
}
#[test]
fn object_properties_card_only_closes() {
let mut h = Harness::new();
+30 -5
View File
@@ -134,8 +134,31 @@ old Intel Vulkan driver, `igvk64.dll`, did). Windows' Event Viewer names the dri
From 0.11.1, SolidCraft on Windows starts Direct3D 12 first and loads no other graphics API
when Direct3D 12 has a graphics card; Vulkan and then OpenGL are tried only on a computer
without one. Earlier versions started Vulkan, Direct3D 12 and OpenGL together and preferred
Vulkan. To choose the graphics API yourself, in any version, open a Command Prompt and start
SolidCraft from it:
Vulkan. On a computer whose only graphics is AMD's integrated GPU (Ryzen laptops and desktops
without a graphics card), SolidCraft starts Vulkan instead: Direct3D 12 hung those drivers a
few seconds after the window opened, and the screen went black until Windows reset the driver.
If the graphics driver stops responding while SolidCraft is open (the window stays white or
black and every click takes a second), SolidCraft notices after a few seconds, closes, and
starts again: on Vulkan if it was using Direct3D 12 (it keeps using Vulkan, as `"backend":
"vulkan"` in `graphics.json`), otherwise in safe mode. Unsaved changes are offered for recovery.
The restarted SolidCraft ignores `WGPU_BACKEND` (below); remove it if you set it with `setx`,
or the next start uses that API again.
To choose the graphics API yourself, use **Preferences › Graphics › Graphics API** (Windows
and Linux): Automatic (the order above), Direct3D 12, Vulkan or OpenGL. The graphics API and
**Anti-aliasing** apply when SolidCraft starts, so a change is tried first: **Restart now**
starts SolidCraft again with it (or the next start does), and it asks *Keep these graphics
settings?* Keep saves them; **Go back**, or no answer within 15 seconds, starts again with the
previous ones. If the new settings leave the window white or black, crash it, or hang it, the
next start goes back to the previous settings and says so, so a choice can't leave SolidCraft
stuck. A start on new settings that draws no window within a minute starts again on the
previous ones by itself. A change made over the control channel (`ui.prefs`) is saved at once, without a trial.
On AMD integrated graphics, Preferences warns when Direct3D 12 is chosen: there it has frozen
the whole screen, at times until the computer was restarted, which no trial start can undo. If
SolidCraft crashes there on Direct3D 12 after you kept it (opening a design, say), the next
start goes back to Automatic and says so; Preferences can choose Direct3D 12 again.
In any version, you can also open a Command Prompt and start SolidCraft from it:
```
set WGPU_BACKEND=dx12
@@ -143,9 +166,11 @@ set WGPU_BACKEND=dx12
```
`vulkan` and `gl` are the other choices. `setx WGPU_BACKEND dx12` keeps it for apps started
from the Start menu after you sign in again. From 0.11.1 the setting can also live in
`graphics.json` in the settings folder, as `{"msaa": 4, "backend": "dx12"}` (`auto` is the
order above). Safe mode ignores it and always uses the software renderer.
from the Start menu after you sign in again. While `WGPU_BACKEND` is set, it decides the API
and Preferences shows it greyed out. From 0.11.1 the setting can also live in `graphics.json`
in the settings folder, as `{"msaa": 4, "backend": "dx12"}` (`auto` is the order above);
settings being tried are its `trial` entry. Safe mode ignores them and always uses the
software renderer.
With OpenGL on Windows, the window has the Windows title bar above SolidCraft's application bar
instead of using the application bar as its title bar. Some OpenGL drivers (older Intel HD and
+1
View File
@@ -708,6 +708,7 @@ One setting per line, sorted by name. A change that adds a setting adds its own
- `extrude_auto_to_object` (default true: in the Extrude dialog, with a profile picked, a face, vertex or parallel plane picked off its plane switches the extent to To Object, unless the Profiles box was clicked into; it applies to Extrude dialogs opened afterwards)
- `frame_rate_details` (default false: with the frame rate, a frame-time graph of the frames drawn with the triangles over it, a frame-time histogram and what the GPU drew; clicking the readout switches it)
- `graphics_adapter` (exact card name, or empty for Automatic; applies after restart; `WGPU_ADAPTER_NAME` takes precedence)
- `graphics_api` (`auto` (default), or on Windows `dx12`, `vulkan`, `gl` and on Linux `vulkan`, `gl`; with `msaa`, saved at once for the next start, without the trial and question that Preferences uses)
- `hidden_toolbar_tabs` (default `[]`: all existing tabs, including Sheet Metal and Weldments, stay visible; lists tabs to hide, each once, e.g. `["SURFACE", "SHEET METAL"]`; hidden tabs stay hidden regardless of the design's features; [ribbon layout](ribbon-layout.md))
- `history_sketches` (`in_order` (default), `under_feature`: the History at the left lists a sketch under the first feature built from it, #6489; the timeline at the bottom keeps the creation order)
- `hole_moves` (default false: a placed Single hole has a drag handle (`hole:<n>`), and a pick on a face that has a hole moves it there)
+6
View File
@@ -96,6 +96,12 @@ SolidCraft changes:
error scopes, awaiting every result before returning it. A refused UI pipeline (D3D12
`egui_pipeline` shader compilation, #6648) returns `WgpuError::CreateRendererError` through
eframe so startup can retry another backend instead of invoking wgpu's panic handler.
- `src/lib.rs`, `src/winit.rs`: `stalled_frames` counts the frames in a row whose texture took
at least `STALLED_ACQUIRE` (950 ms) to acquire. wgpu waits at most a second for the next
image and D3D12 then goes on as if it had come, so a hung GPU shows only as frames that each
wait that second; the app restarts on another renderer after a few of them (#3321). Only the
main window counts. `refused_for` says how long the main window has skipped its frames on a
refused configure, which never reach the acquire.
## winit 0.30.13 (Apache-2.0, © The winit contributors, https://github.com/rust-windowing/winit)
+69
View File
@@ -70,6 +70,61 @@ pub(crate) fn startup_step(step: &'static str, detail: &str) {
}
}
/// SolidCraft: a frame whose texture took at least this long to acquire stalled. wgpu waits at
/// most a second for the swap chain's next image, and D3D12 then goes on as if it had come: a
/// hung GPU shows as frames that each wait that second and never reach the screen, a white
/// window with nothing reported (#3321).
pub const STALLED_ACQUIRE: std::time::Duration = std::time::Duration::from_millis(950);
static STALLED_FRAMES: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
/// SolidCraft: how many of the main window's frames in a row stalled acquiring their texture
/// ([`STALLED_ACQUIRE`]). Other viewports (the agent chat's window) don't count: a frame on time
/// there would start a stalled main window's count over.
pub fn stalled_frames() -> u32 {
STALLED_FRAMES.load(std::sync::atomic::Ordering::Relaxed)
}
/// The frames in a row that stalled, after one whose texture took `elapsed`. Only the `winit`
/// painter acquires textures.
#[cfg_attr(not(feature = "winit"), allow(dead_code))]
fn count_stall(stalled: u32, elapsed: std::time::Duration) -> u32 {
if elapsed >= STALLED_ACQUIRE {
stalled.saturating_add(1)
} else {
0
}
}
/// The main window's acquire took `elapsed`: its surface is configured again, if it wasn't.
#[cfg_attr(not(feature = "winit"), allow(dead_code))]
pub(crate) fn note_acquire(elapsed: std::time::Duration) {
let stalled = count_stall(stalled_frames(), elapsed);
STALLED_FRAMES.store(stalled, std::sync::atomic::Ordering::Relaxed);
if let Ok(mut since) = REFUSED_SINCE.lock() {
*since = None;
}
}
static REFUSED_SINCE: std::sync::Mutex<Option<web_time::Instant>> = std::sync::Mutex::new(None);
/// SolidCraft: how long the main window has gone without acquiring a texture because its surface
/// refuses its configuration (or waits for a refused one's retry, #1935); `None` while it
/// acquires. Those frames are skipped before the acquire, so they never count as stalled: a
/// surface refused for good after a GPU reset leaves a blank window with nothing else to show
/// for it (#3321).
pub fn refused_for() -> Option<std::time::Duration> {
REFUSED_SINCE.lock().ok().and_then(|since| *since).map(|since| since.elapsed())
}
/// The main window skipped a frame at `now` on a refused configure.
#[cfg_attr(not(feature = "winit"), allow(dead_code))]
pub(crate) fn note_refused(now: web_time::Instant) {
if let Ok(mut since) = REFUSED_SINCE.lock() {
since.get_or_insert(now);
}
}
/// An error produced by egui-wgpu.
#[derive(thiserror::Error, Debug)]
pub enum WgpuError {
@@ -881,6 +936,20 @@ mod msaa_tests {
assert_eq!(super::msaa_counts(F::MULTISAMPLE_X4, None), [1]);
assert_eq!(super::clamp_msaa_samples(&[], 8), 1);
}
/// Frames that each waited out wgpu's one-second acquire count up; one on time starts over
/// (#3321).
#[test]
fn frames_that_wait_out_the_acquire_count_as_stalled() {
use std::time::Duration;
let second = Duration::from_millis(1000);
let stalled = (0..3).fold(0, |n, _| super::count_stall(n, second));
assert_eq!(stalled, 3);
assert_eq!(super::count_stall(stalled, Duration::from_millis(16)), 0);
// A slow frame isn't a stall: the image came before the timeout.
assert_eq!(super::count_stall(0, Duration::from_millis(400)), 0);
assert_eq!(super::count_stall(u32::MAX, second), u32::MAX);
}
}
/// SolidCraft: run `f` with `device`'s errors captured, and the first error it caused. Uncaptured,
+14 -1
View File
@@ -687,6 +687,9 @@ impl Painter {
&& let Err(err) = self.recreate_surface(viewport_id, window)
{
log::error!("Failed to recreate surface for {viewport_id:?}: {err}");
if viewport_id == ViewportId::ROOT {
crate::note_refused(now);
}
return vsync_sec;
}
@@ -741,6 +744,10 @@ impl Painter {
now,
)
{
// SolidCraft: the main window's time without a texture (#3321, `refused_for`).
if viewport_id == ViewportId::ROOT {
crate::note_refused(now);
}
return vsync_sec;
}
@@ -761,7 +768,13 @@ impl Painter {
if let Some(error) = failed {
log::warn!("The surface gave no frame: {error}");
}
vsync_sec += start.elapsed().as_secs_f32();
let elapsed = start.elapsed();
vsync_sec += elapsed.as_secs_f32();
// SolidCraft: the main window's frames that wait out the acquire are a hung GPU
// (#3321).
if viewport_id == ViewportId::ROOT {
crate::note_acquire(elapsed);
}
output_frame
};