Project 2: Pixel Artist - Drawing Primitives and Sprites

Build a tiny 2D graphics engine: pixels, lines, rectangles, circles, sprites, and a small animated scene on the RP2350 LCD.

Quick Reference

Attribute Value
Difficulty Level 2: Intermediate
Time Estimate 1-2 weeks
Main Programming Language C (Alternatives: Rust, MicroPython)
Alternative Programming Languages Rust (pico-sdk), MicroPython
Coolness Level Level 3: Impressive
Business Potential 3. The “Demo” Level
Prerequisites Project 1 LCD bring-up, RGB565 encoding, basic math
Key Topics Rasterization, clipping, sprite blitting, framebuffer layout

1. Learning Objectives

By completing this project, you will:

  1. Implement efficient rasterization for lines, rectangles, and circles.
  2. Build a sprite blitter with clipping, transparency, and flipping.
  3. Design a simple framebuffer pipeline for deterministic rendering.
  4. Measure rendering cost and avoid unnecessary redraws.
  5. Animate a scene with a fixed timestep loop.

2. All Theory Needed (Per-Concept Breakdown)

2.1 Rasterization and Discrete Geometry

Fundamentals

Rasterization is the process of converting geometric shapes into discrete pixels. On a microcontroller, this must be deterministic, integer-based, and fast. A line is not continuous; it is a sequence of pixels that approximates a slope. A circle is similarly a set of discrete points that approximate curvature. Rasterization algorithms like Bresenham’s line algorithm or midpoint circle algorithms avoid floating-point math by using incremental error terms. This is especially important on embedded CPUs where floating-point can be slow or absent. Rasterization also introduces the idea of clipping: only draw the pixels that fall inside the screen bounds. If you ignore clipping, you will write out of bounds and corrupt memory. A good rasterization core is the foundation of every 2D graphics system you build on top of a framebuffer.

Deep Dive into the concept

To render primitives efficiently, you need to think in terms of integer steps and error accumulation. Bresenham’s line algorithm works by keeping a decision variable that tells you when to step in the secondary axis. This yields smooth, straight lines without floating point. For circles, the midpoint algorithm uses symmetry: you compute one octant and mirror it across axes to fill the circle quickly. For rectangles, you can treat them as a filled area with horizontal spans; rendering a span (a contiguous run of pixels) is often faster than setting pixels individually. A common pattern is to implement a low-level “draw_span(x0, x1, y)” which sets a contiguous row of pixels, then build rectangles and filled polygons on top.

Clipping is not optional. With a 172x320 display, any pixel outside the bounds can trash memory or be silently ignored depending on your implementation. The simplest approach is per-pixel bounds checks, but these add overhead. A better approach is to clip at the primitive level: if a line’s bounding box is fully off-screen, skip it; if partially on-screen, clip endpoints before drawing. For sprites, clipping determines which rows and columns you copy. By making clipping a first-class concern, you ensure deterministic behavior and prevent bugs when sprites move off-screen.

How this fits on projects

Rasterization is the heart of Section 3.2 and Section 5.10 Phases 2-3. It becomes the workhorse for Project 6 (font rendering) and Project 11 (game rendering). Also used in: Project 6, Project 11.

Definitions & key terms

  • Rasterization -> Converting geometry into discrete pixels.
  • Bresenham’s algorithm -> Integer line drawing with error accumulation.
  • Midpoint algorithm -> Integer circle drawing via symmetry.
  • Clipping -> Discarding pixels outside a region.
  • Span -> A continuous horizontal run of pixels.

Mental model diagram (ASCII)

Line rasterization (y increases when error crosses threshold)

(0,0) *
        *
          *
            *
              * (6,3)

How it works (step-by-step)

  1. Determine the major axis of the line.
  2. Initialize error term based on slope.
  3. Step along the major axis, update error, occasionally step minor axis.
  4. For circles, compute one octant and mirror to seven others.
  5. Clip all output pixels to display bounds.

Failure modes:

  • No clipping -> memory corruption.
  • Floating-point math -> slow rendering.
  • Per-pixel function calls -> large overhead.

Minimal concrete example

void draw_line(int x0, int y0, int x1, int y1, uint16_t color) {
  int dx = abs(x1 - x0), sx = x0 < x1 ? 1 : -1;
  int dy = -abs(y1 - y0), sy = y0 < y1 ? 1 : -1;
  int err = dx + dy;
  while (1) {
    if ((unsigned)x0 < WIDTH && (unsigned)y0 < HEIGHT)
      fb[y0 * WIDTH + x0] = color;
    if (x0 == x1 && y0 == y1) break;
    int e2 = 2 * err;
    if (e2 >= dy) { err += dy; x0 += sx; }
    if (e2 <= dx) { err += dx; y0 += sy; }
  }
}

Common misconceptions

  • “I can just use floats, it’s simpler.” -> It often breaks performance.
  • “Clipping is optional.” -> It’s required for safety on embedded.
  • “Every pixel draw is the same cost.” -> Spans can be much faster.

Check-your-understanding questions

  1. Why does Bresenham avoid floating point?
  2. What is the benefit of drawing spans instead of pixels?
  3. What happens if a line is drawn without clipping?

Check-your-understanding answers

  1. It uses integer error accumulation for speed and determinism.
  2. Spans reduce per-pixel overhead and enable bulk memory writes.
  3. You can write out of bounds and corrupt memory.

Real-world applications

  • 2D UI libraries on embedded devices
  • CAD line drawing in low-power systems
  • Graphics on e-readers and instrument panels

Where you’ll apply it

References

  • “Computer Graphics from Scratch” (Gambetta) - line and circle rasterization
  • Embedded graphics library docs (uGFX, LVGL) for clipping patterns

Key insights

Efficient rasterization is about integer math and minimizing pixel writes.

Summary

A well-designed rasterizer gives you clean primitives without wasting CPU cycles.

Homework/Exercises to practice the concept

  1. Implement a horizontal span drawer and use it for filled rectangles.
  2. Modify Bresenham to draw dashed lines.
  3. Draw a circle using only 1/8 of the points and symmetry.

Solutions to the homework/exercises

  1. Use a loop for x0..x1 and write to one row.
  2. Skip every N pixels in the loop.
  3. Calculate (x,y) in octant and mirror to 8 positions.

2.2 Sprite Blitting, Clipping, and Transparency

Fundamentals

A sprite is a small image stored in memory that you copy (blit) onto the framebuffer. Sprites enable animation and rich scenes without re-rendering everything from primitives. A sprite blitter reads a source pixel, optionally handles transparency, and writes to a destination. The key challenges are clipping (what if a sprite is partially off-screen?) and stride handling (source image width vs framebuffer width). Many embedded sprites use a color key for transparency, such as 0x0000 (black) or 0xFFFF (white). A correct blitter prevents out-of-bounds writes and handles per-pixel alpha or simple transparency correctly.

Deep Dive into the concept

Blitting is fundamentally a memory copy with per-pixel decisions. You compute the intersection rectangle between the sprite and the screen, then iterate only that region. This avoids bounds checks inside the inner loop. Stride matters: a sprite might be 16 pixels wide, but the framebuffer is 172. That means you cannot copy a full row with memcpy unless the widths match. Instead, you compute the offset for each row: dst = fb + (y * WIDTH + x), src = sprite + (sy * sprite_w + sx).

Transparency introduces a branch. For color-key transparency, you skip pixels matching a specific value. For alpha blending, you compute a weighted average of source and destination channels. On embedded systems, alpha blending is expensive; a common trick is to precompute a small alpha table or restrict to two alpha levels (opaque or transparent). Another subtlety is flipping and rotation. For sprite animation, horizontal flip is often enough; you can compute source indices from right to left without copying the sprite data.

The blitter also defines performance limits. If you draw 20 sprites of size 32x32 every frame, you’re writing 20 * 1024 pixels = 20k pixels, which is fine. If you blit a full-screen sprite every frame, you’re effectively re-rendering the entire framebuffer. You should track dirty rectangles: only the regions that change each frame. This reduces bandwidth and helps maintain stable FPS.

How this fits on projects

Sprite blitting is essential in Section 3.2 and Section 5.10 Phase 2. It is the core rendering method in Project 11 (game sprites) and helpful for Project 8 (image viewer). Also used in: Project 8, Project 11.

Definitions & key terms

  • Sprite -> A bitmap image for animation or UI elements.
  • Blit -> Copy source pixels to destination with optional processing.
  • Color key -> A specific color treated as transparent.
  • Dirty rectangle -> A region that needs re-rendering.
  • Stride -> Number of pixels per row in memory.

Mental model diagram (ASCII)

Screen (172x320)
+------------------------+
|                        |
|     [SPRITE]           |
|     overlaps edge      |
+------------------------+

Clip to intersection rectangle before copy

How it works (step-by-step)

  1. Compute sprite bounding box on screen.
  2. Intersect with screen bounds to get clip rectangle.
  3. For each row in the clipped region:
    • Compute source and destination pointers.
    • Copy or blend pixels, skipping transparent ones.
  4. Optionally mark dirty rectangles for display update.

Failure modes:

  • No clipping -> memory corruption.
  • Wrong stride -> diagonal artifacts.
  • Transparency applied to wrong color -> holes in sprites.

Minimal concrete example

void blit_sprite(const uint16_t *spr, int sw, int sh, int x, int y) {
  int x0 = x < 0 ? 0 : x;
  int y0 = y < 0 ? 0 : y;
  int x1 = x + sw > WIDTH ? WIDTH : x + sw;
  int y1 = y + sh > HEIGHT ? HEIGHT : y + sh;
  for (int yy = y0; yy < y1; yy++) {
    int sy = yy - y;
    for (int xx = x0; xx < x1; xx++) {
      int sx = xx - x;
      uint16_t c = spr[sy * sw + sx];
      if (c != 0x0000) fb[yy * WIDTH + xx] = c; // color key
    }
  }
}

Common misconceptions

  • “Just memcpy the sprite.” -> Only works when widths match and no clipping.
  • “Transparency is free.” -> Branching and blending cost CPU.
  • “Full-screen blits are fine.” -> They can destroy frame rate.

Check-your-understanding questions

  1. Why is clipping needed before blitting?
  2. What is stride, and why does it matter?
  3. When would you avoid alpha blending on an MCU?

Check-your-understanding answers

  1. To prevent out-of-bounds writes when sprites go off-screen.
  2. Stride is the per-row width in memory; mismatches cause misalignment.
  3. Alpha blending is expensive; avoid if CPU budget is tight.

Real-world applications

  • Retro-style games on microcontrollers
  • Embedded UI icons and indicators
  • Animation in instrumentation dashboards

Where you’ll apply it

References

  • “Computer Graphics from Scratch” (sprites and image blitting)
  • LVGL documentation (image widgets)

Key insights

Sprite rendering is memory math plus careful clipping.

Summary

A fast blitter plus clipping is the backbone of 2D animation.

Homework/Exercises to practice the concept

  1. Implement horizontal flip without copying sprite data.
  2. Add a transparency color key to an existing sprite.
  3. Optimize blit using a precomputed row offset table.

Solutions to the homework/exercises

  1. Use sx = (sw - 1) - (xx - x) when reading from source.
  2. Compare each pixel to a key (e.g., 0x0000) and skip if equal.
  3. Precompute dst_row = y * WIDTH for each row.

2.3 Framebuffers and Dirty Rectangles

Fundamentals

A framebuffer is a contiguous block of memory representing the display. Each pixel maps to a memory location. For small LCDs, a full RGB565 framebuffer is manageable but still significant (about 110 KB). A na?ve render loop clears the entire framebuffer every frame, then redraws everything. That wastes CPU cycles and SPI bandwidth. Dirty rectangles let you update only the regions that changed, reducing work and enabling smoother animation. This requires tracking which areas change and carefully redrawing only those regions.

Deep Dive into the concept

The framebuffer provides a convenient off-screen drawing surface. You render into RAM, then push the buffer to the LCD. The cost is bandwidth: pushing 110 KB at 10 MHz SPI takes roughly 90 ms, which caps you at ~11 FPS. If you instead update only 10% of the screen, you gain a huge performance boost. Dirty rectangles are the mechanism: you track the bounding boxes of sprites or UI elements that change, then only update those screen areas via smaller CASET/RASET windows. The trade-off is complexity: you must ensure overlapping objects are redrawn in correct order. A common strategy is to keep a list of dirty rectangles, merge overlapping ones, and redraw the background plus sprites inside each rectangle.

You can also use partial framebuffer techniques. Instead of a full-screen buffer, you use a tile buffer (e.g., 16 rows at a time). You render a portion of the screen, send it, then reuse the buffer. This reduces RAM usage but increases software complexity. For this project, a full framebuffer is reasonable, but dirty rectangles keep performance acceptable without DMA.

How this fits on projects

Dirty rectangles are used in Section 3.2 and Section 5.10 Phase 3, and they are critical for Project 6 (text rendering) and Project 9 (system monitor graphs). Also used in: Project 6, Project 9.

Definitions & key terms

  • Framebuffer -> Memory buffer representing the screen.
  • Dirty rectangle -> Region that must be re-rendered.
  • Partial update -> Updating only part of the screen.
  • Tile buffer -> Small reusable framebuffer segment.

Mental model diagram (ASCII)

Screen: [________________________]
Dirty rects:
  [####]      [#####]
Only these regions are redrawn

How it works (step-by-step)

  1. Track objects that moved or changed.
  2. Compute bounding boxes for changes.
  3. Merge overlapping rectangles.
  4. Redraw only those regions.
  5. Send each region via CASET/RASET.

Failure modes:

  • Missing a dirty region -> visual artifacts.
  • Too many rectangles -> overhead exceeds benefit.

Minimal concrete example

struct rect { int x0,y0,x1,y1; };
rect dirty[4];
int dirty_count = 0;

void mark_dirty(int x0,int y0,int x1,int y1){
  dirty[dirty_count++] = (rect){x0,y0,x1,y1};
}

Common misconceptions

  • “Dirty rectangles are only for big displays.” -> They help even on small LCDs.
  • “Full-screen redraws are fine.” -> Not when SPI bandwidth is the bottleneck.

Check-your-understanding questions

  1. Why do full-screen redraws limit FPS?
  2. How do you avoid missed redraws?
  3. When would you merge dirty rectangles?

Check-your-understanding answers

  1. Bandwidth limits; 110 KB per frame is expensive.
  2. Track all moving objects and redraw backgrounds in dirty regions.
  3. When rectangles overlap to reduce the number of updates.

Real-world applications

  • UI frameworks for embedded devices
  • E-ink displays with partial refresh
  • Real-time dashboards on low-power hardware

Where you’ll apply it

References

  • LVGL docs on invalidation/dirty areas
  • Embedded UI optimization guides

Key insights

Performance is often limited by pixels sent, not pixels computed.

Summary

Dirty rectangles let you render less and update faster.

Homework/Exercises to practice the concept

  1. Measure full-screen fill time at 10 MHz SPI.
  2. Implement a moving sprite and update only its bounding box.
  3. Merge two overlapping dirty rectangles into one.

Solutions to the homework/exercises

  1. Expect ~90 ms per frame for full-screen updates.
  2. Mark previous and new sprite positions dirty.
  3. Use min/max of x0,y0,x1,y1 to form a union rectangle.

3. Project Specification

3.1 What You Will Build

A small 2D graphics library supporting pixels, lines, rectangles, circles, and sprites. The project includes a demo scene with animated sprites and a static background, rendered in RGB565 on the LCD.

Included:

  • Primitive drawing routines
  • Sprite blitter with clipping and transparency
  • Dirty-rectangle updates for performance

Excluded:

  • DMA-driven updates (Project 3)
  • True alpha blending or scaling

3.2 Functional Requirements

  1. Primitive drawing: pixels, lines, rectangles, circles.
  2. Sprite support: blit with transparency and clipping.
  3. Animation loop: fixed timestep (e.g., 30 FPS).
  4. Dirty rectangles: update only changed regions.

3.3 Non-Functional Requirements

  • Performance: 30 FPS with a 2-3 sprite scene.
  • Reliability: No out-of-bounds writes.
  • Usability: Simple API (draw_pixel, draw_line, blit_sprite).

3.4 Example Usage / Output

Scene: background grid, 2 moving sprites, FPS counter
Expected: smooth animation, no flicker, stable colors

3.5 Data Formats / Schemas / Protocols

  • Sprites stored as RGB565 arrays
  • Optional color key: 0x0000 for transparency

3.6 Edge Cases

  • Sprites partially off-screen
  • Lines with steep slopes
  • Circles with small radius

3.7 Real World Outcome

You see a pixel-art scene with two animated sprites moving smoothly, with a frame counter in the corner. Only the regions that change update.

3.7.1 How to Run (Copy/Paste)

cd LEARN_RP2350_LCD_DEEP_DIVE/pixel_artist
mkdir -p build
cd build
cmake ..
make -j4
cp pixel_artist.uf2 /Volumes/RP2350

3.7.2 Golden Path Demo (Deterministic)

  • Two sprites move on fixed paths: one left-to-right, one up-and-down.
  • Background grid remains static.
  • FPS counter shows 30 +/-1 FPS.

3.7.3 Failure Demo (Deterministic)

  • Disable clipping in the blitter.
  • Move a sprite off-screen.
  • Expected result: memory corruption or random pixels.
  • Fix: re-enable clipping logic.

4. Solution Architecture

4.1 High-Level Design

[Scene Graph] -> [Rasterizer + Blitter] -> [Framebuffer] -> [LCD Update]

4.2 Key Components

| Component | Responsibility | Key Decisions | |———–|—————-|—————| | Primitive renderer | Lines, circles, rectangles | Use integer algorithms | | Sprite blitter | Copy images to framebuffer | Clip and color-key | | Dirty rect manager | Track changed regions | Merge overlapping rects |

4.3 Data Structures (No Full Code)

struct sprite {
  const uint16_t *pixels;
  uint16_t w, h;
  uint16_t key; // transparency
};

4.4 Algorithm Overview

Key Algorithm: Dirty Rectangle Update

  1. Clear only dirty regions to background.
  2. Render sprites within dirty regions.
  3. Push those rectangles to LCD with CASET/RASET.

Complexity Analysis:

  • Time: O(pixels in dirty regions)
  • Space: O(1) extra per rectangle

5. Implementation Guide

5.1 Development Environment Setup

# Use Project 1 environment

5.2 Project Structure

pixel_artist/
- src/
  - raster.c
  - sprite.c
  - scene.c
  - main.c
- assets/
  - sprites.h

5.3 The Core Question You’re Answering

“How do I render a tiny 2D scene efficiently on a microcontroller LCD?”

5.4 Concepts You Must Understand First

  1. Rasterization algorithms (Bresenham, midpoint)
  2. Sprite blitting and clipping
  3. Framebuffer stride and layout

5.5 Questions to Guide Your Design

  1. How will you separate draw calls from LCD updates?
  2. What transparency method will you use?
  3. How many pixels can you draw per frame at 30 FPS?

5.6 Thinking Exercise

Calculate how many pixels you can update in 33 ms at 10 MHz SPI.

5.7 The Interview Questions They’ll Ask

  1. What is Bresenham’s algorithm used for?
  2. Why does clipping matter in embedded graphics?
  3. How do dirty rectangles improve performance?

5.8 Hints in Layers

  • Hint 1: Start with pixels and rectangles before lines.
  • Hint 2: Add clipping at the rectangle level.
  • Hint 3: Implement a color key for transparency.
  • Hint 4: Update only the changed bounding boxes.

5.9 Books That Will Help

| Topic | Book | Chapter | |——-|——|———| | Rasterization | “Computer Graphics from Scratch” | Ch. 3-6 | | Embedded optimization | “Making Embedded Systems” | Ch. 8 |

5.10 Implementation Phases

Phase 1: Primitives (3-4 days)

Goals: Draw lines, rectangles, circles. Tasks: Implement Bresenham, midpoint circle. Checkpoint: Static test scene renders correctly.

Phase 2: Sprites (3-4 days)

Goals: Blit sprites with clipping and transparency. Tasks: Implement blitter and color key. Checkpoint: Sprite moves across screen without artifacts.

Phase 3: Dirty Rectangles (2-3 days)

Goals: Improve performance with partial updates. Tasks: Track moving objects; update regions. Checkpoint: FPS stable at 30 with minimal flicker.

5.11 Key Implementation Decisions

| Decision | Options | Recommendation | Rationale | |———-|———|—————-|———–| | Transparency | Color key vs alpha | Color key | Faster on MCU | | Update strategy | Full vs dirty | Dirty rects | Higher FPS | | Framebuffer | Full vs tile | Full | Simpler for project scope |


6. Testing Strategy

6.1 Test Categories

| Category | Purpose | Examples | |———-|———|———-| | Unit Tests | Validate math | Line endpoints, circle symmetry | | Integration Tests | Render scenes | Sprite + background test | | Performance Tests | FPS measurement | 30 FPS target |

6.2 Critical Test Cases

  1. Line Clipping: draw lines from off-screen endpoints.
  2. Sprite Off-screen: ensure no memory corruption.
  3. Dirty Update: verify only rectangles are updated.

6.3 Test Data

Sprite: 16x16 checkerboard with transparent border

7. Common Pitfalls & Debugging

7.1 Frequent Mistakes

| Pitfall | Symptom | Solution | |———|———|———-| | No clipping | Random pixels or crashes | Clip at primitive level | | Wrong stride | Diagonal artifacts | Verify row width calculations | | Too many updates | Low FPS | Use dirty rectangles |

7.2 Debugging Strategies

  • Render test patterns (grid, border) to verify math.
  • Log bounding boxes and verify they match actual sprite area.

7.3 Performance Traps

  • Per-pixel function calls can be expensive; inline where possible.

8. Extensions & Challenges

8.1 Beginner Extensions

  • Add a simple color palette for sprites.
  • Draw a scrolling background.

8.2 Intermediate Extensions

  • Implement sprite flipping and rotation.
  • Add simple alpha blending for UI elements.

8.3 Advanced Extensions

  • Add a tile map engine for larger scenes.
  • Implement sprite batching for speed.

9. Real-World Connections

9.1 Industry Applications

  • Embedded dashboards and UI indicators
  • Retro game consoles on microcontrollers
  • LVGL: UI widgets and drawing APIs
  • Adafruit GFX: simple graphics primitives

9.3 Interview Relevance

  • Rasterization and clipping are common graphics interview topics.

10. Resources

10.1 Essential Reading

  • “Computer Graphics from Scratch” - rasterization basics
  • “Making Embedded Systems” - optimization and timing

10.2 Video Resources

  • Rasterization tutorials (Bresenham walkthrough)

10.3 Tools & Documentation

  • Pico SDK examples for framebuffers

11. Self-Assessment Checklist

11.1 Understanding

  • I can explain Bresenham’s line algorithm.
  • I can describe why clipping is required.
  • I can compute framebuffer stride correctly.

11.2 Implementation

  • Sprites move without artifacts.
  • FPS stays stable at target rate.
  • No out-of-bounds writes observed.

11.3 Growth

  • I can explain my rendering loop in an interview.

12. Submission / Completion Criteria

Minimum Viable Completion:

  • Primitives and sprites render correctly.
  • A demo scene animates at 30 FPS.

Full Completion:

  • Dirty rectangles implemented with stable FPS.
  • Clipping validated with off-screen tests.

Excellence (Going Above & Beyond):

  • Tile map engine or sprite batching.
  • Performance report with pixel counts per frame.