Reusable sprite feedback effects for Bevy 0.18 2D games. The crate ships a deliberate hybrid backend: cheap native Sprite and Transform mutation for flash and squash/stretch, plus an internal Material2d proxy path for dissolve, palette swap, outline, silhouette, and screen-style flash.
Quick Start
saddle-rendering-sprite-effects = { git = "https://github.com/julien-blanchon/saddle-rendering-sprite-effects" }saddle-rendering-sprite-effects = { git = "https://github.com/julien-blanchon/saddle-rendering-sprite-effects" }use bevy::{prelude::*, sprite::Anchor};
use saddle_rendering_sprite_effects::{
DissolveConfig, DissolveEffect, DissolvePhase, FlashBlendMode, FlashConfig, FlashEffect,
OutlineConfig, OutlineEffect, PaletteConfig, PaletteSwap, SilhouetteConfig,
SilhouetteEffect, SpriteEffectsPlugin, SquashStretchConfig, SquashStretchEffect,
};
fn main() {
App::new()
.add_plugins(DefaultPlugins.set(ImagePlugin::default_nearest()))
.add_plugins(SpriteEffectsPlugin::default())
.add_systems(Startup, setup)
.run();
}
fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
let sprite = asset_server.load("player.png");
let palette = asset_server.load("palettes/teams.png");
commands.spawn((
Name::new("Effect Target"),
Sprite::from_image(sprite),
FlashEffect::new(FlashConfig {
blend: FlashBlendMode::Screen,
duration_secs: 0.10,
..FlashConfig::default()
}),
SquashStretchEffect::new(SquashStretchConfig {
axis_bias: Vec2::Y,
compensation_anchor: Some(Anchor::BOTTOM_CENTER),
..SquashStretchConfig::default()
}),
PaletteSwap::new(PaletteConfig::new(palette, 4)),
OutlineEffect::new(OutlineConfig::default()),
SilhouetteEffect::new(SilhouetteConfig {
color: Color::srgba(0.18, 0.82, 1.0, 0.88),
sort_offset: 0.25,
..SilhouetteConfig::default()
}),
DissolveEffect::new(DissolveConfig {
phase: DissolvePhase::Reveal,
edge_width: 0.08,
edge_color: Color::srgb(1.0, 0.68, 0.2),
..DissolveConfig::default()
}),
));
}use bevy::{prelude::*, sprite::Anchor};
use saddle_rendering_sprite_effects::{
DissolveConfig, DissolveEffect, DissolvePhase, FlashBlendMode, FlashConfig, FlashEffect,
OutlineConfig, OutlineEffect, PaletteConfig, PaletteSwap, SilhouetteConfig,
SilhouetteEffect, SpriteEffectsPlugin, SquashStretchConfig, SquashStretchEffect,
};
fn main() {
App::new()
.add_plugins(DefaultPlugins.set(ImagePlugin::default_nearest()))
.add_plugins(SpriteEffectsPlugin::default())
.add_systems(Startup, setup)
.run();
}
fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
let sprite = asset_server.load("player.png");
let palette = asset_server.load("palettes/teams.png");
commands.spawn((
Name::new("Effect Target"),
Sprite::from_image(sprite),
FlashEffect::new(FlashConfig {
blend: FlashBlendMode::Screen,
duration_secs: 0.10,
..FlashConfig::default()
}),
SquashStretchEffect::new(SquashStretchConfig {
axis_bias: Vec2::Y,
compensation_anchor: Some(Anchor::BOTTOM_CENTER),
..SquashStretchConfig::default()
}),
PaletteSwap::new(PaletteConfig::new(palette, 4)),
OutlineEffect::new(OutlineConfig::default()),
SilhouetteEffect::new(SilhouetteConfig {
color: Color::srgba(0.18, 0.82, 1.0, 0.88),
sort_offset: 0.25,
..SilhouetteConfig::default()
}),
DissolveEffect::new(DissolveConfig {
phase: DissolvePhase::Reveal,
edge_width: 0.08,
edge_color: Color::srgb(1.0, 0.68, 0.2),
..DissolveConfig::default()
}),
));
}Add, remove, or mutate the public effect components directly. Each channel owns its own lifetime and cleans up the temporary runtime state it created.
Core defaults stay intentionally neutral. If you want game- or demo-specific recipes, compose them in your app code or keep them in an example/helper layer rather than relying on gameplay-named constructors in the crate API.
Plugin Constructor
SpriteEffectsPlugin::new(activate_schedule, deactivate_schedule, update_schedule) accepts injected schedules so host apps can decide when the runtime exists. SpriteEffectsPlugin::default() is always-on and uses Update.
If you map the crate into a larger game pipeline, order against the public sets instead of private systems:
# use bevy::prelude::*;
# use saddle_rendering_sprite_effects::{SpriteEffectsPlugin, SpriteEffectsSystems};
# #[derive(States, Default, Debug, Clone, Copy, Eq, PartialEq, Hash)]
# enum Screen { #[default] Gameplay }
# #[derive(SystemSet, Debug, Clone, Copy, Eq, PartialEq, Hash)]
# enum GameSet { Presentation }
App::new()
.add_plugins(SpriteEffectsPlugin::new(
OnEnter(Screen::Gameplay),
OnExit(Screen::Gameplay),
Update,
))
.configure_sets(
Update,
SpriteEffectsSystems::UpdateMaterials.in_set(GameSet::Presentation),
);# use bevy::prelude::*;
# use saddle_rendering_sprite_effects::{SpriteEffectsPlugin, SpriteEffectsSystems};
# #[derive(States, Default, Debug, Clone, Copy, Eq, PartialEq, Hash)]
# enum Screen { #[default] Gameplay }
# #[derive(SystemSet, Debug, Clone, Copy, Eq, PartialEq, Hash)]
# enum GameSet { Presentation }
App::new()
.add_plugins(SpriteEffectsPlugin::new(
OnEnter(Screen::Gameplay),
OnExit(Screen::Gameplay),
Update,
))
.configure_sets(
Update,
SpriteEffectsSystems::UpdateMaterials.in_set(GameSet::Presentation),
);Public API
| Type | Purpose |
|---|---|
SpriteEffectsPlugin | Registers the runtime, proxy material path, diagnostics, and cleanup |
SpriteEffectsSystems | Public ordering hooks: Prepare, TickCpuEffects, UpdateMaterials, Cleanup, Diagnostics |
FlashEffect / FlashConfig | Tint or screen-style flash with configurable duration, easing, and time domain |
DissolveEffect / DissolveConfig | Noise, directional, radial, or mask-backed dissolve and reveal |
SquashStretchEffect / SquashStretchConfig | Transform-driven squash/stretch with area preservation and anchor compensation |
PaletteSwap / PaletteConfig | Exact palette-bank remap using a row-based lookup texture |
OutlineEffect / OutlineConfig | Alpha-edge outline rendered by the proxy shader around the authored sprite |
SilhouetteEffect / SilhouetteConfig | Proxy-backed silhouette tint with configurable sort offset |
SpriteEffectFinished | Optional completion message for flash, dissolve, and squash/stretch |
SpriteEffectsDiagnostics | Runtime counts for active channels, outline/silhouette usage, and proxy usage |
Backend Model
The crate keeps the public API backend-agnostic, but the implementation is intentionally not a monolithic shader:
FlashEffectwithFlashBlendMode::Tintstays on the nativeSprite.colorpath when no shader-only effect is active on that entity.SquashStretchEffectalways stays on the CPU transform path.DissolveEffect,PaletteSwap,OutlineEffect,SilhouetteEffect, andFlashEffectwithFlashBlendMode::Screencreate an internal proxy child usingMesh2d+MeshMaterial2d.- If a shader proxy exists, the authored sprite is hidden by alpha while its original tint and alpha are copied into the proxy material.
This keeps the common hit-flash and squash cases cheap, while still supporting per-pixel effects like dissolve, outline, silhouette, and palette remap without forcing every sprite onto a material-backed render path.
Deactivate schedules clear runtime-owned state only. The public authored components stay attached, so persistent channels such as PaletteSwap can resume cleanly after the host re-activates the plugin.
Overlap Policy
The crate owns one active component channel per effect family on an entity.
| Channel | Policy |
|---|---|
FlashEffect | Reapplying the component immediately restarts the flash timer |
DissolveEffect | Reapplying the component immediately replaces the active dissolve state |
SquashStretchEffect | Reapplying the component immediately restarts the squash/stretch envelope |
PaletteSwap | Persistent authored state; changing the config updates the proxy material on the next frame |
OutlineEffect | Persistent authored state; changing the config updates the proxy material on the next frame |
SilhouetteEffect | Persistent authored state; changing the config updates the proxy material on the next frame |
Refresh and Replace are both exposed in the API today, but with the current component-driven trigger model they both resolve to an immediate restart of the authored channel. The distinction is preserved for forward-compatible expansion without changing the public types.
Time Policy
EffectTimeDomain::GlobalScaledreadsTime<Virtual>EffectTimeDomain::UnscaledreadsTime<Real>
This means flashes and squash/stretch can ignore hitstop or pause while dissolves can choose to respect it.
Atlas And Pixel-Art Notes
- Atlas animation is supported. The proxy material samples the authored sprite's current atlas rect every frame, and dissolve patterns use local frame UVs so atlas effects stay frame-local.
- Outline and silhouette also stay frame-local because the proxy shader samples only within the current atlas rect.
- Palette lookup expects a row-based texture: one source row, one target row, and
columns <= 32. - Exact palette matching relies on nearest sampling. Set
PaletteConfig::enforce_nearest_sampling = trueand preferImagePlugin::default_nearest()for pixel-art projects. - Alpha is preserved during palette remap and when the authored sprite is hidden behind a proxy.
Outline And Silhouette Notes
OutlineEffectsamples neighboring source texels around the current atlas frame and draws only where the base sprite alpha is belowalpha_threshold.OutlineConfig::width_pixelsis measured in source-texture texels, so1.0gives a crisp one-texel outline on unscaled pixel art.SilhouetteEffecttints only sprite pixels that pass the configured alpha threshold.SilhouetteConfig::sort_offsetnudges the proxy child's local Z so the silhouette can sit slightly in front of or behind the authored sprite in a 2D stack.- Outline and silhouette compose with dissolve, palette swap, and screen flash because they share the same proxy material path.
Examples
Every shipped example now includes saddle-pane controls for effect tuning plus live diagnostics from the active sprite channels.
| Example | Purpose | Run |
|---|---|---|
basic | Minimal hybrid showcase | cargo run -p saddle-rendering-sprite-effects-example-basic |
flash | Native tint flash versus proxy-backed screen flash | cargo run -p saddle-rendering-sprite-effects-example-flash |
dissolve | Noise, radial, and authored-mask dissolves | cargo run -p saddle-rendering-sprite-effects-example-dissolve |
palette_swap | Palette-bank cycling for team/status variants | cargo run -p saddle-rendering-sprite-effects-example-palette-swap |
outline_silhouette | Readability-focused outline and silhouette layering through foreground occluders | cargo run -p saddle-rendering-sprite-effects-example-outline-silhouette |
atlas_animation | Atlas animation compatibility while proxy effects are active | cargo run -p saddle-rendering-sprite-effects-example-atlas-animation |
stress | Dense proxy and effect lifetime stress pass | cargo run -p saddle-rendering-sprite-effects-example-stress |
Crate-Local Lab
The richer verification target lives at shared/rendering/saddle-rendering-sprite-effects/examples/lab:
cargo run -p saddle-rendering-sprite-effects-labcargo run -p saddle-rendering-sprite-effects-labFocused E2E scenarios:
cargo run -p saddle-rendering-sprite-effects-lab --features e2e -- smoke_launch
cargo run -p saddle-rendering-sprite-effects-lab --features e2e -- sprite_effects_flash
cargo run -p saddle-rendering-sprite-effects-lab --features e2e -- sprite_effects_dissolve
cargo run -p saddle-rendering-sprite-effects-lab --features e2e -- sprite_effects_palette_swap
cargo run -p saddle-rendering-sprite-effects-lab --features e2e -- sprite_effects_atlas_animation
cargo run -p saddle-rendering-sprite-effects-lab --features e2e -- sprite_effects_stress
cargo run -p saddle-rendering-sprite-effects-lab --features e2e -- sprite_effects_outline_silhouettecargo run -p saddle-rendering-sprite-effects-lab --features e2e -- smoke_launch
cargo run -p saddle-rendering-sprite-effects-lab --features e2e -- sprite_effects_flash
cargo run -p saddle-rendering-sprite-effects-lab --features e2e -- sprite_effects_dissolve
cargo run -p saddle-rendering-sprite-effects-lab --features e2e -- sprite_effects_palette_swap
cargo run -p saddle-rendering-sprite-effects-lab --features e2e -- sprite_effects_atlas_animation
cargo run -p saddle-rendering-sprite-effects-lab --features e2e -- sprite_effects_stress
cargo run -p saddle-rendering-sprite-effects-lab --features e2e -- sprite_effects_outline_silhouetteLimitations And Tradeoffs
- One proxy material is allocated per proxied sprite. This avoids per-frame churn, but very large numbers of permanent palette swaps still imply one material per entity.
- Transient proxy-only effects tear their proxy down when the entity returns to the cheap path. Re-triggering that shader path later allocates a fresh proxy material for that entity.
- Palette lookup is exact-match oriented. If the source art is filtered or heavily tinted before lookup, color matching can fail unless the palette epsilon is widened.
PaletteConfig::columnsis practically capped at 32 by the current shader loop.- Outline width is texel-based rather than screen-space constant. That keeps pixel art crisp, but it also means large world scaling makes the outline look thicker on screen.
- Silhouette is a tint-style readability effect, not a geometry-aware occlusion pass. It helps “behind wall” style presentation in 2D stacks, but it does not detect blockers by itself.
- The crate does not queue effect requests internally. If you need queued or blended effect recipes, layer that policy above the component API or use
game_feelfor higher-level orchestration.
More detail lives in architecture.md and configuration.md.