Анимации на 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, а оттуда оно попадёт к нам на экран.



A
B

example 1


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



A
B

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:



A
B

Обратите внимание на код. Мы декларативно описали всю анимацию: её начало, конец, длительность и т.д. А это позволяет браузеру ещё до начала анимации определить, какие именно 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 и т.д.
  • Элементы , ,

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

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


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


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



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



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


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


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


example 6


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



Разница в том, что у элемента #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-анимации. Первый подход — самый наивный: сохраняем изображение солнца в виде картинки, размещаем на странице как элемент и с помощью 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, но при этом не потерялось эффективность и гибкость решения. Результаты размещайте в комментариях.


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

© Habrahabr.ru