Дискотечный луч — самый благодарный эффект на окно: яркая голова, за ней лесенка догорающих звеньев, и всё это крутится по кругу. Огонь, который бежит в одну сторону, мы уже пускали сдвигом буфера в уроке про бегущий огонь: каждый пиксель забирает цвет предыдущего, и порядок обхода там решает всё. Мой вихрь потребовал крутиться в обратную сторону — и первый скетч, собранный из того урока перестановкой индексов, размазал луч в заливку за один шаг. Разбор этой ловушки — половина сегодняшнего урока, и на ней ломается, если судить по моей почте, каждый второй реверс.
Второй вопрос встал сразу после починки — хвост. Сдвиг переносит яркую голову, но след за ней приходится рисовать отдельно, и поначалу я таскал за лучом второй массив: где голова была пять кадров назад. Потом заменил всю эту бухгалтерию одним вызовом — fadeToBlackBy каждый шаг гасит всю ленту на несколько десятков, и прежние позиции головы догорают сами, ровной лесенкой. Итого в кадре три действия: погасить, сдвинуть, зажечь голову — каркас тот же, что в уроке про эффекты без delay, с одним шагом таймера на всю анимацию.
Разберём: почему порядок обхода переворачивается вместе с направлением движения, чем помогает memmove и когда он лучше цикла, как замкнуть ленту в кольцо и сколько звеньев оставляет в лесенке каждое значение затухания. Скетч — вихрь из двух лучей в противофазе с кнопкой реверса; проверка — симулятор с тремя режимами, включая нарочно сломанный. Все числа — с оконной ленты в шестнадцать пикселей и шага сто тридцать миллисекунд; на другой длине меняется только шаг, логика не трогается.
Почему цикл переворачивается вместе с направлением
Буфер — очередь стульев, движение — копирование соседа. Огонь из прошлого урока ехал в сторону больших номеров: leds[i] = leds[i - 1] при i++ — каждый пишет в свою ячейку, читая у уже пройденного соседа, и перечитать записанное никто не успевает. Реверс хочет обратного: leds[i] = leds[i + 1], читаем у соседа впереди — и вот здесь i++ тоже безопасен, потому что чтение опережает запись. Ломается третья комбинация: захотелось записать «вперёд» — leds[i + 1] = leds[i] — а цикл остался i++. Каждый шаг пишет в ячейку, которую сам же собирался прочитать: leds[1] получил цвет головы, leds[2] копирует уже новый leds[1], и к концу прохода весь буфер — одна головная заливка. Проверить себя можно на бумаге за две минуты: выпишите в строку шесть чисел, пройдите цикл вручную и смотрите, какое число читается после записи — если уже новое, порядок обхода сломан.
Правило, которое я теперь держу наизусть: цикл может писать только туда, куда он уже не вернётся. Едете в сторону больших номеров — пишите leds[i] = leds[i - 1] и идите с начала. В сторону меньших — leds[i] = leds[i + 1], порядок обхода безразличен. Понадобилась запись вперёд — идите с конца: for (int i = NUM_LEDS - 1; i > 0; i--) leds[i] = leds[i - 1]; — здесь каждый читает ячейку, которую цикл ещё не тронул, и размазни не будет ни при каком направлении.
Есть вариант без цикла вовсе: memmove(&leds[1], &leds[0], (NUM_LEDS - 1) * sizeof(CRGB)) сдвигает весь буфер на одну ячейку, и про перекрытие диапазонов библиотека знает сама — потому memmove, а не memcpy, который на пересекающихся кусках честно копирует мусор. На AVR это тот же построчный перенос байтов, выигрыша в тактах почти нет; выигрыш в другом — одна строка, в которой нельзя перепутать направление обхода. Пятнадцать пикселей по три байта — сорок пять байт за вызов; на ленте в сто сорок четыре — четыреста двадцать девять, и это по-прежнему микросекунды. Единственная оговорка: memmove живёт в string.h и на Uno подтягивается сам, но на крошечных ядрах его может не оказаться — там остаётся честный цикл с конца, который ничем не хуже.
#include <FastLED.h>#define LED_PIN 6#define NUM_LEDS 16 CRGB leds[NUM_LEDS];unsigned long lastStep = 0;bool reverse = false;int prevBtn = HIGH; void setup() { FastLED.addLeds<WS2812B, LED_PIN, GRB>(leds, NUM_LEDS); FastLED.setBrightness(200); pinMode(2, INPUT_PULLUP); Serial.begin(115200);} void loop() { int btn = digitalRead(2); if (prevBtn == HIGH && btn == LOW) { // реверс по фронту кнопки reverse = !reverse; Serial.println(reverse ? "spin: reverse" : "spin: forward"); } prevBtn = btn; if (millis() - lastStep < 130) return; // оборот на 16 пикселях за 2,1 с lastStep = millis(); fadeToBlackBy(leds, NUM_LEDS, 45); // лесенка: старые позиции гаснут if (reverse) { for (int i = 0; i < NUM_LEDS - 1; i++) { leds[i] = leds[i + 1]; } leds[NUM_LEDS - 1] = CHSV(200, 220, 255); leds[NUM_LEDS / 2] = CHSV(200, 220, 255); } else { memmove(&leds[1], &leds[0], (NUM_LEDS - 1) * sizeof(CRGB)); leds[0] = CHSV(200, 220, 255); leds[NUM_LEDS / 2] = CHSV(200, 220, 255); } FastLED.show();}Кольцо замыкается временной переменной: перед сдвигом сохраните хвостовой элемент, после — верните его в голову, и узор перестанет выпадать за край. Мой вихрь кольцом не ездит: головы зажигаются заново каждый шаг — на нулевой позиции и ровно через полленты, два луча в противофазе, — а хвост старых позиций гасит fadeToBlackBy(45): при шаге сто тридцать миллисекунд это пять-шесть звеньев лесенки. Второй луч я добавил после того, как одиночный показался одиноким: два луча в противофазе дают ленте симметрию, и вихрь читается даже периферийным зрением. Кнопка на D2 листает направление по фронту — та же механика, что в уроке про кнопку, листающую эффекты, только вместо номера эффекта здесь булев флаг.
Порядок действий в кадре держите как в скетче: затухание, сдвиг, головы, show(). Переставите затухание после записи голов — и голова войдёт в кадр уже погашенной на сорок пять из 255: луч потускнеет на пятую часть без всякой видимой причины. Это та же дисциплина порядка, что и в конфетти: свежий цвет должен попадать в буфер после гашения, тогда он входит в кадр с полной силой и терять яркость начинает только со следующего шага. Сам каркас шага на millis() — из урока про millis вместо delay, между шагами плата свободна для кнопки и остальной логики.
Скорость — единственная ручка, которую я выношу в настройки сцены. Пятьдесят миллисекунд на шаг — вихрь, у которого глаз не различает звеньев и читает только направление; восемьдесят-сто пятьдесят — рабочая зона с лесенкой; двести и выше — дозорный режим, в котором лента становится ночником. Затухание при этом не трогается вовсе: характер хвоста остаётся тем же, меняется только темп, с которым звенья успевают догореть между шагами, — и от одного этого сочетания рождаются совершенно разные настроения.
Кольцевой вихрь для окна собирается из того же скетча добавлением пары строк: сохраните хвостовой элемент до сдвига и верните его после — узор перестанет умирать на краю и поедет по кругу бесконечно. Головы при этом зажигать не нужно вовсе: кольцо из затухающих звеньев само поддерживает свою форму, и лента превращается в самодостаточный калейдоскоп, которому не нужны ни кнопки, ни Serial. Мой оконный вариант жил именно так — включил в розетку, и он крутится, пока светло за окном не сделает его ненужным.
Вихрь в симуляторе: три режима сдвига
Переключитесь в третий режим и подождите два-три шага: лента заливается ровным цветом, из которого лесенка уползает к краю. Это не эффект, это диагностический признак: где-то в сдвиге запись идёт по ещё не прочитанным ячейкам. Лечится всегда одинаково — разворотом цикла, и после пары таких утренних пятнадцати минут я пишу направление обхода комментарием прямо над for, а хвостовой и головной индексы перепроверяю на ленте из шести пикселей, где размазня видна за два шага.
Мнение о границах приёма. Вращающийся луч можно собрать и без сдвига — формулой от номера пикселя и счётчика шагов: горит тот, у кого расстояние до головы мало. Для одного луча это даже проще. Сдвиг берёт верх, как только узору нужна история: лесенка за головой, размазанный хвост, память о прошлом — затухание помнит его за вас, а формуле историю пришлось бы хранить вручную. Плюс сдвиг почти бесплатен по вычислениям: копирование байтов дешевле синусов и шума, и на длинной ленте разница в кадре превращается в заметные миллисекунды. Обратная сторона тоже честная: сдвиг не умеет ускоряться на середине ленты и не знает про матрицы — там узор собирают формулами, а сдвиг остаётся эффектом линейных лент.
| Параметр | Рабочее значение | Что даёт и когда ломается |
|---|---|---|
| Шаг сдвига | 80-150 мс | Оборот на 16 пикселях за 1,3-2,4 с; 200+ — дозор, 50 — суета |
| Затухание лесенки | 40-60 за шаг | Хвост 4-6 звеньев; 20 — длинный шлейф, 100+ — луч без истории |
| Способ сдвига | цикл с конца или memmove | memmove(&leds[1], &leds[0], (N-1)*3) — одна строка, перекрытие учтено |
| Лучи | голова + голова через полленты | Два луча в противофазе; каждый 8-й пиксель — четыре луча |
- 1. Пиксели должны ехать в сторону больших номеров, а цикл записывает leds[i + 1] = leds[i] при i++. Что получится?
- 2. Как сдвинуть содержимое в сторону меньших номеров?
- 3. Что делает memmove(&leds[1], &leds[0], (N - 1) * sizeof(CRGB))?
- 4. Откуда берётся лесенка за лучом?
- 5. Нужен кольцевой сдвиг без записи головы — узор ездит по кругу. Чего не хватает прямому циклу?
- Движение ленты — копирование соседа; цикл пишет только туда, куда уже не вернётся
- Реверс — leds[i] = leds[i + 1]; запись вперёд leds[i + 1] = leds[i] при i++ размазывает буфер — иди с конца
- memmove(&leds[1], &leds[0], (N - 1) * sizeof(CRGB)) — сдвиг одной строкой, перекрытие учтено внутри
- Лесенка за лучом — fadeToBlackBy(45) каждый шаг: 4-6 звеньев при шаге 130 мс; порядок кадра: затухание, сдвиг, голова
- Кольцо — временная переменная с хвостового элемента перед сдвигом и возврат в голову после
Частые вопросы по теме урока
- Пиксели должны ехать в сторону больших номеров, а цикл записывает leds[i + 1] = leds[i] при i++. Что получится?
- Запись опережает чтение: leds[1] получает цвет головы, leds[2] копирует уже новый leds[1]. К концу прохода весь буфер — одна заливка. Луч размажется в заливку: каждая ячейка копирует только что перезаписанную
- Как сдвинуть содержимое в сторону меньших номеров?
- Реверс — leds[i] = leds[i + 1]: каждый читает соседа впереди записи, поэтому порядок обхода безразличен. leds[i] = leds[i + 1] при i++ — чтение опережает запись
- Что делает memmove(&leds[1], &leds[0], (N - 1) * sizeof(CRGB))?
- memmove создан для пересекающихся диапазонов — в отличие от memcpy, он не копирует мусор. Сдвиг занимает одну строку. Сдвигает N-1 пикселей на одну ячейку вперёд; перекрытие учтено внутри
- Откуда берётся лесенка за лучом?
- Сдвиг переносит догорающие ячейки дальше, затухание делает из них убывающий след. Порядок кадра: затухание, сдвиг, голова, show(). Из fadeToBlackBy: каждый шаг вся лента гаснет на несколько десятков, прежние позиции головы догорают
- Нужен кольцевой сдвиг без записи головы — узор ездит по кругу. Чего не хватает прямому циклу?
- Без кольца узор выпадает за край. Сохраните последний элемент до сдвига и верните его в leds[0] после — круг замкнётся. Переноса хвостового элемента в голову через временную переменную