Привет! На связи Кристина, фронтенд-разработчик в KTS.
Большое количество графики и анимаций потребляет достаточно много ресурсов. Однако благодаря оптимизации можно сделать так, чтобы всё работало без глюков и тормозов.
Рассказываю, как создавала CSS-анимации для игры из внутреннего спецпроекта, какие SCSS-фичи использовала для оптимизации кода и как сделала CSS-анимации более производительными.
Оглавление
Сапожник с сапогами: для чего нам анимация
В KTS есть отдел спецпроектов, который занимается разработкой мини-игр под рекламные задачи заказчиков. Можете посмотреть проекты на сайте или почитать статьи:
Некринжовая игра с мемами для подростков: как мы сделали миниапп «ВКонтакте» для промо онлайн-школы
Мини-приложение «СмешАпп» для ВКонтакте к 20-летию Смешариков
Мы создали уже более 300 рекламных спецпроектов для клиентов, и ни одного для себя. Под Новый год мы решили сделать себе подарок в виде атмосферного спецпроекта. Игра была создана полностью нами и в сжатые сроки — за неделю до Нового года мы подобрали звуки, обдумали механику, нарисовали дизайн и разработали спецпроект под Telegram и ВКонтакте.
Для этого мы разработали новую механику — новогодний музыкальный сэмплер. Её суть — юзер сочиняет музыку из трех составляющих: мелодии, перкуссии и звуковых эффектов. Нажатие на кнопки сэмплера сопровождается анимациями, о которых и пойдёт речь в статье.
Какие анимации у меня были
Всего я разработала семь новогодних анимаций:
Танцующий снегирь
Снегирь начинает танцевать при использовании одного из звуковых эффектов. Для движения снегирей я взяла свойство transform: scaleY().
Птички вращаются туда-сюда с углом поворота примерно 10 градусов и одновременно растягиваются по высоте:
$start-scale: 1.1;
$finish-scale: 0.9;
$angle: 5deg;
@keyframes dance {
from {
transform: scaleY($start-scale) rotate($angle * -1);
}
50% {
transform: scaleY($finish-scale) rotate(0);
}
to {
transform: scaleY($start-scale) rotate($angle);
}
}
Далее установила animation-direction: alternate
, чтобы на каждой итерации анимация сначала проигрывалась в прямом направлении, а потом в обратном для красивого цикла. Это позволяет не прописывать возвратное движение внутри @keyframes
.
Также сместила точку, относительно которой происходит движение, из центра в нижнюю часть снегиря с помощью transform-origin:
.bird {
animation: dance 0.66s infinite linear alternate;
transform-origin: 70% 100%;
}
Снегирь со смещённым центром выглядит так:
Вот как он забавно пританцовывает:
Раскачивающийся снеговик
Снеговик покачивается и машет руками-ветками, если использовать любую перкуссию. Его я анимировала по тому же принципу, что и снегиря. Тело и руки поворачиваются на небольшой угол с помощью transform: rotate()
со смещённым вниз центром трансформации.
Движение тела и движение рук отличаются только углом поворота, поэтому @keyframes
можно вынести в @mixin
, чтобы избежать дублирования кода:
@mixin waveKeyframes($name, $angle) {
@keyframes #{$name} {
/* в начальном и конечном положении вращения нет, это состояние по умолчанию */
from, to { transform: rotate(0); }
/* сначала вращаем элемент по часовой стрелке */
25% { transform: rotate($angle); }
/* потом против часовой стрелки на тот же угол, и возвращаемся в исходное положение */
75% { transform: rotate($angle * -1); }
}
}
@include waveKeyframes(hand-wave, 12deg);
@include waveKeyframes(body-wave, 5deg);
Все мои анимации можно включить и выключить. При нажатии на кнопку на элементы навешиваются дополнительные классы-модификаторы.
Стили выглядят примерно так:
.snowman {
/* ... */
&__body,
&__left-hand,
&__right-hand {
/* тело и обе руки двигаются по одному принципу */
transform-origin: 50% 100%;
animation: linear 2s infinite;
}
&__body {
/* ... */
&_animating {
/* чтобы включить анимацию, навешиваем на тело
модификатор .snowman__body_animating */
animation-name: body-wave;
}
}
&__left-hand { /* ... */ }
&__right-hand { /* ... */ }
&__left-hand,
&__right-hand {
&_animating {
/* к каждой руке тоже добавляем модификаторы */
animation-name: hand-wave;
}
}
}
Интересный момент заключается в том, что при выключении анимации не должно быть резкой смены состояний. Снеговик не останавливается резко при отключении перкуссии, а плавно возвращается в исходное положение после окончания текущего цикла.
Для этого я использовала событие animationiteration
, которое срабатывает каждый раз, когда заканчивается текущая итерация CSS-анимации. При отключении перкуссии класс _animating
удаляется только когда снеговик возвращается в первоначальное положение:
// когда пользователь включает и выключает анимацию, меняется пропс isAnimating
const Snowman = ({ isAnimating = false }) => {
// вводим дополнительное состояние isDancing,
// при изменении которого включаем и выключаем анимацию
// с помощью добавления и удаления классов-модификаторов
const [isDancing, setIsDancing] = React.useState(false);
// выключаем снеговика только после того,
// как завершился текущий цикл анимации
const handleAnimationIteration = () => {
if (!isAnimating) {
setIsDancing(false);
}
};
// включаем анимацию без задержек, снеговик начинает танцевать
// сразу при нажатии на кнопку
React.useEffect(() => {
if (isAnimating) {
setIsDancing(true);
}
}, [isAnimating]);
return (
<div className="snowman">
<div
className={classNames(
'snowman__wrapper',
isDancing && 'snowman__wrapper_animating'
)}
onAnimationIteration={handleAnimationIteration}
>
<img
className={classNames(
'snowman__left-hand',
isDancing && 'snowman__left-hand_animating'
)}
src={leftHandImg}
/>
<img className="snowman__body" src={bodyImg} />
<img
className={classNames(
'snowman__right-hand',
isDancing && 'snowman__right-hand_animating'
)}
src={rightHandImg}
/>
</div>
</div>
);
};
В итоге получается такой снеговик:
Летящая упряжка Санта Клауса
Санта с оленями на упряжке пролетает за горами на фоне неба при нажатии на эффект.
Особенность анимации заключается в том, что сани должны двигаться по дуге, а не по прямой линии. Это можно реализовать с помощью комбинации слоев. Я обернула Санту в дополнительныйdiv
, который вращается вокруг своей оси:
<div class="path">
<img class="santa" src="santa.png" />
</div>
Далее с помощью абсолютного позиционирования я разместила Санту на границе вращающегося блока. Для наглядности я округлила углы у обёртки. Получается движение по кругу:
Теперь остаётся только настроить угол поворота, так как описывать полный круг нет необходимости:
$initial-angle: -60deg; /* начальный угол поворота окружности */
$finish-angle: $initial-angle + 120deg; /* финальный угол поворота */
/* помимо rotate присутствуют и другие трансформации,
и чтобы их не дублировать, можно вынести изменение угла поворота в @mixin */
@mixin setTransform($angle) {
transform: translate(-50%, -50%) rotate($angle);
}
@keyframes moving {
from { @include setTransform($initial-angle); }
to { @include setTransform($finish-angle); }
}
.path {
position: relative;
width: 400px;
height: 400px;
animation: moving 3s infinite linear;
}
.santa {
position: absolute;
top: 0;
left: 50%;
transform: translate(-50%, -50%);
width: 25%;
}
Хо-хо-хо!
Звенящие елочные игрушки
Анимация ёлочных игрушек сопровождает звуковой эффект. Они висят на новогодней ёлке и покачиваются.
Анимация на самом деле весьма проста. Она основана на вращении свойства transform: rotate()
с измененным центром трансформации, подобно снегирям и снеговику. Особенность в том, что анимировать пришлось больше десятка однотипных элементов. Здесь мне пригодились возможности SCSS: списки, вспомогательные функции, миксины и циклы. С их помощью мне удалось сэкономить время написания кода и соблюсти принцип DRY (don’t repeat yourself).
Вешаем игрушки на елку
У нас есть контейнер, размеры которого соответствуют размерам ёлки. Внутри с помощью абсолютного позиционирования размещаем игрушки:
<div className="toys">
Array.from({ length: 17 }).map((_, index) => (
<div key={index} className="toy" />
))}
</div>
Всего на ёлке — 17 игрушек. Прописывать стили для каждой из них мне не хотелось, поэтому я оптимизировала этот процесс.
Для начала я создала список координат всех игрушек относительно контейнера в формате (x y)
:
$positions: (
(60 41),
(80 47),
(91 91),
/* ... */
);
Координаты лежат у дизайнеров в Figma. Здесьx
– отступ от левой границы фрейма до игрушки,y
– отступ от верхней границы фрейма:
Верстка у нас адаптивная: размеры ёлки меняются в зависимости от размера экрана. Для этого рассчитываем значенияtop
иleft
игрушек в относительных единицах измерения:
/* размеры фрейма с елкой из фигмы */
$frame-width: 154;
$frame-height: 193;
/* @param $index Порядковый номер игрушки в списке $positions */
@mixin setPosition($index) {
$x: nth(nth($positions, $index), 1);
$y: nth(nth($positions, $index), 2);
top: $y / $frame-height * 100%;
left: $x / $frame-width * 100%;
}
Далее каждой ёлочной игрушке в цикле я задала позиционирование:
/* общее количество игрушек */
$totalToys: length($positions);
.toy {
position: absolute;
width: 8.4%;
@for $toyIndex from 1 through $totalToys {
&:nth-of-type(#{$toyIndex}) {
@include setPosition($toyIndex);
}
}
}
Теперь все игрушки висят на своих местах.
Раскрашиваем игрушки в разные цвета
Ёлочная игрушка представляет собой SVG-элемент, который содержит 3 слоя:
основной цвет;
тень;
блик.
В коде это выглядит так:
<svg width="14" height="13" viewBox="0 0 14 13" fill="none">
<path className="base" d="M5.43003..."/> <!-- основной цвет -->
<path className="shadow" d="M12.5017..."/> <!-- блик -->
<path className="glare" d="M10.0046..." /> <!-- тень -->
</svg>
С такой простой структурой я написала миксин, который будет раскрашивать игрушку в нужный цвет. Для блика основной цвет осветлен с помощью SCSS-функции lighten()
, а для тени я взяла функциюdarken()
:
/* список всех возможных основных цветов */
$colors: (#ece897, #62dfca, #fe8884);
/* @param $index Порядковый номер цвета из списка $colors */
@mixin colorizeToy($index) {
$color: nth($colors, $index);
path {
&.base {
fill: $color;
}
&.shadow {
fill: darken($color, 9%);
}
&.glare {
fill: lighten($color, 7%);
}
}
}
Следующая задача — раскрашивание игрушек в равных пропорциях. Для этого:
Делим все игрушки на равные группы. Количество групп соответствует количеству цветов;
По порядковому номеру определяем, к какой из групп относится игрушка, и в зависимости от этого окрашиваем ее в нужный цвет;
Важен порядок игрушек в списке
$positions
. В данном случае первая треть из списка будет окрашена в жёлтый, вторая треть — в зелёный, и оставшиеся — в красный.
/* общее количество всех расцветок */
$totalColors: length($colors);
.toy {
/* ... */
/* по умолчанию окрашиваем все игрушки в первый цвет из списка $colors */
@include colorizeToy(1);
@for $toyIndex from 1 through $totalToys {
&:nth-of-type(#{$toyIndex}) {
/* ... */
@for $colorIndex from 1 through $totalColors {
/* окрашиваем игрушку в зависимости от ее порядкового номера */
@if $toyIndex < ($totalToys * (1 - 1 / $totalColors * $colorIndex)) {
@include colorizeToy($colorIndex + 1);
}
}
}
}
}
Теперь всё автоматизировано, чтобы добавить новые или убрать имеющиеся цвета. Достаточно только обновить список$colors
.
Добавляем покачивание
Осталось добавить каждой игрушке анимацию покачивания. Чтобы движения не были синхронными и игрушки двигались хаотично, я добавила каждой игрушке небольшую рандомную задержкуanimation-delay: random(750) * 1ms
:
/* ... */
$angle: 15deg;
$start-angle: calc($angle * -1);
$finish-angle: $angle;
@keyframes wiggle {
from { transform: rotate($start-angle); }
to { transform: rotate($finish-angle); }
}
.toy {
/* ... */
transform: rotate($start-angle);
transform-origin: 50% -50%;
animation: wiggle infinite 750ms linear alternate;
@for $toyIndex from 1 through $totalToys {
&:nth-of-type(#{$toyIndex}) {
/* ... */
animation-delay: random(750) * 1ms;
}
}
}
Готово!
Смена времени суток
Время суток изменяется при выборе мелодии. Самое сложное здесь — это переключение между утром, закатом и ночью. Основная заслуга принадлежит дизайнеру, который кропотливо собрал сцену из множества разноплановых слоёв. Мне осталось только перенести эти слои в верстку и реализовать их смену в зависимости от текущего времени суток.
Наложение слоёв друг на друга выглядит так:
Чтобы наложить слои друг на друга, я использовала абсолютное позиционирование. Для масштабирования сцены в зависимости от ширины экрана размеры и положение слоёв выражены в процентах:
/* размеры сцены в px взяты из макета */
$scene-width: 816;
$scene-height: 593;
/* находим размеры и позицию элемента в % относительно сцены, на основе px из макета */
@mixin setSceneElementPosition($width, $height, $offsetTop, $offsetLeft) {
position: absolute;
top: ($offsetTop + $height / 2) / $scene-height * 100%;
left: ($offsetLeft + $width / 2) / $scene-width * 100%;
transform: translate(-50%, -50%);
width: $width / $scene-width * 100%;
}
Появление и скрытие слоев реализовано через добавление и удаление класса-модификатора и изменение прозрачностиopacity
:
/* продолжительность анимации одинакова для всех слоев,
поэтому выносим ее в переменную */
$ambience-duration: 1s;
.element {
/* ... */
opacity: 0;
transition: opacity $ambience-duration;
&_shown {
opacity: 1;
}
}
Луна и солнце сменяют друг друга засчёт измененияtransform
:
.moon,
.sun {
/* ... */
transform: translate(-50%, 150%);
transition: transform $ambience-duration;
&_shown {
transform: translate(-50%, 0);
}
}
Анимация получилась очень уютной:
Красочный фейерверк
Фейерверк запускает при нажатии кнопки соответствующего эффекта. На просторах Сodepen я подсмотрела интересную реализацию салюта, которая и была взята за основу.
Анимируем взрыв
Взрыв реализуется засчёт изменения свойства box-shadow
. Каждая частичка взрыва — это отдельная тень многослойного box-shadow
, которая отличается цветом и сдвигом.
@keyframes bang {
/* from можно опустить, так как по умолчанию box-shadow и так none */
from {
box-shadow: none;
}
to {
box-shadow:
28px -96px white,
207px -218px pink,
167px -60px green,
/* ... */
-26px -113px white;
}
}
Когдаbox-shadow
анимируется от состоянияnone
к многослойной тени, то получается эффект разлетающихся элементов:
Возможности SCSS позволяют сгенерировать взрыв с произвольным количеством частиц и рандомным разбросом:
/* список всех возможных цветов частиц */
$colors: #f1eb70, #5bd3c4, #9de7ff, #fff, #ff30ea, #31ff00;
/* размер разброса частиц */
$spread: 500;
/* общее количество частиц */
$particles: 50;
/* в эту переменную будем записывать многослойную тень */
$box-shadow: ();
@for $i from 0 through $particles {
/* на каждой итерации цикла записываем в переменную $box-shadow
ее предыдущее значение и добавляем к нему еще одну новую тень
с рандомным сдвигом и цветом */
$box-shadow:
$box-shadow,
(random($spread) - $spread / 2) * 1px
(random($spread) - $spread / 1.5) * 1px
nth($colors, random(length($colors)));
}
Далее я взяла полученную тень и задала ей анимацию:
.firework {
$size: 7px;
width: $size;
height: $size;
border-radius: 50%;
animation: 1s bang ease-out infinite backwards;
}
/* анимация взрыва */
@keyframes bang {
to { box-shadow: $box-shadow; }
}
Оптимизация салюта
Если есть возможность, тоbox-shadow
лучше не анимировать. В подавляющем большинстве случаев, когда требуется сделать динамическую тень, анимациюbox-shadow
можно заменить на изменениеopacity
иtransform
:
При анимации
transform
иopacity
анимируемые элементы выносятся на отдельные композиционные слои, и браузер перерисовывает не всю страницу, а только эти слои;Когда анимируется
box-shadow
, то происходит Repaint — один из самых трудоёмких этапов отрисовки, в процессе которого браузер заполняет пиксели цветами. Также Repaint вызывается при изменении свойствcolor
,background
,border-color
и других;Когда анимируются
top
,margin
,padding
и подобные CSS-свойства, то перед Repaint каждый раз происходит еще и Reflow (relayout
), и браузер пересчитывает размеры и положение элементов на странице. С этим кейсом я еще столкнулась чуть дальше.Более подробно узнать о том, какие этапы перерисовки вызывают изменения свойств можно здесь.
Я провела эксперимент:
Вместо изменения
box-shadow
элемента.firework
я сгенерировала под каждую частичку салюта свойdiv
;Анимацию сдвига тени заменила на изменение
transform: translate()
.
Несмотря на то что теперь не происходит ресурсоемкого этапа repaint
, производительность не улучшилась. Дело в том, что с помощьюbox-shadow
я анимировала всего один html-элемент, а в новом варианте рендерится целых 50 элементов.
Ниже — скриншоты вкладки Performance в Chrome DevTools. В обоих примерах для наглядности выставлены настройки CPU: 6x slowdown, чтобы симулировать более слабый процессор, чем есть в действительности.
При анимацииtransform
время Painting сократилось со 160ms до 75ms, чего я и добивалась. Однако при этом многократно возросло время Rendering.
К чему это всё? Есть базовое правило, которому следуют все CSS-аниматоры: в приоритете анимировать свойстваtransform
иopacity
, но важно учитывать и контекст. В нашем случае в финальном варианте анимируются целых 100 огоньков, и реализация с помощью box-shadow
более производительна, чемtransform
.
Включаем гравитацию
Частички равномерно разлетаются в разные стороны. Я сделала так, чтобы они падали вниз под тяжестью собственного веса. Заодно добавила плавное появление и исчезновение:
/* эффект гравитации: частицы падают вниз */
@keyframes gravity {
80% {
opacity: 1;
}
to {
transform: translate(0, $spread * 1px * 0.4);
opacity: 0;
}
}
Добавим к анимации взрыва анимациюgravity
. Повторяющиеся свойства можно вынести в переменную, чтобы избежать дублирования:
.firework {
/* ... */
$local-animation: 1s infinite backwards;
animation: $local-animation, $local-animation;
animation-name: bang, gravity;
animation-timing-function: ease-out, ease-in;
}
Гравитация сработала так:
Запускаем несколько фейерверков
На последнем шаге я сделала запуск салюта в разных частях экрана, а не только по центру. Для этого я меняла местоположение перед каждым новым взрывом. В оригинальной анимации позиция изменяется за счет margin
. Ранее упоминалось, что анимировать margin
— так себе идея, так как это плохо сказывается на производительности браузера. Поэтому я изменилаtransform: translate()
:
@mixin setPosition($x, $y) {
transform: translate(100vw * $x, 100vh * $y);
}
/* появление взрывов в разных частях экрана */
@keyframes position {
0%, 19.9% {
@include setPosition(0.4, 0.1);
}
20%, 39.9% {
@include setPosition(0.3, 0.4);
}
40%, 59.9% {
@include setPosition(0.7, 0.2);
}
60%, 79.9% {
@include setPosition(0.2, 0.3);
}
80%, 99.9% {
@include setPosition(0.8, 0.2);
}
}
Я отрефакторила код и перенесла двойную анимацию со взрывом и гравитацией с элемента .firework
на псевдоэлемент::before
. А на.firework
повесила анимацию смены позиции:
Смотреть код
.firework {
$size: 7px;
width: $size;
height: $size;
border-radius: 50%;
animation: 5s position linear infinite backwards;
&::before {
content: "";
display: block;
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
border-radius: inherit;
$local-animation: 1s infinite backwards;
animation: $local-animation, $local-animation;
animation-name: (bang, gravity);
animation-timing-function: (ease-out, ease-in);
}
}
Запускаем салют:
Для масштабности фейерверка в игре анимируется два html-элемента:
Что в итоге получилось
Красота требует жертв. Такое большое количество графики и анимаций, большая часть которых включена одновременно, потребляет достаточно много ресурсов. Однако благодаря оптимизации наше приложение работает без глюков и тормозов на современных устройствах.
Что я сделала для оптимизации:
Добавила предзагрузку всех статических файлов. Все изображения и звуки загружаются в момент открытия приложения, и, пока они грузятся, пользователь видит крутящийся значок загрузки;
По возможности анимировала только
transform
иopacity
, чтобы лишний раз не запускать процессы Repaint и Reflow;Не стоит забывать про аппаратное ускорение анимаций. В нашем случае все элементы передаются на обработку GPU. Так происходит, потому что в нашем проекте слои накладываются друг на друга. Стоит помнить, что если элемент по оси Z находится выше элемента, который передаётся на обработку в GPU, то к нему тоже автоматически применяется аппаратное ускорение;
Оптимизировала и сжала всю графику. Для .jpg и .png использовала tinypng.com, а для svg — svgomg.net;
Для каждого изображения подбирала подходящий формат. Растровые картинки сохранены в .jpg, простые векторные изображения — в .svg, а сложные — в .png.
В итоге такая симпатичная анимация у меня получилась всего за неделю разработки в самое горячее время новогодних дедлайнов:
Другие статьи про frontend для начинающих:
Роадмэп по современному фронтенду от KTS
Чек-лист фронтендера при разработке рекламного спецпроекта
Как сделать свой текстовый редактор на React.js
Другие статьи про frontend для продвинутых:
Как мы выбирали архитектуру микрофронтендов в ЛК для 260 000 сотрудников Пятёрочки