Using Bevy's TextureAtlas to Reduce Draw Calls in 2D Sprite Animation
Learn how Bevy’s TextureAtlas packs many sprite frames into one GPU texture, reducing draw calls and simplifying 2D animation, with a complete code example and practical checks.
20 Mar 2026, 20:30 UTC

The problem: too many draw calls for individual sprites
When a Bevy game loads each frame of a character animation as a separate texture, the renderer issues one draw call per sprite. With dozens or hundreds of frames, this quickly adds up, raising CPU overhead and lowering frame rates—especially on low‑end GPUs where draw‑call cost is high.
Thesis: TextureAtlas batches sub‑images into a single GPU texture
Bevy’s TextureAtlas system packs many sub‑images (tiles) into one texture. The renderer can then draw all sprites that use the same atlas with a single draw call, while animation logic only needs to change which tile index is displayed.
How the atlas works
- TextureAtlasLayout describes the grid: tile size, columns, rows, and optional padding.
- The actual texture (the sprite sheet) is loaded as a normal
Handle. - A
SpriteSheetBundlecombines the texture handle, the layout, and aTextureAtlascomponent that stores the current tile index. - Changing the index each frame (via a
Timer) animates the sprite without creating new draw calls.
Worked example: loading a sprite sheet and animating it
Assume you have a PNG sprite sheet named hero.png where each frame is 64×64 pixels, arranged in 8 columns and 4 rows.
- Set up the project (run in a terminal with Rust toolchain installed):
cargo new bevy_atlas_demo cd bevy_atlas_demo # Add Bevy 0.13 with the default features (includes dynamic loading) # Edit Cargo.toml:[dependencies] bevy = { version = "0.13", features = ["dynamic"] } - Place the asset:
mkdir -p assets cp /path/to/hero.png assets/ - Write main.rs:
use bevy::prelude::*; fn main() { App::new() .add_plugins(DefaultPlugins) .add_startup_system(setup) .add_system(animate_sprite) .run(); } fn setup( mut commands: Commands, asset_server: Res, mut texture_atlases: ResMut>, ) { // Load the sprite sheet let texture_handle = asset_server.load("hero.png"); // Describe a uniform grid of 64×64 tiles, 8 columns, 4 rows let layout = TextureAtlasLayout::from_grid(Vec2::new(64.0, 64.0), 8, 4, None, None); let layout_handle = texture_atlases.add(layout); commands.spawn(Camera2dBundle::default()); commands.spawn(SpriteSheetBundle { texture_atlas: texture_handle, atlas: layout_handle, sprite: TextureAtlasSprite::new(0), // start at first frame transform: Transform::from_xyz(0.0, 0.0, 0.0), ..default() }); } fn animate_sprite( time: Res, mut query: Query<(&mut TextureAtlasSprite, &mut Timer)> ) { for (mut sprite, mut timer) in query.iter_mut() { timer.tick(time.delta()); if timer.just_finished() { sprite.index = (sprite.index + 1) % 8; // loop over 8 frames in a row } } }Where to run:
cargo run from the project root. No special permissions are needed beyond the ability to execute the binary.Expected check: Add Bevy’s diagnostics plugin to see draw‑call count:
When the sprite is on screen, the diagnostics overlay should show roughly one draw call for the sprite (plus the clear and UI draws). If you see a draw call per frame that matches the number of sprite instances, the atlas is working..add_plugin(FrameCountDiagnosticsPlugin::default())Trade‑off and limitation
The atlas must be prepared ahead of time (or rebuilt at runtime). This adds an asset‑pipeline step: you cannot arbitrarily swap in a new texture that wasn’t part of the original atlas without recreating the
TextureAtlasLayoutand re‑uploading the GPU texture, which can cause a stall if done frequently.Additionally, the final atlas texture cannot exceed the GPU’s maximum texture size (commonly 4096×4096). If your sprite sheet would produce a larger atlas, Bevy will either split it into multiple atlases or return an error. You can verify the size by checking the loaded image dimensions:
If the product of tile size × tiles exceeds the limit, consider reducing tile size, trimming unused frames, or using multiple atlases.println!("Sheet size: {}x{}", image.width(), image.height());Actionable closing
For most 2D games with a fixed set of sprite sheets, using
TextureAtlasis a straightforward way to cut draw calls and improve performance. Start by measuring your current draw‑call count with the diagnostics plugin, then introduce a uniform grid atlas as shown above. Monitor the diagnostics again; you should see the draw‑call count drop to roughly one per sprite instance. If you hit texture‑size limits or need runtime texture changes, plan to split the atlas or accept the occasional rebuild cost.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.