Прочитай перед тем, как делать анимацию по скроллу

в 7:15, , рубрики: gsap, Nuxt.js, vue.js, анимация, анимация скролла, сайты

Я интегрировал видео анимацию, которая перематывалась в зависимости от положения скролла, для лендинга детского парка развлечений - wizardia.land.

Я думаю, я попробовал все неправильные способы, как можно это реализовать, и дальше расскажу про свой опыт.

Стек проекта: nuxt 3 (ts) / tailwindcss

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

Содержание - вкратце по тупым ошибкам, которые я совершил

  • Делал перемотку напрямую видоса mp4

  • Проблема с энергосбережением на IOS

  • Проблема фактической невозможности загрузить видео на некоторых устройствах

  • Проблема "мелькания" между слайдами при скроллинге

  • Проблема долгого кеширования кадров

  • Решил использовать GSAP - ScrollTrigger: проблема с "бликающими" кадра стала меньше

  • Решил поглубже изучить GSAP и наткнулся на Image Sequence on Scroll

  • Выводы

Референс, на который я должен был опираться - hang.com

Проблемы перемотки видео в веб разработке

Изначально видео - это довольно громоздкий объект, затрачивающий ресурсы устройства, поэтому стоит использовать его осторожно. В современных плеерах используется HLS streaming (e.x. .m3u8), который работает намного шустрее древнего mp4, позволяет быстро перематывать видео на любой момент, да и в целом, выглядит более стабильно и оптимизировано. Опустим, почему данная технология не была использована в данном проекте, но, как факт, я использовал .mp4 исходник.

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

Решение проблемы с энергосбережением на IOS

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

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

Вот целиком компонент vue, в котором целиком содержится анимация вместо с загрузочным экраном.

<template>
  <transition name="loading">
    <div
        v-if="!loading"
        class="fixed left-0 top-0 w-screen h-screen flex flex-col items-center justify-center z-[100] bg-figma-background">

      <img src="@/assets/images/backgroundVideo/logo.svg?inline" class="w-[148px]" ref="logo"/>

      <div class="text-figma-target uppercase mt-6 text-[17px] leading-[24px] tracking-[0.04em]">
        Загрузка...
      </div>
    </div>
  </transition>

  <div class="h-[400vh] lg:h-[600vh] relative z-40 bg-figma-background">
    <div class="top-0 sticky">
      <img
          class="w-full lg:w-0 block lg:hidden mx-auto mt-[4px] absolute z-10 translate-y-[54px]"
          src="@/assets/images/topBackgroundLogo.svg?inline"
      />

      <img
          v-if="!videoVisible"
          src="@/assets/images/backgroundVideo/mobileExplosion.jpg"
          alt="First Frame"
          @click="showVideo"
      />

      <video ref="video" video loop muted playsinline webkit-playinginline :autoplay="!videoVisible" v-if="isMobile"

             class="mobile-explosion-video" @loadedmetadata="animationVideoLoaded = true" id="explosionVideo">
        <source src="@/assets/images/backgroundVideo/mobileExplosion.mp4" type="video/mp4">
        Your browser does not support the video tag.
      </video>

      <video ref="video" video loop muted playsinline webkit-playinginline v-if="isDesktop"
             class="w-screen h-screen block mx-auto object-center object-cover" id="explosionVideo"
             @loadedmetadata="animationVideoLoaded = true">
        <source src="@/assets/images/backgroundVideo/desktopExplosion.mp4" type="video/mp4">
        Your browser does not support the video tag.
      </video>
    </div>
  </div>
</template>

<script setup lang="ts">

import {computed, onMounted, onUnmounted, ref} from "vue";

const video = ref(null);
const loadContent = ref(false);
const animationVideoLoaded = ref(false);
const loading = ref(false);

let videoReady = false;
let animationFrameId = null;

watch(animationVideoLoaded, value => {
  if (!value) {
    return;
  }

  setTimeout(() => {
    loading.value = true;
  });
})

const isMobile = computed(() => {
  if (!window) {
    return false;
  }
  return window.innerWidth <= 640
});

const isDesktop = computed(() => {
  if (!window) {
    return false;
  }
  return window.innerWidth >= 1024
});

const showMainLogo = ref(true);
let mainLogoHandler;

onMounted(() => {
  mainLogoHandler = setInterval(() => {
    const scrollTop = document.scrollingElement.scrollTop;

    showMainLogo.value = (scrollTop + 500) <= (isDesktop.value ? 7 : 3) * window.innerHeight;
  }, 100);
})

onUnmounted(() => {
  clearInterval(mainLogoHandler);
})

const throttle = (func, limit) => {
  let lastFunc;
  let lastRan;
  return function () {
    const context = this;
    const args = arguments;
    if (!lastRan) {
      func.apply(context, args);
      lastRan = Date.now();
    } else {
      clearTimeout(lastFunc);
      lastFunc = setTimeout(function () {
        if ((Date.now() - lastRan) >= limit) {
          func.apply(context, args);
          lastRan = Date.now();
        }
      }, limit - (Date.now() - lastRan));
    }
  };
};

const updateVideoTime = () => {
  if (!videoReady || !video.value) return;

  const scrollTop = document.scrollingElement.scrollTop;
  if (scrollTop > (isDesktop.value ? 7 : 3) * window.innerHeight) {
    return;
  }

  const scrollHeight = window.innerHeight * (isDesktop.value ? 8 : 4)
  const maxScroll = scrollHeight - window.innerHeight;
  const scrollFraction = scrollTop / maxScroll;

  let duration = video.value.duration

  video.value.currentTime = duration * scrollFraction;
  if (video.value.currentTime + .1 >= duration) {
    loadContent.value = true;
  } else {
    loadContent.value = false;
  }
};

const throttledScroll = throttle(() => {
  if (animationFrameId) {
    cancelAnimationFrame(animationFrameId);
  }
  animationFrameId = requestAnimationFrame(updateVideoTime);
}, 100);

const videoVisible = ref(false);

function showVideo() {
  videoVisible.value = true;
}


const onVideoLoadedMetadata = () => {
  videoReady = true;

  video.value.play();

  video.value.currentTime = 0;
  video.value.pause();
  updateVideoTime();
};

onMounted(() => {
  if (isDesktop.value) {
    videoVisible.value = true;
  }

  document.addEventListener('touchstart', () => {
    videoVisible.value = true;
  })

  if (video.value) {
    video.value.addEventListener('loadedmetadata', onVideoLoadedMetadata);
  }

  window.addEventListener('scroll', throttledScroll);
});

onUnmounted(() => {
  if (video.value) {
    video.value.removeEventListener('loadedmetadata', onVideoLoadedMetadata);
  }
  window.removeEventListener('scroll', throttledScroll);
  if (animationFrameId) {
    cancelAnimationFrame(animationFrameId);
  }
});


</script>

Разбитие видео по кадрам

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

Вот этот скрипт:


const frameContainer = document.getElementById("frameContainer");
const totalFrames = 163; // Количество изображений
const isMobile = window.innerWidth <= 576;
const imagePath = (index) => isMobile ? `mobile/frame${index}.jpg` : `frames/frame${index}.jpg`;

const preloadedImages = [];
const preloadCount = 5; // Количество изображений для предзагрузки
let lastScrollY = 0;
let currentFrame = 0;

// Предзагрузка изображений
function preloadImages() {
    for (let i = 1; i <= totalFrames; i++) {
        const img = new Image();
        img.src = imagePath(i);
        preloadedImages.push(img);
    }
}

preloadImages(); // Предзагрузка изображений

// Задержка обновления (мс)
const updateDelay = 50;
let timeoutId;

function updateFrame() {
    const scrollPosition = window.scrollY;
    if (lastScrollY !== scrollPosition) {
        lastScrollY = scrollPosition;

        const maxScroll = document.documentElement.scrollHeight - window.innerHeight;
        const scrollFraction = scrollPosition / maxScroll;

        // Рассчитываем текущий кадр
        const frameIndex = Math.min(totalFrames - 1, Math.floor(scrollFraction * totalFrames));

        // Если кадр изменился, устанавливаем новое изображение
        if (frameIndex !== currentFrame) {
            currentFrame = frameIndex;

            // Используем изображение по умолчанию, пока загружается новое
            frameContainer.style.backgroundImage = `url(${imagePath(currentFrame + 1)})`;

            // Предзагрузка следующих кадров
            for (let i = 1; i <= preloadCount; i++) {
                const nextFrameIndex = currentFrame + i + 1;
                if (nextFrameIndex <= totalFrames) {
                    const img = new Image();
                    img.src = imagePath(nextFrameIndex);
                }
            }
        }
    }
    timeoutId = setTimeout(updateFrame, updateDelay); // Задержка
}

updateFrame(); // Запуск анимации
	

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

Две проблемы, которые содержит в себе этот скрипт: предзагрузка 160 кадров - это довольно долгий процесс, а так же, хоть анимация и стала намного плавнее, при скроллинге иногда (даже очень часто) появлялись пропуски - выглядит, как будто кадры не успевают подгружаться, но кешированием кадров это не решилось (не хочу это подробно описывать, как факт - кеширование не решило проблему)

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

Решение использовать GSAP

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

Интеграция GSAP в Nuxt 3 структуру

Раз я настолько детально все описываю, то тут же расскажу, как быстро начать использовать gsap, если пользуешься nuxt.js (v3)

Скачать пакет

npm:

npm install gsap

yarn:

yarn add gsap

В папке plugins нужно создать файл gsap.client.ts:

// plugins/gsap.client.ts
import gsap from 'gsap';
import ScrollTrigger from 'gsap/ScrollTrigger';

export default defineNuxtPlugin((nuxtApp) => {
    if (process.client) {
        gsap.registerPlugin(ScrollTrigger);
        nuxtApp.provide('gsap', gsap);
    }
});

Тут регистрируется ScrollTrigger плагин, который нужен для контроля скролл-анимации

nuxt.config.js: (подключить плагин в конфиге проекта)

plugins: ['~/plugins/gsap.client.ts'],

И потом я мог использовать модуль gsap`а таким образом:

onMounted(async () => {
  const nuxtApp = useNuxtApp();
  const {$gsap} = nuxtApp;
});

Имплементация анимации через gsap.ScrollTrigger

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

Анимация вновь стала плавнее и стабильнее, но проблема с "мелькающими" кадрами осталась - она стала реже проявляться, но все же осталась

Компонент vue для скролл-анимации через gsap.ScrollTrigger:

<template>
  <transition name="loading">
    <div
        v-if="!framesLoaded"
        class="fixed left-0 top-0 w-screen h-screen flex flex-col items-center justify-center z-[200] bg-figma-background">

      <img src="@/assets/images/backgroundVideo/logo.svg?inline" class="w-[148px]" ref="logo"/>

      <div class="text-figma-target uppercase mt-6 text-[17px] leading-[24px] tracking-[0.04em]">
        Загрузка... {{ imagesLoaded }} / {{ frameCount }}
      </div>
    </div>
  </transition>

  <div class="scroll-container">
    <img
        v-if="isMobile"
        src="@/assets/images/topBackgroundLogo.png"
        style="width: 375px; height : 140px; position : relative; z-index:30; margin : 55px auto; "
    />

    <img :src="currentImageSrc" alt="Animation Frame">
  </div>
</template>

<script lang="ts" setup>
import {onMounted, ref, computed,} from 'vue';

const isMobile = computed(() => {
  if (!window) {
    return false;
  }
  return window.innerWidth <= 640
});


const framesLoaded = ref(false);
const imagesLoaded = ref(0);

const frameCount = 80 || 106 || 154;
const imgSeq = ref(0);
const displaySeq = ref(0);
let imgSrcPrefix = '/backgroundVideo/mobileFramesTest/';
const imgSrcSuffix = '.jpg';
let images: HTMLImageElement[] = [];

// Предварительная загрузка изображений
const preloadImages = async () => {
  if (!isMobile.value) {
    imgSrcPrefix = '/backgroundVideo/desktopFrames/frame';
  }

  for (let i = 1; i <= frameCount; i++) {
    const img = new Image();
    img.src = `${imgSrcPrefix}${i}${imgSrcSuffix}`;

    images.push(img);
    await img.decode(); // Декодируем изображение здесь, пока оно не отобразится
    imagesLoaded.value++;
  }
};

const currentImageSrc = computed(() => {
  return images[displaySeq.value]?.src || `${imgSrcPrefix}1.jpg`;
});

onBeforeMount(async () => {
  await preloadImages(); // Предварительная загрузка всех изображений
  framesLoaded.value = true;
})

onMounted(async () => {
  const nuxtApp = useNuxtApp();
  const {$gsap} = nuxtApp;

  $gsap.to(imgSeq, {
    value: frameCount - 1,
    ease: "none",
    scrollTrigger: {
      trigger: ".scroll-container",
      start: "top top",
      end: "bottom top",
      scrub: 1,
      pinSpacing: false,
      pin: true,
    }
  });

  // Функция для обновления кадра
  const updateFrame = () => {
    displaySeq.value = Math.round(imgSeq.value);
    requestAnimationFrame(updateFrame);
  };

  requestAnimationFrame(updateFrame);
});
</script>

<style scoped>
.scroll-container {
  height: 300vh;
  overflow: hidden;
  @apply relative z-40 w-full
}

.scroll-container img {
  display: block;
  width: 100vw;
  height: 100vh;
  object-fit: cover;
  position: fixed;
  top: 0;
  left: 0;
}
</style>

Финальное решение

Поняв, что нужно опять найти что-то посвежее, что я еще не пробовал, я решил просмотреть всю документацию GSAP и наткнулся на Image Sequence on Scroll - я полагаю, уже из названия кристаллически понятно, насколько это подходящий для моего кейса инструмент. Я не могу сказать, каким образом я не наткнулся на него в самом начале, но вместо демагогии просто приложу финальный рабочий компонент vue:

<template>
  <transition name="loading">
    <loading-screen v-if="loading"/>
  </transition>


  <div @click="animationStarted = true">
    <div class="_scroll-container">
      <img
          v-if="isMobile"
          src="@/assets/images/topBackgroundLogo.png"
          class="my-[65px] w-full absolute z-50"
      />

      <img
          class="w-screen h-screen object-cover object-center"
          src="/backgroundVideo/mobileFrames/frame1.jpg"
          v-if="!animationStarted && isMobile"
      />

      <img
          class="w-screen h-screen object-cover object-center"
          src="/backgroundVideo/desktopFrames/frame1.jpg"
          v-if="!animationStarted && isDesktop"
      />

      <canvas
          id="image-sequence"
          :width="windowWidth"
          :height="windowHeight"

      />
    </div>
  </div>
</template>

<script setup lang="ts">

// looking for a non-scrubbing version? https://codepen.io/GreenSock/pen/QWYdgjG

import {computed} from "vue";
import LoadingScreen from "~/components/LoadingScreen.vue";

const loading = ref(true);
const animationStarted = ref(false);

const isMobile = computed(() => {
  if (!window) {
    return false;
  }
  return window.innerWidth <= 640
});

const isDesktop = computed(() => {
  if (!window) {
    return false;
  }
  return window.innerWidth >= 1024
});

const windowWidth = computed(() => {
  return isDesktop.value ? 1920 : 800;
})

const windowHeight = computed(() => {
  return isDesktop.value ? 1024 : 1440
})

onMounted(() => {
  const nuxtApp = useNuxtApp();
  const {$gsap} = nuxtApp;

  let frameCount = isDesktop.value ? 157 : 159,
      urls = new Array(frameCount).fill().map((o, i) => `/backgroundVideo/${isDesktop.value ? 'desktopFrames' : 'mobileFrames'}/frame${i + 1}.jpg`);

  imageSequence({
    urls, // Array of image URLs
    canvas: "#image-sequence", // <canvas> object to draw images to
    //clear: true, // only necessary if your images contain transparency
    //onUpdate: (index, image) => console.log("drew image index", index, ", image:", image),
    scrollTrigger: {
      trigger: "._scroll-container",
      start: "top top",
      end: "bottom top",
      scrub: 1,
      pinSpacing: false,
      pin: true,
    }
  });


  /*
  Helper function that handles scrubbing through a sequence of images, drawing the appropriate one to the provided canvas.
  Config object properties:
  - urls [Array]: an Array of image URLs
  - canvas [Canvas]: the <canvas> object to draw to
  - scrollTrigger [Object]: an optional ScrollTrigger configuration object like {trigger: "#trigger", start: "top top", end: "+=1000", scrub: true, pin: true}
  - clear [Boolean]: if true, it'll clear out the canvas before drawing each frame (useful if your images contain transparency)
  - paused [Boolean]: true if you'd like the returned animation to be paused initially (this isn't necessary if you're passing in a ScrollTrigger that's scrubbed, but it is helpful if you just want a normal playback animation)
  - fps [Number]: optional frames per second - this determines the duration of the returned animation. This doesn't matter if you're using a scrubbed ScrollTrigger. Defaults to 30fps.
  - onUpdate [Function]: optional callback for when the Tween updates (probably not used very often). It'll pass two parameters: 1) the index of the image (zero-based), and 2) the Image that was drawn to the canvas

  Returns a Tween instance
  */
  function imageSequence(config) {
    let playhead = {frame: 0},
        canvas = $gsap.utils.toArray(config.canvas)[0] || console.warn("canvas not defined"),
        ctx = canvas.getContext("2d"),
        curFrame = -1,
        onUpdate = config.onUpdate,
        images,
        updateImage = function () {
          let frame = Math.round(playhead.frame);
          if (frame !== curFrame) { // only draw if necessary

            config.clear && ctx.clearRect(0, 0, canvas.width, canvas.height);
            ctx.drawImage(images[Math.round(playhead.frame)], 0, 0);
            curFrame = frame;
            onUpdate && onUpdate.call(this, frame, images[frame]);
          }
        };
    images = config.urls.map((url, i) => {
      let img = new Image();
      img.src = url;
      i || (img.onload = updateImage);
      return img;
    });
    return $gsap.to(playhead, {
      frame: images.length - 1,
      ease: "none",
      onStart: () => {
        loading.value = false
      },
      onUpdate: updateImage,
      duration: images.length / (config.fps || 30),
      paused: !!config.paused,
      scrollTrigger: config.scrollTrigger
    });
  }
})

</script>


<style scoped>

canvas {
  position: fixed;
  max-width: 100vw;
  max-height: 100vh;
  top: 0;
  @apply w-screen h-screen object-center object-cover
}

._scroll-container {
  @apply w-screen h-[350vh] relative z-40
}

</style>

Я просто взял код из примера, заново написал свою негромоздкую логику в более приятном формате - для десктопа одни картинки, для мобилки другие, размеры канваса динамически подставляю в размер экрана (не динамически работать не будет), убираю экран загрузки в хуке onStart, а также подставляю проверенные мной раннее параметры для scrollTrigger из предыдущей версии кода

Выводы

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

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

Автор: SharapaGorg

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js