Сводный список записей блога

--->>>> Сводный список записей блога <<<<---

10 мая 2021

Гори оно всё огнём.... Огненная лампа на WS2812 по мотивам лампы Гайвера


Попала мне в руки матрица из программируемых светодиодов WS2812, 16 х 16 = 256 диодов. На гибкой печатке. "Нужно что то с этим делать" - шепнуло мироздание. Интернет услужливо подсунул большой и успешный проект Алекса Гайвера - Огненную лампу.

Вроде бы все просто - бери и делай. Но я не хотел повторять его проект, плюс мне не совсем нравился эффект пламени, реализованный в лампе Гайвера. И описанные в видео алгоритмы я попробовал, но они не возбудили меня. Штирлиц шел по улицам Берлина. Что то выдавало в нём советского разведчика. То ли фуражка с красной звездой, то ли парашют за спиной...

 Когда то один товарищ из Симбирска сказал - "Мы пойдем другим путем". Ну вот и я решил попробовать придумать свой алгоритм пламени. Получится - будет хорошо, не получится - фиг с ним, передерём Гайверовский. Хотя передерастом быть не хочется, поэтому надо придумать свой.

Что нужно для начала? Придумать схему устройства и развести под схему печатную плату.
Светодиодов много - целых 256 штук, управление диодами завязано на жесткие тайминги. 1,25 мкс на один бит данных. Или 30 мкс на 24 бита - RGB-цвет. На 256 диодов надо 7.68 мс. И на протяжении этих почти 8 миллисекунд в сторону даже чихнуть нельзя. (Точнее, можно, но быстро-быстро). А поскольку генерация огня связана еще и с кучей математики - выбор пал на 32-битную платформу, на народный камушек STM32F103C8. Там и памяти достаточно, что бы "развернуть" массив данных для матрицы в линейный массив 24 байта на диод, и SPI есть, что бы выплюнуть этот массив в диодную матрицу. И есть DMA, что бы отправка данных выполнялась независимо от текущих задач МК... И тактовая частота до 72 МГц позволит быстро просчитывать кадры.

Добавляем хотелки - управление с ИК-пульта, несколько бортовых кнопок, интеграция с умным домом.... В результате родилась схема, печатка отправилась в производство. Пока китайцы делают печатку и она едет в Украину, продолжаю эксперименты с эффектами и алгоритмами. 
(Зачем там умный дом? Да почти ни зачем. Просто лично мне удобно. Плюс будет добавлено управление отключаемой розеткой. Ибо 256 диодов даже в погашенном состоянии жрут 150 ма тока.)

Долго сказка сказывается , быстро дело делается. 23 дня и платы у меня.


Поиск подходящего светорассеивателя - та еще проблема. Сначала мы с коллегой купили по стеклянной вазе. Но ваза прозрачная, нужно матировать. Обклеивать калькой - некрасиво. Попробовал белую бумагу - слишком непрозрачно. Да и вопрос об основании и креплении стекла к оному остается. В процессе поисков наткнулся на форуме у Гайвера на ссылку - там для особо ленивых можно даже купить полный комплект для лампы Гайвера. Я же взял БП на 4 китайских ампера и пластиковый плафон с пробковым основанием. 

В качестве несущих элементов были применены канализационные трубы d=50мм, стяжки, обрезки какого то пластика и прочие подручные материалы.

Привет парочке очумелых ручек

Готовая плата была разрезана на три части, собрана и закреплена в трубе.






Тест какого то эффекта в плафоне

Со сборкой закончено. Теперь поговорим о собственном алгоритме эффекта пламени.

Еще хочу предупредить, что матовый плафон, за счет того, что он находится на некотором расстоянии от матрицы диодов, кардинально меняет восприятие отображаемого эффекта.
Это следует учитывать при разработке эффектов.

И ещё. Я не знаю, как нормально снять видео работающей лампы. В темноте - идет жуткий пересвет, особенно при съемке телефоном. В хорошем освещении - исчезают полутона и опять же, остаются только цветные пятна.
Но я попробую все же показать, что у меня получилось. А ниже опишу идею алгоритма.


Что бы иметь возможность более точно просчитывать математику движущихся частиц (например, смещение на нецелое число пикселей) и при этом не уползать в вычисления с плавающей запятой, я принял простое решение - умножить все координаты на 256 и работать с целыми числами. А уже при выводе данных на матрицу конечные координаты делить на 256. Поскольку контроллер у нас 32-разрядный - ему пофиг, что суммировать - 16+1, 4096+256 или даже 1048576+65536. 32 бита - это 32 бита. Да и на 256 делить легко - отбросить младший байт - и вуаля!

В качестве пламени была принята концепция искры и её следа. (Как сказал какой то политический деятель доинтернетной эпохи - Из искры возгорится пламя). 

Искра характеризуется семью параметрами - текущие координаты X, Y, время жизни t, вертикальное приращение координаты dY, горизонтальное приращение ±dX, цвет и яркость. 

С цветом работать будем в понятиях цветового пространства HSV. Hue - цветовой круг (0-360), яркость Value от 0 до 255. Насыщенность в эффекте пламени постоянна и максимальна.

Цвет определяется как базовый + случайный зазор. Не меняется на протяжении времени жизни искры.
Для классического пламени, после ряда экспериментов, применен базовый оттенок = 357 - это практически красный, немножко сдвинутый в сторону фиолетового, что б добавить чуточку синей компоненты. Максимальный зазор у меня - 28. Т.е. 357+28 = 385. 385-360=25. 25 - это что то в районе оранжевого цвета. С учетом того, что оранжевый в схеме RGB - это красный плюс 1/2 зеленого - получилось нормально. Если зазор продлевать в сторону желтого цвета - получаются неестественные блики. 

Опять же, если выбрать стартовый цвет и зазор другие - можно получить разные виды пламени, зелёное, синее... Если убрать насыщенность - будет белое пламя.

Координаты X, Y - координата Y при создании искры всегда 0. Координата X - сначала я думал выбирать ее случайно, в пределах ширины матрицы. Но как оказалось, для имитации пламени достаточно 16 искр, по числу столбцов матрицы. И стартовая координата X численно равна номеру искры. Т.е. каждому рабочему - по фабрике, а каждой искре - по столбцу матрицы.

Все параметры, как и цвет, подобраны экспериментально, по максимальной похожести эффекта на реальное пламя. 

Время жизни - это число кадров (циклов расчета), в течении которых искра живет. Выбирается случайно, от 10 до 22 кадров. По истечении времени жизни генерируется новая искра.

Приращение dY - от 1/2 до 1. (Тут и далее я буду оперировать вещественными координатами, что бы не вносить путаницу. В реальной математике всё умножено на 256, что б работать с целыми числами).

Приращение dX - от -1/8 до +1/8. 

Яркость. Тут я предположил, что как в реальном пламени, чем горячее (и ярче) язык пламени, тем он и быстрее. Ну и обратное - чем быстрее язык пламени, тем он ярче. Соответственно, яркость искры прямо пропорциональна её вертикальной скорости. Итого - яркость Value = 255 * dY,  где dY = 1/2 .. 1.

Этап первый - "лента".

В течении жизни искры её яркость уменьшается до 0.

В каждом кадре рассчитываются новые координаты искры, ее яркость и в соответствии с этими координатами на поле матрицы зажигается соответствующий светодиод. При этом зажженные на прошлых циклах светодиоды не стираются, а остаются гореть. Т.е. искра оставляет за собой след - "ленту" с плавно уменьшающейся к концу ленты яркостью свечения.

Ниже - пример зажигания искры, тут dX = 0, после окончания времени жизни поле очищается и начинает стартовать новая искра.
dY, цвет, время жизни - случайные.

Тут и далее частота кадров значительно уменьшена, что бы была видна механика алгоритма.

К сожалению, на видео плохо видно уменьшение яркости каждого следующего пикселя. Камера телефона считает себя умнее всех :(
Но в реальности - пиксели имеют плавное уменьшение яркости.

Добавим dX.

Этап второй - угасание.

Теперь надо подумать, как гасить след искры. 

Оптимальным вариантом оказалось в каждом кадре уменьшать яркость свечения всех пикселей матрицы на константу. Я подобрал константу таким образом, что бы пиксель с максимальной яркостью 255 угасал до нуля за приблизительно 40 кадров.

Вот здесь показано попеременное движение искры вверх и, после окончания времени жизни, угасание всех пикселей. Когда все пиксели погаснут - генерируется новая искра. dX = 0, что б не отвлекал.

За счет того, что шаг уменьшения яркости при движении искры вверх больший, нежели шаг уменьшения яркости пикселей при угасании, как раз и получается эффект угасания сверху вниз.

Теперь попробуем одновременное движение искры и угасание поля. dX = 0.

Все тоже самое, но три искры.


Ну теперь можно попробовать включить все 16 искр и случайный dX.

Этап третий - размытие.

Для большей достоверности я добавил размытие по горизонтали. При зажигании искры в очередном кадре слева и справа от искры зажигаются её "клоны", того же цвета, но с половинной яркостью. Если слева или справа уже светится какой то пиксель, то клон его заменит, если яркость пикселя ниже яркости "клона".

Ну и уже пора в демках включить нормальную скорость (около 60 кадров в сек).

Одна искра с нормальной скоростью, с размытием.

Этап четвертый, финальный. Все 16 искр.

Полноценная имитация - все 16 искр. Если накрыть плафоном - будет видео из начала статьи.

Эффекты

После реализации пламени другие эффекты оказались такими простыми....

Горизонтальная и вертикальная радуги, светлячки, прыгающие огоньки...
Эффект плазмы на основе шума Перлина. 

Добавлено отображение текущего времени (из сети умного дома). Таймер автовыключения и случайные эффекты.

В принципе, лампа может работать и как автономный светильник. Засим, если вдруг кого заинтересует схема - могу выложить.

-------------------

Исходники эффекта огня.

// тип для искры.
typedef struct {
uint16_t hue;
int16_t  x, dx; 
int16_t  y, dy;
uint16_t ttl;
uint8_t value;
uint8_t saturation;
} sparkleElementType;


//  цвет и насыщенность, для вариантов эффектов
typedef struct {
int16_t start;
int16_t gap;
uint8_t saturation;
uint8_t subSaturation;
} hueGapSatType;

//  hue-saturation-value
typedef struct {
uint16_t h;
uint8_t s;
uint8_t v;
} sHSVtype; 



#define EFF_MULTIPLIER 256 // множитель для "дробных" вычислений. Должен быть степенью 2


//***** Огонь на базе Sparkle Strings
#define FLAME_MAX_COLS (LED_MATRIX_WIDTH)
#define FLAME_POINTS (FLAME_MAX_COLS)// число одновременных "искорок"
#define FLAME_MIN_DY (EFF_MULTIPLIER/2)
#define FLAME_MAX_DY EFF_MULTIPLIER // не должен быть больше делителя, что б не было проскоков огня
#define FLAME_MIN_DX (-FLAME_MAX_DX)
#define FLAME_MAX_DX (EFF_MULTIPLIER/8) // +/-
#define FLAME_MIN_TTL 10
#define FLAME_MAX_TTL 22
#define FLAME_MIN_VALUE 128
#define FLAME_MAX_VALUE 255
#define FLAME_VALUE_DECREASE 6
#define FLAME_SIDELIGHTS 1  // yes/no
#define FLAME_DELAY_MS 16 // пауза между кадрами
// настройки пламени
const static hueGapSatType flameStyles[FLAME_STYLES] = {
{.start = 357, .gap =  28, .saturation = 255 },
{.start = 60, .gap = 130, .saturation = 255 },
{.start = 150, .gap =  90, .saturation = 255 },
{.start = 220, .gap =  60, .saturation = 255 },
{.start = 270, .gap =  90, .saturation = 255 },
{.start = 0, .gap =  0 , .saturation = 0 },
};

static union {
struct {
uint16_t delay;
uint16_t brightLevel;
sHSVtype hsvArr[LED_MATRIX_WIDTH][LED_MATRIX_HEIGHT];
sparkleElementType flameArr[FLAME_POINTS];
} flame;
} efData;


//////////////////////////////////////////////////
/////////////////////////// Strings flame routines
//////////////////////////////////////////////////

// pixels - ссылка на линейный массив светодидодов
// ticks - число миллисекунд, прошедшее с прошлого вызова процедуры. Если == 0 - инициализация
// sett - ссылка на структуру с настройками

static uint8_t execStringsFlame(sRGBtype pixels[], uint16_t ticks, settingsType* sett){
int16_t i,j;
if (!ticks) { // init
for (i = 0; i < FLAME_POINTS; i++) {
efData.flame.flameArr[i].ttl = getRand(FLAME_MAX_TTL);
efData.flame.flameArr[i].value = 0;
efData.flame.flameArr[i].x = 0;
efData.flame.flameArr[i].y = 0;
efData.flame.flameArr[i].dx = 0;
efData.flame.flameArr[i].dy = 0;
efData.flame.flameArr[i].hue = flameStyles[sett->flameParam.style].start;
efData.flame.flameArr[i].saturation = flameStyles[sett->flameParam.style].saturation;
}
for (i=0; i < LED_MATRIX_WIDTH; i++)
    for (j=0; j < FLAME_MAX_COLS; j++ ) {
    efData.flame.hsvArr[i][j].h = flameStyles[sett->flameParam.style].start;
    efData.flame.hsvArr[i][j].s = 255;
    efData.flame.hsvArr[i][j].v = 0;
    }
efData.flame.brightLevel = 0;
efData.flame.delay = 0;
} // if isReset

if (efData.flame.delay>=ticks) efData.flame.delay -= ticks; else efData.flame.delay = 0;
if (efData.flame.delay) return 0;
efData.flame.delay = FLAME_DELAY_MS-1;

uint16_t targetBright = sett->brightness; //  яркость, плавное изменение
if (efData.flame.brightLevel < targetBright) efData.flame.brightLevel++;
if (efData.flame.brightLevel > targetBright) efData.flame.brightLevel--;

// угасание поля
for (i=0; i < FLAME_MAX_COLS; i++)
    for (j=0; j < LED_MATRIX_HEIGHT; j++ ) {
    if (efData.flame.hsvArr[i][j].v>FLAME_VALUE_DECREASE) efData.flame.hsvArr[i][j].v -= FLAME_VALUE_DECREASE;
    else efData.flame.hsvArr[i][j].v = 0;
    }

// цикл перебора искр
for (i=0; i < FLAME_POINTS; i++) {
if (efData.flame.flameArr[i].ttl) {
// out sparkle
int8_t mx = efData.flame.flameArr[i].x / EFF_MULTIPLIER;
int8_t my = efData.flame.flameArr[i].y / EFF_MULTIPLIER;
efData.flame.hsvArr[mx][my].h = efData.flame.flameArr[i].hue;
efData.flame.hsvArr[mx][my].s = efData.flame.flameArr[i].saturation;
efData.flame.hsvArr[mx][my].v = efData.flame.flameArr[i].value;
#if defined(FLAME_SIDELIGHTS)  && FLAME_SIDELIGHTS
// размытие влево/право
// left-right : left
mx--;
if (mx < 0) mx += FLAME_MAX_COLS;
if (efData.flame.flameArr[i].value > efData.flame.hsvArr[mx][my].v/2) {
efData.flame.hsvArr[mx][my].h = (int32_t)efData.flame.flameArr[i].hue;
efData.flame.hsvArr[mx][my].v = efData.flame.flameArr[i].value / 2;
efData.flame.hsvArr[mx][my].s = efData.flame.flameArr[i].saturation;
}
// left-right : right
mx += 2;
if (mx > FLAME_MAX_COLS-1) mx -= FLAME_MAX_COLS;
if (efData.flame.flameArr[i].value > efData.flame.hsvArr[mx][my].v/2) {
efData.flame.hsvArr[mx][my].h = (int32_t)efData.flame.flameArr[i].hue;
efData.flame.hsvArr[mx][my].v = efData.flame.flameArr[i].value / 2;
efData.flame.hsvArr[mx][my].s = efData.flame.flameArr[i].saturation;
}
#endif
// step
j = efData.flame.flameArr[i].ttl;
efData.flame.flameArr[i].ttl--;
efData.flame.flameArr[i].value = (efData.flame.flameArr[i].ttl * efData.flame.flameArr[i].value + j / 2) / j;
if (!efData.flame.flameArr[i].value) efData.flame.flameArr[i].ttl = 0;
efData.flame.flameArr[i].x += efData.flame.flameArr[i].dx;
efData.flame.flameArr[i].y += efData.flame.flameArr[i].dy;
// если вышли за верхнюю границу - то конец искорке.
if (efData.flame.flameArr[i].y > LED_MATRIX_HEIGHT*EFF_MULTIPLIER-1) {
efData.flame.flameArr[i].ttl = 0;
}
// если искорка вылезла влево или вправо - перекинем ее на другую сторону
if (efData.flame.flameArr[i].x < 0) {
efData.flame.flameArr[i].x += FLAME_MAX_COLS*EFF_MULTIPLIER;
} else if (efData.flame.flameArr[i].x > FLAME_MAX_COLS*EFF_MULTIPLIER-1) {
efData.flame.flameArr[i].x -= FLAME_MAX_COLS*EFF_MULTIPLIER;
}
// end of "if (flameArr[i].ttl)"
} else {
// new sparkle point
efData.flame.flameArr[i].ttl = getRand(FLAME_MAX_TTL-FLAME_MIN_TTL)+FLAME_MIN_TTL;
j = flameStyles[sett->flameParam.style].gap;
if (j<0) j = 0;
efData.flame.flameArr[i].hue = flameStyles[sett->flameParam.style].start + getRand(j);
efData.flame.flameArr[i].x = getRand(FLAME_MAX_COLS)*256;
efData.flame.flameArr[i].y = 0;
efData.flame.flameArr[i].dx = FLAME_MIN_DX + getRand(FLAME_MAX_DX-FLAME_MIN_DX);
efData.flame.flameArr[i].dy = FLAME_MIN_DY + getRand(FLAME_MAX_DY-FLAME_MIN_DY);
efData.flame.flameArr[i].value = FLAME_MIN_VALUE+getRand(FLAME_MAX_VALUE - FLAME_MIN_VALUE);
efData.flame.flameArr[i].saturation = flameStyles[sett->flameParam.style].saturation;
} // else
} //for i
// output data to LED array
for (i=0; i<LED_MATRIX_WIDTH; i++)
for (j=0; j<LED_MATRIX_HEIGHT; j++) {
uint16_t idx = matrix2linear(i,j);
HSV2RGB(efData.flame.hsvArr[i][j].h,
efData.flame.hsvArr[i][j].s,
efData.flame.brightLevel * efData.flame.hsvArr[i][j].v / 255,
&pixels[idx]);
} // for j
return 1;
}

10 комментариев:

  1. Отличная работа, спасибо большое! Как всегда очень четко сделано и понятно описано.
    Беру на вооружение. ))
    ( никакой политики - см Википедию "Значение:
    что-либо узнав, какой-либо новый приём, метод и т.п., начать им пользоваться" )

    ОтветитьУдалить
  2. Редко нынче попадаются полезные и приятные, для чтения, материалы. Но тут... Спасибо за шикарную дозу эстетического кайфа!

    ОтветитьУдалить
    Ответы
    1. Спасибо.
      Я выкладывал это с тайной мыслью, что кому то пригодится.
      Знаю, что dadigor чего то мутит сэтими адресныме ледами.
      И судя по комментам в ютубе к самому последнему видео, SottNick уже пытается запустить процедуру.

      Удалить
  3. А если помимо эфектов периодически выводить еще и время, бегущей строкой?

    ОтветитьУдалить
    Ответы
    1. Время выводится по нажатию кнопки на пульте или лампе. Ибо и без лампы часов в комнате достаточно.

      Удалить
  4. Попробовал Ваш алгоритм на 328 Меге. Не сразу, но заработало. Огонь пожалуй, даже более живой, по ощущениям, чем на лампе Гайвера получился. Сужу по роликам, так как самой лампы у меня нет и " в живую" не могу сравнить. Спасибо! Теперь бы еще с Перлином разобраться. Судя по хабру, штука адская и, похоже, без математики с плавающей точкой не обойтись.

    ОтветитьУдалить
    Ответы
    1. зачем плавающая точка?
      Умножили на 256 и погнали целочисленную.
      У меня в лампе перлин есть. Плазма на нем сделана. Целочисленный.

      Удалить
  5. Круто! Про умножение понятно :) оно у Вас и в огне используется - удобно. С Перлином сама векторно-скалярная математика не очень понятна пока. Да и очень уж эти матрицы прожорливы оказались. Похоже авр их еле тянет. На радуге у меня плеер отказывается работать, хрипит, трещит и циклится. Удивляюсь, как они на ардуине-то работают...

    ОтветитьУдалить
    Ответы
    1. кинул в скайп. единственное, это СТМ32 - и там 32-битная математика работает с той же скоростью, что и 8-битная.

      Удалить

======= !!! ВНИМАНИЕ !!! ======================================================================
Гугл умный и боится спама. Поэтому иногда ваши комментарии Гугл отправляет мне на премодерацию. Отправлять или нет - решаю не я, а алгоритмы Гугла. Если ваш комментарий не появился сразу, значит я получу уведомление и опубликую ваш комментарий через некоторое время. Я стараюсь это делать достаточно оперативно.