Graphics Guidelines - PixelRoot32 β
Patterns and best practices for rendering, sprites, tilemaps, and palette systems.
Pipeline first: read Rendering for Renderer, layers, camera, and draw flow. This page focuses on asset layout, bitmap conventions, and tile/palette usage.
π¨ Sprite Guidelines β
1bpp Sprite Definition β
Define sprites as static const uint16_t arrays, one row per element:
// β
1bpp sprite: bit 0 = leftmost, bit (width-1) = rightmost
static const uint16_t playerBitmap[] = {
0b0000001111000000, // Row 0
0b0000011111100000, // Row 1
0b0000111111110000, // Row 2
0b0000111111110000, // Row 3
0b0000010101000000, // Row 4
};
pixelroot32::graphics::Sprite playerSprite = {
.width = 16,
.height = 5,
.data = playerBitmap
};Sprite Descriptors β
Always wrap bitmaps in descriptors:
// β
Descriptor approach
void MyActor::draw(Renderer& r) {
r.drawSprite(playerSprite, static_cast<int>(x), static_cast<int>(y));
}
// β Never pass raw bitmaps
void MyActor::draw(Renderer& r) {
r.drawBitmap(playerBitmap, ...); // Wrong API
}Layered Sprites (Multi-Color) β
Compose from multiple 1bpp layers:
// β
Layered multi-color sprite
static const uint16_t shipOutline[] = { ... };
static const uint16_t shipCockpit[] = { ... };
static const uint16_t shipEngine[] = { ... };
pixelroot32::graphics::MultiSprite ship = {
.layers = {
{shipOutline, PaletteType::PR32},
{shipCockpit, PaletteType::NES},
{shipEngine, PaletteType::GB}
},
.layerCount = 3
};
renderer.drawMultiSprite(ship, x, y);Keep layer data static const for flash storage.
Higher Bit Depth (Optional) β
Enable for specific use cases:
// platformio.ini
-DPIXELROOT32_ENABLE_2BPP_SPRITES=1 // 2x memory
-DPIXELROOT32_ENABLE_4BPP_SPRITES=1 // 4x memory| Format | Use For | Cost |
|---|---|---|
| 1bpp (default) | Gameplay sprites, tiles | 1x |
| 2bpp | Logos, detailed UI | 2x |
| 4bpp | Photos, title screens | 4x |
Default to 1bpp for gameplay-critical assets.
πΊοΈ Tilemap System β
Basic Tilemap β
// Tile indices (compact uint8_t)
static const uint8_t tileIndices[] = {
0, 0, 0, 0, // Row 0
0, 1, 1, 0, // Row 1 (tile 1 = ground)
0, 1, 1, 0, // Row 2
0, 0, 0, 0, // Row 3
};
pixelroot32::graphics::TileMap level = {
.width = 4,
.height = 4,
.tileWidth = 16,
.tileHeight = 16,
.tiles = tileIndices,
.tileset = groundTiles,
.tilesetSize = 2 // 2 unique tiles
};
// Draw
renderer.drawTileMap(level, 0, 0); // x, y offsetTile Reuse β
Reuse tiles across the map to minimize flash:
// β
Reuse: indices point to same tile data
// Tile 0 = sky (used 100 times)
// Tile 1 = ground (used 20 times)
// Total unique tiles: 2, not 120Scrolling with Camera β
// β
Centralized camera logic
class GameScene : public Scene {
Camera2D camera;
public:
void update(unsigned long dt) {
camera.setTarget(player->position);
camera.update(dt);
}
void draw(Renderer& r) {
r.setDisplayOffset(-camera.getX(), -camera.getY());
r.drawTileMap(backgroundLayer, 0, 0);
Scene::draw(r); // Actors
}
};Camera bounds under a non-orthogonal projection β
Camera2D itself is projection-agnostic β it is a pure translation pushed into the renderer as an offset, and nothing in it assumes an axis-aligned tile grid. But setBounds() / setVerticalBounds() clamp in whatever units you hand them, and the camera moves in screen space.
For an orthogonal map, world units and screen units coincide, so passing the map's world extent is correct. Under a non-identity projection (gameplay::ProjectionSpec β isometric, oblique) they do not coincide: a cols Γ rows isometric map is a diamond on screen, roughly (cols + rows) Γ halfTileWidth wide, and its leftmost point is at cell (0, rows), not at cell (0, 0).
Passing cell-space or world-space bounds there stops the camera in the wrong place β usually visible as the view refusing to reach the left and right tips of the diamond. Clamp with projected bounds instead:
// β
Bounds computed through the projection, over all four map corners.
using namespace pixelroot32::gameplay;
inline constexpr ProjectionSpec kIso{0, 0, 16, 8, -16, 8};
static_assert(projectionSpecIsValid(kIso, kCols, kRows), "kIso out of range");
const int xs[4] = { cellToScreenX(0, 0, kIso), cellToScreenX(kCols, 0, kIso),
cellToScreenX(0, kRows, kIso), cellToScreenX(kCols, kRows, kIso) };
// minX/maxX over xs[], likewise for Y, then:
camera.setBounds(math::toScalar(minX), math::toScalar(maxX - viewportWidth));All four corners matter for the same reason projectionSpecIsValid() checks four: with a negative axis component the extreme is not at the corner an axis-aligned map would put it.
π¬ Tile Animation β
Memory Budget β
| Tileset Size | RAM Usage | % ESP32 DRAM |
|---|---|---|
| 64 tiles | 73 bytes | 0.02% |
| 128 tiles | 137 bytes | 0.04% |
| 256 tiles | 265 bytes | 0.08% |
Start with 64-128 tiles.
Initialization β
// In PROGMEM
PIXELROOT32_SCENE_FLASH_ATTR const TileAnimation animations[] = {
{ 2, 4, 8, 0 }, // Water: tiles 2-5, 4 frames, 8Γ (1/60 s) ticks per cell
{ 6, 2, 6, 0 }, // Lava: tiles 6-7, 2 frames, 6Γ (1/60 s) ticks per cell
};
TileAnimationManager animManager(animations, 2, 64);
// Link to tilemap
TileMap2bpp backgroundLayer = {
// ... other fields ...
.animManager = &animManager // Enables animations
};Game Loop Integration β
void MyScene::update(unsigned long dt) {
animManager.step(dt); // Wall-time pacing (see TileAnimationManager API)
Scene::update(dt);
}
void MyScene::draw(Renderer& r) {
r.drawTileMap(backgroundLayer, 0, 0);
Scene::draw(r);
}Speed Control β
Animation speed is driven by frameDuration in TileAnimation data (larger value β each sprite frame held longer). Do not gate step(dt) on engine loop count to change speed; that breaks wall-clock pacing when the loop runs faster than the display.
// Pause when game paused (do not call step while frozen)
if (!isPaused) {
animManager.step(dt);
}Common Pitfalls β
- Sequential frames only: Tiles 2,3,4,5 - not 2,5,9,12
- Shared state: All instances of a tile share the same frame
- StaticTilemapLayerCache: If tilemap is in static group, advancing tile animation requires
invalidate()on that cache when applicable
π¨ Multi-Palette Systems β
Slot-Based Palettes β
Separate palettes for sprites and backgrounds:
// Initialize during scene init
void MyScene::init() {
pixelroot32::graphics::Color::enableDualPaletteMode(true);
// Background slots (for tilemaps)
pixelroot32::graphics::initBackgroundPaletteSlots();
setBackgroundPaletteSlot(0, PaletteType::PR32); // Ground
setBackgroundPaletteSlot(1, PaletteType::NES); // Water
setBackgroundPaletteSlot(2, PaletteType::GB); // Underground
// Sprite slots
pixelroot32::graphics::initSpritePaletteSlots();
setSpritePaletteSlot(0, PaletteType::PR32); // Player
setSpritePaletteSlot(1, PaletteType::NES); // Fire enemies
setSpritePaletteSlot(2, PaletteType::GBC); // Ice enemies
}Custom Palettes β
// In PROGMEM
static const uint16_t CUSTOM_FIRE[] = {
0x0000, 0xFFFF, 0xF800, 0xFC00, // Colors 0-3
0xFA00, 0xF800, 0xF600, 0xF400, // Colors 4-7
// ... 16 colors total
};
// Apply to slot
setSpriteCustomPaletteSlot(5, CUSTOM_FIRE);Batching with Context β
// β
Set context once for many sprites
void BulletManager::drawAll(Renderer& r) {
r.setSpritePaletteSlotContext(1); // Fire palette
for (auto& bullet : bullets) {
r.drawSprite(bulletSprite, bullet.x, bullet.y);
}
r.setSpritePaletteSlotContext(0xFF); // Reset
}Slot Documentation β
// Background slots:
// 0: Default ground (PR32)
// 1: Water areas (NES - blue)
// 2: Underground (GB - green)
// 3: Lava (custom red)
// 4-7: Reserved
// Sprite slots:
// 0: Player (PR32)
// 1: Fire enemies (NES)
// 2: Ice enemies (GBC)
// 3: Boss (PICO8)
// 4-7: Reservedπ§± Render Layers β
Standard layer assignment:
| Layer | Content |
|---|---|
| 0 | Background (tilemaps, fills) |
| 1 | Gameplay (player, enemies, bullets) |
| 2 | UI (HUD, menus, text) |
class MyActor : public Actor {
public:
MyActor() {
renderLayer = 1; // Gameplay layer
}
};
class HUD : public Actor {
public:
HUD() {
renderLayer = 2; // UI layer (top)
}
};π Related Documentation β
| Document | Topic |
|---|---|
| UI Guidelines | Layouts, panels, HUDs |
| Performance | Hot paths, optimization |
| Tile animation | Animation system deep dive |
| API Graphics | Complete graphics API |
Graphics should be efficient, authentic, and layer-friendly.
