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

dabd8315db3843eb98d603a03acc8024

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



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

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

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


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:






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

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






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

Выводы

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

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

© Habrahabr.ru