9 декабря 2016 в 18:22

Анимации на GPU: делаем это правильно

Думаю, все уже знают, что современные браузеры умеют рисовать некоторые части страницы на GPU. Особенно это заметно на анимациях. Например, анимация, сделанная с помощью CSS-свойства transform выглядит гораздо приятнее и плавнее, чем анимация, сделанная через top/left. Однако на вопрос «как правильно делать анимации на GPU?» обычно отвечают что-то вроде «используй transform: translateZ(0) или will-change: transform». Эти свойства уже стали чем-то вроде zoom: 1 для IE6 (если вы понимаете, о чём я ;) для подготовки слоя для анимации на GPU или композиции (compositing), как это предпочитают называть разработчики браузеров.


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


One big disclaimer


Самое важное, что хочется сказать прежде, чем мы приступим к изучению деталей GPU-композиции: это всё один огромный хак. Вы не найдёте в спецификациях W3C (по крайней мере пока) описания процесса GPU-композиции, способов явного переноса элемента на отдельный слой или даже самого режима композиции. Это всего лишь способ ускорить некоторые типовые задачи и каждый разработчик браузеров делает это по-своему. Всё, что вы прочитаете в этой статье — ни в коем случае не официальное объяснение, а результаты экспериментов и наблюдений, приправленных здравым смыслом и знаниями о работе некоторых подсистем браузера. Что-то может оказаться неправдой, а что-то изменится со временем — я вас предупредил!


Как работает композиция


Чтобы правильно подготовить страницу к GPU-анимации, очень важно не столько следовать советам, найденным интернете или в этой статье, сколько понимать, как это работает внутри браузера.


Предположим, у нас есть страница с элементами A и B, у которых указано position: absolute и разный z-index. Браузер отрисует всю страницу на CPU, отправит полученное изображение на GPU, а оттуда оно попадёт к нам на экран.


<style>
#a, #b {
    position: absolute;
}

#a {
    left: 30px;
    top: 30px;
    z-index: 2;
}

#b {
    z-index: 1;
}
</style>
<div id="#a">A</div>
<div id="#b">B</div>

example 1


Мы решили анимировать перемещение элемента A через CSS-свойство left c помощью CSS Animations:


<style>
#a, #b {
    position: absolute;
}

#a {
    left: 10px;
    top: 10px;
    z-index: 2;
    animation: move 1s linear;
}

#b {
    left: 50px;
    top: 50px;
    z-index: 1;
}

@keyframes move {
    from { left: 30px; }
    to   { left: 100px; }
}
</style>
<div id="#a">A</div>
<div id="#b">B</div>

example 1-2


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


Пересчёт геометрии и перерисовка, хоть и частичная, всей страницы на каждый шаг анимации: выглядит как очень трудоёмкая операция, особенно на больших и сложных сайтах. Гораздо эффективнее было бы один раз нарисовать два изображения: элемент A и саму страницу без элемента A, а потом просто перемещать эти изображения друг относительно друга. Иначе говоря, нужно делать композицию закэшированных изображений элементов. И именно с такой задачей лучше всего справляется GPU. Более того, он умеет это делать с субпиксельной точностью, что и придаёт ту самую плавность анимации.


Чтобы применить оптимизацию с композицией, браузер должен быть уверен, что анимируемые CSS-свойства:


  1. никак не влияют на поток документа;
  2. никак не зависят от потока документа;
  3. не потребуют перерисовки самого элемента.

Со стороны может показаться, что свойства top и left вместе с position: absolute/fixed не зависят от внешних факторов, но это не так. Например, свойство left может принимать значения в процентах, которые зависят от размера .offsetParent, а также единицы em, vh и т.д., которые зависят от окружения. Поэтому именно CSS-свойства transform и opacity подходят под описание.


Переделаем нашу анимацию: вместо left будем анимировать transform:


<style>
#a, #b {
    position: absolute;
}

#a {
    left: 10px;
    top: 10px;
    z-index: 2;
    animation: move 1s linear;
}

#b {
    left: 50px;
    top: 50px;
    z-index: 1;
}

@keyframes move {
    from { transform: translateX(0); }
    to   { transform: translateX(70px); }
}
</style>
<div id="#a">A</div>
<div id="#b">B</div>

Обратите внимание на код. Мы декларативно описали всю анимацию: её начало, конец, длительность и т.д. А это позволяет браузеру ещё до начала анимации определить, какие именно CSS-свойства элемента будут меняться. Увидев, что среди этих свойств нет тех, что влияют на reflow/repaint, браузер может применить оптимизацию с композицией: нарисовать два изображения и передать их GPU.


example 2


Преимущества такой оптимизации:


  • Очень плавная анимация с субпиксельным сглаживанием: ей теперь занимается графический процессор, специально оптимизированный под такие задачи.
  • Анимация не зависит от CPU: даже если в этот момент мы будем выполнять очень интенсивные вычисления, анимация попрежнему будет плавной, так как выполняется полностью на GPU.

Казалось бы, всё просто и понятно, какие могут возникнуть проблемы? Давайте рассмотрим, что на самом деле делает такая оптимизация.




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


Чтобы лучше понять это, просто вспомните AJAX. Например, вам нужно зарегистрировать пользователя по данным, которые он ввёл в форме авторизации. Вы не можете сказать удалённому серверу «эй, возьми данные из вот этих полей и ещё вон ту переменную и сохрани их в базе», потому что у сервера нет доступа к памяти браузера. Вместо этого вы собираете необходимые данные со страницы в некую полезную нагрузку с простым форматом данных (например, JSON) и отправляете её на сервер.


То же самое происходит и во время композиции. Так как GPU, по сути, является удалённым сервером, браузер со стороны CPU вынужден сначала подготовить специальную полезную нагрузку, а затем отправить её на устройство. Да, GPU находится совсем рядом c CPU, однако если 2 секунды на отправку и получение ответа через AJAX зачастую являются вполне приемлемыми, то лишние 3–5 миллисекунд на передачу данных на GPU могут серьёзно ухудшить качество анимации.


Что из себя представляет полезная нагрузка для GPU? Как правило, это изображения слоёв и дополнительные инструкции, которые определяют размеры слоёв, их расположение друг относительно друга, инструкции для анимации и т.д. Вот как приблизительно выглядит процесс создания нагрузки и её передачи на GPU:


  • Отрисовка каждого композитного слоя в отдельное изображение.
  • Подготовка данных о слоях (расположение, размер, прозрачность и т.д.).
  • Подготовка шейдеров для анимации (если используется CSS Transitions или Animations).
  • Отправка данных на GPU.

Таким образом, каждый раз, когда вы добавляете магическое transform: translateZ(0) или will-change: transform элементу, вы запускаете весь это процесс. Вы уже знаете, что repaint является достаточно ресурсоёмкой задачей. Но в данном случае всё ещё хуже: довольно часто браузер не может применить инкрементальный repaint и перерисовать только изменившуюся часть. Он должен заново отрисовать те части, которые были скрыты новым слоем:


example 3


Неявная композиция


Давайте вернёмся к нашему примеру с элементами A и B. Ранее мы анимировали элемент A, который находится поверх всех остальных элементов на странице. В результате получили композицию из двух слоёв: слой с A и слой с B и фоном страницы.


А теперь поменяем задачу: будем анимировать элемент B


example 4


…и у нас возникает логическая проблема. Элемент B должен быть на отдельном композитном слое, финальная композиция изображения, которое увидит пользователь, происходит на GPU. Но элемент A, который мы вообще никак не трогаем, визуально должен находиться поверх элемента B.


Вспоминаем One Big Disclaimer — в CSS нет специального режима для GPU-композиции, это просто оптимизация для решения специфических задач. Мы должны получить элементы A и B именно в том порядке, который был задан через z-index. Как в этом случае должен поступить браузер?


Совершенно верно: он перенесёт элемент A на отдельный композитный слой! Добавив тем самым ещё один тяжёлый repaint:


example 4-2


Это называется неявная композиция: один или несколько не-композитных элементов, которые по z-index находятся выше композитного элемента, также становятся композитными, то есть отрисовываются в отдельное изображение, которое затем отправляется на GPU.

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


  • 3D-трансформации: translate3d, translateZ и т.д.
  • Элементы <video>, <canvas>, <iframe>.
  • Анимация transform и opacity через Element.animate().
  • Анимация transform и opacity через СSS Transitions и Animations.
  • position: fixed.
  • will-change.
  • filter.

Подробнее можете посмотреть в файле CompositingReasons.h проекта Chromium.


На первый взгляд может показаться, что главная проблема у GPU-анимаций — неожиданные тяжёлые repaint. Но это не так. Самой большой проблемой является…


Потребление памяти


И вновь вспоминаем, что GPU — это отдельный компьютер. Отрисованные изображения слоёв нужно не только передать на GPU, но и хранить там, чтобы потом красиво анимировать.


Сколько весит изображение одного слоя? Давайте рассмотрим пример. Попробуйте угадать размер обычного прямоугольника, размером 320×240, залитого сплошным цветом #ff0000.


rect


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


Но проблема в том, что PNG (как и JPEG, GIF и т.д.) — это формат хранения и передачи данных. Чтобы отрисовать такое изображение на экране, компьютер должен распаковать его и представить в виде массива пикселей. Таким образом, наше изображение в памяти компьютера будет занимать 320×240×3 = 230 400 байт. То есть мы умножаем ширину изображения на его высоту — так мы получим количество пикселей. Затем количество пикселей умножаем на 3, так как цвет каждого пикселя описывается 3-мя байтами: RGB. Если бы изображение было полупрозрачным, мы бы умножили на 4, так как нужен ещё один байт для описания значения прозрачности (RGBA): 320×240×4 = 307 200 байт.


Браузер всегда отрисовывает композитные слои в RGBA-изображения: судя по всему, нет достаточно быстрого и эффективного способа автоматически определить, есть ли у отрисовываемого DOM-элемента прозрачные области.


Рассмотрим типичный пример: карусель из 10 фотографий размером 800×600. Вы решили сделать плавную смену изображений в карусели, поэтому заранее каждой фотографии указали will-change: transform, а потом с помощью JS анимируете переходы на действие пользователя, например, перетаскивание. Посчитаем, сколько дополнительной памяти потребуется, чтобы просто отобразить такую страницу: 800×600×4 × 10 ≈ 19 МБ.


19 МБ дополнительной памяти потребовалось для отрисовки всего лишь одного контрола на странице. А учитывая любовь современных разработчиков к SPA-страницам с множеством анимированных контролов, параллакс-эффектам, retina-изображениям и прочим визуальным штукам, дополнительные 100–200 МБ на страницу — далеко не предел. Добавьте сюда неявную композицию (признайтесь, вы ведь об этом раньше даже не задумывались? :) и мы получим совсем печальную картину.


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


example 5


И если для десктопных клиентов это ещё не так сильно заметно, то для мобильных устройств эта проблема стоит особенно остро. Во-первых, практически на всех современных устройствах используются экраны c высокой плотностью пикселей: умножаем изображения слоёв ещё на 4–9. Во-вторых, на таких устройствах довольно мало памяти, по сравнению с десктопами. Например, у ещё-не-такого-старого iPhone 6 всего 1 ГБ памяти, причём она общая для RAM и VRAM (память для GPU). Учитывая, что в лучшем случае треть этой памяти будет использоваться самой системой и фоновыми процессами, ещё треть — на браузер и текущую страницу (и это при условии, что вы не пользуетесь десятком фрэймворков и очень сильно всё оптимизируете), то для GPU-спецэффектов останется порядка 200—300 МБ. Причем, iPhone 6 относится к дорогим high-end устройствам, на более доступных устройствах памяти гораздо меньше.


У вас может возникнуть резонный вопрос: а можно ли на GPU хранить картинки в формате PNG, чтобы сэкономить память? Да, технически это возможно, однако особенность работы GPU такова, что каждый слой рисуется попиксельно. А это означает, что для отрисовки одного пикселя на экране нужно будет каждый раз заново декодировать PNG-изображение, чтобы получить нужный цвет. В таком случае скорость самой простой анимации вряд ли будет подниматься выше 1 fps.


Стоит отметить, что на GPU существуют свои форматы сжатия изображений, однако они даже близко не сравнимы с PNG или JPEG по степени сжатия, а возможность их применения в том числе ограничена поддержкой самого GPU.


За и против


Теперь, когда мы рассмотрели теоретическую часть работы анимаций на GPU, давайте для удобства соберём все «за» и «против» их использования.


За


  • Очень плавные анимации с субпиксельной точностью и скоростью в 60 fps.
  • Правильно сделанные анимации работают в отдельном потоке и не блокируются тяжелыми JS-операциями.
  • «Дешёвые» 3D-преобразования.

Против


  • Для выноса элемента на композитный слой требуется дополнительный repaint, иногда он может быть очень медленным (полная отрисовка элемента вместо инкрементальной).
  • Отрисованный слой нужно передать на GPU: чем больше слоёв и чем больше их размер, тем больше времени требуется на передачу. На средних и слабых мобильных устройствах можно заметить «моргание», когда элемент пропадает и тут же появляется.
  • Каждый композитный слой занимает дополнительную память. Память — очень ограниченный ресурс на мобильных устройствах. Чрезмерное потребление памяти может привести к крэшу браузера!
  • Неявная композиция: если не следить за ней, увеличивается время на repaint, потребление памяти и шансы «уронить» браузер.
  • Визуальные артефакты: отрисовка текста в Safari, исчезающие или искажённые фрагменты страницы.

Как видите, при всех своих уникальных достоинствах, у GPU-анимации есть ряд очень существенных недостатков, главные из которых — repaint и потребление памяти. Поэтому все наши оптимизации как раз будут связаны именно с этими двумя пунктами.


Настраиваем окружение


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


Safari


В Safari Web Inspector встроен отличный инструмент, который позволяет увидеть все композитные слои на странице, потребляемую ими память, а также — что не менее ценно — показать причину выноса элемента на отдельный слой. Чтобы увидеть этот инструмент:


  1. В Safari откройте Web Inspector c помощью ⌘⌥I. Если не получается, попробуйте в Preferences > Advanced включить опцию Show Develop Menu in menu bar и попробовать снова.
  2. В Web Inspector откройте вкладку Elements и в правой колонке выберите пункт Layers.
  3. Теперь, когда вы будете кликать по элементам в основной части вкладки Elements, справа вы увидите информацию о композитном слое для этого элемента (если он есть), а также список всех дочерних элементов со своими слоями.
  4. Если кликнуть по дочернему слою, то отобразится причина композиции — почему браузер решил вынести элемент на отдельный композитный слой.

Safari with Web Inspector


Google Chrome


В DevTools также есть похожий инструмент, однако для его включения нужно выставить специальный флаг:


  1. В браузере перейдите на chrome://flags/#enable-devtools-experiments и включите флаг Developer Tools experiments.
  2. Откройте DevTools с помощью ⌘⌥I (Mac) или Ctrl-Shift-I (PC), нажмите на иконку Chrome with DevTools в правом верхнем углу и перейдите в раздел Settings.
  3. Выберите раздел Experiments в меню и включите Layers panel.
  4. Закройте и снова откройте DevTools: вам будет доступна панель Layers.

Chrome with DevTools


В этой панели отображаются все активные композитные слои страницы в виде дерева. Если выбрать слой, будет доступна информация о нём: размер, объем занимаемой памяти, количество перерисовок, а также причина выноса на композитный слой.


Оптимизация


Итак, мы настроили окружение и теперь можем приступить непосредственно к оптимизации. Мы уже определили две основные проблемы с использованием композитных слоёв: лишний repaint, после которого изображение слоя нужно передать на GPU, и потребление памяти. Поэтому все наши оптимизации будут направлены на сокращение циклов перерисовки и снижение потребления памяти.


Избегайте неявной композиции


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


Рассмотрим небольшой пример.



У нас есть элемент A, который мы хотим анимировать по действию пользователя. Если посмотреть на страницу с помощью инструмента Layers, мы увидим, что на ней нет никаких дополнительных слоёв. Однако сразу после нажатия на кнопку Play появится несколько композитных слоёв, которые исчезнут к концу анимации. Если посмотреть на инструмент Timeline, будет видно, что начало и конец анимации сопровождаются repaint значительных областей страницы:


Chrome timeline


Давайте рассмотрим пошагово, что сделал браузер в этом примере.


  1. Как только мы загрузили страницу, браузер не нашёл никаких признаков для дополнительной композиции, поэтому нарисовал всё содержимое страницы на одном единственном слое. Это — самое эффективное и наименее затратное с точки зрения ресурсов отображение страницы.
  2. После нажатия на Play мы добавили признак композиции элементу A — CSS Transition свойства transform. Однако браузер определил, что по z-index элемент A находится ниже элемента B. Поэтому он принимает решение вынести оба элемента на отдельные композитные слои.
  3. Вынос элемента на композитный слой всегда сопровождается repaint. Во-первых, нужно создать отдельное изображение-текстуру самого элемента, а во-вторых — удалить этот элемент из предыдущего слоя. В нашем случае этим слоем был основной холст страницы.
  4. Отрисованные изображения слоёв нужно отправить на GPU, где уже и будет произведена финальная композиция изображения страницы, которое видит пользователь. В зависимости от размеров слоёв, их количества и сложности внутреннего содержимого, перерисовка и отправка слоёв на GPU может занимать значительное время. Именно поэтому иногда можно наблюдать «моргание» в начале или конце анимации: фрагменты страницы пропадают и через некоторое время вновь появляются.
  5. После завершения анимации мы удаляем признак композиции у элемента A. В этом случае браузер понимает, что больше нет смысла тратить драгоценные ресурсы на содержание элементов на отдельных слоях, поэтому он возвращается к самому оптимальному варианту – отображение элементов A и B на основном холсте. А это значит, что элементы нужно заново отрисовать на нём (ещё один repaint) и отправить полученное изображение на GPU. Этот процесс так же, как и в п.4, может сопровождаться «морганием».

Чтобы избежать проблем с неявной композицией и сократить количество артефактов, рекомендую следующее:


  • Старайтесь держать анимируемые объекты как можно выше по z-index. В идеале эти объекты должны находится прямо внутри <body>. Конечно же, это не всегда возможно из-за особенностей вёрстки, когда анимируемый элемент находится глубоко в DOM-дереве и зависит от потока. В таких случаях можно создавать копию элемента, который нужно анимировать, и размещать его прямо в <body>.
  • Можно заранее подсказать браузеру, что вы собираетесь использовать композицию, с помощью CSS-свойства will-change. В этом случае браузер может заранее (но не всегда!) вынести элемент на композитный слой и анимация всегда будет начинаться быстро. Этим свойством стоит пользоваться очень аккуратно, иначе рискуете многократно повысить потребление памяти.

Анимируйте только свойства transform и opacity


Именно эти свойства гарантированно не влияют на геометрию элемента и не зависят от окружения, в котором находится анимируемый элемент, поэтому могут полностью работать на GPU. Фактически это означает, что эффективно вы можете анимировать только перемещение, масштаб, вращение, прозрачность, а также искажения, в том числе в 3D-пространстве. Однако с их помощью вы можете эмулировать некоторые другие анимации.


Рассмотрим классический пример: смена цвета фона у элемента. Чтобы анимированно поменять цвет фона, достаточно написать вот так:


<div id="bg-change"></div>
<style>
#bg-change {
    width: 100px;
    height: 100px;
    background: red;
    transition: background 0.4s;
}

#bg-change:hover {
    background: blue;
}
</style>

Но такая анимация работает на CPU, вызывает repaint на каждый шаг и не достаточно плавная. Мы можем оптимизировать её и вынести на GPU: достаточно создать дополнительный слой поверх элемента с нужным цветом и менять у него прозрачность:


<div id="bg-change"></div>
<style>
#bg-change {
    width: 100px;
    height: 100px;
    background: red;
}

#bg-change::before {
    background: blue;
    opacity: 0;
    transition: opacity 0.4s;
}

#bg-change:hover::before {
    opacity: 1;
}
</style>

Такая анимация будет работать гораздо быстрее и плавнее, но стоит помнить про неявную композицию и повышенное потребление памяти. Хотя последний параметр мы всё-таки сможем немного оптимизировать.


Уменьшайте габариты композитных слоёв


Посмотрите на картинку ниже. Видите ли вы разницу между ними?


example 6


Это два визуально идентичных композитных слоя, однако первый весит 40 000 байт (39 КБ), а второй — в 100 раз меньше, всего 400 байт. Почему? Посмотрите на код этих слоёв:


<div id="a"></div>
<div id="b"></div>

<style>
#a, #b {
    will-change: transform;
}

#a {
    width: 100px;
    height: 100px;
}

#b {
    width: 10px;
    height: 10px;
    transform: scale(10);
}
</style>

Разница в том, что у элемента #a физические габариты — 100×100 пикселей (100×100×4 = 40 000 байт), а у элемента #b — 10×10 пикселей (10×10×4 = 400 байт), увеличенные до 100×100 с помощью transform: scale(10). Но так как #b является композитным слоем из-за will-change, свойство transform в данном случае будет примеряться уже на GPU в момент отрисовки финального изображения.


Суть трюка очень проста: с помощью width и height уменьшаем физические габариты элемента, а с помощью transform: scale(…) масштабируем уже отрисованную текстуру до нужного размера. Конечно же, разницу по весу в несколько порядков можно получить только для очень простых одноцветных слоёв. Но, например, если вам нужно анимировать большие фотографии, можно запросто уменьшить их габариты на 5–10% и компенсировать это за счёт масштаба: потеря качества не сильно должна бросаться в глаза, но зато сэкономите драгоценные ресурсы.


По возможности используйте CSS Transitions и Animations


Мы уже выяснили, что анимации свойств transform и opacity через CSS Transitions или Animations автоматически создаёт композитный слой и выполняет анимацию на GPU. Также мы можем делать анимацию и с помощью JS, однако в этом случае, как правило, нам нужно приложить чуть больше усилий, чтобы элемент оказался на композитном слое: указать translateZ(0) у transform, will-change: transform, opacity или другие свойства, которые создают композицию.


Под JS-анимацией подразумевается та анимация, где на каждый requestAnimationFrame вручную высчитывается новый кадр. Использование Element.animate() является вариацией декларативной CSS-анимации.

С одной стороны CSS Transitions/Animations довольно просто создать и переиспользовать, с другой — через JS движения по сложным траекториям делаются гораздо легче, чем в CSS, а также это единственный способ реагировать на пользовательский ввод.


Какой из этих способов лучше и универсальнее? Может, стоит оставить только JS и использовать какую-нибудь библиотеку для анимации всего?


На самом деле у CSS-анимаций есть одно очень важное преимущество: они полностью работают на GPU. Так как вы декларативно объявляете, где анимация начнётся и где закончится, браузер может предварительно подготовить весь набор необходимых инструкций и отправить их на GPU. В случае императивного JS единственное, что может знать браузер — это состояние текущего кадра. Для плавной анимации мы должны как минимум 60 раз в секунду в основном потоке браузера (а JS работает именно в нём) высчитывать данные для нового кадра и пересылать их на GPU, где этот кадр отрисуется. Помимо того, что эти расчёты и отправка данных работают намного медленнее, чем CSS-анимации, они ещё зависят и от загруженности основного потока:



В примере выше показано, что будет, если в основном потоке на JS будет выполнятся интенсивная операция. На CSS-анимации это никак не повлияет, так как новый кадр полностью считается в отдельном потоке (и даже на отдельном устройстве), в случае с JS придётся дождаться завершения тяжёлой операции и только после этого посчитать новый кадр.


Поэтому по возможности старайтесь делать анимации через CSS, особенно прелоудеры и индикаторы прогресса. Такие анимации не только будут работать быстрее, но и не будут блокировать тяжёлыми расчётами на JS.


Пример оптимизации


На самом деле вся эта статья является результатом исследования и экспериментов, которые я провёл для оптимизации сайта Chaos Fighters. Это отзывчивый промо-сайт для мобильной игры с большим количеством анимаций. Когда я только начинал его делать, я всего лишь знал, как сделать анимации плавными на разных устройствах за счёт GPU, но не понимал, как именно это работает. В итоге первая версия сайта стабильно крэшила iPhone 5 — а это была самая последняя новинка от Apple на тот момент — всего через пару секунд пользования сайтом. Но сейчас этот сайт довольно плавно работает даже на менее мощных устройствах.


Предлагаю рассмотреть самый, на мой взгляд, интересный пример оптимизации с этого сайта.


В самом верху страницы есть описание игры, под которым в фоне вращается что-то типа красного солнца. Вращение бесконечное и не интерактивное: отличный кандидат для простой CSS-анимации. Первый подход — самый наивный: сохраняем изображение солнца в виде картинки, размещаем на странице как элемент <img> и с помощью CSS-анимаций придаём ей вращение:



На первый взгляд всё просто и задача решена. Только вот изображение солнца получилось довольно большим — мобильные пользователи будут не очень этому рады.


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


Для этой оптимизации придётся немного усложнить вёрстку: .sun теперь будет контейнером, в котором размещается несколько элементов с изображением луча; каждый элемент будет повёрнут на определённый градус.



Результат визуально такой же, как и раньше, однако объём передаваемых по сети данных гораздо меньше. Но размер композитной текстуры остался прежним: 500×500×4 ≈ 977 КБ.


Для наглядности изображение солнца в нашем примере достаточно небольшое, всего 500×500 пикселей, но на реальном сайте, с учётом разных размеров у мобильных устройств (телефоны и планшеты) и плотностью пикселей, текстура солнца весила примерно 3000×3000×4 = 36 МБ! И это всего лишь один анимированный объект на странице…

Вновь пристально смотрим на нашу разметку и панель Layers в браузере. Мы вполне логично упростили себе задачу: вращаем весь контейнер с солнцем. И весь этот контейнер был отрисован браузером в одну большую текстуру, которая была отправлена на GPU. Но из-за такого упрощения в текстуру попали как полезные данные (лучи), так и бесполезные — промежутки между лучами.


Более того, бесполезных данных в текстуре гораздо больше, чем полезных! Не самый лучший способ потратить драгоценные ресурсы устройства, которых и так крайне мало.


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


  • Весь контейнер: 500×500×4 ≈ 977 КБ
  • Только 12 лучей: 250×40×4 × 12 ≈ 469 КБ

Потребление памяти сократится в 2 раза. Чтобы это произошло, мы должны анимировать не весь контейнер с солнцем, а каждый луч по отдельности. Если мы будем анимировать только лучи, то именно изображения лучей попадут на GPU, промежутки между ними не будут занимать ресурсы.


Нам нужно ещё немного усложнить разметку для независимой анимации лучей, но теперь CSS нам скорее мешает, чем помогает. Для начального размещения луча мы уже воспользовались свойством transform. Нам нужно начать анимацию именно с этого угла и совершить оборот в 360˚. Фактически это означает, что для каждого луча нужно сделать свою секцию @keyframes, а это очень много кода.


Намного проще написать небольшой JS-код, который возьмёт на себя всю начальную расстановку элементов сцены и позволит нам точнее настраивать анимации, количество лучей и т.д.



Визуально мы получили абсолютно ту же самую анимацию, но потребление памяти сократили в 2 раза.


Но и это ещё не всё. С точки зрения композиции всего сайта анимированное солнце — не центральный, а фоновый, вспомогательный элемент. Да и сами лучи не содержат чётких контрастных элементов. А это означает, что мы можем немного ухудшить качество изображения луча и это будет практически незаметно для пользователя. Таким образом мы cможем отправить на GPU текстуру меньшего размера, а затем отмасштабировать её до нужного значения: это позволит нам ещё немного сократить потребляемую память.


Попробуем сократить размер текстуры луча на 10%. Физический размер текстуры будет 250×0.9 × 40×0.9 = 225×36 пикселей. Соответственно, чтобы визуально она была размером 250×20 пикселей, нам нужно выставить ей коэффициент масштабирования 250/225 ≈ 1.111.


Добавляем несколько важных штрихов в наш код: background-size: cover; у .sun-ray, чтобы фоновая картинка подстраивалась под размер контейнера, и добавим transform: scale(1.111) при анимации луча.



Обратите внимание, что мы меняем только размеры у элемента, размер самой PNG-картинки луча остался прежним. Именно прямоугольник, описанный элементом, отрисовывается в текстуру, а не PNG-картинка.


В итоге размер всей композиции солнца в памяти GPU составляет 225×36×4 × 12 ≈ 380 КБ (было 469 КБ). Потребление памяти сократилось на 19% и мы получили довольно гибкий код, где изменяя параметр downscale можно добиваться нужного соотношения качества картинки и потребления памяти. Если остановиться на значении 0.1, то получается, что с помощью усложнения такой, казалось бы, простой анимации вращения объекта мы сократили потребляемую память в 977 / 380 ≈ 2.5 раза!


Думаю, многие заметили, что у предложенного решения есть один недостаток: анимация теперь работает на CPU, а значит будет блокироваться тяжёлыми JS-вычислениями. Поэтому тем, кто хочет закрепить полученный материал, предлагаю сделать небольшое домашнее задание. Сделайте форк последнего примера и доработайте его таким образом, чтобы вся анимация работала на GPU, но при этом не потерялось эффективность и гибкость решения. Результаты размещайте в комментариях.


Усвоенные уроки


Исследование, проведённое для оптимизации проекта Chaos Fighters, заставило меня полностью пересмотреть процесс создания современных сайтов. Вот мои основные правила:


  • Всегда обговаривайте с заказчиком и дизайнером, какие анимации и спецэффекты будут на сайте. От этого будет зависеть разметка макета и эффективность композиции.
  • С самого начала нужно следить за количеством и размером композитных слоёв с помощью инструмента Layers, особенно за неявной композицией.
  • Современные браузеры достаточно активно используют композицию не только для анимации, но и для оптимизации отрисовки некоторых частей страницы. Например, position: fixed, <iframe>, <video> также выносятся на отдельный слой.
  • Размеры композитного слоя зачастую являются более важным критерием, чем количество слоёв. Иногда браузеры пытаются сократить количество слоёв (см. Layer Squashing) — это позволяет значительно сэкономить ресурсы, особенно если композитные элементы пересекаются. Но иногда получается обратный эффект: вместо нескольких небольших слоёв получается один большой слой, потребляющий намного больше памяти. Чтобы отключить такую оптимизацию, я добавляю небольшое уникальное значение translateZ() каждому элементу. Например: translateZ(0.0001px), translateZ(0.0002px) и т.д. Браузер решит, что слои находятся в разных плоскостях и отключит оптимизацию.
  • Нельзя просто так взять и добавить transform: translateZ(0) или will-change: transform куда попало, чтобы ускорить анимации или избавиться от визуальных артефактов. У выноса элементов на GPU есть огромное количество недостатков и подводных камней, которые стоит учитывать. Неумелое использование композиции в лучшем случае приведёт в обратному эффекту: общему замедлению сайта. В худшем — просто «уронит» браузер.

В очередной раз напомню про One Big Disclaimer: нет единой спецификации по GPU-композиции и каждый производитель браузеров одни и те же проблемы может решать по-своему. Возможно, часть информации из этой статьи в скором времени потеряет актуальность. Например, разработчики Google Chrome исследуют способы по ускорению передачи данных с CPU на GPU, вплоть до использования общей памяти, когда никуда ничего передавать не нужно. А Safari уже сейчас для некоторых простых случаев (например, обычный элемент с background-color и без содержимого) вместо отрисовки текстуры на CPU отрисовывает элемент на GPU в реальном времени, сводя размер потребляемой памяти практически к нулю.


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


English version

Автор: @chikuyonok
Одноклассники
рейтинг 75,98
Делимся экспертизой
Похожие публикации

Комментарии (26)

  • 0
    Поправка: видеокарты всё-таки используют компрессию текстур, так что не всегда всё так печально, как с BMP.
    • +3

      Честно признаюсь: так глубоко не влезал. Но из найденной информации вроде как возможность компрессии текстур зависит от поддерживаемых OpenGL расширений и эта поддержка зависит от конкретного вендора GPU. Да и и при общении с разработчиками Chrome DevTools пару лет назад они обмолвились, что вроде как слои без компрессии хранятся.

      • 0
        А вы как-то мониторили занятые ресурсы на GPU?
        Тема интересная, т.к. сложно предугадать заранее какой блок сколько памяти кушает.
        • +1

          Объём занятых ресурсов отображается в панели Layers, по ней и мониторю. В Хроме ещё есть chrome://tracing/, но там тяжелее мониторить, но вроде как более достоверная информация

          • +2
            В хроме во вкладке Rendering есть галка FPS Meter, и там среди прочего есть показатель потребления памяти GPU, очень помогает в работе.
            • +1
              Точно, раньше не обращал внимания, спасибо)
              Да, вот на ней уже видна разница в выделении памяти при изменении размеров блока, здорово.
            • 0
              Хотя тоже не совсем очевидна работа, я вот зашел на несколько сайтов с огромной пачкой parallax анимаций на GSAP, он все анимации делает через transform: matrix() т.е. на GPU кидает железно и всегда, так там расход не поднимается выше 30mb.
          • 0
            Память я обычно смотрю в timeline с галочкой memory.
            Сейчас глянул через Layers, отобразил блок, применил на нем translate3d, анимировал и расход памяти не меняется когда блок 500*500px и когда сделал его 200*200px. И даже когда ему тень добавил, хоть она и дорогая по ресурсам. Похоже и в timeline и тут идет речь не о GPU-шной RAM. Или размер блока, в случае когда это не растр, не влияет на количество выделяемой памяти.
            • +1

              Для картинки без разницы, что там нарисовано: тень или что-то ещё. Это просто набор пикселей.


              Можете пример показать, где при изменении размера блока не меняется расход памяти?

              • 0
                Конечно:
                200px 1200px
                • 0

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

                  • 0
                    Т.е. рутовый не захватывает в себя все остальные? Это же не совсем адекватно по каждому слою ходить смотреть и считать сумму)
                    1 2
                    • +1

                      Каждый слой — это отдельное изображение, размер которого и отображается. Вы можете написать предложения с улучшениями разработчикам Chrome DevTools ;)

                      • 0
                        Во вкладке Rendering, как посоветовал выше Zavtramen, как раз пишется общее количество занятой / максимальной GPU Memory. В Layers эти значения вообще странно себя ведут. И я не могу найти сайта (без WebGL, сейчас с ними посмотрю) на котором я завалю это значение больше 40mb из 512 доступных. Может приведете пример где память нерационально расходуется?
                        • +1
                          Слак
                        • 0
                          Не так давно я оптимизировал модуль отображения висивиг редактора сайтов. В нем страницы сдвигались по скролу, но через трансформы (там по другому никак). Там я видел значения превышающие 200 и более mb. Там было много проблем, но в итоге получилось более-менее решить все наладить и теперь видео память расходуется в несколько раз более экономично (если верить этому показателю, но в целом падения и лаги стали происходить гораздо реже). Поверх той логики которая отражена в статье, у браузеров всегда в рукаве огромное кол-во механизмов оптимизаций, поэтому утверждать что в вашей демке что-то не так с потреблением не стоит. Я думаю просто срабатывает одна из них. Как, например, если блоки с трансформами выносить за пределы вьюпорта то они перестают учитываться при расчете потребляемой памяти, возможно они вычищаются, или происходит что-то еще.
  • 0
    В Firefox https://ru.4game.com/chaos-fighters/ без GPU-отрисовки очень тормозит, с GPU-отрисовкой лучше, но не идеально (в Linux она выключена по умолчанию). Тем не менее, в Chromium все плавно.
  • 0

    Годный материал! А для свг по потреблению памяти в зависимости от размеров (ширины, высоты) применяются те же правила? Вопрос в том, смогу ли сэкономить память уменьшив свг изображение с 1000px до 100px?

    • +2

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

      • 0
        а есть какие-то негласные правила того, какого максимального кол-ва layer'ов стоит придерживаться?
        допустим в хроме, 500 layers на страницу это уже перебор? или не страшно, если все они занимают килобайты?
        • 0

          Тут больше важно не количество слоёв, а их размер. Но и увеличивать количество слоёв лишний раз не стоит. Например, браузер может по своей внутренней логике прекратить предварительный перенос на композитный слой элементов с will-change, если слоёв станет слишком много. Chrome может по той же причине может включить Layer Squashing, с которым мне приходилось бороться, так как оптимизированный вариант занимал слишком много памяти. Каких-то конкретных цифр назвать не могу.

  • 0
    Для меня самый большой минус в том, что GPU жрет батарейку моего ноутбука, а мне совсем не хочется тратить доп ресурсы для такого, казалось бы, не самого энергоемкого процесса как веб серфинг.
    Что происходит на других мобильных устройствах? Полагаю что-то похожее.
    • 0
      Так CPU жрет намного больше батарейки для тех же задач.
  • 0
    Сварганил вариант на чистом CSS со скрпитингом(SASS).
    Не считая задержки между циклами, от которых я пока не знаю как избавиться, выглядит вполне прилично ИМХО :)
    https://codepen.io/dozenthoughts/pen/xRaJeW
    • 0

      Можно сделать без лишних классов и @keyframe-секций ;)

  • 0
    >>iPhone 6 относится к дорогим high-end устройствам, на более доступных устройствах памяти гораздо меньше.

    Как раз наоборот. На более дешёвых устройствах, памяти от 2Гб до 4 Гб. Нестоит равняться на iPhone, особенно учитывая его долю на рынке.

Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

Самое читаемое Разработка