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
+16
View File
@@ -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);