Code
Getting the Program's Files
Viper and native compiled .mpy files are on the cutting edge of support in CircuitPython. The Adafruit Learning System project bundler has not yet been adapted to work with them. Use the green button below to download the project files instead of the usual "Download Project Bundle" button embedded with the code contents.
Connect your board to your computer via a known good data+power USB cable. The board should show up in your File Explorer/Finder (depending on your operating system) as a flash drive named CIRCUITPY.
Extract the contents of the zip file, copy the lib directory files to CIRCUITPY/lib. Copy the code.py and logo.py files to your CIRCUITPY drive. The program should self start.
After loading the project on your device the CIRCUITPY drive should contain these files. The src/ folder is optional. When the code runs it will use the .mpy version in the lib/ directory. Keeping a copy of the plain source code on the device is nice if you've got the flash storage space to spare, it makes it easy if you ever want to modify the code and re-compile it.
code.py
This is the main code entry point. It initializes the matrix and accelerometer. It runs the main loop, deferring to sand.mpy to carry out the heavy math for each step of the simulation. This version uses FramebufferDisplay to drive the LED matrix panel.
# SPDX-FileCopyrightText: 2026 Mikey Sklar for Adafruit Industries
# SPDX-FileCopyrightText: 2020 Phil Burgess for Adafruit Industries
# SPDX-License-Identifier: MIT
#
# "Pixel dust" on a 64x64 HUB75 panel, Matrix Portal S3 edition. Tilt the
# board and the sand runs with gravity, read from the onboard LIS3DH, around
# the Adafruit logo as in PixelDust's demo3-logo. The
# physics lives in src/sand.py so turbo can compile it; this file only wires
# up the display, the accelerometer and the frame loop. Prints frames/second
# and the time spent in the kernel every 100 frames.
import time
from array import array
import adafruit_lis3dh
import board
import displayio
import framebufferio
import rgbmatrix
import logo
import sand
displayio.release_displays()
# 64x64 panel, 1/32 scan, five address lines (MTX_ADDRA..E).
WIDTH, HEIGHT = 64, 64
MAX_FPS = 1000 # the C demo uses 45; 1000 is effectively unthrottled
ELASTICITY = 128 # bounce keeps this /256 of its speed
# Grains start as N_COLORS blocks of BOX_WIDTH x BOX_HEIGHT along the bottom.
N_COLORS = 8
BOX_WIDTH = WIDTH // N_COLORS
BOX_HEIGHT = 8
N_GRAINS = N_COLORS * BOX_WIDTH * BOX_HEIGHT
OBSTACLE = N_COLORS + 1 # cell value for the logo in the collision map
BG = (0, 0, 0) # background behind the logo (demo3-logo.cpp: 0, 20, 80)
# Which accelerometer axis feeds which simulation axis, and its sign. The
# panel's orientation relative to the LIS3DH is not something the code can
# know; flip these if the sand runs the wrong way.
AX, AY, AZ = (0, 1), (1, 1), (2, 1)
matrix = rgbmatrix.RGBMatrix(
width=WIDTH,
height=HEIGHT,
bit_depth=4,
# This panel has red and blue swapped, so the B pins drive red. For a
# panel wired the usual way, list them R, G, B.
rgb_pins=[
board.MTX_B1,
board.MTX_G1,
board.MTX_R1,
board.MTX_B2,
board.MTX_G2,
board.MTX_R2,
],
addr_pins=[
board.MTX_ADDRA,
board.MTX_ADDRB,
board.MTX_ADDRC,
board.MTX_ADDRD,
board.MTX_ADDRE,
],
clock_pin=board.MTX_CLK,
latch_pin=board.MTX_LAT,
output_enable_pin=board.MTX_OE,
)
display = framebufferio.FramebufferDisplay(matrix, auto_refresh=False)
# Bottom layer: the white logo alpha-blended onto the background, once. The
# palette index is the logo's gray level, so the blend lives in the palette.
LOGO_X = (WIDTH - logo.WIDTH) // 2
LOGO_Y = (HEIGHT - logo.HEIGHT) // 2
backdrop = displayio.Bitmap(WIDTH, HEIGHT, 256)
backdrop_palette = displayio.Palette(256)
for g in range(256):
a1 = g + 1
a2 = 257 - a1
backdrop_palette[g] = (
((255 * a1 + BG[0] * a2) >> 8) << 16
| ((255 * a1 + BG[1] * a2) >> 8) << 8
| ((255 * a1 + BG[2] * a2) >> 8)
)
for y in range(logo.HEIGHT):
for x in range(logo.WIDTH):
backdrop[LOGO_X + x, LOGO_Y + y] = logo.GRAY[y * logo.WIDTH + x]
# Top layer: the sand. One byte per cell so the Bitmap's buffer maps
# row-major with no padding (width is a multiple of 4). It doubles as the
# kernel's collision map; any nonzero cell is solid, so the logo's mask goes
# in as OBSTACLE cells, drawn transparent so the backdrop shows through.
bitmap = displayio.Bitmap(WIDTH, HEIGHT, 256)
palette = displayio.Palette(OBSTACLE + 1)
palette[0] = 0x000000
palette.make_transparent(0)
palette[OBSTACLE] = 0x000000
palette.make_transparent(OBSTACLE)
for y in range(logo.HEIGHT):
for x in range(logo.WIDTH):
if logo.MASK[y * 5 + (x >> 3)] & (0x80 >> (x & 7)):
bitmap[LOGO_X + x, LOGO_Y + y] = OBSTACLE
for i, rgb in enumerate(
(
(64, 64, 64), # dark gray
(120, 79, 23), # brown
(228, 3, 3), # red
(255, 140, 0), # orange
(255, 237, 0), # yellow
(0, 128, 38), # green
(0, 77, 255), # blue
(117, 7, 135), # purple
)
):
palette[i + 1] = (rgb[0] << 16) | (rgb[1] << 8) | rgb[2]
group = displayio.Group()
group.append(displayio.TileGrid(backdrop, pixel_shader=backdrop_palette))
group.append(displayio.TileGrid(bitmap, pixel_shader=palette))
display.root_group = group
accel = adafruit_lis3dh.LIS3DH_I2C(board.I2C(), address=0x19)
accel.range = adafruit_lis3dh.RANGE_4_G
grains = array("i", [0] * (5 * N_GRAINS))
rng = array("I", [time.monotonic_ns() & 0x7FFFFFFF])
n = 0
for c in range(N_COLORS):
for y in range(HEIGHT - BOX_HEIGHT, HEIGHT):
for x in range(c * BOX_WIDTH, (c + 1) * BOX_WIDTH):
sand.place(grains, bitmap, n, x, y, WIDTH, c + 1)
n += 1
bitmap.dirty()
display.refresh()
print(N_GRAINS, "grains")
FRAME_NS = 1_000_000_000 // MAX_FPS
next_frame = time.monotonic_ns()
frames = 0
kernel_ns = 0
t_report = next_frame
while True:
# Throttle to MAX_FPS. The kernel's time varies with how much is
# moving, so without this gravity would look stronger in a settled pile.
now = time.monotonic_ns()
if now < next_frame:
continue
next_frame = now + FRAME_NS
a = accel.acceleration # m/s^2
ax = int(a[AX[0]] * 1000) * AX[1]
ay = int(a[AY[0]] * 1000) * AY[1]
az = int(a[AZ[0]] * 1000) * AZ[1]
t0 = time.monotonic_ns()
sand.iterate(grains, bitmap, N_GRAINS, WIDTH, HEIGHT, ax, ay, az, ELASTICITY, rng)
kernel_ns += time.monotonic_ns() - t0
bitmap.dirty() # the kernel wrote through the buffer, tell displayio
display.refresh()
frames += 1
if frames == 100:
elapsed = now - t_report
print(
"%.1f fps, kernel %.2f ms/frame, accel (%d, %d, %d)"
% (frames * 1e9 / elapsed, kernel_ns / frames / 1e6, ax, ay, az)
)
frames = 0
kernel_ns = 0
t_report = now
sand.py
This is the code for the viper module that handles the physics and collision math for the simulation. Viper functions are written as Python, but have an extra set of rules that must be followed.
Local variables and arguments must be int, uint (32-bit only), bool, ptr, ptr8, ptr16, ptr32, or object. They cannot use floats like normal Python numbers do. Parameters get passed to the Viper function as a list of values, and are accessed via their index within that list. There is no overflow protection. Integers will silently wrap, out-of-range indexes can corrupt memory or crash instead of raising a clean IndexError. Variables cannot change types. Mixing of typed values with object values will fall back to slower runtime calls.
This code gets compiled by mpy-cross for the specific architecture of the chip that it will run on. The sand_viper.mpy file included in the bundle is compiled for the ESP32-S3 architecture xtensawin.
# SPDX-FileCopyrightText: 2026 Mikey Sklar for Adafruit Industries
# SPDX-FileCopyrightText: 2020 Phil Burgess for Adafruit Industries
#
# SPDX-License-Identifier: MIT
# "Pixel dust" sand physics from Adafruit_PixelDust (Phil Burgess, BSD),
# ported to a turbo viper kernel. Integer only: positions are 8.8 fixed
# point (pixel * 256), velocities are in the same units per frame, terminal
# velocity is 256 (one pixel) so grains never tunnel through each other.
#
# `g` is an array('i') of 5 ints per grain: x, y, vx, vy, colour. `fb` is any
# writable byte buffer of height rows, width bytes apart, one byte per cell:
# 0 free, else the colour of the grain sitting there. A displayio.Bitmap with
# value_count=256 and a width that is a multiple of 4 works directly and is
# both the collision map and the frame. `rng` is a one-element array('I')
# holding the random state, advanced in place.
#
# Where the C truncates toward zero on a division this does too, so a grain
# bouncing left behaves like one bouncing right.
import micropython
# pylint: disable=too-many-locals, undefined-variable, too-many-nested-blocks, too-many-branches, too-many-statements
# pylint: disable=import-outside-toplevel
@micropython.viper
def iterate(
g: ptr32,
fb: ptr8,
n: int,
width: int,
height: int,
ax: int,
ay: int,
az: int,
elasticity: int,
rng: ptr32,
) -> int:
xmax = width * 256 - 1
ymax = height * 256 - 1
# Scale raw accelerometer input (milli-m/s^2) down to grain units. The C
# has a `scale` parameter that the demo leaves at 1, so it is folded in.
if ax >= 0:
ax = ax // 256
else:
ax = -((-ax) // 256)
if ay >= 0:
ay = ay // 256
else:
ay = -((-ay) // 256)
if az < 0:
az = -az
az = az // 2048
# A bit of random jitter per grain so tall stacks topple instead of
# sliding as a block. Stronger the further from level the panel is.
if az >= 4:
az = 1
else:
az = 5 - az
ax -= az
ay -= az
az2 = az * 2 + 1
r = rng[0]
moved = 0
for i in range(n):
b = i * 5
x = g[b]
y = g[b + 1]
vx = g[b + 2]
vy = g[b + 3]
# Apply the 2D acceleration vector plus jitter.
r = (r * 214013 + 2531011) & 0x3FFFFFFF
vx += ax + ((r >> 16) % az2)
r = (r * 214013 + 2531011) & 0x3FFFFFFF
vy += ay + ((r >> 16) % az2)
# Clip velocity as a 2D vector at 256 so diagonal motion is no
# faster than axial. Integer Newton sqrt stands in for sqrtf().
v2 = vx * vx + vy * vy
if v2 > 65536:
v = 512
for _ in range(8):
v = (v + v2 // v) >> 1
t = vx * 256
if t >= 0:
vx = t // v
else:
vx = -((-t) // v)
t = vy * 256
if t >= 0:
vy = t // v
else:
vy = -((-t) // v)
# Tentative new position, bouncing off the walls.
nx = x + vx
ny = y + vy
if nx < 0:
nx = 0
if vx >= 0:
vx = -((vx * elasticity) // 256)
else:
vx = ((-vx) * elasticity) // 256
elif nx > xmax:
nx = xmax
if vx >= 0:
vx = -((vx * elasticity) // 256)
else:
vx = ((-vx) * elasticity) // 256
if ny < 0:
ny = 0
if vy >= 0:
vy = -((vy * elasticity) // 256)
else:
vy = ((-vy) * elasticity) // 256
elif ny > ymax:
ny = ymax
if vy >= 0:
vy = -((vy * elasticity) // 256)
else:
vy = ((-vy) * elasticity) // 256
# Cell indices before and after. Comparing indices rather than x and
# y separately makes the blocked-direction test cheap.
ox = x >> 8
oy = y >> 8
cx = nx >> 8
cy = ny >> 8
oldidx = oy * width + ox
newidx = cy * width + cx
if oldidx != newidx and fb[newidx] != 0:
delta = newidx - oldidx
if delta < 0:
delta = -delta
if delta == 1:
# Blocked left or right: cancel X, bounce X, Y is fine.
nx = x
if vx >= 0:
vx = -((vx * elasticity) // 256)
else:
vx = ((-vx) * elasticity) // 256
elif delta == width:
# Blocked up or down: cancel Y, bounce Y, X is fine.
ny = y
if vy >= 0:
vy = -((vy * elasticity) // 256)
else:
vy = ((-vy) * elasticity) // 256
else:
# Diagonal: try skidding along the faster axis first, then
# the other, else stop dead.
avx = vx
if avx < 0:
avx = -avx
avy = vy
if avy < 0:
avy = -avy
if avx >= avy:
if fb[oy * width + cx] == 0:
ny = y
if vy >= 0:
vy = -((vy * elasticity) // 256)
else:
vy = ((-vy) * elasticity) // 256
elif fb[cy * width + ox] == 0:
nx = x
if vx >= 0:
vx = -((vx * elasticity) // 256)
else:
vx = ((-vx) * elasticity) // 256
else:
nx = x
ny = y
if vx >= 0:
vx = -((vx * elasticity) // 256)
else:
vx = ((-vx) * elasticity) // 256
if vy >= 0:
vy = -((vy * elasticity) // 256)
else:
vy = ((-vy) * elasticity) // 256
else:
if fb[cy * width + ox] == 0:
nx = x
if vx >= 0:
vx = -((vx * elasticity) // 256)
else:
vx = ((-vx) * elasticity) // 256
elif fb[oy * width + cx] == 0:
ny = y
if vy >= 0:
vy = -((vy * elasticity) // 256)
else:
vy = ((-vy) * elasticity) // 256
else:
nx = x
ny = y
if vx >= 0:
vx = -((vx * elasticity) // 256)
else:
vx = ((-vx) * elasticity) // 256
if vy >= 0:
vy = -((vy * elasticity) // 256)
else:
vy = ((-vy) * elasticity) // 256
newidx = (ny >> 8) * width + (nx >> 8)
c = g[b + 4]
fb[oldidx] = 0
fb[newidx] = c
if newidx != oldidx:
moved += 1
g[b] = nx
g[b + 1] = ny
g[b + 2] = vx
g[b + 3] = vy
rng[0] = r
return moved
def place(g, fb, i, x, y, width, colour):
"""Put grain i at pixel (x, y) with the given colour index (1..255).
Returns False, leaving everything unchanged, if the cell is taken."""
idx = y * width + x
if fb[idx]:
return False
fb[idx] = colour
b = i * 5
g[b] = x * 256
g[b + 1] = y * 256
g[b + 2] = 0
g[b + 3] = 0
g[b + 4] = colour
return True
def _turbo_bench():
# 64x64 world, 512 grains in the demo's eight 8x8 colour blocks along the
# bottom, 60 frames of gravity that swings between the four edges.
# Returns a hash of every grain field; every tier must reproduce it.
from array import array
W, H, N = 64, 64, 512
fb = bytearray(W * H)
g = array("i", [0] * (5 * N))
rng = array("I", [12345])
i = 0
for c in range(8):
for y in range(H - 8, H):
for x in range(c * 8, c * 8 + 8):
place(g, fb, i, x, y, W, c + 1)
i += 1
dirs = ((0, 9800, 300), (9800, 0, 300), (0, -9800, 300), (-9800, 0, 300))
for k in range(60):
ax, ay, az = dirs[(k // 15) % 4]
iterate(g, fb, N, W, H, ax, ay, az, 128, rng)
h = 0
for v in g:
h = (h * 31 + v) & 0x3FFFFFFF
return h
Here is the command used to compile the sand module for the Matrix Portal S3.
mpy-cross -march=xtensawin sand.py
Page last edited September 29, 2026
Text editor powered by tinymce.