Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / IoT / Arduino

Driving Multiple Screens with GFX and an ESP32

0.00/5 (No votes)
22 Apr 2022MIT4 min read 7.7K   48  
How to use GFX to drive multiple screens at the same time on an ESP32
Driving multiple screens with GFX is simple, but as simple as it is, it's always nice to have some code. Here's some code.

multi_screen project

Introduction

GFX is a full featured graphics library designed on the ESP32, although it will run elsewhere as well. It provides things like True Type font support, JPG loading and alpha blending.

We'll be demonstrating all of what I just mentioned, and on two screens at once. It can be especially useful to drive two screens when one is a small status screen to compliment the larger display, or when the main display is an e-paper display and you need a responsive screen for user input.

Concepts

Due to the way GFX is orchestrated, it can drive as many screens as you can reasonably connect to your MCU. What you do is declare for each screen a bus type and the driver type instantiation that uses it, the same as if you were just using one screen, except you do it twice.

The trickier part is the actual drawing because it's not a good idea to try to use GFX from multiple threads because while in most cases. GFX is not thread averse, it also does nothing to make it thread safe, per se. It's almost all stateless so it's thread safe to degrees, but because your bus interface might not be you still can run into trouble fast trying to use GFX from multiple threads.

Enter cooperative multitasking. We're going to round robin schedule our animation so that odd frames get drawn to one screen and on even frames, we draw to the other.

Building this Mess

Here's what it looks like:

Prerequisites

You'll need an ESP32 WROOM or WROVER. You'll need Platform IO. You'll need an ILI9341 display and an SSD1306. You can change these - all you have to do is swap the driver and potentially the bus out, and you can wire up whatever you want. All the drawing code itself is pretty much device agnostic.

Wiring

We're using the default VSPI host and primary I2C host pins for our screens.

Wire the ILI9341's SPI to MOSI 23, MISO 19, and SCLK 18. Wire DC to 2, RST 4, and BL to 14. VCC is 3.3v.

For the SSD1306 wire SDA to 21 and SCL to 22. VCC is 3.3v.

platformio.ini

Here, we just include our drivers, update the compiler C++ standard used, and include some other boilerplate for things like the serial monitor baud rate:

[env:esp32-demo]
platform = espressif32
board = node32s
framework = arduino
monitor_speed = 115200
upload_speed = 921600
build_unflags=-std=gnu++11
build_flags=-std=gnu++14
lib_deps = 
    codewitch-honey-crisis/htcw_ili9341
    codewitch-honey-crisis/htcw_ssd1306
lib_ldf_mode = deep

main.cpp

This is where we do all of our work, so we'll give it a thorough treatment. First, we include our headers and import our namespaces - this is all pretty straightforward:

C++
#include <Arduino.h>

// the TFT IO bus library
// used by the drivers below
#include <tft_io.hpp>

// the ILI9341 driver
#include <ili9341.hpp>
// the SSD1306 driver
#include <ssd1306.hpp>

// GFX (for C++14)
#include <gfx_cpp14.hpp>

// our truetype font
#include "DEFTONE.hpp"
// color jpg image
#include "image.h"
// b&w jpg image
#include "image3.h"

// import driver namespace
using namespace arduino;
// import GFX namespace
using namespace gfx;

Now - and this is optional in your own projects - there are macros that define the various hardware mappings and configurations for our device drivers:

C++
// wiring is as follows for the ILI9341 display
// MOSI 23
// MISO 19
// SCLK 18
// VCC 3.3v
// see below for additional pins:
#define LCD1_CS 5
#define LCD1_DC 2
#define LCD1_RST 4
#define LCD1_BL 14

#define LCD1_WRITE_SPEED 400 // 400% of 10MHz = 40MHz
#define LCD1_READ_SPEED 200 // 200% of 10MHz = 20MHz
// you may need to change this to 1 if your screen is upside down
#define LCD1_ROTATION 3
// if you don't see any backlight, or any display
// try changing this to false
#define LCD1_BL_HIGH true

// wiring is as follows for the SSD1306 display
// SCL 22
// SDA 21
// VCC 3.3v
#define LCD2_WIDTH 128
#define LCD2_HEIGHT 64
#define LCD2_3_3v true
#define LCD2_ADDRESS 0x3C
// if your screen isn't working, change
// this to 400:
#define LCD2_WRITE_SPEED 800 // 800% of 100KHz = 800KHz
// change this to 1 if your screen is upside down
#define LCD2_ROTATION 3
#define LCD2_BIT_DEPTH 8

The write speeds and read speeds could use some explaining. These values for our buses are specified in percentages of a base value. The base value varies depending on the type of bus. For SPI, the base value is 10MHz. For I2C the base value is 100KHz. Note that many, if not most SSD1306s will happily operate at 800KHz over I2C, but some will not. If you have trouble with that screen, try halving the speed.

Next, we declare our buses. We need one for SPI and one for I2C. Since we're using default pins, and we're not doing anything fancy like DMA transfers, we can leave these declarations mercifully brief:

C++
using ili9341_bus_t = tft_spi<VSPI, LCD1_CS>;
using ssd1306_bus_t = tft_i2c<>;

For the SPI host, all we had to do was tell it to use VSPI and our ILI9341 LCD's CS line (5), and for the I2C we didn't have to specify any arguments at all! The default host is zero, which is the one we want.

Now we declare our drivers and for ease of access, two color<> pseudo enumerations - one in the native pixel type for each screen:

C++
using screen1_t = ili9341<LCD1_DC, 
                          LCD1_RST, 
                          LCD1_BL, 
                          ili9341_bus_t, 
                          LCD1_ROTATION, 
                          LCD1_BL_HIGH, 
                          LCD1_WRITE_SPEED, 
                          LCD1_READ_SPEED>;

using screen2_t = ssd1306<LCD2_WIDTH, 
                          LCD2_HEIGHT, 
                          ssd1306_bus_t, 
                          LCD2_ROTATION, 
                          LCD2_BIT_DEPTH, 
                          LCD2_ADDRESS, 
                          LCD2_3_3v, 
                          LCD2_WRITE_SPEED>;

// for easy access to x11 colors in the screen's native format
using color1_t = color<typename screen1_t::pixel_type>;
using color2_t = color<typename screen2_t::pixel_type>;

Now we have our globals, including the two screen instantiations:

C++
// declare the screens
screen1_t screen1;
screen2_t screen2;

// frame counter
int frame;

// title text
const char* text = "ESP32";

Now for some good stuff! The following routine draws a random shape of a random color on a screen, alpha blending it:

C++
// draw a random alpha blended shape
template <typename Destination>
void draw_alpha(Destination& lcd) {
    // randomize
    randomSeed(millis());
    // declare a pixel with an alpha channel
    rgba_pixel<32> px;
    // points for a triangle
    spoint16 tpa[3];
    // maximum shape width
    const uint16_t sw =
        min(lcd.dimensions().width, lcd.dimensions().height) / 4;
    // set each channel to a random value
    // note that the alpha channel is ranged differently
    px.channel<channel_name::R>((rand() % 256));
    px.channel<channel_name::G>((rand() % 256));
    px.channel<channel_name::B>((rand() % 256));
    px.channel<channel_name::A>(50 + rand() % 156);
    // create a rectangle of a random size bounding the shape 
    srect16 sr(0, 0, rand() % sw + sw, rand() % sw + sw);
    // offset it to a random location
    sr.offset_inplace(rand() % (lcd.dimensions().width - sr.width()),
                      rand() % (lcd.dimensions().height - sr.height()));
    // choose a random shape to draw
    switch (rand() % 4) {
        case 0:
            draw::filled_rectangle(lcd, sr, px);
            break;
        case 1:
            draw::filled_rounded_rectangle(lcd, sr, .1, px);
            break;
        case 2:
            draw::filled_ellipse(lcd, sr, px);
            break;
        case 3:
            // create a triangle polygon
            tpa[0] = {int16_t(((sr.x2 - sr.x1) / 2) + sr.x1), sr.y1};
            tpa[1] = {sr.x2, sr.y2};
            tpa[2] = {sr.x1, sr.y2};
            spath16 path(3, tpa);
            draw::filled_polygon(lcd, path, px);
            break;
    }
}

Notice it's a template method taking typename Destination as a single argument. This is so it can work with either display. There is no common base type for draw targets (like displays) in GFX. To take a draw target, such as a draw destination, it must be a template argument, and it must be passed in as a method argument of the same type. The comments should make what it's doing pretty clear, especially if you've used GFX before.

The setup() method is trivial:

C++
void setup() {
    Serial.begin(115200);
    frame = 0;
    // fill the screens just so we know they're alive
    // (not really necessary)
    screen1.fill(screen1.bounds(), color1_t::white);
    screen2.fill(screen2.bounds(), color2_t::white);
}

Finally, loop() is where we do all the work:

C++
void loop() {
    if (!frame) { // first frame
        // prepare to draw the text for screen 1
        const float text_scale1 = DEFTONE_ttf.scale(80);
        // measure the text
        const ssize16 text_size1 = 
          DEFTONE_ttf.measure_text({32767, 32767}, 
                                    {0, 0}, 
                                    text, 
                                    text_scale1);
        // center it
        const srect16 text_rect1 = 
          text_size1.bounds().center((srect16)screen1.bounds());
        // prepare to draw the image for screen 1
        // ensure stream is at beginning since JPG loading doesn't seek
        image_jpg_stream.seek(0);
        size16 isz;
        if (gfx_result::success == 
              jpeg_image::dimensions(&image_jpg_stream, &isz)) {
            // start back at the beginning
            image_jpg_stream.seek(0);
            // draw them both
            draw::image(screen1, 
                        isz.bounds().center(screen1.bounds()), 
                        &image_jpg_stream);
            draw::text(screen1, 
                      text_rect1, 
                      {0, 0}, 
                      text, 
                      DEFTONE_ttf, 
                      text_scale1, 
                      color2_t::black, 
                      color2_t::white, 
                      true);
        }
        // prepare to draw the text for screen 2
        const float text_scale2 = DEFTONE_ttf.scale(30);
        // measure the text
        const ssize16 text_size2 = 
          DEFTONE_ttf.measure_text({32767, 32767}, 
                                    {0, 0}, 
                                    text, 
                                    text_scale2);
        // center it
        const srect16 text_rect2 = 
          text_size2.bounds().center((srect16)screen2.bounds());
        // prepare to draw the image for screen 1
        // ensure stream is at beginning since JPG loading doesn't seek
        image3_jpg_stream.seek(0);
        if (gfx_result::success == 
              jpeg_image::dimensions(&image3_jpg_stream, &isz)) {
            // start back at the beginning
            image3_jpg_stream.seek(0);
            // suspend so we don't see the JPG being painted
            draw::suspend(screen2);
            // draw them both
            draw::image(screen2, 
                        isz.bounds().center(screen2.bounds()), 
                        &image3_jpg_stream);
            draw::text(screen2, 
                        text_rect2, 
                        {0, 0}, 
                        text, 
                        DEFTONE_ttf, 
                        text_scale2, 
                        color2_t::black, 
                        color2_t::white, 
                        true, 
                        true);
            draw::resume(screen2);
        }
    }
    // on even frames we draw to screen 1
    // on odd frames we draw to screen 2
    if (frame & 1) {
        draw_alpha(screen2);
    } else {
        draw_alpha(screen1);
    }
    // once we have about 30 per screen start over
    if (frame < 60) {
        ++frame;
    } else {
        frame = 0;
    }
}

Several things are happening here, but there are three major phases: In the first phase, the frame backgrounds are drawn by loading the JPGs and displaying the text. After that, the next phase is, each successive frame draws a random alpha blended shape on one of the screens, depending if the frame is odd or even. After 60 frames, the final phase is simply a reset and we start over.

Conclusion

As you can see, driving one screen is fundamentally no different than driving two except for your time management - how to allocate your resources between both screens so that they both appear responsive.

Get the latest documentation for GFX at https://honeythecodewitch.com/gfx.

History

  • 22nd April, 2022 - Initial submission

License

This article, along with any associated source code and files, is licensed under The MIT License