Ленту на шестьдесят пикселей я повесил вертикально в окно, чтобы сделать дождь. Первая версия работала так: одна переменная позиции, каждый кадр плюс один, в конце — сброс на ноль. Получилась одиночная капля, которая честно падала по одному и тому же маршруту, как лифт. Вторая попытка честнее: я завёл две переменные и два блока кода — и капли родились в один кадр, падали строем и приземлялись синхронно, как десант по команде. Ни то, ни другое погоду не изображало.
Разгадка простая и неприятная для первой интуиции: одна переменная хранит ровно одну каплю, а живой дождь — это капли, у каждой из которых своя позиция, своя скорость и своё расписание рождения. Никакая хитрая формула это не заменит, нужен список состояний. Такой список в Arduino-мире держат в массиве-слотах: пять-восемь ячеек, каждая — потенциальная капля, свободна или занята.
В этом уроке собираем дождь по-взрослому: массив слотов, спавн по случайному интервалу, разгон капли в полёте, след, который дорисовывает затухание, и всплеск при ударе о землю. Механику затухания-шлейфа мы разбирали в отдельном уроке про fadeToBlackBy — здесь она работает на нас, а каркас кадров по-прежнему строится на millis() из урока про millis вместо delay.
Массив капель: у каждой своя скорость и своё расписание
Каркас выглядит так. Заводим массивы: позиция головы, период шага, счётчик до шага и флаг жива-ли — по одному на слот. Восемь слотов — до восьми капель одновременно; на ленте в шестьдесят пикселей этого достаточно с запасом, потому что при кадре в 30 мс капля живёт одну-две секунды и слоты успевают освобождаться.
Спавн отделён от рисования — и это первое, что стоит запомнить. Кадр рисуется по таймеру каждые 30 мс, а рождение капли живёт по своему таймеру: как только он срабатывает, ищем свободный слот и заполняем его, а следующий интервал рождения тут же перебрасываем кубиком — случайное число из диапазона 250–900 мс. Именно переброс после каждого спавна делает дождь неровным: капли идут не строем, а вразнобой, и глаз верит.
Падение — это не плюс-один каждый кадр, а шаг раз в dropStep кадров. Новый капле достаётся случайный шаг: единица — быстрая, двойка — вдвое медленнее. Дальше начинается физика: с шансом примерно один из пяти шагов период сокращается на единицу — капля разгоняется. Мелочь, а полёт сразу обретает вес: медленный старт, ускорение к земле. След рисовать не нужно вообще: перед движением каждого кадра вся лента гаснет через fadeToBlackBy, и прошлые позиции головы тают сами, оставляя ровно ту траекторию, которую капля прошла.
Форма хранения — второе решение каркаса. Четыре параллельных массива по восемь ячеек, как здесь, живут проще, чем массив структур: к слоту обращаемся номером, и каждая функция берёт только своё. Массив структур Kaplya kapli[8] с полями pozicia, shag, taimer, zhiva компактнее для передачи целиком в функцию и легче расширяется — но для восьми слотов выигрыша нет, а вложенные обращения kapli[i].pozicia читаются длиннее. Я держу простое правило: до пары десятков однотипных слотов — параллельные массивы, дальше — структура. На Uno оба варианта съедают одинаковые тридцать два байта, так что выбор диктует читаемость, а не память.
#include <FastLED.h>#define LED_PIN 6#define NUM_LEDS 60 CRGB leds[NUM_LEDS]; const int MAX_DROPS = 8; // сколько капель живёт одновременноint dropPos[MAX_DROPS]; // позиция головыint dropStep[MAX_DROPS]; // через сколько кадров шаг (больше = медленнее)int dropTick[MAX_DROPS]; // счётчик кадров до шагаbool dropAlive[MAX_DROPS];unsigned long lastSpawn = 0;unsigned long lastFrame = 0;int spawnGap = 500; // мс до следующего рождения капли void setup() { FastLED.addLeds<WS2812B, LED_PIN, GRB>(leds, NUM_LEDS); FastLED.setBrightness(160); randomSeed(analogRead(A0)); // свободный пин — иначе дождь по одному сценарию} void loop() { // спавн: капля рождается в случайный момент, а не в каждый кадр if (millis() - lastSpawn >= spawnGap) { lastSpawn = millis(); for (int d = 0; d < MAX_DROPS; d++) { if (!dropAlive[d]) { dropAlive[d] = true; dropPos[d] = 0; // нулевой пиксель — «небо» dropStep[d] = random(1, 3); // 1 — каждый кадр, 2 — вдвое ленивее dropTick[d] = dropStep[d]; break; } } spawnGap = random(250, 900); // следующий спавн — снова кубик } if (millis() - lastFrame < 30) return; // кадр 30 мс, рисуем по таймеру lastFrame = millis(); fadeToBlackBy(leds, NUM_LEDS, 90); // след тает за три-четыре кадра for (int d = 0; d < MAX_DROPS; d++) { if (!dropAlive[d]) continue; dropTick[d]--; if (dropTick[d] <= 0) { dropPos[d]++; if (dropPos[d] >= NUM_LEDS) { // удар о землю: брызги dropAlive[d] = false; leds[NUM_LEDS - 1] += CRGB(60, 80, 255); leds[NUM_LEDS - 2] += CRGB(30, 40, 150); continue; } dropTick[d] = dropStep[d]; if (dropStep[d] > 1 && random(0, 5) == 0) dropStep[d]--; // разгон } leds[dropPos[d]] = CRGB(80, 130, 255); // голова — самая яркая точка } FastLED.show();}Три места, где я спотыкался. Первое — зерно генератора: без randomSeed() ардуиновский random каждый раз после включения питания выдаёт одну и ту же последовательность, и ваш дождь идёт по одному и тому же сценарию. Свободный аналоговый пин, ни к чему не подключённый, ловит шум и даёт при старте разные числа — одна строка в setup решает вопрос. Второе — соблазн спавнить каплю в каждом кадре: при кадре 30 мс это тридцать три рождения в секунду, и через секунду лента превращается в сплошной поток без просветов. Интервалы рождения должны быть заметно длиннее жизни одной капли — тогда на ленте живут и просветы, и очереди.
Третье место тоньше. Когда все слоты заняты, новая капля молча не рождается — цикл просто не находит свободный слот. Это не сбой, а нормальное поведение очереди с отказом: дождь в этот миг и так плотный. Я один раз потратил вечер на поиск «пропадающих капель», прежде чем понял, что их и не должно было быть — слоты кончились. Считайте это встроенным ограничителем плотности.
Ещё одна ручка, о которой легко забыть: FastLED.setBrightness() режет яркость и ток глобально, и для оконного дождя имеет смысл ставить её на 150-180 из 255 — сквозь стекло ночью полной яркости никто не видел, а блок питания и проводка дышат свободнее. Глобальный потолок не отменяет локальный: всплеск у земли всё равно пробивается выше общего уровня оператором +=, и библиотека сама прижмёт его к границе масштаба. Это тот случай, когда ограничение честнее на входе, чем клиппинг на выходе.
В симуляторе проверьте две крайности ручки. На минимуме дождь должен читаться как морось: пара капель, длинные паузы, каждая — с заметным разгоном. На максимуме — как ливень: слоты забиты, между каплями нет просветов, а у земли постоянно тлеют всплески. Кнопка-шквал интересна другим: заметьте, что при рождении каждые 60–150 мс разгон исчезает — капля не успевает ускориться, потому что долетает за считанные шаги. Густой дождь в природе и выглядит «плоским», без индивидуальных характеров.
Числа кадра тоже стоит проверить руками. Кадр 30 мс при 60 пикселях — это 533 кадра на четыре секунды падения с верха до низа; капля со шагом 2 проходит путь за 1066 тиков таймера, и если слоты разрешили только восемь капель, плотность выше определённой физически не наберётся — рождению просто негде жить. Отсюда практический потолок: на шестидесяти пикселях восемь слотов держат морось и дождь, а сплошная стена ливня потребует либо больше слотов, либо короче ленту. Я пробовал и тот и другой вариант — оба работают, но второй съедает заметный кусок кадра на show().
| Приём | Что даёт | Где уместен |
|---|---|---|
| Сдвиг соседа: каждый пиксель копирует соседний | Строгое движение всей картины, один буфер без служебных массивов | Бегущий огонь, волна-перелив, стрелки |
| Затухание плюс случайные точки | Стихийные эффекты без счётчиков: конфетти, угольки, звёзды | Салюты и мерцание, где траектория не важна |
| Массив капель-слотов | Индивидуальная скорость и расписание у каждой частицы | Дождь, метеоры, снег — всё с траекторией |
Всплеск у земли и мелочи, из которых собирается погода
Всплеск — одна строка, а эффект даёт непропорционально большой: в момент приземления нижний пиксель получает добавку синего оператором +=, второй снизу — вдвое меньше. Затухание гасит брызги за пару кадров, и у земли возникает мерцающая кромка, как у лужи под дождём. Тот же приём в обратную сторону работает для метеора: яркость вспышки умножить на три — и удар будет читаться даже из соседней комнаты.
Практическая мелочь, которая портит половину подвесов: нумерация пикселей идёт от разъёма. Если лента висит разъёмом вниз, ваш «небо-ноль» оказывается снизу, и дождь льёт с земли в небо. Лечится двумя способами: переклеить ленту или перевернуть индекс — рисовать в leds[NUM_LEDS - 1 - pos]. Прежде чем сверлить отверстия под подвес, залейте один пиксель в нулевой ячейке и посмотрите, где он физически оказался, — приём из урока про первый скетч экономит один перекос на стене.
И не путайте этот дождь со статичным «дождём» из урока про ленту-прогноз погоды — там лента просто красилась синим с двумя светлыми пикселями по коду от ESP32. То — сценарий-картинка, здесь — траектории. Комбинировать их можно: внешний код шлёт интенсивность, а массив капель уже превращает её в плотность спавна.
- 1. Капля долетела до последнего пикселя. Что скетч делает со слотом?
- 2. Почему след капли не написан ни одной отдельной строкой кода?
- 3. Кадр 30 мс, у капли dropStep = 2. Сколько времени капля тратит на один пиксель?
- 4. Зачем в setup вызывается randomSeed(analogRead(A0))?
- 5. Спавн настроен на 50 мс, затухание — 40. Что будет видно на ленте?
- У каждой капли свой слот: позиция, период шага, счётчик и флаг — массив состояний вместо одной переменной
- Спавн живёт по своему таймеру со случайным интервалом 250–900 мс — переброс кубика после каждого рождения и делает дождь неровным
- Разгон — это уменьшение dropStep с шансом один из пяти: медленный старт и ускорение к земле
- След дорисовывает fadeToBlackBy: 90 — след на три-четыре кадра, 40 — туман, плотному дождю нужно резкое затухание
- Буфер стоит 3 байта на пиксель: 60 пикселей — 180 байт из 2 КБ Uno, матрицы и длинные ленты считайте заранее
Частые вопросы по теме урока
- Капля долетела до последнего пикселя. Что скетч делает со слотом?
- Приземление — это выход из массива: флаг сбрасывается, и слот сразу доступен следующему спавну. В этот же момент рисуются брызги, а сама капля перестаёт существовать. Ставит флаг dropAlive в false — слот свободен для новой капли
- Почему след капли не написан ни одной отдельной строкой кода?
- Затухание каждый кадр темнит всю ленту. Прошлая позиция головы не стирается, а просто темнеет — из этих всё более тёмных точек и складывается след. Его дорисовывает fadeToBlackBy, гася прошлые позиции кадр за кадром
- Кадр 30 мс, у капли dropStep = 2. Сколько времени капля тратит на один пиксель?
- Шаг случается раз в dropStep кадров: два кадра по 30 мс — 60 мс на пиксель. Разгон уменьшает dropStep, и вторая половина пути идёт быстрее первой. 60 мс
- Зачем в setup вызывается randomSeed(analogRead(A0))?
- Без зерна random выдаёт одну и ту же последовательность после каждого сброса. Шум на свободном аналоговом пине разный при каждом включении — его и скармливают в зерно. Чтобы дождь после каждого включения шёл по новому сценарию
- Спавн настроен на 50 мс, затухание — 40. Что будет видно на ленте?
- Тридцать три рождения в секунду при медленном затухании заполняют ленту без пауз. Плотный спавн требует резкого затухания — или это уже не дождь, а туман. Ровный поток без просветов: следы не успевают догорать между рождениями