Skip to content

Raw graphics

picogame’s C module is also a plain drawing library. A Canvas draws shapes, bitmaps and text into any RGB565 buffer, and a Bitmap wraps any buffer of pixels without copying it. Sprites and Scene are built on top of that, and you do not have to use them.

How you draw depends on where the screen’s pixels live.

  • Framebuffer boards (Fruit Jam and other DVI boards): the whole screen is a buffer in RAM. Put a Canvas on it and draw, the way you would in SDL or Pygame. Everything below the next heading is for these boards.
  • SPI panels (PicoPad and most boards with a small display): the pixels live in the panel, and there is no room for a copy - a 320×240 RGB565 frame is 150 KB, and an RP2040 game has 25-40 KB of free RAM. picogame never keeps a whole frame there; it renders a few rows at a time and sends them to the panel. You still draw in immediate mode, but inside a StripDraw callback that runs once per strip.
import board
import displayio
import picodvi
import picogame as pg
displayio.release_displays()
framebuffer = picodvi.Framebuffer(320, 240, color_depth=16,
clk_dp=board.CKP, clk_dn=board.CKN,
red_dp=board.D0P, red_dn=board.D0N,
green_dp=board.D1P, green_dn=board.D1N,
blue_dp=board.D2P, blue_dn=board.D2N)
screen = pg.Canvas(320, 240, buffer=framebuffer)
def color(r, g, b):
"""RGB565 in the byte order picodvi reads."""
return ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3)

screen now draws into the memory the DVI output is showing. There is no refresh call: a pixel you write is on the screen.

Use color() for every colour on this screen, palettes included. pg.rgb565() returns the byte order SPI panels want, so on a DVI screen its red comes out blue.

screen.clear(color(0, 0, 40))
screen.fill_rect(200, 10, 50, 30, color(255, 0, 0))
screen.fill_circle(290, 25, 15, color(0, 255, 0))
screen.line(200, 50, 310, 50, color(255, 255, 255))

A Canvas also has pixel, rect, circle, ring, triangle, fill_triangle, ellipse, fill_ellipse, fill_round_rect and frame3d, plus batch calls for many triangles or spans at once. The full list is in the reference.

For single pixels you can also write the buffer directly: memoryview(framebuffer) has one item per pixel, so pixels[y * 320 + x] = color(255, 255, 255).

adafruit_imageload reads the file. pg.Bitmap then uses the same pixels, so only the palette is converted:

import array
import adafruit_imageload
def load_image(path):
"""Load an 8-bit (indexed) BMP as a picogame Bitmap."""
bitmap, palette = adafruit_imageload.load(
path,
bitmap=lambda width, height, colors: displayio.Bitmap(width, height, 256),
palette=displayio.Palette)
colors = array.array("H", [0] * 256)
for index in range(len(palette)):
rgb = palette[index]
colors[index] = color(rgb >> 16, (rgb >> 8) & 0xFF, rgb & 0xFF)
pixels = memoryview(bitmap)
return pg.Bitmap(pixels, bitmap.width, bitmap.height, format=pg.PAL8,
palette=colors, stride=len(pixels) // bitmap.height)
picture = load_image("/picture.bmp")
screen.blit(picture, 10, 10)

Save your image as an 8-bit (256-colour) BMP. Most image editors can do that; with Pillow it is Image.open("picture.png").convert("RGB").quantize(256).save("picture.bmp").

blit(bitmap, x, y, frame=0, flip_x=False, flip_y=False) draws a bitmap at any position, clipped to the canvas. To leave one palette colour see-through, pass its index as transparent= when you make the pg.Bitmap.

import terminalio
screen.text(200, 60, "Hello, DVI", color(255, 255, 255), terminalio.FONT)
screen.text(200, 76, "on a box", color(0, 0, 0), terminalio.FONT, color(255, 200, 0))

The arguments are x, y, text, color, font, and an optional background colour as the sixth argument. x, y is the top-left corner. terminalio.FONT is 6×12 pixels per character. This C path only takes the built-in font.

For any other font, adafruit_bitmap_font loads the glyphs and each one becomes a small Bitmap you blit:

from adafruit_bitmap_font import bitmap_font
font = bitmap_font.load_font("/fonts/myfont.pcf")
glyph_cache = {}
def glyph_bitmap(glyph, text_color):
"""Turn one font glyph into a picogame Bitmap with a transparent background."""
width, height = glyph.width, glyph.height
pixels = bytearray(width * height)
for y in range(height):
for x in range(width):
pixels[y * width + x] = glyph.bitmap[x, y]
palette = array.array("H", (0, text_color))
return pg.Bitmap(pixels, width, height, format=pg.PAL8,
palette=palette, transparent=0)
def draw_text(canvas, x, y, text, text_color):
"""Draw text with its baseline at y."""
font.load_glyphs(text)
for char in text:
glyph = font.get_glyph(ord(char))
if glyph is None:
continue
if glyph.width and glyph.height: # a space has an empty bitmap
key = (char, text_color)
if key not in glyph_cache:
glyph_cache[key] = glyph_bitmap(glyph, text_color)
canvas.blit(glyph_cache[key], x + glyph.dx, y - glyph.height - glyph.dy)
x += glyph.shift_x
draw_text(screen, 10, 170, "Proportional text", color(255, 220, 0))

This handles proportional fonts: every glyph is placed by its own offset and advance. Each character is converted once per colour and then comes from glyph_cache.

The same code reads BDF and PCF. Prefer PCF for large fonts: it is a binary format, usually about half the size of the same BDF, and adafruit_bitmap_font reads only the glyphs you ask for. bdftopcf (part of the X.Org tools) converts a BDF to PCF.

A Canvas can draw into any bytearray, and pg.Bitmap turns that bytearray into something you can blit:

panel_pixels = bytearray(120 * 40 * 2) # width * height * 2 bytes (RGB565)
panel = pg.Canvas(120, 40, buffer=panel_pixels)
panel.clear(color(70, 70, 90))
panel.rect(0, 0, 120, 40, color(255, 255, 255))
panel.text(8, 14, "off-screen", color(255, 255, 255), terminalio.FONT)
panel_bitmap = pg.Bitmap(panel_pixels, 120, 40)
screen.blit(panel_bitmap, 190, 190)

Use this to build a panel, a sprite or a whole frame once and stamp it many times. It is a plain bytearray, so your own code - viper, a native module, ulab - can work on the same pixels.

There is no frame loop to hook into: erase what moved, draw it at its new place, repeat.

import time
x = 0
while True:
screen.fill_rect(x, 200, 20, 20, color(0, 0, 40)) # erase the old box
x = (x + 2) % 160
screen.fill_rect(x, 200, 20, 20, color(0, 200, 255)) # draw the new one
time.sleep(1 / 60)

You are drawing into the picture that is on the screen, so a large redraw can show up half done. Compose big changes in an off-screen buffer and blit the result in one call.

On an SPI panel, a Scene renders the screen a strip of rows at a time, and a StripDraw layer calls your function for each strip. Inside it you get a Canvas for that strip and use the same calls as above:

import board
import terminalio
import picogame as pg
display = board.DISPLAY
WIDTH, HEIGHT = display.width, display.height
STRIP_HEIGHT = 8
scene = pg.Scene(display,
bytearray(WIDTH * STRIP_HEIGHT * 2), bytearray(WIDTH * STRIP_HEIGHT * 2))
ball_x = 0
def draw(view, strip_x, strip_y, strip_width, strip_height):
# view covers screen rows strip_y .. strip_y + strip_height - 1,
# so subtract strip_y from every screen y
view.clear(pg.rgb565(0, 0, 40))
view.fill_circle(ball_x, HEIGHT // 2 - strip_y, 20, pg.rgb565(0, 255, 0))
view.text(10, 10 - strip_y, "Hello", pg.rgb565(255, 255, 255), terminalio.FONT)
scene.add(pg.StripDraw(draw, 0, 0, WIDTH, HEIGHT))
while True:
ball_x = (ball_x + 2) % WIDTH
scene.refresh()

Here colours come from pg.rgb565(), since this is the byte order SPI panels use. Anything that does not fit in one strip - an image, a long line of text - is simply drawn again, clipped, in the next strip.

Your function runs for every strip, so keep it to drawing. Work out positions and game state once per frame, before scene.refresh().

A StripDraw repaints on every refresh, which is right for content that moves. For content that changes only now and then, create it with always_dirty=False and call its invalidate() after a change; the rest of the time the scene skips it.

Everything above is the whole C module seen from below. Scene, Sprite, Tilemap and the picogame_* helper libraries add redrawing only what changed, collisions, animation, input and text widgets. How picogame works and Drawing paths show how they fit together.