Writing a backend
A backend is the only part of the stack that knows about hardware. It is one folder per rendering
API or peripheral, not per chip: any STM32 with a Chrom-ART blitter uses dma2d, any RP2040 with an
SPI panel uses pico-spi-lcd. Before writing one, check whether an existing backend already covers
your display; a new board is usually a new board.c in an example, not a new backend.
The contract
#include "native_renderer.h"
static void fill (uint32_t argb, int x, int y, int w, int h, void *ctx);
static void copy (const void *src, int stride, int x, int y, int w, int h, void *ctx);
static void blend(const void *src, int stride, uint8_t alpha, int x, int y, int w, int h, void *ctx);
static void wait_fn(void *ctx); /* may be NULL */
static void on_frame(void *ctx); /* may be NULL */
static const EmbeddedRenderBackend backend = {fill, copy, blend, wait_fn, on_frame, &my_state};
embedded_renderer_set_backend(&backend);
fill_rectpaints a solid rectangle. The colour is straight-alpha0xAARRGGBB, and an alpha below 255 means blend, not replace (anti-aliased rounded corners arrive this way). 1-bit glyphs arrive as runs of fills.copy_rectcomposites a premultiplied ARGB8888 buffer source-over: a pixel with alpha 255 replaces, a lower alpha blends, 0 leaves the destination alone.strideis the source row stride in bytes. Images and anti-aliased glyph rows arrive here, so a backend that copies the bytes verbatim draws text in black boxes.blend_rectcomposites a premultiplied ARGB8888 buffer at a globalalpha: scale every source channel, alpha included, byalpha / 255, then source-over,out = src + dst * (1 - srcA / 255). Vector and arc anti-aliasing, gradients, shadows, transformed content and translucent groups arrive here. A backend that leaves it NULL draws none of that, and nothing warns.waitblocks until the hardware has finished consuming the previous frame's pixels, for DMA-driven panels. NULL for a synchronous backend.frame_readysays a frame is complete. NULL if you present from your own loop.
Coordinates are in framebuffer pixels, already clipped to the screen. The backend converts to the panel's format (RGB565, RGB888, BGR) inside the callbacks; the engine never knows.
Presenting
The engine paints; it never presents. After er_commit(), the host asks the engine what changed and
pushes that to the panel:
er_commit();
ERRect rects[16];
int n = er_get_dirty_rects(rects, 16); /* the disjoint rectangles this commit repainted */
for (int i = 0; i < n; i++) panel_flush(rects[i]); /* one transfer window per region */
er_get_dirty_rect() returns the covering box if the driver wants one transfer. Both report the
last commit that actually painted, so a Flow A host, where the reconciler already committed inside
the pump, still reads the frame's real damage.
The dirty-rectangle convention. Track an accumulated box as inclusive minimum corner,
exclusive maximum corner, the same as ERRect, and name the fields dirty_x0/dirty_y0 and
dirty_x_end/dirty_y_end so the convention is visible at every use; empty is dirty_x_end <= dirty_x0. Backends are exactly where code
gets copied from one board to the next, and an inclusive box lifted into an exclusive flush loop (or
the reverse) leaves a one-pixel column of stale panel that survives every review, because both
versions look right in isolation. Convert at the panel call instead: esp_lcd_panel_draw_bitmap
takes exclusive bounds, an ST7789-style set_window takes inclusive ones.
Optional extensions
Banded rendering, for a board with no RAM for a framebuffer. Set band_height and provide
band_begin(x, y, w, h) and band_flush(). The engine then renders each commit's damaged rows as
full-width horizontal strips of at most band_height rows: for each strip it calls band_begin,
emits fill/copy/blend calls with band-local Y (already offset by the strip's top) into a
screen_width × band_height buffer, then calls band_flush to push it to the panel, whose own memory
retains the rest of the picture. Band tiling is applied when ops are emitted, not as a clip, so
transform and opacity scratch sources do not truncate at a seam. esp32-spi-lcd keeps two band
buffers and ping-pongs them, compositing the next strip while the panel DMAs the previous one;
without that overlap a large repaint shows as a stepped top-to-bottom wave.
Format-aware copy. Provide copy_rect_format(src, stride, fmt, x, y, w, h) and the engine hands
you images it has proved fully opaque, in their baked format (ER_IMAGE_ARGB8888 or
ER_IMAGE_RGB565), as one call for the whole rectangle: replace destination pixels outright, no
per-pixel alpha, no read-modify-write. On DMA2D this is a single M2M/PFC transfer; on an RGB565
framebuffer a 565 source is a row memcpy. Leave it NULL and the engine expands non-ARGB sources on
the CPU and goes through copy_rect.
Multiple display buffers. A page-flipping panel renders into a buffer that was last shown one
or two presents ago, so plain incremental damage would leave the rest of it stale. Call
er_set_display_buffer_count(2) (or 3) once at init and er_display_present() after each flip; the
engine keeps a damage debt per buffer and repaints enough each commit that whichever buffer is the
target ends up fully correct, with no canonical third buffer and no host-side convergence copy. Base
the flip decision on a box the backend accumulates across every paint since the last flip (the
dma2d backend's take_dirty), not on er_get_dirty_rect(), which covers only the most recent
commit: in Flow A the reconciler commits inside the pump, so by the host's own commit that damage
is already consumed.
Lessons from the four that run on hardware
- Premultiplied sources and hardware blenders. DMA2D's blender takes straight-alpha foregrounds;
the engine emits premultiplied ones. Blending in hardware would multiply the colour channels by
alpha a second time and darken every anti-aliased edge. The
dma2dbackend therefore sends opaque work to the peripheral and does translucent work on the CPU, deciding with one read-only alpha scan of the source. Ops below a size threshold skip the peripheral entirely; register setup costs more than a tiny blit. - Cache coherency on a Cortex-M7. When the framebuffer is cacheable, DMA bypasses the D-cache.
Clean before the peripheral reads CPU-written pixels, clean-and-invalidate before it overwrites
rows (
SCB_CleanDCache_by_Addrand friends). Leave the hooks NULL for a non-cacheable or write-through region. - Panel pixel order. Some SPI panels want the two colour bytes swapped and red/blue in BGR
order; the Cheap Yellow Display's ST7789 is one. The telltale is grey rendering pastel green and
blue rendering pink while red and white look fine.
esp32-spi-lcdpre-compensates behind a flag. - Only one DMA outstanding. With ping-pong band buffers, wait on a bank before reusing it and a plain binary done-semaphore stays correct.
- The caller owns the panel. The backends take an initialised panel handle and register their
transfer-done callback; bring-up (SPI bus, reset, inversion, MADCTL orientation, backlight) is the
board's job, in
board.c. - Log what you can see. Both ESP32 examples log free RAM at boot and after start-up, and the RP2040 example has a colour-bar test pattern behind a build flag: clean bands prove the driver end to end, so anything wrong after that is upstream.
Build integration
A backend is its own small CMake library that links the engine:
add_subdirectory(path/to/embedded-react/engine ${CMAKE_BINARY_DIR}/embedded-react)
add_subdirectory(path/to/embedded-react/backends/dma2d ${CMAKE_BINARY_DIR}/backends/dma2d)
target_link_libraries(my_firmware PRIVATE er-backend-dma2d)
On ESP-IDF the examples wrap each backend as a component whose CMakeLists.txt compiles the
fetched sources. Keep the backend free of the engine's internals: native_renderer.h and
er_scene.h are the only headers it should need.
Testing a backend
The engine's CTest suites paint through their own in-test callbacks, so they verify the engine, not
yours. For a backend, the useful checks are: a solid fill_rect of the whole screen in each primary
(byte order and inversion), a copy_rect of a baked image (stride and format), text and a
translucent blend_rect over a color (premultiplied maths), and a dirty-rect flush of a
one-pixel-wide change at each screen edge (the inclusive/exclusive convention). Then compile the engine and your host with
the C define ER_PERF_OVERLAY=1 (not a CMake option of the engine's; it turns on ER_PERF_STATS
too), mark the present phase, and the perf overlay shows what present costs per frame.