Давайте заставим еще одного персонажа Marvel исчезнуть!

Недавно я запоем посмотрел фильмы о Мстителях на Disney+ (снова!), и когда я увидел, как Танос щелкнул пальцами и превратил почти всех в пыль, я вспомнил старую анимацию, которую я сделал много лет назад с помощью Adobe Flash. Это был эффект растворения образа, который имеет сверхъестественное сходство с фильмом.

Эта статья научит нас, как создавать частицы из пикселей изображения и анимировать их по отдельности.

Итак, вот какой эффект мы собираемся сделать:

Круто, начнем!

Небольшое примечание. Несмотря на то, что в заголовке написано React, код анимации написан на чистом JavaScript, поэтому вы можете легко изменять части React с помощью document.querySelector или его эквивалента.

Подготовка нашего холста

Нашей жертвой для этого проекта, как вы видели на видео, станет Ванда Максимофф, она же Алая Ведьма. Конечно, вы можете выбрать любого персонажа MCU. Просто помните, что изображение должно иметь прозрачный фон и не быть слишком большим из соображений производительности. Что-то вроде 600x400 должно быть нормально.

Давайте начнем с добавления холста и загрузки в него нашего изображения. Вот код:

Код здесь довольно прост. Сначала мы создаем ref для нашего холста, затем в нашем useEffect проверяем, загружен ли наш холст. Если это так, мы получаем доступ к контексту и устанавливаем ширину и высоту нашего холста в соответствии с внутренними размерами окна. Это заставляет наш холст занимать весь экран (размер холста должен быть определен в единицах измерения, одних единиц CSS недостаточно).

Все стили имеют 100% ширину и высоту, поэтому мы будем работать в полном окне просмотра.

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

Пока ничего интересного. Мы просто показываем нашу Ванду посередине экрана. Теперь мы создадим частицы и генератор (или, скажем, контроллер, если хотите) для этих частиц.

Классы частиц

Для создания частиц мы напишем классы, с которыми вы, возможно, знакомы из языков ООП. Это сделает наш код чистым и позволит нам легче вносить изменения.

Во-первых, мы начинаем с класса Particle, подобного этому. Вы можете разместить этот код вне функции React где угодно.

class Particle {
  constructor(x, y, color) {
    this.color = color;
    this.x = x;
    this.y = y;
    this.size = 5;
   }

  draw(context) {
    context.fillStyle = this.color;
    context.fillRect(this.x, this.y, this.size, this.size);
  }
  update() {
    
  }
}

Наша частица будет крошечным прямоугольником в 5px, который принимает параметры x и y, чтобы определить, где разместить себя на холсте и какие color аргументы должны быть заполнены каким цветом. Метод draw заставляет наш прямоугольник иметь заданный размер и цвет. Также мы добавляем метод обновления, который скоро реализуем.

Продолжая наш Generator, добавьте этот код сразу после класса Particle:

class Generator {
  constructor(width, height) {
    this.width = width;
    this.height = height;
    this.particlesArray = [];
  }

  init(context) {
    this.particlesArray.push(new Particle(10, 10, 'red'));
  }

  draw(context) {
    this.particlesArray.forEach((particle) => particle.draw(context));
  }

  update() {
    this.particlesArray.forEach((particle) => particle.update());
  }
}

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

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

Получение информации о пикселях из изображения

Как упоминалось ранее, мы будем генерировать частицы в соответствии с пикселями нашего изображения и реконструировать изображение по этим частицам. Для этого элемент Canvas имеет метод getImageData, который возвращает данные о цвете всех пикселей указанной области холста (дополнительная информация по адресу: https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/ получитьданные изображения).

Теперь замените строку, где мы генерируем частицу, строкой ниже в методе init Generator:

init(context) {
    const pixels = context.getImageData(0, 0, this.width, this.height).data;
}

Если мы console.log(pixels) на этом этапе проверим инструменты разработчика, мы увидим множество вложенных массивов, подобных этому:

Это связано с тем, что getImageData возвращает необработанные данные в Uint8ClampedArray (здесь зажато означает, что его значение находится в диапазоне от 0 до 255), а не отформатированные. Теперь, если вы проверите значения этого массива, вы заметите, что большинство из них — 0. Но если вы выберете серию массивов в середине списка, вы можете увидеть некоторые значения, подобные этим:

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

Итак, как нам обрабатывать эти массивы? Поскольку они вложенные, нам нужна вложенная структура для их разбора. Измените init, как показано ниже:

init(context) {
    const pixels = context.getImageData(0, 0, this.width, this.height).data;
     for (let y = 0; y < this.height; y++) {
      for (let x = 0; x < this.width; x++) {
        const index = (y * this.width + x) * 4;
        const red = pixels[index];
        const green = pixels[index + 1];
        const blue = pixels[index + 2];
        const alpha = pixels[index + 3];
        const color = `rgb(${red}, ${green}, ${blue})`;

        if (alpha > 0) {
          this.particlesArray.push(new Particle(x, y, color));
        }
      }
    }
}

Адский код! Ну, все, что я могу сказать, это то, что вам не обязательно это понимать, но мы делаем то, что в алгоритмах изображения называется «сканлайн». Мы перебираем каждый пиксель нашего холста и получаем *якорь* для каждого четвертого значения. Затем мы выбираем значения между ними и последовательно присваиваем их переменным RGBA. Наконец, мы генерируем частицу для каждого пикселя с альфа-значением, то есть не пустым.

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

Теперь вернемся к нашему useEffect и добавим следующие строки:

useEffect(() => {
    const canvas = canvasRef.current;

    if (canvas) {
      const ctx = canvas.getContext("2d");
      ctx.imageSmoothingEnabled = false;

      canvas.width = window.innerWidth;
      canvas.height = window.innerHeight;

      const generator = new Generator(canvas.width, canvas.height);

      const image = new Image();

      image.onload = function () {
        ctx.drawImage(
          image,
          canvas.width / 2 - image.width / 2,
          canvas.height / 2 - image.height / 2
        );

        generator.init(ctx);
       
        startAnimation(
          generator,
          ctx,
          canvas.width,
          canvas.height,
        );
      };
      image.src = "/images/wanda.png";
    }
  }, []);

Здесь мы инициализируем наш Generator, передаем размер холста и контекст в init. Кроме того, мы определяем нашу функцию анимации отдельно. Возможно, мы бы оставили его внутри useEffect, но нам нужно убедиться, что он запустится один раз. Итак, давайте обернем эту часть useCallback и поместим над useEffect.

const startAnimation = useCallback((generator, ctx, w, h, count) => {
     function animate() {
      ctx.clearRect(0, 0, w, h);
      generator.draw(ctx);
      generator.update();
      requestAnimationFrame(animate);
    }
    setTimeout(() => animate(), 2000);
  }, []);

Здесь происходит анимация. Во-первых, мы начинаем с setTimeout, чтобы дать зрителям немного времени, чтобы увидеть исходное изображение, чтобы они могли понять, как начинается анимация. Позже наша функция animate() запускается и сначала очищает наш холст (наше исходное изображение исчезает таким образом), затем вызывает метод рисования и обновления Generator, который, по сути, вызывает метод рисования и обновления Particle для рисования всего холста с обновленными частицами. А с window.requestAnimationFrame или просто requestAnimationFrame промываем и повторяем процесс.

Итак, перед запуском нашего кода нам нужно обновить наш класс Particle, иначе ничего не произойдет:

lass Particle {
  constructor(x, y, color) {
    this.color = color;
    this.x = x;
    this.y = y;
    this.size = 5;
    this.vx = -Math.random() * 2;
    this.vy = -Math.random() * 2;
    this.vFactor = 1.01;
  }

  draw(context) {
    context.fillStyle = this.color;
    context.fillRect(this.x, this.y, this.size, this.size);
  }
  update() {
    this.x += this.vx;
    this.y += this.vy;
    this.vx *= this.vFactor;
    this.vy *= this.vFactor;
  }
}

Здесь мы добавляем значения скорости как vx и vy и позволяем им иметь случайное значение между 0 и 2. Обратите внимание, что это отрицательные значения, которые позволяют частице перемещаться вверх и влево по экрану. Это дало бы эффект, что какой-то ветерок дует справа. Я также добавил множитель скорости vFactor, который заставляет частицы ускоряться с течением времени.

Хорошо, давайте посмотрим, что у нас есть на данный момент:

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

Это происходит потому, что мы добавляем больше частиц, чем нам нужно. Размер частиц 5, но мы генерируем частицы для каждого пикселя. Чтобы исправить это, введите переменную gap, которая позволит нам генерировать меньше частиц, например:

class Generator {
  constructor(width, height) {
    this.width = width;
    this.height = height;
    this.particlesArray = [];
    this.gap = 5;
  }

  init(context) {
    const pixels = context.getImageData(0, 0, this.width, this.height).data;
    for (let y = 0; y < this.height; y += this.gap) {
      for (let x = 0; x < this.width; x += this.gap) {
        const index = (y * this.width + x) * 4;
        const red = pixels[index];
        const green = pixels[index + 1];
        const blue = pixels[index + 2];
        const alpha = pixels[index + 3];
        const color = `rgb(${red}, ${green}, ${blue})`;

        if (alpha > 0) {
          this.particlesArray.push(new Particle(x, y, color));
        }
      }
    }

   ...
}

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

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

Улучшения

Итак, как можно начать анимацию сверху и закончить внизу? Мы должны указать пикселям сверху немедленно начать анимацию, а остальные ждут своей строки. Способ задержки в JavaScript состоит в том, чтобы обернуть их функцией setTimeout, что было бы очень рискованно использовать здесь. Это потому, что у нас есть просто requestAnimationFrame, которое работает в миллисекундах. Так почему бы нам не использовать эту функцию? Давайте изменим некоторые части:

Сначала давайте получим длину частиц в Generator:

init(context) {
    const pixels = context.getImageData(0, 0, this.width, this.height).data;
    for (let y = 0; y < this.height; y += this.gap) {
      for (let x = 0; x < this.width; x += this.gap) {
       ....
      }
    }

    return this.particlesArray.length;
}

Затем получите эту сумму и передайте ее startAnimation на useEffect.

...  
const particlesCount = generator.init(ctx);
   startAnimation(
      generator,
      ctx,
      canvas.width,
      canvas.height,
      particlesCount
   );
...

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

const startAnimation = useCallback((generator, ctx, w, h, count) => {
    let d = 0;
    function animate() {
      ctx.clearRect(0, 0, w, h);
      generator.draw(ctx);
      generator.update(d);
      if (d <= count) d++;
      requestAnimationFrame(animate);
    }
    setTimeout(() => animate(), 2000);
  }, []);

Наконец, отредактируйте функцию обновления Generator на основе значения счетчика следующим образом:

update(counter) {
    this.particlesArray.forEach((particle, index) => {
      if (index < counter) particle.update();
    });
  }

В этом коде мы говорим: «Если позиция частицы ниже значения счетчика, ничего не делайте, потому что это еще не очередь этой частицы. В противном случае начните обновление, и пока значение счетчика превышает позицию, продолжайте обновлять».

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

Теперь давайте исправим это и добавим немного случайности в наш код. Сначала мы начнем с изменения порядка пикселей. Мое решение состоит в том, чтобы продублировать particlesArray и разбить их на несколько небольших массивов, содержащих около 50 элементов, перемешать их по отдельности и объединить в один массив. Для такого рода операций в библиотеке lodash есть очень полезные функции, так что давайте установим их:

npm install lodash --save

Затем добавьте эти строки внизу функции init Generator.

import {chunk, shuffle} from 'lodash';

...

init(context) {
    ...

    let chunks = chunk(this.particlesArray, 50);
    this.particlesArray = [];
    chunks.forEach((chunk) => {
      this.particlesArray.push(...shuffle(chunk));
    });

    return this.particlesArray.length;

 
  }
 

Мы не хотим, чтобы весь массив перемешивался, потому что мы не хотим, чтобы пиксели внизу тоже анимировались. Таким образом, мы сохраняем случайность и порядок одновременно.

И последнее, но не менее важное: вы можете захотеть ускорить частицы ближе к концу. Таким образом, наша анимация может казаться более реалистичной. Измените значение счетчика в startAnimation, чтобы оно увеличивалось более чем на единицу с течением времени.

const startAnimation = useCallback((generator, ctx, w, h, count) => {
    let d = 0;
    function animate() {
     ...
      if (d <= count) d += 1 + d /100;
      requestAnimationFrame(animate);
    }
    ...
  }, []);

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

И это он! Окончательный код будет выглядеть так. Я также добавил кнопку для перезагрузки страницы и повторного запуска эффекта.

Заключение

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

И не жалей Ванду. MCU всегда воскрешает персонажей!

До скорого!