OTHER PROJECTS

Pixel Art Animation Display with Arduino Nano and 16×16 LED Matrix

We have always liked retro pixel art, so I built a small display that plays 41 pixel-art animations on a 16×16 LED matrix, drawn over a flowing rainbow. It runs on an Arduino Nano and needs only three wires. In this post we will walk you through every step, so you can build the same thing even if it’s your first LED project.

What You Will Build :

A 16×16 WS2812B LED matrix that :

  • plays 41 pixel-art animations (retro game characters, a spinning gear, flames and more)
  • shows them over a smooth, moving rainbow background
  • changes to a new animation about every 2.5 seconds
  • lets you change brightness, colours and effects live from your computer

What You Will Learn :

  • How to wire and power a WS2812B LED matrix
  • How to install a library and upload a multi-file sketch in the Arduino IDE
  • How heatshrink compression fits 41 animations into the Nano’s tiny memory
  • How to control the display over Serial
  • How to fix the most common problems (scrambled rows, wrong colours, no upload)

Components Required :

ComponentQuantityNotes
Arduino Nano (ATmega328P)1Original or clone, 5 V / 16 MHz
16×16 WS2812B LED matrix (256 LEDs)1Must have a DIN, 5V and GND connection
Jumper wires3Red, black and green help you keep track
USB cable for your Nano1Mini-USB or the type your Nano uses
External 5V power supply1Recommended. 5V 4A is a good start for medium brightness
330 Ω resistor (optional)1In series with the data wire
1000 µF capacitor (optional)1Across the matrix’s 5V and GND

You also need the Arduino IDE on your computer.

Circuit Diagram :

Wiring Table :

Matrix PinArduino Nano PinWire Colour
DIN (data in)D2Green
5V5VRed
GNDGNDBlack

Read these notes before you connect anything:

  • Matrices have an input end (DIN) and an output end (DOUT). Connect to DIN. If nothing lights up, check that you didn’t use DOUT.
  • The data pin is set by LED_PIN in the code. If you wire to a different pin, change that line.
  • If you power the matrix from an external 5V supply, connect that supply’s GND to the Nano’s GND as well. Without a shared ground the data signal won’t work reliably.
  • Don’t power a bright, full matrix from the Nano’s 5V pin. See the Power Warning below.

How It Works :

Each of the 256 LEDs has its own tiny controller chip, so the Nano needs only one data wire to set the colour of every pixel. The sketch holds all 256 colours in memory and sends them to the matrix 50 times a second.

Two layers are drawn on every frame:

  • Rainbow layer: a smooth, flowing rainbow that fills the whole matrix.
  • Sprite layer: a 16×16 pixel-art animation, unpacked from a compressed copy stored in the Nano’s program memory.

Coloured sprite pixels are copied straight onto the matrix. The empty background pixels are faded, so the rainbow shows through behind the character. About every 2.5 seconds the display moves on to the next animation.

You pick what is shown with the ” e ” key in the Serial Monitor:

Display ModeWhat You See
Rainbow onlyJust the flowing rainbow
Sprites onlyAnimations on their own, with soft fading
Sprites over rainbow (default)Animations on top of the rainbow

Power Warning :

A full 16×16 matrix can draw a lot of current. Each WS2812B LED can use up to about 60 mA at full-brightness white, so all 256 together could in theory pull over 15 A. Animations light only part of the matrix, so real use is far lower. Even so, it can be more than a USB port (about 500 mA) can supply.

The sketch starts at full brightness (BRIGHTNESS 255) with no power limit (MAX_MILLIWATTS 0). Before you power it up:

  • Set BRIGHTNESS to about 40 while testing from USB
  • Or set MAX_MILLIWATTS to 2500 to stay within a USB port’s limit
  • For bright, long-running builds, use a separate 5V supply and share its ground with the Nano
  • If the matrix flickers or the Nano keeps resetting, the supply is too weak. Lower the brightness

Step 1: Get the Files :

The project uses six files that must all sit in one folder:

FileWhat It Does
scintillating_heatshrink.inoMain sketch (the full code is below)
gifs.hThe 41 compressed sprite animations
heatshrink_config.hDecoder settings (fixed memory, 256-byte window)
heatshrink_common.hLibrary constants and version number
heatshrink_decoder.hDecoder function declarations
heatshrink_decoder.inoThe decompression code
  1. Create a folder called exactly scintillating_heatshrink. The Arduino IDE needs the folder name to match the main sketch.
  2. Save the sketch from the Arduino Code section below as scintillating_heatshrink.ino inside it.
  3. Download the other five files from the project’s GitHub page: https://github.com/sutaburosu/scintillating_heatshrink/. Click the green Code button, then Download ZIP, unzip it, and copy gifs.h, heatshrink_config.h, heatshrink_common.h, heatshrink_decoder.h and heatshrink_decoder.ino into your scintillating_heatshrink folder. If you open a file on GitHub, you can also use the Raw button and save it from there.
  4. Keep the licence notices inside the heatshrink files as they are.

Check: the folder should now contain six files with these exact names.

The compression library behind these files is heatshrink by Scott Vokes: https://github.com/atomicobject/heatshrink

We couldn’t open the GitHub page from here, so we haven’t confirmed which folder the five files sit in. Before you publish, open the link and check. If they are inside a subfolder, change step 3 to name it, for example “open the scintillating_heatshrink folder inside the ZIP”. If the repository’s .ino is named differently, rename your copy of the files to match what the page shows.

Step 2: Install the Arduino IDE and FastLED :

  1. Download and install the Arduino IDE from arduino.cc.
  2. Open it and go to Sketch → Include Library → Manage Libraries.
  3. Search for FastLED and click Install. Use a recent version, because the sketch uses a newer FastLED function for smoother colours.

Step 3: Open and Upload :

  1. Go to File → Open, and choose scintillating_heatshrink.ino. The other five files open as extra tabs.
  2. Find the line #define BRIGHTNESS 255 and change it to #define BRIGHTNESS 40 for testing.
  3. Plug the Nano into your computer with the USB cable.
  4. Set Tools → Board → Arduino Nano.
  5. Set Tools → Processor → ATmega328P. If the upload fails later, try ATmega328P (Old Bootloader), which many clone boards need.
  6. Select your board under Tools → Port.
  7. Click Upload and wait for “Done uploading”.

Step 4: Wire and Power Up :

  1. Disconnect the USB cable.
  2. Make the three connections from the Wiring Table.
  3. Reconnect power (and the external 5V supply, if you use one).
  4. The display fades in over about two-thirds of a second, then the animations start.

Circuit Simulation :

Arduino Code :

This is the main sketch we used. we tidied it by removing unused commented-out code and two unused helper functions, and the working logic is unchanged.

#include <Arduino.h>
#include <FastLED.h>
#include <stdint.h>
#include <avr/pgmspace.h>
#include "heatshrink_config.h"
#include "heatshrink_decoder.h"

enum XY_matrix_config { SERPENTINE = 1, ROWMAJOR = 2, FLIPMAJOR = 4, FLIPMINOR = 8 };

// MAX_MILLIWATTS can only be changed at compile-time. Use 0 to disable limit.
// Brightness can be changed at runtime via serial with 'b' and 'B'
#define MAX_MILLIWATTS 0
#define BRIGHTNESS 255
#define LED_PIN 2
#define COLOR_ORDER GRB
#define CHIPSET WS2812B
#define kMatrixWidth 16
#define kMatrixHeight 16
#define XY_MATRIX (ROWMAJOR)
#define NUM_LEDS ((kMatrixWidth) * (kMatrixHeight))

#define SERIAL_UI 1 // if 1, can be controlled via keypresses in PuTTY
#define FADEIN_FRAMES 32 // how long to reach full brightness when powered on
#define MS_GOAL 20 // to try maintain 1000 / 20ms == 50 frames per second

#define GIF2H_MAX_PALETTE_ENTRIES 140 // RAM is always tight on ATmega328...
#define GIF2H_NUM_PIXELS NUM_LEDS // all images must be the same size as the matrix

CRGB leds[NUM_LEDS];
CRGBPalette16 currentPalette;

uint16_t XY(uint8_t x, uint8_t y) {
uint8_t major, minor, sz_major, sz_minor;
if (x >= kMatrixWidth || y >= kMatrixHeight)
return NUM_LEDS;
if (XY_MATRIX & ROWMAJOR)
major = x, minor = y, sz_major = kMatrixWidth, sz_minor = kMatrixHeight;
else
major = y, minor = x, sz_major = kMatrixHeight, sz_minor = kMatrixWidth;
if (XY_MATRIX & FLIPMINOR)
minor = sz_minor - 1 - minor;
if ((XY_MATRIX & FLIPMAJOR) ^ (minor & 1 && (XY_MATRIX & SERPENTINE)))
major = sz_major - 1 - major;
return (uint16_t) minor * sz_major + major;
}

#if FASTLED_USE_PROGMEM == 1
#define FL_PGM_READ_PTR_NEAR(x) (pgm_read_ptr_near(x))
#else
#if !defined(FL_PGM_READ_PTR_NEAR)
#define FL_PGM_READ_PTR_NEAR(addr) ({typeof(addr) addr = (addr); (void const )(*addr); })
#endif
#if !defined(memcpy_P)
#define memcpy_P(dest, src, n) memcpy(dest, src, n)
#endif
#endif

// Blend one colour toward another by a given amount (linear, in RGB space).
// https://gist.github.com/kriegsman/d0a5ed3c8f38c64adcb4837dafb6e690
void nblendU8TowardU8(uint8_t& cur, const uint8_t target, uint8_t amount) {
if (cur == target) return;
if (cur < target) {
cur += scale8(target - cur, amount);
} else {
cur -= scale8(cur - target, amount);
}
}

CRGB fadeTowardColour(CRGB& cur, const CRGB& target, uint8_t amount) {
nblendU8TowardU8(cur.red, target.red, amount);
nblendU8TowardU8(cur.green, target.green, amount);
nblendU8TowardU8(cur.blue, target.blue, amount);
return cur;
}

typedef struct HSprite {
uint16_t datasize;
uint16_t frames;
uint16_t duration;
uint8_t flags;
uint8_t palette_entries;
CRGB palette[];
uint8_t hs_data[];
} HSprite;

#include "gifs.h"

FL_PROGMEM extern const HSprite *const hsprite_list[] = {
(HSprite*) &HSpr_35disk,
(HSprite*) &HSpr_load,
(HSprite*) &HSpr_vn,
(HSprite*) &HSpr_unionflag16,
(HSprite*) &HSpr_eu,
(HSprite*) &HSpr_ghost,
(HSprite*) &HSpr_scheisse,
(HSprite*) &HSpr_devil_nod,
(HSprite*) &HSpr_kputrummis,
(HSprite*) &HSpr_rjb,
(HSprite*) &HSpr_owl,
(HSprite*) &HSpr_twylogo,
(HSprite*) &HSpr_gear,
(HSprite*) &HSpr_16x16_oric,
(HSprite*) &HSpr_zx_k_ref,
(HSprite*) &HSpr_invader,
(HSprite*) &HSpr_c64,
(HSprite*) &HSpr_amigaroll_trs,
(HSprite*) &HSpr_joystick,
(HSprite*) &HSpr_slime,
(HSprite*) &HSpr_DigDug16x16,
(HSprite*) &HSpr_octorokblue,
(HSprite*) &HSpr_ptititi,
(HSprite*) &HSpr_burgertime,
(HSprite*) &HSpr_pouet_avatar_poi_charly_walk2,
(HSprite*) &HSpr_rockhell,
(HSprite*) &HSpr_pouet_avatar_mario,
(HSprite*) &HSpr_kirby_run,
(HSprite*) &HSpr_bub,
(HSprite*) &HSpr_sirlord,
(HSprite*) &HSpr_plane,
(HSprite*) &HSpr_fire,
(HSprite*) &HSpr_bird16,
(HSprite*) &HSpr_mspacman,
(HSprite*) &HSpr_ghost3,
(HSprite*) &HSpr_batman2,
(HSprite*) &HSpr_lemming,
(HSprite*) &HSpr_mwk,
(HSprite*) &HSpr_gwain2,
(HSprite*) &HSpr_scoopexrulez,
(HSprite*) &HSpr_8bit_avatar,
};
#define HSPRITES_N (sizeof(hsprite_list) / sizeof(hsprite_list[0]))

typedef struct {
uint8_t xw, yw, xd, yd;
int16_t xdd, ydd, bcd;
} RainbowSmoothiePreset;

typedef struct {
uint32_t frame = 0;
uint32_t millis = 0;
uint8_t effect = 2;
uint8_t palette_n = 1;
uint8_t alpha_fade = 64;
uint8_t brightness = BRIGHTNESS;
uint8_t rs_preset_n = 0;
TBlendType currentBlending = LINEARBLEND;
uint8_t extendedmixing = 1;
RainbowSmoothiePreset rs;
} Config;
Config cfg;

void setup() {
FastLED.addLeds<CHIPSET, LED_PIN, COLOR_ORDER>(leds, NUM_LEDS);
FastLED.setCorrection(UncorrectedColor);
FastLED.setTemperature(UncorrectedTemperature);
FastLED.setDither(DISABLE_DITHER);
if (MAX_MILLIWATTS > 0) FastLED.setMaxPowerInMilliWatts(MAX_MILLIWATTS);

if (SERIAL_UI == 1) {
Serial.begin(250000);
halp();
}

pinMode(LED_BUILTIN, OUTPUT);
inc_palette(0);
inc_rs_preset(0);
}

#define MAX_EFFECTS 3
void loop() {
uint8_t effect = cfg.effect + 1;
if (effect & 1) rainbow_smoothie();
if (effect & 2) scintillating_heatshrink();
if (SERIAL_UI == 1) poll_serial();
if (cfg.frame <= FADEIN_FRAMES)
FastLED.setBrightness(scale8(cfg.brightness, (cfg.frame * 255) / FADEIN_FRAMES));

// cap the frame rate and indicate idle time via the built-in LED
cfg.frame++;
uint32_t frame_time = millis() - cfg.millis; // wraparound safe
int32_t pause = MS_GOAL - frame_time;
digitalWrite(LED_BUILTIN, HIGH);
if (pause > 0) delay(pause);
digitalWrite(LED_BUILTIN, LOW);
cfg.millis = millis();
FastLED.show();
}

///////////////////////////////////////////////////////////////////////

void poll_serial() {
if (Serial.available() <= 0)
return;
int input = Serial.read();
switch (input) {
case 'e': cfg.effect = addmod8(cfg.effect, 1, MAX_EFFECTS); break;
case 'f': cfg.alpha_fade = qsub8(cfg.alpha_fade, 1); break;
case 'F': cfg.alpha_fade = qadd8(cfg.alpha_fade, 1); break;
case '{': cfg.currentBlending = LINEARBLEND; break;
case '}': cfg.currentBlending = NOBLEND; break;
case '[': cfg.extendedmixing = 1; break;
case ']': cfg.extendedmixing = 0; break;
case 'p': inc_palette(1); break;
case '~': inc_rs_preset(1); break;
case 'B':
cfg.brightness = qadd8(cfg.brightness, 1);
FastLED.setBrightness(cfg.brightness);
break;
case 'b':
cfg.brightness = qsub8(cfg.brightness, 1);
FastLED.setBrightness(cfg.brightness);
break;
case 'd': FastLED.setDither(DISABLE_DITHER); break;
case 'D': FastLED.setDither(BINARY_DITHER); break;
case ',': cfg.rs.bcd -= 64; break;
case '<': cfg.rs.bcd -= 256; break;
case '.': cfg.rs.bcd += 64; break;
case '>': cfg.rs.bcd += 256; break;
case '#': randomise_rainbowsmoothie(); break;
case 13: print_params(); break;
case 10: break;
default: Serial.println(F("?"));
}
}

void halp() {
Serial.println(F(" #\trandomise rainbow smoothie\r\n"
" ~\tchoose next preset for rainbow smoothie\r\n"
" <enter>\tprint parameters\r\n"
" e\tswitch to next Effect\r\n"
" p\tswitch to next palette\r\n"
" {}\tturn extended palette blending on/off\r\n"
" []\tturn linear palette blending on/off\r\n"
" ,.<>\tchange colour cycling rate\r\n"
" bB\tbrightness down/up\r\n"
" dD\tbinary dither off/on\r\n"
" fF\tdecrease/increase fade out rate\r\n"));
}

void print_params() {
Serial.println(F("bright\tfade\txw\tyw\txd\tyd\txdd\tydd\tbcd\tFPS"));
Serial.print(cfg.brightness); Serial.print("\t");
Serial.print(cfg.alpha_fade); Serial.print("\t");
Serial.print(cfg.rs.xw); Serial.print("\t");
Serial.print(cfg.rs.yw); Serial.print("\t");
Serial.print(cfg.rs.xd); Serial.print("\t");
Serial.print(cfg.rs.yd); Serial.print("\t");
Serial.print(cfg.rs.xdd); Serial.print("\t");
Serial.print(cfg.rs.ydd); Serial.print("\t");
Serial.print(cfg.rs.bcd); Serial.print("\t");
Serial.println(FastLED.getFPS());
}

///////////////////////////////////////////////////////////////////////

FL_PROGMEM extern const TProgmemRGBPalette16 RainbowStripeColors2_p = {
0xFF0000, 0x7F0000, 0xAB5500, 0x552A00,
0xABAB00, 0x555500, 0x00FF00, 0x007F00,
0x00AB55, 0x00552A, 0x0000FF, 0x00007F,
0x5500AB, 0x2A0055, 0xAB0055, 0x55002A
};

FL_PROGMEM extern const TProgmemRGBPalette16 *const palettes16[] = {
&RainbowColors_p, &RainbowStripeColors_p, &RainbowStripeColors2_p,
};
#define RGB16PALETTES (sizeof(palettes16) / sizeof(palettes16[0]))

void inc_palette(uint8_t n) {
cfg.palette_n = n = addmod8(cfg.palette_n, n, RGB16PALETTES);
currentPalette = (TProgmemRGBPalette16 ) FL_PGM_READ_PTR_NEAR(&palettes16[n]);
}

///////////////////////////////////////////////////////////////////////

FL_PROGMEM const RainbowSmoothiePreset rs_presets[] = {
//xw yw xd yd xdd ydd bcd
{ 4, 3, 80, 208, 15327, -3010, 27000},
{ 1, 1, 34, 86, -7661, 30835, 9600},
};
#define MAX_RS_PRESETS (sizeof(rs_presets) / sizeof(RainbowSmoothiePreset))

void inc_rs_preset(uint8_t n) {
cfg.rs_preset_n = addmod8(cfg.rs_preset_n, n, MAX_RS_PRESETS);
uint8_t src = (uint8_t ) &rs_presets[cfg.rs_preset_n];
uint8_t dst = (uint8_t ) &cfg.rs;
memcpy_P(dst, src, sizeof(cfg.rs));
}

void rainbow_smoothie() {
uint32_t startHue = cfg.frame * cfg.rs.bcd;
uint32_t yHueDelta = ((uint32_t)sin16(cfg.frame cfg.rs.xd) cfg.rs.xw);
uint32_t xHueDelta = ((uint32_t)cos16(cfg.frame cfg.rs.yd) cfg.rs.yw);
uint32_t lineStartHue = startHue - (kMatrixHeight / 2) * yHueDelta;
uint32_t yd2 = cfg.rs.ydd * 2048;
uint32_t xd2 = cfg.rs.xdd * 2048;
for (byte y = 0; y < kMatrixHeight; y++) {
lineStartHue += yHueDelta;
yHueDelta += yd2;
uint32_t pixelHue = lineStartHue - (kMatrixWidth / 2) * xHueDelta;
uint32_t xhd = xHueDelta;
for (byte x = 0; x < kMatrixWidth; x++) {
pixelHue += xhd;
xhd += xd2;
if (cfg.extendedmixing == 1) {
leds[XY(x, y)] = ColorFromPaletteExtended(currentPalette, pixelHue >> 7, 255, cfg.currentBlending);
} else {
leds[XY(x, y)] = ColorFromPalette(currentPalette, pixelHue >> 15, 255, cfg.currentBlending);
}
}
}
}

void randomise_rainbowsmoothie() {
random16_add_entropy(random());
cfg.rs.bcd = random16(16383) - 8192;
cfg.rs.xd = random8();
cfg.rs.yd = random8();
cfg.rs.xdd = random16() - 32768;
cfg.rs.ydd = random16() - 32768;
cfg.rs.xw = random8(24);
cfg.rs.yw = random8(24);
}

///////////////////////////////////////////////////////////////////////

struct hstatus {
uint32_t last_millis;
heatshrink_decoder hsd;
size_t heatsunk;
uint16_t frame;
uint8_t hs_spr_n = -1;
uint8_t loops;
CRGB palette[GIF2H_MAX_PALETTE_ENTRIES];
} hstatus;

void heatshrunk_sprite_reset(struct hstatus * status, uint8_t hs_spr_n) {
// decompress the palette and leave the decompressor ready to emit pixels
status->hs_spr_n = hs_spr_n;
status->heatsunk = 0;
status->frame = 0;
HSprite spr_ptr = (HSprite ) FL_PGM_READ_PTR_NEAR(&hsprite_list[hs_spr_n]);
uint8_t palette_entries = FL_PGM_READ_BYTE_NEAR(&spr_ptr->palette_entries);

// set heatshrink's window buffer position, so the pixels end up aligned with the start of the buffer
heatshrink_decoder_reset(&status->hsd);
status->hsd.head_index -= palette_entries * sizeof(CRGB);

uint8_t dst = (uint8_t ) &status->palette;
uint16_t remaining = palette_entries * sizeof(CRGB);
size_t count = 0;
while (remaining) {
heatshrink_decoder_sinkP(&status->hsd, &spr_ptr->hs_data[status->heatsunk],
HEATSHRINK_STATIC_INPUT_BUFFER_SIZE, &count);
status->heatsunk += count;
HSD_poll_res pres;
do {
pres = heatshrink_decoder_poll(&status->hsd, dst, remaining, &count);
dst += count;
remaining -= count;
} while ((pres == HSDR_POLL_MORE) && remaining);
}
}

void heatshrunk_sprite_prepare(struct hstatus * status, uint8_t hs_spr_n) {
HSprite spr_ptr = (HSprite ) FL_PGM_READ_PTR_NEAR(&hsprite_list[hs_spr_n]);
uint16_t datasize = FL_PGM_READ_WORD_NEAR(&spr_ptr->datasize);
uint16_t frames = FL_PGM_READ_WORD_NEAR(&spr_ptr->frames);
uint16_t duration = FL_PGM_READ_WORD_NEAR(&spr_ptr->duration);

if (hs_spr_n != status->hs_spr_n) {
// a different sprite has been selected; force a reset
status->frame = frames;
status->loops = 0;
duration = 0;
}

// time to decompress the next frame?
if ((cfg.millis - status->last_millis) < duration) return;
if (frames == 1 && status->loops != 0) return;

// need to loop/reset first?
if (status->frame >= frames) {
heatshrunk_sprite_reset(status, hs_spr_n);
status->loops += 1;
}

status->frame++;
status->last_millis = cfg.millis;
size_t count = 0;
uint16_t remaining = GIF2H_NUM_PIXELS;
while (remaining) {
if (status->heatsunk < datasize) {
heatshrink_decoder_sinkP(&status->hsd, &spr_ptr->hs_data[status->heatsunk],
datasize - status->heatsunk, &count);
status->heatsunk += count;
}
HSD_poll_res pres;
do {
pres = heatshrink_decoder_poll(&status->hsd, 0, remaining, &count);
remaining -= count;
} while ((pres == HSDR_POLL_MORE) && remaining);
}
}

void heatshrunk_sprite_plot(int8_t xstart, int8_t ystart, struct hstatus * status, const uint8_t fade_speed) {
CRGB pal = (CRGB ) status->palette;
uint8_t *pixels = status->hsd.buffers + HEATSHRINK_STATIC_INPUT_BUFFER_SIZE;
// plot each sprite pixel modulo the size of the matrix, i.e. wrap the image
for (uint8_t y = 0; y < kMatrixHeight; y++) {
for (uint8_t x = 0; x < kMatrixWidth; x++) {
uint16_t led_index = XY(addmod8(x, xstart, kMatrixWidth),
addmod8(y, ystart, kMatrixHeight));
uint8_t palette_index = *pixels++;
if (palette_index > 0 && cfg.effect != 1) {
// non-0 palette entry; copy the palette entry to the LED
leds[led_index] = pal[palette_index];
} else {
// palette entry 0 is the mask; fade this LED to the mask colour (black usually)
fadeTowardColour(leds[led_index], pal[palette_index], fade_speed);
}
}
}
}

void scintillating_heatshrink() {
static uint8_t spr_n = 0;
static uint32_t last_spr_change = 0;
if ((hstatus.loops > 0) && (cfg.millis - last_spr_change > 2500)) {
last_spr_change = cfg.millis;
spr_n = addmod8(spr_n, 1, HSPRITES_N);
}
heatshrunk_sprite_prepare(&hstatus, spr_n);
heatshrunk_sprite_plot(0, 0, &hstatus, cfg.alpha_fade);
}

Code Explanation :

  • Settings (#define lines): LED_PIN is the data pin (D2), kMatrixWidth and kMatrixHeight set the 16×16 size, BRIGHTNESS is the starting brightness, MS_GOAL is the time per frame (20 ms, about 50 frames per second) and FADEIN_FRAMES makes the display fade in at power-on.
  • XY() function: Converts an (x, y) position into an LED number. If your panel zig-zags, you change the setting that this function reads (see Troubleshooting).
  • leds[] array: Holds the colour of all 256 pixels. Everything is drawn here first, and FastLED.show() then sends it to the matrix.
  • fadeTowardColour(): Moves a pixel part of the way towards a target colour. It fades the empty background pixels of a sprite.
  • HSprite and hsprite_list: Each animation is stored in flash memory with a small header (frame count, frame duration, palette size) and its compressed pixel data. hsprite_list lists all 41 sprites from gifs.h.
  • Config cfg: Keeps the settings you can change live: display mode, palette, fade rate and brightness.
  • setup(): Starts FastLED, turns off colour correction and dithering, applies the optional power limit, opens Serial at 250000 baud and prints the list of keys.
  • loop(): Draws the rainbow and/or the sprite, reads any key you type, fades the brightness in during the first 32 frames, then waits so each frame lasts 20 ms. The Nano’s built-in LED is on while it waits, so it shows how much spare time each frame has.
  • rainbow_smoothie(): Works out a colour for every pixel using sine and cosine waves, so the rainbow ripples and flows.
  • scintillating_heatshrink(): Picks the current sprite, moves on to the next every 2.5 seconds, decompresses its next frame and draws it.
  • heatshrunk_sprite_prepare(): Checks whether it is time for the next frame, restarts the animation when it ends, and decompresses 256 pixels, one per LED.
  • heatshrunk_sprite_plot(): Pixels with a palette number above 0 are copied to the matrix. Palette entry 0 is the mask (the empty background, usually black), and those pixels are faded instead.
  • poll_serial(): Reads one character from Serial and runs the matching command.

How the Compression Works :

The raw pixel data for all 41 animations adds up to well over 70 KB, more than twice the 32 KB of flash on a Nano. This project solves that with heatshrink, a small compression library that stores repeated patterns as short references to data it has already unpacked. Pixel art repeats colours a lot, so it compresses very well. The 59-frame twylogo animation shrinks from 15,131 bytes of raw pixels to 743 bytes.

  • Colour palette: Each sprite carries a small palette. Each pixel is stored as one number pointing into that palette, not as a full RGB colour.
  • Compressed in flash: The compressed data stays in program memory and is read straight from there.
  • One frame at a time: The decoder unpacks only 256 pixels (one frame) when needed, so very little RAM is used.
  • Fixed memory: heatshrink_config.h turns off dynamic memory and sets a 256-byte window (the size of one 16×16 frame) and a 32-byte input buffer.

RAM is always tight on the Nano’s ATmega328 chip, and the LED array alone uses 768 bytes (256 LEDs × 3 bytes). That is why this careful approach matters.

Serial Controls :

Open Tools → Serial Monitor, set the speed to 250000 baud, type a key and press Send. The Nano prints the list of keys when it starts. Choose “No line ending” to send single letters.

KeyWhat It Does
eSwitch display mode (rainbow only, sprites only, sprites over rainbow)
pNext rainbow palette (3 available)
~Next rainbow motion preset (2 available)
#Randomise the rainbow motion
, and .Decrease / increase colour cycling rate (small steps)
< and >Decrease / increase colour cycling rate (big steps)
b and BBrightness down / up
f and FLower / raise the fade rate of background pixels
{ and }Palette blending: linear / none
[ and ]Smooth extended colour mixing: on / off
d and DDithering: off / on
Enter (carriage return)Print the current settings and frame rate

Note: The help text printed at start-up shows the {} and [] labels the other way round. This table follows what the code actually does. To print the settings, choose “Carriage return” and send any message.

Troubleshooting :

ProblemWhat to Try
Nothing lights upCheck you used DIN (not DOUT), the data wire is on D2, and the matrix has 5V and shared GND
Upload fails (“not in sync”)Choose Processor ATmega328P (Old Bootloader), then check the Port
Compile error about missing filesThe folder name must be scintillating_heatshrink and all six files must be inside it
Compile error about ColorFromPaletteExtendedUpdate FastLED in the Library Manager
Sketch too big or memory warningUse a normal Nano with an ATmega328P, and don’t add more than the original 41 sprites
Nano keeps restarting or the display flickersPower supply is too weak. Lower BRIGHTNESS or use an external 5V supply
Picture looks scrambled or every second row is reversedChange XY_MATRIX to (ROWMAJOR | SERPENTINE)
Picture is mirrored or upside downAdd FLIPMAJOR and/or FLIPMINOR, e.g. (ROWMAJOR | SERPENTINE | FLIPMINOR)
Colours are wrong (red shows as green)Change COLOR_ORDER from GRB to RGB or BRG
First LED flickers randomlyAdd the 330 Ω resistor on the data wire and the 1000 µF capacitor across 5V and GND

Customisation Tips :

  • Change brightness: Edit BRIGHTNESS (0–255), or use b and B while it runs
  • Limit power: Set MAX_MILLIWATTS to what your supply can handle (0 means no limit)
  • Change the data pin: Edit LED_PIN and move the green wire to match
  • Change the sprite time: In scintillating_heatshrink(), change 2500 (milliseconds) to show each animation for longer or shorter
  • Choose your favourites: Remove or reorder lines in hsprite_list to play only the animations you like
  • Add your own sprites: Sprites must be 16×16 and compressed with heatshrink using the settings in the comments of gifs.h (-w 8 -l 7)

Conclusion :

In this project, we built a pixel art animation display using an Arduino Nano and a 16×16 WS2812B LED matrix. By compressing 41 animated sprites with heatshrink and decoding them on the fly, the Nano plays them over a flowing rainbow without running out of its tiny 2 KB of RAM. If you build one, try the serial keys and change the settings in the Customisation Tips. Then see how your own animations look.

Visit Tinkercircuits.com for more exciting projects!

Happy Tinkering!

The Tinker Circuits Team

The animation code is based on the open-source scintillating_heatshrink by sutaburosu (https://github.com/sutaburosu/scintillating_heatshrink/), which uses the heatshrink library by Scott Vokes.

Related Articles

Leave a Reply

Your email address will not be published. Required fields are marked *

Back to top button