themastercoder007/engine

0.12.0

Latest
uploaded 1 day 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.

### Hidden subtrees (`display: none`)

A node with `ERProps.display = ER_DISPLAY_NONE` and everything under it is pruned from the layout
solver, the render walk, and hit-testing, while its nodes stay allocated with their props intact —
so an app can build a page once and flip it on and off instead of destroying and recreating its
nodes. Hiding collapses the node's computed rect to zero, so it takes no space and `onLayout` reports 
an empty box.

The bookkeeping is what makes it cheap and correct. `ERNode::subtree_hidden` — maintained by the
tree and prop mutators, never by a per-frame walk — lets the flat per-commit passes, which have no
top-down parent context, skip a hidden node in O(1). On the transition itself, the engine banks each
node's last painted rect as vacated damage (the same channel node removal uses) and drops the stale
trail, because layout stops maintaining a hidden node's descendants: they would otherwise read as
unchanged-and-in-place, and any pixels they painted outside their parent's box would stay on screen.
Showing marks the subtree dirty so it repaints. Hidden nodes are also swept clear of dirty flags at
the end of each commit — they can never reach the paint that would clear them, and a stuck flag is a
rect re-damaged on every commit forever, which matters because React keeps rendering into a cached
page while it is off screen. The result is that a hidden page costs nothing per frame and reports no
dirty rect.

### 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.12.0"

download archive

Stats

  • Archive size
    Archive size ~ 4.18 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.12.0
|