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.
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.
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.
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:
-
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.
- 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.
#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();
}