50x Faster OLED Text Rendering with Pre-Paged Font Optimization
How pre-converting fonts to SSD1306's native page format and batched I2C writes achieves massive performance gains on MicroPython
The Problem
As of writing this entry, unfortunately the MicroPython driver does not support custom fonts. There was another library based on a PivoDev SSD1306 driver. This driver was implemented extremely inefficiently.
The reason is that SSD1306 is a monochrome 1-bit display that represents each pixel by a single bit only. Consequently a byte encodes 8 pixels. This makes changing a single pixel a bit more complicated than just accessing an array. The driver then implemented any draw via changing single pixels through bit wise operations. Even worse, every pixel change caused an I2C write.
As I used much of the display for my use case this resulted in around 1hz of update frequency only.
My solution? Pre-convert fonts to the display’s native format and batch all writes this avoids complex bit shifting at runtime and reduces time waiting for I2C – The result: 50x faster text rendering in pure MicroPython!
Understanding SSD1306’s Memory Layout
The SSD1306 organizes its 128×64 pixel display in a clever way to minimize memory usage and data transfer:
Each byte represents 8 vertical pixels:
┌───────────┐
│ bit 0 → • │ (top pixel)
│ bit 1 → • │
│ bit 2 → • │
│ bit 3 → • │
│ bit 4 → • │
│ bit 5 → • │
│ bit 6 → • │
│ bit 7 → • │ (bottom pixel)
└───────────┘
The display is divided into 8 horizontal “pages” (rows of bytes), each 8 pixels tall. So a 128×64 display uses 128×8 = 1024 bytes total.
The Optimization Strategy
1. Pre-Convert Fonts at Load Time
Instead of converting character bitmaps during rendering every time, pre-process them once when loading:
def _convert_char_to_paged_format(self, char_def, font_data):
# Convert horizontal scanline format → vertical paged format
for y in range(height):
for x in range(width):
if pixel_is_set:
dest_page_index = y // 8
dest_bit_mask = 1 << (y % 8)
paged_data[dest_page_index][x] |= dest_bit_mask
Each character is stored as pre-formatted page slices, matching exactly how the SSD1306 expects data - In a 2D array of 8-pixel high columns.
2. The Bit-Shifting Trick for Arbitrary Positioning
What is not quite ovbious when pre converting font, even though fonts are pre-converted to page-aligned format, you can still position text at any Y coordinate using bit-shifting:
def text(self, text, x, y):
start_page = y // 8
y_offset_in_page = y % 8
if y_offset_in_page == 0:
# Perfect alignment - direct copy
self.buffer[dest] |= src_byte
else:
# Not aligned - split across two pages
self.buffer[page] |= (src_byte << y_offset_in_page)
self.buffer[page+1] |= (src_byte >> (8 - y_offset_in_page))
When text isn’t page-aligned (e.g., Y=12), each byte gets split:
- Lower bits go to the current page (shifted up)
- Upper bits spill into the next page (shifted down)
This maintains pixel-perfect positioning while keeping the pre-converted format’s speed advantage.
3. Single I2C Transaction
Traditional approach:
# BAD: Multiple I2C calls per character
for char in text:
for x,y in char_pixels:
set_pixel(x, y) # Each call triggers I2C write
Optimized approach:
for char in text:
# Just memory operations - no I2C
copy_to_buffer(char_paged_data)
display.show() # Single I2C write for everything
Full source and font converter available at: github.com/FrederickAlt/SSD1306_Custom_Font