From e6a95615a091c3f63a94563a5ef2edd24ab3a908 Mon Sep 17 00:00:00 2001 From: Tobias Breckle <25928171+tbreckle@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:46:54 +0200 Subject: [PATCH] fix(ui): start with an anti-aliasing the graphics device can't do (#329) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Choosing 8x anti-aliasing in Preferences › Graphics on a device that only allows 1x and 4x for the window's format made every later start panic in wgpu's pipeline validation (`Sample count 8 is not supported by format Bgra8Unorm on this device`), so the app could not be opened again without editing graphics.json by hand. - egui-wgpu (vendored): request TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES when the adapter has it (WebGPU only guarantees 1x and 4x), and clamp the sample count to the largest one the device supports for both the colour target and the depth/stencil format. The supported counts are exposed as `RenderState::msaa_supported`. - At start, a saved count the device can't do goes back to the default (4x, or the largest supported count below it), is written back, and a "Settings were changed" notice says what changed. - The Anti-aliasing list greys out unsupported counts, `ui.prefs` rejects them, and an invalid saved `msaa` loads as the default. - New messages translated in en, de, ja and ko. Co-authored-by: Claude Opus 5.5 --- apps/solvecraft/src/main.rs | 2 + crates/ui-egui/src/lib.rs | 14 ++- crates/ui-egui/src/locales/de.json | 7 ++ crates/ui-egui/src/locales/en.json | 7 ++ crates/ui-egui/src/locales/ja.json | 7 ++ crates/ui-egui/src/locales/ko.json | 7 ++ crates/ui-egui/src/prefs.rs | 141 ++++++++++++++++++++++++++++- vendor/README.md | 6 ++ vendor/egui-wgpu/src/lib.rs | 89 +++++++++++++++++- vendor/egui-wgpu/src/setup.rs | 4 + 10 files changed, 277 insertions(+), 7 deletions(-) diff --git a/apps/solvecraft/src/main.rs b/apps/solvecraft/src/main.rs index a1a56dcb..531f5212 100644 --- a/apps/solvecraft/src/main.rs +++ b/apps/solvecraft/src/main.rs @@ -200,6 +200,8 @@ fn main() -> eframe::Result { if let Some(rs) = &cc.wgpu_render_state { eprintln!("SolidCraft graphics: {:?}, MSAA {}x (requested {}x)", rs.adapter.get_info().backend, rs.msaa_samples, samples); app.set_wgpu(rs, DEPTH_BITS, STENCIL_BITS, rs.msaa_samples); + // A saved anti-aliasing the device can't do: back to the default, with a notice. + solvecraft_ui_egui::prefs::check_msaa(&mut app, u32::from(samples)); } if let Some(port) = control_port { let rx = control_server::start(port, cc.egui_ctx.clone()); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6e007d72..4e812b21 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -239,6 +239,11 @@ pub struct SolveApp { pub recoverable: Vec, /// The bodies of a just-opened Y-up file, offered to be stood up (`stand_up`). pub stand_up: Option>, + /// The anti-aliasing sample counts the graphics device supports (empty without a GPU). + pub msaa_supported: Vec, + /// Settings put back at start because they kept SolidCraft from starting, one line each, + /// shown until dismissed (`prefs::show_reset_notice`). + pub settings_reset: Vec, /// The last command was a big operation: autosave after it. pub big_operation: bool, /// Autosave interval (minutes; 0 = only after big operations). A preference. @@ -300,6 +305,8 @@ impl SolveApp { autosave: None, recoverable: Vec::new(), stand_up: None, + msaa_supported: Vec::new(), + settings_reset: Vec::new(), big_operation: false, autosave_minutes: 5.0, custom_titlebar: false, @@ -335,6 +342,7 @@ impl SolveApp { /// stencil buffer bits and MSAA sample count). Without it the viewport renders on the CPU. pub fn set_wgpu(&mut self, rs: &egui_wgpu::RenderState, depth_bits: u8, stencil_bits: u8, samples: u32) { self.viewport.gpu = Some(gpu::install(rs, depth_bits, stencil_bits, samples)); + self.msaa_supported = rs.msaa_supported.clone(); let info = rs.adapter.get_info(); self.help.gpu = Some(format!("{} ({:?}, {:?})", info.name.trim(), info.backend, info.device_type)); } @@ -412,7 +420,10 @@ impl SolveApp { if let Some(t) = v.get("toolbar") { self.toolbar_custom.load(t); } - if let Some(p) = v.get("preferences").and_then(|p| serde_json::from_value::(p.clone()).ok()) { + if let Some(mut p) = v.get("preferences").and_then(|p| serde_json::from_value::(p.clone()).ok()) { + if !prefs::MSAA.contains(&p.msaa) { + p.msaa = prefs::Prefs::default().msaa; + } self.preferences = p; } // Saved before the theme was a preference: the Dark/Light flag says which. @@ -1004,6 +1015,7 @@ impl SolveApp { params_dialog::show(self, ui.ctx()); recovery_ui::show(self, ui.ctx()); stand_up::show(self, ui.ctx()); + prefs::show_reset_notice(self, ui.ctx()); context_menu::show(self, ui.ctx()); delete::show(self, ui.ctx()); if self.custom_titlebar { diff --git a/crates/ui-egui/src/locales/de.json b/crates/ui-egui/src/locales/de.json index adbfc42b..53a5a4d7 100644 --- a/crates/ui-egui/src/locales/de.json +++ b/crates/ui-egui/src/locales/de.json @@ -11,6 +11,11 @@ "dialog.selected_count": "↖ {count} ausgewählt", "document.save_changes": "Änderungen in {name} speichern?", "document.unsaved_changes": "{name} enthält ungespeicherte Änderungen. Wenn Sie nicht speichern, gehen sie verloren.", + "settings.msaa_applies_on_restart": "Die Kantenglättung wird beim nächsten Start von SolidCraft übernommen", + "settings.msaa_reset": "{setting}: {requested}× wird von diesem Grafikgerät nicht unterstützt; jetzt {reset}× (unterstützt: {supported}).", + "settings.msaa_unsupported": "Von diesem Grafikgerät nicht unterstützt", + "settings.reset_intro": "Diese Einstellungen funktionieren auf diesem Computer nicht und würden den Start von SolidCraft verhindern. Sie wurden daher geändert:", + "settings.reset_title": "Einstellungen wurden geändert", "sketch.degrees_of_freedom": "{count} Freiheitsgrade", "sketch.draw_curves": "{linetype}-Kurven zeichnen", "sketch.make_selection": "Auswahl als {linetype} markieren", @@ -27,6 +32,7 @@ "ui.animate_view_changes": "Ansichtsänderungen animieren", "ui.animation": "Animation", "ui.animation_speed": "Animationsgeschwindigkeit", + "ui.anti_aliasing": "Kantenglättung", "ui.apply": "Anwenden", "ui.arc": "Kreisbogen", "ui.assemble": "Zusammenbau", @@ -199,6 +205,7 @@ "ui.one_side": "Einseitig", "ui.open": "Öffnen", "ui.open_ellipsis": "Öffnen...", + "ui.open_preferences": "Einstellungen öffnen", "ui.open_recent": "Zuletzt verwendet", "ui.operation": "Operation", "ui.options": "Optionen", diff --git a/crates/ui-egui/src/locales/en.json b/crates/ui-egui/src/locales/en.json index b0feae8a..c96a5537 100644 --- a/crates/ui-egui/src/locales/en.json +++ b/crates/ui-egui/src/locales/en.json @@ -11,6 +11,11 @@ "dialog.selected_count": "↖ {count} selected", "document.save_changes": "Save changes to {name}?", "document.unsaved_changes": "{name} has unsaved changes. If you don't save, they are lost.", + "settings.msaa_applies_on_restart": "Anti-aliasing applies the next time SolidCraft starts", + "settings.msaa_reset": "{setting}: {requested}× isn't supported by this graphics device; it is now {reset}× (supported: {supported}).", + "settings.msaa_unsupported": "Not supported by this graphics device", + "settings.reset_intro": "These settings don't work on this computer and would keep SolidCraft from starting, so they were changed:", + "settings.reset_title": "Settings were changed", "sketch.degrees_of_freedom": "{count} degrees of freedom", "sketch.draw_curves": "Draw {linetype} curves", "sketch.make_selection": "Make the selection {linetype}", @@ -27,6 +32,7 @@ "ui.animate_view_changes": "Animate view changes", "ui.animation": "Animation", "ui.animation_speed": "Animation speed", + "ui.anti_aliasing": "Anti-aliasing", "ui.apply": "Apply", "ui.arc": "Arc", "ui.assemble": "Assemble", @@ -199,6 +205,7 @@ "ui.one_side": "One Side", "ui.open": "Open", "ui.open_ellipsis": "Open...", + "ui.open_preferences": "Open Preferences", "ui.open_recent": "Open Recent", "ui.operation": "Operation", "ui.options": "Options", diff --git a/crates/ui-egui/src/locales/ja.json b/crates/ui-egui/src/locales/ja.json index 199385db..169685d0 100644 --- a/crates/ui-egui/src/locales/ja.json +++ b/crates/ui-egui/src/locales/ja.json @@ -11,6 +11,11 @@ "dialog.selected_count": "↖ {count} 個選択", "document.save_changes": "{name} への変更を保存しますか?", "document.unsaved_changes": "{name} には保存されていない変更があります。保存しない場合、変更内容は失われます。", + "settings.msaa_applies_on_restart": "アンチエイリアスは次回 SolidCraft を起動したときに適用されます", + "settings.msaa_reset": "{setting}: {requested}× はこのグラフィックスデバイスではサポートされていないため、{reset}× に変更しました(サポート: {supported})。", + "settings.msaa_unsupported": "このグラフィックスデバイスではサポートされていません", + "settings.reset_intro": "次の設定はこのコンピューターでは使用できず、SolidCraft が起動できなくなるため変更されました:", + "settings.reset_title": "設定が変更されました", "sketch.degrees_of_freedom": "自由度 {count}", "sketch.draw_curves": "{linetype}の曲線を描く", "sketch.make_selection": "選択を{linetype}にする", @@ -27,6 +32,7 @@ "ui.animate_view_changes": "視点変更をアニメーション表示", "ui.animation": "アニメーション", "ui.animation_speed": "アニメーション速度", + "ui.anti_aliasing": "アンチエイリアス", "ui.apply": "適用", "ui.arc": "円弧", "ui.assemble": "アセンブリ", @@ -199,6 +205,7 @@ "ui.one_side": "片側", "ui.open": "開く", "ui.open_ellipsis": "開く...", + "ui.open_preferences": "環境設定を開く", "ui.open_recent": "最近使った項目を開く", "ui.operation": "操作", "ui.options": "オプション", diff --git a/crates/ui-egui/src/locales/ko.json b/crates/ui-egui/src/locales/ko.json index e3c1b940..8175a12f 100644 --- a/crates/ui-egui/src/locales/ko.json +++ b/crates/ui-egui/src/locales/ko.json @@ -11,6 +11,11 @@ "dialog.selected_count": "↖ {count}개 선택됨", "document.save_changes": "{name}의 변경 사항을 저장하시겠습니까?", "document.unsaved_changes": "{name}에 저장되지 않은 변경 사항이 있습니다. 저장하지 않으면 변경 사항이 사라집니다.", + "settings.msaa_applies_on_restart": "안티앨리어싱은 다음에 SolidCraft를 시작할 때 적용됩니다", + "settings.msaa_reset": "{setting}: {requested}×는 이 그래픽 장치에서 지원되지 않아 {reset}×로 변경했습니다(지원: {supported}).", + "settings.msaa_unsupported": "이 그래픽 장치에서 지원되지 않음", + "settings.reset_intro": "다음 설정은 이 컴퓨터에서 작동하지 않아 SolidCraft를 시작할 수 없게 하므로 변경되었습니다:", + "settings.reset_title": "설정이 변경되었습니다", "sketch.degrees_of_freedom": "자유도 {count}개", "sketch.draw_curves": "{linetype} 곡선 그리기", "sketch.make_selection": "선택 항목을 {linetype}(으)로 변경", @@ -27,6 +32,7 @@ "ui.animate_view_changes": "뷰 변경 애니메이션", "ui.animation": "애니메이션", "ui.animation_speed": "애니메이션 속도", + "ui.anti_aliasing": "안티앨리어싱", "ui.apply": "적용", "ui.arc": "호", "ui.assemble": "조립", @@ -199,6 +205,7 @@ "ui.one_side": "한 방향", "ui.open": "열기", "ui.open_ellipsis": "열기...", + "ui.open_preferences": "환경 설정 열기", "ui.open_recent": "최근 항목 열기", "ui.operation": "작업", "ui.options": "옵션", diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs index f811712d..a3504eb4 100644 --- a/crates/ui-egui/src/prefs.rs +++ b/crates/ui-egui/src/prefs.rs @@ -158,6 +158,7 @@ pub fn nav_preset(id: &str) -> &'static NavPreset { pub const PAGES: [&str; 7] = ["General", "Navigation", "Keyboard Shortcuts", "Design", "Graphics", "Sketch", "Selection"]; pub const PAGE_SHORTCUTS: usize = 2; pub const PAGE_DESIGN: usize = 3; +pub const PAGE_GRAPHICS: usize = 4; pub const THEMES: [(&str, &str); 3] = [("dark", "Dark"), ("light", "Light"), ("system", "System")]; pub const MSAA: [u32; 4] = [1, 2, 4, 8]; @@ -332,7 +333,7 @@ fn defaults(app: &mut SolveApp, page: usize) { p.up_axis = d.up_axis; p.offer_stand_up = d.offer_stand_up; } - 4 => { + PAGE_GRAPHICS => { let msaa = p.msaa != d.msaa; p.msaa = d.msaa; app.ui.visual_style = 0; @@ -520,6 +521,77 @@ fn save_graphics(app: &SolveApp) { } } +/// At start, with the graphics device known: the saved anti-aliasing (`requested` samples, as +/// the host read it) is more than the device supports. The window would not have opened with it, +/// so the renderer fell back; put the setting back to its default (or the largest supported +/// count below it) and tell the user what changed. +pub fn check_msaa(app: &mut SolveApp, requested: u32) { + let supported = &app.msaa_supported; + if requested <= 1 || supported.is_empty() || supported.contains(&requested) { + return; + } + let default = Prefs::default().msaa; + let reset = supported.iter().copied().filter(|&n| n <= default).max().unwrap_or(1); + let supported = supported.clone(); + app.preferences.msaa = reset; + save_graphics(app); + app.settings_reset.push(SettingReset::Msaa { requested, reset, supported }); +} + +/// A setting put back at start because it would keep SolidCraft from starting. +#[derive(Clone, Debug, PartialEq)] +pub enum SettingReset { + /// Anti-aliasing samples the graphics device can't render with. + Msaa { requested: u32, reset: u32, supported: Vec }, +} + +impl SettingReset { + /// The line the notice shows, in the current interface language. + pub fn message(&self) -> String { + match self { + SettingReset::Msaa { requested, reset, supported } => { + let setting = format!("{} › {}", crate::i18n::tr("ui.graphics"), crate::i18n::tr("ui.anti_aliasing")); + let supported = supported.iter().map(|n| format!("{n}")).collect::>().join("×, ") + "×"; + crate::i18n::format( + "settings.msaa_reset", + &[("setting", &setting), ("requested", &requested.to_string()), ("reset", &reset.to_string()), ("supported", &supported)], + ) + } + } + } +} + +/// Settings put back at start (`SolveApp::settings_reset`), until dismissed. +pub fn show_reset_notice(app: &mut SolveApp, ctx: &egui::Context) { + if app.settings_reset.is_empty() { + return; + } + let mut ok = false; + let mut prefs = false; + crate::frame::window(ctx, app.tr("settings.reset_title"), crate::frame::Width::Normal) + .id(egui::Id::new("sc_settings_reset")) + .collapsible(false) + .resizable(false) + .show(ctx, |ui| { + ui.label(app.tr("settings.reset_intro")); + ui.add_space(4.0); + for reset in &app.settings_reset { + ui.label(format!("• {}", reset.message())); + } + ui.add_space(6.0); + ui.horizontal(|ui| { + ok = ui.button(app.tr("ui.heading_ok")).clicked(); + prefs = ui.button(app.tr("ui.open_preferences")).clicked(); + }); + }); + if ok || prefs { + app.settings_reset.clear(); + } + if prefs { + open_at(app, PAGE_GRAPHICS); + } +} + pub fn show(app: &mut SolveApp, ctx: &egui::Context) { // File › Keyboard Shortcuts and Help › Keyboard Shortcuts open this dialog at that page. if app.keymap.open { @@ -646,7 +718,6 @@ const PREFS_NAV_W: f32 = 128.0; /// One page of the dialog. fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) { - let _language = crate::i18n::scope(&app.preferences.language); let note = |ui: &mut egui::Ui, s: &str| { ui.label(""); // Descriptions wrap within the page instead of widening it. @@ -801,14 +872,18 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) { ui.label("Perspective view"); ui.checkbox(&mut app.ui.perspective, ""); ui.end_row(); - ui.label("Anti-aliasing"); + ui.label(app.tr("ui.anti_aliasing")); egui::ComboBox::from_id_salt("pref_msaa").selected_text(format!("{}×", app.preferences.msaa)).show_ui(ui, |ui| { for m in MSAA { - ui.selectable_value(&mut app.preferences.msaa, m, format!("{m}×")); + // Counts the graphics device can't do would keep the window from opening. + let ok = app.msaa_supported.is_empty() || app.msaa_supported.contains(&m); + ui.add_enabled_ui(ok, |ui| ui.selectable_value(&mut app.preferences.msaa, m, format!("{m}×"))) + .inner + .on_disabled_hover_text(app.tr("settings.msaa_unsupported")); } }); ui.end_row(); - note(ui, "Anti-aliasing applies the next time SolidCraft starts"); + note(ui, app.tr("settings.msaa_applies_on_restart")); ui.label("Renderer"); ui.label(match (&app.viewport.gpu, &app.help.gpu) { (Some(_), Some(g)) => format!("GPU: {g}"), @@ -888,6 +963,9 @@ pub fn set(app: &mut SolveApp, p: &serde_json::Value) -> Result Result, + /// Egui renderer responsible for drawing the UI. pub renderer: Arc>, @@ -284,8 +288,19 @@ impl RenderState { // SolidCraft: the OpenGL resolve path can present an entirely black frame with MSAA // (notably Mesa/llvmpipe). Decide after selecting the real surface adapter, // before creating either pipelines or attachments; no second GPU probe. + // A sample count the device doesn't support for the target or depth format fails + // pipeline validation (a panic at start): fall back to the largest supported one. + let msaa_supported = + supported_msaa_samples(&adapter, &device, target_format, options.depth_stencil_format); + let wanted = effective_msaa_samples(adapter.get_info().backend, options.msaa_samples); + let msaa_samples = clamp_msaa_samples(&msaa_supported, wanted); + if msaa_samples != wanted { + log::warn!( + "MSAA {wanted}x is not supported for {target_format:?} on this device (supported: {msaa_supported:?}); using {msaa_samples}x" + ); + } let options = RendererOptions { - msaa_samples: effective_msaa_samples(adapter.get_info().backend, options.msaa_samples), + msaa_samples, ..options }; let renderer = Renderer::new(&device, target_format, options); @@ -302,6 +317,7 @@ impl RenderState { queue, target_format, msaa_samples: options.msaa_samples, + msaa_supported, renderer: Arc::new(RwLock::new(renderer)), surface_config: config.surface, }) @@ -571,6 +587,58 @@ pub fn effective_msaa_samples(backend: wgpu::Backend, requested: u32) -> u32 { if backend == wgpu::Backend::Gl { 1 } else { requested.max(1) } } +/// SolidCraft: the MSAA sample counts `device` can render `target_format` and +/// `depth_stencil_format` with (ascending, always starting with 1). Without +/// [`wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES`] the device allows only the counts +/// WebGPU guarantees for a format (1 and 4), whatever the adapter could do. +pub fn supported_msaa_samples( + adapter: &wgpu::Adapter, + device: &wgpu::Device, + target_format: wgpu::TextureFormat, + depth_stencil_format: Option, +) -> Vec { + let features = device.features(); + let flags = |format: wgpu::TextureFormat| { + if features.contains(wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES) { + adapter.get_texture_format_features(format).flags + } else { + format.guaranteed_format_features(features).flags + } + }; + let target = flags(target_format); + let depth = depth_stencil_format.map(flags); + msaa_counts(target, depth) +} + +/// The sample counts both the colour target and the depth buffer support. A multisampled colour +/// target must also resolve into the single-sampled surface. +fn msaa_counts( + target: wgpu::TextureFormatFeatureFlags, + depth: Option, +) -> Vec { + let resolves = target.contains(wgpu::TextureFormatFeatureFlags::MULTISAMPLE_RESOLVE); + [1, 2, 4, 8, 16] + .into_iter() + .filter(|&n| { + n == 1 + || (resolves + && target.sample_count_supported(n) + && depth.is_none_or(|d| d.sample_count_supported(n))) + }) + .collect() +} + +/// SolidCraft: the largest of the `supported` sample counts that is at most `requested` (1 when +/// none is). +pub fn clamp_msaa_samples(supported: &[u32], requested: u32) -> u32 { + supported + .iter() + .copied() + .filter(|&n| n <= requested.max(1)) + .max() + .unwrap_or(1) +} + #[cfg(test)] mod msaa_tests { #[test] @@ -582,4 +650,23 @@ mod msaa_tests { } } } + + #[test] + fn unsupported_sample_counts_fall_back_to_the_largest_supported_one() { + use wgpu::TextureFormatFeatureFlags as F; + // WebGPU's guarantee for Bgra8Unorm without adapter-specific features: 1 and 4 (a + // saved 8x failed pipeline validation at start). + let guaranteed = F::MULTISAMPLE_X4 | F::MULTISAMPLE_RESOLVE; + let supported = super::msaa_counts(guaranteed, Some(F::MULTISAMPLE_X4)); + assert_eq!(supported, [1, 4]); + assert_eq!(super::clamp_msaa_samples(&supported, 8), 4); + assert_eq!(super::clamp_msaa_samples(&supported, 2), 1); + assert_eq!(super::clamp_msaa_samples(&supported, 0), 1); + // The depth buffer limits it too, and a target that can't resolve can't multisample. + let adapter = F::MULTISAMPLE_X2 | F::MULTISAMPLE_X4 | F::MULTISAMPLE_X8 | F::MULTISAMPLE_RESOLVE; + assert_eq!(super::msaa_counts(adapter, None), [1, 2, 4, 8]); + assert_eq!(super::msaa_counts(adapter, Some(F::MULTISAMPLE_X4)), [1, 4]); + assert_eq!(super::msaa_counts(F::MULTISAMPLE_X4, None), [1]); + assert_eq!(super::clamp_msaa_samples(&[], 8), 1); + } } diff --git a/vendor/egui-wgpu/src/setup.rs b/vendor/egui-wgpu/src/setup.rs index 3c9ceae5..ef2fa48b 100644 --- a/vendor/egui-wgpu/src/setup.rs +++ b/vendor/egui-wgpu/src/setup.rs @@ -272,6 +272,10 @@ impl WgpuSetupCreateNew { max_texture_dimension_2d: 8192, ..base_limits }, + // SolidCraft: without it only the sample counts WebGPU guarantees (1x, 4x) + // are allowed, even where the adapter supports 2x and 8x. + required_features: adapter.features() + & wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES, ..Default::default() } }),