Привет! На связи Кристина, фронтенд-разработчик в KTS.

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

Рассказываю, как создавала CSS-анимации для игры из внутреннего спецпроекта, какие SCSS-фичи использовала для оптимизации кода и как сделала CSS-анимации более производительными.

Оглавление

Сапожник с сапогами: для чего нам анимация

В KTS есть отдел спецпроектов, который занимается разработкой мини-игр под рекламные задачи заказчиков. Можете посмотреть проекты на сайте или почитать статьи:

Мы создали уже более 300 рекламных спецпроектов для клиентов, и ни одного для себя. Под Новый год мы решили сделать себе подарок в виде атмосферного спецпроекта. Игра была создана полностью нами и в сжатые сроки — за неделю до Нового года мы подобрали звуки, обдумали механику, нарисовали дизайн и разработали спецпроект под Telegram и ВКонтакте.

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

Какие анимации у меня были

Всего я разработала семь новогодних анимаций:

  1. Танцующий снегирь

  2. Раскачивающийся снеговик

  3. Летящая упряжка Санта Клауса

  4. Звенящие елочные игрушки

  5. Смена времени суток

  6. Красочный фейерверк

Танцующий снегирь 

Снегирь начинает танцевать при использовании одного из звуковых эффектов. Для движения снегирей я взяла свойство 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, чтобы симулировать более слабый процессор, чем есть в действительности.

Анимация box-shadow одного элемента
Анимация box-shadow одного элемента
Анимация transform множества элементов
Анимация transform множества элементов

При анимации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 сотрудников Пятёрочки

Как мы сетапили монорепозиторий с SSR и SPA для Otus.ru

Как собрать свою библиотеку для SSR на React

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


  1. kovserg
    06.06.2024 07:47

    На фоне древнего flash всё это выглядит как возврат в каменный век. После того как закопали flash похоже ничего нормального так и не появилось.


    1. uwriter
      06.06.2024 07:47
      +4

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


  1. gilmanov
    06.06.2024 07:47
    +8

    Как то на известном сайте зимой попросили выкатить "снежинки" на сайт. Неплохо погрели офис компами в тот день)


  1. lock87
    06.06.2024 07:47
    +4

    Мишень на снегирях разбивает мне сердце...