Skip to content

About

DGX is a small, opinionated display graphics library for microcontrollers.

Topics

Resources

Stars

5 stars

Watchers

0 watching

Forks

Repository files navigation

DGX

DGX: display graphics for microcontrollers, drawn by DGX itself

DGX is a small display graphics library for microcontrollers. I started writing it years ago, back when most people reached for Adafruit GFX or TFT_eSPI, and I wanted something shaped to my own taste: a clean split between bus, screen and drawing code, no hidden global state, possibility to use several equal screens simultaneously.

Today it lives as an ESP-IDF component with drivers for a handful of common panels, a couple of RAM-backed virtual screens, a UTF-8 text renderer, and a small offline tool for converting fonts. On top of that it animates: dots that morph one glyph into another, and handwritten fonts whose text is written stroke by stroke by a pen or turns into another text.

Contents

Getting started

DGX is an ESP-IDF component, published in the ESP Component Registry as jef-sure/dgx. Add it to a project:

idf.py add-dependency "jef-sure/dgx^0.5.7"

or put it into main/idf_component.yml yourself:

dependencies:
  jef-sure/dgx: "^0.5.7"

Then enable the drivers you need in menuconfig, under the DGX menu. A driver pulls in the transport it requires. To work on DGX itself, check the repository out under components/ instead; a git submodule at components/dgx works well.

A minimal example

Here's a complete setup for an ST7789 panel over SPI:

#include "bus/dgx_spi_esp32.h"
#include "drivers/st7789.h"
#include "dgx_bits.h"
#include "dgx_colors.h"
#include "dgx_draw.h"
#include "dgx_font.h"
#include "fonts/ArialRegular12.h"

// 1. create a bus
dgx_bus_protocols_t *bus =
  dgx_spi_init(SPI2_HOST, SPI_DMA_CH_AUTO,
               GPIO_NUM_23, GPIO_NUM_19,
               GPIO_NUM_18, GPIO_NUM_5,
               GPIO_NUM_27, 40 * 1000 * 1000, 0);

// 2. create a screen on top of that bus
dgx_screen_t *scr = dgx_st7789_init(bus, GPIO_NUM_33, 16, DgxScreenRGB);

// 3. draw something
dgx_fill_rectangle(scr, 0, 0, scr->width, scr->height,
                   DGX_BLACK(dgx_rgb_to_16));
dgx_font_string_utf8_screen(scr, 10, 18, "Hello world",
                            DGX_WHITE(dgx_rgb_to_16),
                            DgxOutputNormal, 1, ArialRegular12(),
                            NULL, NULL);

A working version of this lives in examples/screen_demo.

Recipes

Short answers to the things people look for first. Each recipe names a complete example to run and read; the functions are listed in the API reference.

Animation without flicker

Draw each frame into a virtual screen in RAM, then send the finished frame to the panel in one go.

#include "drivers/vscreen.h"

dgx_screen_t *frame = dgx_vscreen_init(scr->width, scr->height, 16, DgxScreenRGB);

for (int x = 0; x < scr->width; ++x) {
    dgx_fill_rectangle(frame, 0, 0, frame->width, frame->height, DGX_BLACK(dgx_rgb_to_16));
    dgx_solid_circle(frame, x, frame->height / 2, 12, DGX_CYAN(dgx_rgb_to_16));
    dgx_vscreen_to_screen(scr, 0, 0, frame);
}

To send only what has changed, keep a batch open on the virtual screen and ask it for the changed rectangle:

dgx_screen_progress_up(frame);          // once: changes are collected while a batch is open

draw_something(frame);
int left, top, width, height;
if (dgx_screen_take_dirty(frame, &left, &top, &width, &height)) {
    dgx_vscreen_region_to_screen(scr, left, top, frame, left, top, width, height);
}

Example: examples/hw_soft_pen_demo. More calls: Virtual screens.

A monochrome OLED

The same drawing calls work. A color is on (non-zero) or off (zero), and the driver sends its RAM buffer to the panel for you.

#include "bus/dgx_i2c_esp32.h"
#include "drivers/ssd1306.h"
#include "fonts/TerminusTTFMedium12.h"

dgx_bus_protocols_t *bus = dgx_i2c_init(I2C_NUM_0, 0x3C, GPIO_NUM_21, GPIO_NUM_22, 400000);
dgx_screen_t *oled = dgx_ssd1306_init(bus, SSD1306_128X64, 0, GPIO_NUM_NC);

dgx_fill_rectangle(oled, 0, 0, oled->width, oled->height, 0);
dgx_font_string_utf8_screen(oled, 0, 12, "Hello OLED", 1, DgxOutputNormal, 1,
                            TerminusTTFMedium12(), NULL, NULL);

Text in your own font

DGX comes with a few fonts, in src/fonts/:

The bundled fonts, each line drawn in the font it names

When they are not enough, font2c converts a TTF or BDF font into a C source/header pair. Give it the pixel size and the Unicode ranges you need:

cc font2c/font2c.c -o font2c/font2c $(pkg-config --cflags --libs freetype2)
./font2c/font2c path/to/YourFont.ttf 16 0x20 0x7e 0x410 0x44f

Move the .c file into src/fonts/ and the .h file into include/fonts/, rerun CMake configure once, and use the font like a bundled one:

#include "fonts/YourFontRegular16.h"

dgx_font_string_utf8_screen(scr, 10, 30, "Привет", color, DgxOutputNormal, 1,
                            YourFontRegular16(), NULL, NULL);

Details: font2c/README.md.

A gauge

A gauge at three values
#include "dgx_gauge.h"

static uint32_t gauge_color(int value) {
    return value < 70 ? DGX_GREEN(dgx_rgb_to_16) : DGX_RED(dgx_rgb_to_16);
}

dgx_gauge_t gauge;
dgx_gauge_init(&gauge, scr,
               scr->width / 2, scr->height / 2, // center
               40, 12,                          // inner radius, ring width
               135.0f, 270,                     // start angle in degrees, sweep
               0, 100,                          // value range
               DGX_DARKGREY(dgx_rgb_to_16),     // color of the inactive steps
               gauge_color);                    // draws the empty ring

dgx_gauge_set_value(&gauge, 42);                // redraws only the steps that changed

A pie chart, arcs, a Wi-Fi icon

Angles are in degrees, counter-clockwise, and 0 points to the right. With equal radii an ellipse is a circle.

A pie chart, sectors, arcs drawn with pens of several widths and a Wi-Fi icon
// a pie chart
dgx_solid_sector(scr, cx, cy, r, r, 0, 100, red);
dgx_solid_sector(scr, cx, cy, r, r, 100, 190, green);
dgx_solid_sector(scr, cx, cy, r, r, 190, 360, blue);
dgx_draw_ellipse(scr, cx, cy, r, r, white);     // the outline

// a Wi-Fi icon: a dot and three quarter-turn arcs, pen 3 pixels wide
dgx_solid_circle(scr, cx, cy, 2, white);
for (int i = 1; i <= 3; ++i) dgx_draw_arc(scr, cx, cy, i * 9, i * 9, 45, 135, 3, white);

Example: examples/figures_scroll_demo.

Scrolling text

dgx_scroll() moves the contents of a rectangle and fills the freed strip. The usual way is to scroll a virtual screen and send it to the display:

// a terminal: everything moves up by one line, the new line is drawn at the bottom
dgx_scroll(page, 0, 0, page->width, page->height, 0, -line_height, paper);
dgx_font_string_utf8_screen(page, 4, page->height - 4, text, ink, DgxOutputNormal, 1,
                            font, NULL, NULL);
dgx_vscreen_to_screen(lcd, 0, 0, page);

Some displays can be scrolled directly, without a virtual screen: an ILI9341 scrolls by its own hardware along the long side of the panel, and a MIPI-DSI display is scrolled in its frame buffer. The call is the same; it returns ESP_OK, or ESP_ERR_NOT_SUPPORTED when the display cannot do that scroll:

// an ILI9341 in portrait: the rows below a 24-row header move up, the header stays
dgx_scroll(lcd, 0, 24, lcd->width, lcd->height - 24, 0, -line_height, paper);

In that case a virtual screen always works: scroll the virtual screen as above and send it to the display. It can be sent in any orientation, so the text scrolls the way you need whatever way the panel is mounted:

dgx_vscreen_to_screen_oriented(lcd, 0, 0, page, DgxOutputRotate90CW);

Example: examples/figures_scroll_demo, a terminal and a chart on both kinds of display. The rules are under Drawing primitives.

Scale, rotate or mirror a picture

Draw the picture once into a virtual screen, then put it on the display at any size and onto any four corners.

// the texture: the same color depth as the screen it goes to
dgx_screen_t *tex = dgx_vscreen_init(64, 24, 16, DgxScreenRGB);
dgx_fill_rectangle(tex, 0, 0, tex->width, tex->height, DGX_NAVY(dgx_rgb_to_16));
dgx_font_string_utf8_screen(tex, 6, 17, "DGX", DGX_WHITE(dgx_rgb_to_16),
                            DgxOutputNormal, 1, ArialRegular12(), NULL, NULL);

// three times larger
dgx_draw_texture_rect(scr, 10, 10, 192, 72, tex, 0, 0, tex->width, tex->height);

// onto four corners: top-left, top-right, bottom-right, bottom-left
const dgx_point_2d_t card[4] = {{40, 110}, {200, 100}, {220, 170}, {20, 180}};
dgx_draw_texture_quad(scr, card, tex, 0, 0, tex->width, tex->height);

// mirrored: swap the left and the right corners
const dgx_point_2d_t mirrored[4] = {{238, 10}, {174, 10}, {174, 34}, {238, 34}};
dgx_draw_texture_quad(scr, mirrored, tex, 0, 0, tex->width, tex->height);

To rotate or animate the picture, compute the four corners again for every frame. The quad must be convex; the mapping is affine, without perspective. Examples: examples/texture_demo scales, rotates and wobbles a card (card_corners()), examples/flip_clock_demo folds the digits of a split-flap clock.

Morph one glyph into another

A glyph becomes a matrix of dots, and the dots of one glyph fly to the places of the other. Build with CONFIG_DGX_ENABLE_MORPH.

#include "dgx_morph.h"
#include "dgx_morph_render.h"
#include "dgx_morph_sources.h"

const int cell = 8, margin = cell;      // a dot is 8 pixels; its glow needs a margin around the glyph

// both glyphs as dot matrices on the same grid
dgx_bit_matrix_t *from = dgx_morph_glyph_matrix(font, '1');
dgx_bit_matrix_t *to = dgx_morph_glyph_matrix(font, '8');

// the plan: which dot flies where
dgx_morph_t *morph = dgx_morph_create(from, to, dgx_morph_sources_cells, NULL);

// a renderer that draws glowing dots into a buffer of its own
const int width = from->width * cell + 2 * margin, height = from->height * cell + 2 * margin;
dgx_morph_glow_t *glow = dgx_morph_glow_create(width, height, cell, scr->color_bits);

// the frames: half a second from the first glyph to the second
const int64_t start = esp_timer_get_time();
float         t;
do {
    t = dgx_morph_progress(start, esp_timer_get_time(), 500000);
    dgx_morph_draw(morph, t, margin, margin, cell, true, dgx_morph_glow_dot, glow);
    dgx_morph_glow_present(glow, t, scr, x, y);
    vTaskDelay(1);
} while (t < 1.0f);

dgx_morph_glow_destroy(&glow);
dgx_morph_destroy(&morph);
dgx_matrix_destroy(&from);
dgx_matrix_destroy(&to);

Examples: examples/glyph_morph_demo (one large symbol, sized to the screen), examples/morph_demo (words, letter by letter), examples/weather_morph_demo and examples/life_morph_demo. How the plan is made, the other renderers and your own patterns in place of glyphs are in docs/morphing-en.md (Russian).

Handwritten text

A handwritten font stores every symbol as pen strokes on a grid. scale is the number of pixels per grid cell: a line of font0ss is 202 cells high, so 0.25 gives a line of about 50 pixels. The pen width is in pixels.

A handwritten line, finished and half written
#include "dgx_hw_font.h"
#include "fonts/font0ss.h"

dgx_font_t *font = font0ss();

// x is the left end of the line, y its base line;     scale  pen         joined letters
dgx_hw_draw_text(scr, 10, 80, font, "Hello, World", 0.25f, 3, color, true);

To fit a text to the screen, ask for its box in grid cells. Columns count from the start of the line and rows from the base line, so top is negative:

int left, top, right, bottom;
dgx_hw_text_box(font, text, &left, &top, &right, &bottom);
const float cells_wide = right - left + 1, cells_high = bottom - top + 1;

// the largest scale that fits the width; the pen adds half its width on each side
const float scale = (scr->width - 2 * margin - pen) / cells_wide;
// the box in the middle of the screen
const int x = (int)lroundf((scr->width - cells_wide * scale) / 2 - left * scale);
const int y = (int)lroundf((scr->height - cells_high * scale) / 2 - top * scale);

dgx_hw_draw_text(scr, x, y, font, text, scale, pen, color, true);

To write it stroke by stroke, begin a writing and draw it on as time goes. Effort is the distance the pen has travelled, in grid cells:

dgx_hw_pace_t    pace = dgx_hw_pace(font, 6);       // the pen slows down in bends; 0 for an even speed
dgx_hw_writing_t writing;
dgx_hw_writing_begin(&writing, scr, x, y, font, text, scale, pen, color, true, &pace);

const float   tempo = 1500;                         // grid cells per second
const int64_t start = esp_timer_get_time();
while (!dgx_hw_writing_draw_to(&writing, tempo * (esp_timer_get_time() - start) * 1e-6f)) {
    vTaskDelay(1);
}

For a soft (anti-aliased) pen edge pass DGX_PEN_SOFT | pen and draw into an 8-bit virtual screen, where color is an amount of ink up to 255; send it to the display through a color table that runs from the paper color to the ink color.

Examples: examples/hw_font_demo, examples/hw_soft_pen_demo. Details: docs/handwriting-en.md (Russian).

Morph handwritten text

Every stroke is a Bezier curve, so one text can turn into another. Each frame is a new picture: draw it into a virtual screen and send that to the display.

// the band holds amounts of ink, 0 to 255; lut[] holds the screen colors for them
dgx_screen_t *band = dgx_vscreen_init(scr->width, 100, 8, DgxScreenRGB);
dgx_hw_morph_t *morph = dgx_hw_morph_create(font0ss(), "hello", "world", true);

const int64_t start = esp_timer_get_time();
float         t;
do {
    t = (esp_timer_get_time() - start) / 1500000.0f;   // 1.5 s
    if (t > 1) t = 1;
    dgx_fill_rectangle(band, 0, 0, band->width, band->height, 0);
    dgx_hw_morph_draw(morph, t * t * (3 - 2 * t), band, 10, 70, 0.4f, 4, 255);
    dgx_vscreen8_to_screen16(scr, 0, 70, band, lut, false);
    vTaskDelay(1);
} while (t < 1);

dgx_hw_morph_destroy(&morph);

dgx_hw_morph_text_create() morphs letter by letter instead, each letter with a progress of its own, so the letters that stay the same do not move: a clock, for one. Example: examples/hw_morph_demo.

A MIPI-DSI display without tearing

Ask for two frame buffers and put everything that belongs to one picture into one batch. The batch is shown as a whole on the next frame.

#include "bus/dgx_dsi_esp32.h"
#include "drivers/jd9165.h"

dgx_bus_protocols_t *bus = dgx_dsi_init(0, 2, 750, 3, 2500);
dgx_screen_t *lcd = dgx_jd9165_init(bus, GPIO_NUM_0, 16, DgxScreenRGB, DgxDsiTwoFrames);

dgx_screen_progress_up(lcd);
draw_picture(lcd);
dgx_screen_progress_down(lcd);

Example: examples/dsi_demo. The other update modes, an even frame rate and board settings: docs/dsi-usage-en.md (Russian).

Rotate the screen

Every color driver has an orientation call. swap_xy exchanges the axes, and the width and height of the screen with them.

dgx_ili9341_orientation(scr, DgxScreenRightLeft, DgxScreenTopBottom, true);   // landscape on a CYD

See Coordinates and orientation.

Draw from several tasks

A screen takes one task at a time. Let one task own the display and draw, and let the others send it messages or finished virtual screens; or guard each batch with a mutex of your own. Both ways, with measurements, are in Drawing from several tasks.

Examples

Each example is an ESP-IDF project with its own README. They take DGX from the repository they sit in, so build them from a checkout:

cd examples/screen_demo
idf.py set-target esp32
idf.py flash monitor
Example Board What it shows
screen_demo ESP32 with an ILI9341 a graphics test: fills, text, lines, rectangles, circles, triangles
dsi_demo JC1060P470 (ESP32-P4, 7-inch MIPI-DSI) the same kind of test for a DSI display, update modes, an even frame rate
figures_scroll_demo CYD or JC1060P470 a pie chart, arcs, a Wi-Fi icon, a scrolling terminal and chart; prints timings
texture_demo CYD a textured card scaled, rotated and wobbled
flip_clock_demo CYD a split-flap clock built on textured quads
morph_demo CYD words morphing letter by letter
glyph_morph_demo CYD one large symbol morphing into the next
weather_morph_demo CYD the weather symbols of a clock morphing in the order weather takes
life_morph_demo CYD Conway's Game of Life with morphing generations
hw_font_demo CYD a text written by hand, stroke by stroke
hw_morph_demo CYD handwritten words morphing one into another
hw_soft_pen_demo CYD or JC1060P470 handwriting with a soft pen, lines that move to make room

A CYD is an ESP32-2432S028R: an ESP32 with a 320x240 ILI9341 on SPI.

Supported panels

Each driver returns a dgx_screen_t * you can draw on. Color panels take a pixel depth and a channel order; monochrome panels keep a RAM buffer that DGX flushes for you. Use GPIO_NUM_NC for any reset/CS line your board does not wire.

Controller Type Buses Constructor Kconfig option
ST7735 color TFT SPI, P8 dgx_st7735_init(bus, rst, color_bits, cbo) CONFIG_DGX_ENABLE_SPI_ST7735
ST7789 color TFT SPI, P8 dgx_st7789_init(bus, rst, color_bits, cbo) CONFIG_DGX_ENABLE_SPI_ST7789
ILI9341 color TFT SPI, P8 dgx_ili9341_init(bus, rst, backlight, color_bits, cbo) CONFIG_DGX_ENABLE_SPI_ILI_9341
GC9A01 round color TFT SPI, P8 dgx_gc9a01_init(bus, rst, color_bits, cbo) CONFIG_DGX_ENABLE_SPI_GC9A01
SSD1351 color OLED SPI, P8 dgx_ssd1351_init(bus, rst, color_bits, cbo) CONFIG_DGX_ENABLE_SSD1351
SSD1306 mono OLED SPI, I2C dgx_ssd1306_init(bus, resolution, is_ext_vcc, rst) CONFIG_DGX_ENABLE_SSD1306
ST7565R mono LCD SPI dgx_st7565r_init(bus, rst) CONFIG_DGX_ENABLE_ST7565R
ST7920 mono LCD SPI dgx_st7920_init(bus, rst, cs) CONFIG_DGX_ENABLE_ST7920
JD9165 color TFT, 1024x600 DSI dgx_jd9165_init(bus, rst, color_bits, cbo, frames) CONFIG_DGX_ENABLE_JD9165

Common parameters:

  • bus — a dgx_bus_protocols_t * from dgx_spi_init(), dgx_i2c_init(), dgx_p8_init() or dgx_dsi_init().
  • rst — reset GPIO, or GPIO_NUM_NC if the panel has no reset line.
  • backlight (ILI9341 only) — backlight GPIO, or GPIO_NUM_NC if it is not under software control.
  • color_bits — pixel depth; 16 (RGB565) is the usual choice. JD9165 takes 16 or 24.
  • cbo — channel order, DgxScreenRGB or DgxScreenBGR: the order colors come out in on the usual modules of the controller. The usual ILI9341 modules show red as red with the BGR bit of the controller set, so there DgxScreenRGB sets that bit, the other way round from the other drivers. If red and blue are exchanged on your module, pass the other value.

Color panels and ST7920 also expose an orientation setter (dgx_<driver>_orientation(scr, dir_x, dir_y, swap_xy)). A color controller is told its scan direction. An ST7920 has no command for that: its driver turns the picture where it sends it to the panel, and with swap_xy the screen is 64x128. A few drivers add extras: ILI9341 has dgx_ili9341_backlight(), GC9A01 has dgx_gc9a01_display_off() / dgx_gc9a01_display_on(), SSD1351 has dgx_ssd1351_brightness(), and SSD1306 has dgx_ssd1306_contrast().

JD9165 is a MIPI-DSI display for an ESP32-P4 with PSRAM. The chip keeps a frame buffer for it, 1.2 MB at 16 bits per pixel for 1024x600, and drawing changes that buffer, so a pixel can be read back and all eight orientations work. The driver has the init commands of the 7-inch panel of the Guition JC1060P470 board. The last argument of the constructor, frames, chooses how the screen is updated:

frames Frame buffers Tearing
DgxDsiOneFrame 1 possible
DgxDsiTwoFrames 2 none, for one more frame of PSRAM
DgxDsiOneFrameAfterScan 1 none for a virtual screen sent to the display and for a large rectangle, at the cost of a wait

Everything else a program should know about such a display is in docs/dsi-usage-en.md (Russian): batches and an even frame rate, pieces that bypass the processor, settings that depend on the board, several tasks. How the bus is built and what was measured on it is in docs/dsi-display-en.md (Russian), and examples/dsi_demo is a working program.

For SSD1306, pick a geometry from ssd1306_resolution_t: SSD1306_128X32, SSD1306_128X64, SSD1306_96X16, SSD1306_64X48 or SSD1306_72X40.

What's in the box

  • Panel drivers for ST7735, ST7789, ILI9341, GC9A01, SSD1351, SSD1306, ST7565R, ST7920 and JD9165.
  • SPI, I2C and 8-bit parallel (I80) transports for ESP32, and MIPI-DSI for ESP32-P4.
  • A color RAM-backed virtual screen and a 1-bit monochrome virtual screen. They are useful on their own, as staging buffers for animation, or as shadow buffers behind monochrome controllers.
  • A two-head compositor (vscreen_2h) that exposes two child screens as one logical screen.
  • Drawing primitives — pixels, lines, rectangles, circles, ellipses, their arcs and sectors, filled and textured quads, a rectangle scrolled any way — and an arc gauge helper.
  • UTF-8 text rendering with 8-way orientation, glyph lookup, layout and bounds queries, plus a small "morph" helper for animating between glyphs.
  • Handwritten fonts: symbols kept as pen paths of Bezier curves, written at any size with a pen of any thickness, letters joined, at once or stroke by stroke at the pace of a pen.
  • font2c, an offline tool that turns TTF/BDF-style fonts and handwritten fonts of the hw-fonts editor into C source/header pairs, and a set of pre-converted fonts in src/fonts/.

How it fits together

Everything above the transport layer talks to a dgx_screen_t vtable. Bus backends produce a dgx_bus_protocols_t. Driver constructors return either a plain dgx_screen_t * for RAM-only screens, or a dgx_screen_with_bus_t * for hardware panels. The drawing and font code never touches SPI, I2C or P8 directly — it only goes through the screen vtable. Monochrome panels keep a RAM page buffer and flush it to the controller, and the ST7920 driver reuses the color virtual screen the same way. A MIPI-DSI display is sent a frame buffer all the time; its bus takes the window and the pixels a driver sends, as a display with memory of its own does, and puts them into that buffer.

application
  └─ dgx_draw.h / dgx_font.h / dgx_hw_font.h / dgx_gauge.h / dgx_morph.h
        └─ dgx_screen_t  (vtable)
              ├─ dgx_screen_with_bus_t  → bus backend (SPI / I2C / P8 / DSI)
              ├─ dgx_bw_vscreen_t       → 1-bit RAM buffer
              ├─ dgx_vscreen_t          → color RAM buffer
              └─ vscreen_2h             → composes two child screens

API reference

Every drawing and text call takes a dgx_screen_t *, so the same code works across hardware panels and virtual screens. Colors are plain uint32_t values packed for the target pixel format (see Colors).

Drawing primitives

Declared in include/dgx_draw.h:

Function Description
dgx_set_pixel(scr, x, y, color) Set one pixel.
dgx_get_pixel(scr, x, y) Read one pixel: virtual screens, panels drawn through a virtual back screen and a DSI display only; see Known gaps.
dgx_screen_is_readable(scr) true when the screen gives back the pixels drawn on it, false for a display that keeps them in the panel behind a bus.
dgx_draw_line(scr, x1, y1, x2, y2, color) Single-pixel line by Bresenham's algorithm; the same pixels whichever end it is given from.
dgx_draw_line_thick(scr, x1, y1, x2, y2, width, color) Thick line with caps.
dgx_draw_bezier3(scr, points, pieces, width, color) Thick quadratic Bezier curve by three dgx_point_2d_t points, drawn by pieces straight pieces; 0 finds their number from the points, within half a pixel of the curve. `DGX_PEN_SOFT
dgx_draw_bezier4(scr, points, pieces, width, color) Thick cubic Bezier curve by four points.
dgx_bezier3_begin(b, scr, points, pieces, width, color), dgx_bezier4_begin(...) Prepare the same curves in a dgx_bezier_t for drawing part by part, to animate writing.
dgx_bezier_draw_to(b, t) Draw a prepared curve on until it is t complete, 0 .. 1 by the way of the pen, and flush what was added; returns true when the curve is finished.
dgx_bezier_length(b) Length of a prepared curve in pixels, to share time between curves.
dgx_draw_line_mask(scr, x1, y1, x2, y2, color, bg, mask, mask_bits) Dashed/dotted line; returns the rotated mask to continue the pattern.
dgx_fill_rectangle(scr, x, y, w, h, color) Filled rectangle.
dgx_draw_circle(scr, x, y, r, color) Circle outline by the midpoint algorithm.
dgx_solid_circle(scr, x, y, r, color) Filled circle, the same outline filled by rows.
dgx_draw_ellipse(scr, x, y, rx, ry, color) Ellipse outline, a pixel wide, by the rule of the midpoint algorithm: with equal radii it is dgx_draw_circle() pixel for pixel.
dgx_draw_arc(scr, x, y, rx, ry, from, to, width, color) Arc of an ellipse, of a circle with equal radii, between two angles in degrees, by a pen of width; `DGX_PEN_SOFT
dgx_solid_sector(scr, x, y, rx, ry, from, to, color) Filled sector of an ellipse between two angles; a whole turn is the ellipse filled.
dgx_scroll(scr, x, y, w, h, dx, dy, color) Move what a rectangle shows by dx, dy within it, any way, and fill what it has left with color. In memory where a screen is read, by the scroll of the panel where an ILI9341 can. Returns ESP_OK, or ESP_ERR_NOT_SUPPORTED when neither is possible.
dgx_draw_triangle_solid(scr, x0, y0, x1, y1, x2, y2, color) Filled triangle.
dgx_draw_polygon4_solid(scr, x0..y3, color) Filled simple quadrilateral (convex or concave).
dgx_draw_texture_quad(scr, quad, texture, tx, ty, tw, th) Stretch a region of a virtual screen onto a convex quadrilateral (affine, nearest texel).
dgx_draw_texture_rect(scr, x, y, w, h, texture, tx, ty, tw, th) Scale a region of a virtual screen into a rectangle.

Lines, circles, ellipses and sectors are one code for every screen, made of the pixels and the rectangles a screen draws itself: a figure has the same pixels on a display, on a virtual screen and on a monochrome one.

An angle of an arc or a sector is counted in degrees from the ray that goes from the center to the right, against the clock as the screen is seen, so 90 is up; it is the angle of a ray on the screen whatever the radii are. The figure is what lies between the rays of from and to, a pixel on a ray counted in. Angles are not brought into a turn: from 350 to 370 is twenty degrees across the ray that goes right, and 360 degrees or more between the two is the whole ellipse. The outline, the arc by a hard pen of 1 and the sector are the same pixels: the arc is the outline between the rays, the sector is the outline and what is within it. An arc by any other pen is the pen of the curves led along it, with round ends:

/* the sign of Wi-Fi: a dot and three arcs of a quarter of a turn over it */
dgx_solid_circle(band, cx, cy, dot, ink);
for (int i = 1; i <= 3; ++i) {
    dgx_draw_arc(band, cx, cy, i * step, i * step, 45, 135, DGX_PEN_SOFT | pen, ink);
}

dgx_scroll() reads the picture from the screen where pixels are memory: on a virtual screen, on a display drawn through a virtual back screen and on a DSI display. A virtual screen of whole bytes a pixel is moved in its memory a row at a time, and so is a DSI display in its frame buffer, by its bus; any other such screen is moved pixel by pixel. examples/figures_scroll_demo shows the figures and every kind of scroll on a CYD and on a 7-inch DSI display.

A display that keeps its pixels in the panel behind a bus is not read. There the panel moves the picture itself when it can, and no pixel of the picture is sent. An ILI9341 scrolls along the long side of its panel: up and down on a screen upright, left and right on one that lies. The rectangle has to take the screen from edge to edge across that side, what is before it and after it along the side stays, as the head and the foot of a terminal do:

/* an ILI9341 upright, 240x320: a head of 24 rows stays, the lines under it go up by the panel */
dgx_scroll(lcd, 0, 24, lcd->width, lcd->height - 24, 0, -line_height, paper);
/* ... draw the new line at the bottom, where it is seen ... */

The panel shows its rows from another row of its memory on, so a row is not where it is shown; every window is led to where its rows lie, and a picture is drawn where it is seen whatever has been scrolled. A window that crosses the place where the rows turn round goes in pieces: two when the screen is upright, two for every row of it when the screen lies. The panel has one such area at a time: a rectangle that has been moved is filled, by a move as large as it, before another is moved. Any other move, across the long side or aslant, returns ESP_ERR_NOT_SUPPORTED and draws nothing; a text that scrolls so is drawn on a virtual screen, which is moved and sent to the display, in any orientation:

/* a line of text more: the picture goes up by the height of a line */
dgx_scroll(page, 0, 0, page->width, page->height, 0, -line_height, paper);
/* ... draw the new line at the bottom of `page` ... */
dgx_vscreen_to_screen(lcd, 0, 0, page);

On a CYD, an ESP32 at 240 MHz with an ILI9341 on SPI at 40 MHz, a line of 14 rows scrolled by the panel upright takes 1.5 ms, the time of filling the strip of 240 x 14; a band of 320 x 112 moved in memory and sent takes 2.4 ms and 15 ms. On a JC1060P470, an ESP32-P4 with a JD9165 of 1024x600 on DSI, the whole screen moved in the frame buffer takes 24 ms.

How a Bezier curve is split into straight pieces and how a curve is drawn on by t is described in docs/handwriting-en.md. What figures a pen puts on a screen for a piece, what it counts with and what that costs on an ESP32 is in docs/pen-en.md (Russian).

The pen of a curve is its width and color, and its edge is hard: a pixel is the line or is not. DGX_PEN_SOFT added to the width asks for a soft edge, DGX_PEN_SOFT | 2: a pixel on the edge of the line gets the share of color the pen covers of it, a pixel keeps the larger of what it has and what it is given, and a soft pen with color 0 takes the ink off every pixel it touches. That takes a pixel that is an amount of ink, a byte of a virtual screen of 8 bits a pixel; the table such a screen is sent to the display through, dgx_vscreen8_to_screen16(), runs then from the color of the paper at 0 to the color of the ink. A display that mixes two colors itself, a DSI display of an ESP32-P4 with two frame buffers, takes such a table as its first and its last color and mixes the rest, within a step of a channel of the table; DGX_LUT_EXACT for the last argument of the call asks for the colors of the table to the last bit. On any other screen a pen asked to be soft draws as a hard one; see A soft edge.

quad is four dgx_point_2d_t vertices in the order of the region's corners: top-left, top-right, bottom-right, bottom-left. The texture needs the same color depth as the target screen; see Scale, rotate or mirror a picture, and examples/flip_clock_demo uses both functions.

Text and fonts

Declared in include/dgx_font.h:

Function Description
dgx_font_string_utf8_screen(scr, x, y, str, color, orientation, scale, font, draw_func, param) Render a UTF-8 string. Pass NULL, NULL for the last two for default rendering.
dgx_font_char_to_screen(scr, x, y, codePoint, color, orientation, scale, font, draw_func, param) Render one code point.
dgx_font_string_bounds(str, font, ycorner, height) Measure a string; returns width, fills top offset and height.
dgx_font_find_glyph(codePoint, font, xAdvance) Look up a single glyph.
decodeUTF8next(chr, idx) Decode the next UTF-8 code point.
dgx_font_make_morph_struct(...) / dgx_font_make_morph_struct_destroy(...) Build/free a glyph-to-glyph morph descriptor for animation.
dgx_bw_bitmap_foreach_set(bmap, func, user_data) From include/dgx_bitmap.h: iterate set pixels of a 1-bpp bitmap; zero bytes skip 8 pixels at once (LINES format).

orientation is a dgx_output_orientation_t: DgxOutputNormal, DgxOutputMirrorX, DgxOutputMirrorY, DgxOutputRotate180, DgxOutputTranspose, DgxOutputRotate90CCW, DgxOutputRotate90CW or DgxOutputTransverse. scale is an integer multiplier (1 = native size). Each bundled font header under include/fonts/ exposes an accessor, e.g. ArialRegular12().

A font tells its kind in f_type: bitmaps (DGX_FONT_BITMAP_LINES, DGX_FONT_BITMAP_STREAM), dots (DGX_FONT_DOTS) or pen paths (DGX_FONT_HW). The functions above draw the first two kinds; a handwritten font shares only the glyph lookup with them and is drawn by the functions of the next section. Fonts made by the current font2c also tell the number of their ranges, so a glyph is found by halving instead of trying the ranges in order.

Handwritten fonts

A handwritten font keeps every symbol as the path of a pen: Bezier curves in the order a hand writes them. It is a dgx_font_t of type DGX_FONT_HW, its symbols are found like any glyphs, but it is drawn by its own functions declared in include/dgx_hw_font.h: the size and the thickness of the pen are chosen at drawing, and letters are joined.

Function Description
dgx_hw_draw_text(scr, x, y, font, text, scale, width, color, joined) Write a UTF-8 text at once. x, y is the start of the line on its base line, scale pixels per cell of the font's grid (a line is symbol_size_y cells high), width the thickness of the pen, joined joined writing; \n starts the next line.
dgx_hw_text_box(font, text, left, top, right, bottom) The box the text takes, in cells from the start of its first line: to choose a size and a place.
dgx_hw_writing_begin(writing, scr, x, y, font, text, scale, width, color, joined, pace) Prepare a text for writing by the pace of a pen; draws nothing.
dgx_hw_writing_draw_to(writing, effort) Write on until the pen has gone through effort, adding only what is new; returns true when the text is finished.
dgx_hw_text_effort(font, text, joined, pace) The whole effort of a text; t * effort leads the writing by a progress t of 0 .. 1.
dgx_hw_pace(font, k) How effort is counted: an element takes length + k * pieces, a dot and a move of the pen in the air as much as the hyphen of the font.
dgx_hw_writer_begin(writer, font, text, joined) / dgx_hw_writer_next(writer, stroke) The strokes of a text one by one in the order of writing, in cells: for a drawing of your own.
dgx_hw_line_begin(line, font) / dgx_hw_line_place(line, code_point, shift) Where each next symbol of a line stands.
dgx_hw_draw_text_xy(...), dgx_hw_writing_begin_xy(...), dgx_hw_morph_draw_xy(...) The same as their namesakes with scale_x, scale_y in place of scale: a text narrow and tall or wide and low. The pen stays round.
dgx_hw_morph_create(font, from, to, joined) / dgx_hw_morph_destroy(&morph) Plan the morph of a line into a line as a whole: the whole way of the pen into the whole way of the pen.
dgx_hw_morph_text_create(font, from, to, joined) / dgx_hw_morph_text_destroy(&text) Plan it letter by letter: text->letters[i] is a morph for every position, each led by its own t.
dgx_hw_morph_draw(morph, t, scr, x, y, scale, width, color) Draw the frame at t of 0 .. 1 on a cleared place.
dgx_hw_morph_shift(morph, from_x, from_y, to_x, to_y) / dgx_hw_morph_text_shift(...) Move the two texts, each by its own shift in cells: to centre both, say.
dgx_hw_morph_box(morph, left, top, right, bottom) The box no frame leaves, in cells.

A stroke of any kind is a cubic curve, so a text is a list of cubic curves in the order of writing and a morph is a list of pairs of them; a frame is every pair with its four points taken between the two curves. A frame is drawn anew every time, so it wants a virtual screen.

width is the pen of all of these, and DGX_PEN_SOFT | width a pen with a soft edge: color is then an amount of ink, and a text written again by the same pen with color 0 is taken off, see Drawing primitives.

examples/hw_font_demo fits three lines to the screen and writes them by hand, examples/hw_morph_demo morphs words one into another both ways.

One such font is bundled: font0ss() from fonts/font0ss.h. It has the Latin alphabet with the German letters ÄÖÜäöüß, the Russian alphabet, digits and punctuation, 155 symbols in all, and nothing else: no accented letters of other languages, no Greek, no scripts written right to left. Drawing a handwritten alphabet is slow work and I am not going to draw more of them; if you need another one, draw it in the editor and send it over, co-authors are welcome. A font comes from the hw-fonts editor through font2c, see font2c/README.md.

Sizing, the thick Bezier curves underneath, writing at the pace of a pen and both morphs are described in detail in docs/handwriting-en.md (Russian), with two recipes above: Handwritten text and Morph handwritten text. The format of a font and the rules of placing and joining letters are described with the editor, font-format-ru.md; how a font is converted and how it lies in the tables of DGX is in docs/hw-font-ru.md (both in Russian).

Dot morphing framework

Enable with CONFIG_DGX_ENABLE_MORPH. Everything morphs one bit matrix into another; declared in include/dgx_matrix_morph.h, include/dgx_morph.h, include/dgx_morph_sources.h and include/dgx_morph_render.h:

Part API Description
Data dgx_bit_matrix_t, dgx_matrix_*() Packed 1-bit grid; inline get_point/set_point; clear, clone, copy, equals.
Sources dgx_morph_glyph_matrix(font, cp), dgx_matrix_from_bw_bitmap(bmap) Glyph of a dot or bitmap font as a matrix in the font-wide box; any 1-bpp bitmap as a matrix.
Morph dgx_morph_create(from, to, sources, user_data) Plans flights in cell coordinates. sources decides where each new cell flies in from: dgx_morph_sources_life (all live neighbors) dgx_morph_sources_cells (the nearest free cell, searched vector by vector over the whole grid, so a line that moved flies as a whole), dgx_morph_sources_cells_within (the same no further than a radius, an int that user_data points to), or your own callback, which is many times slower when it defers pass after pass. Sides above 32767 cells are rejected. dgx_morph_create_with(..., &options) takes a dgx_morph_options_t: with DGX_MORPH_ORPHANS_MERGE an old cell nobody took flies into the nearest cell of to, along its figure first, and goes out there instead of fading in place; merge_radius bounds the way, 0 is no limit.
Text dgx_morph_text_create(font, from, to, length, sources, user_data), dgx_morph_text_duration_us() One morph per letter between two UTF-8 strings; the shorter one is padded with spaces. changed tells how many letters actually move.
Frame dgx_morph_draw(morph, t, x, y, cell, trail, dot, user_data), dgx_morph_progress() Stateless: emits the dots of progress t in pixels through a dgx_morph_dot_func_t.
Renderers dgx_morph_glow_* / dgx_morph_sprite_* Additive glow with phosphor persistence, two bytes a pixel at any color depth, or an intensity-scaled dot sprite. Both map brightness to colors through a replaceable 256-entry LUT in the screen format (16, 18 or 24 bits).
Filter dgx_morph_glow_set_filter(glow, filter, user_data), dgx_morph_glow_blur One more pass over a copy of every finished glow frame right before it is shown; it does not get into the phosphor. The ready blur smooths the steps of glyphs turned into dots; in one pass it works row by row and needs no copy.

Morph one glyph into another is a short example, and docs/morphing-en.md walks through all of it.

Colors

The helpers in include/dgx_colors.h are parameterized color constructors, not stored color values. DGX_RED(rgb_func) substitutes the supplied packer into a call with the color's RGB channels. The screen's pixel format is selected by passing either an uppercase packing macro from include/dgx_bits.h or an inline dgx_rgb_to_*() wrapper:

DGX_LIGHTGREY(DGX_RGB_16)   // pure macro expansion, RGB565
DGX_RED(dgx_rgb_to_16)      // inline wrapper, also RGB565
DGX_RED(DGX_RGB_24)         // 24-bit RGB
DGX_WHITE(DGX_RGB_12)       // 12-bit packed color

For example, DGX_RED(DGX_RGB_16) preprocesses all the way to the bitwise RGB565 packing expression with channels (255, 0, 0). With DGX_RED(dgx_rgb_to_16), it preprocesses to dgx_rgb_to_16(255, 0, 0). The argument is a macro or function name, not a function pointer: there is no indirect call or runtime format switch.

That keeps the color's meaning separate from its binary representation: one palette of names works for 12-, 16-, 18- and 24-bit screens. Switching a renderer from RGB565 to 18-bit RGB means changing the packer, not maintaining a second set of RED_565, RED_666 and RED_888 constants. Passing the packing macro gives direct macro expansion; passing the inline wrapper gives the same result through a typed function-like interface.

Available packers: DGX_RGB_12 / dgx_rgb_to_12, DGX_RGB_16 / dgx_rgb_to_16, DGX_RGB_18 / dgx_rgb_to_18, DGX_RGB_24 / dgx_rgb_to_24. Named colors include DGX_BLACK, DGX_WHITE, DGX_RED, DGX_GREEN, DGX_BLUE, DGX_CYAN, DGX_MAGENTA, DGX_YELLOW, DGX_ORANGE, DGX_NAVY, DGX_GOLD, DGX_SILVER, DGX_SKYBLUE and more. For most ESP32 TFT panels here, DGX_RGB_16 is the right choice.

Virtual screens (API)

Virtual screens let you draw into RAM first and decide later when and where to push the result — handy for flicker-free animation, off-screen composition, shadow buffers behind monochrome controllers, region copies, and LUT-expanded 8-bit assets. Declared in include/drivers/vscreen.h (color) and include/dgx_bw_screen.h (1-bit):

Function Description
dgx_vscreen_init(width, height, color_bits, cbo) Allocate a color RAM screen.
dgx_vscreen_clone(src) Allocate a copy with the same geometry and pixels.
dgx_vscreen_copy(dst, src) Copy a whole same-sized screen.
dgx_vscreen_to_vscreen(dst, x, y, src, has_transparency) Blit one RAM screen onto another.
dgx_vscreen_to_vscreen_oriented(dst, x, y, src, has_transparency, orientation) Blit with rotation/mirroring.
dgx_vscreen_to_screen(dst, x, y, src) Push a RAM screen to any destination (incl. hardware).
dgx_vscreen_region_to_screen(dst, x, y, src, x_src, y_src, w, h) Push a sub-region.
dgx_vscreen_to_screen_oriented(...) / dgx_vscreen_region_to_screen_oriented(...) Oriented variants.
dgx_vscreen8_to_screen16(dst, x, y, src, lut, flags) Expand an 8-bit indexed screen into 16-bit through a LUT. flags is 0 or DGX_LUT_TRANSPARENT (a pixel of index 0 is not put; true means the same) and DGX_LUT_EXACT (the colors of the table to the last bit, no hardware that mixes two colors) added together.
dgx_vscreen8_region_to_screen16(dst, x, y, src, x_src, y_src, w, h, lut, flags) The same for a sub-region; x, y are where the corner of the region goes.
dgx_bw_init(width, height) Allocate a 1-bit monochrome RAM screen.
dgx_vscreen_2h_init(left, right) Compose two screens as one wide logical screen.
dgx_vscreen_is_linear(scr) True when the screen is a dgx_vscreen_t with row-major pixels in v_array.
dgx_bw_screen_is_paged(scr) True when the screen keeps its bits in 8-row pages (dgx_bw_init(), SSD1306, ST7565R).
dgx_bw_blit_or(scr, x, y, bmap) OR a 1-bpp bitmap into a page screen, clipped.

Free any screen with dgx_screen_destroy(&scr).

Arc gauge

Declared in include/dgx_gauge.h:

Function Description
dgx_gauge_init(gauge, scr, cx, cy, inner_radius, width, start_angle, sweep_degrees, min, max, bg_color, color_fn) Configure a ring gauge and draw its empty ring. color_fn maps a value to a step color.
dgx_gauge_set_value(gauge, value) Update the value, redrawing only changed steps.
dgx_gauge_redraw(gauge) Redraw the whole gauge.

Bus backends

Declared under include/bus/:

Function Description
dgx_spi_init(host_id, dma_chan, mosi, miso, sclk, cs, dc, clock_speed_hz, cpolpha_mode) SPI master bus.
dgx_i2c_init(i2c_num, i2c_address, sda, sclk, clock_speed_hz) I2C master bus.
dgx_p8_init(d0..d7, wr, rd, cs, dc, pclk_hz) 8-bit parallel (I80) bus.
dgx_dsi_init(bus_id, data_lanes, lane_bit_rate_mbps, phy_ldo_chan, phy_ldo_mv) MIPI-DSI bus of ESP32-P4. phy_ldo_chan and phy_ldo_mv name the regulator of the chip that powers the DSI PHY, 3 and 2500 on the usual boards; -1 when it is powered otherwise.

Each returns a dgx_bus_protocols_t * to hand to a driver constructor. A driver pulls in only the transports it needs; enable them in menuconfig.

The 8-bit parallel bus stands on the I80 driver of the esp_lcd component, and that driver is two different ones. On an ESP32-S3 and an ESP32-P4 it can send data alone. On an ESP32 and an ESP32-S2, where the bus is made of the I2S peripheral, every transfer starts with a command byte. So on this bus a command goes in one transfer with its data, and every block of pixels goes with the command that writes memory: a block that continues a window has the window set anew, to what is left of it. That is one or two short commands more for every 4000 bytes of pixels, and the same code for both kinds of chips.

include/bus/dgx_dsi_esp32.h also has what a driver of another DSI display is written with: dgx_dsi_video_setup() makes the frame buffer from the size and the timing of the picture, dgx_dsi_video_start() starts sending it after the commands of the display, dgx_dsi_orientation() sets how the screen lies on the panel and dgx_dsi_screen_functions() gives the screen its drawing functions, those that write straight into the frame buffer among them. The window and the pixels go through the bus as to any display, with the commands 0x2A, 0x2B and 0x2C of the MIPI display command set. The bus answers those itself from dgx_dsi_video_start() on; before it everything goes to the display, so a controller that has registers of its own under those numbers gets them. src/drivers/jd9165.c is such a driver in full.

For a program the same header has dgx_dsi_show(), dgx_dsi_counters(), dgx_dsi_frame_us(), dgx_dsi_frame_ended(), dgx_dsi_tiles(), and dgx_dsi_bench(), dgx_dsi_tune() and dgx_dsi_tuning() with the structure dgx_dsi_tuning_t.

Flush control and batching

For a physical display that should not be updated after every primitive, draw the complete image into a virtual screen in RAM and send it to the panel when the frame is ready with dgx_vscreen_to_screen() (see Animation without flicker). The virtual screen is the backbuffer; in_progress is not a pixel buffer.

Some screens stage changes and commit them when update_screen() is called (ST7920, SSD1306, ST7565R, the two-head compositor). Every primitive marks what it changed with dgx_screen_touch(); the screen keeps one pending dirty rectangle. Outside a batch the rectangle is committed at once. Inside a batch (in_progress > 0) rectangles are merged, and the outermost dgx_screen_progress_down() commits their bounding box with a single update_screen() call. One transfer of a larger area is usually cheaper than several small ones: setting up a transfer costs more latency than the pixels themselves. On drivers that transmit pixels immediately from write_area(), the batch cannot buffer those transfers; use a virtual screen when the whole frame must be composed before it is sent.

Declared in include/dgx_screen.h:

API Description
dgx_screen_progress_up(scr) Open a batch (nesting counter). Returns the new depth.
dgx_screen_progress_down(scr) Close a batch; at depth 0 commits the pending dirty area. Returns the new depth.
dgx_screen_touch(scr, left, right, top, bottom) Mark an area as changed (inclusive, clipped). For code that writes pixels directly, e.g. into v_array.
dgx_screen_flush(scr) Commit the pending dirty area now.
dgx_screen_take_dirty(scr, &left, &top, &width, &height) Take the pending dirty area and clear it without committing; false when nothing has changed. For a virtual screen shown by parts: there is something to take only while a batch is open.
dgx_screen_destroy(&scr) Destroy a screen allocated by a driver or virtual screen constructor.
dgx_screen_progress_up(scr);
dgx_fill_rectangle(scr, x, y, w, h, bg);
dgx_draw_line(scr, x1, y1, x2, y2, fg);
dgx_font_string_utf8_screen(scr, x, y, "42", fg, DgxOutputNormal, 1, font, NULL, NULL);
dgx_screen_progress_down(scr);   // one update_screen() for everything above

Guidelines:

  • Most applications only need progress_up/progress_down around a group of drawing calls, or nothing at all.
  • Always pair every up with one down; do not modify in_progress directly.
  • If you write pixels behind the primitives' back, call dgx_screen_touch() for that area.

Drawing from several tasks

DGX has no task of its own and no lock of its own, and nothing in it is global. All it keeps is in the objects a program makes: a screen with its bus, a virtual screen, a morph, a writer of handwriting. So what may run at the same time follows from what each object keeps between calls.

Object What it keeps between calls What follows
The screen of a display and its bus the window being written and the place in it, in the controller or, for MIPI-DSI, in the bus; the one transfer buffer of the bus; transfers under way; how deep in batches the screen is and what is changed but not shown yet one task at a time
A virtual screen its pixels, how deep in batches it is, what is changed one task writes it at a time; any number of virtual screens may be written at the same time
A font nothing, it is read only shared freely
A morph, a glow renderer, a writer or a morph of handwriting its own progress and memory one task an object
Several displays nothing in common they do not hinder each other; each takes one task at a time

Why a screen takes one task at a time: a primitive is "set a window, then send pixels into it", often in several pieces through the one buffer of the bus. Two tasks that do it at once send their pixels into each other's windows. Nothing guards against that, on any bus.

A program with several sources of pictures has two ways.

One task draws, the others send it messages. The task that owns the display is the only one that calls DGX for it, so nothing is shared and there is no mutex: a mutex guards data, and here no data has two owners. A source sends what has changed, or a virtual screen it has drawn in, and does not touch that screen until it is told the picture was taken. The drawing task puts what came in the time of a frame into one batch and keeps the pace of the display as any single drawing task does. This way also lets a program keep clear of inversion of priorities: no source waits for another source, only for the drawing task, so give that task a priority above the tasks that prepare pictures. It is the way to choose when pictures are to come evenly.

Every task draws itself, under a mutex of the program. It works as well, and is less to write when each task has a part of the screen of its own.

  • Lock a batch, not a primitive. What is drawn between dgx_screen_progress_up() and dgx_screen_progress_down() is shown together, so the mutex goes around the two. The depth of batches is kept by the screen, not by the task: two tasks that open batches on one screen without a mutex make one batch of them.
  • Draw into a virtual screen without the lock, send it with the lock: each task has a virtual screen of its own and takes the mutex only for dgx_vscreen_to_screen().
  • A mutex, not a binary semaphore. A task of low priority that is put aside while it holds the lock keeps all the others waiting; a mutex of FreeRTOS raises its priority meanwhile, a semaphore does not.

The two ways measured on an ESP32-P4 with a MIPI-DSI display and two frame buffers, three sources, 10 seconds. The cells tell how many times the next picture of a source came after one, two and three frames of the display: all in one column is even motion.

Three sources Each draws itself under a mutex Messages to one task that draws
A part of 320x100 each, a picture a frame 609, 0, 0 607, 0, 0
A band of 1024x168 each, little redrawn, a picture a frame 608, 0 to 1, 0 604 to 606, 0 to 1, 0
A band of 1024x168 each, redrawn whole, a picture every second frame 1 to 53, 198 to 302, 1 to 53 1, 302, 0

In either way no drawing call may be made from an interrupt handler: they wait for transfers.

What a drawing call may wait for, with the lock held if there is one:

Bus What a drawing call waits for
SPI, I2C, 8-bit parallel the transfer of the pixels, as long as the bus takes for them
MIPI-DSI, one frame buffer nothing but a transfer or a fill by the hardware of the chip, when the piece is large
MIPI-DSI, two frame buffers the same, and the display: the first drawing after a finished picture may wait until the display has taken it, up to a frame, 16 ms on the JD9165 panel
MIPI-DSI, behind the scan the same as with one frame buffer, and the scan of the display to leave the rows, up to a frame

What the DSI bus does for several tasks with two frame buffers, and where DGX uses interrupts, is in docs/dsi-usage-en.md.

Coordinates and orientation

Virtual screens use a single canonical layout:

  • origin in the top-left, x going right, y going down;
  • pixels stored row-major at offset x + y * width;
  • set_area, write_area and read_area always use those canonical bounds and traverse left-to-right, top-to-bottom.

Hardware panel drivers have their own orientation setters (dgx_st7789_orientation(), dgx_gc9a01_orientation() and so on) that reprogram the controller's scan direction. Text rendering takes an explicit dgx_output_orientation_t, which means you can draw rotated or mirrored text on any screen without touching the framebuffer layout. Handwritten text is the exception: it has no orientation parameter and is always written left to right, see Known gaps.

The dir_x, dir_y and swap_xy fields on the screen struct are currently metadata on virtual screens — they describe the screen but do not transform framebuffer access. In practice, treat virtual screens as always stored in their canonical layout.

Build options

Everything is wired through Kconfig. Toggle only what you need; drivers pull in the transports they require.

Option Adds Notes
CONFIG_DGX_ENABLE_SPI bus/spi_esp32.c SPI transport
CONFIG_DGX_ENABLE_I2C bus/i2c_esp32.c I2C transport
CONFIG_DGX_ENABLE_P8 bus/p8_esp32.c 8-bit parallel (I80) transport
CONFIG_DGX_ENABLE_DSI bus/dsi_esp32.c, bus/fb_area.c, bus/fb_tiles.c, bus/fb_flip.c MIPI-DSI transport; chips that have it (ESP32-P4)
CONFIG_DGX_ENABLE_SPI_ST7735 drivers/st7735.c needs SPI or P8
CONFIG_DGX_ENABLE_SPI_ST7789 drivers/st7789.c needs SPI or P8
CONFIG_DGX_ENABLE_SPI_GC9A01 drivers/gc9a01.c needs SPI or P8
CONFIG_DGX_ENABLE_SPI_ILI_9341 drivers/ili9341.c needs SPI or P8
CONFIG_DGX_ENABLE_SSD1351 drivers/ssd1351.c needs SPI or P8
CONFIG_DGX_ENABLE_SSD1306 drivers/ssd1306.c needs SPI or I2C; selects V_BW_SCREEN
CONFIG_DGX_ENABLE_ST7565R drivers/st7565r.c needs SPI; selects V_BW_SCREEN
CONFIG_DGX_ENABLE_ST7920 drivers/st7920.c needs SPI; selects VSCREEN
CONFIG_DGX_ENABLE_JD9165 drivers/jd9165.c needs DSI
CONFIG_DGX_ENABLE_V_BW_SCREEN bw_screen.c 1-bit virtual screen
CONFIG_DGX_ENABLE_VSCREEN drivers/vscreen.c color RAM-backed screen
CONFIG_DGX_ENABLE_VSCREEN_2H drivers/vscreen_2h.c needs VSCREEN
CONFIG_DGX_ENABLE_MORPH matrix.c, morph*.c dot morphing; selects VSCREEN
CONFIG_DGX_DSI_HARDWARE_COPY nothing DSI on chips with a 2D DMA: large pieces are moved and filled past the processor; on by default
CONFIG_DGX_HW_FONT_IN_IRAM nothing ESP32 only: the code that lays out a handwritten text is kept in IRAM, 4.1 KB
CONFIG_DGX_BEZIER_IN_IRAM nothing ESP32 only: the code of the pen of Bezier curves is kept in IRAM, 9.3 KB

The two IN_IRAM options are for a handwritten text on an ESP32. There code and the tables of a font are read from flash through one cache of two ways, and the time of the same text changes from build to build with where the linker has put the font: going through its strokes takes up to twice as long, drawing it up to a third longer. With the code in IRAM it does not. IRAM of an ESP32 is no part of the heap, but there are 128 KB of it and a program with Wi-Fi leaves a few; see docs/pen-en.md.

dgx_lcd_init.c, the drawing and font code, handwritten fonts included, and everything in src/fonts/ are always compiled. Fonts are picked up via a file(GLOB) on src/fonts/*.c, so the linker keeps only the font objects your application actually references. If you add or remove a font file, rerun CMake configure once.

Known gaps

A couple of practical limitations are worth knowing up front:

  • Pixel readback works only from RAM. get_pixel() reads virtual screens, panels that draw into a virtual back screen first (ST7920, SSD1306, ST7565R) and a DSI display, whose frame buffer is memory of the chip (JD9165). Direct panels (ILI9341, ST7789, ...) return 0: many SPI modules have no MISO line at all, and where RAMRD works it is far too slow. If you need to read what you drew, draw into a vscreen and push it with dgx_vscreen_to_screen(). dgx_screen_is_readable() tells which of the two a screen is. dgx_scroll() reads the picture it moves; on a screen that is not read it moves only what the panel can scroll itself, an ILI9341 along its long side.

  • The scroll of the panel is used on an ILI9341 only. ST7789, ST7735 and GC9A01 have the same two commands and take the same code, three lines of a driver each; they are not switched on, for there was no panel to see them on. dgx_scroll() returns ESP_ERR_NOT_SUPPORTED there.

  • Virtual screens don't honor dir_x/dir_y/swap_xy for framebuffer access. Those fields describe orientation metadata, but they do not rotate or mirror the stored pixel data.

  • Handwritten text goes left to right only. There is no orientation parameter. A soft edge of the lines, DGX_PEN_SOFT, comes out on a virtual screen of 8 bits a pixel only; drawn straight to a display or into a screen of another depth, the lines are not antialiased. The direction has a plain reason: I have never written right to left or top to bottom myself, have no handwritten fonts of that kind and do not know such languages, so there was nothing to build it on. Rotate a virtual screen on its way to the display if you need the text turned.

  • The handwritten font knows Latin with German letters and Russian. That is all font0ss has. Other alphabets are to be drawn in the hw-fonts editor by those who need them; contributions are welcome.

Host tests

The platform-independent code (matrices, morphing, renderers, virtual and page screens, fills, lines, circles and ellipses with their arcs and sectors, scrolling in memory, the frame buffer of the DSI bus and the way its two frame buffers change places, fonts, textured quads, Bezier curves, handwritten fonts with their writing and morphing, the arc gauge) has host tests built with gcc against small ESP-IDF stubs and run under ASan/UBSan. Two pieces that do depend on the platform are tested there too, each against a model of what is on its wires: the 8-bit parallel bus with both drivers of IDF under it, the ST7920 driver in all its orientations, and the ILI9341 driver with the scroll of its panel:

make -C test/host

What a host cannot tell, the look of a figure on a panel, the scroll of the panel itself and the times on the chip, is seen on a board: examples/figures_scroll_demo, for a CYD or a JC1060P470. On a display that is read it draws and scrolls the same on the display and on a virtual screen and compares the two pixel by pixel.

Repository layout

include/                 public headers
  bus/                   transport interfaces (SPI, I2C, P8, DSI)
  drivers/               panel drivers + virtual screens
  fonts/                 generated font headers
src/                     implementations matching include/
  bus/                   ESP32 bus backends
  drivers/               panel + virtual screen sources
  fonts/                 generated font sources (glob-built)
font2c/                  offline TTF/BDF -> C font generator
examples/                one project per example, see "Examples"
test/host/               host tests with ESP-IDF stubs
docs/                    morphing articles and handwritten text guides (English and Russian),
                         the converter and the tables of handwritten fonts (Russian),
                         a guide to a display on the DSI bus and notes on how its bus is made
                         (English and Russian)
Kconfig                  feature toggles
CMakeLists.txt           ESP-IDF component build

See CHANGES.md for the release history.

License

Copyright (c) 2021-2026 Anton Petrusevich. See LICENSE.

About

DGX is a small, opinionated display graphics library for microcontrollers.

Topics

Resources

Stars

5 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages