EPD_Painter · How it works · July 2026
Full-motion greyscale video on a stock ESP32-S3 e-paper board: no FPGA, no PC, the panel driven directly over its parallel bus. This page explains how the driver actually works: a vector unit fed 64 pixels at a time, waveforms that live in registers rather than tables, a delta engine that only pays for change, and a flatbed scanner that keeps the whole thing honest.
EPD_Painter is an open-source driver for controller-less parallel e-paper panels (the kind in Kindles) on the ESP32-S3, currently the M5PaperS3 and LilyGo T5 boards. These panels are dumb glass. There is no controller, no framebuffer, no command set: just a source-driver shift register along one edge and a gate driver along the other. You clock 960 pixels' worth of drive data into the shift register, pulse a latch, advance the gate to the next row, and repeat 540 times. That is one pass. The ink doesn't move in one pass; electrophoretic particles drift while voltage is applied, so an image is built from seven passes, each telling every pixel to darken, lighten, or float.
Which means grey is not a value you write, it is a dose you deliver: how many passes, and how long each one is left to act. Most microcontroller drivers manage a few frames per second this way, and the usual route to video-rate e-paper is an FPGA with hand-rolled waveforms. EPD_Painter's bet is that a $71 ESP32-S3 board has exactly enough silicon, provided the driver refuses to do unnecessary work. The rest of this page is what that refusal looks like in practice.
Three buffers, three jobs, each in the memory that suits it:
Adafruit GFX / LVGL / pre-packed frames from SD
│ draw
8bpp canvas ......................... PSRAM, 518 KB
│ SIMD delta vs panel state (64 px per op)
2bpp ink planes + row masks ....... internal SRAM, 130 KB
│ ×7 passes: waveform convert, registers only
240-byte row buffers .............. internal SRAM, double-buffered
│ LCD_CAM DMA
panel source driver ............... the glass
Applications draw wherever they like on an ordinary 8-bit canvas through Adafruit GFX or LVGL; the video player skips the canvas entirely and hands in frames already packed two bits per pixel (a Python converter produces these: linear-reflectance greys, ordered dither, PackBits RLE, about 4.4 KB per frame of Bad Apple, streamed straight off SD). Everything below that line is the driver's business, and everything below that line is built around one principle: touch each byte as few times as possible, in the fastest memory it can live in.
The driver keeps a 2-bit-per-pixel mirror of what is physically on the glass. When a new frame arrives, a single SIMD pass compares it against that mirror and emits ink: a darkening plane (pixels appearing on white ground) and a lightening plane (pixels being erased), plus a bitmask per row recording which 64-pixel chunks contain any work at all. The two planes are pixel-disjoint by construction; a changed pixel is going one way or the other.
Everything downstream is gated on those masks. A row whose mask is empty is never converted and never re-read in any of the seven passes; a static screen costs almost nothing, and a talking-head video pays only for the head. This is the property that makes e-paper video tractable at all: the panel's slowest habit (needing seven sweeps) is multiplied only by the pixels that actually changed.
The delta pass is also where PSRAM is paid for, exactly once. Reading the new frame is the comparison; the copy into the driver's working buffer is fused into the same 64-pixel loop, so per frame each canvas byte crosses the PSRAM bus a single time. After that the seven passes run entirely out of internal SRAM.
The ESP32-S3 hides a genuinely useful secret: the PIE vector unit, with 128-bit registers and single-cycle byte-lane operations. The driver's entire pixel path is written against it in assembly. Two bits per pixel is not a compression choice, it is an alignment choice: 64 pixels fill one vector register exactly, so compare, select, mask and pack all operate on 64 pixels per instruction. The full-screen delta detection (both planes, both buffers, mask generation, mirror update) costs 6.4 ms for half a million pixels.
A common objection is that SIMD is pointless here because memory bandwidth dominates. That is exactly why the buffers are arranged the way they are: the vector code is fed from internal SRAM at core speed, and PSRAM only ever appears in the one fused pass above. The vector unit is not fighting the memory system; the memory layout exists so that it never has to.
Here is the model shift that buys the most. A waveform maps a pixel's 2-bit value to a drive code for the current pass. The textbook implementation is a lookup table in RAM consulted per pixel, per pass: at 960×540×7 that is 3.6 million memory reads per frame before a single byte reaches the panel.
EPD_Painter instead treats the waveform as state, not data. A pass's entire waveform column is a handful of bytes; at the top of each conversion call it is broadcast into vector registers once, and from then on the translation from packed pixels to drive codes is pure register work: shifts, compares, selects, 64 pixels at a time, zero memory lookups per pixel. A 960-pixel row is fifteen calls into the assembly. Converting all seven passes of a full frame this way takes 15.7 ms, and scales down with the row masks, so a mostly-quiet frame converts in a fraction of that.
The panel's parallel bus is driven by the S3's LCD_CAM peripheral, a block intended for cheap TFT screens and perfectly happy to clock 240 bytes of row data at an e-paper's shift register instead. Row buffers are double-buffered in internal SRAM: while DMA streams row n to the glass, the vector unit is converting row n+1. The CPU never bit-bangs a pixel and never waits for the wire; transmission time hides entirely behind conversion time. The row-timing signals (start pulse, gate clock, latch) are the only part the CPU touches, because their edges are where the panel's physics lives, as the next section demonstrates the hard way.
One awkward case stresses the whole design: a 64-pixel chunk containing a moving silhouette edge needs pixels darkened on its leading side and lightened on its trailing side, in the same update. An early version of the engine could only send one direction per chunk per frame and deferred the other, which under continuous video produced smeared, trailing edges. The obvious hardware-flavoured fix: the gate driver holds a row selected until you clock CKV, and the latch pin (LE) can fire as many times as you like in between. So latch the darkening data onto the row, then shift in the lightening data and latch it onto the same row before advancing. Both directions per row, per pass. We built it: a dual-plane delta detector emitting seperate darken/lighten data, and a latch-without-advance mode in the row driver.
It ran. It also looked like FIG. 3: darks barely forming, washed-out pencil-sketch edges. Debugging by squinting at a phone photo was getting nowhere, so we stopped guessing and measured. This project already used a Canon flatbed scanner as a photometer, so we wrote a probe: raw drive codes, no delta engine, three bands of one-pixel stripes every six rows. Band 1 drives each stripe normally (the control). Band 2 drives the stripe, then latches an all-float data set onto the same row without advancing. Band 3 inverts the order: float first, black delivered only by the second, no-advance latch. Flash, place panel on scanner, scan at 300 dpi, measure stripe positions numerically.
One scan settles two questions. The bottom band shows the mechanism works: a second latch drives exactly the same gate row, with no displacement. The middle band shows the idea is nevertheless a dead end: following black with float erased the black almost completely. The blank band forced the right mental model. A row's transmission slot is ~30 µs, far too short to move ink. What actually drives the pixel is that each cell stores the latched voltage on its own capacitance and keeps driving until that row is next latched, normally a full pass period, hundreds of times longer. Retention is the dose. It is also why 540 rows effectively drive in parallel from one shared source driver.
So driving a row twice per pass hands essentially the entire dose to whichever plane goes last; the other gets a 30 µs consolation prize. A property of the glass, not a defect in the code, and not something any amount of reasoning about latches was going to reveal from the armchair.
The same physics hands over the correct construction. The wire format
is 2 bits per pixel: 00 float,
01 darken, 10 whiten. Direction
was never a per-chunk property of the hardware, only of the buffer
encoding. And the two ink planes are pixel-disjoint by construction. So
both directions ride one transmission: convert the
dark plane into the row buffer, then OR the light plane's ink on top
with a load-OR-store variant of the same register-resident converter.
One latch. Full retention for every pixel.
// per row, per pass: both directions in one transmission
memset(dma_buf, 0x00, row_bytes); // float default
convert_packed_fb_to_ink (darkPlane, dma_buf, row_bytes, darker_wf, maskD);
convert_packed_fb_to_ink_or(lightPlane, dma_buf, row_bytes, lighter_wf, maskL);
sendRow(); // one latch, full dose
Everything the driver once carried to work around not having this (deferral queues, direction priorities, an "interlace mode" that ran three full drive cycles to dither the artifact) turned out to be the slow part, and is gone. The correct engine is also the simple one.
The panel shows four levels: white, two greys, black. What dose should the greys get? Datasheets don't say; every panel batch and temperature is slightly different, and human eyes flatter whatever they are shown. The driver's waveform tables were therefore calibrated optically, against the panel itself. The panel displays test cards mixing solid grey patches with ordered-dither patterns of its own black and white; the flatbed scanner reads both; a script adjusts the waveform's pass doses until each solid grey numerically matches the reflectance of the dither it should be indistinguishable from.
Matching greys to dithers, rather than to abstract lightness targets, is what makes the video pipeline seamless: the converter's ordered-dither shading and the panel's solid greys land on the same optical scale, so dithered gradients stay smooth instead of banding. Each supported board carries its own tuned tables. The quality modes differ mostly in dose: the inter-pass settling delays of the higher modes are part of the calibrated dose itself, while the fast mode trades them away for 20 fps.
| Per frame (960×540, 7-pass fast mode) | cost |
|---|---|
| Delta detection + PSRAM ingest (SIMD, both planes) | 6.4 ms |
| Waveform convert, all passes (register-resident) | 15.7 ms |
| Row transmission | hidden behind convert (DMA) |
| Sustained playback | 20.0 fps |
An embarassing footnote, kept because it is the most instructive number here: late in development the build measured 17 fps and we blamed DMA pipeline variance, at length and with conviction. The actual cause was a forgotten 2 ms experimental delay in the pass loop. Removing one line restored 20.0. Every wall-clock mystery in this project turned out to be something measurable, never the thing we had argued ourselves into; the flatbed scanner and a stripe pattern settled in one image what days of reasoning could not.
Prior art, credited: FPGA-driven e-paper video demos proved the glass could do this years ago; EPDiy and FastEPD proved microcontrollers could drive bare panels at all. This project sits in the intersection: video-rate, greyscale, optically calibrated, delta-driven, on hardware anyone can buy, in an Arduino library anyone can read. The panel was never the bottleneck; it was waiting for a driver that only does necessary work.
Code & docs:
github.com/tonywestonuk/EPD_Painter:
see How_It_Works.md for the full pipeline and the
retention physics, and examples/other/bad_apple for the
player and video converter.
Built by Tony Weston with Claude (Anthropic) as pair programmer. The hardware intuitions (the double-latch experiment, the dose insight behind the quality-mode delays) were Tony's; the probe, the assembly, and the wrong DMA theory were the AI's. The scanner kept both honest. Panel: 4.7″ 960×540 on LilyGo T5 E-Paper S3 Pro ($71) / M5PaperS3.