Animated Sprites on OLED: Frame-by-Frame Bitmap Animation
A static logo on a 0.96" OLED is fine. A walking pixel-art character is delightful. This post covers frame-by-frame sprite animation on SSD1306 (and any Adafruit_GFX-compatible display), from converting a GIF into a multi-frame C array with image2cpp to non-blocking millis()-based animation loops.
The animation model
Frame-by-frame animation on a microcontroller is dead simple:
- Store an array of bitmap frames in PROGMEM
- Track which frame to draw with a counter
- Advance the counter every N milliseconds (using
millis()) - Clear the screen and draw the current frame, call
display.display()
That's the whole pattern. The trick is doing it without blocking other work.
Step 1: prepare frames
Start with a GIF or a stack of PNGs. If you have a GIF:
- Use ImageMagick:
convert anim.gif -coalesce frame_%03d.png - Or extract frames in GIMP: File → Open as Layers, then export each layer as PNG
- Or use ezgif.com's "split frames" tool online
Resize each frame to your target sprite size (e.g. 32x32 for an OLED character).
Step 2: convert each frame in image2cpp
pixel.hjlabs.in/converter supports batch upload — drop in all frames at once and it generates one array per frame. Settings:
- Canvas: 32x32 (or your sprite size)
- Output: "Arduino code, Horizontal, 1 bit per pixel"
- Variable name:
frame_0,frame_1, ... (auto-numbered)
You'll get something like:
const unsigned char frame_0 [] PROGMEM = { 0x00, 0xFF, /* 128 bytes */ };
const unsigned char frame_1 [] PROGMEM = { 0x00, 0xFE, /* 128 bytes */ };
const unsigned char frame_2 [] PROGMEM = { 0x00, 0xFC, /* 128 bytes */ };
const unsigned char frame_3 [] PROGMEM = { 0x00, 0xF8, /* 128 bytes */ };
Step 3: pack frames into a pointer table
Iterating through individually-named frames is awkward. Stuff them into an array of pointers:
const unsigned char* const frames [] PROGMEM = {
frame_0, frame_1, frame_2, frame_3
};
const uint8_t FRAME_COUNT = 4;
Note the double const — the pointers and the data they point to are both in PROGMEM. Read the pointer with pgm_read_ptr:
const unsigned char* current = (const unsigned char*) pgm_read_ptr(&frames[i]);
Step 4: the animation loop
Bad pattern (blocks everything):
void loop() {
for (int i = 0; i < FRAME_COUNT; i++) {
display.clearDisplay();
display.drawBitmap(48, 16, frames[i], 32, 32, SSD1306_WHITE);
display.display();
delay(100); // BAD: blocks loop()
}
}
Good pattern (non-blocking):
const unsigned long FRAME_INTERVAL_MS = 100; // 10 fps
uint8_t currentFrame = 0;
unsigned long lastFrameTime = 0;
void loop() {
unsigned long now = millis();
if (now - lastFrameTime >= FRAME_INTERVAL_MS) {
lastFrameTime = now;
currentFrame = (currentFrame + 1) % FRAME_COUNT;
const unsigned char* spr = (const unsigned char*) pgm_read_ptr(&frames[currentFrame]);
display.clearDisplay();
display.drawBitmap(48, 16, spr, 32, 32, SSD1306_WHITE);
display.display();
}
// ... other non-blocking work fits here ...
}
Adding scrolling motion
Combine frame cycling with positional motion (a walking sprite that moves across the screen):
uint8_t currentFrame = 0;
int spriteX = -32;
unsigned long lastFrameTime = 0;
const unsigned long FRAME_INTERVAL_MS = 80;
void loop() {
if (millis() - lastFrameTime >= FRAME_INTERVAL_MS) {
lastFrameTime = millis();
currentFrame = (currentFrame + 1) % FRAME_COUNT;
spriteX = (spriteX + 2) % (128 + 32) - 32; // walk across, wrap
display.clearDisplay();
const unsigned char* spr = (const unsigned char*) pgm_read_ptr(&frames[currentFrame]);
display.drawBitmap(spriteX, 16, spr, 32, 32, SSD1306_WHITE);
display.display();
}
}
Memory budget
For 32x32 mono sprites: 128 bytes/frame. An 8-frame walk cycle = 1024 bytes. Add the SSD1306 buffer (1024 bytes SRAM) and library code (~5KB), and you're at ~7KB on a 32KB Uno. Plenty of room.
For larger sprites or longer animations on AVR, you'll hit flash before SRAM.
Multi-sprite scenes
Same pattern, multiple sprite tables:
const unsigned char* const playerFrames [] PROGMEM = {p0, p1, p2, p3};
const unsigned char* const enemyFrames [] PROGMEM = {e0, e1};
void loop() {
if (millis() - lastTick >= 80) {
lastTick = millis();
playerFrame = (playerFrame + 1) % 4;
enemyFrame = (enemyFrame + 1) % 2;
display.clearDisplay();
display.drawBitmap(playerX, 32, (const unsigned char*) pgm_read_ptr(&playerFrames[playerFrame]), 16, 16, SSD1306_WHITE);
display.drawBitmap(enemyX, 32, (const unsigned char*) pgm_read_ptr(&enemyFrames[enemyFrame]), 16, 16, SSD1306_WHITE);
display.display();
}
}
Tearing and flicker
Adafruit_SSD1306 already double-buffers internally — clearDisplay() and drawBitmap() work on a RAM buffer; only display.display() pushes pixels to the OLED. You should not see tearing.
If you do see flicker, you're probably calling display.display() too fast (more than ~30Hz). I2C at 400kHz takes ~30ms per full frame transfer; pushing harder leaves no time for setup. Cap your animation at 15-20 FPS (60-80ms per frame).
SPI vs I2C: speed difference
| Bus | Frame transfer time | Practical FPS cap |
|---|---|---|
| I2C 400kHz | ~30ms | ~30 FPS |
| I2C 1MHz (fast mode+) | ~12ms | ~60 FPS |
| SPI 8MHz | ~2ms | ~200 FPS (CPU-bound) |
If your animation matters, use the SPI variant of SSD1306. The wiring is one extra pin and the framerate gains are huge.
Sound effects synchronized to frames
If your animation has sound (a buzzer beep on a step), trigger it inside the same millis() tick that advances the frame:
if (currentFrame == FOOT_DOWN_FRAME) {
tone(BUZZER_PIN, 1500, 30);
}
Common pitfalls
- Animation freezes randomly. You used
delay()somewhere else and it's blockingloop(). Audit for anydelaycalls. - "out of memory" link error. Too many frames in flash. Reduce sprite size or count.
- Sprite is partially drawn. Forgot
display.clearDisplay()before each frame. - FPS lower than expected. Animation interval shorter than I2C transfer time.
Bottom line
Multi-frame animation is just millis() + a frame counter + an array of PROGMEM bitmaps. Generate your frames in image2cpp (batch upload supported), pack pointers into a table, and ship a delightful animated UI on a $5 OLED. For the underlying drawBitmap mechanics, see our Arduino tutorial.
Need a custom enclosure for that animated badge? quotes laser-cut acrylic. For project demo videos, og.hjlabs.in handles social previews.
Try image2cpp now — free, in browser
Drop in a PNG, JPG or BMP. Get a paste-ready C++ array for Arduino, ESP32 or RP2040. 100% client-side. Learn more about image2cpp or jump straight to the tool.
Open the converter →