SDL2 Sprite Batching: Atlas Textures vs RenderGeometry
Reduce draw calls in 2D SDL2 games by batching sprites into atlas textures and vertex buffers.
23 Mar 2026, 19:09 UTC

You add a few hundred sprites to your 2D game and frame time jumps from two milliseconds to fourteen. The problem is rarely the sprite count itself; it is the draw call count. One SDL_RenderCopy per sprite means one command submitted to the GPU per sprite, and driver overhead accumulates quickly.
The practical takeaway is to pack sprites into a single atlas texture and submit them in larger batches. In SDL2 you can do that with a streaming texture plus SDL_RenderCopy, or skip per-sprite copies entirely with SDL_RenderGeometry. The right choice depends on whether your sprites change every frame and how much CPU bookkeeping you want to handle.
Why One Draw Call Per Sprite Hurts
SDL_Renderer abstracts OpenGL, Direct3D, Metal, or Vulkan behind a unified interface. That convenience hides a cost: every SDL_RenderCopy is a draw call. The driver must validate state, bind textures, and submit commands for each one. With ten thousand sprites, you are asking the driver to do ten thousand separate submissions per frame.
Measure this before optimizing. Use SDL_GetPerformanceCounter around your render loop to get frame time, and compare it against your monitor's refresh interval (about 16.6 ms for 60 Hz). If the CPU side of rendering dominates your profile, draw calls are a likely suspect.
Sprite Atlas with a Streaming Texture
Pack your sprite frames into one large texture. Create it with SDL_TEXTUREACCESS_STREAMING so you can upload new pixel data each frame with SDL_UpdateTexture instead of destroying and recreating the texture. Then draw each sprite with SDL_RenderCopy, passing different source and destination rectangles against the same atlas.
This reduces texture binds and state changes, but it does not eliminate per-sprite draw calls. It is a good fit when sprite pixels change every frame, such as animated characters or particle systems, because you can rewrite the texture buffer and keep drawing.
Fewer Draw Calls with SDL_RenderGeometry
SDL_RenderGeometry submits custom vertex and index buffers directly to the renderer. If all your sprites share the same atlas texture, you can build one vertex buffer containing quads for many sprites and issue a single draw call for the whole batch.
The trade-off is CPU bookkeeping. You must generate vertex positions, texture coordinates, and indices yourself, and update them when sprites move. This approach works best for many relatively static sprites or UI elements that share a texture atlas.
Worked Example: Minimal SDL2 Sprite Batcher
Run this in a terminal as a normal user. Compile with:
cc -o sprite_batcher sprite_batcher.c -lSDL2
The example assumes SDL2 is installed. It creates a window, requests a hardware-accelerated renderer with vsync, and allocates a streaming atlas texture. If hardware acceleration is unavailable, it falls back to software rendering and prints the SDL error.
#include <SDL2/SDL.h>
#include <stdio.h>
#define WINDOW_W 1280
#define WINDOW_H 720
#define ATLAS_W 2048
#define ATLAS_H 2048
int main(int argc, char **argv) {
(void)argc; (void)argv;
if (SDL_Init(SDL_INIT_VIDEO) != 0) {
fprintf(stderr, "SDL_Init failed: %s\n", SDL_GetError());
return 1;
}
SDL_Window *window = SDL_CreateWindow("Sprite Batcher",
SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED,
WINDOW_W, WINDOW_H, SDL_WINDOW_SHOWN);
if (!window) {
fprintf(stderr, "SDL_CreateWindow failed: %s\n", SDL_GetError());
SDL_Quit();
return 1;
}
/* Request hardware acceleration and present vsync. */
SDL_Renderer *renderer = SDL_CreateRenderer(window, -1,
SDL_RENDERER_ACCELERATED | SDL_RENDERER_PRESENTVSYNC);
if (!renderer) {
fprintf(stderr, "Hardware renderer unavailable: %s\n", SDL_GetError());
renderer = SDL_CreateRenderer(window, -1, SDL_RENDERER_SOFTWARE);
if (!renderer) {
fprintf(stderr, "SDL_CreateRenderer failed: %s\n", SDL_GetError());
SDL_DestroyWindow(window);
SDL_Quit();
return 1;
}
}
/* Streaming texture: CPU writes pixels, GPU reads them for drawing. */
SDL_Texture *atlas = SDL_CreateTexture(renderer, SDL_PIXELFORMAT_RGBA8888,
SDL_TEXTUREACCESS_STREAMING, ATLAS_W, ATLAS_H);
if (!atlas) {
fprintf(stderr, "SDL_CreateTexture failed: %s\n", SDL_GetError());
SDL_DestroyRenderer(renderer);
SDL_DestroyWindow(window);
SDL_Quit();
return 1;
}
/* Check for errors after creation. SDL_GetError should return an empty
string on success. */
const char *err = SDL_GetError();
if (err[0] != '\0') {
fprintf(stderr, "SDL error after setup: %s\n", err);
}
/* Per frame:
1. SDL_UpdateTexture(atlas, NULL, pixels, pitch);
2. SDL_RenderClear(renderer);
3. SDL_RenderCopy(renderer, atlas, &src_rect, &dst_rect) per sprite;
4. SDL_RenderPresent(renderer);
*/
SDL_DestroyTexture(atlas);
SDL_DestroyRenderer(renderer);
SDL_DestroyWindow(window);
SDL_Quit();
return 0;
}
Expected check: the window opens and SDL_GetError reports nothing after renderer and texture creation. A risk is using the wrong texture access flag; SDL_UpdateTexture on a non-streaming texture is undefined behavior or a performance penalty depending on the driver.
Trade-offs and How to Verify
- Streaming texture plus
SDL_RenderCopy: simpler and safe for per-frame pixel changes, but still one draw call per sprite. SDL_RenderGeometry: far fewer draw calls for sprites sharing one atlas, but requires manual vertex buffer management and is less convenient for rapidly animating textures.- Avoid mixing software
SDL_Surfacerendering with a hardware renderer, and avoid reading pixels back from the GPU; both can stall the pipeline.
To verify the result, measure frame time with SDL_GetPerformanceCounter before and after enabling batching. Target under 16 ms per frame for 60 fps. Toggle SDL_RENDERER_PRESENTVSYNC to confirm presentation matches your monitor refresh rate. If you change renderer flags or texture access, rebuild and re-measure.
Start with an atlas and SDL_RenderCopy if your sprites change every frame. Move to SDL_RenderGeometry once profiling shows draw calls, not shader work, are the bottleneck. Measure first, then batch.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.