Everything that talks to the accelerometer or the capacitive touch pads is fairly vanilla Arduino code and easily figured out. The candle flicker effect of the backlight was derived from the Circuit Playground Jack-o’-Lantern guide. Let’s talk about graphics!
The code that makes this work derives heavily from a prior project — the Electronic Animated Eyes — which similarly presents full-screen smooth animation.
If you’ve worked with the Adafruit GFX library you’ve seen that it tends to be very “pixel-y”…that it takes time to draw and erase objects and there are very visible artifacts when trying to show animation. This is necessary for a couple reasons: that library has to work within the limited memory constraints of 8-bit AVR microcontrollers, and also it presents graphical objects as persistent — old graphics remain on the display as new graphics are overlaid.
The spirit board and eye code take a different approach that’s only practical with the speed and RAM of 32-bit SAMD microcontrollers: the entire screen, every pixel, is computed and updated for every frame of animation. The normal display graphics library is still used to initialize the hardware and to specify a “window” of pixels to be updated (the full screen)…but after that the code goes in a very different direction, there are no drawPixel() or even drawBitmap() calls…
These two images — the spirit board and planchette — are stored in flash memory:
To do this, they had to be converted into big arrays (in the “graphics.h” file), using some Python code (shown later) to read the compressed PNG images and reorganize this into a list of unsigned 32-bit integers, uncompressed but “bit packed” — 2 bits per pixel (4 possible colors in each image), so each integer value contains 16 pixels:
const uint32_t planchetteData[] = { 0x55555555, 0x55555555, 0x51515555, 0x44414441, 0x51111414, 0x55555555, 0x55555555, 0x55555555, 0x55555555, 0x55555555, 0x45155155, 0x11141114, 0x04444101, 0x55544440, 0x55555555, 0x55555555, 0x55555555, 0x55555555, ... etc.
Stored this way, the board uses about 110K of program flash space, planchette about 4K (out of 256K available on the chip).
At the bottom of the graphics.h file are two color palettes, each containing four elements (since there are two bits…four possible values…per pixel):
const uint16_t boardPalette[] = { 0x6531, 0x2F8C, 0x3BEF, 0x99DE }, planchettePalette[] = { 0x0000, 0x0000, 0x699C, 0x8562 };
Though color palettes are sometimes present as a graphics hardware feature, in this case they’re just look-up tables that our code uses to expand each 2-bit pixel value to a 16-bit color value (5 bits red + 6 bits green + 5 bits blue) that will be issued to the display. (Scrolling and overlays are also sometimes graphics hardware features…but in this case again we’re doing these effects all on the CPU…the ST7735 display driver used in the Hallowing screen is fairly basic.)
The substance of the drawing code…the drawFrame() function, starting around line 309 in Hallowing_Spirit_Board.ino…works through every pixel of the display, processing scanlines from top to bottom, and on each scanline processing columns from left to right.
For each pixel of the screen the code first looks up the corresponding pixel in the planchette graphics array. This is relatively straightforward as the planchette image is exactly the same size as the screen…pixel (x, y) on the screen is pixel (x, y) in the image. Some bit-shifting and -masking is necessary as the graphics are stored in a 2-bits-per-pixel format…producing an index from 0 to 3.
If the planchette pixel value is 1, 2 or 3, this is used as an index into the planchettePalette[] array…the corresponding 16-bit value is read from the array and added to a one-scanline memory buffer which will later be issued to the display. These pixel appear opaque.
If the planchette pixel value is 0, we disregard the planchette palette and do a second 2-bit look-up in the board graphics array (some additional bit shenanigans are required since we’re extracting just a section of this image, it’s not pixel-aligned like the planchette), then retrieve the corresponding 16-bit value from the boardPalette[] array to add to the display buffer. This is how we get the transparent overlay effect.
The math alone would clobber lesser microcontrollers, but the real SECRET SAUCE THAT MAKES THIS ALL WORK is direct memory access or DMA. This is a capability of current devices like the SAMD21 microcontroller at the heart of Hallowing that allows peripherals (such as our LCD display on the SPI bus) to communicate directly with memory without the CPU’s intervention at every step…one sets up an operation (basically “send this buffer of data over SPI”), initiates the transfer, and it then proceeds in the background. Meanwhile, we still have 100% full use of every cycle of the CPU. In the past we’d have to stop processing to handle the transfer, but now this is “free time.” I like to say this chip has a good walking to chewing gum ratio — it can do a lot at once.
DMA can be really difficult to set up properly but we’ve taken care of a lot of the dirty work into our Adafruit_ZeroDMA library.
What we do then is render one scanline in RAM while the prior scanline transfers over SPI using DMA concurrently, then switch the render/transfer indexes between lines. This way, we don’t need to buffer the entire image in RAM, only two scanlines’ worth…just 512 bytes! If we can balance the render time and transfer time to operate concurrently, it’s just as fast as doing the SPI transfers alone, like having one big image that we’re simply sending out the SPI bus.
More Shenanigans
Because the SAMD21 is little-endian (16- and 32-bit values are stored least significant byte first) but the ST7735 SPI interface expects big-endian data (most significant byte first), 16-bit colors are stored “pre-swapped” in the palettes to avoid having to do this operation on every pixel.
16-bit colors destined for the ST7735 driver contain 5 bits red data, 6 bits green and 5 bits blue. For example, solid red would be represented binarily as 0b1111100000000000. In hexadecimal notation, that’s 0xF800. But the color palette format used in this code would instead use 0x00F8…the high and low bytes are reversed. It would be a minor operation to do that swap in code, perhaps a single cycle per pixel, but by swapping bytes when they go in the table we get this operation for free.
The format of the packed pixel data is also a little unconventional. For each 32-bit value (representing a span of 16 pixels, at 2 bits per pixel), it’s fairly common in graphics coding to have the most significant bits represent the leftmost pixel…for example, a single 2-bit pixel with an index of “1” followed by 15 “0” pixels would be represented in hexadecimal notation as 0x40000000. But this code stores the pixels within each value in the reverse order…not the bits, but each 2-bit value…so the most significant two bits are the rightmost pixel within that 16-pixel span, making the prior example instead 0x00000001. Formatting the data this way seemed to make the rendering code a little simpler and might avoid some shift and multiply operations. This might be wrong, I haven’t actually benchmarked different approaches, just call it a hunch.
If you want to have a go at making your own (a “Luigi board” perhaps?), here’s the Python code that generated the tables in graphics.h. This was run on my regular desktop machine…not the Hallowing board or any other CircuitPython device…just regular Python, command-line style. The output was redirected to a file and manually cleaned up a bit before merging into the Arduino sketch.
This script does not generate the color palettes, though I suppose it could. Instead I just entered those manually by querying the color palette entries in Photoshop and doing the 5/6/5 color reduction and byte-swap operations myself. Note also the normally-commented-out “p = pixels[x, y] ^ 1” line, which is there to reorder the color palette indices when converting the planchette image (so index 0 will be the transparent color). That’s the thing with one-off disposable code like this…sometimes you just have to cobble in a solution, it’s not meant to be elegant or performant.
If you change the graphics significantly, you’ll need to update the coordinates of each letter and number in the coord[] array messages.h. The script doesn’t do this nor do I have an automatic technique…it was just a tedious matter of typing in the cursor coordinates (from Photoshop’s “Info” palette) for each character’s center pixel.
#!/usr/bin/python # 2-bit image converter -- generates PROGMEM arrays for Arduino sketches # from PNG or GIF images. Requires PIL or Pillow library. This is NOT a # general-purpose image-to-array converter, it's fairly specific to the # 2-bit Spirit Board project and DOES NOT handle other image cases! import sys from PIL import Image from os import path # FORMATTED HEX OUTPUT ----------------------------------------------------- class HexTable: # Initialize counters, etc. for write() function below def __init__(self, count, columns=12, digits=2): self.hexLimit = count # Total number of elements in array self.hexCounter = 0 # Current array element number 0 to hexLimit-1 self.hexDigits = digits # Digits per array element (after 0x) self.hexColumns = columns # Max number of elements before line wrap self.hexColumn = columns # Current column number, 0 to hexColumns-1 # hexColumn is initialized to columns to force first-line indent # Write hex value (with some formatting for C array) to stdout def write(self, n): if self.hexCounter > 0: sys.stdout.write(",") # Comma-delimit prior item if self.hexColumn < (self.hexColumns - 1): # If not last item on line, sys.stdout.write(" ") # append space after comma self.hexColumn += 1 # Increment column number if self.hexColumn >= self.hexColumns: # Max column exceeded? sys.stdout.write("\n ") # Line wrap, indent self.hexColumn = 0 # Reset column number sys.stdout.write("{0:#0{1}X}".format(n, self.hexDigits + 2)) self.hexCounter += 1 # Increment item counter if self.hexCounter >= self.hexLimit: print(" };\n"); # Cap off table # IMAGE CONVERSION --------------------------------------------------------- def convertImage(filename): prefix = path.splitext(path.split(filename)[1])[0] im = Image.open(filename) if im.mode == 'P': # PALETTED IMAGE (assume 2-bit for now) # 16 pixels per 32 bit value pixels = im.load() hex = HexTable(((im.size[0] + 15) / 16) * im.size[1], 6, 8) sys.stderr.write("Image OK\n") sys.stdout.write( "#define %s_WIDTH %d\n" "#define %s_HEIGHT %d\n\n" "const uint32_t %sData[] = {" % (prefix.upper(), im.size[0], prefix.upper(), im.size[1], prefix)) for y in range(im.size[1]): bits = 0 sum = 0 for x in range(im.size[0]): p = pixels[x, y] # I wanted white to be index 0 in the planchette image but # Photoshop had other plans on export. Soooo I manually # fiddle with the palette index here in that case... #p = pixels[x, y] ^ 1 sum += p << bits bits += 2 if bits >= 32: hex.write(sum) bits = 0 sum = 0 if bits > 0: # Scanline pad hex.write(sum) for i, filename in enumerate(sys.argv): # Each argument... if i == 0: continue # Skip first argument; is program name convertImage(filename)
Page last edited March 08, 2024
Text editor powered by tinymce.