ESP32 Performance Guide - PixelRoot32 β
π₯ Hot Path Rules β
Hot paths = update(), draw(), collision detection loops, audio callbacks.
Prohibited in hot paths:
| Feature | Why | Alternative |
|---|---|---|
std::optional | Extra branching/code size | Raw pointers or sentinel values |
| Virtual calls | Vtable indirection | Templates or function pointers |
new / malloc | Heap fragmentation | Pre-allocated pools |
std::vector::push_back | Potential reallocation | Fixed-size arrays with flags |
Logging / log() | String formatting overhead | Debug-only counters or periodic logging |
std::rand() | Slow, uses division | math::randomScalar() (Xorshift) |
β‘ Performance (ESP32 Focus) β
Inlining β
- Define trivial accessors (e.g.,
getHitBox,getX) in the header (.h) to allow compiler inlining. - Keep heavy implementation logic in
.cpp.
Fast Randomness β
std::rand() is slow and uses division. Use math::randomScalar() or math::randomRange() (which use optimized Xorshift algorithms compatible with Fixed16) for visual effects.
Collision Detection β
- Use simple AABB (Axis-Aligned Bounding Box) checks first. Use Collision Layers (
GameLayers.h) to avoid checking unnecessary pairs. - For very fast projectiles (bullets, lasers), prefer lightweight sweep tests:
- Represent the projectile as a small
physics::Circleand callphysics::sweepCircleVsRect(startCircle, endCircle, targetRect, tHit)against potential targets. - Use sweep tests only for the few entities that need them; keep everything else on basic AABB to avoid unnecessary CPU cost.
- Represent the projectile as a small
Dirty Region Selective Clear β
Reduces framebuffer clearing overhead by tracking which 8Γ8 pixel cells were actually drawn to in the previous frame, utilizing a double dirty grid pipeline.
- Benefit: Replaces full-screen
memsetwith targeted selective row-run 8bpp clearing. It skips untouched rows entirely and uses__builtin_popcountoptimizations to quickly identify blocks of dirty cells. - RAM cost: 64β226 bytes (depends on resolution and cell size).
- When it pays off: Games with mostly static backgrounds and small moving sprites.
- Profiling flag:
PIXELROOT32_ENABLE_DIRTY_REGION_PROFILING=1 - Metric:
dirty_ratioβ fraction of cells marked dirty. Good values are <0.5; >0.8 suggests full clear is cheaper.
; Enable in platformio.ini
build_flags =
-DPIXELROOT32_ENABLE_DIRTY_REGIONS=1
-DPIXELROOT32_ENABLE_DIRTY_REGION_PROFILING=1Tip: If
dirty_ratio> 0.8, disable dirty regions and use full clearβit avoids the tracking overhead.
Static Layer Snapshot (PIXELROOT32_ENABLE_STATIC_LAYER_SNAPSHOT) β
Stops a static layer being redrawn at all, where dirty regions only make the clear cheaper.
- What it is for: static layers that game code draws.
StaticTilemapLayerCachealready covers layers the engine can redraw itself, but it owns theTileMap4bppand repaints it β so it needs one to exist. An isometric or oblique floor has none by default, becausedrawTileMapassumes axis-aligned cells unless given a projection (PIXELROOT32_ENABLE_TILEMAP_PROJECTION, default0); such a floor is drawn sprite-per-cell in the default build. - Benefit: the layer is drawn once. Later frames restore it β and with dirty regions on, only over the cells the previous frame's movers touched. A 7Γ7 isometric room drops from 49
drawSpritecalls (~35,000 pixels of 4bpp decode) to a few hundred bytes copied. - RAM cost: one logical framebuffer of heap per allocating scene β ~57 KB at 240Γ240. This is the whole trade, and it is why the flag defaults to
0. - When it pays off: when the static layer is expensive to draw and rarely changes, and the DRAM is there to spend. Allocate in
Scene::init(), never in the loop. - When it does not: a layer that changes every frame. Each
invalidate()costs a full redraw plus a capture.
build_flags =
-DPIXELROOT32_ENABLE_STATIC_LAYER_SNAPSHOT=1
-DPIXELROOT32_ENABLE_DIRTY_REGIONS=1 ; per-cell restore instead of a full copyReality check: on a 240Γ240 panel at
SPI_FREQUENCY=40000000, pushing one frame costs ~23 ms β the frame budget. CPU saved here does not become frame rate on its own; it becomes headroom to raise the SPI clock, enable 12-bit colour, or spend on game logic. Measure the bus before optimising the draw β see the Display Bandwidth (TFT_eSPI) section below, and 12-bit Color on the Wire for the cheapest way to cut that 23 ms.
See graphics/iso_dungeon in PixelRoot32-Demo-Projects for a working consumer.
Single-Core Resource Contention (ESP32-C3) β
Single-core architectures (like the ESP32-C3) run the game logic, display transfers, and audio synthesis on a single core.
- Priority Inversion: Heavy display transfers (like full-screen U8G2 refreshes) can block the audio task, causing buffer underruns and audio glitches. The engine dynamically detects single-core platforms and elevates the audio task priority (e.g., to
18) to protect audio streams. - Context Thrashing: An audio priority that is too high (e.g.,
24) will preempt the display transfer constantly to synthesize audio, fragmenting the hardware SPI transaction and ballooning draw times (up to 4x). The engine mitigates this by balancing priority, reducing audio buffer block sizes to128samples, and usingtaskYIELD()for cooperative multitasking. - Float Operations: Soft-float emulation on the ESP32-C3 is extremely slow. The engine provides fixed-point Q15 implementations for performance-critical inner loops (like
tickEnvelopeQ15, LFO generation for vibrato/tremolo, HPF filtering, and audio mixer LUTs). Avoid introducing new float-based calculations inside per-sample audio loops or per-pixel drawing loops.
πΊ Display Bandwidth (TFT_eSPI) β
The SPI panels in use do not run reliably above 40 MHz, so a full-frame push is bus-bound: no CPU optimization can cross the transfer time. This fixes a hard FPS ceiling per panel and per wire format.
| Panel | Format | Bytes/frame | Transfer @40 MHz | Hard ceiling |
|---|---|---|---|---|
| 240Γ240 | RGB565 | 115,200 | 23.04 ms | 43.4 FPS |
| 240Γ240 | RGB444 | 86,400 | 17.28 ms | 57.9 FPS |
| 240Γ320 | RGB565 | 153,600 | 30.72 ms | 32.6 FPS |
| 240Γ320 | RGB444 | 115,200 | 23.04 ms | 43.4 FPS |
Tip: Lowering
LOGICAL_WIDTH/LOGICAL_HEIGHTbuys CPU time but not bus time β the scaler upscales to physical during scan-out, so the same number of bytes still goes out. OnlyPHYSICAL_DISPLAY_WIDTH/HEIGHT(letterboxing) or a narrower wire format shrink the transfer.
Deferred DMA Wait β
sendBufferScaled() leaves the frame's last DMA block in flight and flushes it at the top of the next call, so the tail of the SPI transfer overlaps the next frame's update() and draw() work. The frame cost becomes max(CPU, transfer) instead of CPU + transfer.
- Always on for the TFT_eSPI driver β there is no flag to enable or disable it.
- Benefit: closes most of the gap between measured FPS and the bus ceiling above; it does not raise the ceiling itself.
- Contract: anything else that touches the SPI bus, the panel, or the line buffers must synchronize first β see Shared SPI Bus Contract.
Shared SPI Bus Contract β
Any code that touches the SPI bus, the TFT, or frees/reallocates the DMA line buffers MUST call TFT_eSPI_Drawer::waitForPendingDMA() first. Skipping it either corrupts the open SPI transaction or reads a buffer that DMA is still streaming. The call is a no-op when nothing is pending.
Already wired inside the engine:
| Call site | Why |
|---|---|
TFT_eSPI_TouchBridge reads | Touch controller shares the display SPI bus |
freeScalingBuffers() | Line buffers are freed while DMA may still read them |
~TFT_eSPI_Drawer() | Same, at teardown |
init() / setRotation() | Panel commands must not interleave with a pixel stream |
Engine on a skipped frame, via DrawSurface::flushPendingTransfers() | A scene reporting shouldRedrawFramebuffer() == false skips present(), so nothing else would close the open transaction |
Why not just flush in
processEvents()? It runs beforedraw(), so it would wait out the DMA ahead of the work that block's SPI time exists to overlap β cancelling the deferral outright.
Add the same guard when you introduce a new peripheral on the shared bus β an SD card, a second display, or a raw SPI sensor:
// Before ANY other transaction on the shared SPI bus
drawer.waitForPendingDMA();
sdCard.read(block, buffer);12-bit Color on the Wire (RGB444) β
β οΈ Experimental β not yet verified on hardware. The flag ships off. The panel accepting
COLMOD 0x03, the rendered result and the predicted FPS gain are all still unvalidated. Enable it only on a board you can look at.
PIXELROOT32_TFT_12BIT_COLOR=1 sends the frame as 12-bit RGB444, two pixels per three bytes, instead of RGB565. That is a flat 25% reduction in bus time on every frame, independent of scene content β the only lever here that also helps full-screen scrollers.
; Enable in platformio.ini (per board)
build_flags =
-DPIXELROOT32_TFT_12BIT_COLOR=1- No colors are lost. The framebuffer is 8bpp RGB332, so a frame carries at most 256 distinct colors. TFT_eSPI expands RGB332 into 8 red levels, 8 green levels and 4 blue levels, and all 256 combinations survive truncation to 4 bits per channel without a single collision. The bijection is asserted by
test/unit/test_rgb444/test_rgb444.cpp, not assumed. - It is not bit-exact. The absolute shade shifts slightly on red and green (blue is exact); what is preserved is the full set of 256 distinguishable colors, which is everything the 8bpp framebuffer can express.
- Width constraint: only applies when
PHYSICAL_DISPLAY_WIDTH % 4 == 0. Other widths keep RGB565 and log a warning at init.pushPixelsDMA()counts 16-bit words, so bytes-per-line must be even β and an even width is not sufficient (242 px β 363 bytes/line). This excludes panels such as the 135Γ240 ST7789. - Memory effect: each DMA line buffer shrinks 25% (28,800 β 21,600 bytes at 60 lines on a 240-wide panel), minus a 768-byte pair LUT. Net gain in DMA-capable internal RAM.
πΎ Memory & Resources β
π For comprehensive C++17 memory management guide, see Memory Management Guide
Smart Pointers (C++17) β
Use std::unique_ptr for init-time ownership (Scenes, Actors, UI elements) to automate memory management and document ownership.
- Use
std::make_unique<T>(...)to create objects during initialization only. - Pass raw pointers (via
.get()) to functions that do not take ownership (likeaddEntity). - Use
std::moveonly when transferring ownership explicitly. - β οΈ Do not use in hot paths:
unique_ptris for init-time, not runtime game loop.
Object Pooling β
Pre-allocate all game objects (obstacles, particles, enemies) during init().
- Pools are for runtime zero-allocation recycling;
unique_ptris for init-time ownership semantics. - Pattern: Use fixed-size arrays (e.g.,
Particle particles[50]) and flags (isActive) instead ofstd::vectorwithpush_back/erase. - Trade-off: Eliminates runtime allocations and fragmentation at the cost of a slightly higher fixed RAM footprint; dimension pools to realistic worst-case usage.
Zero Runtime Allocation β
Never use new or malloc inside the game loop (update or draw).
String Handling β
Avoid std::string copies. Use std::string_view for passing strings. For formatting, use snprintf with stack-allocated char buffers.
Scene Arenas (PIXELROOT32_ENABLE_SCENE_ARENA) β
Use a single pre-allocated buffer per scene for temporary entities or scratch data when you need strict zero-allocation guarantees.
- Trade-off: Very cache-friendly and fragmentation-proof, but the buffer cannot grow at runtime; oversizing wastes RAM, undersizing returns
nullptrand requires graceful fallback logic.
ποΈ Build Profiles β
PixelRoot32 supports two build profiles for different use cases:
Embedded Profile (Default) β
For ESP32 and resource-constrained hardware:
- Zero allocation at runtime
- No exceptions,
-fno-exceptionsflag - Deterministic behavior prioritized
- Modular compilation to reduce binary size
- All Hot Path Rules enforced
Native Profile (Optional) β
For PC simulation and development:
- Relaxed constraints for faster iteration
- Exceptions permitted if needed for tooling
- Debug-friendly features enabled
- All subsystems can be compiled in
Use PLATFORM_NATIVE flag to switch profiles.
π Recommended Build Profiles β
Choose a profile based on your game type to optimize memory usage:
| Game Type | Profile | Enabled | Disabled |
|---|---|---|---|
| Arcade shooters/platformers | arcade | Audio, Physics, Particles | UI System |
| Puzzle/casual games | puzzle | Audio, UI System | Physics, Particles |
| Retro/minimal | retro | None | All |
| Educational/tools | puzzle or custom | Audio, UI System | Physics, Particles |
Example platformio.ini configuration:
[env:esp32_arcade]
extends = base_esp32, profile_arcade
build_flags =
${base_esp32.build_flags}
${profile_arcade.build_flags}
[env:esp32_puzzle]
extends = base_esp32, profile_puzzle
build_flags =
${base_esp32.build_flags}
${profile_puzzle.build_flags}
[env:native_retro]
build_flags =
-DPLATFORM_NATIVE=1
-DPIXELROOT32_ENABLE_AUDIO=0
-DPIXELROOT32_ENABLE_PHYSICS=0
-DPIXELROOT32_ENABLE_PARTICLES=0
-DPIXELROOT32_ENABLE_UI_SYSTEM=0π Resolution Scaling β
PixelRoot32 separates logical resolution (what your game draws at) from physical resolution (the actual display), so you can target low pixel counts for performance while filling modern panels.
When to Use β
- Ship gameplay at 128Γ128 or 160Γ144 but drive a 240Γ240 TFT
- Keep UI and physics in logical space; only the final blit scales up
Configure DisplayConfig β
Set logicalWidth / logicalHeight for the render buffer and physicalWidth / physicalHeight for the panel. The renderer and input pipeline map between the two.
See DisplayConfig / Engine and the architecture deep dive Resolution Scaling for implementation details, ESP32 considerations, and coordinate mapping.
π Related Documentation β
| Document | Description |
|---|---|
| Memory Management Guide | Complete C++17 memory guide with smart pointers |
| Platform Compatibility | Hardware matrix and feature support |
| Architecture Index | Layer architecture and subsystem navigation |
| Rendering Guide | Core rendering pipeline |
| Configuration Reference | Every build flag, including the TFT_eSPI display flags |
| Driver Layer | TFT_eSPI driver internals and the shared SPI bus contract |
| ESP32 Performance Audit | Full source audit with the bandwidth analysis behind these numbers |
