uploaded 1 week ago
embedded-react engine — a tiny C99 UI engine for MCUs you drive with JSX (Flow B AOT, or Flow A via the QuickJS bridge). Backend-agnostic: you provide the framebuffer flush.

Readme

# engine

The pure C99 runtime that does everything visible on screen: scene graph, layout,
rendering, text, animation, fonts. Runtime-agnostic by design — `er_scene.h` is the
public ABI any frontend (React-on-QuickJS, AOT-compiled React, future Lua / JSON / visual
editor) calls into.

Contributors working on the engine itself: this is your README. End users writing React
apps don't need to know about the layout here.

## Layout

| Folder | What lives here |
|---|---|
| `include/` | Public headers — `er_scene.h` (scene API) and `native_renderer.h` (backend interface). The only headers downstream code is allowed to include directly. |
| `core/` | Backend glue, frame tick, time advance. Everything that connects the engine to the hardware-blitting backend. |
| `scene/` | Node pool, parent/child/sibling tree, props, dirty tracking, render pass orchestration, hit-testing. |
| `layout/` | Yoga-compatible 7-pass flexbox. |
| `rendering/` | Painters for the renderable primitives — rounded rectangles, shadows, transforms, image scaling, canvas. |
| `text/` | UTF-8 decoder, glyph rasterizer, multi-line layout. |
| `animation/` | `Animated.Value` engine, timing/spring/decay curves, native driver. |
| `resources/` | Font registry, font blob loader, font bitmaps, built-in font data. Future home for image assets. |
| `platform/` | Platform-abstraction hooks the engine needs (time source, optional memory abstractions). Empty today. |
| `tests/` | Host-side CTest suites — layout, text, rendering, animation, input, scroll, resources. |

## Building

The engine is a CMake STATIC library named `embedded-react`. Configure it from this
folder (it pulls in nothing else):

```
cmake -S engine -B build -DBUILD_TESTING=ON
cmake --build build
ctest --test-dir build --output-on-failure
```

A new board needs only a C99 compiler, `<math.h>`, and a writable framebuffer — no RTOS,
no MCU SDK. The engine never includes a platform header; it paints through one backend
struct of function pointers (see [`backends/README.md`](../backends/README.md)).

## Internals

### Layout — Yoga 7-pass flexbox

`layout/layout_engine.c` implements a Yoga-compatible flexbox solve per container:
collect in-flow children with hypothetical sizes → wrap into lines → resolve
`flexGrow`/`flexShrink` against free space (iterative, like Yoga's resolve-flexible-lengths
loop, so min/max-frozen children redistribute) → compute per-line cross size → place along
main axis (`justifyContent`) and cross axis (`alignSelf`/`alignItems`) → write back and
recurse → lay out absolutely-positioned children against the padding box. Scratch arrays
are static at module scope, sized to `ERUI_MAX_NODES`.

### Pixel format — premultiplied ARGB8888

All bitmap data — buffers passed to `copy_rect` / `blend_rect`, internal offscreen
buffers, and images from `er_image_load` — is **premultiplied ARGB8888** (memory order
A, R, G, B; word `0xAARRGGBB` with R, G, B already multiplied by A/255). The one
exception is `fill_rect`'s `argb`, which is **straight-alpha** `0xAARRGGBB` (CSS-friendly)
— the engine premultiplies it once at call time. Backends convert to their display's
native format (RGB565, BGR888, …) inside the callback. Blend, per channel:

```
out.C = src.C * a + dst.C * (1 - sA * a)      // src.C already premultiplied
```

### Scratch buffers (no heap during rendering)

A subtree with `opacity < 1`, a transform, or a shadow blur is composited into an
offscreen premultiplied-ARGB8888 buffer first. Everything is statically allocated — no
allocation happens in a render pass. Three pools exist, each sized by its own constraint:

- **Opacity strips** — `ERUI_MAX_OPACITY_DEPTH` strips of
  `ERUI_SCRATCH_W × ERUI_SCRATCH_BAND_H × 4` bytes. A translucent group is composited
  through one strip; a group **larger than one strip is composited in multiple band
  passes** (the subtree is re-walked once per strip-sized tile, with off-tile subtrees
  pruned), so any node up to `ERUI_SCRATCH_W` wide fades correctly regardless of height.
  Small `ERUI_SCRATCH_BAND_H` = big RAM saving, more passes for tall fades.
- **Transform source** — one `ERUI_XFORM_W × ERUI_XFORM_H × 4` buffer (defaults to the
  `ERUI_SCRATCH_W/H` dims) holding the untransformed subtree while it is resampled. This
  is the one buffer that cannot be banded (rotation reads across the whole source), so
  `ERUI_XFORM_W/H` cap the largest rotatable/scalable node — decouple them when strips
  are screen-wide but transforms only ever hit small widgets. The transformed **output**
  is streamed out per row segment, so the destination AABB (which grows under
  rotation/scale-up) is unlimited.
- **Shadow plane** — `ERUI_SCRATCH_W × ERUI_SCRATCH_H` bytes of A8 coverage
  (`ERUI_SHADOWS` only).

When no opacity strip is available (nesting deeper than `ERUI_MAX_OPACITY_DEPTH`), the
group's opacity is multiplied into each primitive draw instead of being dropped — exact
wherever siblings don't overlap.

- **Fade cache** (optional) — one `ERUI_FADE_CACHE_W × ERUI_FADE_CACHE_H × 4` buffer
  holding the composited subtree of the most recent translucent group. During a pure
  opacity animation the subtree's content is identical every frame, so after one capture
  each frame is a single blend at the new alpha instead of a full re-render — roughly
  double the frame rate on fades of static content. Any content mutation anywhere in the
  scene invalidates it (coarse but O(1) and always safe). Off by default (`0`); size it to
  the largest node you animate opacity on (device boards typically place it in external
  RAM).

### Disjoint dirty rects (damage tracking)

Each commit's damage is tracked as up to `ER_DAMAGE_RECTS_MAX` (default 4, an `#ifndef` override
in `er_scene.h` like `ER_DISPLAY_BUFFERS_MAX`) **pairwise-disjoint rects** rather than one
bounding box, so a widget updating top-left and another bottom-right
repaint two small areas — not the span between them. Overlapping or touching damage merges on
insert (disjointness is what makes multiple clipped render passes safe: no pixel composites
twice); when the budget is exceeded, the least-wasteful pair merges, degrading gracefully toward
the old single-box behavior without ever dropping coverage. The multi-buffer page-flip debt
(`er_set_display_buffer_count`) replays disjoint history the same way. Hosts read the rects with
`er_get_dirty_rects()` — one transfer window per region on capable display drivers — while
`er_get_dirty_rect()` still returns the covering box.

### Banded rendering (low-RAM panels)

A backend can opt into banded RGB565 (`ER_LCD_BANDED`): it sets a band height and
`band_begin`/`band_flush` callbacks, and the engine renders dirty rows as full-width
strips through a small RGB565 band buffer (~19 KB) while panel GRAM retains the rest —
16-bit color at less RAM than a full framebuffer. Band tiling is applied at backend-emit,
not as a clip, so transform/opacity scratch sources don't truncate at the seam.

### Frame instrumentation

`er_perf.h` splits each frame into the four phases that can independently blow up, and
samples the fixed-size pools alongside them — so an occasional 2-second frame can be
attributed instead of guessed at. An FPS counter can't do this: the average stays fine
and the spike is gone before anyone looks, which is why the **worst frame seen so far is
retained with its whole split** (`er_perf_get_worst`) until you `er_perf_reset()`.

| Phase | Marked by | Covers |
|---|---|---|
| `ER_PERF_PHASE_JS` | host | JS pump + React's commit into the scene graph |
| `ER_PERF_PHASE_LAYOUT` | engine | The flex solve + text measurement inside `er_commit()` |
| `ER_PERF_PHASE_RASTER` | engine | The rest of `er_commit()` — damage pre-pass, composite, blits |
| `ER_PERF_PHASE_PRESENT` | host | Backend flush / panel transfer |

Whatever the four don't cover (input polling, the animation tick, host work) lands in
`other_us`, so the split always reconstructs `frame_us`. Counters sampled per frame:
the repainted region and its area (what raster *and* present both scale with), vector
storage slots in use out of `ERUI_MAX_VECTOR_NODES`, and image registry slots out of
`ERUI_IMAGE_REGISTRY_MAX` — a screen silently missing an asset reads as a full pool here. The vector
field gains a `!FULL` marker once a node has actually been turned away (see the vector section).

The engine has no clock of its own, so timing is opt-in: hand it one with
`er_perf_set_clock()` (without one the phase times read 0 and the counters still work).
The host owns the frame boundary and its own two phases:

```c
er_perf_set_clock(now_us);                 /* once, at startup */

er_perf_frame_begin();
er_perf_phase_begin(ER_PERF_PHASE_JS);
er_runtime_pump();
er_perf_phase_end(ER_PERF_PHASE_JS);
er_commit();                               /* times LAYOUT + RASTER itself */
er_perf_phase_begin(ER_PERF_PHASE_PRESENT);
er_display_present();
er_perf_phase_end(ER_PERF_PHASE_PRESENT);
er_perf_frame_end();
```

`er_perf_overlay_lines()` formats the whole thing into short lines ready to pass to
`er_perf_overlay_draw()`, so a host gets the panel without writing any `snprintf`:

```
FRM 18.4 PK 2013.1     last frame / worst frame, ms
J6.2 L0.3 R9.1 P2.4    last frame: JS, layout, raster, present
PK J1900 L12 R80 P9    the WORST frame's split — what to blame the spike on
PKDRT 800x40 32k       the WORST frame's repainted region (pairs with PK above)
VEC 3/8 IMG 5/32       slots in use, out of the compiled-in pool size
```

The same `ERPerfFrame.dirty_*` data supports three region policies, and the overlay only has room
for one — pick per host:

1. **Peak** — what `PKDRT` shows: the region that accompanied the worst frame, so it pairs with the
   `PK` split above it and answers "did anything ever go full-screen?". Retained until
   `er_perf_reset()`, like the split.
2. **Last frame** — `er_perf_get_last()` directly; nearly always 0 (most frames repaint nothing),
   so it is rarely useful on screen by itself.
3. **Last non-empty frame** — "what did that interaction just cost?", the everyday debugging
   question. Latch it host-side, a few lines per frame:

   ```c
   static ERPerfFrame s_last_paint; /* the most recent frame that actually repainted */
   ERPerfFrame f;
   if (er_perf_get_last(&f) && f.dirty_px > 0U)
       s_last_paint = f; /* format s_last_paint.dirty_* into your own overlay line */
   ```

Gated by `ER_PERF_STATS` (see the flag table below). If `ER_PERF_STATS` is not defined by the build,
it defaults to `ER_PERF_OVERLAY`, so turning the panel on turns the instrumentation on with it. Set it
explicitly to collect the numbers without drawing anything — to log them, or ship them over a debug
link. At 0 every entry point becomes a no-op and the compositor drops even the calls.

## Compile-time feature flags

Set these in CMake before `FetchContent_MakeAvailable` (or at the ESP-IDF component
level). The defaults are desktop-sized — tune them down for a board.

| Flag | Default | Effect |
|---|---|---|
| `ERUI_SHADOWS` | 0 | Box-shadow rasteriser (two-pass box blur) |
| `ERUI_BORDER_AA` | 1 | Anti-aliased border-radius edges |
| `ERUI_3D_TRANSFORMS` | 0 | `rotateX` / `rotateY` / `perspective` |
| `ERUI_BILINEAR_SCALE` | 0 | Bilinear image scaling (vs. nearest-neighbour) |
| `ERUI_GRADIENT` | 1 | Linear gradient rasteriser |
| `ERUI_GRADIENT_RADIAL` | 1 | Radial gradients (requires `ERUI_GRADIENT`) |
| `ERUI_TRANSFORMS` | FULL | `TRANSLATE_ONLY` strips rasterisation paths |
| `ERUI_FONT_SIZES` | 7 | Number of pre-rasterised font sizes |
| `ERUI_MAX_NODES` | 512 | Scene-graph node pool size |
| `ERUI_MAX_OPACITY_DEPTH` | 4 | Max nested offscreen-composite layers (opacity strips) |
| `ERUI_SCRATCH_W` | 240 | Strip width / max transformable node width |
| `ERUI_SCRATCH_H` | 240 | Transform-source height (max transformable node height) |
| `ERUI_SCRATCH_BAND_H` | `ERUI_SCRATCH_H` | Opacity strip height; shrink to trade band passes for RAM |
| `ERUI_XFORM_W` | `ERUI_SCRATCH_W` | Transform-source width (max rotatable/scalable node width) |
| `ERUI_XFORM_H` | `ERUI_SCRATCH_H` | Transform-source height (max rotatable/scalable node height) |
| `ERUI_FADE_CACHE_W` | 0 | Fade-cache width (composited-subtree reuse across fade frames); 0 disables |
| `ERUI_FADE_CACHE_H` | 0 | Fade-cache height; 0 disables |
| `ERUI_FONT_POOL_BYTES` | 0 | Static pool for runtime-loaded fonts; 0 disables `er_font_load` |
| `ERUI_IMAGE_REGISTRY_MAX` | 128 | Concurrently registered images. ~80 B/slot (~10 KB at the default), so shrink it on a RAM-tight board — but see below before shrinking it *below* your asset count |
| `ERUI_PERF_STATS` | 1 | Per-frame timing split + resource counters (`ERUI_PERF_STATS=OFF` compiles them out). Defaults to `ER_PERF_OVERLAY` on the ESP-IDF component path, which never sees this CMake option — see [Frame instrumentation](#frame-instrumentation) |
| `ERUI_RENDER_WORKERS` | 1 | Max render workers for multi-core rendering. Above 1, per-worker context/scratch arrays are sized for N workers and a host may install threads via `embedded_renderer_set_workers` (see `native_renderer.h`); the repaint region is then rendered as horizontal slices, one per core. The opacity strip pool is split between workers (`ERUI_MAX_OPACITY_DEPTH / workers` slots each — raise the depth alongside), and each extra worker costs a full transform-source buffer. Scenes with vector or shadow nodes automatically render single-core. 1 (the default) is the plain single-core engine |

### Image registry

`er_image_load()` puts each distinct name in one of `ERUI_IMAGE_REGISTRY_MAX` slots. Past
that, registration is **refused**, and a refused image simply never draws — no crash, no
layout change, just a hole where the art should be, on whichever assets happened to load
last. An icon-heavy app runs well past a hundred images, so the old fixed 32 was well
under a real asset set. The default is now 128; a diagnostics build warns once on the
first refusal (`ERUI_IMAGE_DIAGNOSTICS`, on unless `NDEBUG`), and the perf overlay's
`IMG n/n` counter reads full.

Each slot is ~80 B on a 32-bit target — the 64-byte name field is most of it — so the
default costs ~10 KB of `.bss`. Nothing scales with it per frame (lookups skip free slots
on a bool test), so the only reason to shrink it is RAM: the RP2040 and ESP32-2432S028R
examples pin it to 8 and 16. Set it **at or above the number of images your app bakes**;
the count is whatever `assets.config.js` emits.

### Vector pools (SVG / `<Svg>` rasteriser)

The vector rasteriser (`rendering/vector.c`) pre-allocates static buffers sized by the
macros below. Unlike the pixel scratch buffers above, these stay in **internal RAM** on
a PSRAM board (the scanline loops touch them per pixel), so they're sized to fit there —
raise them for bigger / more complex SVGs and watch the internal-RAM budget. They split
into transient rasterize scratch (reused per shape) and persistent per-node storage.

| Flag | Default | Bounds | Static cost |
|---|---|---|---|
| `ERUI_VECTOR_MAX_PTS` | 2048 | flattened vertices in one shape | `2 × PTS × 4` B |
| `ERUI_VECTOR_MAX_SUBPATHS` | 256 | contours / holes in one shape | `SUBPATHS × 12` B |
| `ERUI_VECTOR_MAX_EDGES` | 2048 | edges in one rasterise pass | `EDGES × 32` B (edge + crossing + active lists) |
| `ERUI_VECTOR_MAX_ROW` | 1024 | max vector-node **width** in px | `ROW × 4` B |
| `ERUI_MAX_VECTOR_NODES` | 8 | concurrent `<Svg>` nodes with geometry | `NODES × (TAPE_MAX×4 + PAINTS_MAX×20)` B |
| `ERUI_VECTOR_TAPE_MAX` | 1024 | op-tape floats stored per node | (in the per-node cost) |
| `ERUI_VECTOR_PAINTS_MAX` | 16 | paint entries (shapes) per node | (in the per-node cost) |
| `ERUI_VECTOR_GRAD_LUT` | 256 | gradient colour-LUT entries (`ERUI_GRADIENT` only) | `LUT × 4` B internal |

`ERUI_VECTOR_GRAD_LUT` sizes the per-gradient color ramp the rasteriser samples per pixel (built once per
gradient shape) instead of interpolating the stops each pixel — the bulk of an interactive gradient drag's
cost. 256 matches 8-bit color resolution; a RAM-tight board can lower it (e.g., 64–128) for coarser steps,
and there's little benefit above 256.

At the defaults that's ~122 KB. The fastest-growing terms are `MAX_EDGES` (~32 B each, across
three lists) and the **per-node op-tape**: persistent storage is `MAX_VECTOR_NODES ×
VECTOR_TAPE_MAX × 4` bytes, so "many nodes" and "large tape" multiply.

**Placement (PSRAM targets).** The vector code is two objects: `vector.c` (the **hot** per-pixel
rasterize scratch — edge/coverage/crossing lists) and `vector_store.c` (the **cold** per-node
op-tape/paint pool). The storage pool is read once per node when it re-rasterizes, not in the
scanline inner loop, so a target with far memory can place `vector_store.o`'s `.bss` there — e.g.,
ESP32 PSRAM via a linker fragment — while the hot scratch stays in fast internal RAM. With the
storage in PSRAM, **`ERUI_MAX_VECTOR_NODES` (and `ERUI_VECTOR_TAPE_MAX`) can be raised well past
the internal-RAM-bound default**. See `examples/esp32/esp32-s3` —
`components/engine/linker_psram.lf` maps `vector_store` to `extram_bss` and the component sets
`ERUI_MAX_VECTOR_NODES=32`.

**Overflow is silent truncation, not a crash** — an over-complex shape is clipped or dropped.
A debug build (or `-DERUI_VECTOR_DIAGNOSTICS=1`) prints a one-line `stderr` warning naming the
macro to raise on the first overflow of each pool; it is compiled out under `NDEBUG` so a
release MCU pulls in no `<stdio.h>`.

**`ERUI_MAX_VECTOR_NODES` is the exception, and warns in release builds too.** The other caps
truncate one shape, so the screen shows something recognisably wrong, and the culprit is the shape
you were editing. Running out of *storage slots* instead denies a whole node its geometry — it
draws nothing — and since slots are handed out in mount order, *which* nodes go missing shifts as
screens mount and unmount. On a panel that reads as random glitching with no obvious cause. So the
first refusal prints one `stderr` line even under `NDEBUG`, and raises a sticky flag the perf
overlay shows as `!FULL` on its `VEC` field (`VEC 8/8!FULL`) — the counter alone can't carry this,
since a screen that exactly fills the pool renders perfectly well. Hosts can read the same flag
from `ERPerfFrame::vector_slots_overflow`. The flag clears on `er_reset()`; the warning is
one-shot per process. Set `-DERUI_VECTOR_STORE_WARN=0` on a target that must not link `<stdio.h>`
(the flag and the overlay marker keep working).

Override from CMake (`-DERUI_VECTOR_MAX_PTS=4096`), or in an ESP-IDF build from your project's
`CMakeLists.txt`:

```
idf_build_set_property(COMPILE_DEFINITIONS "ERUI_VECTOR_MAX_PTS=4096" APPEND)
```

## Rules

- **No platform headers.** Pure C99. No `stm32h7xx_hal.h`, no `esp_lcd.h`, no
  `<windows.h>`. Hardware specifics live in `backends/`.
- **No React assumptions.** The engine does not import React. Bindings to React (or
  Lua, JSON, visual editors, anything else) live in `bridges/`.
- **No heap during rendering.** All scratch buffers are static, sized at compile time.
- **Section banners + JSDoc-style function docs** per [`CONTRIBUTING.md`](../CONTRIBUTING.md).

Links

To add this component to your project, run:

idf.py add-dependency "themastercoder007/engine^0.11.0"

download archive

Stats

  • Archive size
    Archive size ~ 4.04 MB
  • Downloaded in total
    Downloaded in total 2 times
  • Weekly Downloads Weekly Downloads (All Versions)
  • Downloaded this version
    This version: 0 times

Badge

themastercoder007/engine version: 0.11.0
|