Каминную полку я подсвечивал лентой в сорок пикселей, уложенной вдоль, и огонь из базового Fire2012 поджигал с одного края. По отдельности всё выглядело прилично, но по смыслу — нет: огонь жил на трети длины, а остальные две трети ленты были декорацией к нему. Настоящий камин горит по всей длине, и пламя поднимается где гуще, где реже. Пересказывать массив жара с нуля не буду — модель температур, случайное остывание и вспышки у дна уже разобраны в уроке про Fire2012, — здесь идём дальше по трём местам, которые тот урок честно оставил за кадром.
Первое место — геометрия: как разложить жар, чтобы пламя горело с обоих концов и встречалось в середине. Второе — цвет: HeatColor жёстко натягивает на жар один и тот же маршрут чёрный-красный-оранжевый-жёлтый, а хочется управлять раскладкой — свечная головня, газовая синева, угли. Третье — масштаб: на длинной ленте всплывают и цена кадра, и ток, и остывание, подобранное под другую длину. По ходу добавим поверх огня редкие угольки, которые вылетают выше пламени и гаснут в воздухе.
Два поля жара и зеркальный вывод
Чистое решение для двустороннего огня — не один массив с хитрым индексом, а два независимых поля по половине ленты: heatL и heatR, каждое длиной N/2, каждое со своим поджигом у своего края. Внутри каждое поле живёт по классике: случайное остывание каждой ячейки, подъём от края внутрь, вспышки у корня. Рисовка зеркальная: пиксель i получает цвет heatL[i], пиксель N-1-i — цвет heatR[i], и оба пламени растут с концов навстречу. В центре они встречаются, и стык получается сам собой: жар с двух сторон подтягивает соседние ячейки вверх, картина в середине выглядит как самый горячий участок, хотя никакого специального кода для шва нет.
А вот одни общий heat[] на всю ленту с зеркальным индексом — решение, которое я пробовал первым и похоронил. При зеркалировании центральная ячейка и её сосед дважды участвуют в подъёме друг друга, и в центре образуется капкан: жар не остывает как остальные, а подпитывается сам от себя. На длинной ленте это выглядит как яркая полоса-перемычка посередине, которая не умирает никогда. Отдельные поля лечат это автоматически — в каждой половине центральная ячейка обычная, и остывает как все.
Направление стоит фиксировать сразу. Поля считаются в координатах «расстояние от своего края», а не в номерах пикселей ленты: тогда перенос скетча на ленту другой длины — это правка одного числа HALF, и физика не едет. Если лента висит разъёмом вниз, зеркальная рисовка разъедется с горизонтом — тот же приём из урока про Fire2012: рисовать в leds[N-1-i] или физически перевернуть ленту, но решать это надо до сверления, проверив один пиксель в нулевой ячейке.
Цвет через палитру — второй уровень свободы. В базовой версии цвет делает HeatColor(heat[i]) — один жёсткий маршрут на все случаи. В примере Fire2012WithPalette из комплекта библиотеки сделано иначе: жар становится индексом палитры — ColorFromPalette(gPal, heat[i], 255, LINEARBLEND), где gPal по умолчанию равен HeatColors_p. Картина та же, но маршрут теперь переменная: подставьте палитру с приглушёнными краями — получится свечная головня; сдвиньте опорные в синий — газовая плита; то, что в однотонной версии правилось экспериментами с CHSV, здесь становится отдельными строками опорных точек. Как устроены палитры и их индексы — урок про CRGBPalette16, там же про шестнадцать опорных и смешивание.
Третий аргумент ColorFromPalette — яркость — у огня не гуляет: он держит 255, а общий потолок задаётся FastLED.setBrightness. Мерцать пламенем через третий аргумент я пробовал — получается рябь поверх физики: жар-то не менялся, а цвет подскочил. Если нужен порыв — правьте поджиг и остывание, пусть физика остаётся честной.
Пары COOLING и SPARKING — весь характер пламени, и крутить их стоит по очереди, а не разом. COOLING 55 при поле в 30 ячеек даёт высокие языки, 80 прижимает огонь к краям, 30 растягивает жар по всей половине в ровное тление. SPARKING 84 — вспышка в среднем на каждый третий кадр; поднимете к 150 — пламя кипит и плещется, опустите к 40 — горит как ночник. Свечная головня у меня получилась на COOLING 95 со SPARKING 60, камин — на паре из скетча, и никакая третья ручка этого не добавила.
#include <FastLED.h>#define LED_PIN 6#define NUM_LEDS 60#define HALF (NUM_LEDS / 2) CRGB leds[NUM_LEDS];uint8_t heatL[HALF];uint8_t heatR[HALF];unsigned long last = 0; // своя раскладка пламени: приглушённые угли, мягкая оранжевая серединаDEFINE_GRADIENT_PALETTE( ember_gp ) { 0, 0, 0, 0, 64, 90, 10, 0,128, 200, 60, 0,200, 255, 140, 10,255, 255, 190, 60};CRGBPalette16 firePal = ember_gp; void setup() { FastLED.addLeds<WS2812B, LED_PIN, GRB>(leds, NUM_LEDS); FastLED.setBrightness(180); randomSeed(analogRead(A0));} void loop() { if (millis() - last < 15) return; // 66 кадров в секунду last = millis(); // остывание: COOLING 55 при поле в 30 ячеек -> (55*10)/30 + 2 = 20 for (uint8_t i = 0; i < HALF; i++) { heatL[i] = qsub8(heatL[i], random8(0, 21)); heatR[i] = qsub8(heatR[i], random8(0, 21)); } // подъём от края внутрь половины: треть от двух ячеек ближе к краю for (uint8_t i = HALF - 1; i >= 2; i--) { heatL[i] = (heatL[i-1] + heatL[i-2] + heatL[i-2]) / 3; heatR[i] = (heatR[i-1] + heatR[i-2] + heatR[i-2]) / 3; } // вспышки у обоих корней, каждый третий кадр в среднем if (random8() < 84) { heatL[random8(0, 3)] = random8(160, 255); } if (random8() < 84) { heatR[random8(0, 3)] = random8(160, 255); } // зеркальная рисовка: i-я ячейка половины -> оба конца ленты for (uint8_t i = 0; i < HALF; i++) { leds[i] = ColorFromPalette(firePal, heatL[i], 255, LINEARBLEND); leds[NUM_LEDS - 1 - i] = ColorFromPalette(firePal, heatR[i], 255, LINEARBLEND); } FastLED.show();}Разбор упирается в две цифры. Остывание масштабируется по длине поля, а не по ленте: формула (COOLING умножить на 10) разделить на N и прибавить 2 здесь получает N = 30 — половинку, — и это принципиально: посчитать её от всей ленты в 60 пикселей значит вдвое ослабить остывание, и пламя завалит всю длину жаром. Вторая цифра — кадр. При 15 мс и 60 пикселях show() на 800 кГц занимает около 1,8 мс — десятую часть бюджета; на 144 пикселях это уже 4,3 мс, треть кадра, и потолок FPS падает. Сколько стоит вывод и как его считать — считали в уроке про время кадра; для огня вывод — не мелочь: при длинной ленте кадр 15 мс на глаз склеивается в желе, и честнее перейти на 20-25 мс, чем держать FPS в убыток физике.
И про ток — приятный бонус огня. Пиксель WS2812B в белом на полной яркости берёт до 50-60 мА, но огонь живёт красным и оранжевым: синий канал почти нулевой, зелёный — четверть, и реальное потребление пламени — порядка 15-25 мА на пиксель. Камин на 144 пикселей в пике берёт два-три ампера, а не восемь, и это одна из причин, почему огонь — эффект для автономных сцен: бюджет питания из урока про яркость и ток он съедает вчетверо дешевле белой заливки.
Крутите ручку и смотрите на центр. На левом краю пламя живёт от углов: жар долетает до середины, и центральная пара пикселей горит почти не переставая — это та самая встреча полей. На правом краю остывание прижимает огонь к углам, и середина живёт тлением. Порыв кнопкой показывает другое: вспышки у корней учащаются, обе стенки вздымаются, а после отпускания камин спадает сам — никакого отдельного кода затухания не нужно, физика остывания делает это бесплатно.
Телеметрия в Serial — не украшение. Значение midL — жар в центре левой половины — показывает, докидывает ли пламя до середины: при низкой ручке оно держится в диапазоне 60-150, при высокой падает к нулю. Когда будете переносить настройки на плату, сверяйтесь именно с серединой поля: симулятор шестнадцатипиксельный, и на длинной ленте пропорции остывания придётся пересчитать по формуле из скетча, иначе камин либо задохнётся, либо зальёт всё.
| Лента | Остывание на поле | Вспышки (из 255) | Буфер: heat + цвет |
|---|---|---|---|
| 16 пикселей (половина 8) | случайно 0-34 за кадр | 84 | 8+8 + 48 = 64 байта |
| 30 пикселей (половина 15) | 0-19 | 84 | 15+15 + 90 = 120 байт |
| 60 пикселей (половина 30) | 0-20 при COOLING 55 | 84 | 30+30 + 180 = 240 байт |
| 144 пикселя (половина 72) | 0-9, остывание режется длиной | 60-84 | 72+72 + 432 = 576 байт |
Угольки поверх пламени и сцена под камин
Живому камину не хватает мелочи, которой нет в алгоритме: отдельных угольков, которые вылетают выше языков и гаснут в воздухе. Поверх огня это делается вторым слоем: раз в несколько секунд случайный пиксель в верхней трети получает яркую тёплую точку, которая дальше просто тает тем же fadeToBlackBy, что гасит и остальное. Механика искр с затуханием разобрана в отдельном уроке — здесь она встаёт на подложку: угольку нужна высота, а её даёт уже горящий жар, поэтому точку стоит рисовать там, где пламя сейчас яркое. Частота — редкая: один уголёк на два-три секунды читается как искра, шесть в секунду — как пожар в микроволновке.
Вторая сценическая деталь — тление под пламенем. Камину не идёт полный ноль в паузах: пока жар спал, лента чернеет, и следующий поджиг выглядит как вспышка в темноте. Лечится подложкой: перед рисовкой пламени весь кадр получает минимальную тёплую подмешку — пара-тройка единиц красного в нижней трети, — и камин всегда «тёплый на ощупь», даже когда пламя присело. Ночью этот же камин приглушается глобально: бюджет яркости на ночь — тема ночная версия ночника на EEPROM, и огонь вписывается туда младшим режимом наравне со свечой.
- 1. Где встречаются пламена при зеркальном камине из двух полей жара?
- 2. Что даёт раскраска жара через ColorFromPalette вместо HeatColor?
- 3. Почему один общий heat[] с зеркальным индексом даёт горящую перемычку в центре?
- 4. Пиксель WS2812B в белом на полной яркости берёт около 60 мА. Сколько примерно берёт пиксель огня?
- 5. Кадр огня 15 мс, лента 144 пикселя, show() при 800 кГц занимает около 4,3 мс. Что это значит для скетча?
- Зеркальный камин — два независимых поля жара по половине ленты, зеркальная рисовка leds[i] и leds[N-1-i]
- Один общий heat[] с зеркальным индексом создаёт самоподпитку в центре — горящую перемычку, которую не лечит остывание
- Цвет пламени — это палитра: ColorFromPalette(gPal, heat, 255, LINEARBLEND) вместо HeatColor открывает раскладку под сцену
- Остывание считается по длине поля, а не ленты: (COOLING*10)/N + 2, где N — ячейки половины
- Огонь ест в 2-4 раза меньше белой заливки — 15-25 мА на пиксель, и это делает его пригодным для автономных сцен
Частые вопросы по теме урока
- Где встречаются пламена при зеркальном камине из двух полей жара?
- Каждое поле растёт от своего конца ленты к середине. В центре жар подтягивается с двух сторон, и самый горячий участок получается сам собой, без специального кода для стыка. В центре ленты, куда жар приходит с обоих концов
- Что даёт раскраска жара через ColorFromPalette вместо HeatColor?
- Алгоритм жара не меняется — меняется только палитра, через которую температура переводится в цвет. HeatColor — это один зашитый маршрут, палитра — заменяемый. Любую раскладку цветов — газ, свечная головня, угли — без правки алгоритма жара
- Почему один общий heat[] с зеркальным индексом даёт горящую перемычку в центре?
- При зеркалировании индекса центр и его сосед читают друг друга в формуле подъёма. Жар в середине подпитывается сам от себя и перестаёт следовать общей физике остывания. Центральные ячейки дважды участвуют в подъёме друг друга и не остывают как остальные
- Пиксель WS2812B в белом на полной яркости берёт около 60 мА. Сколько примерно берёт пиксель огня?
- Ток зависит от включённых каналов. Пламя живёт красным с примесью зелёного, синий почти нулевой — реальное потребление в два-четыре раза ниже белого. Порядка 15-25 мА: у огня почти нулевой синий канал и приглушённый зелёный
- Кадр огня 15 мс, лента 144 пикселя, show() при 800 кГц занимает около 4,3 мс. Что это значит для скетча?
- Каждый пиксель отдаётся последовательно, 30 мкс на пиксель: 144 пикселя — это 4,3 мс только на вывод. На длинной ленте кадр 15 мс уже нельзя считать настоящими 15 мс. Вывод съедает треть бюджета кадра — с длиной ленты потолок FPS падает, и честнее перейти на 20-25 мс