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:
#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 displayDrawing Primitives
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:
// Player at world position (1000, 500)
player->position = math::Vector2(1000, 500);Logical Resolution
The rendering resolution—may differ from physical display:
// 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:
// 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:
// 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 displayBenefits
- Consistent game logic—coordinates are always 0-127
- Memory savings—framebuffer sized to logical resolution
- Performance—render fewer pixels, scale in hardware/DMA
- Portability—same code works on different displays
Scaling Algorithms
| Algorithm | Quality | Speed | Use Case |
|---|---|---|---|
| Nearest neighbor | Low | Fastest | Retro/pixel art |
| Bilinear | Medium | Fast | Smooth graphics |
| 1:1 (no scale) | Perfect | Fastest | Matching resolutions |
The engine automatically selects the best approach based on configuration.
Sprites
1bpp Sprites (Monochrome)
Most memory-efficient format:
// 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)
// 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)
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:
// 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:
// 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
// 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 colorsViewport Culling
The renderer automatically skips off-screen entities:
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
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:
// 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); // VibrantCustom Palettes
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
// 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
// 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
build_flags =
-DPIXELROOT32_ENABLE_DIRTY_REGIONS=1
-DPIXELROOT32_ENABLE_DIRTY_REGION_PROFILING=1 ; Optional: runtime profiling
-DPIXELROOT32_DEBUG_MODE=1 ; Required for debug overlayUsing 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:
// 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:
// 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
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:
// 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.
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.
// 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
// 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
// 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
| Format | Colors | Use Case |
|---|---|---|
| 1bpp | 2 | UI, simple sprites, tilemaps |
| 2bpp | 4 | Characters, detailed sprites |
| 4bpp | 16 | High-color elements, effects |
| Multi | Per-layer | Complex layered sprites |
Next Steps
- Performance Guide — Logical vs physical resolution, hot paths
- Graphics Techniques — Tilemaps, palettes, indexed color
