Animating Sprites using Bevy’s AnimationPlayer
Getting the full power of AnimationPlayer for your sprite animations
Animating Sprites using Bevy’s AnimationPlayer
Getting the full power of AnimationPlayer for your sprite animations

Animated sprite using Bevy’s AnimationPlayer
Recently I’ve been playing around with Rust and Bevy, experimenting with game development and being fascinated by Bevy’s ECS (Entity Component System) architecture. And I wanted to share some thoughts on how to animate 2D sprites using Bevy’s AnimationPlayer component.
Check out https://bevy.org/ for more information about Bevy and its architecture. Bevy is still in development, but has most of the important stuff in place for making games in Rust.
Just want to look at some code right away? Check out a complete example application over at https://github.com/thomsmed/rust-examples/tree/main/bevy-animated-sprites.
Sprite
For rendering sprites in your 2D game, Bevy provides the Sprite component. Point it to a sprite sheet (using TextureAtlas), and put it on your game entities. Bevy will do the rest!
fn main() -> AppExit {
App::new()
.add_plugins(DefaultPlugins.set(
ImagePlugin::default_nearest(), // Makes sprites/images crisp
))
.add_systems(Startup, setup)
.run()
}
// ---
fn setup(
mut commands: Commands,
asset_server: ResMut<AssetServer>,
mut texture_atlas_layouts: ResMut<Assets<TextureAtlasLayout>>,
mut animations: ResMut<Assets<AnimationClip>>,
mut graphs: ResMut<Assets<AnimationGraph>>,
) {
// # Camera
commands.spawn((
Name::new("Camera"),
Camera2d,
Transform::default()
.with_translation(Vec3::new(0.0, 32.0, 0.0))
.with_scale(Vec3::splat(0.2)),
));
// # Character
const CHARACTER_PIXEL_HEIGHT: u32 = 24;
let character_name = Name::new("Character");
let character_entity = commands.spawn(character_name.clone()).id();
let sprite_sheet_num_rows = 1;
let sprite_sheet_num_columns = 7;
let sprite_sheet_section_size = CHARACTER_PIXEL_HEIGHT;
let texture_handle = asset_server.load("gabe-idle-run.png");
let texture_atlas_layout = TextureAtlasLayout::from_grid(
UVec2::splat(sprite_sheet_section_size),
sprite_sheet_num_columns,
sprite_sheet_num_rows,
None,
None,
);
let texture_atlas_layout_handle = texture_atlas_layouts.add(texture_atlas_layout);
commands.entity(character_entity).insert((
character_name.clone(),
Sprite::from_atlas_image(
texture_handle.clone(),
TextureAtlas {
layout: texture_atlas_layout_handle.clone(),
index: 0,
},
),
Transform::from_xyz(0.0, (CHARACTER_PIXEL_HEIGHT / 2) as f32, 0.0),
));
}
To change the current sprite rendered (as a section of your sprite sheet), change the index of your Sprite’s TextureAtlas.
fn main() -> AppExit {
App::new()
.add_plugins(DefaultPlugins.set(
ImagePlugin::default_nearest(), // Makes sprites/images crisp
))
.add_systems(Startup, setup)
.add_systems(Update, update_texture_atlas_index)
.run()
}
// ---
fn update_texture_atlas_index(
sprites: Query<&mut Sprite>,
mut timer: Local<Timer>,
time: Res<Time>,
) {
if timer.tick(time.delta()).is_finished() {
timer.reset();
timer.set_duration(Duration::from_secs_f32(1.0)); // 1 seconds timer duration
} else {
return;
}
for mut sprite in sprites {
let Some(texture_atlas) = &mut sprite.texture_atlas else {
continue;
};
// Jump to next index every 1 seconds (assuming 1..5 are valid indices)
if texture_atlas.index > 4 {
texture_atlas.index = 0;
} else {
texture_atlas.index += 1;
}
}
}
AnimationPlayer
The primary tool for driving animations in Bevy, is the AnimationPlayer component. Pair it with an AnimationGraphHandle, referencing an AnimationGraph populated with relevant AnimationClips, and you are ready to animate!
A lot of values are animatable out of the box, but not integers. And therefore not the index of a Sprite’s TextureAtlas.
AnimationPlayer has a lot of neat functionality, including the possibility to publish (Animation) Events during an animation. Which is awesome if you want to sync audio, visual effects, game logic or other stuff with your animations. So if we could somehow find a way to use AnimationPlayer to run sprite animations, that would be great!
AnimatedSprite
One solution I’ve had success with so far, is to define a new type to hold the currently selected index of a TextureAtlast. And then make that type animatable (in order to define animations around that type). A custom system will then make sure to sync the animated index with the selected index under a Sprite’s TextureAtlas.
// Putting everything into its own plugin!
pub struct AnimatedSpritePlugin;
impl Plugin for AnimatedSpritePlugin {
fn build(&self, app: &mut App) {
app.add_systems(Update, update_texture_atlas_index);
}
}
#[derive(Reflect, Deref, Clone, Default, Debug)]
pub struct TextureAtlasIndex(pub usize);
impl TextureAtlasIndex {
pub fn new(index: usize) -> Self {
Self(index)
}
}
impl Animatable for TextureAtlasIndex {
fn interpolate(a: &Self, b: &Self, time: f32) -> Self {
if time < 1.0 { a.clone() } else { b.clone() }
}
fn blend(inputs: impl Iterator<Item = BlendInput<Self>>) -> Self {
inputs
.max_by_key(|x| FloatOrd(x.weight))
.map_or(Self::default(), |x| x.value)
}
}
#[derive(Component, Reflect, Clone, Debug)]
#[require(Sprite)]
pub struct AnimatedSprite {
pub index: TextureAtlasIndex,
}
impl AnimatedSprite {
pub fn from_index(index: usize) -> Self {
Self {
index: TextureAtlasIndex::new(index),
}
}
}
impl Default for AnimatedSprite {
fn default() -> Self {
Self {
index: TextureAtlasIndex::new(0),
}
}
}
fn update_texture_atlas_index(
sprites: Query<(&mut Sprite, &AnimatedSprite)>,
) {
for (mut sprite, animated_sprite) in sprites {
let inner_sprite = sprite.bypass_change_detection();
let Some(texture_atlas) = &mut inner_sprite.texture_atlas else {
continue;
};
let new_index = *animated_sprite.index;
if new_index != texture_atlas.index {
texture_atlas.index = new_index;
sprite.set_changed();
}
}
}
Then we can build Curves to describe how the Sprite’s TextureAtlas index should change over time, using for example AnimatableKeyFrameCurve.
const CHARACTER_RUN_ANIMATION_DURATION: f32 = 0.6; // 0.6 seconds
const CHARACTER_RUN_ANIMATION_SECONDS_PER_FRAME: f32 = CHARACTER_RUN_ANIMATION_DURATION / 6.0; // 6 sprite frames to animate over
let character_run_keyframe_curve = AnimatableKeyframeCurve::new([
(0.0, TextureAtlasIndex::new(1)),
(CHARACTER_RUN_ANIMATION_SECONDS_PER_FRAME * 1.0, TextureAtlasIndex::new(2)),
(CHARACTER_RUN_ANIMATION_SECONDS_PER_FRAME * 2.0, TextureAtlasIndex::new(3)),
(CHARACTER_RUN_ANIMATION_SECONDS_PER_FRAME * 3.0, TextureAtlasIndex::new(4)),
(CHARACTER_RUN_ANIMATION_SECONDS_PER_FRAME * 4.0, TextureAtlasIndex::new(5)),
(CHARACTER_RUN_ANIMATION_SECONDS_PER_FRAME * 5.0, TextureAtlasIndex::new(6)),
(CHARACTER_RUN_ANIMATION_DURATION, TextureAtlasIndex::new(6)),
])
.expect("Should be valid keyframes");
Wrap it in AnimatableCurve, add it to an AnimationClip and register the clip in an AnimationGraph, and we are good to go. Even register AnimationEvents!
fn main() -> AppExit {
App::new()
.add_plugins(DefaultPlugins.set(
ImagePlugin::default_nearest(), // Makes sprites/images crisp
))
.add_systems(Startup, setup)
.add_systems(Update, update_texture_atlas_index)
.add_observer(on_character_step) // Observe the 'CharacterStep' AnimationEvent!
.run()
}
#[derive(Resource)]
struct CharacterAnimations {
run: AnimationNodeIndex,
}
#[derive(AnimationEvent, Clone)]
struct CharacterStep;
fn setup(
mut commands: Commands,
asset_server: ResMut<AssetServer>,
mut texture_atlas_layouts: ResMut<Assets<TextureAtlasLayout>>,
mut animations: ResMut<Assets<AnimationClip>>,
mut graphs: ResMut<Assets<AnimationGraph>>,
) {
// # Camera
commands.spawn((
Name::new("Camera"),
Camera2d,
Transform::default()
.with_translation(Vec3::new(0.0, 32.0, 0.0))
.with_scale(Vec3::splat(0.2)),
));
// # Character entity
let character_name = Name::new("Character");
let character_entity = commands.spawn(character_name.clone()).id();
let sprite_sheet_num_rows = 1;
let sprite_sheet_num_columns = 7;
let sprite_sheet_section_size = CHARACTER_PIXEL_HEIGHT;
let texture_handle = asset_server.load("gabe-idle-run.png");
let texture_atlas_layout = TextureAtlasLayout::from_grid(
UVec2::splat(sprite_sheet_section_size),
sprite_sheet_num_columns,
sprite_sheet_num_rows,
None,
None,
);
let texture_atlas_layout_handle = texture_atlas_layouts.add(texture_atlas_layout);
let initial_section_index = 0;
commands.entity(character_entity).insert((
character_name.clone(),
Sprite::from_atlas_image(
texture_handle.clone(),
TextureAtlas {
layout: texture_atlas_layout_handle.clone(),
index: initial_section_index,
},
),
AnimatedSprite::from_index(initial_section_index),
Transform::from_xyz(0.0, (CHARACTER_PIXEL_HEIGHT / 2) as f32, 0.0),
));
// # Character animations
let character_animation_target_id = AnimationTargetId::from_name(&character_name);
let mut character_animation_graph = AnimationGraph::new();
// ## Character run animation
const CHARACTER_RUN_ANIMATION_DURATION: f32 = 0.6; // 0.6 seconds
let mut character_run_animation_clip = AnimationClip::default();
// ### Animate character sprite
const CHARACTER_RUN_ANIMATION_SECONDS_PER_FRAME: f32 = CHARACTER_RUN_ANIMATION_DURATION / 6.0; // 6 sprite frames to animate over
let character_run_keyframe_curve = AnimatableKeyframeCurve::new([
(0.0, TextureAtlasIndex::new(1)), // Foot touches ground
(CHARACTER_RUN_ANIMATION_SECONDS_PER_FRAME * 1.0, TextureAtlasIndex::new(2)),
(CHARACTER_RUN_ANIMATION_SECONDS_PER_FRAME * 2.0, TextureAtlasIndex::new(3)),
(CHARACTER_RUN_ANIMATION_SECONDS_PER_FRAME * 3.0, TextureAtlasIndex::new(4)), // Foot touches ground
(CHARACTER_RUN_ANIMATION_SECONDS_PER_FRAME * 4.0, TextureAtlasIndex::new(5)),
(CHARACTER_RUN_ANIMATION_SECONDS_PER_FRAME * 5.0, TextureAtlasIndex::new(6)),
(CHARACTER_RUN_ANIMATION_DURATION, TextureAtlasIndex::new(6)),
])
.expect("Should be valid keyframes");
let character_run_sprite_animation_curve = AnimatableCurve::new(
animated_field!(AnimatedSprite::index),
character_run_keyframe_curve,
);
character_run_animation_clip.add_curve_to_target(
character_animation_target_id,
character_run_sprite_animation_curve,
);
// ### Animate character transform
let character_run_bounce_curve = FunctionCurve::new(
Interval::new(0.0, CHARACTER_RUN_ANIMATION_DURATION).unwrap(),
|t| {
// y = 1 + cos(PI + t * K),
// where K is some constant making y(0) = 0.0 and y(CHARACTER_RUN_ANIMATION_DURATION) = 0.0,
// meaning PI + t * K = PI for t = 0.0,
// and PI + t * K = 5PI for t = CHARACTER_RUN_ANIMATION_DURATION.
// That gives: K = 4PI / CHARACTER_RUN_ANIMATION_DURATION
const K: f32 = (4.0 * PI) / CHARACTER_RUN_ANIMATION_DURATION;
Vec3::new(0.0, 1.0 + (PI + t * K).cos(), 0.0)
},
);
let character_run_bounce_animation_curve = AnimatableCurve::new(
animated_field!(AnimatedSprite::translation),
character_run_bounce_curve,
);
character_run_animation_clip.add_curve_to_target(
character_animation_target_id,
character_run_bounce_animation_curve,
);
// ### Animation event(s)
character_run_animation_clip.add_event(
0.0, // Matches sprite frame where foot touches ground
CharacterStep,
);
character_run_animation_clip.add_event(
CHARACTER_RUN_ANIMATION_SECONDS_PER_FRAME * 3.0, // Matches sprite frame where foot touches ground
CharacterStep,
);
// ## Remember animation node indices
let character_run_animation_node_index = character_animation_graph.add_clip(
animations.add(character_run_animation_clip),
0.0,
character_animation_graph.root,
);
// ## Register animation clip
commands.insert_resource(CharacterAnimations {
run: character_run_animation_node_index,
});
// ## Animation target
commands.entity(character_entity).insert((
character_animation_target_id,
AnimatedBy(character_entity), // The character entity animates itself
));
// ## Animation player (often not the same as the target, but in our case it is)
commands.entity(character_entity).insert((
AnimationPlayer::default(),
AnimationGraphHandle(graphs.add(character_animation_graph)),
));
}
// ---
fn on_character_step(
event: On<CharacterStep>,
characters: Query<Entity, With<AnimatedSprite>>,
) {
let Ok(character) = characters.get(event.trigger().target) else {
return;
};
info!("The character ({}) took a step!", character);
}
Final words
There it is! My take on how to animate sprites in Bevy, and getting the full power of Bevy’s AnimationPlayer.
Again, check out https://github.com/thomsmed/rust-examples/tree/main/bevy-animated-sprites for a complete example application.
Happy coding! 😄
메타데이터
- post_id
- fa715d2c0815
- slug
- animating-sprites-using-bevys-animationplayer-fa715d2c0815
- url
- https://medium.com/@thomsmed/animating-sprites-using-bevys-animationplayer-fa715d2c0815
- canonical_url
- https://medium.com/@thomsmed/animating-sprites-using-bevys-animationplayer-fa715d2c0815
- author_url
- https://medium.com/@thomsmed
- status
- ok
- fetched_at
- 2026-06-09 15:37:30