Первая «праздничная» лента была собрана у меня честно, но криво: три эффекта из трёх примеров подряд — радуга, бегущая точка, мигание — внутри одного loop() с delay(). Выглядело это как дёрганое слайд-шоу: пять секунд радуга, пауза, секунда бегущей точки с рывками, снова пауза. Я долго винил ленту и блок питания, пока не признал очевидное: delay() останавливает всю программу целиком, а анимация — это непрерывный поток кадров, который нельзя ставить на паузу без последствий.
В этом уроке соберём правильный каркас: кадровый таймер на millis() с шагом 30 мс, поверх него — бегущую точку с тающим хвостом через fadeToBlackBy(), волну из одного счётчика и упрощённый огонь на случайных красно-оранжевых пикселях. Всё это работает в симуляторе на 16 пикселях — и всё это же, почти без изменений, заливается на настоящую ленту.
Анимация — это кадры: таймер на millis()
Кино обманывает глаз при 24 кадрах в секунду. Ленте хватает примерно того же: кадр раз в 30 миллисекунд — это около 33 кадров в секунду, движение выглядит плавным, а Arduino между кадрами успевает считать датчики и кнопки. Ключевое отличие от delay(): millis() возвращает, сколько миллисекунд прошло с включения платы, и не останавливает программу. Мы просто спрашиваем: «прошло 30 мс с прошлого кадра? нет — идём дальше, да — рисуем новый кадр».
Каркас один на все эффекты курса, выучите его как таблицу умножения:
unsigned long lastFrame = 0; void loop() { if (millis() - lastFrame >= 30) { lastFrame = millis(); // один кадр анимации: посчитали, перезаписали буфер, показали }}Два нюанса, которые новички ломают чаще всего. Первый: сравнивайте через вычитание — millis() - lastFrame >= 30, а не через millis() больше какого-то порога с прибавлением. Вычитание с беззнаковыми числами не боится переполнения millis(), которое случается примерно через 49 суток непрерывной работы: когда счётчик переполнится и «обнулится», разность всё равно останется честной. Второй нюанс: lastFrame обновляйте внутри if. Если забыть эту строку, кадр будет рисоваться на каждом проходе loop() — сотни тысяч раз в секунду, и тогда непонятно, зачем мы вообще уходили от delay().
А теперь самое приятное: всё, что не занял кадр, остаётся в вашем распоряжении. Между кадрами в 30 мс Arduino спокойно успевает опросить кнопку, дёрнуть Serial.println() для отладки, раз в секунду пересчитать показания датчика. Анимация перестаёт быть единственным занятием платы и становится просто одним из дел в расписании — именно этого не мог дать delay().
Эффект 1: бегущая точка и хвост — fadeToBlackBy
Бегущая точка — «hello world» светодиодных анимаций, и делается она из трёх движений. Заводим счётчик кадров; каждые четыре кадра, то есть раз в 120 мс, сдвигаем позицию на пиксель. В каждом кадре сначала fadeToBlackBy(leds, 16, 120) — функция уменьшает яркость каждого пикселя примерно на половину, — потом рисуем точку в новой позиции заново. Прошлые позиции мы не стираем специально: они уже потемнели, и получается хвост, тающий за точкой. Никаких ручных циклов затухания — одна строка.
Число затухания — художественная ручка. fadeToBlackBy(leds, 16, 255) гасит всё мгновенно — хвоста нет. Значение 40 темнит медленно, и точка оставляет длинный тлеющий след, который держится секунды. Мне в коротких эффектах нравится диапазон 100–150: хвост читается, но не превращается в вечный шлейф. Если ваш след «никогда не догорает до конца» — fade слишком маленький, поднимайте.
Эффект 2: волна из счётчика
Настоящие синусоиды в FastLED тоже есть — функции sin8() и beatsin8(), и на реальной плате для волн это лучший выбор, почитайте о них в официальной документации. Но учебную волну можно собрать и без тригонометрии, из одного счётчика, — и заодно понять, как устроены вообще все «плавные» эффекты. Идея: по ленте бежит невидимый центр; яркость каждого пикселя тем меньше, чем дальше он от центра. Получается светящийся сгусток, который ползёт по кругу.
Расстояние до центра — вычитание и модуль: int d = abs(i - center). Чтобы сгусток оборачивался через конец ленты, а не обрывался, расстояние больше половины длины сворачиваем: если d больше 8, считаем его как 16 - d. Яркость — линейный спад: v = 255 - d * 30, с условием, что ниже нуля она не уйдёт. Оттенок 160 — синий; поменяете на 0 — волна станет красной, на 96 — зелёной. Это уже почти настоящий эффект, собранный из арифметики начальной школы: счётчик, вычитание, одно умножение.
Обратите внимание, что и скорость, и длина сгустка управляются двумя числами. Скорость — это кадр (30 мс) и шаг счётчика на кадр; длина — множитель спада 30. Хотите две волны навстречу друг другу — заведите второй центр 16 - center % 16 и рисуйте каждый пиксель дважды, беря максимальную из яркостей. Каркас не меняется, меняется только формула внутри кадра. Именно так и рождаются собственные эффекты: не поиск готового кода, а мелкая правка арифметики внутри уже работающего каркаса.
Эффект 3: упрощённый огонь
Огонь — эффект, из-за которого ленты покупают. Классическая реализация — модель тепла с шумом Перлина и палитрой HeatColors, и если вы дойдёте до ленты в 60 и больше пикселей, обязательно соберите её, это отдельное удовольствие. Для шестнадцати пикселей и для понимания принципа хватает упрощённой схемы из двух правил.
Правило первое: каждый кадр вся лента гаснет — fadeToBlackBy(leds, 16, 50), это «остывание». Правило второе: у основания, в первых двух-трёх пикселях, вспыхивают случайные красно-оранжевые пиксели — CHSV со случайным оттенком в диапазоне 0–25, от красного к оранжевому, и случайной яркостью. Периодически «искры» подбрасываются выше по ленте — и гаснут по дороге тем же fadeToBlackBy. Случайность здесь не баг, а суть эффекта: ровное пламя выглядит мёртвым, живое — это постоянные неровные вспышки. Кадр в 40 мс для огня работает лучше, чем 30: чуть более ленивое, «тягучее» остывание.
Кстати, почему тут CHSV, а не CRGB с тремя числами: оттенок 0–25 — это один короткий отрезок цветового круга, и случайное число внутри него честно ложится на переход от красного к оранжевому. В CRGB мне пришлось бы рандомить красный и зелёный канал по отдельности и молиться, чтобы пропорция не уехала в розовый или салатовый. HSV для огня — буквально защита от испорченной палитры: одна случайная ручка оттенка вместо двух независимых каналов.
Эксперименты для закрепления
Эффекты собираются только руками. Четыре задачи — от точной настройки параметров до сборки собственного авторского эффекта из уже знакомых деталей.
- Комета наоборот: в первом симуляторе рисуйте точку в позиции 15 - pos — она побежит в обратную сторону. Затем заведите вторую точку и пустите навстречу первой.
- Скорость волны: замерьте взглядом, за сколько волна проходит круг при кадре 30 мс, потом поставьте 15 мс. Зависимость линейная: вдвое короче кадр — вдвое быстрее волна.
- Радуга с хвостом: соедините fill_rainbow и fadeToBlackBy — радуга будет подгасать, а поверх неё каждый кадр рисуйте бегущую белую точку. Это уже авторский эффект, а не пример из документации.
- Огонь на ветру: добавьте в огонь редкие яркие вспышки на случайных пикселях через if (t % 5 == 0). Пламя станет заметно живее — вы удивитесь, насколько.
- Анимация = кадры по таймеру: if (millis() - lastFrame >= 30) — каркас всех эффектов
- millis() не останавливает программу, в отличие от delay(); вычитание защищает от переполнения счётчика (~49 суток)
- fadeToBlackBy(leds, 16, N) гасит буфер: 255 — мгновенно, 40 — долгий тлеющий хвост, 100–150 — короткий читаемый след
- Волна = бегущий центр плюс спад яркости с расстоянием: abs(i - center) и линейный v
- Упрощённый огонь: остывание fadeToBlackBy + случайные красно-оранжевые CHSV у основания
- 1. Почему кадр анимации рисуют по if (millis() - lastFrame >= 30), а не через delay(30)?
- 2. Что сделает fadeToBlackBy(leds, 16, 128) за один вызов?
- 3. От какой ошибки защищает вычитание millis() - lastFrame?
- 4. Что даёт строка int d = abs(i - center) в эффекте-волне?
- 5. Почему в огне яркость вспышек случайная, а не постоянная?
- 6. Что произойдёт, если lastFrame обновлять вне блока if?
У вас на руках полный базовый набор адресного художника: кадры по millis(), затухание с хвостами, волна, огонь — и HSV из прошлого урока. Дальше по курсу — питание больших лент, матрицы и несколько полос данных, но всё это будет строиться ровно на этом каркасе. А пока попробуйте соединить радугу и комету в один эффект: это лучший способ убедиться, что вы действительно понимаете FastLED, а не копируете примеры.
Частые вопросы по теме урока
- Почему кадр анимации рисуют по if (millis() - lastFrame >= 30), а не через delay(30)?
- delay() замирает целиком и рвёт логику на куски. millis() позволяет совмещать анимацию с любой другой работой программы. millis() не останавливает программу — между кадрами можно считать датчики и кнопки
- Что сделает fadeToBlackBy(leds, 16, 128) за один вызов?
- 128 — это примерно половина от 255: каждый пиксель теряет половину яркости. При повторных вызовах хвост быстро догорает. Уменьшит яркость каждого пикселя примерно вдвое
- От какой ошибки защищает вычитание millis() - lastFrame?
- Беззнаковое вычитание остаётся корректным и после переполнения счётчика, а прямое сравнение абсолютных значений в этот момент ломается. От переполнения millis() примерно через 49 суток
- Что даёт строка int d = abs(i - center) в эффекте-волне?
- abs() убирает знак разности — получается дистанция пикселя до центра сгустка, из которой потом считается яркость. Расстояние пикселя от бегущего центра
- Почему в огне яркость вспышек случайная, а не постоянная?
- Неровность — суть эффекта. Одинаковые вспышки превращают огонь в мигающую гирлянду, случайные яркость и оттенок дают живое пламя. Живой огонь неровный: постоянные вспышки выглядят мёртвыми
- Что произойдёт, если lastFrame обновлять вне блока if?
- Условие станет всегда истинным, и анимация будет крутиться на максимальной скорости процессора — с тем же успехом можно оставить delay(). Кадр будет рисоваться на каждом проходе loop() без ограничения частоты