rgbxmas

2022-12-21 ยท 21 min read

setup #

First, plug the USB cable into your machine (macOS, Windows, Linux, whatever). The board should power up and show the little demo; press the UP button to go to the next screen.

On your machine, a USB-drive called CIRCUITPY should also pop up. This is the Adafruit Matrix Portal set up with CircuitPython ready for programming.

The Matrix Portal's got lots of features and room for other peripherals and sensors. it can even connect to stuff on the internet, so you can make a weather display, read some tweets, show the bitcoin price... : )

In the CIRCUITPY directory, there should be a code.py file. This contains the code for the demo, which is also listed below, see: demo code.

CircuitPython makes it super painless to write code for little microcontroller boards like this. It ain't gonna be the fastest, but it's pretty easy to get stated. To get some basic code going, you literally just edit the code.py file, save it, and the board will auto-restart with the new updates.

I'd probably start with the Mu editor here: https://codewith.mu/en/download. It's bare-bones, but it'll work in the beginning.

I've already setup the board so everything should be good to go. With the board plugged in, just open the Mu editor, press the "Mode" icon, and select "CircuitPython". Then you should be able to edit the code, save, and see the changes appear on the device.

Also check out Adafruit's MatrixPortal overview: https://learn.adafruit.com/adafruit-matrixportal-m4/overview

and the CircuitPython Essentials guide: https://learn.adafruit.com/circuitpython-essentials/circuitpython-essentials

and the detailed CircuitPython core modules documentation: https://docs.circuitpython.org/en/latest/shared-bindings/index.html

Here's a basic example to get started: it blinks an LED on the board itself (not the display) on/off every 0.5 seconds.

# file: code.py

import board
import digitalio
import time

# This a the little red LED on the MatrixPortal m4 board,
# not the big LED matrix panel. It's located right above
# the "adafruit" logo.
led = digitalio.DigitalInOut(board.LED)
led.direction = digitalio.Direction.OUTPUT

# This block loops endlessly
while True:
    # Turn on the LED, then wait for 0.5 seconds
    led.value = True
    time.sleep(0.5)

    # Turn off the LED, then wait for 0.5 seconds
    led.value = False
    time.sleep(0.5)

getting help from the REPL #

The panel opened by the "Serial" button doesn't just show the print output of the device, but also let's you interact with it. This is called a REPL (Read-Evaluate-Print-Loop) and lets you quickly play with bits of code or explore different modules.

To enter the REPL:

If your program has finished, you should see this message:

Auto-reload is on. Simply save files over USB to run them or enter REPL to disable.
code.py output:

Code done running.

Press any key to enter the REPL. Use CTRL-D to reload.

In this case just press the "Enter" key in the Serial box and you should see the REPL pop up:

Adafruit CircuitPython 7.3.3 on 2022-08-29; Adafruit Matrix Portal M4 with samd51j19
>>>

If some code is running (like the infinite blink example above), you may need to hit CTRL-C to stop the program first (though it seems a bit flaky, I had to hold the keys down for a second or two to get it to work).

With the REPL open, we can see all the available built-in modules by typing "import " (with the space) then hitting the TAB key to auto-complete:

>>> import ^TAB
builtins        micropython     __future__      _asyncio
_bleio          adafruit_bus_device
adafruit_bus_device.i2c_device  adafruit_bus_device.spi_device
adafruit_pixelbuf               alarm           analogio
array           atexit          audiobusio      audiocore
audioio         audiomixer      audiomp3        binascii
bitbangio       bitmaptools     board           busio
collections     countio         digitalio       displayio
errno           fontio          framebufferio   frequencyio
gc              getpass         gifio           i2cperipheral
io              json            keypad          math
microcontroller                 msgpack         neopixel_write
onewireio       os              ps2io           pulseio
pwmio           rainbowio       random          re
rgbmatrix       rotaryio        rtc             samd
sdcardio        select          storage         struct
supervisor      synthio         sys             terminalio
time            touchio         traceback       usb_cdc
usb_hid         usb_midi        uselect         vectorio
watchdog        zlib

Don't worry about what most of these do for now.

If you want to get very very detailed documentation about these modules, you can check the CircuitPython Modules reference.

Let's check out the time module we used in the previous example:

>>> import time
>>> help(time)
object <module 'time'> is of type module
  __name__ -- time
  monotonic -- <function>
  sleep -- <function>
  struct_time -- <class 'struct_time'>
  localtime -- <function>
  mktime -- <function>
  time -- <function>
  monotonic_ns -- <function>

We can get the number of seconds since the board powered on by calling time.monotonic():

>>> time.monotonic()
1003.53

We use this one a lot in the demo.

From the REPL we can define functions and variables just like we expect:

>>> def f(x):
	# f(x) = 4x^2 - 4x - 24
	x_squared = x**2
    return 4*x_squared - 4*x - 24
>>> f(0)
-24
>>> print("wow look f(3) is zero:", f(3))

We can exit the REPL again by hitting CTRL-D.

displaying text #

This example requires the adafruit_display_text and adafruit_matrixportal libraries, which are not built-in modules. Most interesting libraries are not built-in, as they are updated more frequently than the base modules and would otherwise take up too much space on the device.

To install these community libraries, go to https://circuitpython.org/libraries and download the zip file under "Bundle for Version 7.X" (I've installed CircuitPython version 7.3.3).

Opening the adafruit-circuitpython-bundle-7.x.zip file there should be a lib folder, with the adafruit_display_text and adafruit_matrixportal folders inside (among many others).

Copy the those two folders from the .zip file to the mounted CIRCUITPY/lib/ folder to install the library.

import terminalio

from adafruit_display_text import label
from adafruit_matrixportal.matrix import Matrix

# This call creates the Matrix display object.
#
# `bit_depth` can range from 1 to 6; higher numbers allow
# for more color shades to be displayed, but increase memory
# usage and slow down your Python code.
#
# If you just want to show primary colors
# plus black and white, use 1. Otherwise, try 3, 4 and 5
# to see which effect you like best.
matrix = Matrix(
    bit_depth=4,
)
display = matrix.display

# this is a text area with two lines like
# |       Hello       |
# |       World       |
text_area = label.Label(
    terminalio.FONT,
    text="Hello\nWorld", # '\n' stands for newline
    color=0x113311, # this is the RGB (Red Green Blue) color
)
# this is the starting position of the text on the board,
# counting pixels from the top-left.
text_area.x = 16
text_area.y = 8
display.show(text_area)

# You can add more effects in this loop. For instance, maybe
# you want to set the color of each label to a different
# value
while True:
    display.refresh()

demo code #

This demo shows a few screens with some cool graphics.

Note that it requires the adafruit_display_text library is copied into the CIRCUITPY/lib/ directory. See displaying text for more details on libraries.

# code.py

import random
import time
import math
from math import trunc, sin, cos, sqrt

import board
import countio
import digitalio
import displayio
import framebufferio
import rgbmatrix
import terminalio
import vectorio

from adafruit_display_text import label

# create a new `Display` object for the big RGB matrix display
def make_display(bit_depth=6, brightness=0.5):
    displayio.release_displays()
    matrix = rgbmatrix.RGBMatrix(
        width=64,
        height=32,
        bit_depth=bit_depth,
        # NOTE: for some reason the board has reversed the green and blue pins,
        # so we just swap them here to get standard RGB ordering
        rgb_pins=[
            board.MTX_R1,
            board.MTX_B1,
            board.MTX_G1,
            board.MTX_R2,
            board.MTX_B2,
            board.MTX_G2,
        ],
        addr_pins=[
            board.MTX_ADDRA,
            board.MTX_ADDRB,
            board.MTX_ADDRC,
            board.MTX_ADDRD,
        ],
        clock_pin=board.MTX_CLK,
        latch_pin=board.MTX_LAT,
        output_enable_pin=board.MTX_OE
    )
    display = framebufferio.FramebufferDisplay(matrix)
    display.brightness = brightness
    return display

def rgb_to_int(r, g, b):
    return (r << 16) | (g << 8) | b

def percent_to_byte(t):
    return round(t * 255.0) & 255

def hue_to_rgb_ratio(p, q, t):
    if t < 0.0:
        t += 1.0
    elif t > 1.0:
        t -= 1.0

    if t < 0.166667:
        return p + (q - p) * 6.0 * t
    elif t < 0.5:
        return q
    elif t < 0.666667:
        return p + (q - p) * (0.666667 - t) * 6.0
    else:
        return p

# convert Hue-Saturation-Lightness to RGB color
# `h` is the Hue, in degrees (0 -> 360)
# `s` is the Saturation, between 0.0 and 1.0
# `l` is the Lightness, between 0.0 and 1.0
def hsl(h, s, l):
    # all greys if saturation is 0
    if s == 0.0:
        l = percent_to_byte(l)
        return rgb_to_int(l, l, l)

    h = h / 360.0

    q = 0.0
    if l < 0.5:
        q = l * (1.0 + s)
    else:
        q = l + s - (l * s)
    p = 2.0 * l - q

    r = percent_to_byte(hue_to_rgb_ratio(p, q, h + 0.333333))
    g = percent_to_byte(hue_to_rgb_ratio(p, q, h))
    b = percent_to_byte(hue_to_rgb_ratio(p, q, h - 0.333333))

    return rgb_to_int(r, g, b)

# the fractional part of a float. ex: `frac(1.75) == 0.75`.
def frac(x):
    return x - trunc(x)

# clamp `x` so the output is always between `x_min` and `x_max`.
def clamp(x_min, x_max, x):
    return min(x_max, max(x_min, x))

# linearly interpolate between `start` and `end`, using `t` between 0.0 and 1.0
# as the "interpolation distance"
def lerp(start, end, t):
    return t * start + (1.0 - t) * end

# saw-tooth signal, between y = 0.0 and 1.0, with period = 1.0
def saw(t):
    return math.fabs(2.0 * frac(t) - 1.0)

# exponential ease in-out function
# see: <https://easings.net/#easeInOutExpo>
# this is defined for x between 0.0 and 1.0
def ease_in_out_exp(x):
    if x < 0.5:
        return 0.5 * math.pow(2.0, 20.0 * x - 10.0)
    else:
        return 1.0 - 0.5 * math.pow(2.0, -20.0 * x + 10.0)

# see: <https://mathworld.wolfram.com/SincFunction.html>
def sinc(x):
    a = math.pi * x
    return sin(a) / (a+0.001)

def parabola(x, k):
    return math.pow(4.0 * x * (1.0 - x), k)

class Screen:
    def __init__(self, display):
        pass

    def update(self, display):
        pass

# shows "PRESS UP >>", with the ">>" bouncing every few seconds.
class StartScreen(Screen):
    def __init__(self, display):
        group = displayio.Group()

        # the "PRESS UP" text
        press_up = label.Label(terminalio.FONT, text="PRESS UP")
        press_up.x = 4
        press_up.y = 14
        group.append(press_up)

        # the two '>' arrows on the right
        arrow_group = displayio.Group()

        arrow_right_1 = label.Label(terminalio.FONT, text=">")
        arrow_right_1.x = 52
        arrow_right_1.y = 14
        arrow_group.append(arrow_right_1)

        arrow_right_2 = label.Label(terminalio.FONT, text=">")
        arrow_right_2.x = 54
        arrow_right_2.y = 14
        arrow_group.append(arrow_right_2)
        group.append(arrow_group)

        display.show(group)

        self.arrow_group = arrow_group

    def update(self, display):
        t = time.monotonic()

        #  self.arrow_group.x = round(2.0 * ease_in_out_exp(0.5 * sin(3.0 * t) + 0.5) - 4.0)

        #  t = math.fmod(t, 5.0)
        #  self.arrow_group.x = round(8.0 * sinc(0.54 * (t - 2.5)))

        # repeat "bounce" every 3 sec
        t = math.fmod(t, 3.0)
        self.arrow_group.x = round(4.0 - 4.0 * parabola(0.333333 * t, 7))

        display.refresh()

# makes a big circle wave across the screen in a loop
class CircleScreen(Screen):
    def __init__(self, display):
        group = displayio.Group()

        palette = displayio.Palette(2)
        palette[0] = hsl(215, 0.42, 0.34)
        palette[1] = hsl(0, 0, 0.0)

        circle_outer = vectorio.Circle(
            pixel_shader=palette,
            radius=16,
            x=0,
            y=0,
            color_index=0,
        )
        group.append(circle_outer)

        circle_inner = vectorio.Circle(
            pixel_shader=palette,
            radius=15,
            x=0,
            y=0,
            color_index=1,
        )
        group.append(circle_inner)
        display.show(group)

        self.palette = palette
        self.group = group

    def update(self, display):
        t = time.monotonic()

        # make the circle wave across the screen
        self.group.x = round(math.fmod(64.0 * t, 110.0) - 25.0)
        self.group.y = int(8.0 * sin(3.0 * t)) + 16

        # slowly rotate the hue of the circle over time
        t_color = 0.5 * sin(1.0 * t) + 0.5
        hue = lerp(0.0, 180.0, t_color)
        self.palette[0] = hsl(hue, 0.75, 0.50)

        display.refresh()

# test the display's color
class ColorTestScreen(Screen):
    def __init__(self, display):
        width = 64
        height = 32
        num_colors = 256

        # start with all greyscale
        palette = displayio.Palette(num_colors)
        for idx in range(num_colors):
            palette[idx] = rgb_to_int(idx, idx, idx)

        # fill the bitmap with 0..=255
        bm = displayio.Bitmap(width, height, num_colors)
        for y in range(height):
            for x in range(width):
                # use the same color every 8 pixels in the y-direction
                color_idx = (y // 8) * width + x
                bm[x, y] = color_idx
        bm.dirty()

        tg = displayio.TileGrid(bm, pixel_shader=palette)

        group = displayio.Group()
        group.append(tg)

        display.show(group)

        self.num_colors = num_colors
        self.palette = palette
        self.prev_update_t = time.monotonic()

    def update(self, display):
        t = time.monotonic()
        dt = t - self.prev_update_t

        # every 5 sec change the color spectrum to a random hue and saturation
        # with lightness evenly between 0.0 and 1.0
        if dt > 5.0:
            num_colors = self.num_colors
            hue = random.randint(0, 360)
            saturation = random.random()
            print(f"hue: {hue}, saturation: {saturation}")

            for idx in range(num_colors):
                self.palette[idx] = hsl(hue, saturation, 0.5 * (idx / num_colors))

            self.prev_update_t = t
            display.refresh()

# run a random [Conway's Game of Life](https://www.wikiwand.com/en/Conway%27s_Game_of_Life)
# simulation every ~30 sec
class GameOfLifeScreen(Screen):
    def __init__(self, display):
        width = 64
        height = 32
        num_colors = 2

        palette = displayio.Palette(num_colors)

        bm1 = displayio.Bitmap(width, height, num_colors)
        bm2 = displayio.Bitmap(width, height, num_colors)

        tg1 = displayio.TileGrid(bm1, pixel_shader=palette)
        tg2 = displayio.TileGrid(bm2, pixel_shader=palette)

        g1 = displayio.Group()
        g1.append(tg1)

        g2 = displayio.Group()
        g2.append(tg2)

        display.show(g1)

        self.width = width
        self.height = height
        self.iter = 0
        self.reset_after = 200

        self.curr = bm1
        self.next = bm2

        self.curr_group = g1
        self.next_group = g2

        self.palette = palette

    def randomize(self):
        curr = self.curr
        for idx in range(self.width * self.height):
            curr[idx] = random.random() < 0.333333

        #  hue = random.randint(0, 360)
        #  saturation = (random.random() / 4.0) + 0.5
        #  lightness = (random.random() / 3.0) + 0.1
        #  self.palette[1] = hsl(hue, saturation, lightness)

    def apply_life_rule(self):
        curr = self.curr

        w = self.width
        h = self.height

        for y in range(h):
            yyy = y * w
            ym1 = ((y + h - 1) & 31) * w
            yp1 = ((y + 1) & 31) * w

            xm1 = w - 1
            for x in range(w):
                xp1 = (x + 1) & 63
                neighbors = (
                    curr[xm1 + ym1] + curr[xm1 + yyy] + curr[xm1 + yp1] +
                    curr[  x + ym1] +                   curr[  x + yp1] +
                    curr[xp1 + ym1] + curr[xp1 + yyy] + curr[xp1 + yp1]
                )
                self.next[x + yyy] = (neighbors == 3) or ((neighbors == 2) and curr[x + yyy])
                xm1 = x

        self.curr = self.next
        self.next = curr

        curr_group = self.curr_group
        self.curr_group = self.next_group
        self.next_group = curr_group

    def update(self, display):
        t = time.monotonic()

        # slowly change the color
        t_color = 0.5 * sin(1.0 * t) + 0.5
        hue = lerp(0.0, 180.0, t_color)
        self.palette[1] = hsl(hue, 0.75, 0.50)

        # reset and randomize the state every `self.reset_after` iterations
        if self.iter == 0:
            self.randomize()
        else:
            self.apply_life_rule()

        self.iter = (self.iter + 1) % self.reset_after
        display.show(self.curr_group)

# a cool wavy pattern
class WarpScreen(Screen):
    def __init__(self, display):
        width = 64
        height = 32
        num_colors = 64

        palette = displayio.Palette(num_colors)
        for idx in range(num_colors):
            #  palette[idx] = hsl(240, 0.8, 0.25 * (idx / num_colors))
            palette[idx] = hsl(265, 0.85, 0.95 * (idx / num_colors) + 0.05)

        bm1 = displayio.Bitmap(width, height, num_colors)
        bm2 = displayio.Bitmap(width, height, num_colors)

        tg1 = displayio.TileGrid(bm1, pixel_shader=palette)
        tg2 = displayio.TileGrid(bm2, pixel_shader=palette)

        g1 = displayio.Group()
        g1.append(tg1)

        g2 = displayio.Group()
        g2.append(tg2)

        #  display.show(g1)

        self.width = width
        self.height = height
        self.num_colors = num_colors

        self.curr = bm1
        self.next = bm2

        self.curr_group = g1
        self.next_group = g2

    def update(self, display):
        width = self.width
        height = self.height
        num_colors = self.num_colors
        curr = self.curr
        next = self.next

        # render to next framebuffer while current one is shown

        t = time.monotonic()

        for y in range(height):
            for x in range(width):
                tx = 20.0 * sin(0.01 * x) + 0.2 * t
                ty = 10.0 * sin(0.01 * y) + 0.2 * t
                l = (0.5 * sin(tx) * sin(ty)) + 0.5
                next[x, y] = round((num_colors - 1) * l)

        # swap frame buffers

        self.curr = next
        self.next = curr

        curr_group = self.curr_group
        self.curr_group = self.next_group
        self.next_group = curr_group

        # show new framebuffer

        display.show(self.curr_group)


def noise(u, v):
    return sin(u) * sin(v)

# [ [ 0.8 -0.5 ]
#   [ 0.5  0.8 ] ] dot [p_x, p_y] * 2
def mdp(x, y):
    return ((1.6 * x) - (1.0 * y), (1.0 * x) + (1.6 * y))

def fbm4(x, y):
    f = 0.0
    f += 0.5000 * noise(x, y); (x, y) = mdp(x, y)
    f += 0.2500 * noise(x, y); (x, y) = mdp(x, y)
    f += 0.1250 * noise(x, y); (x, y) = mdp(x, y)
    f += 0.0625 * noise(x, y)
    return f / 0.9375

# another cool pattern that rotates a bit
# uses the same `sin(x)*sin(y)` but run through a few octaves of modified
# [Fractional Brownian Motion](https://www.wikiwand.com/en/Fractional_Brownian_motion)
class Warp2Screen(Screen):
    def __init__(self, display):
        width = 64
        height = 32
        num_colors = 64

        palette = displayio.Palette(num_colors)
        for idx in range(num_colors):
            #  palette[idx] = hsl(240, 0.8, 0.25 * (idx / num_colors))
            palette[idx] = hsl(15, 0.8, 0.6 * (idx / num_colors))

        bm1 = displayio.Bitmap(width, height, num_colors)
        bm2 = displayio.Bitmap(width, height, num_colors)

        tg1 = displayio.TileGrid(bm1, pixel_shader=palette)
        tg2 = displayio.TileGrid(bm2, pixel_shader=palette)

        g1 = displayio.Group()
        g1.append(tg1)

        g2 = displayio.Group()
        g2.append(tg2)

        #  display.show(g1)

        self.width = width
        self.height = height
        self.num_colors = num_colors

        self.curr = bm1
        self.next = bm2

        self.curr_group = g1
        self.next_group = g2

    def update(self, display):
        width = self.width
        height = self.height
        num_colors = self.num_colors
        curr = self.curr
        next = self.next

        # render to next framebuffer while current one is shown

        t = time.monotonic()

        for y in range(height):
            for x in range(width):
                u = (x - width) / height
                v = (y - height) / height

                # add some back-and-forth rotation
                coa = cos(0.10 * sin(0.15 * t))
                sia = sin(0.31 * sin(0.10 * t))
                u = (u * coa) - (v * sia)
                v = (u * sia) + (v * coa)

                u += 0.03 * sin(0.54 * t)
                v += 0.03 * sin(0.46 * t)

                # kinda fractional brownian motion over sin(x)*sin(y)
                l = 0.5 * fbm4(8.0 * u, 8.0 * v) + 0.5

                # make the lightness periodically show the dark spots
                c = 0.1 * cos(0.2 * t) + 0.2
                l = clamp(0.0, 1.0, l - c)

                next[x, y] = round((num_colors - 1) * l)

        # swap frame buffers

        self.curr = next
        self.next = curr

        curr_group = self.curr_group
        self.curr_group = self.next_group
        self.next_group = curr_group

        # show new framebuffer

        display.show(self.curr_group)

# ||v||^2
def norm_sq(x, y, z):
    return x*x + y*y + z*z

# ||v||
def norm(x, y, z):
    return sqrt(norm_sq(x, y, z))

# v / ||v||
def normalize(x, y, z):
    inv_l = 1.0 / norm(x, y, z)
    return (inv_l * x, inv_l * y, inv_l * z)

# a * b
def dot(a_x, a_y, a_z, b_x, b_y, b_z):
    return a_x*b_x + a_y*b_y + a_z*b_z

# a x b
def cross(bx, by, bz, cx, cy, cz):
    return (by*cz - bz*cy, bz*cx - bx*cz, bx*cy - by*cx)

# sphere hit
def i_sphere(ro_x, ro_y, ro_z, rd_x, rd_y, rd_z, sph_x, sph_y, sph_z):
    oc_x = ro_x - sph_x
    oc_y = ro_y - sph_y
    oc_z = ro_z - sph_z

    b = dot(oc_x, oc_y, oc_z, rd_x, rd_y, rd_z)
    c = norm_sq(oc_x, oc_y, oc_z) - 1.0
    h = b*b - c

    if h < 0.0:
        return -1.0
    else:
        return -b - sqrt(h)

# sphere soft shadow
def ss_sphere(ro_x, ro_y, ro_z, rd_x, rd_y, rd_z, sph_x, sph_y, sph_z):
    oc_x = sph_x - ro_x
    oc_y = sph_y - ro_y
    oc_z = sph_z - ro_z

    b = dot(oc_x, oc_y, oc_z, rd_x, rd_y, rd_z)

    if b > 0.0:
        h = norm_sq(oc_x, oc_y, oc_z) - b*b - 1.0
        return clamp(0.0, 1.0, 16.0 * h / b)
    else:
        return 1.0

# sphere lambert shading
def lambert(lig_x, lig_y, lig_z, nor_x, nor_y, nor_z):
    return max(0.0, dot(lig_x, lig_y, lig_z, nor_x, nor_y, nor_z))

# sphere occlusion
def occ_sphere(pos_x, pos_y, pos_z, nor_x, nor_y, nor_z, sph_x, sph_y, sph_z):
    di_x = sph_x - pos_x
    di_y = sph_y - pos_y
    di_z = sph_z - pos_z

    l_sq = norm_sq(di_x, di_y, di_z)
    inv_l = 1.0 / math.sqrt(l_sq)
    di_x = di_x * inv_l
    di_y = di_y * inv_l
    di_z = di_z * inv_l

    nd = dot(nor_x, nor_y, nor_z, di_x, di_y, di_z)

    return 1.0 - (max(0.0, nd) / l_sq)

# this screen is a small [raytracer](https://www.wikiwand.com/en/Ray_tracing_(graphics))
# that renders two spheres with soft shadows and ambient occlusion, while
# rotating the camera around them.
class SpheresScreen(Screen):
    def __init__(self, display):
        width = 64
        height = 32
        num_colors = 64

        palette = displayio.Palette(num_colors)
        for idx in range(num_colors):
            #  palette[idx] = hsl(240, 0.8, 0.25 * (idx / num_colors))
            #  palette[idx] = hsl(lerp(240, 260, idx / num_colors), 1.0, 1.0 * (idx / num_colors))
            palette[idx] = hsl(220, 0.9, 1.0 * (idx / num_colors))

        bm1 = displayio.Bitmap(width, height, num_colors)
        bm2 = displayio.Bitmap(width, height, num_colors)

        tg1 = displayio.TileGrid(bm1, pixel_shader=palette)
        tg2 = displayio.TileGrid(bm2, pixel_shader=palette)

        g1 = displayio.Group()
        g1.append(tg1)

        g2 = displayio.Group()
        g2.append(tg2)

        #  display.show(g1)

        self.width = width
        self.height = height
        self.num_colors = num_colors

        self.curr = bm1
        self.next = bm2

        self.curr_group = g1
        self.next_group = g2

    def update(self, display):
	    width = self.width
        height = self.height
        num_colors = self.num_colors
        curr = self.curr
        next = self.next

        # render to next framebuffer while current one is shown

        t = time.monotonic()

        # all i want for christmas is numpy...
        # linear algebra heavy stuff is pretty obnoxious in plain python

        # light direction
        (lig_x, lig_y, lig_z) = normalize(0.6, 0.5, 0.4)

        # rotate camera around the center over time
        angle = -0.1*t
        #  angle = -.25*t
        ro_x = 2.5 * cos(angle)
        ro_y = 1.0
        ro_z = 2.5 * sin(angle)

        # camera matrix
        #
        # M = [ [ ww_x, uu_x, vv_x ]
        #       [ ww_y, uu_y, vv_y ]
        #       [ ww_z, uu_z, vv_z ] ]
        ww_x = 0.0 - ro_x
        ww_y = 1.0 - ro_y
        ww_z = 0.0 - ro_z
        (ww_x, ww_y, ww_z) = normalize(ww_x, ww_y, ww_z)

        (uu_x, uu_y, uu_z) = cross(ww_x, ww_y, ww_z, 0.0, 1.0, 0.0)
        (uu_x, uu_y, uu_z) = normalize(uu_x, uu_y, uu_z)

        (vv_x, vv_y, vv_z) = cross(uu_x, uu_y, uu_z, ww_x, ww_y, ww_z)
        (vv_x, vv_y, vv_z) = normalize(vv_x, vv_y, vv_z)

        # sphere locations
        (sph1_x, sph1_y, sph1_z) = (-1.2, 1.0, 0.0)
        (sph2_x, sph2_y, sph2_z) = ( 1.0, 1.5, 0.0)

        for y in range(height):
            for x in range(width):
                # normalized screen space
                # p_y in -1 .. 1
                # p_x in -2 .. 2
                p_x = (2.0 * x - width) / height
                p_y = -(2.0 * y - height) / height

                # create view ray
                # rd = normalize(M * [ p_x, p_y, 1.5 ])
                # rd = normalize(p_x*uu + p_y*vv + 1.5*ww)
                rd_x = (p_x*uu_x) + (p_y*vv_x) + (1.5*ww_x)
                rd_y = (p_x*uu_y) + (p_y*vv_y) + (1.5*ww_y)
                rd_z = (p_x*uu_z) + (p_y*vv_z) + (1.5*ww_z)
                (rd_x, rd_y, rd_z) = normalize(rd_x, rd_y, rd_z)

                #  l = clamp(0.0, 1.0, rd_z)
                #  next[x, y] = round((num_colors - 1) * l)
                #  continue

                # raytrace
                tmin = 4.0

                # rendered lightness. initial value is also bg color (no hit).
                l = 0.0

                # ray position
                pos_x = 0.0
                pos_y = 0.0
                pos_z = 0.0

                # normal vector
                nor_x = 0.0
                nor_y = 0.0
                nor_z = 0.0

                occ = 1.0

                # raytrace sphere 1
                h = i_sphere(ro_x, ro_y, ro_z, rd_x, rd_y, rd_z, sph1_x, sph1_y, sph1_z)
                if h > 0.0 and h < tmin:
                    tmin = h

                    # pos = ro + h*rd
                    pos_x = ro_x + h * rd_x
                    pos_y = ro_y + h * rd_y
                    pos_z = ro_z + h * rd_z

                    # nor = normalize(pos - sph1)
                    nor_x = pos_x - sph1_x
                    nor_y = pos_y - sph1_y
                    nor_z = pos_z - sph1_z

                    (nor_x, nor_y, nor_z) = normalize(nor_x, nor_y, nor_z)

                    occ = 0.5 + 0.5*nor_y
                    occ *= occ_sphere(pos_x, pos_y, pos_z, nor_x, nor_y, nor_z, sph1_x, sph1_y, sph1_z)

                # raytrace sphere 2
                h = i_sphere(ro_x, ro_y, ro_z, rd_x, rd_y, rd_z, sph2_x, sph2_y, sph2_z)
                if h > 0.0 and h < tmin:
                    tmin = h

                    # pos = ro + h*rd
                    pos_x = ro_x + h * rd_x
                    pos_y = ro_y + h * rd_y
                    pos_z = ro_z + h * rd_z

                    # nor = normalize(pos - sph2)
                    nor_x = pos_x - sph2_x
                    nor_y = pos_y - sph2_y
                    nor_z = pos_z - sph2_z
                    (nor_x, nor_y, nor_z) = normalize(nor_x, nor_y, nor_z)

                    occ = 0.5 + 0.5*nor_y
                    occ *= occ_sphere(pos_x, pos_y, pos_z, nor_x, nor_y, nor_z, sph2_x, sph2_y, sph2_z)

                # shading + lighting
                if tmin < 4.0:
                    # pos = ro + tmin*rd
                    pos_x = ro_x + tmin * rd_x
                    pos_y = ro_y + tmin * rd_y
                    pos_z = ro_z + tmin * rd_z

                    # shadows
                    sha = 1.0
                    # # skip first sphere since its shadow doesn't hit anything
                    # sha *= ss_sphere(pos_x, pos_y, pos_z, lig_x, lig_y, lig_z, sph1_x, sph1_y, sph1_z)
                    sha *= ss_sphere(pos_x, pos_y, pos_z, lig_x, lig_y, lig_z, sph2_x, sph2_y, sph2_z)

                    l = 0.0

                    # ambient occlusion
                    l += 0.5 * occ

                    # lambert shading
                    l += 5.0 * lambert(lig_x, lig_y, lig_z, nor_x, nor_y, nor_z) * sha

                    l *= 0.22

                # gamma adjustment
                l = clamp(0.0, 1.0, math.pow(l, 0.85))
                next[x, y] = round((num_colors - 1) * l)

        # swap frame buffers

        self.curr = next
        self.next = curr

        curr_group = self.curr_group
        self.curr_group = self.next_group
        self.next_group = curr_group

        # show new framebuffer

        display.show(self.curr_group)


class Button:
    def __init__(self, button_pin):
        self.press_counter = countio.Counter(
            pin=button_pin,
            edge=countio.Edge.RISE,
            pull=digitalio.Pull.UP,
        )
        self.prev_total = 0

    # Returns the number of button presses since we last checked
    def new_presses(self):
        new_total = self.press_counter.count
        new_presses = new_total - self.prev_total
        self.prev_total = new_total
        return new_presses

def main():
    display = make_display()

    screens = [
        StartScreen,
        CircleScreen,
        WarpScreen,
        Warp2Screen,
        SpheresScreen,
        GameOfLifeScreen,
        #  ColorTestScreen,
    ]
    prev_screen_idx = -1
    screen_idx = 0

    up_button = Button(board.BUTTON_UP)
    # down_button = Button(board.BUTTON_DOWN)

    while True:
        up_button_presses = up_button.new_presses()
        if up_button_presses > 0:
            screen_idx = (screen_idx + 1) % len(screens)

        if screen_idx != prev_screen_idx:
            prev_screen_idx = screen_idx
            screen = screens[screen_idx](display)

        screen.update(display)

try:
    main()
except Exception as err:
    print(f"Error: {err}")

(for reference) one-time setup process #

Board product page: https://www.adafruit.com/product/4745

Board info:

UF2 Bootloader v1.23.1-adafruit.1-328-gf06693a SFHWRO
Model: Matrix Portal M4
Board-ID: SAMD51J19A-MatrixPortal-v0

When first connected to power, we see the cool little falling sand demo that comes pre-installed.

After connecting it to a machine through the USB-C port, double-press the RST button on the board; the debug light flashes and a USB-drive called MATRIXBOOT mounts on the connected machine.

If nothing happens, make sure the USB-C cable you're using supports data transfer and not just power.

The pre-installed contents of the drive:

$ ls /media/phlip9/MATRIXBOOT
CURRENT.UF2
INDEX.HTM
INFO_UF2.TXT

We then install the CircuitPython "OS" onto the board:

$ cd /tmp
$ wget https://downloads.circuitpython.org/bin/matrixportal_m4/en_US/adafruit-circuitpython-matrixportal_m4-en_US-7.3.3.uf2
$ cp adafruit-circuitpython-matrixportal_m4-en_US-7.3.3.uf2 /media/phlip9/MATRIXBOOT

The lights should flash a bit and the disk will unmount from your machine. A second later another USB disk mounts called CIRCUITPY.

Download and install the Mu editor.

On linux I had to add my user, phlip9, to the dialout group and then restart for Mu+CircuitPython to work. macOS and Windows users can skip this step.

$ sudo adduser phlip9 dialout

Open the Mu editor and select "Mode", then choose "CircuitPython". If everything was setup properly, it should auto-detect the board.

Close any random "untitled" file it tries to create. Next hit the "Load" icon and navigate to the CIRCUITPY drive. Choose the code.py file. You should see something like:

print("Hello, World!")

Click the "Serial" button to show the device print output. Save the currently open code.py file and the board will automatically restart and run the file!