saddle-rendering-sprite-effects

Sprite feedback effects for Bevy 2D games


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

TypePurpose
SpriteEffectsPluginRegisters the runtime, proxy material path, diagnostics, and cleanup
SpriteEffectsSystemsPublic ordering hooks: Prepare, TickCpuEffects, UpdateMaterials, Cleanup, Diagnostics
FlashEffect / FlashConfigTint or screen-style flash with configurable duration, easing, and time domain
DissolveEffect / DissolveConfigNoise, directional, radial, or mask-backed dissolve and reveal
SquashStretchEffect / SquashStretchConfigTransform-driven squash/stretch with area preservation and anchor compensation
PaletteSwap / PaletteConfigExact palette-bank remap using a row-based lookup texture
OutlineEffect / OutlineConfigAlpha-edge outline rendered by the proxy shader around the authored sprite
SilhouetteEffect / SilhouetteConfigProxy-backed silhouette tint with configurable sort offset
SpriteEffectFinishedOptional completion message for flash, dissolve, and squash/stretch
SpriteEffectsDiagnosticsRuntime 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:

  • FlashEffect with FlashBlendMode::Tint stays on the native Sprite.color path when no shader-only effect is active on that entity.
  • SquashStretchEffect always stays on the CPU transform path.
  • DissolveEffect, PaletteSwap, OutlineEffect, SilhouetteEffect, and FlashEffect with FlashBlendMode::Screen create an internal proxy child using Mesh2d + 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.

ChannelPolicy
FlashEffectReapplying the component immediately restarts the flash timer
DissolveEffectReapplying the component immediately replaces the active dissolve state
SquashStretchEffectReapplying the component immediately restarts the squash/stretch envelope
PaletteSwapPersistent authored state; changing the config updates the proxy material on the next frame
OutlineEffectPersistent authored state; changing the config updates the proxy material on the next frame
SilhouetteEffectPersistent 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::GlobalScaled reads Time<Virtual>
  • EffectTimeDomain::Unscaled reads Time<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 = true and prefer ImagePlugin::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

  • OutlineEffect samples neighboring source texels around the current atlas frame and draws only where the base sprite alpha is below alpha_threshold.
  • OutlineConfig::width_pixels is measured in source-texture texels, so 1.0 gives a crisp one-texel outline on unscaled pixel art.
  • SilhouetteEffect tints only sprite pixels that pass the configured alpha threshold.
  • SilhouetteConfig::sort_offset nudges 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.

ExamplePurposeRun
basicMinimal hybrid showcasecargo run -p saddle-rendering-sprite-effects-example-basic
flashNative tint flash versus proxy-backed screen flashcargo run -p saddle-rendering-sprite-effects-example-flash
dissolveNoise, radial, and authored-mask dissolvescargo run -p saddle-rendering-sprite-effects-example-dissolve
palette_swapPalette-bank cycling for team/status variantscargo run -p saddle-rendering-sprite-effects-example-palette-swap
outline_silhouetteReadability-focused outline and silhouette layering through foreground occluderscargo run -p saddle-rendering-sprite-effects-example-outline-silhouette
atlas_animationAtlas animation compatibility while proxy effects are activecargo run -p saddle-rendering-sprite-effects-example-atlas-animation
stressDense proxy and effect lifetime stress passcargo 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-lab
cargo run -p saddle-rendering-sprite-effects-lab

Focused 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_silhouette
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_silhouette

Limitations 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::columns is 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_feel for higher-level orchestration.

More detail lives in architecture.md and configuration.md.