feat(engine): per-theme nearest-sampling opt-in for pixel-art themes

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<Image> 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 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-06 19:21:53 -07:00
parent de4751115f
commit 17e3112502
7 changed files with 54 additions and 0 deletions
+32
View File
@@ -187,6 +187,7 @@ fn sync_card_image_set_with_active_theme(
themes: Res<Assets<CardTheme>>,
mut card_image_set: Option<ResMut<CardImageSet>>,
mut state_events: MessageWriter<StateChangedEvent>,
mut images: ResMut<Assets<Image>>,
) {
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<Image>,
) {
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,
},
}],
});