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() } }),