EPD_Painter · How it works · July 2026

Bad Apple at 20 fps on a $71 e-paper display

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.

20.0fps painted
960×540panel, 4 greys
$71whole device
64 pxper SIMD op
NoneFPGAs required
FIG. 1 · The panel playing Bad Apple at 20 fps. If the player doesn't load here, watch it on YouTube.
Flatbed scan of the e-paper panel showing a clean white silhouette with detailed hair outline on a solid black background, a paused frame of the Bad Apple video
FIG. 2 · Paused video frame, captured by scanning the panel itself on a Canon flatbed at 300 dpi. Solid blacks, crisp silhouette edge, background dither texture intact. This is the panel's real output, not a screenshot.

01The glass you are actually driving

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.

02The pipeline at a glance

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.

03Only pay for change

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.

04Sixty-four pixels per instruction

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.

05The waveform lives in registers, not in a table

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.

06Rows go out by DMA while the next converts

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.

07The experiment the glass refused: driving a row twice

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.

Photo of the e-paper panel showing a barely visible, washed-out sketch-like image with scattered horizontal black dashes
FIG. 3 · The double-latch build's output: washed-out pencil-sketch edges and scattered chunk-length dashes. Wrong, but wrong in an informative way: the darkening plane was being starved of drive, everywhere, and only the lightening survived.

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.

The LilyGo T5 e-paper board lying face-down on a flatbed scanner's glass, USB-C cable running out from under the lid
FIG. 4 · The instrumentation. Device under test face-down on the scanner glass, USB tether to the build machine running out under the lid. The loop is fully closed: flash firmware, drive the panel, scan the result, measure the pixels. No human judgement in the chain.
Flatbed scan of the panel showing three horizontal bands: fine stripes at top, a blank middle band, fine stripes at bottom
FIG. 5 · The probe, scanned. Top band (control): stripes. Middle band (black then float): blank. Bottom band (black via second latch only): stripes, landing within 0.4 px of the control grid extrapolated 57 periods down. (First attempt was entirely blank: we'd guessed the drive polarity backwards. The waveform tables settled it: on this glass, code 01 darkens.)

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.

single latch
latch
retained charge keeps driving for the whole pass period
double latch
A
≈30µs
B
B retains the full period; A got ~0.2% of its dose
Whichever data set is latched first is discharged by the second. Retention cannot be shared.

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.

08Both directions, one latch

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.

09Waveforms tuned on a flatbed scanner

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.

10The numbers, and one confession

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 transmissionhidden behind convert (DMA)
Sustained playback20.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.

Flatbed scan of another paused video frame: a figure with an outstretched hand, individual fingers resolved, on a dark patterned background
FIG. 6 · Another paused frame, scanned mid-playback (the player grew a serial pause command precisely so the scanner could verify the video). Individual fingers resolved on a moving silhouette; swirling background texture held in the blacks.

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.