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.

image.png

What it looks like after installation:

c00c439195bcba29fd4df0fa347c4ff4.jpg

5a2720101c36d0a2e274c31585e96b8e.jpg

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: