mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-16 12:16:50 -04:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5483f74599 |
No files matched your search
@@ -0,0 +1,555 @@
|
||||
//! Crash-safe GPU startup (#4).
|
||||
//!
|
||||
//! A graphics driver that segfaults while the window's wgpu device is created (the Intel Vulkan
|
||||
//! driver `igvk64.dll` on Windows) can't be caught in-process. So before the device exists the app
|
||||
//! writes a *marker* (`gpu-starting.json` in the config directory: backend and, once chosen, the
|
||||
//! adapter) and holds an OS file lock on it; it clears the marker once the first frames have
|
||||
//! rendered. If the next launch finds a marker that no running process holds, the previous start
|
||||
//! died inside the driver, and this one retries with the next safer backend:
|
||||
//!
|
||||
//! - Windows: Vulkan → DX12 → CPU
|
||||
//! - Linux and other Unix: Vulkan → GL → CPU
|
||||
//! - macOS: Metal → CPU
|
||||
//!
|
||||
//! The choice that worked is remembered in `performance.gpuBackend` (Preferences › Performance,
|
||||
//! where it can be reset). With `auto`, Intel adapters on Windows use DX12. `WGPU_BACKEND` still
|
||||
//! overrides everything, and `--safe-gpu` forces the CPU path for one launch.
|
||||
//!
|
||||
//! "CPU" composites on the CPU and draws the window with a software adapter where the platform
|
||||
//! has one (WARP on Windows, llvmpipe over GL elsewhere).
|
||||
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{Read, Seek, SeekFrom, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
|
||||
use eframe::{egui_wgpu, wgpu};
|
||||
use photocraft_engine::prefs::GpuBackend;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// The marker's file name in the config directory.
|
||||
pub const MARKER_FILE: &str = "gpu-starting.json";
|
||||
/// PCI vendor id of Intel.
|
||||
pub const INTEL: u32 = 0x8086;
|
||||
/// Largest marker read (a marker is a few hundred bytes).
|
||||
const MARKER_MAX: u64 = 64 << 10;
|
||||
|
||||
/// Platform families with different backend chains.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Os {
|
||||
Windows,
|
||||
Mac,
|
||||
/// Linux, the BSDs and other Unix.
|
||||
Other,
|
||||
}
|
||||
|
||||
impl Os {
|
||||
pub fn current() -> Self {
|
||||
if cfg!(windows) {
|
||||
Os::Windows
|
||||
} else if cfg!(target_os = "macos") {
|
||||
Os::Mac
|
||||
} else {
|
||||
Os::Other
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a start records before touching the driver.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Marker {
|
||||
/// The plan's backend (`auto`, `dx12`, …), or `env:<value>` under `WGPU_BACKEND`.
|
||||
pub backend: String,
|
||||
/// The chosen adapter's name, backend and driver (empty until the adapter is chosen).
|
||||
pub adapter: String,
|
||||
pub adapter_backend: String,
|
||||
pub driver: String,
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
impl Marker {
|
||||
pub fn to_json(&self) -> String {
|
||||
json!({
|
||||
"backend": self.backend,
|
||||
"adapter": self.adapter,
|
||||
"adapterBackend": self.adapter_backend,
|
||||
"driver": self.driver,
|
||||
"version": self.version,
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Parse a marker leniently: anything unreadable is a marker of an unknown backend.
|
||||
pub fn parse(text: &str) -> Self {
|
||||
let v: Value = serde_json::from_str(text).unwrap_or(Value::Null);
|
||||
let s = |k: &str| v.get(k).and_then(Value::as_str).unwrap_or("").chars().take(256).collect::<String>();
|
||||
Marker { backend: s("backend"), adapter: s("adapter"), adapter_backend: s("adapterBackend"), driver: s("driver"), version: s("version") }
|
||||
}
|
||||
|
||||
/// The backend the crashed start was using: the adapter's when it got that far with `auto`,
|
||||
/// else the plan's. `None` for a start under `WGPU_BACKEND` (the user's own override).
|
||||
pub fn tried(&self) -> Option<GpuBackend> {
|
||||
let planned = if self.backend.is_empty() { Some(GpuBackend::Auto) } else { GpuBackend::parse(&self.backend) }?;
|
||||
match (planned, GpuBackend::parse(&self.adapter_backend)) {
|
||||
(GpuBackend::Auto, Some(actual)) => Some(actual),
|
||||
_ => Some(planned),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The backend this launch uses and why.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Plan {
|
||||
pub backend: GpuBackend,
|
||||
/// `WGPU_BACKEND`, which picks the backends itself.
|
||||
pub env: Option<String>,
|
||||
/// Why this isn't simply the preference.
|
||||
pub reason: Option<String>,
|
||||
/// Store `backend` in the preferences once the start succeeded (a crash fallback).
|
||||
pub remember: bool,
|
||||
}
|
||||
|
||||
/// A backend the platform has (others fall back to `auto`).
|
||||
pub fn sanitize(pref: GpuBackend, os: Os) -> GpuBackend {
|
||||
use GpuBackend::*;
|
||||
match (os, pref) {
|
||||
(Os::Windows, Metal) | (Os::Mac, Vulkan | Dx12 | Gl) | (Os::Other, Dx12 | Metal) => Auto,
|
||||
(_, b) => b,
|
||||
}
|
||||
}
|
||||
|
||||
/// The next safer backend after a start on `tried` crashed.
|
||||
pub fn next_safer(tried: GpuBackend, os: Os) -> GpuBackend {
|
||||
use GpuBackend::*;
|
||||
match (os, tried) {
|
||||
(Os::Windows, Auto | Vulkan | Gl | Metal) => Dx12,
|
||||
(Os::Windows, Dx12 | Cpu) => Cpu,
|
||||
(Os::Mac, _) => Cpu,
|
||||
(Os::Other, Auto | Vulkan | Dx12 | Metal) => Gl,
|
||||
(Os::Other, Gl | Cpu) => Cpu,
|
||||
}
|
||||
}
|
||||
|
||||
/// Decide this launch's backend from the preference, a marker left by a start that crashed,
|
||||
/// `WGPU_BACKEND` and `--safe-gpu`.
|
||||
pub fn plan(pref: GpuBackend, crashed: Option<&Marker>, env: Option<&str>, safe_gpu: bool, os: Os) -> Plan {
|
||||
if let Some(v) = env.map(str::trim).filter(|v| !v.is_empty()) {
|
||||
let backend = if safe_gpu { GpuBackend::Cpu } else { GpuBackend::Auto };
|
||||
return Plan { backend, env: Some(v.to_string()), reason: Some(format!("WGPU_BACKEND={v} overrides the backend")), remember: false };
|
||||
}
|
||||
if safe_gpu {
|
||||
return Plan { backend: GpuBackend::Cpu, env: None, reason: Some("--safe-gpu: CPU renderer for this launch".into()), remember: false };
|
||||
}
|
||||
let pref = sanitize(pref, os);
|
||||
if let Some((m, tried)) = crashed.and_then(|m| Some((m, m.tried()?))) {
|
||||
let next = next_safer(tried, os);
|
||||
let on = if m.adapter.is_empty() { String::new() } else { format!(" ({})", m.adapter) };
|
||||
return Plan {
|
||||
backend: next,
|
||||
env: None,
|
||||
reason: Some(format!("the previous start didn't finish on {}{on}; using {}", tried.name(), next.name())),
|
||||
remember: next != pref,
|
||||
};
|
||||
}
|
||||
Plan { backend: pref, env: None, reason: None, remember: false }
|
||||
}
|
||||
|
||||
/// Instance backends for `plan` (`None`: egui's default, which honours `WGPU_BACKEND`).
|
||||
pub fn backends(plan: &Plan, os: Os) -> Option<wgpu::Backends> {
|
||||
if plan.env.is_some() {
|
||||
return None;
|
||||
}
|
||||
Some(match plan.backend {
|
||||
GpuBackend::Auto => return None,
|
||||
GpuBackend::Vulkan => wgpu::Backends::VULKAN,
|
||||
GpuBackend::Dx12 => wgpu::Backends::DX12,
|
||||
GpuBackend::Metal => wgpu::Backends::METAL,
|
||||
GpuBackend::Gl => wgpu::Backends::GL,
|
||||
GpuBackend::Cpu => match os {
|
||||
Os::Windows => wgpu::Backends::DX12,
|
||||
Os::Mac => wgpu::Backends::METAL,
|
||||
Os::Other => wgpu::Backends::GL,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// What adapter selection needs to know about an adapter.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Candidate {
|
||||
pub vendor: u32,
|
||||
pub device_type: wgpu::DeviceType,
|
||||
pub backend: wgpu::Backend,
|
||||
/// Can present to the window.
|
||||
pub surface_ok: bool,
|
||||
}
|
||||
|
||||
/// Rank of an adapter (lower is better): high-performance device types first (egui's power
|
||||
/// preference) or software adapters first for `cpu`; then backends, with DX12 before Vulkan for
|
||||
/// Intel on Windows.
|
||||
fn rank(a: &Candidate, backend: GpuBackend, os: Os) -> (u8, u8) {
|
||||
let ty = match a.device_type {
|
||||
wgpu::DeviceType::DiscreteGpu => 0,
|
||||
wgpu::DeviceType::IntegratedGpu => 1,
|
||||
wgpu::DeviceType::VirtualGpu => 2,
|
||||
wgpu::DeviceType::Other => 3,
|
||||
wgpu::DeviceType::Cpu => 4,
|
||||
};
|
||||
let ty = match (backend, a.device_type) {
|
||||
(GpuBackend::Cpu, wgpu::DeviceType::Cpu) => 0,
|
||||
(GpuBackend::Cpu, _) => ty + 1,
|
||||
_ => ty,
|
||||
};
|
||||
let be = match a.backend {
|
||||
wgpu::Backend::Dx12 if os == Os::Windows && a.vendor == INTEL => 0,
|
||||
wgpu::Backend::Vulkan | wgpu::Backend::Metal => 1,
|
||||
wgpu::Backend::Dx12 => 2,
|
||||
wgpu::Backend::Gl => 3,
|
||||
wgpu::Backend::BrowserWebGpu | wgpu::Backend::Noop => 4,
|
||||
};
|
||||
(ty, be)
|
||||
}
|
||||
|
||||
/// The adapter to use among `adapters` (`None` when there is none).
|
||||
pub fn pick(adapters: &[Candidate], backend: GpuBackend, os: Os) -> Option<usize> {
|
||||
let any_ok = adapters.iter().any(|a| a.surface_ok);
|
||||
adapters.iter().enumerate().filter(|(_, a)| a.surface_ok || !any_ok).min_by_key(|(i, a)| (rank(a, backend, os), *i)).map(|(i, _)| i)
|
||||
}
|
||||
|
||||
/// Whether picking `chosen` applied the Intel-on-Windows DX12 default (a Vulkan adapter of the
|
||||
/// same vendor was available).
|
||||
pub fn intel_dx12_applied(adapters: &[Candidate], chosen: usize, os: Os) -> bool {
|
||||
let Some(c) = adapters.get(chosen) else { return false };
|
||||
os == Os::Windows
|
||||
&& c.vendor == INTEL
|
||||
&& c.backend == wgpu::Backend::Dx12
|
||||
&& adapters.iter().any(|a| a.vendor == INTEL && a.backend == wgpu::Backend::Vulkan)
|
||||
}
|
||||
|
||||
/// Configure eframe's wgpu setup for `plan`: instance backends, adapter selection (Intel on
|
||||
/// Windows, software adapters for `cpu`) and the marker update once the adapter is chosen.
|
||||
/// `note` receives a remark for System Info (e.g. the Intel default).
|
||||
pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, sentinel: SharedSentinel, note: Arc<Mutex<Option<String>>>) {
|
||||
if let egui_wgpu::WgpuSetup::CreateNew(create) = setup {
|
||||
if let Some(b) = backends(plan, os) {
|
||||
create.instance_descriptor.backends = b;
|
||||
}
|
||||
let select = plan.env.is_none() && (plan.backend == GpuBackend::Cpu || (plan.backend == GpuBackend::Auto && os == Os::Windows));
|
||||
if select {
|
||||
let backend = plan.backend;
|
||||
create.native_adapter_selector = Some(Arc::new(move |adapters: &[wgpu::Adapter], surface: Option<&wgpu::Surface<'_>>| {
|
||||
let cands: Vec<Candidate> = adapters
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let i = a.get_info();
|
||||
Candidate {
|
||||
vendor: i.vendor,
|
||||
device_type: i.device_type,
|
||||
backend: i.backend,
|
||||
surface_ok: surface.is_none_or(|s| a.is_surface_supported(s)),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let i = pick(&cands, backend, os).ok_or_else(|| "no graphics adapter found".to_string())?;
|
||||
if backend == GpuBackend::Auto && intel_dx12_applied(&cands, i, os) {
|
||||
*note.lock().unwrap_or_else(PoisonError::into_inner) =
|
||||
Some("Intel graphics on Windows: using DirectX 12 (the Intel Vulkan driver is known to crash)".into());
|
||||
}
|
||||
adapters.get(i).cloned().ok_or_else(|| "no graphics adapter found".to_string())
|
||||
}));
|
||||
}
|
||||
}
|
||||
photocraft_ui_egui::gpu_canvas::use_adapter_limits_with(setup, move |adapter| {
|
||||
let info = adapter.get_info();
|
||||
let mut guard = sentinel.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if let Some(s) = guard.as_mut() {
|
||||
let mut m = s.marker.clone();
|
||||
m.adapter = info.name.clone();
|
||||
m.adapter_backend = photocraft_ui_egui::gpu_canvas::backend_name(info.backend).to_string();
|
||||
m.driver = [info.driver.as_str(), info.driver_info.as_str()].iter().filter(|s| !s.is_empty()).copied().collect::<Vec<_>>().join(" ");
|
||||
if let Err(e) = s.write(m) {
|
||||
log::warn!("GPU startup marker: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// What a previous start left behind.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Previous {
|
||||
/// Nothing: it finished starting (or never ran).
|
||||
Clean,
|
||||
/// Its marker, unlocked: it died before rendering its first frames.
|
||||
Crashed(Marker),
|
||||
/// Another instance is starting right now (it holds the lock).
|
||||
Busy,
|
||||
}
|
||||
|
||||
impl Previous {
|
||||
pub fn crashed(&self) -> Option<&Marker> {
|
||||
match self {
|
||||
Previous::Crashed(m) => Some(m),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The locked marker file of this start.
|
||||
#[derive(Debug)]
|
||||
pub struct Sentinel {
|
||||
path: PathBuf,
|
||||
file: Option<File>,
|
||||
marker: Marker,
|
||||
}
|
||||
|
||||
/// The sentinel, shared with eframe's adapter hook and the app's started hook.
|
||||
pub type SharedSentinel = Arc<Mutex<Option<Sentinel>>>;
|
||||
|
||||
impl Sentinel {
|
||||
/// Open (creating) the marker in `dir` and lock it. Returns what a previous start left and,
|
||||
/// unless another instance holds the lock or the directory isn't writable, this start's
|
||||
/// sentinel (not yet written: see [`Sentinel::write`]).
|
||||
pub fn begin(dir: &Path) -> (Previous, Option<Sentinel>) {
|
||||
let path = dir.join(MARKER_FILE);
|
||||
if let Err(e) = std::fs::create_dir_all(dir) {
|
||||
log::warn!("GPU startup marker: {e}");
|
||||
return (Previous::Clean, None);
|
||||
}
|
||||
let mut file = match OpenOptions::new().read(true).write(true).create(true).truncate(false).open(&path) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
log::warn!("GPU startup marker {}: {e}", path.display());
|
||||
return (Previous::Clean, None);
|
||||
}
|
||||
};
|
||||
match file.try_lock() {
|
||||
Ok(()) => {}
|
||||
Err(std::fs::TryLockError::WouldBlock) => return (Previous::Busy, None),
|
||||
Err(std::fs::TryLockError::Error(e)) => {
|
||||
log::warn!("GPU startup marker lock: {e}");
|
||||
return (Previous::Clean, None);
|
||||
}
|
||||
}
|
||||
let mut text = String::new();
|
||||
let _ = (&mut file).take(MARKER_MAX).read_to_string(&mut text);
|
||||
let previous = if text.trim().is_empty() { Previous::Clean } else { Previous::Crashed(Marker::parse(&text)) };
|
||||
(previous, Some(Sentinel { path, file: Some(file), marker: Marker::default() }))
|
||||
}
|
||||
|
||||
/// Replace the marker's contents with `m`.
|
||||
pub fn write(&mut self, m: Marker) -> std::io::Result<()> {
|
||||
let f = self.file.as_mut().ok_or_else(|| std::io::Error::other("marker closed"))?;
|
||||
f.set_len(0)?;
|
||||
f.seek(SeekFrom::Start(0))?;
|
||||
f.write_all(m.to_json().as_bytes())?;
|
||||
f.flush()?;
|
||||
self.marker = m;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The start succeeded: clear the marker and release the lock.
|
||||
pub fn finish(mut self) {
|
||||
if let Some(f) = self.file.take() {
|
||||
// Empty means "no crash" even if removing fails (another instance has it open).
|
||||
let _ = f.set_len(0);
|
||||
drop(f);
|
||||
}
|
||||
let _ = std::fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
/// `performance.gpuBackend` and `performance.useGpu` from the preferences file, read before the
|
||||
/// app (and its full preference load) exists. Unreadable values are the defaults.
|
||||
pub fn read_prefs(path: Option<&Path>) -> (GpuBackend, bool) {
|
||||
let v: Value = path.and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);
|
||||
let perf = v.get("performance");
|
||||
let backend = perf.and_then(|p| p.get("gpuBackend")).and_then(Value::as_str).and_then(GpuBackend::parse).unwrap_or_default();
|
||||
let use_gpu = perf.and_then(|p| p.get("useGpu")).and_then(Value::as_bool).unwrap_or(true);
|
||||
(backend, use_gpu)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use GpuBackend::*;
|
||||
|
||||
fn crashed(backend: &str, adapter_backend: &str) -> Marker {
|
||||
Marker { backend: backend.into(), adapter: "Intel(R) UHD Graphics".into(), adapter_backend: adapter_backend.into(), ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_marker_uses_the_preference() {
|
||||
for os in [Os::Windows, Os::Mac, Os::Other] {
|
||||
let p = plan(Auto, None, None, false, os);
|
||||
assert_eq!(p, Plan { backend: Auto, env: None, reason: None, remember: false });
|
||||
}
|
||||
assert_eq!(plan(Dx12, None, None, false, Os::Windows).backend, Dx12);
|
||||
assert_eq!(plan(Gl, None, None, false, Os::Other).backend, Gl);
|
||||
// A backend the platform lacks is `auto`.
|
||||
assert_eq!(plan(Metal, None, None, false, Os::Windows).backend, Auto);
|
||||
assert_eq!(plan(Dx12, None, None, false, Os::Mac).backend, Auto);
|
||||
assert_eq!(plan(Dx12, None, None, false, Os::Other).backend, Auto);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_crash_moves_down_each_chain_and_is_remembered() {
|
||||
// Windows: Vulkan → DX12 → CPU.
|
||||
let p = plan(Auto, Some(&crashed("auto", "vulkan")), None, false, Os::Windows);
|
||||
assert_eq!((p.backend, p.remember), (Dx12, true));
|
||||
assert!(p.reason.as_deref().unwrap_or("").contains("vulkan (Intel(R) UHD Graphics)"), "{p:?}");
|
||||
assert_eq!(plan(Dx12, Some(&crashed("dx12", "dx12")), None, false, Os::Windows).backend, Cpu);
|
||||
// Crashed before the adapter was chosen (enumeration): from `auto`.
|
||||
assert_eq!(plan(Auto, Some(&crashed("auto", "")), None, false, Os::Windows).backend, Dx12);
|
||||
// `auto` that had already picked DX12 (the Intel default) goes to the CPU.
|
||||
assert_eq!(plan(Auto, Some(&crashed("auto", "dx12")), None, false, Os::Windows).backend, Cpu);
|
||||
// Linux: Vulkan → GL → CPU.
|
||||
assert_eq!(plan(Auto, Some(&crashed("auto", "vulkan")), None, false, Os::Other).backend, Gl);
|
||||
assert_eq!(plan(Gl, Some(&crashed("gl", "gl")), None, false, Os::Other).backend, Cpu);
|
||||
// macOS: Metal → CPU.
|
||||
assert_eq!(plan(Auto, Some(&crashed("auto", "metal")), None, false, Os::Mac).backend, Cpu);
|
||||
// The CPU path stays there, and isn't "remembered" again.
|
||||
let p = plan(Cpu, Some(&crashed("cpu", "dx12")), None, false, Os::Windows);
|
||||
assert_eq!((p.backend, p.remember), (Cpu, false));
|
||||
// An unreadable marker still counts as a crash.
|
||||
assert_eq!(plan(Auto, Some(&Marker::parse("{garbage")), None, false, Os::Windows).backend, Dx12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_override_beats_everything() {
|
||||
let p = plan(Cpu, Some(&crashed("auto", "vulkan")), Some("vulkan"), false, Os::Windows);
|
||||
assert_eq!(p.env.as_deref(), Some("vulkan"));
|
||||
assert_eq!(p.backend, Auto);
|
||||
assert!(!p.remember);
|
||||
assert_eq!(backends(&p, Os::Windows), None, "egui reads WGPU_BACKEND itself");
|
||||
// A crash under the override doesn't count against `auto` later.
|
||||
let p = plan(Auto, Some(&Marker { backend: "env:vulkan".into(), ..Default::default() }), None, false, Os::Windows);
|
||||
assert_eq!(p.backend, Auto);
|
||||
// Empty means unset.
|
||||
assert_eq!(plan(Auto, None, Some(" "), false, Os::Windows).env, None);
|
||||
// --safe-gpu with the override: CPU canvas, the override's backends.
|
||||
assert_eq!(plan(Auto, None, Some("dx12"), true, Os::Windows).backend, Cpu);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn safe_gpu_forces_the_cpu_for_one_launch() {
|
||||
let p = plan(Vulkan, Some(&crashed("vulkan", "vulkan")), None, true, Os::Other);
|
||||
assert_eq!((p.backend, p.remember), (Cpu, false));
|
||||
assert_eq!(backends(&p, Os::Windows), Some(wgpu::Backends::DX12));
|
||||
assert_eq!(backends(&p, Os::Other), Some(wgpu::Backends::GL));
|
||||
assert_eq!(backends(&p, Os::Mac), Some(wgpu::Backends::METAL));
|
||||
}
|
||||
|
||||
fn cand(vendor: u32, device_type: wgpu::DeviceType, backend: wgpu::Backend) -> Candidate {
|
||||
Candidate { vendor, device_type, backend, surface_ok: true }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intel_on_windows_defaults_to_dx12() {
|
||||
use wgpu::{Backend as B, DeviceType as T};
|
||||
let intel = [cand(INTEL, T::IntegratedGpu, B::Vulkan), cand(INTEL, T::IntegratedGpu, B::Dx12), cand(INTEL, T::IntegratedGpu, B::Gl)];
|
||||
let i = pick(&intel, Auto, Os::Windows).unwrap();
|
||||
assert_eq!(intel[i].backend, B::Dx12);
|
||||
assert!(intel_dx12_applied(&intel, i, Os::Windows));
|
||||
// Elsewhere (and for other vendors) Vulkan stays first.
|
||||
assert_eq!(intel[pick(&intel, Auto, Os::Other).unwrap()].backend, B::Vulkan);
|
||||
let nv = [cand(0x10de, T::DiscreteGpu, B::Dx12), cand(0x10de, T::DiscreteGpu, B::Vulkan)];
|
||||
let i = pick(&nv, Auto, Os::Windows).unwrap();
|
||||
assert_eq!(nv[i].backend, B::Vulkan);
|
||||
assert!(!intel_dx12_applied(&nv, i, Os::Windows));
|
||||
// Intel iGPU + discrete GPU: the discrete one wins (high performance), on Vulkan.
|
||||
let hybrid = [intel[0], intel[1], nv[1]];
|
||||
assert_eq!(pick(&hybrid, Auto, Os::Windows), Some(2));
|
||||
// Adapters that can't present lose to ones that can.
|
||||
let mut only = [cand(0x10de, T::DiscreteGpu, B::Vulkan), intel[1]];
|
||||
only[0].surface_ok = false;
|
||||
assert_eq!(pick(&only, Auto, Os::Windows), Some(1));
|
||||
assert_eq!(pick(&[], Auto, Os::Windows), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_prefers_software_adapters() {
|
||||
use wgpu::{Backend as B, DeviceType as T};
|
||||
let a = [cand(INTEL, T::IntegratedGpu, B::Dx12), cand(0x1414, T::Cpu, B::Dx12)];
|
||||
assert_eq!(pick(&a, Cpu, Os::Windows), Some(1));
|
||||
// No software adapter: still something.
|
||||
assert_eq!(pick(&a[..1], Cpu, Os::Windows), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marker_round_trip_and_lenient_parse() {
|
||||
let m = Marker {
|
||||
backend: "auto".into(),
|
||||
adapter: "A \"quoted\" GPU".into(),
|
||||
adapter_backend: "vulkan".into(),
|
||||
driver: "1.2".into(),
|
||||
version: "0.1".into(),
|
||||
};
|
||||
assert_eq!(Marker::parse(&m.to_json()), m);
|
||||
assert_eq!(Marker::parse(""), Marker::default());
|
||||
assert_eq!(Marker::parse("[1,2]").tried(), Some(Auto));
|
||||
assert_eq!(Marker::parse(r#"{"backend": 7}"#).tried(), Some(Auto));
|
||||
assert_eq!(Marker::parse(r#"{"backend": "env:dx12"}"#).tried(), None);
|
||||
let long = format!(r#"{{"adapter": "{}"}}"#, "x".repeat(10_000));
|
||||
assert_eq!(Marker::parse(&long).adapter.len(), 256);
|
||||
}
|
||||
|
||||
fn temp_dir(name: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("photocraft-gpu-startup-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sentinel_detects_a_crash_and_clears_on_success() {
|
||||
let dir = temp_dir("cycle");
|
||||
// First launch: nothing left behind.
|
||||
let (prev, s) = Sentinel::begin(&dir);
|
||||
assert_eq!(prev, Previous::Clean);
|
||||
let mut s = s.expect("sentinel");
|
||||
s.write(Marker { backend: "auto".into(), ..Default::default() }).unwrap();
|
||||
s.write(Marker { backend: "auto".into(), adapter: "GPU".into(), adapter_backend: "vulkan".into(), ..Default::default() }).unwrap();
|
||||
// While it is starting, a second instance sees it busy, not crashed.
|
||||
let (busy, none) = Sentinel::begin(&dir);
|
||||
assert_eq!(busy, Previous::Busy);
|
||||
assert!(none.is_none());
|
||||
// The process dies in the driver: the lock goes, the marker stays.
|
||||
drop(s);
|
||||
let (prev, s) = Sentinel::begin(&dir);
|
||||
let m = prev.crashed().cloned().expect("crash detected");
|
||||
assert_eq!((m.adapter.as_str(), m.tried()), ("GPU", Some(Vulkan)));
|
||||
// This start succeeds: the next one finds nothing.
|
||||
let mut s = s.expect("sentinel");
|
||||
s.write(Marker { backend: "dx12".into(), ..Default::default() }).unwrap();
|
||||
s.finish();
|
||||
assert!(!dir.join(MARKER_FILE).exists());
|
||||
let (prev, _s) = Sentinel::begin(&dir);
|
||||
assert_eq!(prev, Previous::Clean);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unwritable_config_dir_is_not_fatal() {
|
||||
let dir = temp_dir("file");
|
||||
std::fs::create_dir_all(dir.parent().unwrap()).unwrap();
|
||||
std::fs::write(&dir, b"not a directory").unwrap();
|
||||
let (prev, s) = Sentinel::begin(&dir.join("sub"));
|
||||
assert_eq!(prev, Previous::Clean);
|
||||
assert!(s.is_none());
|
||||
let _ = std::fs::remove_file(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefs_are_read_leniently() {
|
||||
let dir = temp_dir("prefs");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let p = dir.join("preferences.json");
|
||||
assert_eq!(read_prefs(None), (Auto, true));
|
||||
assert_eq!(read_prefs(Some(&p)), (Auto, true));
|
||||
std::fs::write(&p, r#"{"performance": {"gpuBackend": "dx12", "useGpu": false}, "interface": {}}"#).unwrap();
|
||||
assert_eq!(read_prefs(Some(&p)), (Dx12, false));
|
||||
std::fs::write(&p, r#"{"performance": {"gpuBackend": "quantum"}}"#).unwrap();
|
||||
assert_eq!(read_prefs(Some(&p)), (Auto, true));
|
||||
std::fs::write(&p, "{not json").unwrap();
|
||||
assert_eq!(read_prefs(Some(&p)), (Auto, true));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
+80
-10
@@ -2,7 +2,11 @@
|
||||
//!
|
||||
//! Usage: `photocraft [--control <port>] [--control-token <64-hex> |
|
||||
//! --control-token-file <path>] [--automation-read-root <dir>]
|
||||
//! [--automation-write-root <dir>] [files…]`
|
||||
//! [--automation-write-root <dir>] [--safe-gpu] [files…]`
|
||||
//!
|
||||
//! `--safe-gpu` starts with the CPU renderer (no GPU canvas; a software adapter for the window
|
||||
//! where the platform has one) for this launch, e.g. after a graphics driver crash. A start that
|
||||
//! crashes inside the driver also falls back by itself next time (see `gpu_startup`).
|
||||
//!
|
||||
//! `--control <port>` (or `PHOTOCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server.
|
||||
//! The first line must authenticate; subsequent request lines get reply lines.
|
||||
@@ -17,6 +21,7 @@
|
||||
mod apple_events;
|
||||
mod control_server;
|
||||
mod crash_guard;
|
||||
mod gpu_startup;
|
||||
// Pure logic is tested on every platform; only Linux runs the check.
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
mod linux_libs;
|
||||
@@ -47,6 +52,7 @@ fn main() -> eframe::Result {
|
||||
let mut automation_read_root = std::env::var_os("PHOTOCRAFT_AUTOMATION_READ_ROOT").map(std::path::PathBuf::from);
|
||||
let mut automation_write_root = std::env::var_os("PHOTOCRAFT_AUTOMATION_WRITE_ROOT").map(std::path::PathBuf::from);
|
||||
let mut files = Vec::new();
|
||||
let mut safe_gpu = false;
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(a) = args.next() {
|
||||
match a.as_str() {
|
||||
@@ -55,6 +61,7 @@ fn main() -> eframe::Result {
|
||||
"--control-token-file" => control_token_file = args.next().map(std::path::PathBuf::from),
|
||||
"--automation-read-root" => automation_read_root = args.next().map(std::path::PathBuf::from),
|
||||
"--automation-write-root" => automation_write_root = args.next().map(std::path::PathBuf::from),
|
||||
"--safe-gpu" => safe_gpu = true,
|
||||
"--version" => {
|
||||
println!("photocraft {}", photocraft_engine::build_info::long_version());
|
||||
return Ok(());
|
||||
@@ -124,9 +131,38 @@ fn main() -> eframe::Result {
|
||||
.with_title_shown(false),
|
||||
..Default::default()
|
||||
};
|
||||
// Crash-safe GPU startup (#4): pick the backend (a marker left by a start that died in the
|
||||
// driver moves to a safer one), and lock this start's marker until the first frames render.
|
||||
let t_sentinel = std::time::Instant::now();
|
||||
let os = gpu_startup::Os::current();
|
||||
let (pref, _) = gpu_startup::read_prefs(services::prefs_file().as_deref());
|
||||
let (previous, sentinel) = match services::config_dir() {
|
||||
Some(dir) => gpu_startup::Sentinel::begin(&dir),
|
||||
None => (gpu_startup::Previous::Clean, None),
|
||||
};
|
||||
let env_backend = std::env::var("WGPU_BACKEND").ok();
|
||||
let plan = gpu_startup::plan(pref, previous.crashed(), env_backend.as_deref(), safe_gpu, os);
|
||||
if let Some(m) = previous.crashed() {
|
||||
log::warn!("the previous start didn't finish (GPU backend {}, adapter {:?}); {}", m.backend, m.adapter, plan.reason.as_deref().unwrap_or(""));
|
||||
}
|
||||
let sentinel: gpu_startup::SharedSentinel = std::sync::Arc::new(std::sync::Mutex::new(sentinel));
|
||||
if let Some(s) = sentinel.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_mut() {
|
||||
let backend = match &plan.env {
|
||||
Some(v) => format!("env:{v}"),
|
||||
None => plan.backend.name().to_string(),
|
||||
};
|
||||
let marker = gpu_startup::Marker { backend, version: photocraft_engine::build_info::long_version().to_string(), ..Default::default() };
|
||||
if let Err(e) = s.write(marker) {
|
||||
log::warn!("GPU startup marker: {e}");
|
||||
}
|
||||
}
|
||||
let gpu_note: std::sync::Arc<std::sync::Mutex<Option<String>>> = Default::default();
|
||||
// The adapter's real texture limits (egui asks for 8192 px), so big documents stay on the GPU.
|
||||
photocraft_ui_egui::gpu_canvas::use_adapter_limits(&mut options.wgpu_options.wgpu_setup);
|
||||
eframe::run_native(
|
||||
gpu_startup::configure(&mut options.wgpu_options.wgpu_setup, &plan, os, sentinel.clone(), gpu_note.clone());
|
||||
let sentinel_ms = t_sentinel.elapsed().as_secs_f64() * 1000.0;
|
||||
log::info!("GPU startup: {:?} ({sentinel_ms:.2} ms)", plan);
|
||||
let started_sentinel = sentinel.clone();
|
||||
let result = eframe::run_native(
|
||||
"Photocraft",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
@@ -138,13 +174,39 @@ fn main() -> eframe::Result {
|
||||
if let Ok(Some(icc)) = monitor.recv_timeout(std::time::Duration::from_secs(2)) {
|
||||
app.session.color.monitor_profile = Some(std::sync::Arc::new(icc));
|
||||
}
|
||||
// Preferences › Performance › Use Graphics Processor.
|
||||
if let Some(rs) = cc.wgpu_render_state.clone()
|
||||
&& std::env::var_os("PHOTOCRAFT_CPU_CANVAS").is_none()
|
||||
&& app.session.prefs().performance.use_gpu
|
||||
{
|
||||
app.set_wgpu(rs);
|
||||
// Preferences › Performance › Use Graphics Processor (and the GPU backend: `cpu`
|
||||
// composites on the CPU).
|
||||
let info = &mut app.perf.gpu_info;
|
||||
info.preference = pref.name().to_string();
|
||||
info.selected = if plan.env.is_some() { "env".into() } else { plan.backend.name().to_string() };
|
||||
info.fallback = plan.reason.clone().or_else(|| gpu_note.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone());
|
||||
info.canvas = "cpu".into();
|
||||
if let Some(rs) = cc.wgpu_render_state.clone() {
|
||||
app.perf.gpu_info.set_adapter(&rs.adapter.get_info());
|
||||
if plan.backend != photocraft_engine::prefs::GpuBackend::Cpu
|
||||
&& std::env::var_os("PHOTOCRAFT_CPU_CANVAS").is_none()
|
||||
&& app.session.prefs().performance.use_gpu
|
||||
{
|
||||
app.set_wgpu(rs);
|
||||
} else {
|
||||
// The window still draws with wgpu: record its errors instead of panicking.
|
||||
let _ = photocraft_ui_egui::gpu_canvas::DeviceHealth::watch(&rs.device);
|
||||
}
|
||||
}
|
||||
app.perf.span("gpuSentinel", sentinel_ms);
|
||||
// Once the first frames rendered: clear the marker, and keep a crash fallback.
|
||||
let remember = plan.remember.then_some(plan.backend);
|
||||
app.on_started(move |app| {
|
||||
if let Some(s) = started_sentinel.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take() {
|
||||
s.finish();
|
||||
}
|
||||
if let Some(b) = remember
|
||||
&& app.session.prefs().performance.gpu_backend != b
|
||||
&& let Err(e) = app.run("prefs.set", serde_json::json!({"path": "performance.gpuBackend", "value": b.name()}))
|
||||
{
|
||||
log::warn!("couldn't remember GPU backend {}: {e}", b.name());
|
||||
}
|
||||
});
|
||||
if let Some((port, token, _)) = control {
|
||||
let rx = control_server::start(port, token, cc.egui_ctx.clone());
|
||||
app = app.with_control(rx);
|
||||
@@ -157,5 +219,13 @@ fn main() -> eframe::Result {
|
||||
app.open_paths(&files);
|
||||
Ok(Box::new(app))
|
||||
}),
|
||||
)
|
||||
);
|
||||
// Closed before the first frames rendered: not a driver crash. (A start that failed to
|
||||
// create its device keeps the marker, so the next one tries a safer backend.)
|
||||
if result.is_ok()
|
||||
&& let Some(s) = sentinel.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take()
|
||||
{
|
||||
s.finish();
|
||||
}
|
||||
result
|
||||
}
|
||||
@@ -53,7 +53,7 @@ pub fn config_dir() -> Option<PathBuf> {
|
||||
std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from).or_else(|| home.map(|h| h.join(".config"))).map(|c| c.join("photocraft"))
|
||||
}
|
||||
|
||||
fn prefs_file() -> Option<PathBuf> {
|
||||
pub fn prefs_file() -> Option<PathBuf> {
|
||||
config_dir().map(|d| d.join("preferences.json"))
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,13 @@ choice!(Theme { Pro = "pro", ProMedium = "proMedium", Studio = "studio", StudioL
|
||||
choice!(CanvasColor { Default = "default", Black = "black", DarkGray = "darkGray", MediumGray = "mediumGray", LightGray = "lightGray", Custom = "custom" } default Default);
|
||||
choice!(CanvasBorder { DropShadow = "dropShadow", Line = "line", None = "none" } default DropShadow);
|
||||
choice!(UiScale { Auto = "auto", P100 = "100", P200 = "200" } default Auto);
|
||||
choice!(
|
||||
/// Graphics backend of the desktop app's window and GPU canvas (applies at next launch).
|
||||
/// `auto` lets PhotoCraft pick (DX12 for Intel adapters on Windows); `cpu` composites on the
|
||||
/// CPU and draws the window with a software adapter where the platform has one. A start that
|
||||
/// crashes inside the graphics driver moves this to the next safer choice.
|
||||
GpuBackend { Auto = "auto", Vulkan = "vulkan", Dx12 = "dx12", Metal = "metal", Gl = "gl", Cpu = "cpu" } default Auto
|
||||
);
|
||||
choice!(UiFontSize { Tiny = "tiny", Small = "small", Medium = "medium", Large = "large" } default Small);
|
||||
choice!(LogDestination { Metadata = "metadata", TextFile = "textFile", Both = "both" } default Metadata);
|
||||
choice!(LogDetail { SessionsOnly = "sessionsOnly", Concise = "concise", Detailed = "detailed" } default Concise);
|
||||
@@ -370,6 +377,8 @@ pub struct Performance {
|
||||
pub cache_tile_size: u32,
|
||||
/// Draw the canvas with the GPU (applies at next launch).
|
||||
pub use_gpu: bool,
|
||||
/// Graphics backend (applies at next launch; see [`GpuBackend`]).
|
||||
pub gpu_backend: GpuBackend,
|
||||
/// Memory budget of the layer-effect cache, in MB.
|
||||
pub effect_cache_mb: u32,
|
||||
pub legacy_compositing: bool,
|
||||
@@ -391,6 +400,7 @@ impl Default for Performance {
|
||||
cache_levels: 4,
|
||||
cache_tile_size: 8192,
|
||||
use_gpu: true,
|
||||
gpu_backend: GpuBackend::Auto,
|
||||
effect_cache_mb: 768,
|
||||
legacy_compositing: false,
|
||||
}
|
||||
@@ -860,6 +870,7 @@ pub fn choices(path: &str) -> Option<&'static [&'static str]> {
|
||||
"rawDefaults.colorSpace" => RawColorSpace::NAMES,
|
||||
"rawDefaults.bitDepth" => RawDepth::NAMES,
|
||||
"rawDefaults.sharpenFor" => RawSharpen::NAMES,
|
||||
"performance.gpuBackend" => GpuBackend::NAMES,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -204,3 +204,29 @@ fn right_click_with_painting_tools_pref() {
|
||||
let old: Tools = serde_json::from_value(json!({"showTooltips": false})).unwrap();
|
||||
assert_eq!(old.right_click_with_painting_tools, RightClickPaint::BrushPicker);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_backend_round_trips_and_validates() {
|
||||
let mut s = session();
|
||||
assert_eq!(s.prefs().performance.gpu_backend, GpuBackend::Auto);
|
||||
assert_eq!(s.execute("prefs.get", json!({"path": "performance.gpuBackend"})).unwrap(), json!("auto"));
|
||||
s.execute("prefs.set", json!({"path": "performance.gpuBackend", "value": "dx12"})).unwrap();
|
||||
assert_eq!(s.prefs().performance.gpu_backend, GpuBackend::Dx12);
|
||||
// Unknown names and wrong types are errors, and leave the value alone.
|
||||
assert!(s.execute("prefs.set", json!({"path": "performance.gpuBackend", "value": "directx"})).is_err());
|
||||
assert!(s.execute("prefs.set", json!({"path": "performance.gpuBackend", "value": 3})).is_err());
|
||||
assert_eq!(s.prefs().performance.gpu_backend, GpuBackend::Dx12);
|
||||
// Persisted and restored with the rest of the preferences.
|
||||
let text = s.prefs_to_json();
|
||||
let mut t = Session::new();
|
||||
t.load_prefs_json(&text).unwrap();
|
||||
assert_eq!(t.prefs().performance.gpu_backend, GpuBackend::Dx12);
|
||||
// Files from before the setting load as `auto`.
|
||||
let mut u = Session::new();
|
||||
u.load_prefs_json(r#"{"performance": {"useGpu": true}}"#).unwrap();
|
||||
assert_eq!(u.prefs().performance.gpu_backend, GpuBackend::Auto);
|
||||
assert_eq!(choices("performance.gpuBackend"), Some(GpuBackend::NAMES));
|
||||
for n in GpuBackend::NAMES {
|
||||
assert_eq!(GpuBackend::parse(n).map(GpuBackend::name), Some(*n));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
//! Device health (#243): notices a lost wgpu device or an uncaptured wgpu error so callers stop
|
||||
//! issuing GPU work and fall back to the CPU compositor instead of panicking.
|
||||
//!
|
||||
//! wgpu's default uncaptured-error handler panics ("Buffer 'pc_compose_uniforms' is invalid"
|
||||
//! after the OS killed a huge submission), and `Device::poll` panics on a lost device whatever
|
||||
//! the handler. [`DeviceHealth::watch`] replaces the handler with one that records the fault,
|
||||
//! registers the device-lost callback, and [`DeviceHealth::wait`] polls only while the device is
|
||||
//! healthy (catching the poll's panic as a last resort). Every GPU entry point checks
|
||||
//! [`DeviceHealth::fault`] first.
|
||||
//!
|
||||
//! Builds for the web too: the callbacks only touch atomics and a mutex.
|
||||
|
||||
use std::fmt;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
|
||||
/// Why the GPU can't be used any more.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Fault {
|
||||
/// The device was lost (driver reset, the OS killed a submission, `Device::destroy`).
|
||||
Lost(String),
|
||||
/// An uncaptured wgpu error (out of memory, validation, internal): the GPU output can't be
|
||||
/// trusted, and on most drivers the device is about to be lost anyway.
|
||||
Error(String),
|
||||
}
|
||||
|
||||
impl Fault {
|
||||
/// Whether the device itself was lost (rather than an error reported).
|
||||
pub fn is_lost(&self) -> bool {
|
||||
matches!(self, Fault::Lost(_))
|
||||
}
|
||||
|
||||
/// The driver's or wgpu's message.
|
||||
pub fn detail(&self) -> &str {
|
||||
match self {
|
||||
Fault::Lost(s) | Fault::Error(s) => s,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Fault {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Fault::Lost(s) if s.is_empty() => f.write_str("GPU device lost"),
|
||||
Fault::Lost(s) => write!(f, "GPU device lost: {s}"),
|
||||
Fault::Error(s) => write!(f, "GPU error: {s}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Inner {
|
||||
faulted: AtomicBool,
|
||||
fault: Mutex<Option<Fault>>,
|
||||
errors: AtomicU64,
|
||||
}
|
||||
|
||||
/// Shared health flag of one wgpu device. Cheap to clone; every clone sees the same state.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct DeviceHealth(Arc<Inner>);
|
||||
|
||||
impl fmt::Debug for DeviceHealth {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("DeviceHealth").field("fault", &self.fault()).field("errors", &self.errors()).finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl DeviceHealth {
|
||||
/// A healthy flag not attached to a device (tests, CPU-only callers).
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Watch `device`: its device-lost callback and uncaptured errors mark the returned flag
|
||||
/// instead of panicking. Replaces any handler set before (wgpu keeps one of each).
|
||||
pub fn watch(device: &wgpu::Device) -> Self {
|
||||
let health = Self::new();
|
||||
let lost = health.clone();
|
||||
// Called by wgpu with its own locks held: only record, never call back into wgpu.
|
||||
device.set_device_lost_callback(move |reason, message| {
|
||||
let detail = match (reason, message.is_empty()) {
|
||||
(wgpu::DeviceLostReason::Destroyed, true) => "device destroyed".to_string(),
|
||||
(_, true) => "lost by the driver".to_string(),
|
||||
(_, false) => message,
|
||||
};
|
||||
log::error!("wgpu device lost ({reason:?}): {detail}");
|
||||
lost.mark(Fault::Lost(detail));
|
||||
});
|
||||
let errors = health.clone();
|
||||
device.on_uncaptured_error(Arc::new(move |e: wgpu::Error| errors.uncaptured(&e)));
|
||||
health
|
||||
}
|
||||
|
||||
/// Record an uncaptured wgpu error.
|
||||
fn uncaptured(&self, e: &wgpu::Error) {
|
||||
self.0.errors.fetch_add(1, Ordering::Relaxed);
|
||||
let text = e.to_string();
|
||||
log::error!("uncaptured wgpu error: {text}");
|
||||
// Errors about invalid or lost objects follow a device loss whose callback may not have
|
||||
// run yet (it fires on the next poll).
|
||||
let lower = text.to_ascii_lowercase();
|
||||
let fault = if lower.contains("lost") || lower.contains("destroyed") { Fault::Lost(first_line(&text)) } else { Fault::Error(first_line(&text)) };
|
||||
self.mark(fault);
|
||||
}
|
||||
|
||||
/// Mark the device unusable. The first fault is kept; a later device loss upgrades an error.
|
||||
pub fn mark(&self, fault: Fault) {
|
||||
let mut slot = self.0.fault.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let replace = match slot.as_ref() {
|
||||
None => true,
|
||||
Some(old) => !old.is_lost() && fault.is_lost(),
|
||||
};
|
||||
if replace {
|
||||
*slot = Some(fault);
|
||||
}
|
||||
self.0.faulted.store(true, Ordering::Release);
|
||||
}
|
||||
|
||||
/// Whether the device may still be used.
|
||||
pub fn is_ok(&self) -> bool {
|
||||
!self.0.faulted.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
/// Why the device can't be used, if it can't.
|
||||
pub fn fault(&self) -> Option<Fault> {
|
||||
if self.is_ok() {
|
||||
return None;
|
||||
}
|
||||
self.0.fault.lock().unwrap_or_else(PoisonError::into_inner).clone()
|
||||
}
|
||||
|
||||
/// Uncaptured wgpu errors seen so far.
|
||||
pub fn errors(&self) -> u64 {
|
||||
self.0.errors.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Block until `index` (or all submitted work) finished, unless the device is unusable.
|
||||
/// Returns whether the device is still healthy afterwards. `Device::poll` panics on a lost
|
||||
/// device, so a panic is caught and recorded as a loss (native only; the web has no blocking
|
||||
/// wait).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn wait(&self, device: &wgpu::Device, index: Option<wgpu::SubmissionIndex>) -> bool {
|
||||
if !self.is_ok() {
|
||||
return false;
|
||||
}
|
||||
let poll = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| device.poll(wgpu::PollType::Wait { submission_index: index, timeout: None })));
|
||||
match poll {
|
||||
Ok(Ok(_)) => {}
|
||||
Ok(Err(e)) => log::warn!("wgpu poll: {e}"),
|
||||
Err(_) => self.mark(Fault::Lost("the device stopped responding".into())),
|
||||
}
|
||||
self.is_ok()
|
||||
}
|
||||
|
||||
/// Non-blocking poll (reclaims staging memory), skipped once the device is unusable.
|
||||
pub fn poll(&self, device: &wgpu::Device) {
|
||||
if !self.is_ok() {
|
||||
return;
|
||||
}
|
||||
if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| device.poll(wgpu::PollType::Poll))).is_err() {
|
||||
self.mark(Fault::Lost("the device stopped responding".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn first_line(s: &str) -> String {
|
||||
let line = s.lines().map(str::trim).find(|l| !l.is_empty()).unwrap_or("");
|
||||
let mut out: String = line.chars().take(200).collect();
|
||||
if line.chars().count() > 200 {
|
||||
out.push('…');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn first_fault_wins_but_loss_upgrades_an_error() {
|
||||
let h = DeviceHealth::new();
|
||||
assert!(h.is_ok());
|
||||
assert_eq!(h.fault(), None);
|
||||
h.mark(Fault::Error("out of memory".into()));
|
||||
assert!(!h.is_ok());
|
||||
let c = h.clone();
|
||||
c.mark(Fault::Error("second".into()));
|
||||
assert_eq!(h.fault(), Some(Fault::Error("out of memory".into())));
|
||||
c.mark(Fault::Lost("reset".into()));
|
||||
assert_eq!(h.fault(), Some(Fault::Lost("reset".into())));
|
||||
c.mark(Fault::Lost("later".into()));
|
||||
assert_eq!(h.fault().map(|f| f.to_string()).as_deref(), Some("GPU device lost: reset"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fault_text() {
|
||||
assert_eq!(Fault::Lost(String::new()).to_string(), "GPU device lost");
|
||||
assert_eq!(Fault::Error("x".into()).to_string(), "GPU error: x");
|
||||
assert_eq!(first_line("\n Buffer 'pc' is invalid\nmore"), "Buffer 'pc' is invalid");
|
||||
assert_eq!(first_line(&"a".repeat(300)).chars().count(), 201);
|
||||
}
|
||||
}
|
||||
+39
-2
@@ -33,6 +33,7 @@
|
||||
|
||||
pub mod bounds;
|
||||
mod fx;
|
||||
pub mod health;
|
||||
pub mod plan;
|
||||
|
||||
use std::collections::HashMap;
|
||||
@@ -45,6 +46,7 @@ use photocraft_doc::{DocId, Document, Layer, LayerContent, LayerId, Pattern};
|
||||
use photocraft_geom::{Rect, TILE_SIZE, TileCoord};
|
||||
use photocraft_raster::{Surface, Tile};
|
||||
|
||||
pub use health::{DeviceHealth, Fault};
|
||||
pub use plan::{Kernel, Plan, Role, Unsupported, plan};
|
||||
|
||||
/// Accumulator format for intermediate buffers. Full float: discontinuous operations
|
||||
@@ -410,6 +412,8 @@ pub struct Compositor {
|
||||
effect_maps: bool,
|
||||
/// `CmykSpace::id` of the document being encoded (0: built-in coated CMYK).
|
||||
cmyk: u64,
|
||||
/// The device's health (see [`Compositor::set_health`]); `None` = assumed healthy.
|
||||
health: Option<DeviceHealth>,
|
||||
}
|
||||
|
||||
fn tex_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
|
||||
@@ -598,6 +602,7 @@ impl Compositor {
|
||||
acc_format,
|
||||
effect_maps: map_error.is_none(),
|
||||
cmyk: 0,
|
||||
health: None,
|
||||
}
|
||||
.with_texture_limit(device.limits().max_texture_dimension_2d)
|
||||
}
|
||||
@@ -758,6 +763,18 @@ impl Compositor {
|
||||
self.fx.values().map(FxEntry::bytes).sum()
|
||||
}
|
||||
|
||||
/// Watch `health` (see [`DeviceHealth::watch`]): once the device is lost or reported an
|
||||
/// error, every render returns [`Unsupported`] without touching the GPU, so callers use the
|
||||
/// CPU compositor instead.
|
||||
pub fn set_health(&mut self, health: DeviceHealth) {
|
||||
self.health = Some(health);
|
||||
}
|
||||
|
||||
/// Why the device can't be used (`None` while healthy or unwatched).
|
||||
pub fn fault(&self) -> Option<Fault> {
|
||||
self.health.as_ref().and_then(DeviceHealth::fault)
|
||||
}
|
||||
|
||||
/// Composite `region` of `doc`. Each finished chunk is passed to `sink` while its encoder is
|
||||
/// still open; the chunk texture is reused afterwards, so the sink must record any copies
|
||||
/// or passes that read it into the given encoder. Submits the work before returning; a
|
||||
@@ -801,6 +818,9 @@ impl Compositor {
|
||||
sink: &mut dyn FnMut(&mut wgpu::CommandEncoder, ChunkOut<'_>),
|
||||
flush: bool,
|
||||
) -> Result<Stats, Unsupported> {
|
||||
if let Some(f) = self.fault() {
|
||||
return Err(Unsupported(f.to_string()));
|
||||
}
|
||||
// CMYK layers convert through the document's own CMYK profile (uploads and plan colours).
|
||||
let space = photocraft_compose::cmyk_space(doc);
|
||||
self.cmyk = space.as_ref().map_or(0, |s| s.id);
|
||||
@@ -892,7 +912,14 @@ impl Compositor {
|
||||
let index = queue.submit([done.finish()]);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(prev) = last_submit.replace(index) {
|
||||
let _ = device.poll(wgpu::PollType::Wait { submission_index: Some(prev), timeout: None });
|
||||
match &self.health {
|
||||
Some(h) => {
|
||||
h.wait(device, Some(prev));
|
||||
}
|
||||
None => {
|
||||
let _ = device.poll(wgpu::PollType::Wait { submission_index: Some(prev), timeout: None });
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let _ = index;
|
||||
@@ -900,6 +927,10 @@ impl Compositor {
|
||||
work = 0;
|
||||
freed = 0;
|
||||
stats.flushes += 1;
|
||||
// The device was lost mid-refresh: issue no more work.
|
||||
if let Some(f) = self.fault() {
|
||||
return Err(Unsupported(f.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1824,7 +1855,13 @@ pub fn render_to_vec_stats(
|
||||
for (_, b, _) in &staging {
|
||||
b.slice(..).map_async(wgpu::MapMode::Read, |_| {});
|
||||
}
|
||||
let _ = device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None });
|
||||
match &comp.health {
|
||||
Some(h) if !h.wait(device, None) => return Err(Unsupported(h.fault().map_or_else(|| "GPU device lost".into(), |f| f.to_string()))),
|
||||
Some(_) => {}
|
||||
None => {
|
||||
let _ = device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None });
|
||||
}
|
||||
}
|
||||
let w = rect.width() as usize;
|
||||
let mut out = vec![[0.0f32; 4]; w * rect.height() as usize];
|
||||
for (r, b, row) in &staging {
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
//! The compositor on a lost device (#243): renders return `Unsupported` (so callers use the CPU
|
||||
//! compositor) instead of panicking with "Buffer 'pc_compose_uniforms' is invalid". Skips when no
|
||||
//! GPU adapter exists.
|
||||
|
||||
use photocraft_color::{Color, ColorMode, SampleType};
|
||||
use photocraft_doc::{Document, Layer, LayerContent};
|
||||
use photocraft_geom::{Rect, Size};
|
||||
use photocraft_gpu::{Compositor, DeviceHealth, Fault, render_to_vec};
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
/// Concurrent wgpu instances in one process segfault on some drivers (see `parity.rs`).
|
||||
static GPU_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
fn device() -> Option<(wgpu::Adapter, wgpu::Device, wgpu::Queue)> {
|
||||
let instance = wgpu::Instance::default();
|
||||
let adapter = match pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())) {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
eprintln!("skipping device-loss tests: no adapter ({e})");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).ok()?;
|
||||
Some((adapter, device, queue))
|
||||
}
|
||||
|
||||
fn doc() -> Document {
|
||||
let mut d = Document::with_background("t", Size::new(600, 400), ColorMode::Rgb, SampleType::U8, Color::rgba(0.9, 0.9, 0.9, 1.0));
|
||||
let mut s = Surface::new(d.pixel_format());
|
||||
s.fill_rect(Rect::new(20, 20, 500, 300), &[0.8, 0.1, 0.1, 0.7]);
|
||||
d.layers.push(Layer::new("red", LayerContent::Raster(s)));
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn injected_fault_skips_the_gpu() {
|
||||
let _lock = GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let Some((adapter, device, queue)) = device() else { return };
|
||||
let Ok(mut comp) = Compositor::try_new_with_format(&device, Compositor::preferred_acc_format(&adapter)) else { return };
|
||||
let health = DeviceHealth::watch(&device);
|
||||
comp.set_health(health.clone());
|
||||
let d = doc();
|
||||
assert!(render_to_vec(&mut comp, &device, &queue, &d, d.bounds()).is_ok());
|
||||
assert!(comp.fault().is_none());
|
||||
health.mark(Fault::Lost("injected".into()));
|
||||
let e = render_to_vec(&mut comp, &device, &queue, &d, d.bounds()).unwrap_err();
|
||||
assert!(e.0.contains("GPU device lost: injected"), "{e:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn destroyed_device_returns_an_error_not_a_panic() {
|
||||
let _lock = GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let Some((adapter, device, queue)) = device() else { return };
|
||||
let Ok(mut comp) = Compositor::try_new_with_format(&device, Compositor::preferred_acc_format(&adapter)) else { return };
|
||||
let health = DeviceHealth::watch(&device);
|
||||
comp.set_health(health.clone());
|
||||
let d = doc();
|
||||
assert!(render_to_vec(&mut comp, &device, &queue, &d, d.bounds()).is_ok());
|
||||
device.destroy();
|
||||
// The first render after the loss runs into it (uploads, uniforms, submit, readback);
|
||||
// every one after sees the flag and touches nothing.
|
||||
let mut d2 = d.clone();
|
||||
d2.layers[1].opacity = 0.4;
|
||||
for _ in 0..3 {
|
||||
assert!(render_to_vec(&mut comp, &device, &queue, &d2, d2.bounds()).is_err());
|
||||
}
|
||||
assert!(health.fault().is_some(), "loss not detected");
|
||||
assert!(health.errors() > 0 || health.fault().is_some_and(|f| f.is_lost()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn health_wait_on_a_destroyed_device_is_safe() {
|
||||
let _lock = GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let Some((_adapter, device, _queue)) = device() else { return };
|
||||
let health = DeviceHealth::watch(&device);
|
||||
assert!(health.wait(&device, None));
|
||||
device.destroy();
|
||||
// Polling a destroyed device panics inside wgpu; the wait records a loss instead.
|
||||
health.wait(&device, None);
|
||||
health.poll(&device);
|
||||
assert!(!health.is_ok());
|
||||
assert!(!health.wait(&device, None));
|
||||
}
|
||||
@@ -6,6 +6,8 @@
|
||||
//! --script '[["ui.set", {"tool": "type"}], ["ui.menu.invoke", {"id": "image.imageSize"}]]'
|
||||
//! ```
|
||||
//!
|
||||
//! `--safe-gpu` draws the canvas on the CPU path, like the app's `--safe-gpu` launch.
|
||||
//!
|
||||
//! `--script` is a JSON array of `[method, params]` control-protocol calls (see
|
||||
//! docs/control-protocol.md), applied in order with a few frames between them.
|
||||
|
||||
@@ -40,11 +42,20 @@ fn main() {
|
||||
..Default::default()
|
||||
};
|
||||
let open = arg(&args, "--open");
|
||||
let safe_gpu = args.iter().any(|a| a == "--safe-gpu");
|
||||
let mut harness =
|
||||
egui_kittest::Harness::builder().with_size(egui::vec2(w, h)).with_pixels_per_point(scale).with_max_steps(64).wgpu().build_eframe(move |cc| {
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services);
|
||||
if let Some(rs) = cc.wgpu_render_state.as_ref() {
|
||||
// `--safe-gpu`: the CPU canvas, as the desktop app's `--safe-gpu` launch.
|
||||
if safe_gpu {
|
||||
app.perf.gpu_info.selected = "cpu".into();
|
||||
app.perf.gpu_info.canvas = "cpu".into();
|
||||
app.perf.gpu_info.fallback = Some("--safe-gpu: CPU renderer for this launch".into());
|
||||
if let Some(rs) = cc.wgpu_render_state.as_ref() {
|
||||
app.perf.gpu_info.set_adapter(&rs.adapter.get_info());
|
||||
}
|
||||
} else if let Some(rs) = cc.wgpu_render_state.as_ref() {
|
||||
app.set_wgpu(rs.clone());
|
||||
}
|
||||
if let Some(path) = &open {
|
||||
|
||||
@@ -587,6 +587,10 @@ fn ensure_gpu(app: &mut PhotocraftApp, idx: usize, visible: DRect) -> bool {
|
||||
let partial = present && damage.is_some();
|
||||
gpu_budget(app, &gpu, idx, partial, visible);
|
||||
let r = gpu.refresh(id.0, &doc, if partial { damage } else { None }, display.as_deref());
|
||||
if r.kind == "lost" {
|
||||
// The device is gone: draw this frame on the CPU path; `gpu_status` drops the GPU canvas.
|
||||
return false;
|
||||
}
|
||||
if let Some(e) = &r.fallback
|
||||
&& app.perf.gpu_fallback.as_deref() != Some(e.as_str())
|
||||
{
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
//! - `ui.click {x, y, button?, count?}` / `ui.move {x, y}`: synthetic pointer input in screen points
|
||||
//! - `ui.key {key, command?, shift?, alt?, ctrl?}` / `ui.type {text}`: synthetic keyboard input
|
||||
//! - `ui.resize {width, height}`: resize the main window
|
||||
//! - `ui.gpu.simulateLoss {error?}`: act as if the wgpu device was lost (or, with `error: true`,
|
||||
//! reported an error): the app switches to the CPU renderer for the rest of the session, as on a
|
||||
//! real loss. For testing the fallback; returns whether a GPU canvas was active
|
||||
//! - `ui.screenshot {path?, focus?}`: capture the main window (PNG). Raises the window first (default)
|
||||
//! because occluded macOS windows stop rendering
|
||||
//! - `ui.focus`: bring the main window to the front
|
||||
@@ -356,6 +359,24 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(w as f32, h as f32)));
|
||||
ok(Value::Null)
|
||||
}
|
||||
"ui.gpu.simulateLoss" => {
|
||||
let error = p.get("error").and_then(Value::as_bool).unwrap_or(false);
|
||||
let fault = if error {
|
||||
photocraft_gpu::Fault::Error("simulated error (ui.gpu.simulateLoss)".into())
|
||||
} else {
|
||||
photocraft_gpu::Fault::Lost("simulated (ui.gpu.simulateLoss)".into())
|
||||
};
|
||||
let active = match app.gpu_health() {
|
||||
Some(h) => {
|
||||
h.mark(fault);
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
};
|
||||
app.check_gpu(ctx);
|
||||
ctx.request_repaint();
|
||||
ok(json!({"wasActive": active, "gpuInfo": app.perf.gpu_info}))
|
||||
}
|
||||
"ui.focus" => {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true));
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false));
|
||||
|
||||
@@ -78,6 +78,16 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
ui.add_space(8.0);
|
||||
match d.kind {
|
||||
DialogKind::NewDocument => crate::new_doc_ui::body(ui, &mut fields),
|
||||
DialogKind::About if fields.get("systemInfo").and_then(Value::as_bool) == Some(true) => {
|
||||
let lines = crate::gpu_status::system_info(app);
|
||||
for l in &lines {
|
||||
ui.add(egui::Label::new(egui::RichText::new(l).font(crate::theme::mono(12.0))).selectable(true));
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
if crate::widgets::secondary_button(ui, "Copy", 84.0).clicked() {
|
||||
ui.ctx().copy_text(lines.join("\n"));
|
||||
}
|
||||
}
|
||||
DialogKind::About => {
|
||||
ui.label("PhotoCraft — an open-source, native image editor written in Rust.");
|
||||
ui.label(format!("Version {}", photocraft_engine::build_info::long_version()));
|
||||
@@ -169,6 +179,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
pub fn title(d: &Dialog) -> String {
|
||||
match d.kind {
|
||||
DialogKind::NewDocument => "New Document".into(),
|
||||
DialogKind::About if d.fields.get("systemInfo").and_then(Value::as_bool) == Some(true) => "System Info".into(),
|
||||
DialogKind::About => "About PhotoCraft".into(),
|
||||
DialogKind::LayerStyle => "Layer Style".into(),
|
||||
DialogKind::Command => d.fields.get("__label").and_then(Value::as_str).unwrap_or("Command").trim_end_matches('…').to_string(),
|
||||
|
||||
@@ -29,7 +29,8 @@ use std::num::NonZeroU64;
|
||||
|
||||
use eframe::egui_wgpu::{self, CallbackResources, CallbackTrait, RenderState, ScreenDescriptor};
|
||||
use eframe::wgpu;
|
||||
use eframe::wgpu::util::DeviceExt as _;
|
||||
|
||||
pub use photocraft_gpu::{DeviceHealth, Fault};
|
||||
|
||||
/// Default tile side; documents up to this size (and the device limit) use a single texture.
|
||||
pub const DEFAULT_TILE: u32 = 8192;
|
||||
@@ -83,11 +84,16 @@ enum HighPolicy {
|
||||
}
|
||||
|
||||
/// Uploads documents to the GPU and paints them. Cheap to clone.
|
||||
///
|
||||
/// Once the device is lost or reports an uncaptured error (see [`GpuCanvas::fault`]), every
|
||||
/// method is a no-op or an `Err`, so nothing reaches the GPU again; the app then switches to the
|
||||
/// CPU canvas (#243).
|
||||
#[derive(Clone)]
|
||||
pub struct GpuCanvas {
|
||||
rs: RenderState,
|
||||
tile: u32,
|
||||
high: HighPolicy,
|
||||
health: photocraft_gpu::DeviceHealth,
|
||||
}
|
||||
|
||||
/// Can `adapter` use `Rgba16Float` as a sampled, filtered, render-target and copy texture?
|
||||
@@ -115,10 +121,42 @@ impl GpuCanvas {
|
||||
Some("1") => HighPolicy::Always,
|
||||
_ => HighPolicy::Budget,
|
||||
};
|
||||
let res = Resources::new(&rs.device, &rs.queue, rs.target_format, high != HighPolicy::Off);
|
||||
// Device loss and uncaptured errors mark this flag instead of panicking (#243).
|
||||
let health = photocraft_gpu::DeviceHealth::watch(&rs.device);
|
||||
let mut res = Resources::new(&rs.device, &rs.queue, rs.target_format, high != HighPolicy::Off);
|
||||
res.health = health.clone();
|
||||
rs.renderer.write().callback_resources.insert(res);
|
||||
log::info!("gpu canvas: target {:?}, max texture {max}, tile {tile}, 16F canvas {high:?}", rs.target_format);
|
||||
Self { rs: rs.clone(), tile, high }
|
||||
Self { rs: rs.clone(), tile, high, health }
|
||||
}
|
||||
|
||||
/// The device's health flag (shared with the wgpu compositor and the paint callback).
|
||||
pub fn health(&self) -> &photocraft_gpu::DeviceHealth {
|
||||
&self.health
|
||||
}
|
||||
|
||||
/// Why the GPU can't be used any more (`None` while it can).
|
||||
pub fn fault(&self) -> Option<photocraft_gpu::Fault> {
|
||||
self.health.fault()
|
||||
}
|
||||
|
||||
/// Free every GPU resource the canvas holds (compositor pages, document textures, LUTs) after
|
||||
/// a fault; the paint callback draws nothing from then on.
|
||||
pub fn release(&self) {
|
||||
let mut r = self.rs.renderer.write();
|
||||
if let Some(res) = r.callback_resources.get_mut::<Resources>() {
|
||||
res.compositor = None;
|
||||
res.compositor_failed = Some(photocraft_gpu::Unsupported(self.fault().map_or_else(|| "GPU canvas released".into(), |f| f.to_string())));
|
||||
res.docs.clear();
|
||||
res.luts.clear();
|
||||
res.display_lut_signatures.clear();
|
||||
res.views.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// The adapter this canvas renders with.
|
||||
pub fn adapter_info(&self) -> wgpu::AdapterInfo {
|
||||
self.rs.adapter.get_info()
|
||||
}
|
||||
|
||||
/// The canvas texture format for a document of `depth` and `size`: `Rgba16Float` for 16/32-bit
|
||||
@@ -223,7 +261,7 @@ impl GpuCanvas {
|
||||
|
||||
/// Replace the whole document image with a `format` texture of premultiplied `texels`.
|
||||
fn upload_full_as(&self, doc: u64, size: [u32; 2], format: wgpu::TextureFormat, texels: &[u8]) {
|
||||
if size[0] == 0 || size[1] == 0 || texels.len() as u64 != size[0] as u64 * size[1] as u64 * texel_bytes(format) {
|
||||
if size[0] == 0 || size[1] == 0 || texels.len() as u64 != size[0] as u64 * size[1] as u64 * texel_bytes(format) || !self.health.is_ok() {
|
||||
return;
|
||||
}
|
||||
let (device, queue) = (&self.rs.device, &self.rs.queue);
|
||||
@@ -242,6 +280,9 @@ impl GpuCanvas {
|
||||
/// in its texture format (`size[0] * size[1]` texels: RGBA8 or RGBA16F). Returns false if the
|
||||
/// document isn't uploaded, the rectangle doesn't fit or the buffer has the wrong size.
|
||||
pub fn upload_rect(&self, doc: u64, origin: [u32; 2], size: [u32; 2], texels: &[u8]) -> bool {
|
||||
if !self.health.is_ok() {
|
||||
return false;
|
||||
}
|
||||
let (device, queue) = (&self.rs.device, &self.rs.queue);
|
||||
let mut renderer = self.rs.renderer.write();
|
||||
let Some(res) = renderer.callback_resources.get_mut::<Resources>() else { return false };
|
||||
@@ -303,6 +344,9 @@ impl GpuCanvas {
|
||||
if std::env::var_os("PHOTOCRAFT_CPU_COMPOSE").is_some() {
|
||||
return Err(photocraft_gpu::Unsupported("disabled by PHOTOCRAFT_CPU_COMPOSE".into()));
|
||||
}
|
||||
if let Some(f) = self.fault() {
|
||||
return Err(photocraft_gpu::Unsupported(f.to_string()));
|
||||
}
|
||||
let mut renderer = self.rs.renderer.write();
|
||||
let Some(res) = renderer.callback_resources.get_mut::<Resources>() else { return Err(photocraft_gpu::Unsupported("no GPU canvas".into())) };
|
||||
// A driver that couldn't build the pipelines once won't later: stay on the CPU compositor.
|
||||
@@ -320,6 +364,7 @@ impl GpuCanvas {
|
||||
}
|
||||
},
|
||||
};
|
||||
comp.set_health(self.health.clone());
|
||||
if let Some(b) = res.compositor_budget {
|
||||
comp.set_memory_budget(b);
|
||||
}
|
||||
@@ -351,51 +396,62 @@ impl GpuCanvas {
|
||||
let bgl = &res.encode_bgl;
|
||||
// A float canvas keeps values above 1.0 (32-bit documents, for the 32-bit preview).
|
||||
let keep_hdr = i32::from(format == FORMAT_HIGH);
|
||||
let result = comp.render(device, queue, doc, region, |enc, out| {
|
||||
for t in &d.tiles {
|
||||
let [tx, ty, tw, th] = t.rect.map(|v| v as i32);
|
||||
let r = out.rect.intersect(&photocraft_geom::Rect::from_xywh(tx, ty, tw as u32, th as u32));
|
||||
if r.is_empty() {
|
||||
continue;
|
||||
// Last-resort guard: a wgpu panic (e.g. polling a device lost mid-refresh) marks the
|
||||
// device lost instead of taking the app and its documents down.
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
comp.render(device, queue, doc, region, |enc, out| {
|
||||
for t in &d.tiles {
|
||||
let [tx, ty, tw, th] = t.rect.map(|v| v as i32);
|
||||
let r = out.rect.intersect(&photocraft_geom::Rect::from_xywh(tx, ty, tw as u32, th as u32));
|
||||
if r.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let offset = [out.rect.x0 - tx, out.rect.y0 - ty, i32::from(encode_srgb), keep_hdr];
|
||||
let ubuf = init_buffer(device, "pc_encode", &offset.iter().flat_map(|v| v.to_le_bytes()).collect::<Vec<u8>>(), wgpu::BufferUsages::UNIFORM);
|
||||
let bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("pc_encode"),
|
||||
layout: bgl,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(out.view) },
|
||||
wgpu::BindGroupEntry { binding: 1, resource: ubuf.as_entire_binding() },
|
||||
],
|
||||
});
|
||||
let mut pass = enc.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("pc_encode"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &t.levels[0],
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations { load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store },
|
||||
})],
|
||||
depth_stencil_attachment: None,
|
||||
timestamp_writes: None,
|
||||
occlusion_query_set: None,
|
||||
multiview_mask: None,
|
||||
});
|
||||
pass.set_pipeline(pipe);
|
||||
pass.set_bind_group(0, &bg, &[]);
|
||||
pass.set_scissor_rect((r.x0 - tx) as u32, (r.y0 - ty) as u32, r.width(), r.height());
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
let offset = [out.rect.x0 - tx, out.rect.y0 - ty, i32::from(encode_srgb), keep_hdr];
|
||||
let ubuf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("pc_encode"),
|
||||
contents: &offset.iter().flat_map(|v| v.to_le_bytes()).collect::<Vec<u8>>(),
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
});
|
||||
let bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("pc_encode"),
|
||||
layout: bgl,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(out.view) },
|
||||
wgpu::BindGroupEntry { binding: 1, resource: ubuf.as_entire_binding() },
|
||||
],
|
||||
});
|
||||
let mut pass = enc.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("pc_encode"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &t.levels[0],
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations { load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store },
|
||||
})],
|
||||
depth_stencil_attachment: None,
|
||||
timestamp_writes: None,
|
||||
occlusion_query_set: None,
|
||||
multiview_mask: None,
|
||||
});
|
||||
pass.set_pipeline(pipe);
|
||||
pass.set_bind_group(0, &bg, &[]);
|
||||
pass.set_scissor_rect((r.x0 - tx) as u32, (r.y0 - ty) as u32, r.width(), r.height());
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
});
|
||||
})
|
||||
}));
|
||||
let Ok(result) = result else {
|
||||
self.health.mark(photocraft_gpu::Fault::Lost("the GPU compositor stopped (internal error)".into()));
|
||||
res.docs.remove(&key);
|
||||
return Err(photocraft_gpu::Unsupported("GPU device lost".into()));
|
||||
};
|
||||
// Lost during the refresh: its output can't be trusted.
|
||||
let result = match self.fault() {
|
||||
Some(f) => Err(photocraft_gpu::Unsupported(f.to_string())),
|
||||
None => result,
|
||||
};
|
||||
if result.is_ok() {
|
||||
d.regenerate_mips(device, queue, res, [region.x0 as u32, region.y0 as u32, region.x1 as u32, region.y1 as u32]);
|
||||
if std::env::var_os("PHOTOCRAFT_GPU_SYNC").is_some() {
|
||||
// Benchmarking: wait for the GPU so callers can time the whole refresh.
|
||||
let _ = device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None });
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
self.health.wait(device, None);
|
||||
}
|
||||
} else if fresh {
|
||||
res.docs.remove(&key);
|
||||
@@ -416,6 +472,10 @@ impl GpuCanvas {
|
||||
damage: Option<photocraft_geom::Rect>,
|
||||
display: Option<&photocraft_engine::display_color::CanvasDisplay>,
|
||||
) -> Refresh {
|
||||
if let Some(f) = self.fault() {
|
||||
// Nothing reaches the GPU any more; the app switches to the CPU canvas.
|
||||
return Refresh { kind: "lost", fallback: Some(f.to_string()), ..Default::default() };
|
||||
}
|
||||
let bounds = doc.bounds();
|
||||
let size = [doc.size.width, doc.size.height];
|
||||
let format = self.format_for(doc.depth, size);
|
||||
@@ -467,7 +527,7 @@ impl GpuCanvas {
|
||||
/// never needs a full-size float composite or texture-format copy in memory.
|
||||
pub fn upload_composite(&self, key: u64, doc: &photocraft_doc::Document, display: Option<&photocraft_engine::display_color::CanvasDisplay>) {
|
||||
let size = [doc.size.width, doc.size.height];
|
||||
if size[0] == 0 || size[1] == 0 {
|
||||
if size[0] == 0 || size[1] == 0 || !self.health.is_ok() {
|
||||
return;
|
||||
}
|
||||
let format = self.format_for(doc.depth, size);
|
||||
@@ -482,6 +542,9 @@ impl GpuCanvas {
|
||||
}
|
||||
let mut rgba = Vec::new();
|
||||
let _ = photocraft_compose::render_bands(doc, doc.bounds(), 0, |band| -> Result<(), ()> {
|
||||
if !self.health.is_ok() {
|
||||
return Err(());
|
||||
}
|
||||
texels_into(format, &texture_buffer(display, &band).px, &mut rgba);
|
||||
let renderer = self.rs.renderer.read();
|
||||
let d = renderer.callback_resources.get::<Resources>().and_then(|r| r.docs.get(&key)).ok_or(())?;
|
||||
@@ -489,9 +552,12 @@ impl GpuCanvas {
|
||||
drop(renderer);
|
||||
// Flush the staged band so its staging memory is reclaimed while the next renders.
|
||||
queue.submit(std::iter::empty::<wgpu::CommandBuffer>());
|
||||
let _ = device.poll(wgpu::PollType::Poll);
|
||||
self.health.poll(device);
|
||||
Ok(())
|
||||
});
|
||||
if !self.health.is_ok() {
|
||||
return;
|
||||
}
|
||||
let mut renderer = self.rs.renderer.write();
|
||||
if let Some(res) = renderer.callback_resources.get_mut::<Resources>()
|
||||
&& let Some(d) = res.docs.get(&key)
|
||||
@@ -535,6 +601,9 @@ impl GpuCanvas {
|
||||
/// fastest. RGB is the display colour for each lattice input; alpha 255 marks out-of-gamut
|
||||
/// colours for the gamut warning.
|
||||
pub fn set_display_lut(&self, doc: u64, size: u32, rgba: Option<&[u8]>) {
|
||||
if !self.health.is_ok() {
|
||||
return;
|
||||
}
|
||||
let (device, queue) = (&self.rs.device, &self.rs.queue);
|
||||
let mut renderer = self.rs.renderer.write();
|
||||
let Some(res) = renderer.callback_resources.get_mut::<Resources>() else { return };
|
||||
@@ -559,6 +628,9 @@ impl GpuCanvas {
|
||||
/// and debugging.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn read_texels(&self, key: u64) -> Option<(wgpu::TextureFormat, [u32; 2], Vec<[f32; 4]>)> {
|
||||
if !self.health.is_ok() {
|
||||
return None;
|
||||
}
|
||||
let (device, queue) = (&self.rs.device, &self.rs.queue);
|
||||
let renderer = self.rs.renderer.read();
|
||||
let d = renderer.callback_resources.get::<Resources>()?.docs.get(&key)?;
|
||||
@@ -586,7 +658,9 @@ impl GpuCanvas {
|
||||
queue.submit([enc.finish()]);
|
||||
let slice = buf.slice(..);
|
||||
slice.map_async(wgpu::MapMode::Read, |_| {});
|
||||
device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None }).ok()?;
|
||||
if !self.health.wait(device, None) {
|
||||
return None;
|
||||
}
|
||||
let data = slice.get_mapped_range().ok()?;
|
||||
for y in 0..th as usize {
|
||||
let src = data.get(y * row as usize..)?;
|
||||
@@ -644,11 +718,16 @@ pub fn wgpu_setup() -> egui_wgpu::WgpuSetup {
|
||||
/// default 8192 px cap (which sent every document wider or taller than 8192 px to the CPU
|
||||
/// compositor). Everything else (features, the WebGL2 base limits on GL) stays egui's default.
|
||||
pub fn use_adapter_limits(setup: &mut egui_wgpu::WgpuSetup) {
|
||||
use_adapter_limits_with(setup, |_| {});
|
||||
}
|
||||
|
||||
/// [`use_adapter_limits`], calling `on_adapter` with the chosen adapter just before the device
|
||||
/// is requested (the desktop app records it in its crash-safe startup marker).
|
||||
pub fn use_adapter_limits_with(setup: &mut egui_wgpu::WgpuSetup, on_adapter: impl Fn(&wgpu::Adapter) + Send + Sync + 'static) {
|
||||
if let egui_wgpu::WgpuSetup::CreateNew(create) = setup {
|
||||
create.device_descriptor = std::sync::Arc::new(|adapter| wgpu::DeviceDescriptor {
|
||||
label: Some("photocraft wgpu device"),
|
||||
required_limits: device_limits(adapter),
|
||||
..Default::default()
|
||||
create.device_descriptor = std::sync::Arc::new(move |adapter| {
|
||||
on_adapter(adapter);
|
||||
wgpu::DeviceDescriptor { label: Some("photocraft wgpu device"), required_limits: device_limits(adapter), ..Default::default() }
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -693,6 +772,8 @@ pub struct Perf {
|
||||
pub gpu_uploads: u64,
|
||||
/// Why the last refresh fell back to the CPU compositor (None = GPU composited).
|
||||
pub gpu_fallback: Option<String>,
|
||||
/// Adapter, backend, driver and fallback state (Help › System Info).
|
||||
pub gpu_info: GpuInfo,
|
||||
/// GPU memory the compositor may hold for layer pages and effect maps (MB; see
|
||||
/// [`memory_budget`]).
|
||||
pub gpu_budget_mb: u64,
|
||||
@@ -708,6 +789,81 @@ pub struct Perf {
|
||||
max_decay: f64,
|
||||
}
|
||||
|
||||
/// What the app renders with and why (Help › System Info, Preferences › Performance, the `perf`
|
||||
/// control-channel output).
|
||||
#[derive(Clone, Debug, Default, PartialEq, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GpuInfo {
|
||||
/// Adapter name ("" before the window opens).
|
||||
pub adapter: String,
|
||||
/// wgpu backend of the window's device ("vulkan", "dx12", "metal", "gl", …).
|
||||
pub backend: String,
|
||||
/// Device type ("discreteGpu", "integratedGpu", "cpu", …).
|
||||
pub device_type: String,
|
||||
/// Driver name and version as the driver reports them.
|
||||
pub driver: String,
|
||||
/// `performance.gpuBackend` at launch.
|
||||
pub preference: String,
|
||||
/// The backend startup chose ("auto", "dx12", "cpu", …, or "env" for `WGPU_BACKEND`).
|
||||
pub selected: String,
|
||||
/// Why startup didn't use the preference as is (previous start crashed, `--safe-gpu`,
|
||||
/// `WGPU_BACKEND`, Intel on Windows).
|
||||
pub fallback: Option<String>,
|
||||
/// "gpu" (wgpu canvas and compositor) or "cpu" (CPU compositor, egui textures).
|
||||
pub canvas: String,
|
||||
/// Why the GPU canvas was dropped during this session (device lost or a GPU error).
|
||||
pub lost: Option<String>,
|
||||
}
|
||||
|
||||
impl GpuInfo {
|
||||
/// Fill the adapter fields from `info`.
|
||||
pub fn set_adapter(&mut self, info: &wgpu::AdapterInfo) {
|
||||
self.adapter = info.name.clone();
|
||||
self.backend = backend_name(info.backend).to_string();
|
||||
self.device_type = match info.device_type {
|
||||
wgpu::DeviceType::DiscreteGpu => "discreteGpu",
|
||||
wgpu::DeviceType::IntegratedGpu => "integratedGpu",
|
||||
wgpu::DeviceType::VirtualGpu => "virtualGpu",
|
||||
wgpu::DeviceType::Cpu => "cpu",
|
||||
wgpu::DeviceType::Other => "other",
|
||||
}
|
||||
.to_string();
|
||||
self.driver = [info.driver.as_str(), info.driver_info.as_str()].iter().filter(|s| !s.is_empty()).copied().collect::<Vec<_>>().join(" ");
|
||||
}
|
||||
|
||||
/// Human-readable lines for Help › System Info.
|
||||
pub fn lines(&self) -> Vec<String> {
|
||||
let or = |s: &str, d: &str| if s.is_empty() { d.to_string() } else { s.to_string() };
|
||||
let mut v = vec![
|
||||
format!("Graphics adapter: {}", or(&self.adapter, "unknown")),
|
||||
format!("Backend: {} ({})", or(&self.backend, "unknown"), or(&self.device_type, "unknown type")),
|
||||
format!("Driver: {}", or(&self.driver, "unknown")),
|
||||
format!("GPU backend preference: {}", or(&self.preference, "auto")),
|
||||
format!("Selected at launch: {}", or(&self.selected, "auto")),
|
||||
format!("Canvas renderer: {}", if self.canvas == "gpu" { "GPU" } else { "CPU" }),
|
||||
];
|
||||
if let Some(f) = &self.fallback {
|
||||
v.push(format!("Fallback: {f}"));
|
||||
}
|
||||
if let Some(l) = &self.lost {
|
||||
v.push(format!("This session: {l}"));
|
||||
}
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
/// The preference name of a wgpu backend.
|
||||
pub fn backend_name(b: wgpu::Backend) -> &'static str {
|
||||
match b {
|
||||
wgpu::Backend::Vulkan => "vulkan",
|
||||
wgpu::Backend::Dx12 => "dx12",
|
||||
wgpu::Backend::Metal => "metal",
|
||||
wgpu::Backend::Gl => "gl",
|
||||
wgpu::Backend::BrowserWebGpu => "webgpu",
|
||||
wgpu::Backend::Noop => "noop",
|
||||
}
|
||||
}
|
||||
|
||||
impl Perf {
|
||||
pub fn record(&mut self, kind: &'static str, px: u64, composite_ms: f64, upload_ms: f64) {
|
||||
self.last_refresh = kind;
|
||||
@@ -930,6 +1086,8 @@ struct Resources {
|
||||
compositor: Option<photocraft_gpu::Compositor>,
|
||||
/// Why the wgpu compositor couldn't be created (then the CPU compositor is used).
|
||||
compositor_failed: Option<photocraft_gpu::Unsupported>,
|
||||
/// The device's health: the paint callback issues no GPU work once it's lost.
|
||||
health: photocraft_gpu::DeviceHealth,
|
||||
/// GPU memory the compositor may hold (`None`: its default), and the document area the
|
||||
/// view shows; applied before every composite.
|
||||
compositor_budget: Option<u64>,
|
||||
@@ -1193,6 +1351,7 @@ impl Resources {
|
||||
out_linear: target.is_srgb(),
|
||||
compositor: None,
|
||||
compositor_failed: None,
|
||||
health: photocraft_gpu::DeviceHealth::new(),
|
||||
compositor_budget: None,
|
||||
compositor_focus: None,
|
||||
encode_bgl,
|
||||
@@ -1215,6 +1374,20 @@ fn mip_count(w: u32, h: u32) -> u32 {
|
||||
32 - w.max(h).max(1).leading_zeros()
|
||||
}
|
||||
|
||||
/// A buffer holding `contents`, like `DeviceExt::create_buffer_init` but without its panic when
|
||||
/// the device was lost (an invalid buffer has no mapped range): the buffer is then just invalid.
|
||||
fn init_buffer(device: &wgpu::Device, label: &str, contents: &[u8], usage: wgpu::BufferUsages) -> wgpu::Buffer {
|
||||
let size = (contents.len() as u64).next_multiple_of(wgpu::COPY_BUFFER_ALIGNMENT).max(wgpu::COPY_BUFFER_ALIGNMENT);
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some(label), size, usage, mapped_at_creation: true });
|
||||
if let Ok(mut view) = buffer.slice(..).get_mapped_range_mut()
|
||||
&& view.len() >= contents.len()
|
||||
{
|
||||
view.slice(..contents.len()).copy_from_slice(contents);
|
||||
}
|
||||
buffer.unmap();
|
||||
buffer
|
||||
}
|
||||
|
||||
impl DocTextures {
|
||||
fn new(device: &wgpu::Device, res: &Resources, size: [u32; 2], tile: u32, format: wgpu::TextureFormat) -> Self {
|
||||
let mut tiles = Vec::new();
|
||||
@@ -1239,11 +1412,7 @@ impl DocTextures {
|
||||
.map(|l| texture.create_view(&wgpu::TextureViewDescriptor { base_mip_level: l, mip_level_count: Some(1), ..Default::default() }))
|
||||
.collect();
|
||||
let full = texture.create_view(&wgpu::TextureViewDescriptor::default());
|
||||
let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("pc_canvas_tile"),
|
||||
contents: &f32_bytes(&[tx as f32, ty as f32, w as f32, h as f32]),
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
});
|
||||
let uniform = init_buffer(device, "pc_canvas_tile", &f32_bytes(&[tx as f32, ty as f32, w as f32, h as f32]), wgpu::BufferUsages::UNIFORM);
|
||||
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("pc_canvas_tile"),
|
||||
layout: &res.tile_bgl,
|
||||
@@ -1416,6 +1585,9 @@ impl CallbackTrait for CanvasCallback {
|
||||
resources: &mut CallbackResources,
|
||||
) -> Vec<wgpu::CommandBuffer> {
|
||||
let Some(res) = resources.get_mut::<Resources>() else { return Vec::new() };
|
||||
if !res.health.is_ok() {
|
||||
return Vec::new();
|
||||
}
|
||||
let data = self.uniforms(screen.size_in_pixels, screen.pixels_per_point, res.out_linear, &res.style);
|
||||
let (bgl, sampler) = (&res.view_bgl, &res.sampler);
|
||||
let v = res.views.entry(self.params.view_key).or_insert_with(|| {
|
||||
@@ -1444,6 +1616,9 @@ impl CallbackTrait for CanvasCallback {
|
||||
|
||||
fn paint(&self, info: egui::PaintCallbackInfo, pass: &mut wgpu::RenderPass<'static>, resources: &CallbackResources) {
|
||||
let Some(res) = resources.get::<Resources>() else { return };
|
||||
if !res.health.is_ok() {
|
||||
return;
|
||||
}
|
||||
let Some(view) = res.views.get(&self.params.view_key) else { return };
|
||||
let [sw, sh] = info.screen_size_px;
|
||||
let clip = info.clip_rect_in_pixels();
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
//! GPU health in the shell (#243, #4): when the wgpu device is lost or reports an error, the
|
||||
//! GPU canvas is dropped and every document keeps drawing through the CPU compositor and egui
|
||||
//! textures for the rest of the session, with a non-modal notice. Also runs the desktop app's
|
||||
//! "started" hook once the first frames have rendered (the crash-safe startup marker), and
|
||||
//! provides the Help › System Info text.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::PhotocraftApp;
|
||||
|
||||
/// Notice title when the device was lost.
|
||||
pub const LOST_MESSAGE: &str = "GPU device was lost; using the CPU renderer.";
|
||||
/// Notice title when the device reported an error (out of memory, validation, internal).
|
||||
pub const ERROR_MESSAGE: &str = "GPU error; using the CPU renderer.";
|
||||
/// Frames after which startup counts as done even if a document never drew.
|
||||
const STARTED_MAX_FRAMES: u64 = 120;
|
||||
|
||||
/// Hook run once the app has rendered its first frames (see [`PhotocraftApp::on_started`]).
|
||||
pub type StartedHook = Box<dyn FnOnce(&mut PhotocraftApp)>;
|
||||
|
||||
/// Per-frame check: switch to the CPU canvas when the GPU faulted, and run the started hook.
|
||||
pub fn check(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
if let Some(fault) = app.gpu.as_ref().and_then(|g| g.fault()) {
|
||||
fall_back(app, &fault);
|
||||
ctx.request_repaint();
|
||||
}
|
||||
if app.started.is_some() {
|
||||
// Frames 1–2 set up fonts; by frame 3 the window has presented, and a document open at
|
||||
// launch has drawn its canvas (or the GPU path was abandoned).
|
||||
let drawn = app.session.documents().is_empty() || !app.perf.last_refresh.is_empty() || app.gpu.is_none();
|
||||
if app.frame >= 3 && (drawn || app.frame >= STARTED_MAX_FRAMES) {
|
||||
if let Some(hook) = app.started.take() {
|
||||
hook(app);
|
||||
}
|
||||
} else {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop the GPU canvas after `fault`: free its resources, send every view through the CPU
|
||||
/// path, record why, and tell the user. Documents are untouched.
|
||||
pub fn fall_back(app: &mut PhotocraftApp, fault: &photocraft_gpu::Fault) {
|
||||
let Some(gpu) = app.gpu.take() else { return };
|
||||
log::error!("{fault}; using the CPU renderer for the rest of the session");
|
||||
gpu.release();
|
||||
// Canvas caches pointed at GPU textures: rebuild them as egui textures.
|
||||
app.canvases.clear();
|
||||
app.proxy_uploaded = None;
|
||||
app.prefs_rt.gpu_style = None;
|
||||
app.perf.gpu = false;
|
||||
app.perf.gpu_fallback = Some(fault.to_string());
|
||||
app.perf.gpu_info.canvas = "cpu".into();
|
||||
app.perf.gpu_info.lost = Some(fault.to_string());
|
||||
let title = if fault.is_lost() { LOST_MESSAGE } else { ERROR_MESSAGE };
|
||||
let detail = fault.detail().trim();
|
||||
let mut lines = Vec::new();
|
||||
if !detail.is_empty() {
|
||||
lines.push(detail.to_string());
|
||||
}
|
||||
lines.push("Your documents are unchanged. Save your work; the GPU is used again after a restart.".into());
|
||||
crate::notices::post(app, title, lines, true);
|
||||
app.ui.status = title.to_string();
|
||||
app.ui.status_error = true;
|
||||
}
|
||||
|
||||
/// Help › System Info: version, platform and the graphics state.
|
||||
pub fn system_info(app: &PhotocraftApp) -> Vec<String> {
|
||||
let mut v =
|
||||
vec![format!("PhotoCraft {}", photocraft_engine::build_info::long_version()), format!("Platform: {} {}", std::env::consts::OS, std::env::consts::ARCH)];
|
||||
v.extend(app.perf.gpu_info.lines());
|
||||
v
|
||||
}
|
||||
|
||||
/// The `help.systemInfo` result: the same facts as JSON.
|
||||
pub fn system_info_json(app: &PhotocraftApp) -> serde_json::Value {
|
||||
json!({
|
||||
"version": photocraft_engine::build_info::long_version(),
|
||||
"os": std::env::consts::OS,
|
||||
"arch": std::env::consts::ARCH,
|
||||
"gpu": app.perf.gpu_info,
|
||||
"lines": system_info(app),
|
||||
})
|
||||
}
|
||||
@@ -42,6 +42,7 @@ pub mod file_ui;
|
||||
pub mod filter_dialog;
|
||||
pub mod gallery_ui;
|
||||
pub mod gpu_canvas;
|
||||
pub mod gpu_status;
|
||||
pub mod gradient_ui;
|
||||
mod icon_data;
|
||||
pub mod icons;
|
||||
@@ -304,8 +305,11 @@ pub struct PhotocraftApp {
|
||||
pub(crate) channel_views: HashMap<u64, channel_view::Cache>,
|
||||
/// Selection outline keyed by (document, mask identity × step × visible region).
|
||||
pub(crate) outline_cache: Option<(DocId, u64, std::sync::Arc<Vec<outline::Segment>>)>,
|
||||
/// GPU canvas renderer, when running on the wgpu backend (see [`Self::set_wgpu`]).
|
||||
/// GPU canvas renderer, when running on the wgpu backend (see [`Self::set_wgpu`]). Dropped
|
||||
/// for the rest of the session when the device is lost (see `gpu_status`).
|
||||
gpu: Option<gpu_canvas::GpuCanvas>,
|
||||
/// Run once the first frames have rendered (see [`Self::on_started`]).
|
||||
started: Option<gpu_status::StartedHook>,
|
||||
/// Frame and canvas-upload timings (exposed via `ui.inspect`).
|
||||
pub perf: gpu_canvas::Perf,
|
||||
/// Preferences, autosave and snapping runtime state (see `prefs_ui`, `snap_ui`).
|
||||
@@ -377,6 +381,7 @@ impl PhotocraftApp {
|
||||
tone_hist: None,
|
||||
doc_hist: None,
|
||||
gpu: None,
|
||||
started: None,
|
||||
perf: Default::default(),
|
||||
prefs_rt: Default::default(),
|
||||
discard: None,
|
||||
@@ -397,10 +402,36 @@ impl PhotocraftApp {
|
||||
pub fn set_wgpu(&mut self, rs: eframe::egui_wgpu::RenderState) {
|
||||
// Preferences › Performance › cache tile size (PHOTOCRAFT_GPU_TILE still overrides).
|
||||
let tile = self.session.prefs().performance.cache_tile_size;
|
||||
self.gpu = Some(gpu_canvas::GpuCanvas::with_tile(&rs, Some(tile)));
|
||||
let gpu = gpu_canvas::GpuCanvas::with_tile(&rs, Some(tile));
|
||||
self.perf.gpu_info.set_adapter(&gpu.adapter_info());
|
||||
self.perf.gpu_info.canvas = "gpu".into();
|
||||
self.gpu = Some(gpu);
|
||||
self.prefs_rt.gpu_style = None;
|
||||
}
|
||||
|
||||
/// Whether the document canvas currently draws on the GPU (false on the CPU path, and after
|
||||
/// the device was lost).
|
||||
pub fn gpu_active(&self) -> bool {
|
||||
self.gpu.is_some()
|
||||
}
|
||||
|
||||
/// The GPU canvas's device health flag (tests inject faults through it).
|
||||
pub fn gpu_health(&self) -> Option<photocraft_gpu::DeviceHealth> {
|
||||
self.gpu.as_ref().map(|g| g.health().clone())
|
||||
}
|
||||
|
||||
/// Run `hook` once the app has rendered its first frames (and a document opened at launch
|
||||
/// has drawn): the desktop app clears its crash-safe GPU startup marker there.
|
||||
pub fn on_started(&mut self, hook: impl FnOnce(&mut PhotocraftApp) + 'static) {
|
||||
self.started = Some(Box::new(hook));
|
||||
}
|
||||
|
||||
/// Per-frame GPU health check (called from `logic`; tests call it directly): falls back to
|
||||
/// the CPU canvas after a device loss and runs the started hook.
|
||||
pub fn check_gpu(&mut self, ctx: &egui::Context) {
|
||||
gpu_status::check(self, ctx);
|
||||
}
|
||||
|
||||
/// Attach a control channel (requests arrive from a transport thread: TCP, stdin, tests).
|
||||
pub fn with_control(mut self, rx: Receiver<ControlRequest>) -> Self {
|
||||
self.control_rx = Some(rx);
|
||||
@@ -722,6 +753,7 @@ impl eframe::App for PhotocraftApp {
|
||||
}
|
||||
self.last_frame_time = now;
|
||||
self.sync_views();
|
||||
self.check_gpu(ctx);
|
||||
#[cfg(all(debug_assertions, not(target_arch = "wasm32")))]
|
||||
if self.live_tokens.poll(ctx, self.ui.theme) {
|
||||
self.checker = None;
|
||||
@@ -816,6 +848,8 @@ impl eframe::App for PhotocraftApp {
|
||||
wide_angle_ui::show(self, &ctx);
|
||||
canvas::extra_windows(self, &ctx);
|
||||
notices::show(self, &ctx);
|
||||
// A device lost while drawing this frame: switch to the CPU canvas before the next one.
|
||||
gpu_status::check(self, &ctx);
|
||||
self.automation_input = false;
|
||||
self.perf.frame(gpu_canvas::now_ms() - t0);
|
||||
// Synthetic input is injected one press/release step per frame: keep frames coming until
|
||||
|
||||
@@ -98,10 +98,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_menu_lists_links_then_separator_then_about() {
|
||||
fn help_menu_lists_links_then_separator_then_system_info_and_about() {
|
||||
let app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
let help: Vec<String> = crate::menus::menu_items(&app).into_iter().filter(|i| i.path == ["Help"]).map(|i| i.id).collect();
|
||||
assert_eq!(help, ["help.discord", "help.website", "help.artcraftWebsite", "help.github", "help.reportIssue", "---", "help.about"]);
|
||||
assert_eq!(help, ["help.discord", "help.website", "help.artcraftWebsite", "help.github", "help.reportIssue", "---", "help.systemInfo", "help.about"]);
|
||||
for (id, _) in COMMANDS {
|
||||
assert!(crate::menus::is_live(id) && crate::menus::is_enabled(&app, id), "{id}");
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ pub const UI_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[
|
||||
("help.artcraftWebsite", "ArtCraft Website", &["Help"], None),
|
||||
("help.github", "PhotoCraft on GitHub", &["Help"], None),
|
||||
("help.reportIssue", "Report an Issue…", &["Help"], None),
|
||||
("help.systemInfo", "System Info…", &["Help"], None),
|
||||
("help.about", "About PhotoCraft", &["Help"], None),
|
||||
];
|
||||
|
||||
@@ -281,6 +282,12 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id:
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"help.about" => Ok(json!({"dialog": app.ui.open_dialog(DialogKind::About, Default::default())})),
|
||||
"help.systemInfo" => {
|
||||
let mut fields = serde_json::Map::new();
|
||||
fields.insert("systemInfo".into(), json!(true));
|
||||
let dialog = app.ui.open_dialog(DialogKind::About, fields);
|
||||
Ok(json!({"dialog": dialog, "info": crate::gpu_status::system_info_json(app)}))
|
||||
}
|
||||
"file.export.exportAs" => Ok(json!({"dialog": crate::export_dialog::open(app)?})),
|
||||
"file.export.quickExportAsPng" => crate::export_dialog::quick_export_png(app),
|
||||
// Layer › Export As… / Quick Export as PNG: the export pipeline on just the active layer.
|
||||
@@ -438,7 +445,7 @@ pub fn is_enabled(app: &PhotocraftApp, id: &str) -> bool {
|
||||
return e;
|
||||
}
|
||||
match id {
|
||||
"file.open" | "file.exit" | "file.clearRecent" | "help.about" | "edit.search" => true,
|
||||
"file.open" | "file.exit" | "file.clearRecent" | "help.about" | "help.systemInfo" | "edit.search" => true,
|
||||
i if i.starts_with("file.openRecent.") => true,
|
||||
i if crate::links::url_for(i).is_some() => true,
|
||||
i if i.starts_with("window.theme.") => true,
|
||||
@@ -653,8 +660,8 @@ pub fn menu_items(app: &PhotocraftApp) -> Vec<MenuItem> {
|
||||
items.insert(after + 1 + k, it);
|
||||
}
|
||||
}
|
||||
// Help: the link items, a separator, then About.
|
||||
if let Some(at) = items.iter().position(|i| i.id == "help.about") {
|
||||
// Help: the link items, a separator, then System Info and About.
|
||||
if let Some(at) = items.iter().position(|i| i.id == "help.systemInfo" || i.id == "help.about") {
|
||||
items.insert(
|
||||
at,
|
||||
MenuItem { id: "---".into(), label: "---".into(), path: vec!["Help".into()], shortcut: None, enabled: false, checked: None, color: None },
|
||||
|
||||
@@ -487,7 +487,8 @@ pub fn open_preferences(app: &mut PhotocraftApp, section: &str) -> u64 {
|
||||
let values = app.session.prefs().to_json();
|
||||
let working: Map<String, Value> = SECTIONS.iter().filter_map(|(id, _)| Some((id.to_string(), values.get(id)?.clone()))).collect();
|
||||
let order = field_order(app.session.prefs(), &working);
|
||||
open_kind(app, "prefs", "Preferences", json!({"section": section, "values": working, "__order": order}))
|
||||
let gpu = app.perf.gpu_info.lines();
|
||||
open_kind(app, "prefs", "Preferences", json!({"section": section, "values": working, "__order": order, "__gpuInfo": gpu}))
|
||||
}
|
||||
|
||||
/// Each section's keys in declaration order (JSON objects sort their keys; the serialised text
|
||||
@@ -588,6 +589,11 @@ fn choice_label(v: &str) -> String {
|
||||
"16" => "16 Bits/Channel".into(),
|
||||
"postScript" => "PostScript (72 points/inch)".into(),
|
||||
"traditional" => "Traditional (72.27 points/inch)".into(),
|
||||
"vulkan" => "Vulkan".into(),
|
||||
"dx12" => "DirectX 12".into(),
|
||||
"metal" => "Metal".into(),
|
||||
"gl" => "OpenGL".into(),
|
||||
"cpu" => "CPU (no GPU acceleration)".into(),
|
||||
v => humanize(v),
|
||||
}
|
||||
}
|
||||
@@ -646,6 +652,9 @@ fn prefs_body(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
|
||||
ui.label(RichText::new("These settings aren't available in PhotoCraft yet.").color(t.text_faint));
|
||||
} else if let Some(obj) = values.get_mut(§ion).and_then(Value::as_object_mut) {
|
||||
section_fields(ui, §ion, obj, &order);
|
||||
if section == "performance" {
|
||||
gpu_status_rows(ui, f.get("__gpuInfo"), obj);
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
if has_visible_fields(&values, §ion)
|
||||
@@ -661,6 +670,25 @@ fn prefs_body(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
|
||||
f.insert("values".into(), values);
|
||||
}
|
||||
|
||||
/// Preferences › Performance: what the app renders with now, and a reset of the GPU backend
|
||||
/// (a crashed start may have moved it to a safer choice).
|
||||
fn gpu_status_rows(ui: &mut egui::Ui, info: Option<&Value>, obj: &mut Map<String, Value>) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.add_space(10.0);
|
||||
ui.label(RichText::new("Graphics").font(crate::theme::semibold(12.5)).color(t.text));
|
||||
for line in info.and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str) {
|
||||
ui.label(RichText::new(line).color(t.text_dim));
|
||||
}
|
||||
let auto = obj.get("gpuBackend").and_then(Value::as_str) == Some("auto");
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
if ui.add_enabled(!auto, egui::Button::new("Reset GPU Backend")).clicked() {
|
||||
obj.insert("gpuBackend".into(), json!("auto"));
|
||||
}
|
||||
ui.label(RichText::new("Applies at next launch.").color(t.text_faint));
|
||||
});
|
||||
}
|
||||
|
||||
/// Does `section` have any setting the dialog shows (see [`prefs::HIDDEN_UNTIL_IMPLEMENTED`])?
|
||||
fn has_visible_fields(values: &Value, section: &str) -> bool {
|
||||
values.get(section).and_then(Value::as_object).is_some_and(|o| o.keys().any(|k| !prefs::is_hidden(&format!("{section}.{k}"))))
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
//! A lost wgpu device must not take the app down (#243): the GPU canvas stops issuing GPU work,
|
||||
//! the app falls back to the CPU canvas for the rest of the session with a notice, and the
|
||||
//! documents stay. Both an injected loss and a real `Device::destroy()` are covered. Skips when
|
||||
//! there is no GPU adapter.
|
||||
|
||||
use eframe::egui_wgpu::RenderState;
|
||||
use photocraft_color::{Color, ColorMode, SampleType};
|
||||
use photocraft_doc::{Document, Size};
|
||||
use photocraft_gpu::Fault;
|
||||
use photocraft_ui_egui::gpu_canvas::{self, GpuCanvas};
|
||||
use photocraft_ui_egui::{PhotocraftApp, gpu_status};
|
||||
use serde_json::json;
|
||||
|
||||
/// Concurrent wgpu devices in one process crash on some drivers (see `canvas_16f.rs`).
|
||||
static GPU_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
fn gpu_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||
GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
fn render_state() -> Option<RenderState> {
|
||||
let rs =
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| egui_kittest::wgpu::create_render_state(gpu_canvas::wgpu_setup(), Default::default()))).ok();
|
||||
if rs.is_none() {
|
||||
eprintln!("skipping: no GPU adapter");
|
||||
}
|
||||
rs
|
||||
}
|
||||
|
||||
fn doc() -> Document {
|
||||
Document::with_background("t", Size::new(300, 200), ColorMode::Rgb, SampleType::U8, Color::rgba(0.2, 0.5, 0.8, 1.0))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn injected_loss_stops_gpu_work_without_panicking() {
|
||||
let _gpu = gpu_lock();
|
||||
let Some(rs) = render_state() else { return };
|
||||
let g = GpuCanvas::new(&rs);
|
||||
let d = doc();
|
||||
let key = d.id.0;
|
||||
let first = g.refresh(key, &d, None, None);
|
||||
assert_ne!(first.kind, "lost");
|
||||
assert!(g.fault().is_none());
|
||||
g.health().mark(Fault::Lost("test".into()));
|
||||
// Every entry point is a no-op or an error now.
|
||||
let r = g.refresh(key, &d, None, None);
|
||||
assert_eq!(r.kind, "lost");
|
||||
assert!(r.fallback.as_deref().unwrap_or("").contains("GPU device lost"));
|
||||
assert!(g.composite(&d, d.bounds(), false).is_err());
|
||||
assert!(!g.upload_rect(key, [0, 0], [1, 1], &[0; 4]));
|
||||
g.upload_full(key, [1, 1], &[0; 4]);
|
||||
g.set_display_lut(key, 2, Some(&[0; 32]));
|
||||
assert!(g.read_texels(key).is_none());
|
||||
g.release();
|
||||
assert!(!g.has(key, [300, 200]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn destroyed_device_falls_back_without_panicking() {
|
||||
let _gpu = gpu_lock();
|
||||
let Some(rs) = render_state() else { return };
|
||||
let g = GpuCanvas::new(&rs);
|
||||
let d = doc();
|
||||
let key = d.id.0;
|
||||
g.refresh(key, &d, None, None);
|
||||
rs.device.destroy();
|
||||
// The next refreshes run into the destroyed device: errors are recorded, nothing panics.
|
||||
let mut d2 = d.clone();
|
||||
d2.layers[0].opacity = 0.5;
|
||||
for _ in 0..3 {
|
||||
let r = g.refresh(key, &d2, Some(photocraft_geom::Rect::new(0, 0, 50, 50)), None);
|
||||
if r.kind == "lost" {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(g.read_texels(key).is_none() || g.fault().is_some());
|
||||
assert!(g.fault().is_some(), "device loss not detected");
|
||||
assert_eq!(g.refresh(key, &d2, None, None).kind, "lost");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_switches_to_the_cpu_canvas_and_keeps_the_documents() {
|
||||
let _gpu = gpu_lock();
|
||||
let Some(rs) = render_state() else { return };
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), photocraft_ui_egui::Services::default());
|
||||
app.set_wgpu(rs);
|
||||
app.run("file.new", json!({"width": 320, "height": 240})).unwrap();
|
||||
app.sync_views();
|
||||
assert!(app.gpu_active());
|
||||
assert_eq!(app.perf.gpu_info.canvas, "gpu");
|
||||
let started = std::rc::Rc::new(std::cell::Cell::new(false));
|
||||
let flag = started.clone();
|
||||
app.on_started(move |_| flag.set(true));
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1000.0, 700.0)).build_ui_state(
|
||||
|ui, app: &mut PhotocraftApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
|
||||
return;
|
||||
}
|
||||
app.check_gpu(&ctx);
|
||||
egui::CentralPanel::default().show(ui, |ui| photocraft_ui_egui::canvas::document_area(app, ui));
|
||||
app.check_gpu(&ctx);
|
||||
},
|
||||
app,
|
||||
);
|
||||
PhotocraftApp::setup_context(&h.ctx, photocraft_ui_egui::theme::ThemeKind::ALL[0]);
|
||||
h.run_steps(4);
|
||||
assert!(h.state().gpu_active(), "GPU canvas in use before the loss");
|
||||
// The device is lost (e.g. the OS killed a huge submission).
|
||||
h.state().gpu_health().unwrap().mark(Fault::Lost("killed by the OS".into()));
|
||||
h.run_steps(3);
|
||||
let app = h.state();
|
||||
assert!(!app.gpu_active(), "still on the GPU canvas");
|
||||
assert_eq!(app.session.documents().len(), 1, "document kept");
|
||||
let n = app.ui.notices.last().expect("notice");
|
||||
assert_eq!(n.title, gpu_status::LOST_MESSAGE);
|
||||
assert!(n.error);
|
||||
assert!(app.perf.gpu_info.lost.as_deref().unwrap_or("").contains("killed by the OS"));
|
||||
assert_eq!(app.perf.gpu_info.canvas, "cpu");
|
||||
// The CPU path draws the document: a refresh after the loss is a CPU one.
|
||||
assert!(matches!(app.perf.last_refresh, "full" | "rect"), "{}", app.perf.last_refresh);
|
||||
// Editing keeps working.
|
||||
h.state_mut().run("layer.new.layer", json!({})).unwrap();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().session.active().map(|d| d.doc.layers.len()), Some(2));
|
||||
// The perf output carries the state for agents.
|
||||
let perf = serde_json::to_value(&h.state().perf).unwrap();
|
||||
assert_eq!(perf["gpuInfo"]["canvas"], "cpu");
|
||||
let _ = started;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn started_hook_runs_once_after_the_first_frames() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), photocraft_ui_egui::Services::default());
|
||||
let runs = std::rc::Rc::new(std::cell::Cell::new(0));
|
||||
let r = runs.clone();
|
||||
app.on_started(move |_| r.set(r.get() + 1));
|
||||
let ctx = egui::Context::default();
|
||||
for frame in 1..=2 {
|
||||
app.frame = frame;
|
||||
app.check_gpu(&ctx);
|
||||
assert_eq!(runs.get(), 0, "too early (frame {frame})");
|
||||
}
|
||||
for frame in 3..=6 {
|
||||
app.frame = frame;
|
||||
app.check_gpu(&ctx);
|
||||
assert_eq!(runs.get(), 1, "runs once (frame {frame})");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_info_lists_the_graphics_state() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), photocraft_ui_egui::Services::default());
|
||||
app.perf.gpu_info.adapter = "Test GPU".into();
|
||||
app.perf.gpu_info.backend = "dx12".into();
|
||||
app.perf.gpu_info.preference = "auto".into();
|
||||
app.perf.gpu_info.selected = "dx12".into();
|
||||
app.perf.gpu_info.fallback = Some("the previous start didn't finish on vulkan; using dx12".into());
|
||||
let lines = gpu_status::system_info(&app);
|
||||
let text = lines.join("\n");
|
||||
for want in ["Graphics adapter: Test GPU", "Backend: dx12", "Selected at launch: dx12", "Fallback: the previous start", "Canvas renderer: CPU"] {
|
||||
assert!(text.contains(want), "{want} missing from\n{text}");
|
||||
}
|
||||
let v = gpu_status::system_info_json(&app);
|
||||
assert_eq!(v["gpu"]["adapter"], "Test GPU");
|
||||
}
|
||||
@@ -34,7 +34,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i
|
||||
|
||||
- `engine.execute {command, params}`: run any engine or UI command by id. Engine commands run directly with their default params and never open a dialog. Use `ui.menu.invoke` for menu-click behaviour, which opens a command's dialog when no params are given
|
||||
- `engine.commands`: list commands with enablement
|
||||
- `ui.inspect`: full UI state (tool, panels, views, dialogs, windows, menu tree, window size). `view` holds the View/Window/Type preferences: `screen_mode`, `extras`, `show` and `snap_to` flags, `flip_horizontal`, `arrange` (Window › Arrange layout), pixel aspect, font preview size, language options
|
||||
- `ui.inspect`: full UI state (tool, panels, views, dialogs, windows, menu tree, window size). `view` holds the View/Window/Type preferences: `screen_mode`, `extras`, `show` and `snap_to` flags, `flip_horizontal`, `arrange` (Window › Arrange layout), pixel aspect, font preview size, language options. `perf.timings.gpuInfo` holds the graphics adapter, backend, driver, the backend chosen at launch and why, the canvas renderer (`gpu`/`cpu`) and, after a device loss, `lost` (`help.systemInfo` returns the same as `info`)
|
||||
- `ui.set {tool?, panels?, dockTabs?, dock?, dockWidth?, maskTarget?, selectionMode?, zoom?, center?, fit?, theme?, brushSize?, brushSection?, brushTab?}`: change UI state (`selectionMode` is the selection tools' options-bar mode, 0 New, 1 Add, 2 Subtract, 3 Intersect; `brushSection` indexes the Brush Settings sections, `brushTab` 0 = Brush Settings, 1 = Brushes; `dock` is `{order: ["layers", …], heights: {"properties": 180}, collapsed: ["color"]}`, the right-dock groups top to bottom, their heights in points and the groups collapsed to their tab strip; `dockWidth` sets the right dock width in points, clamped to 250..520)
|
||||
- `ui.menu.invoke {id}` / `ui.menu.list`: activate a menu item by id; list the menu tree
|
||||
- `ui.dialog.open {kind, fields?}` (kinds `newDocument`, `about`, `layerStyle {effect?}`, `colorPicker {target: foreground|background}`, `command {command}`) / `ui.dialog.set {dialog, field, value}` / `ui.dialog.confirm {dialog}` / `ui.dialog.cancel {dialog}`
|
||||
@@ -43,6 +43,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i
|
||||
- `ui.key {key, command?, shift?, alt?, ctrl?}` (flags may also be grouped under `modifiers`): press and release a key, e.g. `{"key": "ArrowLeft", "shift": true}`
|
||||
- `ui.type {text}`: type text (goes to the focused widget, or to the canvas while the Type tool is editing)
|
||||
- `ui.resize {width, height}`: resize the main window
|
||||
- `ui.gpu.simulateLoss {error?}`: act as if the wgpu device was lost (or reported an error with `error: true`). The app switches to the CPU renderer for the rest of the session and shows the same notice as on a real loss. Returns `wasActive` and `gpuInfo`. For testing the fallback
|
||||
- `ui.screenshot {path?, focus?}`: capture the main window (PNG). With no path the reply contains
|
||||
base64 PNG data; a path is relative to the automation write root. Raises the window first
|
||||
(default) because occluded macOS windows stop rendering
|
||||
|
||||
@@ -19,6 +19,12 @@ cargo xtask parity # Photoshop menu cove
|
||||
|
||||
Image code is slow at `opt-level 0`, so the workspace profile builds dependencies at `opt-level 2`. Use `--release` for anything interactive.
|
||||
|
||||
## Graphics startup and device loss
|
||||
|
||||
`--safe-gpu` starts with the CPU renderer for one launch (no GPU canvas; a software adapter for the window where the platform has one: WARP on Windows, llvmpipe over GL on Linux). Before creating the wgpu device the app writes and locks `gpu-starting.json` in the config directory; it clears it once the first frames have rendered. A launch that finds an unlocked marker knows the previous start died inside the graphics driver (#4) and uses the next safer backend (Windows: Vulkan → DX12 → CPU; Linux: Vulkan → GL → CPU; macOS: Metal → CPU), remembering it in `performance.gpuBackend` (Preferences › Performance › GPU Backend, with **Reset GPU Backend**). With `auto`, Intel adapters on Windows use DX12. Help › System Info shows the adapter, backend, driver and fallback state.
|
||||
|
||||
If the device is lost while running (#243), every GPU entry point checks the device's health flag first, the canvas switches to the CPU compositor for the rest of the session and a notice says "GPU device was lost; using the CPU renderer." `ui.gpu.simulateLoss` triggers this path from the control channel.
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Effect |
|
||||
@@ -29,6 +35,7 @@ Image code is slow at `opt-level 0`, so the workspace profile builds dependencie
|
||||
| `PHOTOCRAFT_AUTOMATION_READ_ROOT` | Directory capability for automation reads; requests use relative paths |
|
||||
| `PHOTOCRAFT_AUTOMATION_WRITE_ROOT` | Separate directory capability for automation writes; requests use relative paths |
|
||||
| `PHOTOCRAFT_CPU_CANVAS=1` | Force the CPU canvas path instead of the wgpu shader canvas |
|
||||
| `WGPU_BACKEND=dx12` | Pick the wgpu backend(s) (`vulkan`, `dx12`, `metal`, `gl`); overrides `performance.gpuBackend` and the startup fallback |
|
||||
| `PHOTOCRAFT_GPU_TILE=2048` | Force GPU canvas tiling (tests tile seams) |
|
||||
| `PHOTOCRAFT_FX_NOCACHE=1` | Bypass the CPU layer-effect map cache (`compose::effect_maps`) |
|
||||
| `PHOTOCRAFT_CPU_COMPOSE=1` | Keep the wgpu canvas but composite on the CPU (compare GPU vs CPU renders, e.g. with the snapshot example) |
|
||||
|
||||
+2
-2
@@ -9,7 +9,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js
|
||||
| [Performance](#performance) | 3 | 22 | 10 | 44 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 0 not run, 10 not measurable yet (baseline 2026-10-05) |
|
||||
| [File compatibility](#file-compatibility) | 0 | 6 | 21 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 229/309, round trip ≥ 307/309 |
|
||||
| [Tools](#tools) | 0 | 16 | 14 | |
|
||||
| [Workspace and UI](#workspace-and-ui) | 0 | 3 | 20 | settings that do nothing: 61 of 129 (target 0) |
|
||||
| [Workspace and UI](#workspace-and-ui) | 0 | 3 | 20 | settings that do nothing: 61 of 130 (target 0) |
|
||||
| [Type](#type) | 0 | 3 | 6 | |
|
||||
| [Automation](#automation) | 1 | 3 | 7 | 626 of 626 menu items live (100.0%) |
|
||||
| [Reliability](#reliability) | 2 | 1 | 4 | never-crash lints on 25 of 25 crates; panic_hunt present |
|
||||
@@ -150,7 +150,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t
|
||||
|
||||
## Workspace and UI
|
||||
|
||||
**Settings that do nothing: 61 of 129** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't.
|
||||
**Settings that do nothing: 61 of 130** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't.
|
||||
|
||||
Unread: `general.color_picker`, `general.beep_when_done`, `general.export_clipboard`, `general.resize_image_during_place`, `general.always_create_smart_objects_when_placing`, `general.animated_zoom`, `general.zoom_resizes_windows`, `general.use_legacy_free_transform`, `interface.ui_font_size`, `interface.show_channels_in_color`, `interface.dynamic_color_sliders`, `workspace.auto_collapse_icon_panels`, `workspace.auto_show_hidden_panels`, `workspace.open_documents_as_tabs`, `workspace.enable_floating_document_window_docking`, `workspace.large_tabs`, `workspace.enable_narrow_options_bar`, `tools.zoom_clicked_point_to_center`, `tools.enable_flick_panning`, `tools.vary_round_brush_hardness_on_hud`, `tools.show_transformation_values`, `tools.overscroll`, `tools.double_click_layer_mask_launches_select_and_mask`, `file_handling.image_previews`, `file_handling.lowercase_extension`, `file_handling.save_in_background`, `file_handling.ignore_exif_profile_tag`, `file_handling.ask_before_saving_layered_tiff`, `file_handling.maximize_psd_compatibility`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `cursors.brush_preview_color`, `units_and_rulers.type_units`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `units_and_rulers.print_resolution`, `units_and_rulers.screen_resolution`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.smart_quotes`, `type_.missing_glyph_protection`, `type_.show_font_names_in_english`, `type_.use_esc_to_commit`, `type_.text_engine`, `type_.font_preview`, `type_.fill_new_type_layers_with_placeholder`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `enhanced_controls.zoom_with_trackpad_pinch`, `enhanced_controls.rotate_view_with_trackpad`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`.
|
||||
|
||||
|
||||
Reference in new issue
Block a user