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:
@@ -2681,6 +2681,7 @@ mod tests {
|
|||||||
author: "test".into(),
|
author: "test".into(),
|
||||||
version: "0".into(),
|
version: "0".into(),
|
||||||
card_aspect: (2, 3),
|
card_aspect: (2, 3),
|
||||||
|
pixel_art: false,
|
||||||
},
|
},
|
||||||
faces: HashMap::<CardKey, Handle<bevy::image::Image>>::new(),
|
faces: HashMap::<CardKey, Handle<bevy::image::Image>>::new(),
|
||||||
back: theme_back.clone(),
|
back: theme_back.clone(),
|
||||||
|
|||||||
@@ -2458,6 +2458,7 @@ mod tests {
|
|||||||
author: "x".into(),
|
author: "x".into(),
|
||||||
version: "x".into(),
|
version: "x".into(),
|
||||||
card_aspect: (2, 3),
|
card_aspect: (2, 3),
|
||||||
|
pixel_art: false,
|
||||||
},
|
},
|
||||||
}],
|
}],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -478,6 +478,7 @@ mod tests {
|
|||||||
author: "Tester".into(),
|
author: "Tester".into(),
|
||||||
version: "1.0.0".into(),
|
version: "1.0.0".into(),
|
||||||
card_aspect: (2, 3),
|
card_aspect: (2, 3),
|
||||||
|
pixel_art: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ mod tests {
|
|||||||
author: "Solitaire Quest".into(),
|
author: "Solitaire Quest".into(),
|
||||||
version: "1.0.0".into(),
|
version: "1.0.0".into(),
|
||||||
card_aspect: (2, 3),
|
card_aspect: (2, 3),
|
||||||
|
pixel_art: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -164,6 +164,18 @@ pub struct ThemeMeta {
|
|||||||
/// the artwork's intended proportions when the player resizes the
|
/// the artwork's intended proportions when the player resizes the
|
||||||
/// window. Standard playing cards are 2:3.
|
/// window. Standard playing cards are 2:3.
|
||||||
pub card_aspect: (u32, u32),
|
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`].
|
/// Errors surfaced by [`ThemeMeta::validate`].
|
||||||
@@ -271,6 +283,7 @@ mod tests {
|
|||||||
author: "Solitaire Quest".into(),
|
author: "Solitaire Quest".into(),
|
||||||
version: "1.0.0".into(),
|
version: "1.0.0".into(),
|
||||||
card_aspect: (2, 3),
|
card_aspect: (2, 3),
|
||||||
|
pixel_art: false,
|
||||||
};
|
};
|
||||||
assert_eq!(meta.validate(), Ok(()));
|
assert_eq!(meta.validate(), Ok(()));
|
||||||
}
|
}
|
||||||
@@ -283,6 +296,7 @@ mod tests {
|
|||||||
author: "x".into(),
|
author: "x".into(),
|
||||||
version: "x".into(),
|
version: "x".into(),
|
||||||
card_aspect: (2, 3),
|
card_aspect: (2, 3),
|
||||||
|
pixel_art: false,
|
||||||
};
|
};
|
||||||
assert_eq!(meta.validate(), Err(ThemeMetaError::EmptyId));
|
assert_eq!(meta.validate(), Err(ThemeMetaError::EmptyId));
|
||||||
}
|
}
|
||||||
@@ -295,6 +309,7 @@ mod tests {
|
|||||||
author: "x".into(),
|
author: "x".into(),
|
||||||
version: "x".into(),
|
version: "x".into(),
|
||||||
card_aspect: (2, 3),
|
card_aspect: (2, 3),
|
||||||
|
pixel_art: false,
|
||||||
};
|
};
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
meta.validate(),
|
meta.validate(),
|
||||||
@@ -310,6 +325,7 @@ mod tests {
|
|||||||
author: "x".into(),
|
author: "x".into(),
|
||||||
version: "x".into(),
|
version: "x".into(),
|
||||||
card_aspect: (0, 3),
|
card_aspect: (0, 3),
|
||||||
|
pixel_art: false,
|
||||||
};
|
};
|
||||||
assert_eq!(meta.validate(), Err(ThemeMetaError::ZeroNumerator));
|
assert_eq!(meta.validate(), Err(ThemeMetaError::ZeroNumerator));
|
||||||
meta.card_aspect = (2, 0);
|
meta.card_aspect = (2, 0);
|
||||||
|
|||||||
@@ -187,6 +187,7 @@ fn sync_card_image_set_with_active_theme(
|
|||||||
themes: Res<Assets<CardTheme>>,
|
themes: Res<Assets<CardTheme>>,
|
||||||
mut card_image_set: Option<ResMut<CardImageSet>>,
|
mut card_image_set: Option<ResMut<CardImageSet>>,
|
||||||
mut state_events: MessageWriter<StateChangedEvent>,
|
mut state_events: MessageWriter<StateChangedEvent>,
|
||||||
|
mut images: ResMut<Assets<Image>>,
|
||||||
) {
|
) {
|
||||||
let Some(active) = active else { return };
|
let Some(active) = active else { return };
|
||||||
let active_id = active.0.id();
|
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 {
|
let Some(theme) = themes.get(&active.0) else {
|
||||||
return;
|
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 {
|
let Some(card_image_set) = card_image_set.as_deref_mut() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -214,6 +218,32 @@ fn sync_card_image_set_with_active_theme(
|
|||||||
state_events.write(StateChangedEvent);
|
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
|
/// Pure helper that copies the theme's image handles into the
|
||||||
/// `[suit][rank]` face matrix and into the dedicated `theme_back`
|
/// `[suit][rank]` face matrix and into the dedicated `theme_back`
|
||||||
/// slot. Split out so it can be unit-tested without spinning up a
|
/// slot. Split out so it can be unit-tested without spinning up a
|
||||||
@@ -459,6 +489,7 @@ mod tests {
|
|||||||
author: "test".into(),
|
author: "test".into(),
|
||||||
version: "0".into(),
|
version: "0".into(),
|
||||||
card_aspect: (2, 3),
|
card_aspect: (2, 3),
|
||||||
|
pixel_art: false,
|
||||||
},
|
},
|
||||||
faces: HashMap::new(),
|
faces: HashMap::new(),
|
||||||
back: Handle::default(),
|
back: Handle::default(),
|
||||||
@@ -723,6 +754,7 @@ mod tests {
|
|||||||
author: "x".into(),
|
author: "x".into(),
|
||||||
version: "x".into(),
|
version: "x".into(),
|
||||||
card_aspect: (2, 3),
|
card_aspect: (2, 3),
|
||||||
|
pixel_art: false,
|
||||||
},
|
},
|
||||||
}],
|
}],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ fn default_entry() -> ThemeEntry {
|
|||||||
author: "Solitaire Quest".to_string(),
|
author: "Solitaire Quest".to_string(),
|
||||||
version: "1.0".to_string(),
|
version: "1.0".to_string(),
|
||||||
card_aspect: (2, 3),
|
card_aspect: (2, 3),
|
||||||
|
pixel_art: false,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -344,6 +345,7 @@ mod tests {
|
|||||||
author: "x".into(),
|
author: "x".into(),
|
||||||
version: "x".into(),
|
version: "x".into(),
|
||||||
card_aspect: (2, 3),
|
card_aspect: (2, 3),
|
||||||
|
pixel_art: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user