mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-24 10:16:59 -06:00
Merge PR #380: Startup: pick the GPU that drives the display, fall back across adapters and backends, and log startup errors
Conflict in apps/designcraft/src/main.rs (with #366): keep both, self.startup.leave() then the macOS IME discard.
This commit is contained in:
Generated
+7
@@ -912,6 +912,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"winit",
|
||||
"winresource",
|
||||
"winsafe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5614,6 +5615,12 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winsafe"
|
||||
version = "0.0.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ef0ffc427f045c0cc9ebffd6f4f91153dcd2a1547b5c3c29ee3f541e16e95c6"
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.57.1"
|
||||
|
||||
@@ -133,8 +133,9 @@ The desktop app writes its `log` records to standard error and to `logs/designcr
|
||||
settings directory, beside `ui.json` and `prefs.json` (Linux `~/.config/designcraft/logs/`, or
|
||||
`$XDG_CONFIG_HOME/designcraft/logs/`; macOS `~/Library/Application Support/DesignCraft/logs/`;
|
||||
Windows `%APPDATA%\DesignCraft\logs\`). A start launched from a desktop menu or the Dock has no
|
||||
terminal, so this file is what to attach to a bug report: the crash guard's panic report and a
|
||||
failed crash-recovery save land there. Each launch moves the previous log to `designcraft.1.log`
|
||||
terminal, so this file is what to attach to a bug report: the graphics adapters found and the
|
||||
one drawing, why the app stopped (also when its window never opened), the crash guard's panic
|
||||
report and a failed crash-recovery save land there. Each launch moves the previous log to `designcraft.1.log`
|
||||
(and that one to `designcraft.2.log`), so the log of a run that crashed survives the next start.
|
||||
The file stops growing at 16 MiB. `--version` writes no file.
|
||||
|
||||
@@ -142,23 +143,71 @@ The file stops growing at 16 MiB. `--version` writes no file.
|
||||
|---|---|
|
||||
| `RUST_LOG` | Log levels for standard error and the log file. Default: `info` for DesignCraft's own crates, `warn` for everything else. env_logger-style directives replace that, e.g. `RUST_LOG=debug`, `RUST_LOG=warn,designcraft_render=trace` or `RUST_LOG=info,wgpu_core=warn`; a directive ending in `*` covers every target starting with it (`designcraft*=debug`). |
|
||||
| `WGPU_BACKEND` | The graphics backend that composites the window: `dx12`, `vulkan`, `gl` or `metal` (a comma-separated list lets wgpu choose among them). Default: DirectX 12 on Windows, Vulkan on Linux, Metal on macOS, with OpenGL as the fallback. Setting it also turns off the start-up fallback described below. |
|
||||
| `WGPU_VALIDATION_INDIRECT_CALL` | `1` turns wgpu's check of indirect draw arguments back on. DesignCraft draws nothing indirectly and turns it off, since the compute shader the check needs fails to compile on some drivers (Metal on an Intel Iris Pro), which lost the device before the window opened. |
|
||||
| `WGPU_POWER_PREF` | The kind of GPU that draws the window: `low` (integrated), `high` (discrete) or `none` (the system's order). Default: the GPU that drives the primary display, then `low` on Windows and macOS and `none` elsewhere. |
|
||||
| `WGPU_ADAPTER_NAME` | The graphics adapter by (part of) its name, any case (`nvidia`, `radv`, `intel`); the log lists the names. |
|
||||
| `WGPU_VALIDATION_INDIRECT_CALL` | `1` turns wgpu's check of indirect draw arguments back on. DesignCraft draws nothing indirectly and turns it off: the compute shader it needs failed to compile on an Intel Mac and lost the graphics device (#334). |
|
||||
|
||||
The logger is `apps/designcraft/src/logging.rs`; the web build logs to the browser console instead.
|
||||
|
||||
### Graphics backend
|
||||
### Graphics backend and processor
|
||||
|
||||
The canvas is rendered on the CPU; the GPU only composites the interface, through wgpu. A
|
||||
graphics driver that faults takes the process down before any Rust code can catch it, so the
|
||||
backend is chosen before the window exists, and a start that never showed a frame is remembered:
|
||||
`gpu.json` in the settings directory records the backend being tried until the first frame has
|
||||
been presented. A record left behind means that backend crashed (or the app was killed) at
|
||||
start-up, so the next start tries the next one (DirectX 12, Vulkan, then OpenGL on Windows;
|
||||
Vulkan, then OpenGL on Linux) and says so in the status bar. The list starts over once every
|
||||
backend has failed, or with a new DesignCraft version; delete the file or set `WGPU_BACKEND` to
|
||||
start afresh. Seen in the wild: on an AMD hybrid-graphics laptop (a Radeon RX 6800M beside an
|
||||
integrated Radeon) the AMD Vulkan driver faulted at the first present, which is why DirectX 12 is
|
||||
the Windows default. The code is `apps/designcraft/src/gpu.rs`.
|
||||
The canvas is rendered on the CPU; the GPU only composites the interface, through wgpu, so any
|
||||
adapter that can show the window will do. The code is `apps/designcraft/src/gpu.rs` (the adapter)
|
||||
and `apps/designcraft/src/gpu/backend.rs` (the backend).
|
||||
|
||||
- **Backend.** A graphics driver that faults takes the process down before any Rust code can
|
||||
catch it, so each start creates its wgpu instance with one backend, chosen before the window
|
||||
exists: on Windows DirectX 12, then OpenGL, then Vulkan, each on its own (creating an OpenGL
|
||||
instance crashed inside AMD's `atio6axx.dll`, #167, and creating a Vulkan instance loads every
|
||||
installed Vulkan driver, of which a faulty one can crash the app); on Linux Vulkan with OpenGL
|
||||
beside it, then OpenGL alone; on macOS Metal. Seen in the wild: on an AMD hybrid-graphics laptop
|
||||
(a Radeon RX 6800M beside an integrated Radeon) the AMD Vulkan driver faulted at the first
|
||||
present, which is why DirectX 12 is the Windows default.
|
||||
- **A start that never showed a frame is remembered:** `gpu.json` in the settings directory
|
||||
records the backend being tried until the first frame has been presented. A record left behind
|
||||
means that backend crashed (or the app was killed) at start-up, so the next start tries the next
|
||||
backend and says so in the status bar. The list starts over once every backend has failed, or
|
||||
with a new DesignCraft version; delete the file or set `WGPU_BACKEND` to start afresh.
|
||||
- **Adapter.** Among the adapters of those backends, the app picks the one that draws the window
|
||||
(eframe's `native_adapter_selector`): adapters that report they can't present to the window are
|
||||
never tried; then the one `WGPU_ADAPTER_NAME` names; hardware before software (llvmpipe, WARP);
|
||||
the native backends before OpenGL; the GPU that drives a display, and among those the one that
|
||||
drives the primary display; then the power preference; then a GPU's DirectX 12 adapter before its
|
||||
Vulkan one. A GPU without a monitor is the wrong one even when it can present: Windows has to
|
||||
copy every frame to the GPU that shows it, and an integrated GPU's driver reset under that,
|
||||
losing the graphics device or blanking every monitor for minutes. Windows says which GPU drives
|
||||
which display (`EnumDisplayDevices`), Linux through `/sys/class/drm` (a built-in `eDP`, `LVDS` or
|
||||
`DSI` panel counts as the primary display); on macOS, or when nothing matches, the power
|
||||
preference decides: power saving on Windows and macOS, the system's order elsewhere (Mesa puts
|
||||
the GPU the desktop runs on first, and a Wayland compositor may refuse frames from another GPU).
|
||||
`WGPU_POWER_PREF` replaces this with the user's choice of kind.
|
||||
- **If the window fails on its adapter while starting up** (an error from wgpu, such as a lost
|
||||
device, or a panic inside wgpu, egui-wgpu or naga during the first frames), the app starts
|
||||
again without that adapter and tries the next one, telling which in the status bar; the run that
|
||||
failed keeps its log as `designcraft.1.log`. When no adapter of the start's backends is left, it
|
||||
starts again with the next backend. Software renderers (WARP, llvmpipe) come last: they are
|
||||
passed over while another backend is left to try, and allowed once no graphics processor could
|
||||
show the window. On Unix the new app replaces the process (an AppImage stays mounted); on
|
||||
Windows it starts beside it. Adapters are told apart by backend and PCI ids, or by name where the
|
||||
backend reports no ids (Metal lists every GPU of a dual-GPU Mac as `0000:0000`). What was left
|
||||
out travels in the restarted app's `--gpu-skip=` argument (`Vulkan:1002:164e`, `Metal:Intel
|
||||
Iris Pro Graphics`, or a whole backend such as `Dx12`), one more on each restart, so the
|
||||
restarts end. When nothing is left, the app logs why and exits with an error. Documents
|
||||
recovered after a crash stay in the recovery folder until they are saved or closed, so a restart
|
||||
never loses them.
|
||||
- **If the window's first frame is late** (20 seconds after the app was created and the files
|
||||
named at the start opened), the log and the status bar say so. The adapter counts as hanging,
|
||||
and the app starts again without it, only when the UI thread has been stuck in the graphics
|
||||
stack, outside DesignCraft's own code, for 60 seconds while the window wasn't known to be
|
||||
minimized, covered or in the background. A large document composing on a slow machine never
|
||||
counts.
|
||||
- **The log** lists the GPUs that drive a display (`display GPUs (PCI vendor:device): 10de:2204
|
||||
(primary)`), every adapter with whether it drives a display, the one drawn on and why (`drawing
|
||||
on NVIDIA GeForce RTX 3090 (Dx12, DiscreteGpu): it drives the primary display`), and the driver
|
||||
version. That is the first thing to look for in a report of a window that stays black or never
|
||||
opens.
|
||||
- On Linux with Mesa, `MESA_VK_DEVICE_SELECT=10de:2f04!` (vendor:device as `vulkaninfo --summary`
|
||||
or `lspci -nn` show it) leaves only that GPU to Vulkan, and `DRI_PRIME=1` picks the other GPU.
|
||||
|
||||
### Web
|
||||
|
||||
|
||||
@@ -360,6 +360,7 @@ Newest first.
|
||||
- **2026-10-15** · IDML placed PDFs keep their page and Crop to box (`PDFAttribute` `PageNumber` and `PDFCrop`, written for every placed PDF); two crops of one PDF page stay two assets; a page the PDF lacks is left out of PDF export with a warning instead of failing it; `$ID/` font names resolve; the IDML in a package links the images in its Links folder without embedding them. No readiness percentage change.
|
||||
- **2026-10-15** · The CJK leading model and character alignment take effect only in paragraphs set with a Japanese composer (`HL Composer J`, `HL Single J`), which IDML import and export keep: Western and Arabic paragraphs set baseline to baseline on the Roman baseline, a paragraph switched to a Japanese composer gets the file's Aki Below spacing, and under Aki Below a line's leading is the space below it. No readiness percentage change.
|
||||
- **2026-10-15** · Document pages follow their parent page's margins and columns unless margins were set on the page itself (`Page::local_margins`): applying a different parent gives a page that parent's margins, a page that changes side after an insert takes the other parent page's, and a page given margins equal to its parent's keeps them when the parent changes. IDML import and older files mark pages whose margins differ from their parent as their own. No readiness percentage change.
|
||||
- **2026-10-15** · Start-up picks the GPU that drives the display (or the kind `WGPU_POWER_PREF` or the adapter `WGPU_ADAPTER_NAME` names), and when a start-up fails or hangs it restarts on the next adapter, then the next backend, then a software renderer, keeping recovered documents; start-up errors are logged to standard error and the log file (#273, #334). No readiness percentage change.
|
||||
- **2026-10-14** · The Type tool draws a new text frame over a locked empty frame, as InDesign does, instead of turning the locked frame into a text frame. No readiness percentage change.
|
||||
- **2026-10-14** · 100% black shows as pure black on screen by default, as InDesign's Appearance of Black does out of the box (Display All Blacks as Rich Black); Preferences › Appearance of Black › Display All Blacks Accurately still shows it as the dark grey it prints, and a saved choice is kept. No readiness percentage change.
|
||||
- **2026-10-14** · Pathfinder keeps the frontmost object, as InDesign does: a photo placed over shapes and intersected with them shows through the shapes (it was dropped, and the shapes kept their black fill); Add, Exclude Overlap and Minus Back take the front object's fill too, Subtract still keeps the back one. No readiness percentage change.
|
||||
|
||||
@@ -31,6 +31,12 @@ arboard = { workspace = true }
|
||||
# The same winit eframe uses: to pick the X11 backend (file drag-and-drop) in main.rs.
|
||||
winit = { version = "0.30", default-features = false, features = ["x11"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
# EnumDisplayDevices (src/gpu.rs): which GPU drives the display, so the window draws on the GPU
|
||||
# that shows it; drawing on a GPU without a monitor reset the display driver. Safe wrappers over
|
||||
# Win32 (MIT); the `user` feature is User32.dll only.
|
||||
winsafe = { version = "0.0.29", default-features = false, features = ["user"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
# The same winit as eframe; only the native termination opt-in is used.
|
||||
winit = { version = "0.30", default-features = false }
|
||||
|
||||
+1256
-325
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,520 @@
|
||||
//! Which wgpu backend draws the window, and the fallback when one of them takes the process down
|
||||
//! at start-up.
|
||||
//!
|
||||
//! A fault inside a graphics driver can't be caught: on an AMD hybrid-graphics laptop (a Radeon
|
||||
//! RX 6800M beside an integrated Radeon, two versions of `amdvlk64.dll` loaded into the process)
|
||||
//! AMD's Vulkan driver faulted with an access violation at the first swapchain present; creating
|
||||
//! an OpenGL instance crashed inside AMD's `atio6axx.dll` (#167); and creating a Vulkan instance
|
||||
//! loads every installed Vulkan driver into the process, of which a faulty one (Intel's
|
||||
//! `igvk64.dll`, VectorCraft #806) crashed before the window appeared. So each start creates the
|
||||
//! instance with one backend, chosen before the window exists: on Windows DirectX 12 first, then
|
||||
//! OpenGL, then Vulkan, each on its own; on Linux Vulkan, with OpenGL beside it as the in-process
|
||||
//! fallback, then OpenGL alone; on macOS Metal. `WGPU_BACKEND` — wgpu's own variable: `dx12`,
|
||||
//! `vulkan`, `gl`, `metal`, or a comma-separated list — overrides the choice and turns the
|
||||
//! fallback off.
|
||||
//!
|
||||
//! The fallback: before the window is created, `gpu.json` in the settings directory records the
|
||||
//! backend being tried; once a frame has been presented, or the graphics failed with an error the
|
||||
//! app caught (see `gpu::finish`), that entry is cleared. An entry still there at the next start
|
||||
//! means the previous start never got that far — the driver crashed, or the app was killed
|
||||
//! meanwhile — so that backend joins `failed`, the next candidate is tried and the status bar says
|
||||
//! so. Backends a restart left out (`--gpu-skip=`, see `gpu`) aren't chosen for that start but
|
||||
//! aren't recorded: the restart caught their failure, and a later launch may try them again. When
|
||||
//! every candidate has failed the list starts over, and so does a new DesignCraft version (drivers
|
||||
//! and wgpu move on). `DESIGNCRAFT_NO_PREFS` records nothing.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use eframe::wgpu;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The record's file name inside the settings directory.
|
||||
pub const FILE: &str = "gpu.json";
|
||||
|
||||
/// A graphics backend wgpu can draw the window with.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Backend {
|
||||
Dx12,
|
||||
Vulkan,
|
||||
Metal,
|
||||
Gl,
|
||||
}
|
||||
|
||||
impl Backend {
|
||||
/// The name the status bar and the log use.
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Backend::Dx12 => "DirectX 12",
|
||||
Backend::Vulkan => "Vulkan",
|
||||
Backend::Metal => "Metal",
|
||||
Backend::Gl => "OpenGL",
|
||||
}
|
||||
}
|
||||
|
||||
/// wgpu's bit for this backend.
|
||||
pub fn bit(self) -> wgpu::Backends {
|
||||
match self {
|
||||
Backend::Dx12 => wgpu::Backends::DX12,
|
||||
Backend::Vulkan => wgpu::Backends::VULKAN,
|
||||
Backend::Metal => wgpu::Backends::METAL,
|
||||
Backend::Gl => wgpu::Backends::GL,
|
||||
}
|
||||
}
|
||||
|
||||
/// How a restart's skip list ([`super::skip_arg`]) names the whole backend: wgpu's name for it, which also
|
||||
/// starts each of its adapters' keys (`Dx12:10de:2204`).
|
||||
pub fn key(self) -> &'static str {
|
||||
match self {
|
||||
Backend::Dx12 => "Dx12",
|
||||
Backend::Vulkan => "Vulkan",
|
||||
Backend::Metal => "Metal",
|
||||
Backend::Gl => "Gl",
|
||||
}
|
||||
}
|
||||
|
||||
/// The backend [`Backend::key`] names; `None` for anything else, such as an adapter's key.
|
||||
pub fn from_key(key: &str) -> Option<Backend> {
|
||||
[Backend::Dx12, Backend::Vulkan, Backend::Metal, Backend::Gl].into_iter().find(|b| b.key() == key)
|
||||
}
|
||||
|
||||
/// The backend of the adapter wgpu picked; `None` for the web and no-op backends.
|
||||
pub fn of(backend: wgpu::Backend) -> Option<Backend> {
|
||||
match backend {
|
||||
wgpu::Backend::Dx12 => Some(Backend::Dx12),
|
||||
wgpu::Backend::Vulkan => Some(Backend::Vulkan),
|
||||
wgpu::Backend::Metal => Some(Backend::Metal),
|
||||
wgpu::Backend::Gl => Some(Backend::Gl),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The backends to try on this platform, best first. Windows reaches Vulkan last: creating its
|
||||
/// instance loads every installed Vulkan driver (VectorCraft #806).
|
||||
pub fn candidates() -> &'static [Backend] {
|
||||
if cfg!(windows) {
|
||||
&[Backend::Dx12, Backend::Gl, Backend::Vulkan]
|
||||
} else if cfg!(target_os = "macos") {
|
||||
&[Backend::Metal]
|
||||
} else {
|
||||
&[Backend::Vulkan, Backend::Gl]
|
||||
}
|
||||
}
|
||||
|
||||
/// What `gpu.json` holds.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Record {
|
||||
/// The DesignCraft version that wrote the record; another version starts afresh.
|
||||
#[serde(default)]
|
||||
pub version: String,
|
||||
/// The backend of a start that has not presented a frame yet.
|
||||
#[serde(default)]
|
||||
pub trying: Option<Backend>,
|
||||
/// Backends whose start never presented a frame.
|
||||
#[serde(default)]
|
||||
pub failed: Vec<Backend>,
|
||||
}
|
||||
|
||||
/// The backend for a start, from [`choose`].
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Choice {
|
||||
pub backend: Backend,
|
||||
/// The record to write before the window is created.
|
||||
pub record: Record,
|
||||
/// The backend the previous start never finished with, when this start moves away from it.
|
||||
pub switched_from: Option<Backend>,
|
||||
}
|
||||
|
||||
/// The backends a restart's skip list leaves out ([`super::skip_arg`]): a restart found no adapter
|
||||
/// of them that could show the window.
|
||||
pub fn skipped_backends(skip: &[String]) -> Vec<Backend> {
|
||||
skip.iter().filter_map(|k| Backend::from_key(k)).collect()
|
||||
}
|
||||
|
||||
/// Pick the backend for this start from the candidates (best first), the previous start's record
|
||||
/// and the backends a restart left out (`skipped`, not recorded); `None` without candidates.
|
||||
pub fn choose(candidates: &[Backend], previous: &Record, version: &str, skipped: &[Backend]) -> Option<Choice> {
|
||||
let first = *candidates.first()?;
|
||||
let same_version = previous.version == version;
|
||||
let mut failed: Vec<Backend> = if same_version { previous.failed.clone() } else { Vec::new() };
|
||||
let unfinished = previous.trying.filter(|_| same_version);
|
||||
if let Some(b) = unfinished
|
||||
&& !failed.contains(&b)
|
||||
{
|
||||
failed.push(b);
|
||||
}
|
||||
failed.retain(|b| candidates.contains(b));
|
||||
let pick = |failed: &[Backend]| candidates.iter().copied().find(|b| !failed.contains(b) && !skipped.contains(b));
|
||||
let backend = match pick(&failed) {
|
||||
Some(b) => b,
|
||||
// Every candidate has failed: start over rather than never starting.
|
||||
None => {
|
||||
failed.clear();
|
||||
pick(&failed).unwrap_or(first)
|
||||
}
|
||||
};
|
||||
let switched_from = unfinished.filter(|b| *b != backend);
|
||||
Some(Choice { backend, record: Record { version: version.to_owned(), trying: Some(backend), failed }, switched_from })
|
||||
}
|
||||
|
||||
/// The record in `path`; a missing or unreadable file is an empty record.
|
||||
pub fn read(path: &Path) -> Record {
|
||||
std::fs::read(path).ok().and_then(|bytes| serde_json::from_slice(&bytes).ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Write `record` to `path` (creating the directory).
|
||||
pub fn write(path: &Path, record: &Record) -> std::io::Result<()> {
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
let bytes = serde_json::to_vec_pretty(record).map_err(std::io::Error::other)?;
|
||||
std::fs::write(path, bytes)
|
||||
}
|
||||
|
||||
/// See [`Fallback::backends`].
|
||||
fn instance_backends(backend: Backend, failed: &[Backend]) -> wgpu::Backends {
|
||||
let mut backends = backend.bit();
|
||||
if !cfg!(windows) && !failed.contains(&Backend::Gl) {
|
||||
backends |= wgpu::Backends::GL;
|
||||
}
|
||||
backends
|
||||
}
|
||||
|
||||
/// The first of `candidates` whose bit isn't in `tried` and that hasn't `failed`.
|
||||
fn next_candidate(candidates: &[Backend], tried: wgpu::Backends, failed: &[Backend]) -> Option<Backend> {
|
||||
candidates.iter().copied().find(|b| !tried.contains(b.bit()) && !failed.contains(b))
|
||||
}
|
||||
|
||||
/// A start in progress: the backend chosen for it and the record that says so until a frame has
|
||||
/// been presented.
|
||||
#[derive(Debug)]
|
||||
pub struct Fallback {
|
||||
pub backend: Backend,
|
||||
pub switched_from: Option<Backend>,
|
||||
/// The backends the instance is created with ([`Fallback::backends`]).
|
||||
instance: wgpu::Backends,
|
||||
/// `gpu.json`, when the record is persisted.
|
||||
path: Option<PathBuf>,
|
||||
record: Record,
|
||||
}
|
||||
|
||||
impl Fallback {
|
||||
/// Choose the backend for this start and record it in `path` (nothing is recorded without
|
||||
/// one), leaving out the backends a restart `skipped`. `None` when `WGPU_BACKEND` is set —
|
||||
/// wgpu follows it and the fallback is off — or the platform has no candidates.
|
||||
pub fn begin(path: Option<PathBuf>, version: &str, skipped: &[Backend]) -> Option<Fallback> {
|
||||
if wgpu::Backends::from_env().is_some_and(|b| !b.is_empty()) {
|
||||
log::info!("graphics: WGPU_BACKEND is set, wgpu picks the backend and the start-up fallback is off");
|
||||
return None;
|
||||
}
|
||||
Self::begin_unforced(path, version, skipped)
|
||||
}
|
||||
|
||||
fn begin_unforced(path: Option<PathBuf>, version: &str, skipped: &[Backend]) -> Option<Fallback> {
|
||||
let previous = path.as_deref().map(read).unwrap_or_default();
|
||||
let choice = choose(candidates(), &previous, version, skipped)?;
|
||||
if let Some(p) = &path
|
||||
&& let Err(e) = write(p, &choice.record)
|
||||
{
|
||||
log::warn!("graphics: {}: {e}", p.display());
|
||||
}
|
||||
match choice.switched_from {
|
||||
Some(from) => log::warn!(
|
||||
"graphics: the last start with {} never showed a frame; trying {} (failed so far: {:?})",
|
||||
from.name(),
|
||||
choice.backend.name(),
|
||||
choice.record.failed
|
||||
),
|
||||
None => log::info!("graphics: {} (WGPU_BACKEND overrides)", choice.backend.name()),
|
||||
}
|
||||
let instance = instance_backends(choice.backend, &choice.record.failed);
|
||||
Some(Fallback { backend: choice.backend, switched_from: choice.switched_from, instance, path, record: choice.record })
|
||||
}
|
||||
|
||||
/// The backends wgpu may use: the chosen one, with OpenGL as the in-process fallback when it
|
||||
/// has no adapter (unless OpenGL itself has failed). Not on Windows: some OpenGL drivers
|
||||
/// (AMD's) crash while the instance is created (#167), before any fallback could help, so
|
||||
/// there OpenGL is only ever tried as its own candidate.
|
||||
pub fn backends(&self) -> wgpu::Backends {
|
||||
self.instance
|
||||
}
|
||||
|
||||
/// wgpu picked an adapter of `backend`: record that one, so a crash is blamed on the backend
|
||||
/// that is actually drawing.
|
||||
pub fn started_with(&mut self, backend: Backend) {
|
||||
if self.record.trying == Some(backend) {
|
||||
return;
|
||||
}
|
||||
self.backend = backend;
|
||||
self.record.trying = Some(backend);
|
||||
self.save();
|
||||
}
|
||||
|
||||
/// The backend to start again with when no adapter of this start's backends could show the
|
||||
/// window: the next candidate not among them, not failed and not left out by a restart
|
||||
/// (`skipped`). `None` when there is none, so the restarts end.
|
||||
pub fn next_after(&self, skipped: &[Backend]) -> Option<Backend> {
|
||||
let tried = skipped.iter().fold(self.backends(), |set, b| set | b.bit());
|
||||
next_candidate(candidates(), tried, &self.record.failed)
|
||||
}
|
||||
|
||||
/// The start got past the point where a driver fault could take the process down: a frame was
|
||||
/// presented, the app exited normally, or the graphics failed with an error the app caught.
|
||||
/// Returns the backend that was being tried, for [`Fallback::retry`].
|
||||
pub fn finished(&mut self) -> Option<Backend> {
|
||||
let trying = self.record.trying.take();
|
||||
if trying.is_some() {
|
||||
self.save();
|
||||
}
|
||||
trying
|
||||
}
|
||||
|
||||
/// Undo [`Fallback::finished`]: the start isn't over after all (starting again failed).
|
||||
pub fn retry(&mut self, trying: Option<Backend>) {
|
||||
if self.record.trying != trying {
|
||||
self.record.trying = trying;
|
||||
self.save();
|
||||
}
|
||||
}
|
||||
|
||||
/// The status-bar line for a start that moved away from a backend that crashed.
|
||||
pub fn status_line(&self) -> Option<String> {
|
||||
let from = self.switched_from?;
|
||||
Some(format!(
|
||||
"Graphics: switched from {} to {} — the last start with {} never showed a frame (set WGPU_BACKEND to choose).",
|
||||
from.name(),
|
||||
self.backend.name(),
|
||||
from.name()
|
||||
))
|
||||
}
|
||||
|
||||
fn save(&self) {
|
||||
if let Some(p) = &self.path
|
||||
&& let Err(e) = write(p, &self.record)
|
||||
{
|
||||
log::warn!("graphics: {}: {e}", p.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
const V: &str = "0.5.0";
|
||||
const WIN: &[Backend] = &[Backend::Dx12, Backend::Gl, Backend::Vulkan];
|
||||
|
||||
fn temp_path(tag: &str) -> PathBuf {
|
||||
static N: AtomicUsize = AtomicUsize::new(0);
|
||||
let d = std::env::temp_dir().join(format!("designcraft-gpu-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
d.join(FILE)
|
||||
}
|
||||
|
||||
fn record(trying: Option<Backend>, failed: &[Backend]) -> Record {
|
||||
Record { version: V.into(), trying, failed: failed.to_vec() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_platform_has_candidates_without_duplicates() {
|
||||
let c = candidates();
|
||||
assert!(!c.is_empty());
|
||||
for (i, b) in c.iter().enumerate() {
|
||||
assert!(!c[..i].contains(b), "{b:?} twice in {c:?}");
|
||||
}
|
||||
if cfg!(windows) {
|
||||
assert_eq!(c, WIN, "DirectX 12 first (AMD's Vulkan driver faulted at the first present), Vulkan last (#806)");
|
||||
}
|
||||
if cfg!(target_os = "macos") {
|
||||
assert_eq!(c, &[Backend::Metal]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fresh_start_takes_the_first_candidate() {
|
||||
let c = choose(WIN, &Record::default(), V, &[]).unwrap();
|
||||
assert_eq!(c.backend, Backend::Dx12);
|
||||
assert_eq!(c.record, record(Some(Backend::Dx12), &[]));
|
||||
assert_eq!(c.switched_from, None);
|
||||
assert!(choose(&[], &Record::default(), V, &[]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_start_that_never_presented_moves_to_the_next_candidate() {
|
||||
let c = choose(WIN, &record(Some(Backend::Dx12), &[]), V, &[]).unwrap();
|
||||
assert_eq!(c.backend, Backend::Gl);
|
||||
assert_eq!(c.record, record(Some(Backend::Gl), &[Backend::Dx12]));
|
||||
assert_eq!(c.switched_from, Some(Backend::Dx12));
|
||||
|
||||
let c = choose(WIN, &record(Some(Backend::Gl), &[Backend::Dx12]), V, &[]).unwrap();
|
||||
assert_eq!(c.backend, Backend::Vulkan);
|
||||
assert_eq!(c.record.failed, &[Backend::Dx12, Backend::Gl]);
|
||||
assert_eq!(c.switched_from, Some(Backend::Gl));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_finished_start_keeps_the_failed_list_and_switches_silently() {
|
||||
// `trying` was cleared by a presented frame; the next start still avoids what failed.
|
||||
let c = choose(WIN, &record(None, &[Backend::Dx12]), V, &[]).unwrap();
|
||||
assert_eq!(c.backend, Backend::Gl);
|
||||
assert_eq!(c.record.failed, &[Backend::Dx12]);
|
||||
assert_eq!(c.switched_from, None);
|
||||
// The same backend failing again is listed once.
|
||||
let c = choose(WIN, &record(Some(Backend::Dx12), &[Backend::Dx12]), V, &[]).unwrap();
|
||||
assert_eq!(c.record.failed, &[Backend::Dx12]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn when_every_candidate_failed_the_list_starts_over() {
|
||||
let c = choose(WIN, &record(Some(Backend::Vulkan), &[Backend::Dx12, Backend::Gl]), V, &[]).unwrap();
|
||||
assert_eq!(c.backend, Backend::Dx12);
|
||||
assert_eq!(c.record.failed, &[]);
|
||||
assert_eq!(c.switched_from, Some(Backend::Vulkan));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn another_version_or_platform_forgets_what_failed() {
|
||||
let old = Record { version: "0.4.0".into(), trying: Some(Backend::Dx12), failed: vec![Backend::Vulkan] };
|
||||
let c = choose(WIN, &old, V, &[]).unwrap();
|
||||
assert_eq!(c.backend, Backend::Dx12);
|
||||
assert_eq!(c.record, record(Some(Backend::Dx12), &[]));
|
||||
assert_eq!(c.switched_from, None);
|
||||
// A backend that is no candidate here (a record copied from another machine) is dropped.
|
||||
let c = choose(&[Backend::Vulkan, Backend::Gl], &record(None, &[Backend::Metal, Backend::Vulkan]), V, &[]).unwrap();
|
||||
assert_eq!(c.backend, Backend::Gl);
|
||||
assert_eq!(c.record.failed, &[Backend::Vulkan]);
|
||||
}
|
||||
|
||||
/// A restart that found no adapter of DirectX 12 names the backend in its skip list: that start
|
||||
/// takes the next candidate, with or without a record, and the record doesn't keep it as failed
|
||||
/// (a later launch tries it again).
|
||||
#[test]
|
||||
fn backends_a_restart_left_out_are_not_chosen_or_recorded() {
|
||||
let skip: Vec<String> = ["Dx12:10de:2204", "Dx12", "Metal:Intel Iris Pro Graphics", "dx12", ""].map(String::from).to_vec();
|
||||
assert_eq!(skipped_backends(&skip), [Backend::Dx12]);
|
||||
// A backend's key is wgpu's name for it, the prefix of its adapters' keys.
|
||||
for w in [wgpu::Backend::Dx12, wgpu::Backend::Vulkan, wgpu::Backend::Metal, wgpu::Backend::Gl] {
|
||||
let b = Backend::of(w).expect("native backend");
|
||||
assert_eq!(b.key(), format!("{w:?}"));
|
||||
assert_eq!(Backend::from_key(b.key()), Some(b));
|
||||
}
|
||||
let c = choose(WIN, &Record::default(), V, &[Backend::Dx12]).unwrap();
|
||||
assert_eq!(c.backend, Backend::Gl);
|
||||
assert_eq!(c.record, record(Some(Backend::Gl), &[]));
|
||||
let c = choose(WIN, &record(None, &[Backend::Gl]), V, &[Backend::Dx12]).unwrap();
|
||||
assert_eq!(c.record, record(Some(Backend::Vulkan), &[Backend::Gl]));
|
||||
// Every candidate failed or left out: the record starts over, still without the left-out one.
|
||||
let c = choose(WIN, &record(Some(Backend::Vulkan), &[Backend::Gl]), V, &[Backend::Dx12]).unwrap();
|
||||
assert_eq!(c.record, record(Some(Backend::Gl), &[]));
|
||||
// A backend that is no candidate here is ignored.
|
||||
let c = choose(WIN, &Record::default(), V, &[Backend::Metal]).unwrap();
|
||||
assert_eq!(c.backend, Backend::Dx12);
|
||||
}
|
||||
|
||||
/// When no adapter of a start's backends could show the window, the restart moves on to a
|
||||
/// backend not yet tried in this start (an in-process OpenGL fallback counts as tried), and
|
||||
/// the restarts end once none is left.
|
||||
#[test]
|
||||
fn the_next_backend_after_one_without_a_working_adapter() {
|
||||
let none = wgpu::Backends::empty();
|
||||
assert_eq!(next_candidate(WIN, wgpu::Backends::DX12, &[]), Some(Backend::Gl));
|
||||
assert_eq!(next_candidate(WIN, wgpu::Backends::DX12 | wgpu::Backends::GL, &[]), Some(Backend::Vulkan));
|
||||
assert_eq!(next_candidate(WIN, wgpu::Backends::DX12, &[Backend::Gl]), Some(Backend::Vulkan));
|
||||
assert_eq!(next_candidate(WIN, wgpu::Backends::all(), &[]), None);
|
||||
assert_eq!(next_candidate(WIN, none, &[Backend::Dx12, Backend::Gl, Backend::Vulkan]), None);
|
||||
// Linux: Vulkan with OpenGL beside it has tried both.
|
||||
assert_eq!(next_candidate(&[Backend::Vulkan, Backend::Gl], wgpu::Backends::VULKAN | wgpu::Backends::GL, &[]), None);
|
||||
assert_eq!(next_candidate(&[Backend::Metal], wgpu::Backends::METAL | wgpu::Backends::GL, &[]), None);
|
||||
|
||||
let s = Fallback::begin_unforced(None, V, &[]).expect("candidates");
|
||||
let next = s.next_after(&[]);
|
||||
if cfg!(windows) {
|
||||
assert_eq!(next, Some(Backend::Gl));
|
||||
assert_eq!(s.next_after(&[Backend::Gl]), Some(Backend::Vulkan));
|
||||
assert_eq!(s.next_after(&[Backend::Gl, Backend::Vulkan]), None);
|
||||
} else {
|
||||
assert_eq!(next, None, "the first start already has OpenGL beside its backend");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_record_round_trips_and_garbage_reads_as_empty() {
|
||||
let path = temp_path("roundtrip");
|
||||
assert_eq!(read(&path), Record::default());
|
||||
let r = record(Some(Backend::Vulkan), &[Backend::Dx12]);
|
||||
write(&path, &r).expect("write");
|
||||
assert_eq!(read(&path), r);
|
||||
let text = std::fs::read_to_string(&path).expect("read");
|
||||
assert!(text.contains("\"trying\": \"vulkan\"") && text.contains("\"dx12\""), "{text}");
|
||||
std::fs::write(&path, b"{not json").expect("garbage");
|
||||
assert_eq!(read(&path), Record::default());
|
||||
// Unknown fields and missing ones are tolerated.
|
||||
std::fs::write(&path, br#"{"failed":["gl"],"later":1}"#).expect("partial");
|
||||
assert_eq!(read(&path), Record { version: String::new(), trying: None, failed: vec![Backend::Gl] });
|
||||
let _ = std::fs::remove_dir_all(path.parent().expect("dir"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_start_records_its_backend_until_a_frame_is_presented() {
|
||||
let path = temp_path("startup");
|
||||
let mut s = Fallback::begin_unforced(Some(path.clone()), V, &[]).expect("candidates");
|
||||
let first = candidates()[0];
|
||||
assert_eq!(s.backend, first);
|
||||
assert_eq!(read(&path).trying, Some(first));
|
||||
assert!(s.backends().contains(first.bit()));
|
||||
// OpenGL is the in-process fallback, except on Windows where it is only its own candidate
|
||||
// (#167), and Windows never loads Vulkan drivers on its first try (#806).
|
||||
assert_eq!(s.backends().contains(wgpu::Backends::GL), !cfg!(windows) || first == Backend::Gl);
|
||||
if cfg!(windows) {
|
||||
assert_eq!(s.backends(), wgpu::Backends::DX12);
|
||||
}
|
||||
assert_eq!(s.status_line(), None);
|
||||
|
||||
// wgpu actually picked OpenGL (the chosen backend had no adapter): blame that one.
|
||||
s.started_with(Backend::Gl);
|
||||
assert_eq!(read(&path).trying, Some(Backend::Gl));
|
||||
// A restart takes the entry and puts it back when starting again failed.
|
||||
let trying = s.finished();
|
||||
assert_eq!((trying, read(&path)), (Some(Backend::Gl), record(None, &[])));
|
||||
s.retry(trying);
|
||||
assert_eq!(read(&path).trying, Some(Backend::Gl));
|
||||
s.finished();
|
||||
assert_eq!(read(&path), record(None, &[]));
|
||||
assert_eq!(s.finished(), None);
|
||||
assert_eq!(read(&path), record(None, &[]));
|
||||
|
||||
// The next start after one that never presented: switches, says so, and leaves OpenGL
|
||||
// out of the set once it failed.
|
||||
write(&path, &record(Some(Backend::Gl), &[])).expect("write");
|
||||
let s = Fallback::begin_unforced(Some(path.clone()), V, &[]).expect("candidates");
|
||||
// This synthetic prior start records OpenGL even on Metal-only macOS.
|
||||
// Switching depends on the chosen backend, not the candidate count.
|
||||
if s.backend != Backend::Gl {
|
||||
assert_eq!(s.switched_from, Some(Backend::Gl));
|
||||
let line = s.status_line().expect("status");
|
||||
assert!(line.contains("OpenGL") && line.contains(s.backend.name()) && line.contains("WGPU_BACKEND"), "{line}");
|
||||
assert!(!s.backends().contains(wgpu::Backends::GL));
|
||||
} else {
|
||||
assert_eq!(s.switched_from, None);
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(path.parent().expect("dir"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_path_nothing_is_written_and_a_bad_path_is_not_fatal() {
|
||||
let mut s = Fallback::begin_unforced(None, V, &[]).expect("candidates");
|
||||
s.started_with(Backend::Gl);
|
||||
s.finished();
|
||||
// A file where the directory should be: every write fails, nothing panics.
|
||||
let blocked = temp_path("blocked");
|
||||
let dir = blocked.parent().expect("dir");
|
||||
std::fs::create_dir_all(dir.parent().expect("tmp")).expect("tmp");
|
||||
std::fs::write(dir, "a file where the directory should be").expect("block");
|
||||
let mut s = Fallback::begin_unforced(Some(blocked.clone()), V, &[]).expect("candidates");
|
||||
s.started_with(Backend::Gl);
|
||||
s.finished();
|
||||
let _ = std::fs::remove_file(dir);
|
||||
}
|
||||
}
|
||||
+129
-73
@@ -6,8 +6,10 @@
|
||||
//! `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`.
|
||||
//! See `designcraft_ui_egui::control` for the methods.
|
||||
//!
|
||||
//! The graphics backend (DirectX 12, Vulkan, Metal or OpenGL) is chosen in `gpu` before the window
|
||||
//! exists, with a fallback for a driver that crashes at start-up; `WGPU_BACKEND` overrides it.
|
||||
//! The graphics backend (DirectX 12, Vulkan, Metal or OpenGL) and the adapter that draws the
|
||||
//! window are chosen in `gpu`, with a fallback for a driver or adapter that fails at start-up;
|
||||
//! `WGPU_BACKEND`, `WGPU_POWER_PREF` and `WGPU_ADAPTER_NAME` override them. Why the app stopped
|
||||
//! is logged (`logging`), also when the window never opened.
|
||||
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
@@ -25,21 +27,22 @@ struct App {
|
||||
app: DesignApp,
|
||||
#[cfg(target_os = "macos")]
|
||||
menu: Option<native_menu::NativeMenu>,
|
||||
/// The graphics start in progress (`gpu.json`) until a frame has been presented; see `gpu`.
|
||||
startup: Option<gpu::Startup>,
|
||||
/// The window's start: the adapter and backend chosen for it; see `gpu`.
|
||||
startup: std::sync::Arc<gpu::Startup>,
|
||||
/// Frames begun so far.
|
||||
frames: u32,
|
||||
}
|
||||
|
||||
impl eframe::App for App {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.startup.enter();
|
||||
if let Some(note) = self.startup.take_notice() {
|
||||
self.app.status(note);
|
||||
}
|
||||
// The third call: two frames went through `ui`, so the first was presented and the driver
|
||||
// survived it (a fault at the first present is what the fallback in `gpu` is for).
|
||||
if self.frames >= 2
|
||||
&& let Some(s) = self.startup.as_mut()
|
||||
{
|
||||
s.presented();
|
||||
self.startup = None;
|
||||
if self.frames == 2 {
|
||||
self.startup.presented();
|
||||
}
|
||||
self.frames = self.frames.saturating_add(1);
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -52,12 +55,15 @@ impl eframe::App for App {
|
||||
}
|
||||
}
|
||||
self.app.logic(ctx);
|
||||
self.startup.leave();
|
||||
}
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.app.raw_input_hook(raw);
|
||||
}
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
self.startup.enter();
|
||||
self.app.ui(ui);
|
||||
self.startup.leave();
|
||||
#[cfg(target_os = "macos")]
|
||||
if self.app.take_ime_discard() {
|
||||
discard_marked_text();
|
||||
@@ -65,9 +71,7 @@ impl eframe::App for App {
|
||||
}
|
||||
fn on_exit(&mut self) {
|
||||
// A normal exit before the third frame is not a crash.
|
||||
if let Some(s) = self.startup.as_mut() {
|
||||
s.presented();
|
||||
}
|
||||
self.startup.presented();
|
||||
if let Err(e) = preserve_documents_on_exit(&mut self.app) {
|
||||
log::error!("Couldn't preserve documents on exit: {e}");
|
||||
eprintln!("designcraft: couldn't preserve documents on exit: {e}");
|
||||
@@ -254,13 +258,14 @@ fn control_port_from(source: &str, value: Option<String>) -> Option<u16> {
|
||||
port
|
||||
}
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
fn main() -> std::process::ExitCode {
|
||||
// First, so every start-up record (and the engine's panic hook, installed with the first
|
||||
// Session) is captured; see `logging`.
|
||||
let logger = logging::install();
|
||||
let mut control_port = control_port_from("DESIGNCRAFT_CONTROL_PORT", std::env::var("DESIGNCRAFT_CONTROL_PORT").ok());
|
||||
let mut files = Vec::new();
|
||||
let mut sample = false;
|
||||
let mut skip = Vec::new();
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(a) = args.next() {
|
||||
match a.as_str() {
|
||||
@@ -268,9 +273,12 @@ fn main() -> eframe::Result {
|
||||
"--sample" => sample = true,
|
||||
"--version" => {
|
||||
println!("designcraft {}", env!("CARGO_PKG_VERSION"));
|
||||
return Ok(());
|
||||
return std::process::ExitCode::SUCCESS;
|
||||
}
|
||||
_ => files.push(a),
|
||||
_ => match gpu::skip_arg(&a) {
|
||||
Some(list) => skip = list,
|
||||
None => files.push(a),
|
||||
},
|
||||
}
|
||||
}
|
||||
// The log file lives under the settings directory; opened after the arguments, so `--version`
|
||||
@@ -298,21 +306,37 @@ fn main() -> eframe::Result {
|
||||
options.viewport = options.viewport.with_icon(icon);
|
||||
}
|
||||
// The graphics backend, decided before the window exists: a driver that faults takes the
|
||||
// process down before any Rust code can catch it (see `gpu`).
|
||||
let gpu = gpu::Startup::begin(if no_prefs() { None } else { profile::config_dir().map(|d| d.join(gpu::FILE)) }, env!("CARGO_PKG_VERSION"));
|
||||
if let Some(s) = &gpu
|
||||
&& let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup
|
||||
{
|
||||
create.instance_descriptor.backends = s.backends();
|
||||
// process down before any Rust code can catch it (see `gpu::backend`).
|
||||
let fallback = gpu::backend::Fallback::begin(
|
||||
if no_prefs() { None } else { profile::config_dir().map(|d| d.join(gpu::backend::FILE)) },
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
&gpu::backend::skipped_backends(&skip),
|
||||
);
|
||||
let instance_backends = fallback.as_ref().map(gpu::backend::Fallback::backends);
|
||||
let startup = std::sync::Arc::new(gpu::Startup::new(fallback, skip.clone()));
|
||||
// The adapter: the GPU that drives the (primary) display unless `WGPU_POWER_PREF` chooses a
|
||||
// kind; a GPU without a monitor reset its driver and took every monitor down. Logged first:
|
||||
// the first question in every black-window report.
|
||||
let power_env = eframe::wgpu::PowerPreference::from_env();
|
||||
let power = gpu::power_preference(power_env);
|
||||
let displays = gpu::preferred_displays(power_env);
|
||||
let chosen_gpu = !gpu::automatic(power_env);
|
||||
if chosen_gpu {
|
||||
log::info!("graphics processor chosen by WGPU_POWER_PREF ({power:?}): which GPU drives the display isn't considered");
|
||||
} else {
|
||||
let listed: Vec<String> = displays.iter().map(ToString::to_string).collect();
|
||||
log::info!("display GPUs (PCI vendor:device): {}", if listed.is_empty() { "unknown".to_string() } else { listed.join(", ") });
|
||||
}
|
||||
// Nothing draws or dispatches indirectly, so wgpu's check of indirect arguments only costs a
|
||||
// compute shader when the device is created, one that Metal can't compile on an Intel Iris
|
||||
// Pro: the device is lost and the window never opens. `WGPU_VALIDATION_INDIRECT_CALL=1` turns
|
||||
// the check back on.
|
||||
if let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
|
||||
create.instance_descriptor.flags =
|
||||
create.instance_descriptor.flags.difference(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL).with_env();
|
||||
if let Some(backends) = instance_backends {
|
||||
create.instance_descriptor.backends = backends;
|
||||
}
|
||||
create.instance_descriptor.flags = gpu::instance_flags();
|
||||
// Only adapters that can show the window, in the order `gpu` gives.
|
||||
create.native_adapter_selector = Some(gpu::selector(power, displays, startup.clone()));
|
||||
}
|
||||
gpu::watch_panics();
|
||||
gpu::watch_first_frame(startup.clone());
|
||||
// winit has no file drag-and-drop on Wayland (only on X11), so dropping images from the file
|
||||
// manager showed a "no" cursor. Run through XWayland when it's there; DESIGNCRAFT_WAYLAND=1
|
||||
// keeps the native Wayland backend.
|
||||
@@ -332,54 +356,86 @@ fn main() -> eframe::Result {
|
||||
builder.with_close_requested_on_terminate(true);
|
||||
}));
|
||||
}
|
||||
eframe::run_native(
|
||||
"DesignCraft",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
let mut gpu = gpu;
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
// What a bug report needs to know about the graphics driver.
|
||||
let info = rs.adapter.get_info();
|
||||
log::info!("graphics: {} on {:?} ({:?}; driver {} {})", info.name, info.backend, info.device_type, info.driver, info.driver_info);
|
||||
if let (Some(s), Some(backend)) = (gpu.as_mut(), gpu::Backend::of(info.backend)) {
|
||||
s.started_with(backend);
|
||||
let created = startup.clone();
|
||||
// A panic that ends the window (egui-wgpu's own) is caught here: never a crash.
|
||||
let run = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
eframe::run_native(
|
||||
"DesignCraft",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
let mut restarted = None;
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
// What a bug report needs to know about the graphics driver.
|
||||
let info = rs.adapter.get_info();
|
||||
log::info!(
|
||||
"graphics: {} on {:?} ({:?}; driver {} {}; power preference {power:?})",
|
||||
info.name,
|
||||
info.backend,
|
||||
info.device_type,
|
||||
info.driver,
|
||||
info.driver_info
|
||||
);
|
||||
created.started_with(info.backend);
|
||||
created.created(&cc.egui_ctx);
|
||||
if !skip.is_empty() {
|
||||
// A processor chosen by `WGPU_POWER_PREF` can be the one that can't show the window.
|
||||
let hint = if chosen_gpu { " (without WGPU_POWER_PREF it uses the one that drives your display)" } else { "" };
|
||||
restarted = Some(format!(
|
||||
"The graphics processor tried first couldn't show the window, so DesignCraft started again on {} ({:?}, {:?}){hint}.",
|
||||
info.name.trim(),
|
||||
info.backend,
|
||||
info.device_type
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut session = Session::new();
|
||||
// Crash recovery: reopen what a previous run left unsaved, then keep it current.
|
||||
session.recovery_dir = profile::recovery_dir();
|
||||
let recovered = session.execute("file.recovery.open", &serde_json::json!({})).ok();
|
||||
let mut app = DesignApp::new(session, services());
|
||||
if let Some(line) = gpu.as_ref().and_then(gpu::Startup::status_line) {
|
||||
app.status(line);
|
||||
}
|
||||
// Recovered documents matter more than the graphics note; both are in the log.
|
||||
if let Some(n) = recovered.as_ref().and_then(|r| r["opened"].as_array()).map(Vec::len).filter(|n| *n > 0) {
|
||||
app.status(format!("Recovered {n} unsaved document{} from the last session.", if n == 1 { "" } else { "s" }));
|
||||
}
|
||||
load_prefs(&mut app);
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
if let Some(port) = control_port {
|
||||
let rx = control_server::start(port, cc.egui_ctx.clone());
|
||||
app = app.with_control(rx);
|
||||
}
|
||||
if sample {
|
||||
let _ = app.run("file.newSample", serde_json::json!({}));
|
||||
}
|
||||
for f in files {
|
||||
if let Err(e) = app.open_file("file.open", serde_json::json!({"path": f})) {
|
||||
eprintln!("designcraft: {f}: {e}");
|
||||
let mut session = Session::new();
|
||||
// Crash recovery: reopen what a previous run left unsaved, then keep it current.
|
||||
session.recovery_dir = profile::recovery_dir();
|
||||
let recovered = session.execute("file.recovery.open", &serde_json::json!({})).ok();
|
||||
let mut app = DesignApp::new(session, services());
|
||||
if let Some(line) = restarted.or_else(|| created.status_line()) {
|
||||
app.status(line);
|
||||
}
|
||||
}
|
||||
Ok(Box::new(App {
|
||||
app,
|
||||
#[cfg(target_os = "macos")]
|
||||
menu: None,
|
||||
startup: gpu,
|
||||
frames: 0,
|
||||
}))
|
||||
}),
|
||||
)
|
||||
// Recovered documents matter more than the graphics note; both are in the log.
|
||||
if let Some(n) = recovered.as_ref().and_then(|r| r["opened"].as_array()).map(Vec::len).filter(|n| *n > 0) {
|
||||
app.status(format!("Recovered {n} unsaved document{} from the last session.", if n == 1 { "" } else { "s" }));
|
||||
}
|
||||
load_prefs(&mut app);
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
if let Some(port) = control_port {
|
||||
let rx = control_server::start(port, cc.egui_ctx.clone());
|
||||
app = app.with_control(rx);
|
||||
}
|
||||
if sample {
|
||||
let _ = app.run("file.newSample", serde_json::json!({}));
|
||||
}
|
||||
for f in files {
|
||||
if let Err(e) = app.open_file("file.open", serde_json::json!({"path": f})) {
|
||||
log::warn!("{f}: {e}");
|
||||
}
|
||||
}
|
||||
// The first frame is due from now (`gpu::watch_first_frame`).
|
||||
created.ready();
|
||||
Ok(Box::new(App {
|
||||
app,
|
||||
#[cfg(target_os = "macos")]
|
||||
menu: None,
|
||||
startup: created,
|
||||
frames: 0,
|
||||
}))
|
||||
}),
|
||||
)
|
||||
}))
|
||||
.map_err(|payload| designcraft_engine::guard::panic_message(payload.as_ref()));
|
||||
// The window's end, or a graphics failure that starts the app again. A failure is logged:
|
||||
// standard error and the log file, since a Windows start has no console to show it.
|
||||
match gpu::finish(run, &startup) {
|
||||
Ok(()) => std::process::ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
log::error!("DesignCraft stopped: {e}");
|
||||
std::process::ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -75,7 +75,9 @@ lines with positioned glyph runs, decorations, anchors and overset. The canvas r
|
||||
background worker with vello_cpu into tiles; after an edit only frames whose composition changed
|
||||
are re-rendered and patched (damage regions, pixel-identical to a full render). The GPU (wgpu:
|
||||
Metal, DirectX 12, Vulkan, OpenGL; WebGPU/WebGL2 on the web) only presents; the backend is chosen
|
||||
before the window exists, with a start-up fallback list in `gpu.json`.
|
||||
before the window exists, with a start-up fallback list in `gpu.json`, and the adapter (the GPU
|
||||
that drives the display first) by the app, which starts again on the next one when an adapter
|
||||
fails or hangs at start-up (`apps/designcraft/src/gpu.rs`).
|
||||
|
||||
## File I/O
|
||||
|
||||
|
||||
+5
-1
@@ -79,7 +79,11 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format ·
|
||||
AMD OpenGL driver crash on Windows (#167), launch failures (#273, #220, #192, #166), blank or
|
||||
frozen canvas after graphics loss (#90).
|
||||
- **Evidence:** a backend fallback landed 2026-10-10 (#297: DirectX 12 on Windows, `gpu.json`
|
||||
skip list); not yet confirmed by the reporters.
|
||||
skip list); then VectorCraft's adapter choice (the GPU that drives the display), restart on the
|
||||
next adapter or backend when one fails or hangs at start-up, the indirect-call check off (the
|
||||
first Metal error of #334) and a logged reason when the app stops (#273). Not yet confirmed by
|
||||
the reporters. Still open for #334: wgpu asks an old macOS's Metal compiler for a language
|
||||
version it doesn't have (`-std=metal3.2`).
|
||||
- **Estimate:** 15–30 h.
|
||||
- **Doc:** [hardware-parity.md](hardware-parity.md)
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ graphics driver loses the user entirely. Overall numbers: [target-app-parity.md]
|
||||
| GPU display (canvas) | GPU Performance: GPU preview, animated zoom (Metal on macOS; Windows with supported GPUs) | canvas rasterized on the CPU (vello_cpu, SIMD, multithreaded), presented by wgpu (Metal) | wgpu DirectX 12 (since #297), Vulkan/OpenGL fallback | Vulkan, OpenGL fallback | WebGPU, WebGL2 fallback |
|
||||
| Start-up on any GPU | robust | Metal device lost on Intel Macs (#334) | AMD OpenGL crash (#167), launch failures (#273) | some reports (#192, #220) | |
|
||||
| Backend fallback | n/a | `gpu.json` skips a backend whose start never showed a frame (#297) | same | same | |
|
||||
| Adapter choice and restart | n/a | power saving; a GPU that fails or hangs at start-up is left out and the app starts again on the other one; indirect-call check off (#334) | the GPU that drives the primary display (`EnumDisplayDevices`); DirectX 12 adapters, then OpenGL, then Vulkan | the GPU that drives the panel or a monitor (sysfs), then the system's order | |
|
||||
| Animated / scrubby zoom | ✓ (GPU) | ✓ (CPU tiles, damage regions) | ✓ | ✓ | ✓ |
|
||||
| HiDPI / Retina | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| UI scaling | ✓ | ✓ (menus and dialogs usable at larger scales, #281) | ✓ | ✓ | ✓ |
|
||||
|
||||
@@ -294,6 +294,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
|
||||
- Crash recovery: unsaved documents are written every 30 s into a locked folder owned by that running app and reopened (unsaved, remembering their files) after a crash. Startup takes entries from ended runs and leaves live runs alone; writes and cleanup validate current folder and lock ownership. Failed isolation disables recovery with a warning. Recovered checkpoints remain available through another crash before autosave; restarting IDs and explicit recovery from another folder preserve unopened snapshots. Successful Save, Close or Revert clears the document's checkpoints, including manual recovery while autosave is disabled; failed republishing keeps the original. A folder given to `file.recovery.save` keeps other runs' entries: a run replaces or removes only the entries it wrote or recovered there, and refuses a folder whose run lock another app holds. Concurrent older versions still use unlocked root entries; mixed-version isolation remains open. File › Revert and Save a Copy…; `file.recovery.*`, `file.revert`, `file.saveACopy`.
|
||||
- Window close, application/menu Quit and macOS Dock Quit ask about every unsaved document. Cancel or a cancelled/failed save keeps all tabs open; Don't Save approvals discard nothing until the complete plan is current. Later edits and new documents are checked before closing. Repeated native requests preserve an active dialog and partial approvals. Control-channel quit remains immediate.
|
||||
- Graphics backend chosen before the window exists (DirectX 12 on Windows, Vulkan on Linux, Metal on macOS, OpenGL as the fallback; `WGPU_BACKEND` overrides) with a start-up fallback: a backend whose start never showed a frame is skipped at the next start and the status bar says so (`gpu.json`). Found on an AMD hybrid-graphics laptop whose Vulkan driver faulted at the first present.
|
||||
- Graphics adapter chosen by the app: the GPU that drives the primary display (Windows `EnumDisplayDevices`, Linux sysfs), then the power preference (`WGPU_POWER_PREF`, `WGPU_ADAPTER_NAME` override). An adapter that fails or hangs while the window starts is left out and the app starts again on the next one, then on the next backend, with software renderers last; wgpu's indirect-call check is off (it lost the Metal device on an Intel Mac, #334); why the app stopped is logged, also when the window never opened (#273). Ported from VectorCraft.
|
||||
- Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool).
|
||||
- Color panel (fill/stroke proxy, CMYK/RGB/Lab sliders with channel ramps, tint slider for swatches, spectrum ramp, Add to Swatches) and Color Picker (double-click the proxy); mixed colours are unnamed (not listed in Swatches until added); `object.color`, `swatch.addToSwatches`, `swatch.addUnnamed`.
|
||||
- Threading: click a selected text frame's in or out port (overset or not) to load the text cursor, then click a frame to thread it (stories merge), click empty space for a column-wide frame down to the bottom margin, or drag a frame; Shift-click autoflows, Alt-click stays loaded; click the linked frame or double-click a port to break the thread; Esc unloads. Link and unthread cursors show where a click threads or unthreads. `text.thread`, `text.unthread`, `text.removeFromThread`.
|
||||
|
||||
Reference in New Issue
Block a user