This week I’ve been building the physical interactive interface for my final project. Last week, I tried using a 3D LED array, but I realized that individually controlling every LED would require too many microcontroller output pins. So in the end I decided to use an LED matrix panel as the hardware foundation for my final physical interface. I also experimented with using animations on the LED panel to simulate effects like the inertia of water. Below I’ll go into detail about my code and the physical model.
I didn’t want to give up on my earlier “beaker” idea, so I turned the whole enclosure into a beaker shape—just changing it from the original 3D form to a 2D silhouette.

What it looks like after installation:


The wiring for WS2812 is very simple: I just needed to connect power (+/−) and the data input pin. What surprised me is that the Arduino Nano 33’s built-in 3.3V power rail can drive the entire panel—it’s more powerful than I expected.
The remaining part is the Arduino code. I originally thought this would be very complicated, but Adafruit already has a library that fits my use case well. You can check their GitHub here:
https://github.com/adafruit/Adafruit_PixelDust
GitHub - adafruit/Adafruit_PixelDust: Library-ified version of "LED sand" code
So my code became very straightforward:
#include <FastLED.h>
#include <Arduino_LSM6DS3.h>
#include <Adafruit_PixelDust.h>
#define LED_PIN 6
#define NUM_LEDS 256
#define MATRIX_W 16
#define MATRIX_H 16
#define BRIGHTNESS 60
#define N_GRAINS 85
CRGB leds[NUM_LEDS];
Adafruit_PixelDust dust(MATRIX_W, MATRIX_H, N_GRAINS, 1, 64, false);
int XY(int x, int y) {
if (y % 2 == 0) return y * MATRIX_W + x;
else return y * MATRIX_W + (MATRIX_W - 1 - x);
}
void setup() {
Serial.begin(9600);
FastLED.addLeds<WS2812B, LED_PIN, GRB>(leds, NUM_LEDS);
FastLED.setBrightness(BRIGHTNESS);
FastLED.clear();
FastLED.show();
if (!IMU.begin()) {
while (1) {
fill_solid(leds, NUM_LEDS, CRGB::Red);
FastLED.show(); delay(300);
FastLED.clear(); FastLED.show(); delay(300);
}
}
if (!dust.begin()) {
while (1) {
fill_solid(leds, NUM_LEDS, CRGB::Yellow);
FastLED.show(); delay(300);
FastLED.clear(); FastLED.show(); delay(300);
}
}
dust.randomize();
}
void loop() {
float ax, ay;
if (IMU.accelerationAvailable()) {
float az;
IMU.readAcceleration(ax, ay, az);
int16_t pdX = (int16_t)(ay * 8000);
int16_t pdY = (int16_t)(ax * 8000);
dust.iterate(pdX, pdY, 0);
}
FastLED.clear();
dimension_t x, y;
for (int i = 0; i < N_GRAINS; i++) {
dust.getPosition(i, &x, &y);
leds[XY(x, y)] = CRGB(255, 255, 255);
}
FastLED.show();
}
The final effect wasn’t as good as I expected:
894b6c3e3b795db1544685989206c6c1_raw.mp4
Because each LED looked more like grains of sand than water.
So I thought again: maybe I could compute the water surface boundary line, and then turn on all LEDs below that boundary.
New code: