Bevy Sprites
A Sprite represents an image we want to render. It holds the details of a particular texture (e.g an image). Position, rotation and scale come from its required Transform component.
When we say "textures" we really mean any image data represented for raster graphics which uses a two-dimensional picture as a matrix of pixel color values.
We load these images into our assets and add them to entities with a Sprite component which will render them onto our game using your GPU.
Rendering a sprite
To render a sprite we need a Camera and an entity holding a Sprite component:
fn setup_camera(mut commands: Commands) {
commands.spawn(Camera2d);
}
fn spawn_simple_sprite(mut commands: Commands, asset_server: Res<AssetServer>) {
let image_handle = asset_server.load("sprites/ball.png");
let sprite = Sprite::from_image(image_handle);
commands.spawn(sprite);
}
This will spawn an image in the center of the screen. The size of the sprite will be its natural image dimensions. It's given these properties because Transform is a required component and so will use the default unless we override it with our own.
Bevy will load your assets from the ./assets folder by default. So when we write:
asset_server.load("sprites/ball.png")
Bevy will load ./assets/sprites/ball.png. The load method will return a Handle<Image> which we add as the image field on a Sprite.
Remember that your AssetServer will not immediately load the image (even though it will feel that fast). Instead, it returns a handle component: Handle<Image> and adds it to our entity. The sprite won't actually render until the image has been fully loaded.
We can control the size of our sprite by providing a custom one when we are adding the sprite:
fn spawn_player_with_custom_size(
mut commands: Commands,
asset_server: Res<AssetServer>,
) {
commands.spawn(Sprite {
image: asset_server.load("sprites/ball.png"),
custom_size: Some(Vec2::new(100., 100.)),
..default()
});
}
We could also have chosen to scale the sprite with its Transform. The difference is that custom_size only changes how large the sprite image is drawn, while a Transform scale transforms the whole entity, including its children and any other components. custom_size is usually what you want when only the sprite itself should change size.
If you are prototyping you can use Sprite::from_color to draw a solid colored rectangle until you replace it with a real asset later in your project:
let sprite = Sprite::from_color(Color::srgb(0.2, 0.8, 0.4), Vec2::splat(32.));
Anchors
If you had your camera centered at (0, 0) you would notice the sprite shows up in the dead center of the screen. That's because the default Anchor for a sprite is to be Anchor::CENTER.
Anchor is a required component on your sprite. It's a struct wrapping a Vec2:
// https://github.com/bevyengine/bevy/blob/main/crates/bevy_sprite/src/sprite.rs
pub struct Anchor(pub Vec2);
It comes with a few pre-built associated constants:
Anchor::CENTERAnchor::BOTTOM_LEFTAnchor::BOTTOM_CENTERAnchor::BOTTOM_RIGHTAnchor::CENTER_LEFTAnchor::CENTER_RIGHTAnchor::TOP_LEFTAnchor::TOP_CENTERAnchor::TOP_RIGHT
Anchor is not in the prelude, so you need to import it and add it as its own component alongside the sprite:
use bevy::{
asset::LoadedFolder,
prelude::*,
sprite::{Anchor, SpriteAlphaMode},
};
fn spawn_anchored_sprite(
mut commands: Commands,
asset_server: Res<AssetServer>,
) {
commands.spawn((
Sprite {
image: asset_server.load("sprites/ball.png"),
..default()
},
// `Anchor` is a separate required component, not a field on `Sprite`
Anchor::BOTTOM_LEFT,
));
}
The Vec2 value will be scaled by the size of the sprite. So you do not need to know the exact physical size of your sprite, you just use relative values.
So top left is (-0.5, 0.5) and center is (0., 0.).
Stacking
When you spawn a sprite, the z value of its Transform determines the order it will be drawn.
If you want one sprite to render in front of another you need to set their z-index to be higher than the one you want in the background.
fn setup_game(mut commands: Commands, asset_server: Res<AssetServer>) {
// z-index is 0. so it will get spawned behind
commands.spawn((
Sprite {
image: asset_server.load("sprites/grass.png"),
..default()
},
Tile,
));
// z-index is 1. so it will get spawned in front of our grass tile
commands.spawn((
Transform::from_xyz(0., 0., 1.),
Sprite {
image: asset_server.load("sprites/ball.png"),
..default()
},
Player,
));
}
This will show the ball.png sprite on top of the grass.png sprite.
Scaling
Sprites are normally scaled by being stretched or shrunk based on the original size of the image. These can be configured by setting the SpriteImageMode, which defaults to SpriteImageMode::Auto.
SpriteImageMode can be one of:
Auto: stretches or shrinks the image to fitcustom_sizeScale(SpriteScalingMode): scales the image while preserving its aspect ratioSliced(TextureSlicer): cuts the image into 9 slices so borders keep their proportions on resizeTiled { .. }: repeats the image when it is stretched past a threshold
For example, if we wanted to tile the sprite in a grid we can change the SpriteImageMode:
fn spawn_tiled_sprite(mut commands: Commands, asset_server: Res<AssetServer>) {
commands.spawn(Sprite {
image: asset_server.load("sprites/ball.png"),
// Tiling only has an effect once the drawn size is larger than a tile
custom_size: Some(Vec2::splat(512.)),
image_mode: SpriteImageMode::Tiled {
tile_x: true,
tile_y: true,
// Each repeated tile is half the size of the original image
stretch_value: 0.5,
},
..default()
});
}
A tiled sprite only repeats once it is drawn larger than a single tile. Here stretch_value: 0.5 makes each repeated tile half the size of the original image, so the custom_size gives it room to repeat.
Tinting, flipping and cropping
The Sprite component has a few other useful fields. color tints the image, flip_x and flip_y mirror it, rect draws only part of the image without needing a texture atlas, and alpha_mode controls how transparency is handled:
let sprite = Sprite {
image: asset_server.load("sprites/ball.png"),
color: Color::srgb(1.0, 0.5, 0.5),
flip_x: true,
// Draw the top left 32x32 pixels of the image
rect: Some(Rect::new(0., 0., 32., 32.)),
// Opaque, Mask(threshold) or Blend (the default)
alpha_mode: SpriteAlphaMode::Blend,
..default()
};
There are also a couple of constructors that save some typing: Sprite::sized(Vec2::splat(64.)) for a plain white sprite of a given size, and Sprite::from_atlas_image(image, atlas) for a sprite sheet frame.
Alpha modes
The alpha_mode field of a Sprite controls how its transparency is resolved:
SpriteAlphaMode::Blend: (the default) alpha blends the sprite with whatever is behind it.SpriteAlphaMode::Opaque: ignores the image's alpha and draws the sprite fully opaque. It is the fastest option.SpriteAlphaMode::Mask(threshold): discards each pixel whose alpha is belowthreshold, giving crisp cut-out edges without any blending.
use bevy::{
asset::LoadedFolder,
prelude::*,
sprite::{Anchor, SpriteAlphaMode},
};
fn spawn_sprite_with_alpha_mode(
mut commands: Commands,
asset_server: Res<AssetServer>,
) {
commands.spawn(Sprite {
image: asset_server.load("sprites/ball.png"),
// Discard pixels whose alpha is below 50%
alpha_mode: SpriteAlphaMode::Mask(0.5),
..default()
});
}
Blend is significantly more expensive than the other two, so prefer Opaque or Mask whenever your art allows it.
Sprite sheets
A sprite sheet packs many small images into one big image. Instead of loading and unloading each small image separately, we can read a small part of the big image to get what we want.
Sprite sheets are often used to store keyframes for an animation. Playing the animation involves sliding a window over the sprite sheet. To know how far to slide we need to know the layout of the sprite sheet.
We store this information in a TextureAtlasLayout which acts as a map. Its an Asset that holds a list of URect, each representing a single image in pixels.
The window we are sliding is the actual TextureAtlas that acts as a pointer into our map. It holds the Handle<TextureAtlasLayout> and the index of the current image we want to show. Increasing the index will slide the window to the next image in the sprite sheet.
To load a spritesheet, a common pattern is to do the loading in a resource that you define FromWorld for. Bevy will call this trait's from_world whenever you initialize the resource:
#[derive(Resource)]
struct PlayerSpriteSheet {
image: Handle<Image>,
layout: Handle<TextureAtlasLayout>,
}
impl FromWorld for PlayerSpriteSheet {
fn from_world(world: &mut World) -> Self {
let image = world
.resource::<AssetServer>()
.load("animations/player.png");
// A single row of seven 24x24 frames
let layout = TextureAtlasLayout::from_grid(
UVec2::splat(24), // Size of one frame
7, // Columns
1, // Rows
None, // Padding between frames
None, // Offset from the sheet's edge
);
let layout = world
.resource_mut::<Assets<TextureAtlasLayout>>()
.add(layout);
Self { image, layout }
}
}
fn main() {}
We are asking for a uniform grid of one row with seven 24x24 frames and no padding or offset. Frames start on the top left and end on the bottom right.
We can then spawn a sprite from the sheet with Sprite::from_atlas_image:
fn spawn_sprite_from_atlas(
mut commands: Commands,
sheet: Res<PlayerSpriteSheet>,
) {
commands.spawn(Sprite::from_atlas_image(
sheet.image.clone(),
TextureAtlas {
layout: sheet.layout.clone(),
index: 0, // The frame to draw; `0` is the first frame
},
));
}
An actual animation could be as simple as a timer that slides the window:
#[derive(Component, Deref, DerefMut)]
struct AnimationTimer(Timer);
fn spawn_player_sprite(mut commands: Commands, sheet: Res<PlayerSpriteSheet>) {
commands.spawn((
Sprite::from_atlas_image(
sheet.image.clone(),
TextureAtlas {
layout: sheet.layout.clone(),
index: 0, // The frame to draw; `0` is the first frame
},
),
Player,
AnimationTimer(Timer::from_seconds(0.1, TimerMode::Repeating)),
));
}
fn animate_sprite_sheet(
time: Res<Time>,
sheet: Res<PlayerSpriteSheet>,
layouts: Res<Assets<TextureAtlasLayout>>,
mut players: Query<(&mut Sprite, &mut AnimationTimer), With<Player>>,
) {
let Some(layout) = layouts.get(&sheet.layout) else {
return;
};
let frame_count = layout.len();
for (mut sprite, mut timer) in &mut players {
timer.tick(time.delta());
if timer.just_finished()
&& let Some(atlas) = &mut sprite.texture_atlas
{
// Wrap around the index with modulo to loop through the frames
let next_frame = (atlas.index + 1) % frame_count;
atlas.index = next_frame;
}
}
}
A TextureAtlas assumes you will give it a single image file and it will split that up into many textures. Some workflows separate sprite sheets into many separate files.
If we have many separate images and want to combine them into a single sprite sheet, we can use a TextureAtlasBuilder.
First, we need to load our textures sometime early in our game. This could be done in a FromWorld implementation from the example above, but for variety's sake we will load it from a startup system:
#[derive(Resource, Default)]
struct RpgSpriteFolder(Handle<LoadedFolder>);
fn load_textures(mut commands: Commands, asset_server: Res<AssetServer>) {
let folder = RpgSpriteFolder(asset_server.load_folder("textures/rpg"));
// load multiple, individual sprites from a folder
commands.insert_resource(folder);
}
This is going to load every file in ./assets/textures/rpg.
Later, after they have loaded we can create our texture atlas. We add both the packed atlas image and the layout that describes it as assets, then use the TextureAtlasSources to recover a TextureAtlas for any of the images we added:
fn create_texture_atlas(
mut commands: Commands,
loaded_folders: Res<Assets<LoadedFolder>>,
rpg_sprite_handles: Res<RpgSpriteFolder>,
mut textures: ResMut<Assets<Image>>,
mut texture_atlases: ResMut<Assets<TextureAtlasLayout>>,
) {
let mut builder = TextureAtlasBuilder::default();
let folder = loaded_folders.get(&rpg_sprite_handles.0).unwrap();
for handle in folder.handles.iter() {
let id = handle.id().typed_unchecked::<Image>();
let Some(texture) = textures.get(id) else {
warn!("Texture not loaded: {:?}", handle.path().unwrap());
continue;
};
builder.add_texture(Some(id), texture);
}
let (layout, sources, texture) = builder.build().unwrap();
// Both the packed atlas image and the layout describing it are assets
let atlas_texture = textures.add(texture);
let atlas_layout = texture_atlases.add(layout);
// The sources map lets us recover a `TextureAtlas` for any image we
// added, which we can then attach to a sprite
let source = folder.handles.first().unwrap();
let source_id = source.id().typed_unchecked::<Image>();
let atlas = sources.handle(atlas_layout, source_id).unwrap();
commands.spawn(Sprite::from_atlas_image(atlas_texture, atlas));
}
For advanced use cases like font atlases or dynamic shadow maps there is also the DynamicTextureAtlasBuilder which can be updated at runtime cheaply instead of being finalized. However its use for sprite sheets with animation is not encouraged.
Custom sprite materials
We can give a sprite a custom shader. First we make a material type that implements MaterialExtension2d:
use bevy::{
prelude::*,
render::render_resource::AsBindGroup,
shader::ShaderRef,
};
#[derive(Asset, AsBindGroup, Reflect, Clone)]
struct DissolveMaterial {
// Start at 20 to avoid colliding with the sprite's own bindings.
#[uniform(20)]
amount: f32,
}
impl MaterialExtension2d for DissolveMaterial {
fn fragment_shader() -> Option<ShaderRef> {
Some("shaders/dissolve_material.wesl".into())
}
}
Then register the material with SpriteMaterialPlugin::<M>::default() and add a SpriteMaterial<M> component to your sprite:
fn setup_camera(mut commands: Commands) {
commands.spawn(Camera2d);
}
fn spawn_sprite_material(
mut commands: Commands,
asset_server: Res<AssetServer>,
mut materials: ResMut<Assets<DissolveMaterial>>,
) {
commands.spawn((
Sprite::from_image(asset_server.load("sprites/ball.png")),
SpriteMaterial(materials.add(DissolveMaterial { amount: 0.5 })),
));
}
fn animate_dissolve(
time: Res<Time>,
sprites: Query<&SpriteMaterial<DissolveMaterial>>,
mut materials: ResMut<Assets<DissolveMaterial>>,
) {
for sprite in &sprites {
if let Some(mut material) = materials.get_mut(&sprite.0) {
material.amount = (time.elapsed_secs().sin() + 1.0) / 2.0;
}
}
}
fn main() {
App::new()
.add_plugins((
DefaultPlugins,
SpriteMaterialPlugin::<DissolveMaterial>::default(),
))
.add_systems(Startup, (setup_camera, spawn_sprite_material))
.add_systems(Update, animate_dissolve)
.run();
}
Getting the bounding box of transformed sprites
A sprite's image dimensions are not always the same as its logical size in the game world.
How a sprite renders is determined by its custom_size (or a rect or texture atlas frame), and then by its Transform scale. This means that a sprite's image size in pixels may not be the same as its logical size in our game world.
So to get the true logical size we need to start from the sprite's unscaled size and apply the same scaling the renderer uses. An easy way is to use a Rect to represent it:
fn print_sprite_bounding_boxes(
sprites: Query<(&Transform, &Sprite, &Anchor)>,
images: Res<Assets<Image>>,
atlases: Res<Assets<TextureAtlasLayout>>,
) {
for (transform, sprite, anchor) in &sprites {
// The sprite's unscaled size: `custom_size` wins, then an explicit
// `rect`, then the texture atlas frame, and finally the full image
let Some(image_size) = sprite
.custom_size
.or_else(|| sprite.rect.map(|rect| rect.size()))
.or_else(|| match &sprite.texture_atlas {
None => images.get(&sprite.image).map(Image::size_f32),
Some(atlas) => atlas
.texture_rect(&atlases)
.map(|rect| rect.size().as_vec2()),
})
else {
continue;
};
info!("image_dimensions: {:?}", image_size);
info!("position: {:?}", transform.translation);
info!("scale: {:?}", transform.scale);
let scaled_image_dimension = image_size * transform.scale.truncate();
info!("scaled_image_dimension: {:?}", scaled_image_dimension);
// The anchor offsets the sprite from its `Transform` by a fraction
// of its size
let center = transform.translation.truncate()
- anchor.as_vec() * scaled_image_dimension;
let bounding_box =
Rect::from_center_size(center, scaled_image_dimension);
info!("bounding_box: {:?}", bounding_box);
}
}
For the default scale and center anchor that would log:
image_dimensions: Vec2(288.0, 288.0)
position: Vec3(0.0, 0.0, 0.0)
scale: Vec3(0.1, 0.1, 0.1)
scaled_image_dimension: Vec2(28.800001, 28.800001)
bounding_box: Rect {
min: Vec2(-14.400001, -14.400001),
max: Vec2(14.400001, 14.400001)
}
You usually do not need to compute this yourself. Bevy's calculate_bounds_2d system already inserts an Aabb component for every sprite, taking custom_size, rect, texture atlas frames and Anchor into account. That is the value the engine uses for frustum culling and sprite picking.
Clicking on our sprites
The code above gives us the logical value of the bounds of our sprite in our game world. However, if we wanted to click on it we would run into a big issue: the mouse cursor lives in screen space, not world space.
The best way to get this kind of information is through the built-in picking backend for sprites. However, to illustrate how they work we can see how to do it ourselves.
The actual mouse cursor needs to be projected according to the view of our current Camera, which in the past had to be done manually with normalized device coordinates. Bevy provides Camera::viewport_to_world_2d to do it for us:
fn cast_cursor_ray(
windows: Query<&Window>,
cameras: Query<(&Camera, &GlobalTransform)>,
) {
if let Ok((camera, position)) = cameras.single()
&& let Ok(window) = windows.single()
&& let Some(cursor) = window.cursor_position()
&& let Ok(world_position) = camera.viewport_to_world_2d(position, cursor)
{
info!("World coords: {}/{}", world_position.x, world_position.y);
}
}
A normalized device coordinate is a Vec3 that ranges from (-1, 1) in the x and y directions and (0, 1) in the z axis.
These normalized device coordinates allow us to cast a ray from our mouse to the game world to give the logical value of our mouse in our game world, regardless of how zoomed in or out our camera is.
We could then check if these coordinates are within the Rect from our bounding box to register a clicking event and react to them in other systems.
How sprites are rendered
Sprites are rendered by uploading our textures to the GPU through wgpu.
Since Bevy 0.20 a Sprite renders through the same Mesh2d + Material2d infrastructure as the rest of 2D rendering. SpriteMeshPlugin generates a quad mesh and a SpriteMeshMaterial for each sprite.
To do any post processing we can set a specific Camera to render only our sprites and add our extra processing to it.
Sprites that share a material are batched together by the 2D material pipeline. Because sprites now flow through that shared pipeline, the draw order of sprites that share the same Z can differ. Always give overlapping sprites distinct Z values instead of relying on the order they were spawned.
Bevy will not render sprites that are off screen. Each frame the visibility system frustum-culls entities against the active cameras, storing the result in the per-entity ViewVisibility component. InheritedVisibility tracks whether an entity is hidden by its parents in the hierarchy instead. Sprites whose ViewVisibility says they are not visible are skipped during extraction, so they are never batched or drawn.
Pixel perfect rendering
By default images are sampled with linear interpolation, which makes their edges blur slightly when they are scaled. This can cause "bleed" in your sprites and possibly lines may appear at the edges of your sprites where they shouldn't be.
When working with mostly sprites in a game you'll want to prevent blurry sprites by setting the ImagePlugin to use nearest neighbor sampling:
fn main() {
App::new()
.add_plugins(DefaultPlugins.set(ImagePlugin::default_nearest()))
.add_systems(Startup, setup_camera)
.init_resource::<PlayerSpriteSheet>()
.run();
}
The default is linear interpolation which can leave the edges of sprites looking blurred. Nearest neighbor sampling will keep the hard edges and make sprites look pixel perfect.
If you only want some images to use nearest neighbor sampling, you can instead set the sampler on the individual Image. For example setting:
image.sampler =ImageSampler::nearest();
This can also be done through an image's .meta file.