mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-14 19:27:01 -04:00
6 files changed
+546
-70
No files matched your search
Generated
+1
@@ -3078,6 +3078,7 @@ dependencies = [
|
||||
"lightcraft-ui-egui",
|
||||
"log",
|
||||
"muda",
|
||||
"pollster 1.0.1",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
"winresource",
|
||||
|
||||
@@ -23,9 +23,11 @@ lightcraft-sysmem = { workspace = true }
|
||||
lightcraft-ui-egui = { workspace = true }
|
||||
lightcraft-codecs = { workspace = true }
|
||||
lightcraft-raster = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "glow", "accesskit", "wayland", "x11"] }
|
||||
egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
# probing the window's renderer before it opens (`window_gpu`)
|
||||
pollster = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
[build-dependencies]
|
||||
|
||||
+93
-69
@@ -28,6 +28,7 @@ mod dialog_filter;
|
||||
mod logging;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod native_menu;
|
||||
mod window_gpu;
|
||||
|
||||
use dialog_filter::FileDialogExt as _;
|
||||
use lightcraft_engine::Session;
|
||||
@@ -71,7 +72,7 @@ impl eframe::App for App {
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
self.0.ui(ui);
|
||||
}
|
||||
fn on_exit(&mut self) {
|
||||
fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {
|
||||
if let Err(e) = self.1.save(&self.0) {
|
||||
log::error!("{e}");
|
||||
}
|
||||
@@ -93,15 +94,16 @@ const EXIT_WAIT: std::time::Duration = std::time::Duration::from_secs(2);
|
||||
/// alone on Windows unless `LIGHTCRAFT_GPU_BACKEND` / `WGPU_BACKEND` say otherwise (issue #136:
|
||||
/// with Vulkan in the set, wgpu loads the Vulkan driver even when it then picks DX12) — and DX12
|
||||
/// shaders compiled with FXC, never a stray `dxcompiler.dll` (issue #471).
|
||||
/// The fallback chain on top of it (DX12 → Vulkan → GL → glow) is in `window_gpu`.
|
||||
#[cfg(test)]
|
||||
fn window_wgpu_options() -> eframe::egui_wgpu::WgpuConfiguration {
|
||||
let mut c = eframe::egui_wgpu::WgpuConfiguration::default();
|
||||
if let eframe::egui_wgpu::WgpuSetup::CreateNew(n) = &mut c.wgpu_setup {
|
||||
n.instance_descriptor.backends = lightcraft_engine::gpu::backend::window_backends();
|
||||
n.instance_descriptor.backend_options = lightcraft_engine::gpu::backend::backend_options();
|
||||
// no indirect-call validation pipeline: Intel Metal fails to compile it and the window never opens (#250)
|
||||
n.instance_descriptor.flags = lightcraft_engine::gpu::backend::instance_flags_from_env(n.instance_descriptor.flags);
|
||||
}
|
||||
c
|
||||
window_gpu::base_options()
|
||||
}
|
||||
|
||||
/// Where the window's renderer files live (`window_gpu::Files`): nothing for `--memory` sessions
|
||||
/// and `LIGHTCRAFT_NO_PREFS`.
|
||||
fn window_files(in_memory: bool) -> window_gpu::Files {
|
||||
window_gpu::Files::new(config_dir().filter(|_| !in_memory && std::env::var_os("LIGHTCRAFT_NO_PREFS").is_none()))
|
||||
}
|
||||
|
||||
/// Written while the GPU compute device is created, removed once that returned
|
||||
@@ -773,7 +775,14 @@ fn main() -> eframe::Result {
|
||||
let gpu_crash = gpu_crash_check(in_memory);
|
||||
let gpu_on = prefs.as_ref().is_none_or(|u| u.settings.gpu) && gpu_crash.is_none();
|
||||
lightcraft_engine::gpu::set_enabled(gpu_on);
|
||||
// the window's renderer: probed (Windows) and with a fallback chain, so a driver that can't
|
||||
// build egui's pipeline never kills the start-up
|
||||
let win_files = std::sync::Arc::new(window_files(in_memory));
|
||||
let window_gpu::Chosen { renderer, wgpu_options, which: window_renderer, notice: window_notice } = window_gpu::choose(&win_files);
|
||||
win_files.arm(window_renderer);
|
||||
let win_files_app = win_files.clone();
|
||||
let options = eframe::NativeOptions {
|
||||
renderer,
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("LightCraft")
|
||||
.with_inner_size([1600.0, 1000.0])
|
||||
@@ -785,73 +794,88 @@ fn main() -> eframe::Result {
|
||||
.with_icon(app_icon())
|
||||
// Wayland matches the window to packaging/linux/ai.storyteller.lightcraft.desktop by this id
|
||||
.with_app_id(APP_ID),
|
||||
wgpu_options: window_wgpu_options(),
|
||||
wgpu_options,
|
||||
..Default::default()
|
||||
};
|
||||
// the app's copy: `log_file` is still named in the message of a start that fails
|
||||
let app_log_file = log_file.clone();
|
||||
let started = eframe::run_native(
|
||||
"LightCraft",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
let (mut session, problem) = open_session(in_memory, library_dir, seed_demo && files.is_empty());
|
||||
// AI masks: the SAM 3 checkpoint (facebook/sam3) in <config>/models/sam3, or LIGHTCRAFT_SAM3_DIR
|
||||
// (never required: without it, AI masks offer to download it; see docs/ai-masks.md)
|
||||
session.segmenter.dir =
|
||||
std::env::var_os("LIGHTCRAFT_SAM3_DIR").map(std::path::PathBuf::from).or_else(|| config_dir().map(|d| d.join("models").join("sam3")));
|
||||
// the user's own download locations, one base URL per line (LIGHTCRAFT_SAM3_MIRRORS too)
|
||||
session.segmenter.mirrors_file = config_dir().map(|d| d.join("models").join("sam3-mirrors.txt"));
|
||||
let mut app = LightcraftApp::new(session, services(cc.egui_ctx.clone(), app_log_file.as_deref()));
|
||||
if let Some(ui) = prefs {
|
||||
app.ui = ui;
|
||||
}
|
||||
lightcraft_ui_egui::i18n::set_language(app.ui.language);
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
app.notices.extend(prefs_warning);
|
||||
app.notices.extend(launch_notices);
|
||||
// what's on disk now: only changes are written
|
||||
let writer = PrefsWriter {
|
||||
path: if in_memory { None } else { prefs_path() },
|
||||
written: serde_json::to_vec_pretty(&app.ui).unwrap_or_default(),
|
||||
library: app.ui.settings.library_path.clone(),
|
||||
keep_file: keep_prefs_file,
|
||||
..Default::default()
|
||||
};
|
||||
// after the snapshot above, so the writer saves the switched-off preference
|
||||
if let Some(notice) = gpu_crash {
|
||||
app.ui.settings.gpu = false;
|
||||
app.notices.push(notice);
|
||||
}
|
||||
if let Some(port) = control_port {
|
||||
let rx = control_server::start(port, cc.egui_ctx.clone());
|
||||
app = app.with_control(rx);
|
||||
}
|
||||
if let Some(mut p) = problem {
|
||||
// imported once the user has chosen where (into the library, or the temporary session)
|
||||
p.pending_import = files;
|
||||
app.library_problem = Some(p);
|
||||
} else if !files.is_empty() {
|
||||
// listed and read on the import worker once the window shows (issue #374: never
|
||||
// here, before the first frame, where a large folder kept the window from
|
||||
// appearing)
|
||||
if let Err(e) = lightcraft_ui_egui::import::start_paths(&mut app, files) {
|
||||
log::warn!("import at launch: {e}");
|
||||
let started = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
eframe::run_native(
|
||||
"LightCraft",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
// the window's renderer is up (eframe calls this after creating it)
|
||||
win_files_app.started(window_renderer);
|
||||
let (mut session, problem) = open_session(in_memory, library_dir, seed_demo && files.is_empty());
|
||||
// AI masks: the SAM 3 checkpoint (facebook/sam3) in <config>/models/sam3, or LIGHTCRAFT_SAM3_DIR
|
||||
// (never required: without it, AI masks offer to download it; see docs/ai-masks.md)
|
||||
session.segmenter.dir = std::env::var_os("LIGHTCRAFT_SAM3_DIR")
|
||||
.map(std::path::PathBuf::from)
|
||||
.or_else(|| config_dir().map(|d| d.join("models").join("sam3")));
|
||||
// the user's own download locations, one base URL per line (LIGHTCRAFT_SAM3_MIRRORS too)
|
||||
session.segmenter.mirrors_file = config_dir().map(|d| d.join("models").join("sam3-mirrors.txt"));
|
||||
let mut app = LightcraftApp::new(session, services(cc.egui_ctx.clone(), app_log_file.as_deref()));
|
||||
if let Some(ui) = prefs {
|
||||
app.ui = ui;
|
||||
}
|
||||
app.ui.view = lightcraft_ui_egui::state::ViewMode::PhotoGrid;
|
||||
}
|
||||
// native menu bar generated from the command registry (macOS; elsewhere the menus are
|
||||
// drawn in the window's top bar). LIGHTCRAFT_NO_NATIVE_MENU=1 keeps the in-window menus.
|
||||
#[cfg(target_os = "macos")]
|
||||
let menu = (std::env::var_os("LIGHTCRAFT_NO_NATIVE_MENU").is_none()).then(|| native_menu::NativeMenu::install(&mut app, &cc.egui_ctx));
|
||||
Ok(Box::new(App(
|
||||
app,
|
||||
writer,
|
||||
lightcraft_ui_egui::i18n::set_language(app.ui.language);
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
app.notices.extend(prefs_warning);
|
||||
app.notices.extend(launch_notices);
|
||||
app.notices.extend(window_notice);
|
||||
// what's on disk now: only changes are written
|
||||
let writer = PrefsWriter {
|
||||
path: if in_memory { None } else { prefs_path() },
|
||||
written: serde_json::to_vec_pretty(&app.ui).unwrap_or_default(),
|
||||
library: app.ui.settings.library_path.clone(),
|
||||
keep_file: keep_prefs_file,
|
||||
..Default::default()
|
||||
};
|
||||
// after the snapshot above, so the writer saves the switched-off preference
|
||||
if let Some(notice) = gpu_crash {
|
||||
app.ui.settings.gpu = false;
|
||||
app.notices.push(notice);
|
||||
}
|
||||
if let Some(port) = control_port {
|
||||
let rx = control_server::start(port, cc.egui_ctx.clone());
|
||||
app = app.with_control(rx);
|
||||
}
|
||||
if let Some(mut p) = problem {
|
||||
// imported once the user has chosen where (into the library, or the temporary session)
|
||||
p.pending_import = files;
|
||||
app.library_problem = Some(p);
|
||||
} else if !files.is_empty() {
|
||||
// listed and read on the import worker once the window shows (issue #374: never
|
||||
// here, before the first frame, where a large folder kept the window from
|
||||
// appearing)
|
||||
if let Err(e) = lightcraft_ui_egui::import::start_paths(&mut app, files) {
|
||||
log::warn!("import at launch: {e}");
|
||||
}
|
||||
app.ui.view = lightcraft_ui_egui::state::ViewMode::PhotoGrid;
|
||||
}
|
||||
// native menu bar generated from the command registry (macOS; elsewhere the menus are
|
||||
// drawn in the window's top bar). LIGHTCRAFT_NO_NATIVE_MENU=1 keeps the in-window menus.
|
||||
#[cfg(target_os = "macos")]
|
||||
menu,
|
||||
)))
|
||||
}),
|
||||
);
|
||||
let menu =
|
||||
(std::env::var_os("LIGHTCRAFT_NO_NATIVE_MENU").is_none()).then(|| native_menu::NativeMenu::install(&mut app, &cc.egui_ctx));
|
||||
Ok(Box::new(App(
|
||||
app,
|
||||
writer,
|
||||
#[cfg(target_os = "macos")]
|
||||
menu,
|
||||
)))
|
||||
}),
|
||||
)
|
||||
}));
|
||||
// a panic that still escaped the renderer's start (the probe and error handler should
|
||||
// prevent it): an error like any other failed start
|
||||
let started = started.unwrap_or_else(|_| Err(eframe::Error::AppCreation("the window's renderer panicked while starting".into())));
|
||||
if let Err(e) = &started {
|
||||
// the renderer never came up: start again, which tries the next one in the chain
|
||||
if window_gpu::relaunch_after_failure(&win_files) {
|
||||
log::error!("LightCraft could not open its window with {}: {e}; trying the next renderer", window_renderer.name());
|
||||
return Ok(());
|
||||
}
|
||||
startup_failed(&e.to_string(), log_file.as_deref());
|
||||
}
|
||||
started
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
//! The window's renderer and its fallback chain (crash report: on some Windows machines FXC fails
|
||||
//! to compile egui's own shaders on DX12 — "The parameter is incorrect. (0x80070057)" in
|
||||
//! `egui_pipeline` — and wgpu's default error handler panicked, killing the app at start-up).
|
||||
//!
|
||||
//! - **Windows:** each renderer of [`backend::window_renderer_order`] (DX12, Vulkan, GL, then
|
||||
//! eframe's glow) is *probed* before the window opens: a device is created with an error handler
|
||||
//! that logs instead of panicking, and egui's pipeline is built inside error scopes. The first
|
||||
//! that builds cleanly is handed to eframe as an existing device (so its error handler stays).
|
||||
//! - **Everywhere:** a marker file names the renderer being started and is removed once the
|
||||
//! window's renderer is up. Left behind — the start died or panicked — the next launch tries that
|
||||
//! renderer last; [`relaunch_after_failure`] starts that next launch right away.
|
||||
//! - The renderer that worked is remembered (`<config>/window-renderer`, Windows only) and tried
|
||||
//! first next time. `LIGHTCRAFT_GPU_BACKEND=dx12|vulkan|gl|glow` (or `WGPU_BACKEND`) forces one.
|
||||
|
||||
use eframe::egui_wgpu::{WgpuConfiguration, WgpuSetup, WgpuSetupExisting};
|
||||
use eframe::wgpu;
|
||||
use lightcraft_engine::gpu::backend::{self, Backends, WindowRenderer};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Environment variable counting automatic relaunches after a failed start (bounds the chain).
|
||||
const RETRY_VAR: &str = "LIGHTCRAFT_WINDOW_RETRY";
|
||||
/// Never more relaunches than renderers to try.
|
||||
const MAX_RETRIES: u32 = 4;
|
||||
|
||||
/// The window's renderer for this start.
|
||||
pub struct Chosen {
|
||||
pub renderer: eframe::Renderer,
|
||||
pub wgpu_options: WgpuConfiguration,
|
||||
pub which: WindowRenderer,
|
||||
/// For the user: the last start failed with another renderer, or one failed now.
|
||||
pub notice: Option<String>,
|
||||
}
|
||||
|
||||
/// Files under the settings folder (`None` dir: write nothing — `--memory`, `LIGHTCRAFT_NO_PREFS`).
|
||||
pub struct Files {
|
||||
dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Files {
|
||||
pub fn new(dir: Option<PathBuf>) -> Self {
|
||||
Self { dir }
|
||||
}
|
||||
fn marker(&self) -> Option<PathBuf> {
|
||||
self.dir.as_ref().map(|d| d.join("window-init.marker"))
|
||||
}
|
||||
fn remembered(&self) -> Option<PathBuf> {
|
||||
self.dir.as_ref().map(|d| d.join("window-renderer"))
|
||||
}
|
||||
fn read(p: Option<PathBuf>) -> Option<WindowRenderer> {
|
||||
std::fs::read_to_string(p?).ok().as_deref().and_then(WindowRenderer::parse)
|
||||
}
|
||||
fn write(p: Option<PathBuf>, r: WindowRenderer) {
|
||||
let Some(p) = p else { return };
|
||||
if let Some(d) = p.parent() {
|
||||
let _ = std::fs::create_dir_all(d);
|
||||
}
|
||||
if let Err(e) = std::fs::write(&p, format!("{}\n", r.name())) {
|
||||
log::warn!("window: could not write {}: {e}", p.display());
|
||||
}
|
||||
}
|
||||
/// The renderer the last start died in (marker left behind), removing the marker.
|
||||
pub fn take_failed(&self) -> Option<WindowRenderer> {
|
||||
let m = self.marker()?;
|
||||
let r = Self::read(Some(m.clone()));
|
||||
let _ = std::fs::remove_file(m);
|
||||
r
|
||||
}
|
||||
/// Before the window opens: record what is being started.
|
||||
pub fn arm(&self, r: WindowRenderer) {
|
||||
Self::write(self.marker(), r);
|
||||
}
|
||||
/// The window's renderer is up: clear the marker and (Windows) remember it.
|
||||
pub fn started(&self, r: WindowRenderer) {
|
||||
if let Some(m) = self.marker() {
|
||||
let _ = std::fs::remove_file(m);
|
||||
}
|
||||
if cfg!(windows) {
|
||||
Self::write(self.remembered(), r);
|
||||
}
|
||||
}
|
||||
/// The marker is still there: the window never came up.
|
||||
pub fn armed(&self) -> bool {
|
||||
self.marker().is_some_and(|m| m.exists())
|
||||
}
|
||||
}
|
||||
|
||||
/// The renderer for this start: see the module docs.
|
||||
pub fn choose(files: &Files) -> Chosen {
|
||||
let forced =
|
||||
backend::forced_window_renderer(std::env::var("LIGHTCRAFT_GPU_BACKEND").ok().as_deref(), std::env::var("WGPU_BACKEND").ok().as_deref());
|
||||
let failed = files.take_failed();
|
||||
let remembered = if cfg!(windows) { Files::read(files.remembered()) } else { None };
|
||||
let compiled = wgpu::Instance::enabled_backend_features();
|
||||
let os = std::env::consts::OS;
|
||||
let order = backend::window_renderer_order(forced, remembered, failed, os, compiled, true);
|
||||
let mut notice = failed.map(|f| format!("The window could not start with its {} renderer last time.", f.name()));
|
||||
let mut problems = Vec::new();
|
||||
let chosen = if cfg!(windows) {
|
||||
pick_probed(&order, &mut problems)
|
||||
} else if forced == Some(WindowRenderer::Glow) || (forced.is_none() && failed.is_some_and(|f| f != WindowRenderer::Glow)) {
|
||||
// elsewhere eframe's own wgpu set-up stays the default; glow after a failed start
|
||||
Some((WindowRenderer::Glow, None))
|
||||
} else {
|
||||
Some((WindowRenderer::Wgpu(backend::window_backends()), None))
|
||||
};
|
||||
let (which, existing) = chosen.unwrap_or((WindowRenderer::Glow, None));
|
||||
if !problems.is_empty() {
|
||||
notice = Some(format!("{} {}", notice.unwrap_or_default(), problems.join(" ")).trim().to_string());
|
||||
}
|
||||
let notice = notice.map(|n| {
|
||||
format!(
|
||||
"{n} {app} now draws its window with {}. To choose another, start {app} with {var}=dx12, vulkan, gl or glow \
|
||||
(see docs/gpu-pipeline.md → Troubleshooting).",
|
||||
which.name(),
|
||||
app = "LightCraft",
|
||||
var = "LIGHTCRAFT_GPU_BACKEND",
|
||||
)
|
||||
});
|
||||
log::info!("window: renderer {} (order {:?})", which.name(), order.iter().map(|r| r.name()).collect::<Vec<_>>());
|
||||
let mut wgpu_options = base_options();
|
||||
let renderer = match (which, existing) {
|
||||
(WindowRenderer::Glow, _) => eframe::Renderer::Glow,
|
||||
(WindowRenderer::Wgpu(_), Some(e)) => {
|
||||
wgpu_options.wgpu_setup = WgpuSetup::Existing(e);
|
||||
eframe::Renderer::Wgpu
|
||||
}
|
||||
(WindowRenderer::Wgpu(b), None) => {
|
||||
if let WgpuSetup::CreateNew(n) = &mut wgpu_options.wgpu_setup {
|
||||
n.instance_descriptor.backends = b;
|
||||
}
|
||||
eframe::Renderer::Wgpu
|
||||
}
|
||||
};
|
||||
Chosen { renderer, wgpu_options, which, notice }
|
||||
}
|
||||
|
||||
/// eframe's defaults with the app's backends and DX12 shader compiler (issues #136, #471).
|
||||
pub fn base_options() -> WgpuConfiguration {
|
||||
let mut c = WgpuConfiguration::default();
|
||||
if let WgpuSetup::CreateNew(n) = &mut c.wgpu_setup {
|
||||
n.instance_descriptor.backends = backend::window_backends();
|
||||
n.instance_descriptor.backend_options = backend::backend_options();
|
||||
// no indirect-call validation pipeline: Intel Metal fails to compile it and the window never opens (#250)
|
||||
n.instance_descriptor.flags = backend::instance_flags_from_env(n.instance_descriptor.flags);
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
/// The first renderer of `order` that probes cleanly (glow is taken without a probe).
|
||||
fn pick_probed(order: &[WindowRenderer], problems: &mut Vec<String>) -> Option<(WindowRenderer, Option<WgpuSetupExisting>)> {
|
||||
for &r in order {
|
||||
match r {
|
||||
WindowRenderer::Glow => return Some((r, None)),
|
||||
WindowRenderer::Wgpu(b) => {
|
||||
let probed = std::panic::catch_unwind(|| probe(b)).unwrap_or_else(|_| Err("panicked".into()));
|
||||
match probed {
|
||||
Ok(e) => return Some((r, Some(e))),
|
||||
Err(e) => {
|
||||
log::error!("window: the {} renderer failed: {e}", r.name());
|
||||
problems.push(format!("The {} renderer failed ({e}).", r.name()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Create a device on `backends` and build egui's pipeline on it; any wgpu error is returned.
|
||||
fn probe(backends: Backends) -> Result<WgpuSetupExisting, String> {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
|
||||
backends,
|
||||
flags: backend::instance_flags_from_env(wgpu::InstanceFlags::from_build_config().with_env()),
|
||||
backend_options: backend::backend_options(),
|
||||
memory_budget_thresholds: wgpu::MemoryBudgetThresholds::default(),
|
||||
display: None,
|
||||
});
|
||||
let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
|
||||
power_preference: wgpu::PowerPreference::from_env().unwrap_or(wgpu::PowerPreference::HighPerformance),
|
||||
..Default::default()
|
||||
}))
|
||||
.map_err(|e| format!("no adapter: {e}"))?;
|
||||
let base = if adapter.get_info().backend == wgpu::Backend::Gl { wgpu::Limits::downlevel_webgl2_defaults() } else { wgpu::Limits::default() };
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
|
||||
label: Some("egui wgpu device"),
|
||||
required_limits: wgpu::Limits { max_texture_dimension_2d: 8192, ..base },
|
||||
..Default::default()
|
||||
}))
|
||||
.map_err(|e| format!("{}: no device: {e}", adapter.get_info().name))?;
|
||||
install_error_handler(&device);
|
||||
check_pipeline(&device, |d| {
|
||||
drop(eframe::egui_wgpu::Renderer::new(d, wgpu::TextureFormat::Bgra8Unorm, eframe::egui_wgpu::RendererOptions::default()));
|
||||
})?;
|
||||
Ok(WgpuSetupExisting { instance, adapter, device, queue })
|
||||
}
|
||||
|
||||
/// wgpu's default handler for errors outside an error scope panics ("Handling wgpu errors as
|
||||
/// fatal by default"); this one logs.
|
||||
pub fn install_error_handler(device: &wgpu::Device) {
|
||||
device.on_uncaptured_error(std::sync::Arc::new(|e| log::error!("window: wgpu error: {e}")));
|
||||
}
|
||||
|
||||
/// Run `build` inside validation, internal and out-of-memory error scopes: the first error is
|
||||
/// returned instead of reaching the uncaptured-error handler.
|
||||
pub fn check_pipeline(device: &wgpu::Device, build: impl FnOnce(&wgpu::Device)) -> Result<(), String> {
|
||||
let oom = device.push_error_scope(wgpu::ErrorFilter::OutOfMemory);
|
||||
let internal = device.push_error_scope(wgpu::ErrorFilter::Internal);
|
||||
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
build(device);
|
||||
let errors = [pollster::block_on(validation.pop()), pollster::block_on(internal.pop()), pollster::block_on(oom.pop())];
|
||||
match errors.into_iter().flatten().next() {
|
||||
Some(e) => Err(e.to_string()),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The start failed (error or panic) before the window's renderer came up: start the app again,
|
||||
/// which then tries the next renderer. `false`: not relaunched (it came up, nothing written to
|
||||
/// disk, or the retries are used up).
|
||||
pub fn relaunch_after_failure(files: &Files) -> bool {
|
||||
if !files.armed() {
|
||||
return false;
|
||||
}
|
||||
let tries = std::env::var(RETRY_VAR).ok().and_then(|v| v.parse::<u32>().ok()).unwrap_or(0);
|
||||
if tries >= MAX_RETRIES {
|
||||
return false;
|
||||
}
|
||||
let Ok(exe) = std::env::current_exe() else { return false };
|
||||
log::warn!("window: the renderer failed to start; starting again with the next one");
|
||||
std::process::Command::new(exe)
|
||||
.args(std::env::args_os().skip(1))
|
||||
.env(RETRY_VAR, (tries + 1).to_string())
|
||||
.spawn()
|
||||
.map_err(|e| log::error!("window: could not start again: {e}"))
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn dir(tag: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("app-window-gpu-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
d
|
||||
}
|
||||
|
||||
/// The marker names the renderer being started; left behind, the next start reads it as the
|
||||
/// failed one (and removes it); a good start removes it and remembers the renderer (Windows).
|
||||
#[test]
|
||||
fn marker_records_the_failed_renderer() {
|
||||
let d = dir("marker");
|
||||
let f = Files::new(Some(d.clone()));
|
||||
let dx = WindowRenderer::Wgpu(Backends::DX12);
|
||||
f.arm(dx);
|
||||
assert!(f.armed());
|
||||
assert_eq!(f.take_failed(), Some(dx));
|
||||
assert!(!f.armed());
|
||||
assert_eq!(f.take_failed(), None);
|
||||
f.arm(WindowRenderer::Glow);
|
||||
f.started(WindowRenderer::Glow);
|
||||
assert!(!f.armed());
|
||||
assert_eq!(Files::read(f.remembered()), cfg!(windows).then_some(WindowRenderer::Glow));
|
||||
// nothing written without a settings folder
|
||||
let none = Files::new(None);
|
||||
none.arm(dx);
|
||||
assert!(!none.armed() && !relaunch_after_failure(&none));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// The crash report: a pipeline that fails validation must come back as an error, never as
|
||||
/// wgpu's default panic. Needs an adapter; skipped where there is none (CI without a GPU).
|
||||
#[test]
|
||||
fn pipeline_errors_are_captured_not_panics() {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
|
||||
backends: backend::window_backends(),
|
||||
..wgpu::InstanceDescriptor::new_without_display_handle()
|
||||
});
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())) else {
|
||||
eprintln!("no GPU adapter: skipped");
|
||||
return;
|
||||
};
|
||||
let Ok((device, _queue)) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())) else {
|
||||
eprintln!("no GPU device: skipped");
|
||||
return;
|
||||
};
|
||||
install_error_handler(&device);
|
||||
// a shader that does not validate, then a pipeline using it (like a failed FXC compile)
|
||||
let r = check_pipeline(&device, |d| {
|
||||
let m = d.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("broken"),
|
||||
source: wgpu::ShaderSource::Wgsl("@vertex fn vs_main() -> @builtin(position) vec4<f32> { return undefined_thing; }".into()),
|
||||
});
|
||||
let _ = d.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("egui_pipeline"),
|
||||
layout: None,
|
||||
vertex: wgpu::VertexState { module: &m, entry_point: Some("vs_main"), compilation_options: Default::default(), buffers: &[] },
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: None,
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
});
|
||||
assert!(r.is_err(), "{r:?}");
|
||||
// egui's own pipeline builds cleanly here
|
||||
check_pipeline(&device, |d| {
|
||||
drop(eframe::egui_wgpu::Renderer::new(d, wgpu::TextureFormat::Bgra8Unorm, eframe::egui_wgpu::RendererOptions::default()));
|
||||
})
|
||||
.unwrap();
|
||||
// outside a scope: logged by our handler, not a panic
|
||||
let _ = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: None, source: wgpu::ShaderSource::Wgsl("not wgsl".into()) });
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,8 @@ pub fn parse_backends(s: &str) -> Option<BackendChoice> {
|
||||
"" => None,
|
||||
"auto" | "default" => Some(BackendChoice::Auto),
|
||||
"off" | "none" | "cpu" | "0" | "false" | "no" => Some(BackendChoice::Off),
|
||||
// the window's OpenGL renderer (see `WindowRenderer`): compute keeps its default
|
||||
"glow" => Some(BackendChoice::Auto),
|
||||
_ => {
|
||||
let b = Backends::from_comma_list(&s);
|
||||
(!b.is_empty()).then_some(BackendChoice::Use(b))
|
||||
@@ -211,6 +213,87 @@ pub fn read_init_marker(path: &std::path::Path) -> Option<String> {
|
||||
Some(text.trim().to_string())
|
||||
}
|
||||
|
||||
/// What draws the desktop window: egui-wgpu on one wgpu backend, or eframe's OpenGL renderer
|
||||
/// (glow) as the last resort when no wgpu backend can build the window's pipeline.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum WindowRenderer {
|
||||
/// egui-wgpu on exactly one backend.
|
||||
Wgpu(Backends),
|
||||
/// eframe's glow (OpenGL) renderer.
|
||||
Glow,
|
||||
}
|
||||
|
||||
impl WindowRenderer {
|
||||
/// The name used in `LIGHTCRAFT_GPU_BACKEND`, the remembered-renderer file and the logs.
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
WindowRenderer::Glow => "glow",
|
||||
WindowRenderer::Wgpu(b) if b == Backends::DX12 => "dx12",
|
||||
WindowRenderer::Wgpu(b) if b == Backends::VULKAN => "vulkan",
|
||||
WindowRenderer::Wgpu(b) if b == Backends::METAL => "metal",
|
||||
WindowRenderer::Wgpu(b) if b == Backends::GL => "gl",
|
||||
WindowRenderer::Wgpu(_) => "wgpu",
|
||||
}
|
||||
}
|
||||
|
||||
/// One renderer name (`dx12`, `vulkan`, `metal`, `gl`, `glow`); anything else (lists, `auto`,
|
||||
/// `off`, unknown names) is `None`.
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
let s = s.trim().to_ascii_lowercase();
|
||||
if s == "glow" {
|
||||
return Some(WindowRenderer::Glow);
|
||||
}
|
||||
match parse_backends(&s)? {
|
||||
BackendChoice::Use(b) if [Backends::DX12, Backends::VULKAN, Backends::METAL, Backends::GL].contains(&b) => Some(WindowRenderer::Wgpu(b)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The renderer the environment forces for the window: `LIGHTCRAFT_GPU_BACKEND` (`app`), else
|
||||
/// `WGPU_BACKEND` (`wgpu`), when it names exactly one renderer.
|
||||
pub fn forced_window_renderer(app: Option<&str>, wgpu: Option<&str>) -> Option<WindowRenderer> {
|
||||
app.and_then(WindowRenderer::parse).or_else(|| wgpu.and_then(WindowRenderer::parse))
|
||||
}
|
||||
|
||||
/// The order in which the window's renderers are tried on `os`: the forced one (environment)
|
||||
/// first, then the one remembered from the last good start, then the platform chain — on Windows
|
||||
/// DX12, Vulkan, GL, then glow. DX12 stays first: with Vulkan loaded, a broken Vulkan driver can
|
||||
/// crash the process uncatchably (issue #136), so Vulkan is touched only after DX12 failed.
|
||||
/// `failed` (the renderer the last start died in) goes last, as a last resort only. Backends not
|
||||
/// in `compiled`, and glow without `glow`, are left out; the list is never empty.
|
||||
pub fn window_renderer_order(
|
||||
forced: Option<WindowRenderer>,
|
||||
remembered: Option<WindowRenderer>,
|
||||
failed: Option<WindowRenderer>,
|
||||
os: &str,
|
||||
compiled: Backends,
|
||||
glow: bool,
|
||||
) -> Vec<WindowRenderer> {
|
||||
let platform = match os {
|
||||
"windows" => vec![Backends::DX12, Backends::VULKAN, Backends::GL],
|
||||
"macos" | "ios" => vec![Backends::METAL],
|
||||
_ => vec![Backends::VULKAN, Backends::GL],
|
||||
};
|
||||
let chain = platform.into_iter().map(WindowRenderer::Wgpu).chain([WindowRenderer::Glow]);
|
||||
let usable = |r: &WindowRenderer| match r {
|
||||
WindowRenderer::Glow => glow,
|
||||
WindowRenderer::Wgpu(b) => !b.is_empty() && compiled.contains(*b),
|
||||
};
|
||||
let not_failed = |r: &WindowRenderer| Some(*r) != failed;
|
||||
let candidates = forced.into_iter().chain(remembered.filter(not_failed)).chain(chain.filter(not_failed)).chain(failed);
|
||||
let mut out: Vec<WindowRenderer> = Vec::new();
|
||||
for r in candidates {
|
||||
if usable(&r) && !out.contains(&r) {
|
||||
out.push(r);
|
||||
}
|
||||
}
|
||||
if out.is_empty() {
|
||||
out.push(WindowRenderer::Wgpu(default_window_backends(os) & compiled));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
@@ -310,4 +393,44 @@ mod tests {
|
||||
assert_eq!(read_init_marker(&m), None);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The window's renderer fallback: forced, remembered, the platform chain, the failed one last.
|
||||
#[test]
|
||||
fn window_renderer_fallback_order() {
|
||||
use WindowRenderer::{Glow, Wgpu};
|
||||
let all = Backends::DX12 | Backends::VULKAN | Backends::GL | Backends::METAL;
|
||||
let (dx, vk, gl) = (Wgpu(Backends::DX12), Wgpu(Backends::VULKAN), Wgpu(Backends::GL));
|
||||
assert_eq!(window_renderer_order(None, None, None, "windows", all, true), [dx, vk, gl, Glow]);
|
||||
assert_eq!(window_renderer_order(None, None, None, "windows", all, false), [dx, vk, gl]);
|
||||
// a build without GL
|
||||
assert_eq!(window_renderer_order(None, None, None, "windows", Backends::DX12 | Backends::VULKAN, true), [dx, vk, Glow]);
|
||||
// remembered from the last good start goes first
|
||||
assert_eq!(window_renderer_order(None, Some(vk), None, "windows", all, true), [vk, dx, gl, Glow]);
|
||||
// the environment wins over the remembered one
|
||||
assert_eq!(window_renderer_order(Some(gl), Some(vk), None, "windows", all, true), [gl, vk, dx, Glow]);
|
||||
assert_eq!(window_renderer_order(Some(Glow), None, None, "windows", all, true)[0], Glow);
|
||||
// the last start died in DX12: tried last only (also when it was remembered)
|
||||
assert_eq!(window_renderer_order(None, Some(dx), Some(dx), "windows", all, true), [vk, gl, Glow, dx]);
|
||||
// a forced backend that is not in the build is skipped
|
||||
assert_eq!(window_renderer_order(Some(Wgpu(Backends::METAL)), None, None, "windows", Backends::DX12, false), [dx]);
|
||||
// never empty
|
||||
assert!(!window_renderer_order(None, None, None, "windows", Backends::empty(), false).is_empty());
|
||||
assert_eq!(window_renderer_order(None, None, None, "macos", all, true), [Wgpu(Backends::METAL), Glow]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_renderer_names_round_trip() {
|
||||
for n in ["dx12", "vulkan", "metal", "gl", "glow"] {
|
||||
assert_eq!(WindowRenderer::parse(n).map(WindowRenderer::name), Some(n));
|
||||
}
|
||||
assert_eq!(WindowRenderer::parse(" DX12 "), Some(WindowRenderer::Wgpu(Backends::DX12)));
|
||||
for n in ["auto", "off", "vulkan,gl", "", "bogus"] {
|
||||
assert_eq!(WindowRenderer::parse(n), None, "{n}");
|
||||
}
|
||||
assert_eq!(forced_window_renderer(Some("glow"), Some("dx12")), Some(WindowRenderer::Glow));
|
||||
assert_eq!(forced_window_renderer(Some("auto"), Some("vulkan")), Some(WindowRenderer::Wgpu(Backends::VULKAN)));
|
||||
assert_eq!(forced_window_renderer(None, None), None);
|
||||
// `glow` only concerns the window: compute keeps its default, without a warning
|
||||
assert_eq!(parse_backends("glow"), Some(BackendChoice::Auto));
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,16 @@ harmless: rendering is then on the CPU until the box is checked again. The senti
|
||||
compute device; a crash while the window's renderer starts is avoided by the backend defaults above
|
||||
or worked around with `LIGHTCRAFT_GPU_BACKEND`.
|
||||
|
||||
**The window's renderer and its fallback (Windows).** Before the window opens, each renderer is probed in
|
||||
order: DX12, Vulkan, GL, then eframe's OpenGL renderer (glow). A probe creates a device and builds egui's
|
||||
pipeline inside wgpu error scopes, so a driver that cannot compile egui's shaders ("The parameter is
|
||||
incorrect. (0x80070057)" in `egui_pipeline`) is logged and skipped instead of killing the app. The renderer
|
||||
that worked is remembered in the settings folder (`window-renderer`); a start that dies before the window is up
|
||||
leaves `window-init.marker`, and the next start (relaunched automatically, at most 4 times) tries that
|
||||
renderer last. `LIGHTCRAFT_GPU_BACKEND=dx12 | vulkan | gl | glow` (or `WGPU_BACKEND`) forces one; DX12 stays
|
||||
first because a broken Vulkan driver can crash the process uncatchably (#136). Compute keeps its own
|
||||
device and falls back to the CPU as before.
|
||||
|
||||
**Troubleshooting a crash at startup (Windows).** Start LightCraft from a `.cmd` file or a terminal
|
||||
with `set LIGHTCRAFT_GPU_BACKEND=dx12` (the default since #136), or `=off` to keep the GPU out of
|
||||
rendering; `set VK_LOADER_DRIVERS_DISABLE=*igvk64*` (Vulkan loader) hides a specific Vulkan driver
|
||||
|
||||
Reference in new issue
Block a user