From 17e311250226bf52309b9b52be69e17fb68d7ba7 Mon Sep 17 00:00:00 2001 From: funman300 Date: Wed, 6 May 2026 19:21:53 -0700 Subject: [PATCH] feat(engine): per-theme nearest-sampling opt-in for pixel-art themes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bevy's default sprite sampler is bilinear (Linear), which mushes pixel-art card faces at non-integer scales. The rusty-pixel theme ships 256x384 source PNGs that get displayed at ~150-200px wide on typical desktop windows — an aggressive downscale where bilinear visibly blurs the pixel grid. Globally flipping ImagePlugin to default_nearest() would also affect the SVG-rasterised default theme, where bilinear's smoothing is actually desired (the SVG rasteriser produces a high-res 512x768 pixmap that the GPU has to downscale at draw time). The fix is a per-theme opt-in: - ThemeMeta gains pixel_art: bool with #[serde(default)] for backwards compat. Older manifests load with `false`, preserving SVG-default behaviour. - sync_card_image_set_with_active_theme inspects theme.meta.pixel_art after a theme finishes loading. When true, walks every face + back Handle in the active CardTheme and rewrites its sampler to ImageSampler::Descriptor(ImageSamplerDescriptor::nearest()). The Modified asset event triggers a GPU re-upload with the new sampler descriptor. - The 12 ThemeMeta struct literals across the engine (settings_plugin, card_plugin, theme/{plugin,mod,manifest, importer,registry}) all gain `pixel_art: false` to match the new field. The deployed rusty-pixel theme.ron at ~/.local/share/solitaire_quest/themes/rusty-pixel/ now sets pixel_art: true, so the player's switch-to-pixel-art chip flips to nearest sampling on the spot. Workspace: 1171 passing tests / 0 failing. cargo clippy --workspace --all-targets -- -D warnings clean. Co-Authored-By: Claude Opus 4.7 --- solitaire_engine/src/card_plugin.rs | 1 + solitaire_engine/src/settings_plugin.rs | 1 + solitaire_engine/src/theme/importer.rs | 1 + solitaire_engine/src/theme/manifest.rs | 1 + solitaire_engine/src/theme/mod.rs | 16 +++++++++++++ solitaire_engine/src/theme/plugin.rs | 32 +++++++++++++++++++++++++ solitaire_engine/src/theme/registry.rs | 2 ++ 7 files changed, 54 insertions(+) diff --git a/solitaire_engine/src/card_plugin.rs b/solitaire_engine/src/card_plugin.rs index d55bac0..04eda19 100644 --- a/solitaire_engine/src/card_plugin.rs +++ b/solitaire_engine/src/card_plugin.rs @@ -2681,6 +2681,7 @@ mod tests { author: "test".into(), version: "0".into(), card_aspect: (2, 3), + pixel_art: false, }, faces: HashMap::>::new(), back: theme_back.clone(), diff --git a/solitaire_engine/src/settings_plugin.rs b/solitaire_engine/src/settings_plugin.rs index 16ddb83..d8dffa5 100644 --- a/solitaire_engine/src/settings_plugin.rs +++ b/solitaire_engine/src/settings_plugin.rs @@ -2458,6 +2458,7 @@ mod tests { author: "x".into(), version: "x".into(), card_aspect: (2, 3), + pixel_art: false, }, }], }); diff --git a/solitaire_engine/src/theme/importer.rs b/solitaire_engine/src/theme/importer.rs index bb27ad7..16927ab 100644 --- a/solitaire_engine/src/theme/importer.rs +++ b/solitaire_engine/src/theme/importer.rs @@ -478,6 +478,7 @@ mod tests { author: "Tester".into(), version: "1.0.0".into(), card_aspect: (2, 3), + pixel_art: false, } } diff --git a/solitaire_engine/src/theme/manifest.rs b/solitaire_engine/src/theme/manifest.rs index c607bd6..7ad69fc 100644 --- a/solitaire_engine/src/theme/manifest.rs +++ b/solitaire_engine/src/theme/manifest.rs @@ -87,6 +87,7 @@ mod tests { author: "Solitaire Quest".into(), version: "1.0.0".into(), card_aspect: (2, 3), + pixel_art: false, } } diff --git a/solitaire_engine/src/theme/mod.rs b/solitaire_engine/src/theme/mod.rs index 520b259..5acbb66 100644 --- a/solitaire_engine/src/theme/mod.rs +++ b/solitaire_engine/src/theme/mod.rs @@ -164,6 +164,18 @@ pub struct ThemeMeta { /// the artwork's intended proportions when the player resizes the /// window. Standard playing cards are 2:3. pub card_aspect: (u32, u32), + /// Whether this theme's art should render with nearest-neighbor + /// (point) sampling instead of Bevy's default bilinear filtering. + /// + /// Set `true` for pixel-art themes (each face is a small grid of + /// hand-placed pixels) so non-integer scales preserve crisp edges. + /// Leave `false` for SVG-rasterised or photographic art where + /// bilinear smooths downscale aliasing. + /// + /// `#[serde(default)]` keeps older manifests (which pre-date this + /// field) loading cleanly with the smooth-sampling default. + #[serde(default)] + pub pixel_art: bool, } /// Errors surfaced by [`ThemeMeta::validate`]. @@ -271,6 +283,7 @@ mod tests { author: "Solitaire Quest".into(), version: "1.0.0".into(), card_aspect: (2, 3), + pixel_art: false, }; assert_eq!(meta.validate(), Ok(())); } @@ -283,6 +296,7 @@ mod tests { author: "x".into(), version: "x".into(), card_aspect: (2, 3), + pixel_art: false, }; assert_eq!(meta.validate(), Err(ThemeMetaError::EmptyId)); } @@ -295,6 +309,7 @@ mod tests { author: "x".into(), version: "x".into(), card_aspect: (2, 3), + pixel_art: false, }; assert!(matches!( meta.validate(), @@ -310,6 +325,7 @@ mod tests { author: "x".into(), version: "x".into(), card_aspect: (0, 3), + pixel_art: false, }; assert_eq!(meta.validate(), Err(ThemeMetaError::ZeroNumerator)); meta.card_aspect = (2, 0); diff --git a/solitaire_engine/src/theme/plugin.rs b/solitaire_engine/src/theme/plugin.rs index 84a0dc5..3e75ab6 100644 --- a/solitaire_engine/src/theme/plugin.rs +++ b/solitaire_engine/src/theme/plugin.rs @@ -187,6 +187,7 @@ fn sync_card_image_set_with_active_theme( themes: Res>, mut card_image_set: Option>, mut state_events: MessageWriter, + mut images: ResMut>, ) { let Some(active) = active else { return }; let active_id = active.0.id(); @@ -207,6 +208,9 @@ fn sync_card_image_set_with_active_theme( let Some(theme) = themes.get(&active.0) else { return; }; + if theme.meta.pixel_art { + apply_nearest_sampler_to_theme_images(theme, &mut images); + } let Some(card_image_set) = card_image_set.as_deref_mut() else { return; }; @@ -214,6 +218,32 @@ fn sync_card_image_set_with_active_theme( state_events.write(StateChangedEvent); } +/// Overrides the texture sampler on every face + back `Image` in +/// `theme` to nearest-neighbor (point) sampling, so non-integer +/// downscales preserve crisp pixel-grid edges instead of bilinear +/// blur. +/// +/// Called only for themes whose manifest sets `meta.pixel_art = true`. +/// SVG-rasterised themes leave the field at its `false` default and +/// keep Bevy's smooth-downscale sampler — pixel-art and SVG paths use +/// different filters from the same loader pipeline because they +/// optimise for different artwork tradeoffs. +fn apply_nearest_sampler_to_theme_images( + theme: &CardTheme, + images: &mut Assets, +) { + use bevy::image::{ImageSampler, ImageSamplerDescriptor}; + let nearest = ImageSampler::Descriptor(ImageSamplerDescriptor::nearest()); + for handle in theme.faces.values() { + if let Some(image) = images.get_mut(handle) { + image.sampler = nearest.clone(); + } + } + if let Some(image) = images.get_mut(&theme.back) { + image.sampler = nearest; + } +} + /// Pure helper that copies the theme's image handles into the /// `[suit][rank]` face matrix and into the dedicated `theme_back` /// slot. Split out so it can be unit-tested without spinning up a @@ -459,6 +489,7 @@ mod tests { author: "test".into(), version: "0".into(), card_aspect: (2, 3), + pixel_art: false, }, faces: HashMap::new(), back: Handle::default(), @@ -723,6 +754,7 @@ mod tests { author: "x".into(), version: "x".into(), card_aspect: (2, 3), + pixel_art: false, }, }], }); diff --git a/solitaire_engine/src/theme/registry.rs b/solitaire_engine/src/theme/registry.rs index cf3266e..6be6dad 100644 --- a/solitaire_engine/src/theme/registry.rs +++ b/solitaire_engine/src/theme/registry.rs @@ -118,6 +118,7 @@ fn default_entry() -> ThemeEntry { author: "Solitaire Quest".to_string(), version: "1.0".to_string(), card_aspect: (2, 3), + pixel_art: false, }, } } @@ -344,6 +345,7 @@ mod tests { author: "x".into(), version: "x".into(), card_aspect: (2, 3), + pixel_art: false, }, });