Elektrischer Fackelmob

Die elektrische Mobfackel entstand als Teil der Partybeleuchtung für die Einweihungsparty unseres frisch gegründeten Hackspaces Binary Kitchen in Regensburg. Sie besteht einem Stück Abflussrohr, um das ca. 6 Meter RGB-Led Band gewickelt sind, das von einem Mikrocontroller gesteuert wird, der eine Feueranimation darauf berechnet, so dass es eben nach einer Fackel aussieht. Das ganze steckt auf einem Wischmob.

Was ist das für ein LED-Band?

Im Gegensatz zu "normalen" LED-Bändern aus dem Baumarkt, lassen sich auf diesem Band alle LEDs unabhängig voneinander ansteuern. Die LEDs, die hier verbaut sind, heißen WS2812b, und haben jeweils einen eigenen Controller in ihrem 5050 Gehäuse, der das PWM Signal für die einzelnen Farben (rgb) erzeugt und seriell programmiert wird. Hierzu verfügt jede LED-Einheit über einen seriellen Eingang und einen seriellen Ausgang. Sobald eine LED 24 Bit (je 8 Bit RGB) über ihren Eingang empfangen hat, verhält sie sich transparent, und schaltet das Eingangssignal auf den Ausgang durch. Da alle LED-Einheiten auf dem Streifen in Reihe geschaltet sind, bekommt dann die nächste LED das Signal, und so weiter. Ein neuer Frame beginnt nach 50 μs Sendepause auf der Datenleitung. Man beschreibt den Streifen mit einer konstanten Bitrate von 800 kHz.

Die 800 kHz machen es etwas schwieriger. Auf der einen Seite braucht es die hohe Frequenz, weil man die LEDs nicht einzeln adressieren kann, und man immer von vorne beginnend eine LED nach der anderen schreiben muss. Je länger der Streifen wird, desto niedriger wird dann auch die Framerate. Deshalb ist eine hohe Frequenz von Vorteil. Auf der anderen Seite sind 800 khz echt unhandlich. Die Routine, die die Daten rausschreibt, ist in Assembler geschrieben und manuell auf den Takt der MCU (16 Mhz hier) angepasst (mit NOPs aufgefüllt). Damit klappt es recht gut.

Weil ich gelegentlich gefragt werde, ob das auch mit einem Raspi funktioniert: Eher nicht ohne extra Hardware. Das liegt zum Einen an der fehlenden Echtzeit, und zum Anderen - selbst mit Echtzeitkernel - wäre das Betriebssystem bei einem Systemtakt von "nur" 700 Mhz wahrscheinlich nicht schnell genug, um den hohen Takt am Ausgang zuverlässig zu erzeugen. Ohne Betriebssystem könnte es möglicherweise funktionieren, aber was man so liest, unterbricht der Grafikchip des Raspi die CPU wohl per NMI. Wenn man dieses Verhalten nicht abgeschaltet bekommt, hängen die Erfolgschancen davon ab, wie lang und wie häufig genau diese Unterbrechungen sind.

Die Hardware

Ich verwende hier einen Arduino Micro, weil er ausreicht und gerade da war. Auf dem Micro läuft ein ATmega32u4 mit 2.5 kb RAM. Der RAM reicht gerade so für die Fackel.

Der Algorithmus

Geeignete Farbpaletten für algorithmisches Feuer erhält man am einfachsten, indem man über den HSL-Farbraum iteriert. Der Bequemlichkeit halber lasse ich die Palette direkt vom Mikrocontroller ausrechnen. So kann ich schnell mit den Parametern herumspielen, ohne jedes mal eine Menge Konstanten importieren zu müssen. Wenn man z.B. schnell mal eine grüne Schleimfackel braucht - kein Problem. :-)

# meine Feuerpalette erzeuge ich wie folgt, in Pseudocode:
for (i = 0; i < 128; i++) {
    hue = i / 5;
    sat = 255;
    lum = i * 2 > 128 ? 128 : i * 2;
    ...
}

Für die Animation teile ich das Display in Zeilen (y-Achse) und Spalten (x-Achse) ein. Der Umfang des Abflussrohrs beträgt etwa 15 LEDs, und die Höhe 23 LEDs, also hat das Display eine Auflösung von 15 * 23 = 245 Pixel. Das Feuer wird live berechnet. Der Algorithmus startet am oberen Ende, iteriert der Reihe nach von oben nach unten über jeden Pixel. Die aktuelle Pixelfarbe ergibt sich aus einer Interpolation über die umgebenden Werte minus einen Faktor, der den Energieverlust des Feuers auf dem Weg nach oben repräsentiert. Diese Berechnung läuft folgendermaßen:

[x, y]
[x-1, y -1][x, y-1][x+1, y+1]
[x, y-2]

fire[y][x] = (fire[y-1][x-1] + fire[y-1][x] + fire[y-1][x+1] + fire[y+1][x+2]) / (4 + f)

Ein positives f sorgt dafür, dass nach oben von Zeile zu Zeile etwas Energie verloren geht, sodass das Feuer nach oben langsam ausklingt. Zu den Seiten sorgt ein modulo ScreenWidth dafür, dass dort keine Energie verloren geht, und für den Algorithmus das Display keine Ränder hat.

Damit der Algorithmus Werte zu interpolieren (bzw. Energie zu transportieren) bekommt, werden auf der untersten Zeile zufällig und beständig "Zündfunken" mit hohen Werten eingefügt. Auf die Platte gemappt ist diese "Glut" weiß bis gelb, und sieht sehr künstlich aus. Der gewünschte Feuereffekt entsteht erst frühestens ein bis zwei Zeilen darüber. Um den Gesamteindruck zu verbessern, beginnt die Berechnung deshalb in Wirklichkeit zwei Zeilen unter dem auf der Fackel sichtbaren Bereich (vgl. extraLines im Code unten).

Nach jedem vollständigen Durchlauf der Interpolationsroutine werden die aktuellen Energiewerte aller Pixel durch die Palette auf RGB-Werte für die LEDs gemapped und das Bild auf das Band geschrieben. Danach folgt eine kurze Pause. Die Länge dieser Pause trägt auch entscheidend zum Eindruck des Feuers bei. Das muss man einfach herumprobieren.

Bis hier hin ergibt das schon eine schöne Feuer-Grundanimation. Speziell an meiner Fackel ist noch, dass sie gelegentlich Feuerbälle, bzw. "Funken" nach oben ausspuckt. Ich berechne dafür eine zweite Energiematrix, die anders interpoliert werden, anderen Energieverlust haben, und in größeren Abständen gezündet werden. Kurz vor dem Kopieren auf das Display werden beide Energiematrizen zusammengerechnet. Auch hier muss man viel experimentieren, um ein Gefühl dafür zu bekommen, wie ein guter Effekt entsteht.

Hier ist das Ergebnis. Die Dynamik des Kamerasensors macht da leider schlapp, deshalb die Störungen.


Mobfackel in Aktion / Kamerasensor am Limit

Der Code

Hier ist der Code zum selbst dran herumspielen.

Das Programm funktioniert im Wesentlichen wie oben beschrieben. Der Datentypen wegen (ich rechne beim Interpolieren mit ganzen Zahlen), bilde ich die Division durch 4 - f auf eine Multiplikation mit anschließender Division ab. Das Ergebnis ist aber dasselbe. Für den Zufall nehme ich einen minimalistischen Pseudozufallszahlen-Generator und initialisiere ihn in Ermangelung von Entropie mit einem statischen Seed. Eigentlich ist es also gar kein Zufall. Um das LED-Band anzusteuern, verwende ich die light_ws2812 Bibliothek von cpldcpu auf Github.

#define F_CPU 16000000

#include <util/delay.h>
#include <avr/io.h>
#include <avr/interrupt.h>
#include "light_ws2812.h"
#include <stdlib.h>

#define screenWidth 15
#define screenHeight 23
#define extraLines 2

uint8_t palette[384];

void hsl_to_rgb(uint32_t hue, uint32_t sat, uint32_t lum, uint8_t* r, uint8_t* g, uint8_t* b) {
    uint32_t v;

    v = (lum < 128) ?
        (lum * (256 + sat)) >> 8 :
        (((lum + sat) << 8) - lum * sat) >> 8;

    if (v <= 0) {
        *r = *g = *b = 0;
    } else {
        int32_t m;
        int32_t sextant;
        int32_t fract, vsf, mid1, mid2;

        m = lum + lum - v;
        hue *= 6;
        sextant = hue >> 8;
        fract = hue - (sextant << 8);
        vsf = v * fract * (v - m) / v >> 8;
        mid1 = m + vsf;
        mid2 = v - vsf;
        switch (sextant) {
           case 0: *r = v; *g = mid1; *b = m; break;
           case 1: *r = mid2; *g = v; *b = m; break;
           case 2: *r = m; *g = v; *b = mid1; break;
           case 3: *r = m; *g = mid2; *b = v; break;
           case 4: *r = mid1; *g = m; *b = v; break;
           case 5: *r = v; *g = m; *b = mid2; break;
        }
    }
}

#define BIT_S(var,b) ((var&(1<<b))?1:0)

uint8_t easy_random() {
    static uint16_t m = 0xaa;

    uint8_t x;
    for (x = 0; x < 8; x++){
        m = (m<<1) ^ BIT_S(m,1) ^ BIT_S(m,8) ^ BIT_S(m,9) ^ BIT_S(m,13) ^ BIT_S(m,15);
    }
    return (uint8_t) m;
}

struct CRGB { uint8_t g; uint8_t r; uint8_t b; };

struct CRGB led[screenWidth * screenHeight];
uint8_t fire[screenHeight + extraLines][screenWidth];
uint8_t sparks[screenHeight + extraLines][screenWidth];

int main() {
    CLKPR = _BV(CLKPCE);
    CLKPR = 0;

    DDRB |= _BV(PB4);

    uint8_t x, y;

    uint8_t r, g, b;
    uint16_t i,j;

    // generate palette 
    for (i = 0; i < 128; i++) {
        hsl_to_rgb(i / 5, 255, i * 2> 128 ? 128: i * 2, &r, &g, &b);
        g = g == 1 ? 0 : g;
        b = b == 1 ? 0 : b;
        palette[i*3] = r;
        palette[(i*3)+1] = g;
        palette[(i*3)+2] = b;
    }
    
    // init screen
    for (y = 0; y < screenHeight + extraLines; y++)
        for (x = 0; x < screenWidth; x++) {
            fire[y][x] = 0;
            sparks[y][x] = 0;
        }

    while (1) {
        // seed coal and sparks
        for (x = 0; x < screenWidth; x++) {
            fire[0][x] = easy_random() > 150 ? 255 : 0;
            if (easy_random() % 2 == 0 && easy_random() > 254) {
                sparks[1][x] = 128;
                sparks[1][x+2] = 128;
            }
        }

        // interpolate
        for (y = (screenHeight + extraLines - 1); y > 0; y--) {
            for (x = 0; x < screenWidth; x++) {
                fire[y][x] =  ((
                                fire[(y-1) > 0 ? (y-1) : 0][(x-1) % screenWidth] +
                                fire[(y-1) > 0 ? (y-1) : 0][x] +
                                fire[(y-1) > 0 ? (y-1) : 0][(x+1) % screenWidth] +
                                fire[(y-2) > 0 ? (y-2) : 0][x]
                ) * 8) / 39;

                sparks[y][x] =  ((
                                (sparks[(y-1) > 0 ? (y-1) : 0][(x-1) % screenWidth] / 4) +
                                (sparks[(y-1) > 0 ? (y-1) : 0][(x+1) % screenWidth] / 4) +
                                (sparks[(y-1) > 0 ? (y-1) : 0][x] * 4) +
                                (sparks[(y-2) > 0 ? (y-2) : 0][x])
                ) * 1) / 5;
            }
        }

        // copy fire to screenbuffer
        for (x = 0; x < screenWidth; x++) {
            for (y = 0; y < screenHeight; y++) {
                uint16_t c, i;
                i = screenWidth * y + x;
                c = (((fire[y+extraLines][x] + sparks[y+extraLines][x]) / 2) * 3);
                led[i].r = palette[c];
                led[i].g = palette[c+1];
                led[i].b = palette[c+2];
            }
        }

        // write screenbuffer
        ws2812_sendarray((uint8_t *)&led[0], sizeof(led));
        _delay_ms(45);
    }

    return 0;
}

Viel Spaß beim Nachbasteln! :)

Noch mehr Spaß mit WS2812b LED-Bändern: Der Blinkenjector!