Skip to content

Latest commit

 

History

History
817 lines (670 loc) · 61 KB

morphing-ru.md

File metadata and controls

817 lines (670 loc) · 61 KB

Морфинг точечных структур: общий фреймворк в DGX

English version

Это не первый мой эксперимент с оживлением перехода графических форм. Первый был с кривыми Безье: шрифт на кривых Безье, где по прямой перемещаются опорные точки кривых, а по ним строится новая кривая. Затем были часы с матричным шрифтом, где цифры и символы погоды плавно превращаются друг в друга. Потом морфинг поколений игры «Жизнь». Потом эксперимент cyd-dotview-morphing: морфинг пары произвольных символов обычного битмапного шрифта, где для каждой новой точки источник ищется автоматически.

В последних трёх перетекают именно точки, и каждый раз я копировал из проекта в проект одно и то же: матрицу клеток, поиск «кто куда летит», интерполяцию, «голову» и «хвост», карту яркости с послесвечением. Меняются только данные: точки цифр, клетки «Жизни», точки глифов. Поэтому в DGX 0.1.0 всё это вынесено в общий компонент (CONFIG_DGX_ENABLE_MORPH), а в проектах остаётся только своя логика: откуда брать следующее состояние и как его показывать.

Идея

Есть два состояния: текущее и следующее. Оба — матрицы клеток, где клетка либо горит, либо нет. Проходим по всей матрице и смотрим cell_case = from + to * 2. Получается 4 возможных значения:

cell_case было будет что рисуем
0 нет нет ничего
1 есть нет точка гаснет на месте или, по выбору приложения, стекается в ближайшую оставшуюся
2 нет есть точка откуда-то прилетает
3 есть есть точка стоит, как стояла

Случай 2 самый интересный. В «Жизни» всё просто: если клетка ожила, то вокруг неё ровно 3 живых соседа, они и есть «родители», и от каждого в новую клетку летит точка. У пары символов такого правила нет, источник надо подобрать. Именно здесь проекты и отличаются, поэтому эта часть в фреймворке сделана коллбеком, а всё остальное общее.

Случай 1 проверяется после того, как источники подобраны: клетка могла послужить источником для рождения другой, хоть сама и не выжила. Тогда она уже «улетела», и отдельно её гасить не надо. Если не послужила, это сирота. По умолчанию она просто гаснет, без «головы» и «хвоста», линейно от 255 до 0 по мере продвижения t. Второй режим, dgx_morph_create_with() с DGX_MORPH_ORPHANS_MERGE, отправляет её в ближайшую точку, которая останется на экране; он описан ниже, в разделе «Сироты стекаются в оставшиеся точки».

Модель у фреймворка простая: конечное множество клеток переходит в конечное множество клеток по прямым отрезкам. Это подходит для клеточных автоматов, точечных и растровых шрифтов, пиксельных иконок. Кривые, контуры и полигоны тоже можно сначала растеризовать в матрицу, но связность контура и порядок вершин при этом не сохраняются. Поэтому шрифт на кривых Безье в этот фреймворк не ложится: там анимируются опорные точки кривых, а не горящие клетки.

Голова и хвост

Полёт точки из клетки A в клетку B рисуется двумя дисками один за другим, яркость вокруг каждого складывается. Получается «голова» и «хвост», а между ними яркий мостик. Голова всегда в положении t, хвост сначала стоит на месте, а потом догоняет: t_tail = max(t * 1.5 - 0.5, 0).

t голова хвост
0 0 0
1/3 1/3 0
2/3 2/3 1/2
1 1 1

Хвост трогается с места уже при t = 1/3, а не на половине пути, как можно подумать, глядя на анимацию, и к t = 1 совпадает с головой.

Оба диска рисуются всегда, каждый с половиной яркости полёта. В начале и в конце они совпадают и складываются в полную яркость. Это значит, что хвост имеет смысл только с аддитивным рендерером, который складывает яркость. Для рендерера, который перерисовывает пиксели поверх, хвост надо выключить.

Яркость делится на всех, кто рисует точку

Каждый диск — источник света, и если рисовать все на полную яркость, точки с несколькими источниками пересветятся. В «Жизни» у новой точки 3 родителя, и каждый запускает 2 диска, то есть на одну точку приходится 3 × 2 = 6 источников света. Каждый рисуется с яркостью 255/6, чтобы в итоговой точке сложиться в правильную сумму. У точки шрифта источник один, и каждый из двух дисков рисуется с половиной яркости.

В фреймворке это правило разделено на две части. План делит 255 между сходящимися источниками (3 родителя — по 85), а отрисовка делит яркость полёта между головой и хвостом. Деление целочисленное, поэтому сумма может не сойтись ровно в 255: 85 / 2 = 42, 6 × 42 = 252. На глаз это не видно.

Побочный эффект: только что яркая точка, став родителем, становится в 3–6 раз темнее. Но голова убегает вперёд, и перепад не мешает, скорее наоборот, выглядит органично.

Послесвечение

Чтобы смена кадров не была резкой, glow-рендерер держит две карты яркости. В glow_next собирается яркость всех точек текущего кадра, а в glow_prev лежит результат предыдущего. Новый результат:

intensity = glow_next[i] * t_smooth + (1 - t_smooth) * glow_prev[i];

где t_smooth = t * t * (3 - 2 * t) — это smoothstep. Результат записывается обратно в glow_prev, а glow_next очищается. В самом первом кадре предыдущего результата ещё нет, и кадр берётся целиком.

Движение точек при этом линейное, как во всех трёх исходных проектах: smoothstep применяется только к смешиванию света. Если хочется сгладить и движение, есть dgx_morph_ease(), но в рендерер тогда лучше передавать исходный t, иначе smoothstep наложится дважды.

Смешиваются не начальная и конечная картинки, а каждый новый кадр с результатом предыдущего. Вес зависит только от t, а сколько кадров успеет пройти за переход, зависит от FPS. Чем кадров больше, тем чаще старая яркость умножается на 1 - t_smooth и тем быстрее гаснет след. На медленном выводе послесвечение тянется дольше.

Как это устроено

Путь от данных до пикселей состоит из четырёх шагов:

┌────────────────┐    ┌────────────────┐    ┌────────────────┐    ┌────────────────┐
│   ИСТОЧНИК     │    │     ПЛАН       │    │     КАДР       │    │   РЕНДЕРЕР     │
│                │    │                │    │                │    │                │
│ «какие клетки  │ ─▶ │ «кто куда      │ ─▶ │ «где точки     │ ─▶ │ «нарисуй       │
│  горят»        │    │  летит»        │    │  сейчас»       │    │  точку»        │
└────────────────┘    └────────────────┘    └────────────────┘    └────────────────┘
dgx_bit_matrix_t      dgx_morph_create()    dgx_morph_draw()      dgx_morph_dot_func_t
                      раз на переход        раз на кадр           glow или sprite

Несколько решений, которые определяют всё остальное:

  1. Всё — матрицы. Глиф, поколение «Жизни», иконка погоды превращаются в dgx_bit_matrix_t, а морф всегда строится между двумя матрицами. Глифы — только один из источников матриц.
  2. План в клетках, пиксели только при отрисовке. Морф ничего не знает ни о размере клетки, ни о положении на экране. Их передают в dgx_morph_draw(), поэтому один и тот же морф можно нарисовать где угодно и в любом масштабе.
  3. Выбор источников — коллбек. Всё остальное (классификация клеток, деление яркости, учёт занятых источников, память) делает фреймворк.
  4. Кадр без состояния. Время считает приложение. Несколько морфов можно рисовать с одним t, а можно с разными — например, чтобы буквы в слове перетекали одна за другой.
  5. Рендерер — тоже коллбек, void (*)(void *, const dgx_point_2d_t *, uint8_t). Glow-рендерер живёт дольше морфов: его «фосфор» и даёт плавную смену поколений.

Источник: матрицы

dgx_bit_matrix_t — упакованная сетка, 1 бит на клетку, с быстрыми dgx_matrix_get_point()/dgx_matrix_set_point() и счётчиком горящих клеток. Для «Жизни» это и есть поле, для своих данных матрицу можно заполнить руками.

Для глифов есть dgx_morph_glyph_matrix(font, code_point). Все глифы шрифта получают одинаковую матрицу по общей рамке шрифта с общей базовой линией, поэтому «1» и «8» разной ширины ложатся друг на друга правильно. Пробел или отсутствующий символ — просто пустая матрица. Работает и с точечными, и с битмапными шрифтами.

Общая рамка берётся из метрик шрифта: xOffsetLowest..xRightMax по горизонтали и yOffsetLowest..yBottomMax по вертикали. Для шрифтов, сгенерированных до появления этих метрик, рамка вычисляется проходом по всем глифам.

Пример с числами. У TerminusTTFMedium12 рамка 6 × 12 клеток. На экране 320 × 240 максимальный размер клетки min(320 / 6, 240 / 12) = min(53, 20) = 20, картинка 120 × 240 пикселей.

Точечные шрифты

В DGX шрифт может храниться битмапом (DGX_FONT_BITMAP_LINES, легаси DGX_FONT_BITMAP_STREAM) или списком координат точек (DGX_FONT_DOTS). Точечный формат придуман как раз для анимации:

// src/fonts/CasusDotView.c — «0», 27 точек
static const dgx_font_dot_t dots[] = {
    {2, 2}, {1, 3}, {1, 4}, {5, 4}, {5, 5}, {5, 6}, {4, 6}, {3, 7},
    {2, 8}, {1, 6}, {1, 5}, {3, 2}, {4, 2}, {5, 3},
    {5, 10}, {5, 11}, {5, 9}, {5, 8}, {5, 7}, {4, 12}, {3, 12}, {2, 12},
    {1, 11}, {1, 10}, {1, 9}, {1, 8}, {1, 7},          // ... '0'
    {1, 4}, {2, 3}, {3, 2}, /* ... точки '1' */         // dots + 27
};

В часах порядок точек в массиве и есть хореография: точка с индексом i летит в точку с индексом i другого символа. Точки расставлены так, чтобы неизменяемые части символов оставались на местах, а изменяемые меньше пересекались. Цифры и пару символов для градусов в CasusDotView я сделал сам, а за основу WeatherIconsRegular13 взял шрифт с иконками погоды и доработал их руками в редакторе FontCreator, который позволяет при редактировании символа указать индекс каждой точки.

Матрица порядок точек не хранит, поэтому для морфа через матрицы он не важен: источники подбираются геометрически. Индексный морф часов остаётся в dgx_font_make_morph_struct(), как и раньше.

План: кто куда летит

dgx_morph_t *dgx_morph_create(const dgx_bit_matrix_t *from,
                              const dgx_bit_matrix_t *to,
                              dgx_morph_sources_func_t sources,
                              void *user_data);

Фреймворк проходит по объединению двух матриц (размеры могут отличаться) и раскладывает клетки:

  • горит в обеих — статичная точка, яркость 255;
  • горит только в to — спрашиваем коллбек, откуда она прилетает;
  • горит только в from и никому не послужила источником — гаснет на месте.

Особый случай — пустое to, например символ превращается в пробел. Тогда ничего не гаснет на месте: все точки стягиваются в центр матрицы, угасая от 255 до 0, как в cyd-dotview-morphing. Обратный случай, пустое from, получается сам собой: всем новым клеткам не из чего прилететь, и они появляются из центра.

Коллбек получает координаты новой клетки и возвращает список источников:

typedef int (*dgx_morph_sources_func_t)(const dgx_morph_ctx_t *ctx, int x, int y,
                                        int pass, dgx_point_2d_t out[8],
                                        void *user_data);

Вернул N источников — от каждого летит точка с яркостью 255/N, а источники помечаются использованными. Вернул 0 — точка появляется из центра матрицы с яркостью от 0 до 255. Вернул DGX_MORPH_DEFER — клетку спросят ещё раз на следующем проходе, когда все клетки текущего прохода уже получат источники. Откладывать клетку можно столько раз, сколько есть векторов до любой клетки сетки, и не меньше 8; клетка, отложенная и на последнем проходе, просто появляется из центра. Проходов при этом может быть много: 4 r (r + 1) для сетки шириной r + 1 клеток, то есть сотни тысяч вызовов на одну клетку сетки 320 × 240, если коллбек откладывает её до конца. Поэтому коллбек должен дать окончательный ответ сразу, как только знает, что другого не будет.

Цена коллбека — вызов на каждую ждущую клетку на каждом проходе. Готовый поиск для символов, dgx_morph_sources_cells (о нём ниже), планировщик выполняет сам, на словах битов, когда ему передана именно эта функция. Тот же поиск, спрошенный через свой коллбек, который только её и вызывает, даёт тот же план, но в среднем в 6–16 раз дольше, а в худшем случае до 50 раз. Погодные значки на ESP32 при 240 МГц, план в миллисекундах, в среднем и самое долгое:

переходы сам планировщик через коллбек
день и ночь одной погоды 1,6 и 4,8 11 и 68
погода, сменяющая одна другую 1,6 и 4,2 10 и 65
любая погода в любую 2,6 и 7,8 42 и 412

Поэтому свой коллбек с откладыванием проход за проходом имеет смысл для поиска другого рода, а не для варианта этого. Коллбек, который отвечает на первом или втором проходе, как dgx_morph_sources_life, почти ничего не стоит.

Внутри коллбека доступны dgx_morph_was_set(ctx, x, y) — горела ли клетка в from, и dgx_morph_is_used(ctx, x, y) — не занята ли она уже. Готовых коллбеков два, по одному на каждый исходный проект, и у второго есть вариант с ограничением по радиусу.

«Жизнь»: все живые соседи — родители

int dgx_morph_sources_life(const dgx_morph_ctx_t *ctx, int x, int y, int pass,
                           dgx_point_2d_t out[8], void *user_data)
{
    int n = 0;
    for (int i = 0; i < 8; ++i) {
        if (dgx_morph_was_set(ctx, x + nx[i], y + ny[i])) {
            out[n++] = (dgx_point_2d_t){ x + nx[i], y + ny[i] };
        }
    }
    return n;
}

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

Символы: ближайший источник, вектор за вектором

У пары символов правила рождения нет, поэтому dotview-morphing превращает идею «родителей» в геометрию: новая точка прилетает от ближайшей точки старого символа, которая ещё никого не питает. Статичная точка тоже может быть источником: она остаётся гореть на месте и вдобавок запускает полёт.

Поиск идёт не клетка за клеткой, а вектор за вектором. Проход — это один вектор, общий для всей сетки: каждая новая клетка, у которой ещё нет источника, смотрит на одну клетку, лежащую от неё по этому вектору, и берёт её, если это свободная точка старого символа. Потом следующий вектор, снова для всей сетки.

Векторы идут кольцо за кольцом: радиус 1, потом 2 и так далее. На кольце сначала оси: верх, низ, право, лево. Потом со сдвигом на клетку от каждой оси к углам: от верхней вправо и влево, от нижней вправо и влево, от правой вверх и вниз, от левой вверх и вниз. Потом со сдвигом на две клетки в том же порядке, и так до углов. Для радиуса 2, X — новая клетка:

14  6  1  5 13
11  .  .  .  9
 4  .  X  .  3
12  .  .  . 10
16  8  2  7 15

Отсюда два следствия. Линия, сдвинувшаяся вбок на вектор, находится этим вектором вся целиком, каждая её клетка летит от своей клетки старой линии, и линия едет, а не рассыпается. И порядок, в котором спрашивают клетки, ничего не решает: за один проход две клетки не смотрят на одну и ту же клетку, так что отнять источник друг у друга не могут. Предпочтение осталось только в порядке векторов, а какой-то порядок у них быть должен.

Так было не всегда. Сначала проходов было два: соседи, а потом, клетка за клеткой, сразу все кольца. Клетка в начале обхода могла забрать источник в четырёх клетках от себя, до которого следующей клетке было три. На погодных значках WeatherIconsRegular27 полёты теперь в среднем на 17% короче, 4,1 клетки вместо 5,0, а полётов на четыре клетки и больше — на пятую часть меньше.

Пример. Было: одна точка в (7, 5). Стало: одна точка в (5, 2). На кольцах радиуса 1 и 2 ничего нет; на кольце радиуса 3 вектор (2, 3) находит (7, 5), и вместо появления из центра получается полёт (7, 5) → (5, 2).

Ни одна точка не появляется из воздуха, пока есть хоть один неиспользованный источник, и морф выглядит как перетекание. Когда источники кончаются, оставшиеся клетки сразу появляются из центра.

Через коллбек это один вызов на каждую ждущую клетку на каждый вектор. Когда планировщику передана сама dgx_morph_sources_cells, он делает то же на строках битов: ждущие клетки строки и свободные источники той строки, на которую указывает вектор, сдвинутые на него обратно, встречаются в одном И двух слов. Кольцо, на котором ни у одной ждущей клетки ничего нет, проходится без своих векторов. Пусто ли кольцо, видно по битам, которые хранятся и по строкам, и по столбцам: четыре проверки на клетку, по одной на сторону кольца. Вектор пропускается и тогда, когда он не может вести из габаритов ждущих клеток в габариты свободных источников. Габариты сжимаются по мере того, как клетки получают источники, и поиск кончается на кольце, которое уже не дотягивается от одних до других.

Замер на ESP32 на погодных значках, которые показывают часы, 42 × 39 клеток. День и ночь одной погоды или погода, которая сменяет одна другую, например облака и облака с дождём: план строится в среднем 1,6 мс, самое долгое меньше 5 мс. Любая погода в любую другую: в среднем 2,6 мс, самое долгое 8 мс. Из этого 0,5 мс занимает план без всякого поиска. План строится один раз на переход, а не на кадр.

То же на других чипах, план в миллисекундах, в среднем и самое долгое:

чип частота погода, сменяющая одна другую любая погода в любую без поиска скорость к ESP32
ESP32-P4, ревизия 1.3 360 МГц 0,7 и 1,9 1,2 и 3,6 0,2 на 120% выше
ESP32-S3, ревизия 0.2 240 МГц 1,2 и 3,4 2,1 и 6,3 0,3 на 31% выше
ESP32-D0WD-V3, ревизия 3.1 240 МГц 1,6 и 4,2 2,6 и 7,8 0,5
ESP32-C3, ревизия 0.4 160 МГц 1,8 и 4,7 3,0 и 9,0 0,5 на 10% ниже
ESP32-C6FH4, ревизия 0.2 160 МГц 1,8 и 4,8 3,0 и 9,3 0,5 на 11% ниже

Скорость в последнем столбце посчитана по среднему времени плана с поиском на всех замеренных парах значков; от набора пар она меняется на несколько процентов. ESP32-P4 здесь инженерный образец.

В пределах радиуса

dgx_morph_sources_cells_within — тот же поиск, но не дальше заданного радиуса. Новая клетка, у которой в этих пределах нет свободной, сразу появляется из центра сетки; старая клетка, которую никто не взял, гаснет на месте; кольца дальше радиуса не просматриваются вовсе. Радиус в клетках — это int, на который указывает user_data; NULL или радиус меньше 1 значит без предела.

int radius = 6;
morph = dgx_morph_create(from, to, dgx_morph_sources_cells_within, &radius);

Этот поиск планировщик тоже выполняет сам, на словах битов. Что радиус даёт и что отнимает, для любого погодного значка в любой другой на ESP32 при 240 МГц: время плана в среднем и самое долгое, доля новых клеток, которые появляются из центра вместо полёта, и как далеко в среднем летят остальные.

радиус план, мс из центра полёт, клеток
3 1,1 и 1,7 45% 1,7
5 1,4 и 2,5 33% 2,2
8 1,9 и 3,8 23% 2,8
12 2,2 и 4,9 17% 3,3
без предела 2,6 и 7,8 10% 4,5

Маленький радиус даёт спокойный морфинг, где ничто не летит через всю картинку, ценой большего числа точек, которые просто загораются; без предела летит всякая точка, которой есть откуда.

В своём коллбеке dgx_morph_scan_vector() отдаёт вектор прохода, dgx_morph_ring_at() смотрит всё кольцо одного радиуса, а dgx_morph_ring_find() — все кольца от заданного радиуса и дальше.

Сироты стекаются в оставшиеся точки

Поиск источников решает, откуда прилетают новые точки. Остаётся случай 1, сирота: старая точка, которая никому не послужила источником и в новом символе не горит. По умолчанию она гаснет на месте, и переход E → F — это нижняя перекладина E, которая просто тает. Второй режим включается настройками dgx_morph_create_with():

dgx_morph_options_t options = { DGX_MORPH_ORPHANS_MERGE, 8 };
morph = dgx_morph_create_with(from, to, dgx_morph_sources_cells, NULL, &options);

Сирота летит в ближайшую точку, которая останется на экране, статичную или прилетающую, и вливается в неё. Нижняя перекладина E сметается влево, в основание стойки F. Цели не расходуются: в одну точку может влиться сколько угодно сирот. Ближайшая находится в три шага; каждая сирота получает цель на том шаге, который первым её нашёл.

  1. Сток вдоль фигуры. Сирота, у которой среди восьми соседей есть точка нового символа, берёт её, при нескольких — первую по порядку соседей: верх, лево, право, низ, потом диагонали. Остальные сироты получают цель от соседа-сироты, волна за волной от уже назначенных, клетка за клеткой вдоль фигуры. Так перекладина E вся стекает в ту клетку стойки, к которой примыкает. Без этого шага её правая часть ушла бы вверх, в среднюю перекладину, которая по кольцам ближе. Перекладина в два ряда стекает каждым рядом в свою клетку стойки.
  2. Ближайшая точка по кольцам. Сирота, до которой волна не дошла, например отдельный кусок, который исчезает целиком, ищет точку нового символа векторами dgx_morph_scan_vector(), кольцо за кольцом. Цели не занимаются, поэтому порядок обхода сирот ничего не решает.
  3. Остаток гаснет на месте, как без режима.

merge_radius ограничивает путь на обоих шагах, в клетках вдоль фигуры и по кольцам; 0 — без предела, и тогда остатка не бывает, пока в новом символе есть хоть одна точка. Пустой новый символ — особый случай, как и раньше: всё летит в центр.

Такие полёты лежат в своём массиве merging, и рисуются они не как остальные. Яркость полная до половины пути, дальше гаснет по smoothstep до нуля в t = 1; линейное угасание от начала дало бы то же таяние, только в движении. Делить яркость между сходящимися, как у источников, здесь нельзя: линия из десяти клеток сразу стала бы в десять раз темнее. В конце полёта яркости складываются в glow и дают у цели вспышку, срезанную на 255; это и есть вливание, фосфор её сглаживает. Хвост как у остальных полётов: отстаёт, а в t = 1 совпадает с головой и с целью при нулевой яркости, так что последний кадр — чистая картинка нового символа. segments, static_points и fading_points значат то же, что без режима, а план без него остаётся прежним бит в бит.

Цена на ESP32, план между любыми двумя погодными значками из тех, что показывают часы, 42 × 39 клеток: со слиянием 3,3 мс в среднем и 9,3 самое долгое, без него 2,6 и 7,7. Сирот в такой паре в среднем 57, без предела стекаются все, с радиусом 8 — 88% при полёте в 3,7 клетки в среднем, с радиусом 4 — 62% при 2,4 клетки. Погода, которая сменяет одна другую: 2,0 мс против 1,6, и с радиусом 8 стекается всё.

Кадр: где точки сейчас

float t = dgx_morph_progress(t0, esp_timer_get_time(), 1000000);
dgx_morph_draw(morph, t, x, y, cell_width, true, dgx_morph_glow_dot, glow);

dgx_morph_progress() — это (now - start) / duration, зажатое в [0, 1]. dgx_morph_draw() выдаёт в коллбек рендерера каждую точку в пикселях: клетка (cx, cy) превращается в (x + cx * cell + cell / 2, y + cy * cell + cell / 2). Для полётов это голова и, если включён хвост, хвост, каждый по половине яркости; для статичных точек 255; для гаснущих 255 * (1 - t); для сирот, которые стекаются, такой же полёт с головой и хвостом, только яркость 255 до половины пути и smoothstep до нуля после.

Никакого состояния у кадра нет: морф только читается, время считает приложение. Поэтому несколько морфов в разных местах экрана можно рисовать с одним t, как цифры часов, или со сдвигом по времени, чтобы буквы слова перетекали последовательно:

for (int i = 0; i < letters; ++i) {
    float t = dgx_morph_progress(t0 + i * delay_us, now, duration_us);
    dgx_morph_draw(morph[i], t, x0 + i * letter_w, y0, cell, false, dot, ud);
}

Морфы для такой строки собирает dgx_morph_text_create(): берёт две UTF-8-строки, дополняет короткую пробелами и строит морф на каждую букву в общей рамке шрифта. Поле changed говорит, сколько букв слева реально меняется, а dgx_morph_text_duration_us() считает, когда закончится последняя из них, чтобы хвост из одинаковых пробелов не удлинял переход:

dgx_morph_text_t *text = dgx_morph_text_create(font, "привет", "участникам", 0,
                                               dgx_morph_sources_cells, NULL);
int64_t total_us = dgx_morph_text_duration_us(text, duration_us, delay_us);
/* text->letters[i] рисуются циклом выше */
dgx_morph_text_destroy(&text);

Синхронизировать морфы между собой для этого не нужно. С glow надо помнить, что смешивание кадров у рендерера одно на всю его картинку и берёт единственный t. Поэтому для букв со своим временем проще завести по glow на букву, размером с букву: по памяти это столько же, сколько один glow на всё слово, а каждая буква смешивается со своим t. Диск выступает за край клетки на cell / 4, поэтому glow буквы стоит делать с таким полем по краям, а буквы ставить так, чтобы поля соседних не перекрывались: present() выводит прямоугольник целиком, вместе с чёрным фоном.

Рендереры

Glow — диски со спадающей яркостью

Точка рисуется диском радиусом R = cell / 2 + cell / 4 с яркостью 255 * (1 - 3x² + 2x³), где x = d² / R². Если расчётный радиус меньше 2 пикселей, используется R = 2. Диски складываются в 8-битную карту яркости, которая затем смешивается с предыдущим кадром (послесвечение выше). В цвет яркость превращается уже при выводе, через заранее рассчитанную таблицу (LUT) из 256 цветов в формате экрана: 16, 18 или 24 бита. По умолчанию таблица серая.

dgx_morph_glow_t *glow = dgx_morph_glow_create(width, height, cell_width, 16);
dgx_morph_glow_set_lut(glow, my_lut);   // можно менять на лету
// каждый кадр:
dgx_morph_draw(morph, t, 0, 0, cell_width, true, dgx_morph_glow_dot, glow);
dgx_morph_glow_present(glow, t, screen, ox, oy);

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

Кадр в цветах рендерер не хранит: present() смешивает кадр и выводит его на экран в точку (ox, oy), переводя яркость в цвета по таблице прямо по дороге. Дисплей получает кадр строка за строкой через буфер, из которого он отправляет данные, виртуальный экран — сразу в свои пиксели. dgx_morph_glow_reset() забывает фосфор, если надо начать с чистого листа. Между морфами рендерер пересоздавать не нужно, наоборот: именно сохранённый фосфор даёт плавную смену поколений.

Память. Две карты яркости по байту на пиксель, фосфор и собираемый кадр, и больше ничего размером с кадр, при любой глубине цвета. Для картинки 240 × 240 это 115 200 байт, для ESP32 без PSRAM всё ещё немало. В проекте «Жизни» размер клетки поэтому подбирается под доступную память.

Кадру можно дать ещё один фильтр прямо перед выводом на экран:

static const int passes = 1;
dgx_morph_glow_set_filter(glow, dgx_morph_glow_blur, (void *)&passes);

Фильтр получает копию готового кадра в виде карты яркости, байт на пиксель, и то, что он в ней оставит, переводится в цвета и показывается. В фосфор и в следующие кадры ничего из сделанного им не попадает, поэтому включать и выключать его можно в любой момент. Готовый фильтр один, dgx_morph_glow_blur(), размытие. Символы контурного шрифта, превращённые в точки, сохраняют ступеньки его однобитной картинки, сильнее всего погодные значки с тонкими наклонными линиями; один-два прохода размытия их сглаживают. Свой фильтр — любая функция с той же сигнатурой. Копия стоит ещё байт на пиксель. Размытию в один проход она не нужна: ему хватает строки выше и строки ниже, поэтому оно делается построчно по дороге на экран и занимает четыре строки кадра; результат тот же, что на копии. Числом проходов можно управлять на ходу, копия появится, когда их станет больше одного. На кадре 216 × 203 один проход размытия опускает демо с символами на CYD с 44 до 35 кадров в секунду, два прохода — до 31.

Sprite — точка как матрица полутонов

Как в часах: точка — маленькая 8-битная матрица полутонов. Точки от 1 до 4 пикселей нарисованы руками, большие строятся автоматически из заполненных кругов с падающей к краю яркостью (dgx_font_make_point8()). При выводе значение пикселя умножается на яркость точки и проходит через такой же LUT, как у glow. Нулевые пиксели прозрачные.

dgx_morph_sprite_t *sprite = dgx_morph_sprite_create(8);
dgx_morph_sprite_set_lut(sprite, my_lut);   // перекраска на лету
dgx_morph_sprite_set_target(sprite, vscreen);
dgx_morph_draw(morph, t, x, y, cell, false, dgx_morph_sprite_dot, sprite);

Спрайт перерисовывает пиксели поверх, а не складывает яркость, поэтому хвост с ним выключен.

Оптимизации под микроконтроллер

Сбор яркости в glow — самое горячее место. Всё, что было в проекте «Жизни», осталось, плюс немного нового.

Заранее вычисленная яркость. Яркость в зависимости от расстояния до центра диска считается один раз при создании рендерера, причём индексом служит квадрат расстояния, чтобы не считать корень.

Пропуск углов. Точки клетки вне диска яркости не добавляют, и сканировать их незачем. Для этого заранее считается массив смещений по оси X, индексом служит dy. Для R = 13 пропускается 212 точек из 729, то есть около 29%.

Деление через сдвиги. Вместо деления на 255 для значений 0..255:

static inline uint32_t div255(uint32_t n)
{
    return (n + 1 + (n >> 8)) >> 8;
}

Фиксированная точка при смешивании кадров. Коэффициент смешивания считается один раз до пиксельного цикла, а внутри только целочисленная арифметика:

uint32_t blend = (uint32_t)(256.0f * smoothstep3(t));
for (size_t i = 0; i < pixels; ++i) {
    uint8_t v = (next[i] * blend + prev[i] * (256 - blend)) >> 8;
    /* ... */
}

Новое по сравнению с «Жизнью»:

  • симметрия по X. Вклад для px - dx и px + dx одинаковый, он считается один раз и пишется в обе стороны. Это половина работы внутреннего цикла;
  • квадрат расстояния без умножения. Вместо dx * dx на каждом шаге прибавляется разность dx² - (dx - 1)² = 2 * dx - 1;
  • LUT с переставленными байтами. Для 16-битного экрана таблица цветов хранится сразу в том порядке байтов, в каком пиксель лежит в памяти, так что при выводе на пиксель остаётся одно чтение из таблицы и одна запись.

Полный пример: «Жизнь»

int cell = 16;
dgx_morph_glow_t *glow = dgx_morph_glow_create(15 * cell, 15 * cell, cell, 16);
dgx_bit_matrix_t *now = create_pulsar();

for (;;) {
    dgx_bit_matrix_t *next = next_generation(now);
    dgx_morph_t *morph = dgx_morph_create(now, next, dgx_morph_sources_life, NULL);
    int64_t t0 = esp_timer_get_time();
    float t;
    do {
        t = dgx_morph_progress(t0, esp_timer_get_time(), 1000000);
        dgx_morph_draw(morph, t, 0, 0, cell, true, dgx_morph_glow_dot, glow);
        dgx_morph_glow_present(glow, t, screen, ox, oy);
        vTaskDelay(1);
    } while (t < 1.0f);
    dgx_morph_destroy(&morph);
    dgx_matrix_destroy(&now);
    now = next;
}

Пульсар 15 × 15 при клетке 16 — это 240 × 240 пикселей и 48, 56 или 72 живые клетки в зависимости от фазы. На ESP32 (CYD, ILI9341 320 × 240) оригинал выдавал 25–26 FPS.

Морф пары символов выглядит так же, меняются только источник и коллбек:

dgx_bit_matrix_t *from = dgx_morph_glyph_matrix(font, cp_from);
dgx_bit_matrix_t *to = dgx_morph_glyph_matrix(font, cp_to);
dgx_morph_t *morph = dgx_morph_create(from, to, dgx_morph_sources_cells, NULL);

Как ложатся проекты

Два из трёх проектов в переписанном виде лежат в примерах: examples/glyph_morph_demo и examples/life_morph_demo, третий остаётся как есть.

cyd-dotview-morphing. Из примерно 800 строк main.c уходят матрица клеток, вектор сегментов, create_cell_morphing() с кольцевым поиском (около 300 строк), вся glow-часть и collect_initial_glow(): предпрогрев фосфора не нужен, потому что первый кадр берётся целиком. Уходят и 316 строк обёрток, которые не использовались. Остаются инициализация дисплея, цикл по символам и тайминг.

cyd-life-morphing. Уходят LifeTransformation, collect_glow(), draw_life_transformation() и работа с буферами, около 300 строк из 714. LifeGeneration превращается в dgx_bit_matrix_t: проверка на вымирание становится number_of_set_cells == 0, сравнение поколений остаётся memcmp. Правила игры, паттерны, рестарт и кнопка остаются в проекте. Заодно план теперь строится один раз на поколение, а не заново каждый кадр, как было в оригинале.

dotview-clock. Часы остаются как есть. Их морф — индексный, по порядку точек в шрифте, а матрица этот порядок не хранит. К тому же код морфинга в часах и так занимает несколько строк: dgx_font_make_morph_struct() уже в DGX, а отрисовка — цикл по точкам с выводом спрайта.

Грабли

Реальные ошибки, найденные тестами и при сверке с исходными проектами.

continue в цикле for не отменяет его инкременты. Первая версия dgx_matrix_foreach_set() пропускала нулевые байты через x += 7; continue;, а цикл добавлял свои x++, idx++. Счётчики рассинхронизировались на каждом байте, и координаты съезжали. Переписано на плоский while по линейному индексу.

Порядок соседей важен. В dotview-morphing соседи перебираются сначала по осям, в «Жизни» — построчно. Когда нужен один источник, побеждает первый найденный, и при общем порядке обхода dgx_morph_sources_cells выбирал диагональ там, где оригинал брал соседа сверху. Теперь порядок задаёт сам коллбек.

Статичные точки — тоже источники. Первая версия dgx_morph_sources_cells резервировала статичные точки, и результат расходился с оригиналом. В оригинале статичная точка остаётся на месте и при этом может запустить полёт в соседнюю клетку.

Нулевая конфигурация не равна конфигурации по умолчанию. В первой версии был объект «плеер» с настройками хвоста, и пример с (config_t){ .duration_us = ... } обнулял коэффициенты хвоста. Хвост навсегда застревал в начальной точке. В итоге конфигурации из API убраны совсем.

Метрики шрифта. У встроенных шрифтов не было yBottomMax, и у «0» в CasusDotView терялись нижние строки: рамка получалась высотой 12 вместо 14. А xWidest — это наибольшая ширина глифа, а не правый край: у VerdanaRegular32 правый край на 32, а xWidest - xOffsetLowest даёт 31. Поэтому добавлена метрика xRightMax, а метрики прописаны во все шрифты.

Точка за краем экрана. В первой версии сбора яркости центральный столбец диска писался без проверки X. Точка левее экрана писала в конец предыдущей строки, а в первой строке — перед началом буфера.

Число полётов заранее неизвестно. В «Жизни» у новой клетки 3 родителя, но коллбек вправе вернуть до 8. Массив полётов фиксированного размера тихо переполнялся, и всё падало потом в free() в совсем другом месте. Теперь массив растёт по мере надобности.

Повторы в данных шрифта. В WeatherIconsRegular13 у двух иконок есть точки с одинаковыми координатами: 6 у U+F00C и 1 у U+F010. В матрице они схлопываются, а в индексном морфе часов летели две точки в одно место, что незаметно на глаз.

Что ещё не закрыто

  1. Индексный порядок часов. Морф по порядку точек в шрифте в матричную модель не укладывается и живёт отдельно.
  2. Смена цели в середине полёта. Можно создать новый морф от последнего состояния, фосфор сгладит яркость, но летящие точки начнут с дискретных позиций, и возможен рывок.
  3. Послесвечение зависит от FPS. Для независимости от частоты кадров коэффициент затухания надо считать от времени между кадрами.
  4. Хвост одинаковый для всех полётов. Отставание t * 1.5 - 0.5 не зависит от длины полёта: на соседнюю клетку и через полэкрана хвост ведёт себя одинаково. Логичнее сделать отставание адаптивным от расстояния.
  5. Потокобезопасности нет. Морф после создания только читается, так что рисовать его из одной задачи безопасно, а всё остальное — с внешней синхронизацией.

Тесты

Хост-тесты лежат в test/host, собираются обычным gcc с заглушками ESP-IDF и прогоняются под ASan/UBSan командой make -C test/host. Проверяется:

  • мигалка «Жизни»: 6 полётов по 85, одна статичная точка, никто не гаснет;
  • dgx_morph_sources_cells: полёт (7, 5) → (5, 2) через кольцо, статичная точка как источник, сосед по оси раньше диагонали, неиспользованная диагональ гаснет;
  • поиск по векторам: порядок векторов кольцо за кольцом, сдвинутая линия летит одним вектором, план планировщика на словах битов совпадает с планом через коллбек на случайных сетках и глифах, с радиусом и без;
  • сироты: перекладина E стекает в стойку F, в том числе в два ряда, отдельный кусок ищет по кольцам и гаснет за радиусом, порядок соседей, план без режима не меняется; на всех парах значков WeatherIconsRegular13, цифрах и заглавных буквах TerminusTTFMedium12 и цифрах CasusDotView слияние берёт ровно сирот и каждый полёт кончается в клетке to; отрисовка с поздним угасанием;
  • пустое from: появление из центра; пустое to: все точки летят в центр с 255 до 0; бесконечный DGX_MORPH_DEFER не зацикливается;
  • матрицы разного размера, NULL, плотная шахматка с ростом массива полётов;
  • отрисовка: перевод клеток в пиксели, два полуштампа в конце полёта дают 254;
  • матрицы глифов: одинаковая рамка у разных символов, все точки CasusDotView на месте, символ → пробел стягивает все точки в центр;
  • glow с точками за краем экрана, радиус диска при малых клетках, смешивание кадров; спрайт в виртуальный экран;
  • dgx_morph_text: дополнение пробелами, число меняющихся букв, длительность;
  • морф шире 32767 клеток не создаётся, точки за пределами int16_t не рисуются;
  • копирование, сравнение и очистка матриц, матрица из 1-битного битмапа;
  • метрики всех 11 встроенных шрифтов совпадают с данными глифов, в TerminusTTFMedium12 есть все буквы слов из примера.

Не проверяется на хосте то, что требует железа: как это выглядит, FPS, вывод по SPI.

Итоги

Получилось то, что хотелось: вместо трёх копий одного и того же кода — компонент, где проекту остаётся сказать, откуда брать следующее состояние и как выбирать источники для новых точек. Дольше всего пришлось возиться не с самим морфингом, а с тем, чтобы общий код вёл себя точно так же, как оригиналы: порядок соседей, статичные точки, метрики шрифтов.

Код лежит в esp32-component-dgx под лицензией MIT. Если найдёте баг или знаете, как выжать ещё производительности, — сообщите мне.