SSD1306 display drivers and font rendering
When I first started implementing the SSD1306 OLED on my prototype, I grabbed the quickest and easiest to implement driver I could find - a driver Espressif shipped as part of ESP-BSP that has since been removed. It worked great, updated the screen at about 40 hz, and was very light on resources. However, it only supports a single font, and isn't set up easily to support adding others without doing a fair amount of work to get each glyph into a specific format of C array. I wanted to add a different font, so I started looking around at other options.
That driver is no longer supported, and Espressif has replaced it with a lower level driver that doesn't actually support any fonts, just direct bitmap drawing. The recommendation is now to use LVGL, a fully featured graphics stack that has widgets, buttons, all sorts of things. So I went about implementing LVGL, which - sure enough - has good support for adding your own fonts, but can't hit more than about 18 - 20 hz on the ESP32 (running at full speed I2C, 400khz). It's also set up with its own timer and draw loop, and after fiddling around for quite a while I wasn't able to get the display to update faster. In addition, the draw loop persistently uses about 5% of a core on the ESP32 regardless of how much work there is to do - it just didn't feel like the right thing for me. Additionally, and I didn't look too much into this, when I use LVGL an audible whine comes out of my SSD1306 display. Maybe that's something I could debug, but I had already planned to move on at this point.

U8G2 is a popular library that supports dozens of small displays, including the SSD1306. I had used it before, so figured I'd give it a shot again. It's going a ton of fonts built in and has a system for importing additional fonts into its internal format. It doesn't natively support ESP-IDF, but there's a wrapper that works pretty well. However, after getting this implemented, again I hit the issue of slow update speed - the fastest I could get is around 18 hz (at 400khz I2C). After doing some research, others had noticed the same thing, but consensus seemed to be that that was good enough. Not good enough for me!
I found another promising SSD1306 driver that, when running a simple text test in isolation, could hit 30+ hz. It also had an example supporting BDF (a popular font format for vintage fonts) fonts, so seemed pretty promising. However, that example was quite slow for a reason I was able to fix but left me a little confused. Additionally, the kerning wasn't quite right with the BDF example, and I wasn't keen on trying to fix that. And finally, when I included the driver in my full synthesizer project, it was using more resources than I expected, and wasn't drawing fast enough.

At this point I was close to despair. I know AdafruitGFX is a popular graphics library, but it doesn't support ESP-IDF (only Arduino) natively. To implement it in my project I'd have to bring in an Arduino compatibility layer, and even then, I don't know anything about the performance or resource usage, so it felt like a risk that might not pay off.
I decided to back to the one driver I knew worked really well - the ESP-BSP driver that has been deprecated. I had since upgraded my ESP-IDF version to 5.4.x, and could actually no longer use the driver as written, as it only supports what is now called the "legacy" I2C driver. So I forked the code into my own repo and replaced updated all of the I2c API calls (something I had already done with my ES8388 driver), which worked. And it was still fast! Faster than any other driver I had used. Why is it faster? My hunch is because of how it pushes framebuffer data to the I2C bus in a single transaction, whereas U8G2 pushes bytes to the display in chunks, I'm guessing because of its support for many different variants of displays (I'm not sure about LVGL or the others, but might be similar).
static esp_err_t ssd1306_write_data(ssd1306_handle_t dev, const uint8_t *const data, const uint16_t data_len)
{
ssd1306_dev_t *device = (ssd1306_dev_t *) dev;
esp_err_t ret;
i2c_cmd_handle_t cmd = i2c_cmd_link_create();
ret = i2c_master_start(cmd);
assert(ESP_OK == ret);
ret = i2c_master_write_byte(cmd, device->dev_addr | I2C_MASTER_WRITE, true);
assert(ESP_OK == ret);
ret = i2c_master_write_byte(cmd, SSD1306_WRITE_DAT, true);
assert(ESP_OK == ret);
ret = i2c_master_write(cmd, data, data_len, true);
assert(ESP_OK == ret);
ret = i2c_master_stop(cmd);
assert(ESP_OK == ret);
ret = i2c_master_cmd_begin(device->bus, cmd, 1000 / portTICK_PERIOD_MS);
i2c_cmd_link_delete(cmd);
return ret;
}
...
static esp_err_t ssd1306_write_data(ssd1306_handle_t dev, const uint8_t *const data, const uint16_t data_len) {
ssd1306_dev_t *device = (ssd1306_dev_t *)dev;
esp_err_t ret;
uint8_t *out_buf = (uint8_t *)calloc(data_len + 1, sizeof(uint8_t));
out_buf[0] = SSD1306_WRITE_DAT;
memcpy(out_buf + 1, data, data_len);
ret = i2c_master_transmit(device->i2c_dev_handle, out_buf, data_len + 1, 1000);
free(out_buf);
return ret;
}
Legacy I2C API vs the new I2C API
But I was basically back to square one. I have a display driver that works great and is fast, but it only supports one font.
At this point I started thinking more about handling the font drawing myself - can I add a library to render a font to a bitmap, and then draw the bitmap directly using my driver? After some research I came across nvbdflib, which actually parses BDF fonts directly and allows you to provide your own drawing function! This seemed pretty promising - maybe I can include this library, give it a BDF font and a function that draws directly to my framebuffer, skipping the intermediate bitmap altogether.
void bdf_drawing_function(int x, int y, int c, void *ctx) {
ssd1306_dev_t *device = (ssd1306_dev_t *)ctx;
ssd1306_fill_point(device, x, y, c);
}
...
bdfSetDrawingFunction(bdf_drawing_function, (void *)device);
nvbdflib allows you to pass in a drawing function directly
It took a little fiddling - I don't have a filesystem on my device yet, so to load a BDF file into a buffer I used this (alternatively could compile in as an object file, but wasn't sure how to do that with ESP-IDF). It worked! It does load the entire font into memory, but the BDF format is just plain text, so I edited and trimmed it down to the 94 characters I needed. The trimmed down font I loaded in, the 8x16 IBM VGA font from 1987, was about 10 kb. I'm not memory constrained in my project - CPU is my primary constraint - so this was a perfectly fine compromise for being able to drop in another font very easily without a compilation step into an intermediate format (an improvement to memory usage would be to add that compilation step, but it's not important for my use case). After some tweaks to nvbdflib to pass along the consumer's context into the provided drawing function, I had the library working nicely inside of the display driver.
STARTFONT 2.1
FONT -IBM-VGA-Normal-R-Normal--16-120-96-96-C-80-ISO10646-1
SIZE 12 96 96
FONTBOUNDINGBOX 8 16 0 -4
STARTPROPERTIES 34
FOUNDRY "IBM"
FAMILY_NAME "VGA 8x16"
WEIGHT_NAME "Normal"
SLANT "R"
SETWIDTH_NAME "Normal"
ADD_STYLE_NAME ""
PIXEL_SIZE 16
POINT_SIZE 120
RESOLUTION_X 96
RESOLUTION_Y 96
BDF fonts are just plain text, which makes editing them easy
So that's where I am now - I have a SSD1306 display driver capable of hitting full speed (40 hz), but also supporting any font in BDF format! I'm going to keep refining the driver and added things I need - calculate bounding box for a string, for example - but I feel good about where it is already. No big dependencies, no compatibility layers, and using modern I2C APIs. Neat!
New code on the left, old on the right.