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

от автора

Я интегрировал видео анимацию, которая перематывалась в зависимости от положения скролла, для лендинга детского парка развлечений — 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 из предыдущей версии кода

Выводы

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

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


ссылка на оригинал статьи https://habr.com/ru/articles/856382/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *