Skip to content

Memory Management

ESP32 has limited memory, so efficient memory management is crucial for PixelRoot32 games. This guide covers memory constraints, object pooling, and best practices.

ESP32 Memory Constraints

Available Memory

ESP32 typically has:

  • RAM: ~320KB total (varies by model)
  • Flash: 4MB+ (for program storage)
  • Heap: Limited and fragmented over time

Driver-Specific Footprint

Choosing the right driver can significantly impact memory:

  • TFT_eSPI: Requires a full color framebuffer (Sprite). A 240x240 screen at 16bpp (RGB565) needs ~115KB of RAM.
  • U8G2: Uses a monochromatic buffer. A 128x64 OLED screen only needs ~1KB of RAM.

Tip: If you are extremely low on RAM, consider switching to an OLED display with the PIXELROOT32_USE_U8G2 driver.

Real-World Limits

  • MAX_ENTITIES: 32 per scene (hard limit)
  • Sprite data: Stored in flash (const/constexpr)
  • Dynamic allocation: Should be avoided in game loop
  • Stack: Limited (~8KB), avoid large stack allocations

Hardware-Specific Memory (ESP32)

When working with high-performance drivers (TFT, I2S), memory must be allocated with specific capabilities.

DMA-Capable Memory

For SPI or I2S transfers to work without CPU intervention, the buffers must be in a specific region of SRAM.

// Correct way to allocate a DMA buffer
uint16_t* dmaBuffer = (uint16_t*)heap_caps_malloc(
    bufferSize, 
    MALLOC_CAP_DMA | MALLOC_CAP_8BIT
);

// Always check for success
if (dmaBuffer == nullptr) {
    // Fallback or error
}

// Memory allocated with heap_caps_malloc must be freed with heap_caps_free
heap_caps_free(dmaBuffer);

Memory-Performance Trade-offs (v1.0.0)

In v1.0.0, the TFT_eSPI_Drawer uses double-buffering for DMA. Increasing LINES_PER_BLOCK improves throughput by reducing interrupt frequency, but increase memory usage linearly: - Baseline: 20 lines = ~10KB (at 240 width) - Optimized: 60 lines = ~30KB - Max: 120 lines = ~60KB (Half frame)

[!IMPORTANT] Non-FPU platforms like ESP32-C3 have more limited SRAM. Be cautious when increasing DMA block sizes or logical resolutions.


Smart Pointers (C++17)

PixelRoot32 uses C++17 features to help manage memory safely.

Using std::unique_ptr

Instead of raw new and delete, use std::unique_ptr to manage ownership of dynamically allocated objects. This ensures they are automatically deleted when they go out of scope or the owner is destroyed.

#include <memory>

class MyScene : public Scene {
    // Scene owns the entities via unique_ptr
    std::unique_ptr<Player> player;
    std::unique_ptr<Enemy> enemy;

public:
    void init() override {
        // Create entities
        player = std::make_unique<Player>(10, 10);
        enemy = std::make_unique<Enemy>(50, 50);

        // Add to scene (pass raw pointer)
        addEntity(player.get());
        addEntity(enemy.get());
    }
    // No need to delete player/enemy in destructor!
};

Forward Declarations and std::unique_ptr

When using std::unique_ptr with forward-declared classes (to reduce compile times or avoid circular dependencies), you might encounter an "incomplete type" error (e.g., invalid application of 'sizeof' to incomplete type).

This happens because std::unique_ptr's destructor needs to know the size of the object to delete it. In the header file, if you only have a forward declaration (class Player;), the size is unknown.

The Fix: Declare the destructor in the header and define it in the .cpp file where the full class definition is included.

Header (MyScene.h):

// Forward declaration
class Player;

class MyScene : public Scene {
    std::unique_ptr<Player> player;
public:
    MyScene();
    virtual ~MyScene(); // Declaration only!

    void init() override;
};

Source (MyScene.cpp):

#include "MyScene.h"
#include "Player.h" // Full definition required here

// Define constructor and destructor here
MyScene::MyScene() = default;
MyScene::~MyScene() = default; // Compiler can now generate deletion code

void MyScene::init() {
    player = std::make_unique<Player>();
    addEntity(player.get());
}

When to use raw pointers

  • Passing to Scene: scene->addEntity(entity.get()) takes a raw pointer. The scene uses this pointer for updates and drawing but does not take ownership.
  • Observers: Passing an object to another system that doesn't own it.

Object Pooling

Object pooling reuses objects instead of creating/destroying them, avoiding memory fragmentation.

Basic Pool Pattern

class ProjectilePool {
private:
    static const int POOL_SIZE = 10;
    ProjectileActor pool[POOL_SIZE];
    bool inUse[POOL_SIZE];

public:
    ProjectilePool() {
        for (int i = 0; i < POOL_SIZE; i++) {
            inUse[i] = false;
        }
    }

    ProjectileActor* getAvailable() {
        for (int i = 0; i < POOL_SIZE; i++) {
            if (!inUse[i]) {
                inUse[i] = true;
                return &pool[i];
            }
        }
        return nullptr; // Pool exhausted
    }

    void release(ProjectileActor* projectile) {
        for (int i = 0; i < POOL_SIZE; i++) {
            if (&pool[i] == projectile) {
                inUse[i] = false;
                projectile->isEnabled = false;
                projectile->isVisible = false;
                break;
            }
        }
    }
};

Using Object Pools

class GameScene : public pixelroot32::core::Scene {
private:
    ProjectilePool projectilePool;

public:
    void update(unsigned long deltaTime) override {
        Scene::update(deltaTime);

        // Fire projectile
        if (input.isButtonPressed(Buttons::A)) {
            ProjectileActor* proj = projectilePool.getAvailable();
            if (proj) {
                proj->x = player->x;
                proj->y = player->y;
                proj->isEnabled = true;
                proj->isVisible = true;
                // ... initialize projectile
            }
        }

        // Clean up projectiles that hit target
        for (auto* entity : entities) {
            if (auto* proj = dynamic_cast<ProjectileActor*>(entity)) {
                if (proj->hitTarget) {
                    projectilePool.release(proj);
                }
            }
        }
    }
};

Complete Example: Entity Pool

template<typename T, int POOL_SIZE>
class EntityPool {
private:
    T pool[POOL_SIZE];
    bool inUse[POOL_SIZE];
    int activeCount = 0;

public:
    EntityPool() {
        for (int i = 0; i < POOL_SIZE; i++) {
            inUse[i] = false;
        }
    }

    T* acquire() {
        if (activeCount >= POOL_SIZE) {
            return nullptr; // Pool full
        }

        for (int i = 0; i < POOL_SIZE; i++) {
            if (!inUse[i]) {
                inUse[i] = true;
                activeCount++;
                return &pool[i];
            }
        }
        return nullptr;
    }

    void release(T* obj) {
        for (int i = 0; i < POOL_SIZE; i++) {
            if (&pool[i] == obj) {
                inUse[i] = false;
                activeCount--;
                obj->isEnabled = false;
                obj->isVisible = false;
                break;
            }
        }
    }

    int getActiveCount() const { return activeCount; }
    int getAvailableCount() const { return POOL_SIZE - activeCount; }
};

// Usage
EntityPool<EnemyActor, 8> enemyPool;
EntityPool<ParticleEmitter, 5> particlePool;

Scene Arena (Experimental)

Scene Arena provides a memory arena for scene-specific allocations, reducing fragmentation.

What is Scene Arena?

Scene Arena is a contiguous memory block pre-allocated for a scene. All scene entities are allocated from this arena instead of the heap.

When to Use

  • Large scenes: Scenes with many entities
  • Frequent allocation: Scenes that create/destroy entities often
  • Memory fragmentation: When heap fragmentation is a problem
  • Performance: When you need predictable allocation performance

Configuration

#ifdef PIXELROOT32_ENABLE_SCENE_ARENA
#include <core/Scene.h>

// Define arena buffer (typically in scene header)
static unsigned char MY_SCENE_ARENA_BUFFER[8192]; // 8KB arena

class MyScene : public pixelroot32::core::Scene {
public:
    void init() override {
        // Initialize arena
        arena.init(MY_SCENE_ARENA_BUFFER, sizeof(MY_SCENE_ARENA_BUFFER));

        // Now entities allocated with arena will use this memory
        // (Requires custom allocation functions)
    }
};
#endif

Limitations

  • Experimental: May have bugs or limitations
  • Fixed size: Arena size must be determined at compile time
  • No reallocation: Can't resize arena at runtime
  • Manual management: Requires careful memory management

Note: Scene Arena is an experimental feature. Use object pooling for most cases.

Best Practices

Avoid Dynamic Allocation in Game Loop

// ❌ BAD: Allocates every frame
void update(unsigned long deltaTime) override {
    if (shouldSpawnEnemy) {
        EnemyActor* enemy = new EnemyActor(x, y);
        addEntity(enemy);
    }
}

// ✅ GOOD: Use pool
void update(unsigned long deltaTime) override {
    if (shouldSpawnEnemy) {
        EnemyActor* enemy = enemyPool.getAvailable();
        if (enemy) {
            enemy->reset(x, y);
            enemy->isEnabled = true;
        }
    }
}

Pre-allocate Resources

class GameScene : public pixelroot32::core::Scene {
private:
    // Pre-allocated pools
    ProjectilePool projectiles;
    EnemyPool enemies;
    ParticlePool particles;

public:
    void init() override {
        // All pools created in constructor
        // No allocation in init() or update()
    }
};

Reuse Objects

class EnemyActor : public pixelroot32::core::Actor {
public:
    void reset(float x, float y) {
        this->x = x;
        this->y = y;
        this->isEnabled = true;
        this->isVisible = true;
        this->health = maxHealth;
        // Reset all state
    }

    void deactivate() {
        isEnabled = false;
        isVisible = false;
    }
};

// Usage
EnemyActor* enemy = enemyPool.getAvailable();
if (enemy) {
    enemy->reset(spawnX, spawnY);
    addEntity(enemy);
}

Avoid Strings and Dynamic Memory

// ❌ BAD: String allocation
void draw(Renderer& renderer) override {
    std::string scoreText = "Score: " + std::to_string(score);
    renderer.drawText(scoreText.c_str(), 10, 10, Color::White, 1);
}

// ✅ GOOD: Static buffer
void draw(Renderer& renderer) override {
    char scoreBuffer[32];
    snprintf(scoreBuffer, sizeof(scoreBuffer), "Score: %d", score);
    renderer.drawText(scoreBuffer, 10, 10, Color::White, 1);
}

Store Data in Flash

// ✅ GOOD: Stored in flash (const/constexpr)
static const uint16_t SPRITE_DATA[] = {
    0b00111100,
    0b01111110,
    // ...
};

// ❌ BAD: Stored in RAM
uint16_t spriteData[] = {
    0b00111100,
    0b01111110,
    // ...
};

Memory Monitoring

Check Available Memory

#ifdef PLATFORM_ESP32
#include <Arduino.h>

void checkMemory() {
    Serial.print("Free heap: ");
    Serial.println(ESP.getFreeHeap());
    Serial.print("Largest free block: ");
    Serial.println(ESP.getMaxAllocHeap());
}
#endif

Monitor Entity Count

void update(unsigned long deltaTime) override {
    Scene::update(deltaTime);

    // Check entity count
    int entityCount = getEntityCount();
    if (entityCount >= MAX_ENTITIES) {
        Serial.println("WARNING: Entity limit reached!");
    }
}

Common Patterns

Entity Lifecycle Management

class ManagedEntity {
private:
    bool isActive = false;

public:
    void activate(float x, float y) {
        this->x = x;
        this->y = y;
        isActive = true;
        isEnabled = true;
        isVisible = true;
    }

    void deactivate() {
        isActive = false;
        isEnabled = false;
        isVisible = false;
    }

    bool getIsActive() const { return isActive; }
};

// Pool manages lifecycle
class EntityManager {
private:
    EntityPool<ManagedEntity, 20> pool;

public:
    ManagedEntity* spawn(float x, float y) {
        auto* entity = pool.acquire();
        if (entity) {
            entity->activate(x, y);
        }
        return entity;
    }

    void despawn(ManagedEntity* entity) {
        if (entity) {
            entity->deactivate();
            pool.release(entity);
        }
    }
};

Memory-Efficient Collections

// Fixed-size array instead of vector
class EntityArray {
private:
    static const int MAX_SIZE = 32;
    pixelroot32::core::Entity* entities[MAX_SIZE];
    int count = 0;

public:
    bool add(pixelroot32::core::Entity* entity) {
        if (count >= MAX_SIZE) return false;
        entities[count++] = entity;
        return true;
    }

    void remove(pixelroot32::core::Entity* entity) {
        for (int i = 0; i < count; i++) {
            if (entities[i] == entity) {
                entities[i] = entities[--count];
                break;
            }
        }
    }

    int size() const { return count; }
    pixelroot32::core::Entity* operator[](int index) { return entities[index]; }
};

Troubleshooting

Out of Memory Errors

  • Reduce pool sizes
  • Use fewer entities
  • Store more data in flash
  • Avoid dynamic allocation
  • Check for memory leaks

Entity Limit Reached

  • MAX_ENTITIES = 32 is a hard limit
  • Use object pooling to reuse entities
  • Deactivate entities instead of removing
  • Combine multiple entities into one

Memory Fragmentation

  • Use object pooling
  • Pre-allocate all resources
  • Avoid frequent new/delete
  • Consider Scene Arena (experimental)

Next Steps

Now that you understand memory management, learn about:


See also: