Почему подсветка док-станции Flydigi выглядит дёшево и как я написал свою

У меня геймпад Flydigi Vader 5 Pro и к нему док-станция Charging Dock 2 Pro (в системе она видна как "Flydigi CD2"). На доке 162 RGB-светодиода, собранных в шестиугольную сетку в форме треугольника, и сами по себе они хорошие: яркие, ровные, с приличной цветопередачей. А вот эффекты из фирменной программы Flydigi Space Station выглядели дёшево: медленные анимации идут заметными ступеньками, градиенты между двумя цветами грязнеют в середине, радуга режется на полосы.
Стало интересно, виновато железо или софт. Спойлер: софт. В итоге получился FlyGlow, открытый редактор подсветки для этого дока. Ниже о том, как устроены фирменные эффекты, как док принимает кадры, что не так с цветом и как сделать всё плавно.

Как Space Station рисует эффекты
Space Station - приложение на Electron, а с железом общается фоновая служба на .NET. Эффекты считаются прямо в JavaScript интерфейса, а служба только передаёт готовые кадры в док. Если открыть код генераторов эффектов, видно всё сразу:
Каждый эффект - ровно 50 кадров на цикл. При медленной скорости один кадр держится 100 мс и дольше, поэтому плавный эффект превращается в слайд-шоу.
Цвета смешиваются линейно в sRGB. Середина перехода между двумя насыщенными цветами получается тёмной и серой: линейная интерполяция гамма-кодированных значений не соответствует ни физике, ни восприятию.

Радуга строится через HSL. HSL неравномерен для глаза: жёлтый и голубой выглядят широкими яркими полосами, синий проваливается.
Затухание считается по 8-битным значениям с округлением вниз. На тёмном конце шаг в одну единицу уже заметен глазом, и “дыхание” гаснет рывками.

Важный вывод: сам док эффекты не генерирует. Он просто проигрывает присланный набор кадров. Значит, если прислать кадры лучше, станет лучше и подсветка.
Что умеет док
Протокол дока первым разобрал проект OpenFlydigi (Linux-утилита для Apex 5 и CD2). Я перепроверил всё на своём экземпляре с прошивкой 0.0.3.9.
Параметр | Значение |
USB | VID 37D7, PID 6001, HID-интерфейс, отчёты по 64 байта |
Светодиоды | 162 штуки, 16 рядов: 14, 15, 16, 15, 14, 13 ... 3 |
Кадров в цикла | до 255 (счётчик в один байт), Space Station отправляет максимум 200 |
Шаг кадра | период в единицах по 20 мс, то есть максимум 50 кадров в секунду |
Яркость | 1..100 |
Загрузка | команда 97 открывает передачу, команда 98 несёт пакеты по 50 байт, каждый с подтверждением |
Пакет устроен просто: 5A A5 <команда> <длина> <данные> <контрольная сумма>, где контрольная сумма - восьмибитная сумма байтов от команды до конца данных. Конфиг подсветки - шесть байт заголовка (число кадров, период, яркость, режим, направление, размер палитры) и затем по 486 байт на кадр (162 × RGB).
Пара наблюдений на практике:
Один пакет с подтверждением занимает около 9 мс, то есть кадр загружается примерно 85 мс, а цикл из 200 кадров - около 17 секунд. Для живого управления в духе OpenRGB это не подходит, а вот для заранее посчитанной анимации - вполне.
Память кадров при загрузке не очищается: если заявить в заголовке кадров больше, чем реально отправлено, док начинает играть остатки предыдущей анимации вперемешку с мусором.
У Space Station есть ошибка на единицу при подсчёте пакетов: она объявляет
длина / 50 + 1пакетов, а отправляетceil(длина / 50). Числа расходятся, когда длина кратна 50, то есть при количестве кадров, дающем остаток 4 от деления на 25. FlyGlow такие количества просто не использует.
Цвет: линейный свет и OKLab
Значения в sRGB - это не количество света, а гамма-кодированные числа. 50% в sRGB - это примерно 21% света. Поэтому FlyGlow держит всё в линейном свете: так складываются и смешиваются слои, как это делал бы свет.
Для градиентов линейного света мало: глаз воспринимает яркость нелинейно, и переход в линейном RGB тоже выглядит неровно. Для перцептивной интерполяции используется OKLab - цветовое пространство Бьёрна Оттоссона, в котором равные шаги выглядят равными. Смешивание двух цветов выглядит так:
Rgb mix(const Rgb& a, const Rgb& b, float t)
{
const Lab la = toOklab(a), lb = toOklab(b);
return fromOklab({la.L + (lb.L - la.L) * t,
la.a + (lb.a - la.a) * t,
la.b + (lb.b - la.b) * t});
}Ещё три приёма:
Затухание по воспринимаемой яркости. Глаз оценивает яркость примерно как кубический корень от количества света, на этом построена ось L в OKLab. Поэтому для ровного на глаз затухания яркость задаётся в “воспринимаемых” единицах и переводится в линейный свет возведением в куб. “Дыхание” перестаёт резко гаснуть в конце.
Радуга из насыщенных основных цветов, интерполированных в OKLab, где опорные точки расставлены пропорционально расстоянию между цветами в OKLab. Оттенок идёт с равномерной скоростью, без широких жёлтых и голубых полос.
Гамма вывода настраивается. Светодиод с ШИМ-управлением выдаёт свет линейно от значения, поэтому по умолчанию в док уходят линейные значения: соотношения яркостей совпадают с превью. Для любителей картинки “как в Space Station” есть ползунок.
Восемь бит и дизеринг
Линейные значения хороши всем, кроме тёмного конца: в восьми битах самый тусклый уровень - это уже 1/255, и плавное затухание в темноте снова идёт ступеньками. Помогает временной дизеринг: ошибка округления каждого светодиода переносится на следующий кадр. Анимация зациклена, поэтому прогон делается дважды, и сохраняются результаты второго прохода: ошибка, приходящая в первый кадр, тогда ровно та, что набирается к концу цикла.

Бесшовные циклы
Док крутит анимацию по кругу, поэтому шов между последним и первым кадром заметен сразу. Каждый эффект в FlyGlow - чистая функция от фазы цикла в диапазоне [0, 1): никакого внутреннего состояния, никаких часов. Скорость задаётся целым числом повторов, а не произвольной частотой.
Для "органических" эффектов вроде огня, плазмы и сияния нужен зацикливаемый шум. Трюк классический: четырёхмерный шум, у которого время бежит по окружности в двух дополнительных измерениях.
double loopNoise(double x, double y, double phase, uint32_t seed, double radius)
{
const double a = 2.0 * std::numbers::pi * phase;
return valueNoise4(x, y, radius * std::cos(a), radius * std::sin(a), seed);
}При phase = 0 и phase = 1 точка на окружности одна и та же, поэтому шов исчезает математически, а не подгонкой. Автотест прогоняет все 21 эффект: нет ли NaN, совпадает ли последний кадр с первым, нет ли резких скачков между соседними кадрами.
Жить рядом со Space Station
Первой версией FlyGlow отправлял кадры через фоновую службу Space Station: её интерфейс общается со службой по именованному каналу \\.\pipe\fcs.sock, сообщения - protobuf с текстовым заголовком. Работало, но выяснилось, что служба обслуживает одного клиента за раз: пока открыто окно Space Station, канал занят им.
Решение оказалось проще: HID-интерфейс дока на Windows можно открыть совместно (FILE_SHARE_READ | FILE_SHARE_WRITE), даже когда его держит служба. Ответы устройства приходят во все открытые дескрипторы, поэтому свои подтверждения приходится отличать по номеру команды, а чужие игнорировать. Теперь FlyGlow пишет в док напрямую, а Space Station спокойно работает рядом и занимается геймпадом. Канал службы остался запасным путём.
Редактор
Остальное - уже интерфейс, C++20 и Qt 6 Widgets:
Слои с режимами наложения “Обычный”, “Сложение”, “Экран”, “Умножение”, “Замена светлым”, непрозрачностью и областью, нарисованной кистью.

Своя анимация. Кисть всегда привязана к ближайшему светодиоду, а размер считается кольцами шестиугольной сетки, так что размер 1 - ровно один диод. Ключевые кадры на таймлайне, между ними интерполяция в OKLab. Левая кнопка рисует, правая стирает.
Импорт GIF и видео. Видео декодируется через Media Foundation, без внешних зависимостей. Каждый светодиод усредняет свою шестиугольную ячейку кадра по 19 точкам в линейном свете, а не берёт один пиксель, поэтому мелкие детали не мерцают.

Отправка - кнопка с заливкой-прогрессом, около 85 мс на кадр.
Интерфейс на английском и русском, переключается на лету без перезапуска.
Что дальше
Подсветка геймпадов. У Apex 5 и Vader 5 Pro свой конфиг подсветки, но там всего около 10 кадров на цикл, так что выиграть получится в качестве цветов, а не в плавности.
У дока есть команда записи одного кадра, которую Space Station не использует. Если она окажется быстрой, появится живой режим: реакция на музыку или на картинку на экране, а там и интеграция с OpenRGB. Код открыт под MIT, установщик и portable-версия лежат в релизах. Если у вас такой же док с другой прошивкой, отчёт о том, как всё работает, очень поможет.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.