Отрисовка в браузере большой анимации или как я ушел с mp4 к своему формату видео

Поделюсь с вами успешным опытом разработки рендера в браузере большой, постоянно расширяющейся анимационной сцены, состоящей из множества мелких двигающихся объектов, зацикленных в 5 секунд.

Задача

Давайте представим, что перед нами стала задача создать веб-страницу, которая должна рендерить зацикленную растровую анимацию (60 кадров, fps - 12) размером более чем 5000x5000 пикселей и при этом важно её загружать как можно быстро, поддерживать все современные браузеры и моб. устройства, делать изменение масштаба просмотра (zoom in/out).

Анимация в исходном виде разбита на секции размером 1016x812 пикселей и каждая секция хранит 60 png файлов (1 файл на кадр). Исходный размер всех png файлов, которые нужно превратить в анимацию - 2.2 ГБ. Также важно учитывать, что регулярно добавляются новые секции и размер анимации с каждым днем растет.

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

Итого, у нас 2+ ГБ информации, которую нужно быстро загрузить в браузере и воспроизводить как анимацию с fps=12. Задачу условно можно разделить на 2 взаимосвязанные подзадачи:

  1. как упаковать такой объем информации в приемлемые для скачивания размеры и при этом минимально потерять в качестве (а в идеале сохранить исходное качество);

  2. как рендерить скаченные данные на клиенте.

Поиск решения

Так как анимация уже разбита на секции, то почему бы просто не превратить каждую секцию в некий воспроизводимый браузерами формат и подгружать его по мере приближения viewport браузера к нужной секции во время скролирования? Т.е. по сути на странице будет некая сетка NxM, где в каждой ячейке сетки будет свой элемент с воспроизведением анимации (<img>, <video> или даже <div> с background-image), а на JS мы определяем какие ячейки сейчас видны пользователю (например, с помощью IntersectionObserver) и только их загружаем и воспроизводим. Такая сетка может выглядеть вот так:

Пример разбиения анимации на секции 1016x812
Пример разбиения анимации на секции 1016x812

С ходу кажется все просто, осталось подобрать формат файла для показа каждой секции. Но встает неприятная проблема - необходимо синхронизировать воспроизведение анимации во всех видимых ячейках. Т.е. если в одной ячейке сейчас будет показываться кадр №20, а в соседней ячейке кадр №19, то вся анимация разрушается и будет виден явный стык между ячейками.

Так как нужна синхронизация, то сразу отпадают все растровые анимационные форматы, воспроизведение которых мы в браузере никак не может контролировать, а это GIF и APNG.

Остаются только видео форматы, которые мы можем воспроизводить в <video> элементе внутри каждой ячейки и по какому-то триггеру (таймаут и/или действия пользователя) синхронизировать кадры во всех видимых пользователю плеерах.

Современные браузеры поддерживают различные видео форматы, такие как mp4, webm, webp, avif и пр. Остановимся на 2х самых популярных - mp4 (H.264) и webm (VP9). Оба формата хорошо делают сжатие рисованной анимации, можно настраивать частоту ключевых кадров и качество сжатия кадра.

Чтобы браузер мог достаточно быстро синхронизировать видео до нужного нам кадра необходимо иметь частые ключевые кадры (т.н. i-frames). Если у нас будет только 1й кадр ключевым, то в случае синхронизации видео с условно 5го кадра до 45го браузеру придется вычислять разницу в 40 кадров, чтобы подготовить картинку для вывода. Это очень заметно, особенно, когда таких видео несколько нужно синхронизировать. Так как FPS=12, а синхронизацию достаточно делать раз в 5-10 секунд (получено эмпирическим путем), то я выбрал частоту ключевых кадров - 10. Это, в худшем случае, заставит браузер вычислять разницу в 10 кадров. Часто делать ключевые кадры нельзя, так как это значительно увеличивает вес файла.

В чем проблема выбранного способа

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

Итак, к основным проблемам можно отнести следующее:

  • Сохранение пиксельной четкости при масштабировании. Это проблема, которую никак не смог решить на рендере через тег <video> . Тут ни повышение качество видео, ни css свойства типа image-rendering: pixelated не помогают. Можно, конечно, загружать видео в хорошем качестве в невидимый контейнер и рендерить его в <canvas>, чтобы на нем уже делать масштабирование с отключенным imageSmoothingEnabled, но этот способ очень ресурсоемкий оказался.

  • В некоторых браузерах есть лаг зацикленных видео: при возвращении видео к первому кадру воспроизведение ненадолго замирает. Где-то правится костылем, где-то никак не правится. Это сильно ухудшает просмотр анимации, так как каждые 5 секунд сцена ненадолго замирает в одном и том же моменте.

  • Периодическая синхронизация видео не дает гарантии, что в интервал между синхронизациями не произойдет замедление одного видео относительно другого. Увы, браузеры не гарантируют скорость воспроизведения каждого видео элемента, и даже если вы ничего со страницей делать не будете после, условно, 20-40 секунд одновременного воспроизведения 5 видео элементов, начнутся у проблемы: одно видео запнулось на каком-то кадре на долю секунды и в итоге часть анимации общей стало отставать на эту дельту. Проблема рассинхронизации усугубляется на слабых устройствах. Часто синхронизировать нельзя, так как для синхронизации нужно останавливать все видео потоки, проматывать их до нужного кадра и запускать воспроизведение одновременно у всех элементов. Это нетривиальная операция и визуально может быть заметна.

  • Баги самих браузеров. Например, видео просто останавливается и больше не запускается. При этом никакие действия над HTMLVideoElement не заставляют браузер восстановить воспроизведение. Этот баг происходит чаще всего на мобильных устройствах, когда переключаешься между вкладками и приложениями. Решения так и не нашел, даже костылем, так как повторяемость не частая и замирают не все видео на странице, а только некоторые. Или, например, свежая проблема в Google Chrome, когда четкость картинки у видео теряется после увеличения и уменьшения обратно видео элемента (однако, в Canary версии хрома уже проблема исправлена).

  • Особенности воспроизведения видео на разных браузерах. Больше всего тут сюрпризов подарил Safari и некоторые его особенности показа видео элементов никак не обойти. Каждый браузер что-то да ограничивает или добавляет в плееры видео. Это сильно усложняет разработку и сопровождение проекта.

Свой видео формат

Чтобы убрать синхронизацию видео потоков, уйти от багов и особенностей <video> элемента, а также повысить качество картинки нужен формат, который можно рендерить в <canvas>. Canvas мы растянем на весь экран и будем полностью контролировать процесс рендера всей анимации. HTMLCanvasElement практически не вызывает проблем с кроссбраузерностью, поэтому баги с разными устройствами почти отпадают.

Прежде всего нужно обозначить что мы хотим добиться от своего формата:

  1. пиксельная четкость такая же, как в оригинальном PNG. Это значит никаких размытий и артефактов не должно быть в изображении каждого кадра;

  2. должна быть одинаковая стоимость прокрутки видео до любого кадра. Т.е. неважно на каком мы сейчас кадре, скачок на любой другой будет одинаково требовать ресурсы. Это нужно для того, чтобы видео, как только оно загрузится, моментально подключилось к рендеру общей сцены с нужного нам кадра;

  3. каждая секция (1016x812) должна упаковываться менее, чем в 10 МБ. В идеале секция должна весить меньше, чем mp4 в наилучшем качестве (crf=1);

  4. видео формат должен иметь превью в начале файла, чтобы во время скачивания файла, получив первые N байт, мы могли начать рендерить статический превью секции и поверх progress bar загрузки.

К проблеме пиксельной четкости можно также отнести особенности кодеков. Например, H.264 при самом лучшем качестве (crf=1) почему-то все равно делает ужасное искажение красных линий на темном фоне:

Пример искажения красных линий на темном фоне
Пример искажения красных линий на темном фоне

Как будем упаковывать 2+ ГБ?

Сразу стоит отметить, что приведенные 2.2ГБ - это в PNG формате, он уже имеет сжатие Deflate. Если посчитать в распакованном виде, то это будет

5000px * 5000px * 3 байта на пиксель * 60 кадров = 4.5 ГБ

Секционирование как и с видео остается, но только размер секции уменьшаем вдвое, с 1016x812, до 508x406. Зачем и почему мы так с видео не делали? С видео чем меньше размер секции, тем больше элементов <video> одновременно на странице и это сильно повышает расход CPU/GPU, страница заметно начнет тормозить на среднестатистическом устройстве. А для рендера в canvas нам важны алгоритмы упаковки данных, и один из таких алгоритмов нуждается в частой адресации внутри файла на конкретный байт повторяющегося фрагмента данных (мы его рассмотрим чуть ниже). Чем меньше размер секции выберем, тем меньше размер файла и мы можем адрес указывать не как 4 байта (uint32), а как 3 байта (uint24), и это хорошо сокращает размер итогового файла.

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

Давайте взглянем на увеличенный фрагмент одного кадра случайной секции

Фрагмент секции с примером растровой информации
Фрагмент секции с примером растровой информации

Из этого фрагмента можно вывести следующие 4 особенности графики:

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

  2. есть области, где часто повторяется один и тот же цвет при чтении пикселей слева направо. Например, голова робота имеет длинные последовательности нескольких оттенков белого и голубого цветов;

  3. есть пиксели, которые визуально очень похожи на соседние пиксели (видно в увеличенном кружке), и если чуть отдалится от экрана, то их не различить;

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

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

Алгоритм упаковки анимации

Учитывая перечисленные выше особенности и требования к формату алгоритм упаковки получился следующий:

Блок-схема алгоритма упаковки 60 кадров анимации в один файл
Блок-схема алгоритма упаковки 60 кадров анимации в один файл

Мы инициируем цикл по всем кадрам секции, их у нас 60 штук. Для каждого кадра выполняем процессы 1-5, а затем полученный массив данных направляем в процессы 6-9.

Шаг 1 - Квантование R, G и B каналов

Это очень популярный подход много где, особенно в оцифровке аналоговых сигналов. Суть его проста: мы разделяем пиксель на составляющие три канала (Red, Green, Blue), и каждый канал делим без остатка на шаг квантования.

Я выбрал шаг квантования - 8. Не просто так, деление на 8 легко заменить смещением числа на 3 бита, что ускоряет процесс обработки огромного числа пикселей (вместо Math.floor(v / 8) * 8 делаем v >> 3 << 3). Также, такой шаг квантования позволяет канал цвета упаковать не в 8 бит, а в 5. Таким образом после квантования с шагом 8 каждый пиксель можно упаковать не в 3 байта, а уже в 2, и при этом 1 бит остается свободным (он нам будет нужен чуть позже). Грубо говоря, у нас было 4.5 ГБ данных, а после квантования цветов размер уменьшился до 3 ГБ.

Структура хранения цвета после процесса квантования
Структура хранения цвета после процесса квантования

Визуально результат этого шага будет заметен только опытному глазу и "под микроскопом". На следующем изображении показан фрагмент секции до и после кантования. Немного изменились цвета, например, балка над поездом изменила оттенок.

Изображение до и после квантования с шагом 8
Изображение до и после квантования с шагом 8

Но самый большой плюс для нас от квантования - это избавление небольшой девиации яркости каналов, тем самым похожие цвета становятся одним и тем же цветом. Например, цвет rgb(250, 4, 0) и цвет rgb(255, 0, 0) , которые зрительно неразличимы, оба станут цветом rgb(248, 0, 0)

Шаг 2 - Объединение похожих соседних пикселей

В предыдущем шаге мы уже объединили похожие цвета за счет уменьшения палитры в 8 раз, но этого недостаточно. Остаются цвета, которые также визуально очень похожи и находятся рядом друг с другом, но у них достаточно большая разница в каналах, из-за этого после квантования они стали разными цветами. Обычно такие цвета появляются из-за сглаживания линий при рисовании (antialiasing) или при создании плавных переходов цвета (градиенты).

Для детектирования похожих цветов хорошо подойдет алгоритм Delta E (ΔE). Эта функция на вход принимает два цвета, а на выходе получаем коэффициент различия цвета. Если он меньше 1.0, то цвета неразличимы человеческим глазом, если от 1.0 до 2.0, то цвета очень похожи и также почти неразличимы, от 2.0 до 10.0 - очень похожи, но уже различимы, и т.д. до 100.

На следующем изображении показаны примеры пар цветов и вычисленное значение ΔE:

Примеры вычисления ΔE для различных пар цветов
Примеры вычисления ΔE для различных пар цветов

Зная особенности графики, которую мы хотим сравнивать, применим дифференцированный подход:

  • для оттенков серого мы считаем похожими цвета, если ΔE ≤ 2.0 (т.е. зрительно неразличимые цвета);

  • для любого цвета с уровнем яркости выше 50% (т.е. светлые оттенки) мы считаем похожими цвета, если ΔE ≤ 3.0;

  • для любого цвета с уровнем яркости ниже 50% мы считаем похожими цвета, если ΔE ≤ 4.0.

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

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

Шаг 3 - Вычисление разницы с ключевым кадром

Важный процесс компрессии анимации - не повторять то, что и так уже известно. В упаковке GIF и многих видео форматов есть понятия i-frame - ключевой кадр, и p-frame - кадр, хранящий дельту изменения от ключевого или от предыдущего кадра. В нашем формате не будет сравнения с предыдущим кадром, хоть это дает хорошую компрессию и повышает скорость рендера (так как буфер пикселей предыдущего кадра обычно есть под рукой). Причина отказа - необходимо выполнить требования №2 "должна быть одинаковая стоимость прокрутки видео до любого кадра". Если у нас кадры будут хранить информацию относительно предыдущего кадра, то повторяется проблема, что было у видео форматов mp4 и webm - при переходе с 5го на 45й кадр браузеру нужно будет распаковать и вычислить разницу всех промежуточных кадров, т.е. 40 кадров обработать за раз. Поэтому мы будем иметь только ключевой кадр, и причем только один - самый первый кадр в анимации.

В данном шаге мы сравниваем каждый пиксель текущего кадра с таким же по координате пикселем из первого кадра. Если разница нет, то мы вместо текущего пикселя вставляем зарезервированный цвет - rgb(0, 0, 0). Если текущий пиксель и так равен такому зарезервированному цвету, то мы его слегка меняем на rgb(0, 0, 1), глаз такой разницы не заметит и при этом у нас высвобождается зарезервированный цвет, который мы можем применять для индикации повторения пикселя из первого кадра.

На следующем изображении показан фрагмент секции из первого кадра, и ниже этот же фрагмент, только уже на 4м карде с вычисленной разницей. Как видно, мы очень много информации заменили на цвет rgb(0, 0, 0), и при рендере 4го карда движок отрисовки везде, где будет встречать такой зарезервированный цвет, будет копировать пиксель с такой же координатой из первого кадра.

1й (i-frame) и 4й (p-frame) кадры анимации
1й (i-frame) и 4й (p-frame) кадры анимации

Шаг 4 - упаковка серии одинаковых пикселей

По сути это вариации алгоритма RLE (Run-Length Encoding), только со спецификой конкретного проекта.

Мы читаем каждые 2 байта (напомню, что после шага 1 у нас пиксели хранятся в 2 байтах) и проверяем, есть ли серии одинаковых подряд идущих пикселей. Если пиксель только один такой, то эти 2 байта никак не меняем. Если же мы зафиксировали серию одинаковых пикселей, то сворачиваем её в 3, 5 или 6 байт в зависимости от длины серии (length):

Схемы упаковки серии одинаковых пикселей алгоритмом RLE
Схемы упаковки серии одинаковых пикселей алгоритмом RLE

Как видно из схемы выше, для индикации, что пиксель имеет payload с длиной повторений, мы используем контрольный бит, который у нас освободился после процесса квантования. Движок отрисовки читает 2 байта каждого пикселя, и если видит, что контрольный бит установлен в единицу, то читает дополнительно 1 байт. Если значение этого байта больше 1, значит это длина повторений uint8. Если же он равен 0 или 1, то читает ещё 2 или 3 байта, в которых хранится длина повторений uint16 или uint24 соответственно.

В результате работы этого алгоритма значительно сокращается объем файла. Например, для случайной секции размером 1016x812 после шагов 1-3 размер данных будет:

1016px * 812px * 2 байта * 60 кадров = 98 999 040 ~= 99 MB

После шага 4 размер данных уменьшается до 16 010 969 (~ 16 MB), т.е. уменьшение на 83%. Но все это благодаря подготовке пикселей в шагах 1-3.

Шаг 5 - упаковка повторяющихся последовательностей

Как уже было упомянуто в особенностях графики, у нас имеются повторяющиеся чередования пикселей в разных строках. Даже после упаковки их RLE алгоритмом все равно они остаются одинаковыми. Это избыточность и нам от неё стоит избавиться. К сожалению, готовые алгоритмы типа LZW (используется в GIF), gzip, deflate плохо убирают такую избыточность, поэтому мы напишем свой алгоритм, который будет учитывать особенности нашей задачи.

Итак, алгоритм очень простой, можно даже сказать решение "в лоб" и при этом очень ресурсоемкий в процессе упаковки (требует много RAM и CPU):

  1. В текущем кадре берем каждый пиксель с его payload (это 2, 3, 5 или 6 байт, определяем это описанным в шаге 4 способом).

  2. Ищем такой же пиксель+payload во всех предыдущих кадрах (уже обработанных шагами 1-5).

  3. Если нашли, то пробуем взять следующий пиксель+payload и ищем уже связку из двух пикселей с их payload во всех кадрах.

  4. Цикл повторяется пока мы что-то да находим. Как только мы дошли до длины последовательности пикселей, которую нигде найти не можем, мы фиксируем результаты прошлой удачной итерации поиска. Как результат мы находим в каком предыдущем уже обработанном кадре есть максимальная по длине последовательность пикселей+payload такая же, как в данном.

  5. Если длина найденного фрагмента больше 10 байт, то имеет смысл в текущем кадре заменить фрагмент на ссылку с фиксированным размером 7 байт. Структура ссылки показана на следующем изображении:

Схема хранения ссылки на повторяющийся фрагмент
Схема хранения ссылки на повторяющийся фрагмент

Как видно из схемы, для индикации ссылки мы используем 2 байта, которые всегда равны значениям 128 и 32. Это позволяет при распаковке каждого кадра движку рендера не путать эту последовательность байт с обычным зеленым цветом с индикатором наличия payload. Опять же, если в процессе квантования мы встретили цвет rgb(0, 0, 32), то мы слегка меняем его, чтобы использовать этот цвет как зарезервированный для индикации ссылки.

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

Пример чтение повторяющихся фрагментов (красные линии) из других кадров
Пример чтение повторяющихся фрагментов (красные линии) из других кадров

А в следующей гифке показано уже в динамике какую часть графики движок рендера читает не из текущего кадра, а по ссылке из другого.

Процесс чтения повторяющихся фрагментов (красные линии) из других кадров
Процесс чтения повторяющихся фрагментов (красные линии) из других кадров

Для сравнения как эта же часть секции выглядит без подсветки повторяющихся фрагментов:

Важно отметить, что здесь нет противоречия с моим же утверждением, что мы не будем обрабатывать разницу с предыдущим кадром. В данном шаге мы ищем повторяющиеся фрагменты бинарных данных, т.е. повторения в не распакованных последовательностях байт. Если движок рендера видит ссылку на повторяющийся фрагмент, он зачитывает нужное количество байт по ссылке из другого кадра и распаковывает их при рендере текущего кадра. Таким образом браузеру не нужно при переходе с 5 на 45 кадр распаковывать все 40 кадров.

Если взять ту же секцию, что мы использовали в шаге 4 и получили уменьшение размера файла с 99 МБ до 16 МБ, и применить к ней шаг 5, то размер файла уменьшится на 33%, до 10.7МБ.

Шаги 6-9 - Финальная упаковка всего в файл

Свои алгоритмы упаковки хорошо, но есть эффективные и популярные алгоритмы сжатия общего назначения, применив которые мы ещё сильнее уменьшим размер файла. Так как мы работаем с браузером, то это либо gzip, либо deflate. Оба они имеют нативную поддержку в js c недавних пор, но для браузеров, которые не имеют пока что Compression Streams API, можно использовать сторонние библиотеки, типа pako.

Можно, конечно, вообще не паковать дополнительно и переложить все это на плечи HTTP транспортировки, применять тот же Brotli, но я решил перестраховаться и паковать/распаковывать файл своей программой.

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

У нас есть требование, чтобы в момент загрузки файла, когда мы получим первые N байт, мы уже могли начать рендер некой превью секции. Для превью подойдет первый кадр, поэтому мы его отдельно сжимаем gzip (шаг 6).

Далее нам нужно создать небольшой индекс всех кадров внутри файла (шаг 7), чтобы программа чтения файла знала с какого байта и какой длины расположен каждый кадр внутри файла. Индексом будет простой фиксированной длины массив uint32. Индекс не сжимаем, он весит копейки. В индекс также заносим информацию о том, какая позиция первого кадра в компрессии gzip, и без неё. Без неё информация нужна для рендера, а с gzip информация нужна для чтения кадра в момент загрузки файла.

Итоговая структура видео файла будет следующей:

структура видео файла
структура видео файла

Если для примера взять секцию, которую мы считали в шаге 4 и 5, и выполнить над ней шаги 6-9, то в результате будет файл с размером 5.4 МБ, т.е. ещё 50% сократили относительно шага 5.

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

  • без шага 5, где мы упаковали повторяющиеся фрагменты, gzip вернет 10.6 МБ (т.е. потеряли 5 МБ);

  • без шагов 4 и 5 gzip вернет уже 11.6 МБ;

  • без шагов 2-5 - 34.9 МБ;

  • без всех шагов обработки (т.е. чистые данные rgb пикселей) - 69.7 МБ.

Таким образом gzip хорошо дополняет, но не заменяет процесс упаковки анимации.

Остался один главный вопрос: какой будет финальный размер всех секций после компрессии их этим алгоритмом? После компрессии 2.2 ГБ png файлов на выходе мы получили 82 МБ. Т.е. компрессия относительно png составила 96%. Для сравнения mp4 с crf=19 (среднее качество, нет пиксельной четкости) всех секций весит 45МБ, а mp4 с crf=1 (лучшее качество) - уже 116 МБ. Т.е. наш формат достаточно хорошо сжал при том, что мы смогли сохранить пиксельную четкость и совсем немного исказить цвета.

Как будем рендерить полученный формат?

Одно дело придумать формат, другое - найти способ его быстрого воспроизведения на клиенте. Основная сложность для нас - как распаковывать кадры всех секций, что сейчас видит пользователь, со скоростью 12 кадров в секунду. Казалось бы, всего 12 кадров в секунду, т.е. на 1 кадр у нас 83мс, времени хоть отбавляй. Но мы работает c JS, это не сверхбыстрый язык, поэтому одновременная распаковка, условного говоря, 5-10 секций размером 508x406, чаще всего не будет успевать в 80мс. Встает самый главный вопрос - как нам ускорить процесс распаковки?

Один из вариантов - использовать WebGL или WebGPU для перевода математики распаковки на плечи видеокарты. Ранее я применял такой подход на JS для других задач, типа GPGPU, однако, именно такой формат данных не представляю как можно распаковать на видео карте.

Другой вариант - WebAssembly. Однако, реализация парсера на Rust->wasm показала такие же результаты по скорости, как реализация такого же алгоритма на чистом JS (спасибо сильнейшей оптимизации движков JS в последние годы). Если делать многопоточный wasm, то разные секции можно направлять в разные потоки, что позволит параллельно распаковывать карды каждой видимой секции. Но зачем делать сложный многопоточный wasm, когда все можно на простом JS реализовать, ведь уже давно есть замечательные Web Workers!

Именно Web Workers API нам подойдет лучше всего, так как такая реализация не требует знаний дополнительных языков для компиляции wasm. Принцип работы будет простым:

  • каждая секция создается свой экземпляр Worker и передает в него URL нужного видео файла;

  • Worker загружает файл через обычный Fetch API, но при этом содержимое файла получает чанками (вот тут подробнее как такое делать). С каждым полученным чанком делаем postMessage в основной тред, чтобы сообщить какой процент файла уже загружен (нужно для прогресса загрузки);

  • Если мы накопили достаточно чанков, чтобы построить индекс файла (а он идет в начале файла и имеет фиксированную длину), то мы парсим индекс и узнаем позицию и размер в байтах упакованного в gzip первого кадра анимации;

  • Если мы накопили достаточно чанков для распаковки первого кадра, то зачитываем нужные байты из чанков, делаем ungzip и парсим кадр в массив RGBA (Uint8ClampedArray). Передаем этот массив в основной тред через postMessage, где он будет превращен в ImageBitmap и рендерится под прогрессом загрузки как превью секции.

  • Загружаем все оставшиеся чанки и сообщаем в основной тред, что загрузка завершена и можно начинать рендерить любой кадр этой секции.

  • Основной тред каждые 83мс отправляет сигнал во все активные и уже полностью загруженные Worker, чтобы они начали подготовку следующего кадра. Каждый воркер отвечает за свою секцию и внутри себя хранит бинарные данные видео файла своей секции и распакованный в RGBA первый кадр анимации (так как он ключевой).

  • Как только все Worker завершат подготовку следующего кадра и передадут в основной тред Uint8ClampedArray с RGBA информацией, мы сообщаем рендеру, что все готово для отрисовки следующего кадра.

  • Ждем следующего вызова requestAnimationFrame и просто делаем отрисовку кадра каждой секции в основной canvas.

  • Если пользователь скролирует сцену и секция больше не видна, то терминируем Worker, тем самым удаляем все из памяти. Если пользователь вернется обратно к выгруженной секции, то мы повторно создадим Worker и начнем загрузку видео файла, но в этот раз браузер отдаст нам его из disk cache, что моментально позволит отобразить секцию обратно.

Процесс параллельной загрузки секций показан в следующей гифке (стык 4х секций). Как видно, во время загрузки спустя некоторое время появляется превью картинка (это затемненный наш первый кадр). Затем, как только последний байт файла будет загружен, мы моментально подключаем секцию к рендеру с произвольного кадра.

Процесс параллельной загрузки секций и их подключения к рендеру
Процесс параллельной загрузки секций и их подключения к рендеру

По моим замерам на разных устройствах и браузерах параллельная работа Worker справляется в среднем за 5-20мс, чтобы подготовить кадр у всех видимых секций на экране. Но чем больше экран, тем больше секций, и тем больше нагружается CPU и время подготовки кадра увеличивается. И наоборот, чем меньше экран (а это мобильники и планшеты), тем меньше нужно активных воркеров и CPU на распаковку кадров.

Встает вопрос - а зачем вообще распаковывать налету, не проще ли распаковать все кадры один раз после загрузки и хранить их уже в распакованном виде? Ответ прост - не потянет память вкладки браузера, ведь по сути для 10 активных секций нужно будет выделить

10 секций * 508px * 406px * 4rgba * 60 = 494 995 200 ~= 500 МБ

Если к ним добавить все дополнительные издержки страницы, то получится все 700МБ. Но это только 10 секций, а если экран побольше, то нужно будет уже больше ГБ памяти, а то и больше. В любом случае меня интересовал такой вопрос и на первых же тестах страница падала с ошибкой Out Of Memory. Поэтому для экономии RAM нам нужно в памяти все хранить в упакованном виде, и нагружать CPU для распаковки каждого кадра перед его рендером.

Заключение

Видео форматы mp4 и webm имеют отличную компрессию и при этом достаточно хорошо сохраняют оригинальное качество. Однако, синхронное воспроизведение таких форматов имеет ряд проблем, которые либо не решаются никак, либо решаются очень ненадежными костылями. Также эти форматы плохо подходят для графики, где важно сохранить пикселизацию, особенно при масштабировании видео. Качественным решением проблемы синхронизации и пикселизации может быть замена рендера на canvas. Но для это потребуется разработки своего формата видео, который должен учитывать особенности того контента, что будет рендерится и быть не хуже по качеству сжатия, чем mp4. В частности мы рассмотрели случай рендера рисованной пиксельной анимации, которая хорошо поддается сжатию достаточно простыми алгоритмами. Как результат получили формат, который на выходе имеет приемлемый для быстрого скачивания размер, и с помощью многопоточного JS без особо сильных затрат CPU и RAM может рендерится в канвас.