Cutting Draw Calls in MonoGame with a Texture Atlas and SpriteBatch
SpriteBatch flushes every time the texture changes. Packing sprites into one atlas and drawing with source rectangles collapses hundreds of draw calls into a few—here's how to do it with real MonoGame APIs.
01 Jul 2026, 08:53 UTC

The problem: one texture per sprite means one draw call per sprite
In a 2D MonoGame project, every time SpriteBatch switches textures it must flush its batch and issue a new draw call. If each enemy, bullet, and tile lives in its own Texture2D, a busy scene can produce hundreds of draw calls per frame. On low-end hardware the CPU time spent submitting those calls becomes the bottleneck, and frame rate drops long before the GPU is actually busy.
The standard fix is a texture atlas: one large texture containing many sprites, drawn by passing a source rectangle to SpriteBatch.Draw. Because the texture never changes between sprites, SpriteBatch can keep them all in one batch.
Building the atlas (outside the stock content pipeline)
One caveat up front: the stock MonoGame Content Pipeline does not pack individual PNGs into an atlas for you. There is no built-in sprite-sheet processor that emits a combined texture plus metadata. In practice you generate the atlas with an external tool and import the result as an ordinary texture:
- TexturePacker (commercial) or free alternatives such as Free Texture Packer or libgdx-texturepacker can output a single PNG plus a data file (JSON or XML) mapping sprite names to rectangles.
- Add the atlas PNG to your
.mgcbfile as a normalTexture. Add the data file as a copy content item (or parse it at build time yourself). - Tell the packing tool to add 1–2 pixels of transparent padding around each sprite. Without padding, linear filtering can sample neighboring sprites and cause visible color bleeding at the edges.
Loading and drawing with source rectangles
Load the atlas as a regular texture, parse the rectangle data (a small hand-written parser for the tool's JSON/XML output is usually a few dozen lines), and draw:
Texture2D atlas;
Dictionary<string, Rectangle> frames;
protected override void LoadContent()
{
atlas = Content.Load<Texture2D>("atlas");
// Parse the JSON/XML produced by your packing tool.
frames = AtlasParser.Load("Content/atlas.json");
}
protected override void Draw(GameTime gameTime)
{
GraphicsDevice.Clear(Color.CornflowerBlue);
spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend);
foreach (var enemy in enemies)
spriteBatch.Draw(atlas, enemy.Position, frames["enemy"], Color.White);
spriteBatch.End();
base.Draw(gameTime);
}
With SpriteSortMode.Deferred, SpriteBatch accumulates sprites and flushes when the texture or render state changes, or when End is called. If every sprite comes from the same atlas, the whole loop can typically be submitted as a single batch. Note that this is not a guarantee: switching textures, changing sampler state, or exceeding the internal batch capacity will force additional flushes.
What improvement should you expect?
The draw-call reduction is deterministic: N sprites on N separate textures cost roughly N batches, while N sprites on one atlas can cost one. The frame-rate benefit depends entirely on where your bottleneck is. If you were CPU-bound on draw-call submission (common on single-board computers and older mobile devices), the gain can be large. If you were GPU-fill-bound, expect little change. Treat any specific FPS figures you see in tutorials as illustrative; measure your own scene.
To measure, use the built-in metrics available in recent MonoGame versions (3.8+):
// After spriteBatch.End() and before Present:
var m = GraphicsDevice.Metrics;
debugText = $"Draw calls: {m.DrawCount} Sprites: {m.SpriteCount}";
Run the same scene with per-sprite textures and with the atlas, and compare DrawCount and frame time on your actual target hardware. Frame time in milliseconds is a more honest metric than FPS when comparing.
Trade-offs and limitations
- Texture size limits: GPUs cap texture dimensions (2048×2048 and 4096×4096 are common ceilings; check your target). Oversized atlases must be split, and each atlas switch breaks the batch.
- Memory: One big atlas may waste space versus several tight textures, and padding adds a little overhead.
- Runtime changes: Swapping a sprite means rebuilding the atlas texture, which is expensive. Atlases suit static art; frequently changing images are better kept separate.
- Bleeding: Without padding, linear filtering and mipmaps can pull colors from neighboring sprites.
Actionable closing
Profile first: record GraphicsDevice.Metrics.DrawCount in your current scene. If it is in the hundreds, pack your static sprites with an external tool, add padding, switch to source-rectangle drawing, and re-measure on the slowest device you support. If draw calls collapse and frame time drops, keep the atlas; if not, your bottleneck is elsewhere and the atlas is still worth keeping for simplicity, but look at fill rate or update logic next.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.