feat(eink): bulk SPI row writes for the GDEQ0426T82, at 8 MHz

GxEPD2 sends the panel RAM a byte at a time; on the nRF52's SPIM the
per-call overhead dominates, and an 800x480 partial refresh writes the
whole 48 KB several times. GxEPD2_426_GDEQ0426T82_Fast overrides the three
image writes to send each row in one transfer() call, falling back to the
stock path for the cases it doesn't cover (first write, mirror_y, PROGMEM).
Byte-for-byte the same data as the stock driver.

Opt-in per env: -D EINK_FAST_WRITES=1, and EINK_SPI_HZ (default 4 MHz,
8 MHz here, SPIM2's ceiling).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KxAZVAbhhYr18Dd4guTQM1
This commit is contained in:
pmow
2026-10-09 08:47:35 -10:00
co-authored by Claude Opus 5.5
parent cc4774436f
commit da5ff56cff
4 changed files with 133 additions and 3 deletions
@@ -56,5 +56,7 @@ build_flags = ${nrf52_base.build_flags}
-D DUAL_SERIAL=1
-D ENABLE_SCREENSHOT
-D KEEP_DISPLAY_ON_USB
-D EINK_FAST_WRITES=1
-D EINK_SPI_HZ=8000000
-Os
extra_scripts = post:create-uf2.py
+108
View File
@@ -0,0 +1,108 @@
#pragma once
// Faster image writes for large GxEPD2 panels.
//
// GxEPD2 sends the framebuffer to the panel one byte at a time: every byte is
// its own SPI.transfer(uint8_t) call, and on nRF52 each of those is a complete
// EasyDMA transaction with several microseconds of fixed setup overhead --
// independent of the SPI clock. A partial refresh here writes the full screen
// five times (GxEPDDisplay::endFrame's inverse-redrive pass, then
// GxEPD2_BW::display()), so on an 800x480 panel (48 KB a screen) that overhead
// alone blocks the main loop for well over a second per refresh.
//
// These subclasses override only the image writes, sending each row as one
// bulk SPI.transfer(buf, nullptr, n) -- the same bytes, in the same order, to
// the same RAM window -- so the cost becomes just the wire time. Everything
// else (init, refresh, power) is the stock driver. The first write (panel init
// and the initial screen clear) and the rarely used mirror/PGM variants go
// through the stock path unchanged.
//
// Use by naming the subclass as the panel: -D EINK_DISPLAY_MODEL=GxEPD2_426_GDEQ0426T82_Fast
#include <string.h>
#ifdef _GxEPD2_426_GDEQ0426T82_H_
class GxEPD2_426_GDEQ0426T82_Fast : public GxEPD2_426_GDEQ0426T82 {
typedef GxEPD2_426_GDEQ0426T82 Base;
uint8_t _row[WIDTH / 8]; // one panel row, in RAM (EasyDMA can't read flash)
bool canFast(bool mirror_y, bool pgm) const {
return _init_display_done && !_initial_write && !mirror_y && !pgm;
}
// Same RAM window as the stock driver's (private) _setPartialRamArea():
// the gates are reversed on this panel, so y is mirrored and entered decreasing.
void setRamArea(uint16_t x, uint16_t y, uint16_t w, uint16_t h) {
y = HEIGHT - y - h;
_writeCommand(0x11);
_writeData(0x01);
_writeCommand(0x44);
_writeData(x % 256);
_writeData(x / 256);
_writeData((x + w - 1) % 256);
_writeData((x + w - 1) / 256);
_writeCommand(0x45);
_writeData((y + h - 1) % 256);
_writeData((y + h - 1) / 256);
_writeData(y % 256);
_writeData(y / 256);
_writeCommand(0x4e);
_writeData(x % 256);
_writeData(x / 256);
_writeCommand(0x4f);
_writeData((y + h - 1) % 256);
_writeData((y + h - 1) / 256);
}
// The stock _writeImage(), with the per-byte loop replaced by one bulk
// transfer per row (clipping and indexing unchanged).
void fastWrite(uint8_t command, const uint8_t bitmap[], int16_t x, int16_t y, int16_t w, int16_t h, bool invert) {
delay(1); // as the stock driver: yield
int16_t wb = (w + 7) / 8;
x -= x % 8;
w = wb * 8;
int16_t x1 = x < 0 ? 0 : x;
int16_t y1 = y < 0 ? 0 : y;
int16_t w1 = x + w < int16_t(WIDTH) ? w : int16_t(WIDTH) - x;
int16_t h1 = y + h < int16_t(HEIGHT) ? h : int16_t(HEIGHT) - y;
int16_t dx = x1 - x;
int16_t dy = y1 - y;
w1 -= dx;
h1 -= dy;
if ((w1 <= 0) || (h1 <= 0)) return;
setRamArea(x1, y1, w1, h1);
_writeCommand(command);
_startTransfer();
const int16_t nb = w1 / 8;
for (int16_t i = 0; i < h1; i++) {
const uint8_t* src = &bitmap[dx / 8 + int32_t(i + dy) * wb];
if (invert) { for (int16_t j = 0; j < nb; j++) _row[j] = ~src[j]; }
else { memcpy(_row, src, nb); }
_pSPIx->transfer(_row, nullptr, nb);
}
_endTransfer();
delay(1);
}
public:
GxEPD2_426_GDEQ0426T82_Fast(int16_t cs, int16_t dc, int16_t rst, int16_t busy) : Base(cs, dc, rst, busy) {}
using Base::writeImage; // keep the other overloads visible
void writeImage(const uint8_t bitmap[], int16_t x, int16_t y, int16_t w, int16_t h,
bool invert = false, bool mirror_y = false, bool pgm = false) override {
if (!canFast(mirror_y, pgm)) { Base::writeImage(bitmap, x, y, w, h, invert, mirror_y, pgm); return; }
fastWrite(0x24, bitmap, x, y, w, h, invert);
}
void writeImageForFullRefresh(const uint8_t bitmap[], int16_t x, int16_t y, int16_t w, int16_t h,
bool invert = false, bool mirror_y = false, bool pgm = false) override {
if (!canFast(mirror_y, pgm)) { Base::writeImageForFullRefresh(bitmap, x, y, w, h, invert, mirror_y, pgm); return; }
fastWrite(0x26, bitmap, x, y, w, h, invert); // set previous
fastWrite(0x24, bitmap, x, y, w, h, invert); // set current
}
void writeImageAgain(const uint8_t bitmap[], int16_t x, int16_t y, int16_t w, int16_t h,
bool invert = false, bool mirror_y = false, bool pgm = false) override {
if (!canFast(mirror_y, pgm)) { Base::writeImageAgain(bitmap, x, y, w, h, invert, mirror_y, pgm); return; }
fastWrite(0x26, bitmap, x, y, w, h, invert); // set previous
fastWrite(0x24, bitmap, x, y, w, h, invert); // set current
}
};
#endif
+9 -1
View File
@@ -14,8 +14,16 @@
SPIClass SPI1 = SPIClass(FSPI);
#endif
// SPI clock for the panel. 4 MHz is the conservative default every board has
// always used; a short FPC run can go faster (SSD16xx-family controllers are
// rated ~20 MHz for writes). On nRF52840 the Adafruit core puts SPI1 on SPIM2,
// which caps at 8 MHz -- higher requests are silently clamped to 8 MHz there.
#ifndef EINK_SPI_HZ
#define EINK_SPI_HZ 4000000
#endif
bool GxEPDDisplay::begin() {
display.epd2.selectSPI(SPI1, SPISettings(4000000, MSBFIRST, SPI_MODE0));
display.epd2.selectSPI(SPI1, SPISettings(EINK_SPI_HZ, MSBFIRST, SPI_MODE0));
#ifdef ESP32
SPI1.begin(PIN_DISPLAY_SCLK, PIN_DISPLAY_MISO, PIN_DISPLAY_MOSI, PIN_DISPLAY_CS);
#else
+14 -2
View File
@@ -18,6 +18,18 @@
#include <GxEPD2_7C.h>
#include "EinkGfxDisplay.h"
#include "GxEPD2_FastWrite.h"
// EINK_FAST_WRITES=1: drive the panel through its <model>_Fast subclass
// (GxEPD2_FastWrite.h) -- the same bytes, sent a row at a time in bulk instead
// of one SPI call per byte. Only for panels that have one.
#define EINK_CAT_(a, b) a##b
#define EINK_CAT(a, b) EINK_CAT_(a, b)
#if defined(EINK_DISPLAY_MODEL) && defined(EINK_FAST_WRITES) && EINK_FAST_WRITES
#define EINK_PANEL_TYPE EINK_CAT(EINK_DISPLAY_MODEL, _Fast)
#elif defined(EINK_DISPLAY_MODEL)
#define EINK_PANEL_TYPE EINK_DISPLAY_MODEL
#endif
// This driver calls callBusyPump() during its BUSY-pin waits; app code keys
// its setBusyPumpFn() wiring off this so it isn't compiled for displays (or
@@ -53,8 +65,8 @@
// The panel object, as a base so it's built before EinkGfxDisplay draws on it.
struct GxEPDPanel {
#if defined(EINK_DISPLAY_MODEL)
GxEPD2_BW<EINK_DISPLAY_MODEL, EINK_DISPLAY_MODEL::HEIGHT> display;
GxEPDPanel() : display(EINK_DISPLAY_MODEL(PIN_DISPLAY_CS, PIN_DISPLAY_DC, PIN_DISPLAY_RST, PIN_DISPLAY_BUSY)) {}
GxEPD2_BW<EINK_PANEL_TYPE, EINK_PANEL_TYPE::HEIGHT> display;
GxEPDPanel() : display(EINK_PANEL_TYPE(PIN_DISPLAY_CS, PIN_DISPLAY_DC, PIN_DISPLAY_RST, PIN_DISPLAY_BUSY)) {}
#else
GxEPD2_BW<GxEPD2_150_BN, 200> display;
GxEPDPanel() : display(GxEPD2_150_BN(DISP_CS, DISP_DC, DISP_RST, DISP_BUSY)) {}