Obrotowy Wyświetlacz Sceniczny DMX

Typ_projektu
Arduino
Zdjecie główne
Krótki opis projektu

W tym projekcie zbudowaliśmy od zera własny, w pełni funkcjonalny ekran sceniczny oparty na matrycach LED, którym można sterować na odległość. Co najlepsze – nie potrzebujemy do tego drogiej konsolety oświetleniowej, całością sterujemy prosto z komputera za pomocą na przykład darmowej aplikacji Qlc+, choć każda, która umożliwia sterowanie DMX zadziała równie dobrze. Co więcej, dołożyliśmy do konstrukcji serwomechanizm, dzięki któremu wyświetlacz może się obracać na statywie! Zależało nam, aby całość była stabilna , a obraz na ekranie stanowił jednolitą całość bez przerw i ucięć. Projekt łączy projektowanie 3D, obróbkę drewna, lutowanie oraz programowanie mikrokontrolera.

Niezbędne elementy

Mechanika i obudowa:

  • Wydruki 3D: Zaprojektowaliśmy je w programie Fusion. W plikach projektu znajdziecie gotowe modele, które wystarczy po prostu puścić na drukarkę 3D. Obejmują one m.in. główny element podstawy , wsporniki , obudowę serwa i element do mocowania ekranu.
  • Drewno: Dwa zwykłe drewniane wałki o długości 1 metra i średnicy 28 mm.

  • Złącza: Mosiężne gwinty (tzw. inserty) do wtopienia w plastik oraz zestaw śrubek.
Sprzęt

Elektronika:

  • Matryce LED: Dwa panele, które połączymy w jeden duży ekran.

  • Serwomechanizm: To on będzie kręcił naszym wyświetlaczem.
  • Zasilacz: Użyliśmy zewnętrznego zasilacza 5V / 10A, ponieważ same panele LED pożerają sporo prądu.

  • Gniazdo XLR: Standardowa wtyczka do odbioru sygnału DMX.

  • Mikrokontroler i komunikacja: Płytka (np. ESP32) oraz bardzo ważny układ MAX-485 – to on działa jak tłumacz i pozwala mikrokontrolerowi zrozumieć sygnał DMX płynący z naszego komputera.

Opis projektu

Montaż krok po kroku

Zaczynamy od wydrukowania wszystkich plastikowych części. Żeby łączenia były pancerne i wytrzymały warunki sceniczne, używamy lutownicy, aby wtopić metalowe gwinty (inserty) w przygotowane w wydrukach otwory. Następnie bierzemy jeden z dwóch drewnianych wałków i tniemy go na 3 równe kawałki. To będą nasze nogi. Wciskamy je w wydrukowany z plastiku trójnik (główny element podstawy) i skręcamy. Na końcówki nóg zakładamy wydrukowane nakładki ochronne. W górny otwór trójnika wkładamy drugi, cały metrowy wałek. Łączymy panele LED i skręcamy je tak, żeby z przodu obraz stanowił jednolitą całość. Z tyłu ekranu przykręcamy specjalny uchwyt w kształcie litery "X" (element mocowania). Serwomechanizm zamykamy w jego wydrukowanej obudowie , mocujemy na szczycie drewnianego masztu i łączymy go z "krzyżakiem" na plecach ekranu. Wszystkie moduły łączymy tak, by w razie modyfikacji można było łatwo odpiąć poszczególne elementy. Do układu podpinamy zasilanie z zasilacza 5V/10A oraz lutujemy gniazdo XLR, zamykając je w wydrukowanej osłonie na statywie. 

Nasz mikrokontroler, korzystając z układu MAX-485, "nasłuchuje" komend DMX wysyłanych z programu na komputerze. W trakcie prac napotkaliśmy na dwa główne wyzwania, z którymi się uporaliśmy:

  1. Problem z pasami na ekranie: Początkowo obraz miał uciążliwe pasy. Poprawiliśmy to w kodzie, optymalizując przesyłanie danych (tzw. podwójne buforowanie), dzięki czemu obraz jest teraz idealny.

  2. Sterowanie obrotem: Dołożyliśmy do programu dodatkowy kanał DMX, który służy wyłącznie do sterowania obrotem serwomechanizmu. Sygnał jest programowo ograniczony do bezpiecznego zakresu, co chroni sprzęt przed uderzeniem o własny statyw.

Tabela Kanałów DMX Aby sterować wyświetlaczem z komputera, urządzenie zajmuje 8 wirtualnych suwaków w programie oświetleniowym:

Kanał                     Funkcja                 (Zakres suwaka 0-255)

1                   Jasność główna             Płynne ściemnianie całego ekranu (0% - 100%).

2                   Czerwony                        Intensywność koloru czerwonego.

3                   Zielony                            Intensywność koloru zielonego.

4                   Niebieski                         Intensywność koloru niebieskiego.

5                  Stroboskop                 Poniżej 10: wyłączony. Im wyżej przesuniesz suwak,

tym szybciej ekran błyska.

6                  Tryb Pracy                   Wybór efektu: np. stały kolor, przejście tęczy, kostka 3D.

7             Prędkość                        Kontroluje szybkość animacji z kanału 6.

8             Obrót (Pan)                 Przesuwanie suwaka na ekranie komputera fizycznie obraca

urządzenie w rzeczywistości!

 

Konstrukcja jest gotowa i przetestowana. Po wpięciu zasilania i podłączeniu interfejsu z komputera do gniazda XLR urządzenie reaguje na sygnał. Z poziomu oprogramowania na PC możemy płynnie kręcić głowicą i zmieniać to, co wyświetla się na matrycy. Stojak spełnia swoje zadanie i jest stabilny.

Jeżeli wystarczy czasu, w przyszłości planujemy dodać opcję wyświetlania animacji (np. plików GIF) oraz zaprojektować dodatkową małą obudowę ukrywającą same kable i mikrokontroler.

Zdjęcia
kod programu
#include <ESP32-HUB75-MatrixPanel-I2S-DMA.h>
#include <ESP32-VirtualMatrixPanel-I2S-DMA.h>
#include <Adafruit_GFX.h>
#include <LXESP32DMX.h>
#include <ESP32Servo.h>

// ───── MAPOWANIE PINÓW ESP32 DLA MATRYCY HUB75 ───────────────────
#define P_LAT  4
#define P_OE   2
#define P_A   13
#define P_B   21
#define P_C   17
#define P_D   12
#define P_E   -1
#define P_CLK 16
#define P_R1  26
#define P_G1  18
#define P_B1  19
#define P_R2  23
#define P_G2  14
#define P_B2  5

// ───── KONFIGURACJA ZEWNĘTRZNYCH MODUŁÓW ─────────────────────────
#define DMX_RX_PIN 35   // Pin odbiornika sygnału DMX (MAX485)
#define SERVO_PIN  25   // Pin sterujący PWM dla serwomechanizmu

// =================================================================
// ───── ZARZĄDZANIE MOCĄ (LIMITER PRĄDOWY) ────────────────────────
// Ograniczenie maksymalnej jasności sprzętowej (0-255). 
// Zapobiega to spadkom napięcia zasilacza przy zapaleniu wszystkich diod na biało,
// co eliminujue efekt migotania i restartów mikrokontrolera.
const uint8_t MAX_GLOBAL_BRIGHTNESS = 200; 
// =================================================================

// ───── KONFIGURACJA GEOMETRII PANELI LED ─────────────────────────
// Ustawienia kompensujące fizyczne ułożenie paneli w łańcuchu (Daisy Chain).
const bool FLIP_ENTIRE_SCREEN = true;  // Globalny obrót całego obrazu o 180 stopni
const bool SWAP_PANELS  = false;       // Zamiana logiczna połówek ekranu
const bool FLIP_PANEL_1 = false;       // Orientacja pierwszego panelu
const bool FLIP_PANEL_2 = true;        // Orientacja drugiego panelu (odwrócony fizycznie)
// =================================================================

#define PANEL_WIDTH   64
#define PANEL_HEIGHT  32
#define PANELS_NUMBER  2 

#define NUM_ROWS 2 
#define NUM_COLS 1 

#define MATRIX_WIDTH  (PANEL_WIDTH * NUM_COLS)
#define MATRIX_HEIGHT (PANEL_HEIGHT * NUM_ROWS)

MatrixPanel_I2S_DMA dma_display;

// ───── KLASA MAPUJĄCA PIKSELE (Zarządzanie nietypowym układem) ───
// Rozszerzenie biblioteki wirtualnej matrycy pozwalające na 
// twarde mapowanie współrzędnych X/Y w locie, naprawiające artefakty na łączeniach paneli.
class ConfigurableMatrix : public VirtualMatrixPanel {
public:
  ConfigurableMatrix(MatrixPanel_I2S_DMA &disp, int rows, int cols, int pWidth, int pHeight)
    : VirtualMatrixPanel(disp, rows, cols, pWidth, pHeight, CHAIN_TOP_LEFT_DOWN) {}

  void drawPixel(int16_t x, int16_t y, uint16_t color) override {
    // Strażnik pamięci - odrzuca piksele rysowane poza rozdzielczością ekranu
    if (x < 0 || x >= MATRIX_WIDTH || y < 0 || y >= MATRIX_HEIGHT) {
        return; 
    }

    if (FLIP_ENTIRE_SCREEN) {
        x = (MATRIX_WIDTH - 1) - x;
        y = (MATRIX_HEIGHT - 1) - y;
    }

    int16_t targetX = x;
    int16_t targetY = y;

    if (SWAP_PANELS) {
      if (targetY < 32) targetY += 32;
      else targetY -= 32;
    }

    // Korekta inwersji dla konkretnych paneli składowych
    if (targetY < 32) { 
      if (FLIP_PANEL_1) {
        targetX = 63 - targetX;
        targetY = 31 - targetY;
      }
    } else { 
      if (FLIP_PANEL_2) {
        targetX = 63 - targetX;
        targetY = 95 - targetY; 
      }
    }
    
    VirtualMatrixPanel::drawPixel(targetX, targetY, color);
  }
};

ConfigurableMatrix virtualDisp(dma_display, NUM_ROWS, NUM_COLS, PANEL_WIDTH, PANEL_HEIGHT);

Servo panServo; 

// Struktura pomocnicza do definiowania palet kolorów
struct RGB { uint8_t r; uint8_t g; uint8_t b; };

// Parametry i wierzchołki trójwymiarowej kostki dla trybu animacji 3D
float cube[8][3] = {
  {-1, -1, -1}, {1, -1, -1}, {1, 1, -1}, {-1, 1, -1},
  {-1, -1, 1}, {1, -1, 1}, {1, 1, 1}, {-1, 1, 1}
};
float angleX = 0, angleY = 0, angleZ = 0;
float scale = 11.0; 
float projected[8][2];
float rotVelX = 0.02, rotVelY = 0.03, rotVelZ = 0.025;
float posX = MATRIX_WIDTH / 2, posY = MATRIX_HEIGHT / 2;
float velX = 0.4, velY = 0.4;

// Zmienne przechowujące aktualne stany z protokołu DMX-512
uint8_t chDimmer = 0, chR = 0, chG = 255, chB = 0;
uint8_t chStrobe = 0, chMode = 90, chSpeed = 127, chServo = 127;

unsigned long lastDebugPrint = 0; 

// Funkcja przeliczająca barwę ze skali Hue (0-255) na format RGB
void hueToRGB(uint8_t hue, uint8_t &r, uint8_t &g, uint8_t &b) {
  if (hue < 85) { r = hue * 3; g = 255 - hue * 3; b = 0; } 
  else if (hue < 170) { hue -= 85; r = 255 - hue * 3; g = 0; b = hue * 3; } 
  else { hue -= 170; r = 0; g = hue * 3; b = 255 - hue * 3; }
}

// Funkcja renderująca rotującą, obijającą się o krawędzie ekranu kostkę 3D
void drawCube(float speedMultiplier, uint16_t color) {
  float maxX = -1000, minX = 1000, maxY = -1000, minY = 1000;
  for (int i = 0; i < 8; i++) {
    float x = cube[i][0], y = cube[i][1], z = cube[i][2];
    
    // Rzutowanie macierzy obrotu 3D
    float ry = cos(angleX) * y - sin(angleX) * z;
    float rz = sin(angleX) * y + cos(angleX) * z;
    float rx = cos(angleY) * x + sin(angleY) * rz;
    rz = -sin(angleY) * x + cos(angleY) * rz;
    float finalX = cos(angleZ) * rx - sin(angleZ) * ry;
    float finalY = sin(angleZ) * rx + cos(angleZ) * ry;
    
    // Skalowanie do przestrzeni 2D ekranu
    projected[i][0] = finalX * scale + posX;
    projected[i][1] = finalY * scale + posY;
    maxX = max(maxX, projected[i][0]); minX = min(minX, projected[i][0]);
    maxY = max(maxY, projected[i][1]); minY = min(minY, projected[i][1]);
  }
  
  // Fizyka kolizji ze ścianami
  if (maxX >= MATRIX_WIDTH - 1)  { velX = -abs(velX); rotVelY += abs(velX) * 0.04; rotVelZ += random(-2, 2) * 0.01; }
  if (minX <= 0)                 { velX = abs(velX);  rotVelY += abs(velX) * 0.04; rotVelZ += random(-2, 2) * 0.01; }
  if (maxY >= MATRIX_HEIGHT - 1) { velY = -abs(velY); rotVelX += abs(velY) * 0.04; rotVelZ += random(-2, 2) * 0.01; }
  if (minY <= 0)                 { velY = abs(velY);  rotVelX += abs(velY) * 0.1;  rotVelZ += random(-5, 5) * 0.01; }
  rotVelX *= 0.99; rotVelY *= 0.99; rotVelZ *= 0.99;
  
  posX += velX * speedMultiplier; posY += velY * speedMultiplier;
  angleX += rotVelX * speedMultiplier; angleY += rotVelY * speedMultiplier; angleZ += rotVelZ * speedMultiplier;
  
  // Rysowanie krawędzi bryły
  for (int i = 0; i < 4; i++) {
    int next = (i + 1) % 4;
    virtualDisp.drawLine(projected[i][0], projected[i][1], projected[next][0], projected[next][1], color); 
    virtualDisp.drawLine(projected[i+4][0], projected[i+4][1], projected[next+4][0], projected[next+4][1], color); 
    virtualDisp.drawLine(projected[i][0], projected[i][1], projected[i + 4][0], projected[i + 4][1], color); 
  }
}

void setup() {
  Serial.begin(115200);

  // Inicjalizacja magistrali I2S i mapowanie wyprowadzeń
  HUB75_I2S_CFG cfg(PANEL_WIDTH, PANEL_HEIGHT, PANELS_NUMBER);
  cfg.gpio.r1 = P_R1; cfg.gpio.g1 = P_G1; cfg.gpio.b1 = P_B1;
  cfg.gpio.r2 = P_R2; cfg.gpio.g2 = P_G2; cfg.gpio.b2 = P_B2;
  cfg.gpio.lat = P_LAT; cfg.gpio.oe = P_OE; cfg.gpio.clk = P_CLK;
  cfg.gpio.a = P_A; cfg.gpio.b = P_B; cfg.gpio.c = P_C;
  cfg.gpio.d = P_D; cfg.gpio.e = P_E;

  // Optymalizacja sygnału w celu eliminacji zjawiska ghostingu
  cfg.i2sspeed = HUB75_I2S_CFG::HZ_10M; // Redukcja taktowania zapobiega gubieniu bitów
  cfg.latch_blanking = 4;               
  cfg.clkphase = false;   
  cfg.double_buff = true; // Aktywacja podwójnego buforowania sprzętowego (eliminuje tearing obrazu)

  dma_display.begin(cfg);
  dma_display.setBrightness8(MAX_GLOBAL_BRIGHTNESS); 
  
  // Ekran startowy
  virtualDisp.fillScreen(0);
  virtualDisp.setTextColor(dma_display.color565(0, 255, 255));
  virtualDisp.setCursor(4, 12);
  virtualDisp.print("DMX IN");
  virtualDisp.setCursor(4, 24);
  virtualDisp.print("READY!");
  
  dma_display.flipDMABuffer(); 
  
  // Uruchomienie nasłuchu na sprzętowym porcie UART
  ESP32DMX.startInput(DMX_RX_PIN);
  
  // Przydzielenie osobnego timera dla generatora PWM, aby nie zakłócał odświeżania matrycy
  ESP32PWM::allocateTimer(3); 
  panServo.setPeriodHertz(50); 
  panServo.attach(SERVO_PIN, 800, 2200); 
  panServo.write(90); 

  Serial.println("System Ready. Rendering Engine Active.");
  delay(1500); 
}

void loop() {
  // Limiter klatek (utrzymuje stabilny framerate animacji)
  static unsigned long lastFrameTime = 0;
  if (millis() - lastFrameTime < 16) return; 
  lastFrameTime = millis();

  // Pobieranie wartości kanałów roboczych z bufora DMX
  chDimmer = ESP32DMX.getSlot(1);
  chR      = ESP32DMX.getSlot(2);
  chG      = ESP32DMX.getSlot(3);
  chB      = ESP32DMX.getSlot(4);
  chStrobe = ESP32DMX.getSlot(5);
  chMode   = ESP32DMX.getSlot(6);
  chSpeed  = ESP32DMX.getSlot(7);
  chServo  = ESP32DMX.getSlot(8); 

  // BEZPOŚREDNIE STEROWANIE POZYCJĄ SERWOMECHANIZMU
  // Zawężenie zakresu (15-145 stopni) zapobiega blokowaniu się zębatek o obudowę
  static int lastServoAngle = -1; 
  int currentServoAngle = map(chServo, 0, 255, 15, 145);

  // Aktualizacja stanu PWM tylko przy fizycznej zmianie wartości z suwaka
  if (currentServoAngle != lastServoAngle) {
    panServo.write(currentServoAngle);
    lastServoAngle = currentServoAngle;
  }

  // Wypisywanie parametrów do portu szeregowego dla celów diagnostycznych
  if (millis() - lastDebugPrint > 500) {
    Serial.print("[DMX] Dimmer: "); Serial.print(chDimmer);
    Serial.print(" | Mode: "); Serial.print(chMode);
    Serial.print(" | Servo Angle: "); Serial.println(currentServoAngle);
    lastDebugPrint = millis();
  }
  
  // Czyszczenie ukrytego bufora klatki
  virtualDisp.fillScreen(0);

  // Logika przerwań dla stroboskopu
  static unsigned long lastStrobe = 0;
  static bool strobeOn = true;
  
  if (chStrobe > 9) {
    int strobeDelay = map(chStrobe, 10, 255, 250, 15);
    if (millis() - lastStrobe > strobeDelay) { strobeOn = !strobeOn; lastStrobe = millis(); }
  } else { strobeOn = true; }

  // Flaga sterująca inicjalizacją trybu piasku (automatów komórkowych)
  static bool wasInSandMode = false;

  // Główny blok renderowania obrazu
  if (strobeOn && chDimmer > 0) {
    
    // Ściemnianie programowe (Software Dimming) - zachowuje stałe obciążenie zasilacza
    uint8_t dimR = (chR * chDimmer) / 255;
    uint8_t dimG = (chG * chDimmer) / 255;
    uint8_t dimB = (chB * chDimmer) / 255;
    uint16_t activeColor = dma_display.color565(dimR, dimG, dimB);

    if (chMode <= 25) { 
      wasInSandMode = false;
      virtualDisp.fillScreen(activeColor); 
    }
    else if (chMode <= 50) {
      wasInSandMode = false;
      static uint8_t fadeHue = 0; static unsigned long lastFade = 0;
      int fadeSpeed = map(chSpeed, 0, 255, 100, 1);
      if (millis() - lastFade > fadeSpeed) { fadeHue++; lastFade = millis(); }
      uint8_t baseR, baseG, baseB; hueToRGB(fadeHue, baseR, baseG, baseB); 
      virtualDisp.fillScreen(dma_display.color565((baseR * chDimmer)/255, (baseG * chDimmer)/255, (baseB * chDimmer)/255));
    }
    else if (chMode <= 75) {
      wasInSandMode = false;
      static int colorIdx = 0; static unsigned long lastJump = 0;
      int jumpDelay = map(chSpeed, 0, 255, 1500, 40);
      RGB palette[] = { {255,0,0}, {0,255,0}, {0,0,255}, {255,255,0}, {0,255,255}, {255,0,255} };
      if (millis() - lastJump > jumpDelay) { colorIdx = (colorIdx + 1) % 6; lastJump = millis(); }
      virtualDisp.fillScreen(dma_display.color565((palette[colorIdx].r * chDimmer)/255, (palette[colorIdx].g * chDimmer)/255, (palette[colorIdx].b * chDimmer)/255));
    }
    else if (chMode <= 100) {
      wasInSandMode = false;
      float sMult = chSpeed > 0 ? (chSpeed / 128.0) : 0.2; 
      drawCube(sMult, activeColor);
    }
    // ───── TRYB EFEKTU ZŁUDZENIA OPTYCZNEGO (ILUZJA MUNKERA-WHITE'A) ─────────────
    else if (chMode <= 135) {
      wasInSandMode = false;
      static float stripeScroll = 0;
      stripeScroll += (chSpeed / 255.0) * 0.4; 
      int shift = (int)stripeScroll % 8;

      uint16_t whiteColor = dma_display.color565(255, 255, 255);
      uint16_t blackColor = dma_display.color565(0, 0, 0);

      // Generowanie naprzemiennych pasów i nakładanie dwóch identycznych kwadratów
      for (int y = 0; y < 64; y++) {
        bool isWhiteStripe = ((y + shift) % 8 < 4);
        uint16_t stripeColor = isWhiteStripe ? whiteColor : blackColor;
        for (int x = 0; x < 64; x++) {
          if (x >= 8 && x <= 28 && y >= 12 && y <= 52) {
            if (isWhiteStripe) virtualDisp.drawPixel(x, y, stripeColor);
            else virtualDisp.drawPixel(x, y, activeColor);
          }
          else if (x >= 36 && x <= 56 && y >= 12 && y <= 52) {
            if (!isWhiteStripe) virtualDisp.drawPixel(x, y, stripeColor);
            else virtualDisp.drawPixel(x, y, activeColor);
          }
          else virtualDisp.drawPixel(x, y, stripeColor);
        }
      }
    }
    // ───── TRYB ZAAWANSOWANEGO STROBOSKOPU SCENICZNEGO (POLICJA) ───────────────
    // Wykorzystuje 32-krokową sekwencję (automat stanów) do zmiany wzorów błysku
    else if (chMode <= 170) {
      wasInSandMode = false;
      static unsigned long lastPolice = 0;
      static int policePhase = 0;
      int pSpeed = map(chSpeed, 0, 255, 120, 15); 
      
      if (millis() - lastPolice > pSpeed) {
        policePhase = (policePhase + 1) % 32;
        lastPolice = millis();
      }
      
      uint16_t rFlash = dma_display.color565(chDimmer, 0, 0);
      uint16_t bFlash = dma_display.color565(0, 0, chDimmer);
      uint16_t wFlash = dma_display.color565(chDimmer, chDimmer, chDimmer);
      
      if (policePhase < 8) {
        if (policePhase == 0 || policePhase == 2) virtualDisp.fillRect(0, 0, 32, 64, rFlash);
        if (policePhase == 4 || policePhase == 6) virtualDisp.fillRect(32, 0, 32, 64, bFlash);
      }
      else if (policePhase < 16) {
        if (policePhase % 2 == 0) virtualDisp.fillRect(0, 0, 32, 64, rFlash);
        else virtualDisp.fillRect(32, 0, 32, 64, bFlash);
      }
      else if (policePhase < 24) {
        if (policePhase == 16 || policePhase == 20) virtualDisp.fillRect(0, 0, 64, 32, rFlash);
        else if (policePhase == 18 || policePhase == 22) virtualDisp.fillRect(0, 32, 64, 32, bFlash);
      }
      else {
        if (policePhase == 24 || policePhase == 28) virtualDisp.fillScreen(rFlash);
        if (policePhase == 26 || policePhase == 30) virtualDisp.fillScreen(bFlash);
        if (policePhase == 25 || policePhase == 31) virtualDisp.fillScreen(wFlash);
      }
    }
    // ───── TRYB PIXEL CHASE (ANIMACJA BARYKADOWA) ──────────────────────────────────
    else if (chMode <= 210) {
      wasInSandMode = false;
      static float chaseOffset = 0;
      chaseOffset += (chSpeed / 255.0) * 4.0; 
      
      int rectWidth = 12; 
      int gap = 8;        
      int totalWidth = rectWidth + gap;

      for(int row = 0; row < 4; row++) {
         int yPos = row * 16 + 2; 
         for(int i = 0; i < 5; i++) { 
            // Obliczenia zapętlające karuzelę prostokątów po wyjściu za ekran
            int xPos = ((int)chaseOffset + i * totalWidth) % (64 + totalWidth) - totalWidth;
            virtualDisp.fillRect(xPos, yPos, rectWidth, 12, activeColor);
         }
      }
    }
    // ───── TRYB FIZYKI CZĄSTECZEK (SYMULACJA GRAWITACJI PIAKU) ───────────────
    // Wykorzystuje mechanizm automatów komórkowych sprzężony z kątem obrotu serwa
    else if (chMode <= 255) {
      static uint8_t sandGrid[64][64];
      
      // Inicjalizacja początkowej chmury ziaren piasku przy pierwszym wejściu w ten tryb
      if (!wasInSandMode) {
        memset(sandGrid, 0, sizeof(sandGrid));
        int grains = 0;
        while (grains < 900) {
          int rx = random(2, 62);
          int ry = random(2, 40);
          if (sandGrid[rx][ry] == 0) { sandGrid[rx][ry] = 1; grains++; }
        }
        wasInSandMode = true;
      }

      static unsigned long lastSandUpdate = 0;
      int sandSpeed = map(chSpeed, 0, 255, 40, 0); 
      
      if (millis() - lastSandUpdate > sandSpeed) {
        lastSandUpdate = millis();
        
        // Dynamiczny wektor pochyłu na podstawie osi napędu
        int tilt = currentServoAngle - 90;
        
        // Zmiana wektora skanowania matrycy zapobiegająca przedwczesnemu blokowaniu się symulacji
        int stepX = (tilt > 0) ? -1 : 1;
        int startX = (tilt > 0) ? 63 : 0;
        int endX = (tilt > 0) ? -1 : 64;

        for (int y = 62; y >= 0; y--) {
          for (int x = startX; x != endX; x += stepX) {
            if (sandGrid[x][y] == 1) {
              bool moved = false;

              // Grawitacja horyzontalna działająca w przypadku bardzo silnego odchylenia głowicy
              if (tilt < -15) { 
                if (x > 0 && sandGrid[x-1][y] == 0) { sandGrid[x-1][y] = 2; sandGrid[x][y] = 0; moved = true; }
                else if (x > 0 && y < 63 && sandGrid[x-1][y+1] == 0) { sandGrid[x-1][y+1] = 2; sandGrid[x][y] = 0; moved = true; }
              } 
              else if (tilt > 15) { 
                if (x < 63 && sandGrid[x+1][y] == 0) { sandGrid[x+1][y] = 2; sandGrid[x][y] = 0; moved = true; }
                else if (x < 63 && y < 63 && sandGrid[x+1][y+1] == 0) { sandGrid[x+1][y+1] = 2; sandGrid[x][y] = 0; moved = true; }
              }

              // Normalna grawitacja wertykalna (kaskadowy opadek swobodny)
              if (!moved) {
                if (y < 63 && sandGrid[x][y+1] == 0) { sandGrid[x][y+1] = 2; sandGrid[x][y] = 0; moved = true; }
                else if (y < 63 && x > 0 && sandGrid[x-1][y+1] == 0) { sandGrid[x-1][y+1] = 2; sandGrid[x][y] = 0; moved = true; }
                else if (y < 63 && x < 63 && sandGrid[x+1][y+1] == 0) { sandGrid[x+1][y+1] = 2; sandGrid[x][y] = 0; moved = true; }
              }

              // Mechanizm anty-zatorowy: Losowe osuwanie się ziaren zapobiega tworzeniu się nienaturalnych piramid
              if (!moved && abs(tilt) > 5) {
                if (tilt < 0 && x > 0 && sandGrid[x-1][y] == 0 && random(100) < 60) { sandGrid[x-1][y] = 2; sandGrid[x][y] = 0; }
                else if (tilt > 0 && x < 63 && sandGrid[x+1][y] == 0 && random(100) < 60) { sandGrid[x+1][y] = 2; sandGrid[x][y] = 0; }
              }
            }
          }
        }
        
        // Odblokowanie komórek zbuforowanych do przesunięcia w następnej klatce
        for (int y = 0; y < 64; y++) {
          for (int x = 0; x < 64; x++) {
            if (sandGrid[x][y] == 2) sandGrid[x][y] = 1;
          }
        }
      }

      // Proces renderowania chmury punktów fizycznych na warstwę wizualną ekranu
      for (int y = 0; y < 64; y++) {
        for (int x = 0; x < 64; x++) {
          if (sandGrid[x][y] == 1) {
            virtualDisp.drawPixel(x, y, activeColor);
          }
        }
      }
    }
  } else {
    wasInSandMode = false;
  }

  // Wymiana bufora pamięci RAM na bufor sprzętowy wyświetlacza w momencie gotowości matrycy
  dma_display.flipDMABuffer(); 
}
Youtube
Tagi
DMX-512 Druk 3D HUB75 Serwomechanizm ESP32 Matryca LED