Конвертируем цвета в JS со скоростью 6 миллиардов операций в секунду
В начале этого года я создал @colordx/core, самую быструю JS‑библиотеку для работы с цветом: парсинг различных форматов, конвертация в OKLAB/OKLCH, проверка попадания в P3-гамут и многое другое. Библиотека весит всего 7 КБ и не имеет внешних зависимостей. Высокую скорость обеспечили оптимизации под V8: мономорфизм, скрытые классы, ноль лишних аллокаций.
Для повседневных задач (один цвет по клику, по ховеру, при парсинге CSS‑значения) этого более чем достаточно. На Apple M4 конвертация HEX в OKLCH занимает ~200 нс, что даёт около 5 миллионов операций в секунду на одном ядре.
Для реалтайм‑рендера картина другая. Если экран показывает срез цветового пространства и перекрашивается заново на каждом кадре, каждый пиксель требует своей собственной конвертации. Даже скромное окно 1024×1024 на 60 fps, это уже больше миллиона пикселей за кадр или более 60 миллионов конвертаций в секунду.
Разница между 5 млн и 60 млн, это на порядок больше. Мы упираемся в чистую математику, и сократить число операций физически невозможно. Зато мы можем поменять архитектуру и выбрать лучший инструмент, который был создан, чтобы решать такие задачи. Так родилась идея @colordx/gpu (Репозиторий на Гитхабе).
Разница в архитектуре CPU и GPU
Центральный процессор, это небольшое число очень умных ядер. Даже у топовых CPU в мощном ноутбуке количество ядер сильно ограничено и не превышает двух десятков. Каждое умеет предсказывать ветвления, спекулятивно исполнять инструкции, эффективно гонять многоуровневый кэш. Идеальная архитектура для последовательной логики: разбор JSON, обход дерева, запрос к базе.
У GPU ядер на порядки больше: у RTX 5090, например, 21 760 CUDA‑ядер. Но каждое из этих ядер по отдельности слабее, чем у CPU: тактовая частота у GPU держится в районе 1,5–2,5 ГГц против 2-5+ у CPU, и одно ядро CPU выполнит одну сложную задачу быстрее одного ядра GPU.
Если сравнивать ядро с ядром, GPU проигрывает. Его преимущество не в качестве, а в количестве: тысячи слабых ядер разом работают над одной задачей, пока CPU обрабатывает её последовательно на нескольких сильных.
Поэтому для конвертации одного цвета GPU просто невыгоден: значение нужно передать, посчитать и отправить обратно. Сама передача между чипами стоит дороже, чем расчёт. Для миллионов пикселей та же передача размазывается на всех сразу: тысячи ядер GPU считают тысячи пикселей одновременно, и здесь GPU выигрывает на порядки.
Что такое фрагментные шейдеры и зачем они нужны
В WebGL рендер, это конвейер из двух программируемых стадий:
-
Вершинный шейдер обрабатывает геометрию (в моём случае это просто прямоугольник во весь canvas).
-
Фрагментный шейдер (его ещё называют пиксельным) выполняется отдельно для каждого пикселя и решает, каким цветом его закрасить.
Вся основная работа в @colordx/gpu происходит во фрагментном шейдере. Геометрия тривиальна, а GLSL‑код внутри на каждый пиксель считает полную конвертацию OKLCH → sRGB и проверки на попадание в несколько гамутов сразу, независимо от соседних пикселей. Фрагментный процессор устроен как массив простых параллельных юнитов: каждый гоняет один и тот же шейдерный код на своём пикселе.
Конвертация OKLCH → sRGB, если разложить по шагам, это поворот по hue, пара матричных умножений, три возведения в куб и передаточная кривая. Ни ветвлений, ни общего состояния между пикселями. Ровно то, для чего фрагментный шейдер и используется: одна и та же простая функция, гоняемая параллельно на сотнях пикселей за такт.
Решение
Чтобы отрисовать миллионы цветов, не нужно передавать на GPU данные о каждом пикселе для каждого кадра. Достаточно объяснить GPU одно правило, как устроена математика, а дальше он сам развернёт это правило в миллионы значений.
Это работает, потому что у каждого пикселя уже есть всё нужное, чтобы вычислить свой цвет: его позиция на экране. Если экран показывает срез цветового пространства (lightness по одной оси, chroma по другой, hue зафиксирован), координата пикселя и есть его цвет, просто ещё не переведённый в OKLCH.
Вся содержательная работа, во фрагментном шейдере, один раз на пиксель. Он берёт свою координату, превращает её в lightness и chroma по переданному правилу, добавляет зафиксированный hue, запускает математику OKLCH → sRGB.
Проверка гамута устроена так же просто. Это не отдельная ветка кода, а ещё одно число: отрицательное внутри, положительное снаружи, ровно ноль на самой границе. Отсюда приятный побочный эффект: линия границы гамута появляется сама, без дополнительных затрат.
На стороне JS от этого почти ничего не остаётся. Ни массива, ни цикла по пикселям, ни чтения результата обратно. То, что раньше было задержкой, здесь просто не нужно, потому что результат никогда и не должен возвращаться на CPU. Он рисуется сразу туда, где ему и место, на экран.
Результат в цифрах
Бенчмарк на сцене 1024×1024 (полноэкранный paint() с тремя залитыми гамутами и тремя контурными линиями, где hue меняется каждый кадр): 2000-кадровый прогон на Apple M4 выдаёт 0,164–0,171 мс на кадр, то есть 6,1–6,4 миллиарда конвертаций OKLCH → sRGB в секунду.
Для 1024×1024 на 60 fps нужно было 63 миллиона конвертаций в секунду. GPU выдаёт более 6 миллиардов, что превышает реальную потребность примерно в сто раз.
CPU на том же бенче выдаёт результат в диапазоне 13,2–18,5 миллиона конвертаций в секунду. Разрыв с GPU в среднем в 400 раз.
Также стоит отметить, что разовая инициализация (контекст, компиляция шейдера, первый paint()) стоит 5–6 мс, но происходит только один раз за жизнь рендерера.
Важно: это конвертации, доставленные как пиксели на экране, а не значения, которые можно забрать обратно в JavaScript. Если нужно узнать цвет под курсором, это по‑прежнему один вызов CPU‑библиотеки за ~200 нс, а не чтение с GPU: оба процессора остаются каждый при своей задаче. И маленький виджет на 300×200 пикселей упирается не в скорость заполнения, а в фиксированную стоимость самого вызова отрисовки.
Выводы
Конвертация одного цвета и рендер миллионов пикселей на кадр, это разные задачи, и решает их разный инструмент. Первую выиграла оптимизация под V8. Вторую выиграл отказ от идеи переносить данные: передать не цвета, а правило, по которому GPU сам их выведет из позиции пикселя.
Более 6 миллиардов конвертаций в секунду открывают новые возможности для рендера демо, графиков и различных анимаций в динамике. Когда инструмент подходит задаче, сложные обходные пути и хаки просто исчезают, а не переезжают в код.
ссылка на оригинал статьи https://habr.com/ru/articles/1057978/