|
| 1 | +# App Performance |
| 2 | + |
| 3 | +How to make MicroPythonOS apps hit smooth framerates on resource-constrained hardware. The rules below come from a real optimization pass on QuasiBird (Fri3d badge, ST7789 over SPI): **~17 → ~24.5 fps**, measured in-game. |
| 4 | + |
| 5 | +## 1. Measure first, guess never |
| 6 | + |
| 7 | +- Read the `sysmon:` line over serial with the FPS overlay **off**: `sysmon: 25 FPS (refr_cnt: 8 | ...)`, `refr ... (render ... | flush ...)`. The `render Xms | flush Yms` split tells you whether you're CPU-bound or bus-bound. Optimize the bigger one. |
| 8 | +- `FPS` counts refresh *cycles* per ~300 ms window — including cycles where nothing was dirty. It is not "frames your game drew". |
| 9 | +- FPS is hard-capped at ~30 by `LV_DEF_REFR_PERIOD=33ms`. A 16 ms game timer can't beat it; invalidates just coalesce until the next refresh tick. |
| 10 | +- `change_task_handler(period_ms)` sets the outer pump that evaluates LVGL timers, not the refresh rate. Smaller values reduce latency but don't raise the ceiling. |
| 11 | +- In-game FPS overlays perturb what they measure: every `set_text()` is a `malloc` + invalidate + re-blend, plus serial logging inside the measurement window. Trust the serial `sysmon` line over the on-screen number. |
| 12 | + |
| 13 | +## 2. Know the display path |
| 14 | + |
| 15 | +- ESP32-S3 SPI divides 80 MHz by an integer, so 60 MHz silently rounds down to 40 — 80 MHz is the only step up from 40. It only speeds up the *flush* portion of the frame; if your frame is 50 ms render + 8 ms flush, doubling the bus saves ~4 ms, not half. |
| 16 | +- Display `freq` is per-device on a shared SPI bus. An SD card (20 MHz) or radio (16 MHz) on the same bus keeps its own clock; each transfer just waits its turn. |
| 17 | +- Flushing is DMA-async with double buffering — LVGL renders the next chunk while the previous one transfers. The 33 ms refresh cap usually binds long before the bus does. |
| 18 | +- `rgb565_byte_swap=True` runs an in-place CPU swap loop over every flushed chunk before DMA. Required for correct colors on most panels — just know it's part of `flush` time, not free. |
| 19 | + |
| 20 | +## 3. No floats in hot loops |
| 21 | + |
| 22 | +MicroPython floats are heap objects. Float physics at 60 Hz easily means ~20 short-lived allocations per frame — constant GC pressure that shows up as *jitter* (drifting fps), not just a lower average. |
| 23 | + |
| 24 | +Use integer fixed-point instead: positions in milli-pixels, velocities in milli-pixels/s, `delta_ms` kept as `int`. LVGL takes ints anyway, so convert with `//` at the display call. |
| 25 | + |
| 26 | +Two traps: |
| 27 | + |
| 28 | +- **Don't truncate per frame.** `SPEED * dt // 1000` in whole pixels freezes slow movers (30 px/s × 16 ms = 0.48 px → 0). Accumulate in milli-pixels so sub-pixel motion is preserved. |
| 29 | +- **Bound your counters.** An ever-growing scroll accumulator eventually outgrows MicroPython small ints and becomes a heap-allocated bigint on every op. Wrap with modulo (e.g. one screen width). |
| 30 | + |
| 31 | +Verify with a float-vs-fixed simulation before flashing: identical pixel outputs (within ±1 px at wrap edges) and identical game-logic timing. |
| 32 | + |
| 33 | +## 4. Redraw hygiene |
| 34 | + |
| 35 | +Every invalidate costs render + flush. LVGL redraws only dirty areas, so shrink what you dirty: |
| 36 | + |
| 37 | +- `label.set_text()` **always** mallocs and invalidates, even for an identical string. Only call it when the text actually changed. Same for any label you refresh "just in case" every frame. |
| 38 | +- `image.set_offset_x()` invalidates the whole object unconditionally. Guard it behind a "did the pixel change?" check. |
| 39 | +- `set_x()` / `set_y()` / `set_pos()` are no-ops when the integer pixel didn't change — call them freely. |
| 40 | +- A permanent `set_rotation()` on an image means *every frame* any overlapping dirty rect takes the slow per-pixel transform path, plus a larger invalidated area. Ship a pre-rotated asset instead (verify pixel-identity once with PIL). |
| 41 | +- `TILE` mode issues one draw call **per tile**. A 240 px strip from a 20 px tile = 12 draw calls per frame. Pre-compose the strip offline (tiny PNG) and scroll two leapfrogging images via `set_x` — 1 draw call each, identical pixels. Keep `TILE` as fallback if you target multiple screen widths. |
| 42 | +- Translucent (`bg_opa` < 255) + rounded + bordered panels force read-modify-write blending, corner masking, and repainting of everything beneath them. Opaque rectangles take the fast fill path — a visual tradeoff, budget it deliberately. |
| 43 | +- Images with transparency force the background underneath to repaint (overdraw). Opaque sprites compose cheaper. |
| 44 | +- Slow movers (parallax clouds at ~0.5 px/frame) can push to LVGL at half rate while physics keeps integrating every frame — visually identical, half the invalidates. |
| 45 | + |
| 46 | +## 5. Verify like-for-like |
| 47 | + |
| 48 | +- Simulate old-vs-new logic in CPython where possible (trajectories, scroll phases, wrap behavior) before spending flash cycles. |
| 49 | +- `ruff` and `mpy-cross` must run on the **real** file path: apps symlinked from external repos are not covered by the main repo's `make lint` / `make syntax-tests`. |
| 50 | +- Bump the app version with every user-visible change so testers can tell builds apart. |
0 commit comments