Это не первый мой эксперимент с оживлением перехода графических форм. Первый был с кривыми Безье: шрифт на кривых Безье, где по прямой перемещаются опорные точки кривых, а по ним строится новая кривая. Затем были часы с матричным шрифтом, где цифры и символы погоды плавно превращаются друг в друга. Потом морфинг поколений игры «Жизнь». Потом эксперимент 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
Несколько решений, которые определяют всё остальное:
- Всё — матрицы. Глиф, поколение «Жизни», иконка погоды превращаются в
dgx_bit_matrix_t, а морф всегда строится между двумя матрицами. Глифы — только один из источников матриц. - План в клетках, пиксели только при отрисовке. Морф ничего не знает ни
о размере клетки, ни о положении на экране. Их передают в
dgx_morph_draw(), поэтому один и тот же морф можно нарисовать где угодно и в любом масштабе. - Выбор источников — коллбек. Всё остальное (классификация клеток, деление яркости, учёт занятых источников, память) делает фреймворк.
- Кадр без состояния. Время считает приложение. Несколько морфов
можно рисовать с одним
t, а можно с разными — например, чтобы буквы в слове перетекали одна за другой. - Рендерер — тоже коллбек,
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. Цели не расходуются: в одну точку может влиться сколько угодно сирот. Ближайшая находится в три шага; каждая сирота получает цель на том шаге, который первым её нашёл.
- Сток вдоль фигуры. Сирота, у которой среди восьми соседей есть точка нового символа, берёт её, при нескольких — первую по порядку соседей: верх, лево, право, низ, потом диагонали. Остальные сироты получают цель от соседа-сироты, волна за волной от уже назначенных, клетка за клеткой вдоль фигуры. Так перекладина E вся стекает в ту клетку стойки, к которой примыкает. Без этого шага её правая часть ушла бы вверх, в среднюю перекладину, которая по кольцам ближе. Перекладина в два ряда стекает каждым рядом в свою клетку стойки.
- Ближайшая точка по кольцам. Сирота, до которой волна не дошла,
например отдельный кусок, который исчезает целиком, ищет точку нового
символа векторами
dgx_morph_scan_vector(), кольцо за кольцом. Цели не занимаются, поэтому порядок обхода сирот ничего не решает. - Остаток гаснет на месте, как без режима.
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() выводит
прямоугольник целиком, вместе с чёрным фоном.
Точка рисуется диском радиусом 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.
Как в часах: точка — маленькая 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. В матрице они
схлопываются, а в индексном морфе часов летели две точки в одно место,
что незаметно на глаз.
- Индексный порядок часов. Морф по порядку точек в шрифте в матричную модель не укладывается и живёт отдельно.
- Смена цели в середине полёта. Можно создать новый морф от последнего состояния, фосфор сгладит яркость, но летящие точки начнут с дискретных позиций, и возможен рывок.
- Послесвечение зависит от FPS. Для независимости от частоты кадров коэффициент затухания надо считать от времени между кадрами.
- Хвост одинаковый для всех полётов. Отставание
t * 1.5 - 0.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. Если найдёте баг или знаете, как выжать ещё производительности, — сообщите мне.