Skip to content

Rendering System ​

The rendering system provides hardware abstraction and high-level drawing primitives. It manages the transformation from game world coordinates to physical display pixels.

Architecture Overview ​

The Renderer ​

Renderer is the main interface for all drawing operations:

cpp
#include <Renderer.h>

using namespace pixelroot32;

// Initialize with display configuration
graphics::DisplayConfig config(240, 240);  // Logical resolution
graphics::Renderer renderer(config);
renderer.init();

// Frame structure
renderer.beginFrame();  // Clear framebuffer (full or selective with dirty regions)

// ... draw game content ...

renderer.endFrame();    // Send to display

Drawing Primitives ​

cpp
class Renderer {
    // Shapes
    void drawPixel(int x, int y, Color color);
    void drawLine(int x1, int y1, int x2, int y2, Color color);
    void drawRectangle(int x, int y, int w, int h, Color color);
    void drawFilledRectangle(int x, int y, int w, int h, Color color);
    void drawCircle(int x, int y, int radius, Color color);
    void drawFilledCircle(int x, int y, int radius, Color color);
    
    // Text
    void drawText(std::string_view text, int x, int y, Color c, uint8_t size);
    void drawTextCentered(std::string_view text, int y, Color c, uint8_t size);
    
    // Sprites
    void drawSprite(const Sprite& sprite, int x, int y, Color color);
    void drawSprite2bpp(const Sprite2bpp& sprite, int x, int y);
    void drawSprite4bpp(const Sprite4bpp& sprite, int x, int y);
    void drawMultiSprite(const MultiSprite& sprite, int x, int y);
    
    // Tilemaps
    void drawTileMap(const TileMap& map, int x, int y, Color color, LayerType layerType = LayerType::Dynamic);
    void drawTileMap2bpp(const TileMap2bpp& map, int x, int y, LayerType layerType = LayerType::Dynamic);
    void drawTileMap4bpp(const TileMap4bpp& map, int x, int y, LayerType layerType = LayerType::Dynamic);
};

Coordinate Systems ​

World Space ​

Where game objects exist:

cpp
// Player at world position (1000, 500)
player->position = math::Vector2(1000, 500);

Logical Resolution ​

The rendering resolution—may differ from physical display:

cpp
// Render at 128x128, display on 240x240
graphics::DisplayConfig config;
config.logicalWidth = 128;
config.logicalHeight = 128;
config.physicalWidth = 240;
config.physicalHeight = 240;

Camera/Offset ​

The viewport into the world:

cpp
// Center camera on player
renderer.setDisplayOffset(
    -player->position.x + renderer.getLogicalWidth() / 2,
    -player->position.y + renderer.getLogicalHeight() / 2
);

Physical Resolution ​

Actual display pixels:

Resolution Scaling ​

PixelRoot32 supports independent logical and physical resolutions:

cpp
// Setup for different display sizes
void setupRenderer(int displaySize) {
    graphics::DisplayConfig config;
    
    // Always render at 128x128 for consistent game logic
    config.logicalWidth = 128;
    config.logicalHeight = 128;
    
    // Scale to actual display
    config.physicalWidth = displaySize;
    config.physicalHeight = displaySize;
    
    renderer = graphics::Renderer(config);
}

// Usage
setupRenderer(240);  // 240x240 display
setupRenderer(320);  // 320x320 display

Benefits ​

  1. Consistent game logic—coordinates are always 0-127
  2. Memory savings—framebuffer sized to logical resolution
  3. Performance—render fewer pixels, scale in hardware/DMA
  4. Portability—same code works on different displays

Scaling Algorithms ​

AlgorithmQualitySpeedUse Case
Nearest neighborLowFastestRetro/pixel art
BilinearMediumFastSmooth graphics
1:1 (no scale)PerfectFastestMatching resolutions

The engine automatically selects the best approach based on configuration.

Sprites ​

1bpp Sprites (Monochrome) ​

Most memory-efficient format:

cpp
// Sprite data: 16x16, packed rows
const uint16_t playerSpriteData[] = {
    0b0000000000000000,
    0b0000000110000000,
    0b0000001111000000,
    0b0000011111100000,
    // ... 16 rows total
};

const graphics::Sprite playerSprite = {
    .data = playerSpriteData,
    .width = 16,
    .height = 16
};

// Drawing
void Player::draw(Renderer& r) {
    r.drawSprite(playerSprite, position.x, position.y, Color::WHITE);
}

2bpp Sprites (4 colors) ​

cpp
// Palette: 4 colors
const graphics::Color palette2bpp[] = {
    Color::BLACK,   // Index 0: Transparent
    Color::BROWN,   // Index 1: Skin
    Color::BLUE,    // Index 2: Shirt
    Color::GREEN    // Index 3: Pants
};

// Data: 2 bits per pixel, packed
const uint8_t sprite2bppData[] = {
    0x00, 0x00, 0x00, 0x00,  // Row 0: all transparent
    0x00, 0x11, 0x11, 0x00,  // Row 1: skin
    // ...
};

const graphics::Sprite2bpp player2bpp = {
    .data = sprite2bppData,
    .palette = palette2bpp,
    .width = 16,
    .height = 16,
    .paletteSize = 4
};

// Drawing
r.drawSprite2bpp(player2bpp, position.x, position.y);

4bpp Sprites (16 colors) ​

cpp
const graphics::Color palette4bpp[] = {
    Color::BLACK, Color::DARK_BLUE, Color::PURPLE, Color::DARK_GREEN,
    Color::BROWN, Color::DARK_GRAY, Color::GRAY, Color::WHITE,
    Color::RED, Color::ORANGE, Color::YELLOW, Color::GREEN,
    Color::BLUE, Color::INDIGO, Color::PINK, Color::LIGHT_GRAY
};

const graphics::Sprite4bpp sprite4bpp = {
    .data = sprite4bppData,  // 4 bits per pixel
    .palette = palette4bpp,
    .width = 16,
    .height = 16,
    .paletteSize = 16
};

Multi-Sprite (Layered) ​

Combine multiple 1bpp layers for complex sprites:

cpp
// Layer 0: Outline
const uint16_t outlineData[] = { /* ... */ };
const graphics::SpriteLayer outline = {
    .data = outlineData,
    .color = Color::BLACK
};

// Layer 1: Body
const uint16_t bodyData[] = { /* ... */ };
const graphics::SpriteLayer body = {
    .data = bodyData,
    .color = Color::BLUE
};

// Layer 2: Highlight
const uint16_t highlightData[] = { /* ... */ };
const graphics::SpriteLayer highlight = {
    .data = highlightData,
    .color = Color::WHITE
};

const graphics::SpriteLayer playerLayers[] = { outline, body, highlight };
const graphics::MultiSprite playerMulti = {
    .width = 16,
    .height = 16,
    .layers = playerLayers,
    .layerCount = 3
};

// Drawing (layers drawn in order)
r.drawMultiSprite(playerMulti, position.x, position.y);

Tilemaps ​

Tilemaps efficiently render large backgrounds:

cpp
// Tile definitions (1bpp)
const graphics::Sprite tiles[] = {
    { groundData, 8, 8 },    // Index 0
    { wallData, 8, 8 },      // Index 1
    { waterData, 8, 8 },     // Index 2
    // ...
};

// Map data: indices into tiles array
uint8_t mapIndices[20 * 15];  // 20x15 tile map

const graphics::TileMap tileMap = {
    .indices = mapIndices,
    .width = 20,
    .height = 15,
    .tiles = tiles,
    .tileWidth = 8,
    .tileHeight = 8,
    .tileCount = 3
};

// Initialize map
void initMap() {
    for (int y = 0; y < 15; ++y) {
        for (int x = 0; x < 20; ++x) {
            if (y == 14) {
                mapIndices[y * 20 + x] = 0;  // Ground
            } else if (x == 0 || x == 19) {
                mapIndices[y * 20 + x] = 1;  // Walls
            } else {
                mapIndices[y * 20 + x] = 2;  // Water
            }
        }
    }
}

// Drawing with camera offset
void GameScene::draw(Renderer& r) {
    // Camera follows player
    r.setDisplayOffset(-player->position.x + 60, -player->position.y + 60);
    
    // Draw tilemap (automatically culled to viewport)
    r.drawTileMap(tileMap, 0, 0, Color::WHITE);
    
    // Draw entities
    Scene::draw(r);
}

Multi-Palette Tilemaps ​

cpp
// 2bpp tilemap with per-tile palette selection
const graphics::TileMap2bpp tileMap2bpp = {
    .indices = mapIndices,
    .width = 20,
    .height = 15,
    .tiles = tiles2bpp,
    .tileWidth = 8,
    .tileHeight = 8,
    .tileCount = 10,
    .paletteIndices = paletteIndexMap  // Per-tile palette (0-7)
};

// Palette 0: Day colors
// Palette 1: Sunset colors
// Palette 2: Night colors

Viewport Culling ​

The renderer automatically skips off-screen entities:

cpp
void Scene::draw(Renderer& r) {
    for (int i = 0; i < entityCount; ++i) {
        // Skip if not visible
        if (!entities[i]->isVisible) continue;
        
        // Skip if outside viewport
        if (!isVisibleInViewport(entities[i], r)) continue;
        
        entities[i]->draw(r);
    }
}

Culling significantly improves performance with large levels.

Color System ​

Indexed Colors ​

cpp
namespace Color {
    constexpr uint8_t BLACK = 0;
    constexpr uint8_t DARK_BLUE = 1;
    constexpr uint8_t PURPLE = 2;
    // ... NES palette
}

Palette Resolution ​

Colors are resolved through palettes:

cpp
// Set palettes
renderer.setBackgroundPalette(palettes::NES);
renderer.setSpritePalette(palettes::PR32);

// Or use dual palette mode
renderer.enableDualPaletteMode(true);
renderer.setBackgroundPalette(palettes::GB);    // Muted
renderer.setSpritePalette(palettes::PR32);      // Vibrant

Custom Palettes ​

cpp
const graphics::Color customPalette[] = {
    0x0000,  // 0: Black
    0x001F,  // 1: Blue
    0xF800,  // 2: Red
    0x07E0,  // 3: Green
    0xFFE0,  // 4: Yellow
    // ... (RGB565 format)
};

renderer.setCustomPalette(customPalette, 16);

Font Rendering ​

Bitmap Fonts ​

cpp
// Use built-in 5x7 font
renderer.drawText("Score: 100", 10, 10, Color::WHITE, 2);

// Or load custom font
#include <Font.h>
const graphics::Font customFont = { /* ... */ };
renderer.setFont(&customFont);

Text Alignment ​

cpp
// Left-aligned (default)
renderer.drawText("Left", 10, 10, Color::WHITE, 1);

// Centered horizontally
renderer.drawTextCentered("Centered Title", 50, Color::WHITE, 3);

// Manual centering
int width = graphics::FontManager::textWidth("Text", &customFont);
renderer.drawText("Text", (240 - width) / 2, 100, Color::WHITE, 1);

Dirty Region Optimization ​

The Dirty Region System reduces framebuffer clearing overhead by tracking which 8×8 pixel cells were drawn to in each frame.

When to Enable ​

  • Games with mostly static backgrounds (platformers, top-down RPGs)
  • Scenes where only a small portion of the screen changes per frame
  • When dirty_ratio < 0.5 (most of the screen stays clean)

Enable in platformio.ini ​

ini
build_flags =
    -DPIXELROOT32_ENABLE_DIRTY_REGIONS=1
    -DPIXELROOT32_ENABLE_DIRTY_REGION_PROFILING=1   ; Optional: runtime profiling
    -DPIXELROOT32_DEBUG_MODE=1                       ; Required for debug overlay

Using LayerType (Static vs. Dynamic) ​

When using the Dirty Region pipeline, you must classify elements via the LayerType enum when drawing to optimize tracking:

  • LayerType::Static: For backgrounds or static HUD elements. They are drawn to the framebuffer but do not mark their corresponding cells as dirty, minimizing tracking overhead.
  • LayerType::Dynamic: For moving sprites or animations. Their bounding boxes automatically mark intersecting 8x8 cells as dirty, ensuring they are redrawn next frame.

Classify tilemaps when drawing to optimize tracking:

cpp
// Static background - rarely changes, doesn't mark dirty cells
renderer.drawTileMap(backgroundMap, 0, 0, Color::WHITE, LayerType::Static);

// Dynamic sprites - move every frame, mark their cells as dirty
renderer.drawTileMap(playerSprite, x, y, Color::WHITE, LayerType::Dynamic);

When to Call forceFullRedraw() ​

Call this to force a full framebuffer clear when needed:

cpp
// Scene transitions
void GameScene::onSceneEnter() {
    renderer.forceFullRedraw();
}

// Pause menus
void pauseGame() {
    renderer.forceFullRedraw();
    // Draw pause UI
}

// Camera jump / teleport
void teleportPlayer(Vector2 newPos) {
    camera.setPosition(newPos);
    renderer.forceFullRedraw();
}

Example: Tilemap + Sprites with Layer Types ​

cpp
void GameScene::draw(Renderer& r) {
    // Background (static - won't mark dirty cells)
    r.drawTileMap(stageMap, 0, 0, Color::WHITE, LayerType::Static);
    
    // Midground objects
    r.drawTileMap(wallsMap, 0, 0, Color::WHITE, LayerType::Dynamic);
    
    // Entities (dynamic sprites)
    for (auto* enemy : enemies) {
        enemy->draw(r);  // Automatically marks dirty
    }
    
    // Player
    player->draw(r);  // Automatically marks dirty
    
    // UI (drawn last, on top)
    r.setOffsetBypass(true);
    drawUI(r);
    r.setOffsetBypass(false);
}

Debug Overlay ​

Enable to visualize which cells are dirty:

cpp
// In setup (requires PIXELROOT32_DEBUG_MODE=1)
renderer.setDebugDirtyCellOverlay(true);

This draws a colored overlay showing dirty cells in real-time. When forceFullRedraw() has been called, all cells are highlighted.

StaticTilemapLayerCache Integration ​

The Dirty Region system integrates tightly with StaticTilemapLayerCache to provide an ultra-fast rendering path on ESP32. Instead of redrawing the background tilemap pixel-by-pixel, the cache takes a snapshot of the logical framebuffer containing only LayerType::Static elements. On subsequent frames, renderer.beginFrame() uses a fast memcpy to restore the background, and only the cells marked by LayerType::Dynamic elements are selectively cleared and redrawn.

StaticLayerSnapshot Integration ​

StaticTilemapLayerCache above works by redrawing the TileMap4bpp it owns, so it only helps layers expressible as an axis-aligned tilemap. StaticLayerSnapshot (requires PIXELROOT32_ENABLE_STATIC_LAYER_SNAPSHOT=1, default 0) covers the rest — layers game code draws itself, such as an isometric floor whose diamond cells drawTileMap cannot represent.

It never draws anything. The game captures the framebuffer once its static layers are on it, and restores them afterwards:

cpp
// Scene::init() — allocates, so never in the game loop.
(void)snapshot_.allocateForRenderer(engine.getRenderer());

// Scene::adviseFramebufferBeforeBeginFrame() — lets beginFrame() skip a clear
// restore() is about to redo anyway. Purely an optimisation; skipping this
// costs a redundant clear, never a wrong frame.
snapshot_.adviseFramebufferBeforeBeginFrame(renderer);

// In the static layer's draw(), before anything dynamic:
if (!snapshot_.restore(renderer)) {
    drawMyStaticLayers(renderer);   // cache cold, or unavailable
    (void)snapshot_.capture(renderer);
}

With dirty regions enabled, restore() repaints only the cells the previous frame's movers dirtied; without them, it copies the whole buffer. Both are correct.

Call invalidate() whenever the static layers would draw differently — a palette swap, a camera move, a door opening. The snapshot has no idea what it is holding and deliberately does not try to guess.

Capture order matters: whatever is on the framebuffer when capture() runs is what every later restore() puts back. Capture after the static layers and before anything dynamic, or a moving actor is baked into the background. Entities that must be depth-sorted against a mover — scenery the player walks both behind and in front of — belong outside the snapshot, in the per-frame sort.

Scene Stacking Contract ​

When using StaticTilemapLayerCache with stacked scenes:

Important: At least one scene must NOT use the static cache (i.e., must not advise suppression), or all stacked scenes must collectively cover the entire framebuffer. Failure to meet this contract may result in stale pixels in uncovered regions.

cpp
// Scene A: uses static cache (advises suppression)
void SceneA::beginFrame() {
    renderer.accumulateFramebufferClearSuppressionAdvice(true);
}

// Scene B: does NOT use cache (does NOT advise)
void SceneB::beginFrame() {
    renderer.accumulateFramebufferClearSuppressionAdvice(false);  // Required!
}

Best Practices ​

Batch Similar Draw Calls ​

cpp
// Good: Group by layer
void draw(Renderer& r) {
    // Background layer (0)
    for (auto* e : backgroundEntities) e->draw(r);
    
    // Game layer (1)
    for (auto* e : gameEntities) e->draw(r);
    
    // UI layer (2)
    r.setOffsetBypass(true);  // Ignore camera
    for (auto* e : uiEntities) e->draw(r);
    r.setOffsetBypass(false);
}

Minimize State Changes ​

cpp
// Good: Set color once, draw many
r.setColor(Color::WHITE);
for (int i = 0; i < 100; ++i) {
    r.drawPixel(points[i].x, points[i].y, Color::WHITE);
}

Use Appropriate Sprite Formats ​

FormatColorsUse Case
1bpp2UI, simple sprites, tilemaps
2bpp4Characters, detailed sprites
4bpp16High-color elements, effects
MultiPer-layerComplex layered sprites

Next Steps ​

Released under the MIT License.