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.
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:
#include <Arduino.h>
#include <tft_io.hpp>
#include <ili9341.hpp>
#include <ssd1306.hpp>
#include <gfx_cpp14.hpp>
#include "DEFTONE.hpp"
#include "image.h"
#include "image3.h"
using namespace arduino;
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:
#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
#define LCD1_ROTATION 3
#define LCD1_BL_HIGH true
#define LCD2_WIDTH 128
#define LCD2_HEIGHT 64
#define LCD2_3_3v true
#define LCD2_ADDRESS 0x3C
#define LCD2_WRITE_SPEED 800 // 800% of 100KHz = 800KHz
#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:
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:
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>;
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:
screen1_t screen1;
screen2_t screen2;
int frame;
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:
template <typename Destination>
void draw_alpha(Destination& lcd) {
randomSeed(millis());
rgba_pixel<32> px;
spoint16 tpa[3];
const uint16_t sw =
min(lcd.dimensions().width, lcd.dimensions().height) / 4;
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);
srect16 sr(0, 0, rand() % sw + sw, rand() % sw + sw);
sr.offset_inplace(rand() % (lcd.dimensions().width - sr.width()),
rand() % (lcd.dimensions().height - sr.height()));
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:
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:
void setup() {
Serial.begin(115200);
frame = 0;
screen1.fill(screen1.bounds(), color1_t::white);
screen2.fill(screen2.bounds(), color2_t::white);
}
Finally, loop()
is where we do all the work:
void loop() {
if (!frame) { const float text_scale1 = DEFTONE_ttf.scale(80);
const ssize16 text_size1 =
DEFTONE_ttf.measure_text({32767, 32767},
{0, 0},
text,
text_scale1);
const srect16 text_rect1 =
text_size1.bounds().center((srect16)screen1.bounds());
image_jpg_stream.seek(0);
size16 isz;
if (gfx_result::success ==
jpeg_image::dimensions(&image_jpg_stream, &isz)) {
image_jpg_stream.seek(0);
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);
}
const float text_scale2 = DEFTONE_ttf.scale(30);
const ssize16 text_size2 =
DEFTONE_ttf.measure_text({32767, 32767},
{0, 0},
text,
text_scale2);
const srect16 text_rect2 =
text_size2.bounds().center((srect16)screen2.bounds());
image3_jpg_stream.seek(0);
if (gfx_result::success ==
jpeg_image::dimensions(&image3_jpg_stream, &isz)) {
image3_jpg_stream.seek(0);
draw::suspend(screen2);
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);
}
}
if (frame & 1) {
draw_alpha(screen2);
} else {
draw_alpha(screen1);
}
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