Привет, меня зовут Ваня! Я фронтенд-разработчик в Лиге А. и часто работаю с анимациями на клиентских проектах. В основном, использую Gsap, простые CSS-анимации или Lottie. А вот проекты, которые нужно реализовывать на WebGL встречаются редко и почти всегда задача по ним звучит необычно.

Недавно клиент попросил сделать анимацию прелоадера в виде песка. И единственной вводной был только этот референс.

Референс дал понимание, что клиент хочет добавить анимацию частиц. И в моем случае эта задача оказалась нетривиальной, хотя она и не супер сложная.

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

Этап 1: Планирование и ресерч

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

  1. Прелоадер должен закрывать весь экран;

  2. Частицы должны быть маленьким;

  3. У частиц должны быть разные оттенки.

И первые два пункта на самом старте создают большую проблему. Они задают количество частиц. Простая математика:

(Ширина экрана: 1920) * (Высота экран: 1080) / (Размер частицы: 1) = 2 073 600 частиц

Получается, нужно создать и управлять большим количеством объектов. И каждая частица должна обновлять свое состояние минимум 30 раз в секунду, а лучше 60 и более — в зависимости от частоты экрана. 

Делать это все через JS и обычный канвас — не лучший метод, потому что любой процессор просто захлебнется. Так я понял, что нужно обратится к WebGL и шейдерам. Они напрямую взаимодействуют с видеокартой, которая лучше справится с большим количеством операций.

У меня появилось 3 пути, с которых можно начать:

  1. Pixi.js

  2. Three.js

  3. На чистом WebGL

Я решил остановится на третьем варианте, хоть у меня и был хороший опыт работы с pixi.js и скромный с three.js. А в случае с последним можно найти много референсов по анимации.

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

Чтобы разобраться с WebGL и шейдерами, я начал искать информацию. В процессе наткнулся на хороший референс с открытым кодом, на который ориентировался в будущем. Также нашел доки с видео, по которым изучал работу с WebGL и шейдерами:

И так, все источники есть. Поэтому настало время закрепить примеры на практике. 

Этап 2: Рисуем песок

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

В зависимости от показателей шума, частица имела бы разный оттенок. Я обратился к ChatGPT и он мне выдал палитру на первое время в RGB формате.

const palette:[number,number,number][] = [
          [0.9, 0.78, 0.4],   // Warm Yellow Sand
          [0.96, 0.91, 0.78], // Soft Yellow Sand
          [0.98, 0.95, 0.86], // Light Yellow Sand
          [0.95, 0.8, 0.6],   // Pale Orange Sand
          [0.96, 0.9, 0.7],   // Pale Yellow Sand
          [0.83, 0.65, 0.23], // Mustard Yellow Sand
          [0.90, 0.72, 0.55], // Light Orange Sand
          [0.91, 0.83, 0.52], // Golden Yellow Sand
          [0.84, 0.76, 0.58], // Beige Sand
          [0.85, 0.54, 0.22], // Burnt Orange Sand
          [0.91, 0.65, 0.38], // Warm Orange Sand
          [0.78, 0.49, 0.27], // Coppery Orange Sand
          [0.64, 0.36, 0.18], // Deep Orange Sand
          [0.72, 0.41, 0.22]  // Rust Orange Sand
        ];

Дальше я нашел библиотеку, которая генерирует шум. Через боль и страдание приступил к своему первому опыту работы с WebGL и шейдерами. В результате чего у меня получилось следующее: 

https://codesandbox.io/p/sandbox/9h5666
https://codesandbox.io/p/sandbox/9h5666

Я сделал так, чтобы при каждом обновление текстура отличалась от предыдущей — то, что надо

Этап 3: Боль гуманитария

Как оказалось позже, это была самая простая задача — дальше пошло сложней…

Теперь нужно было научить частицы двигаться. При этом, все расчёты перенести на видеокарту, а на JS оставить только первичную генерацию буферов. С её помощью все частицы принимают свою позу.

Так я узнал про волшебную штуку Transform Feedback. Это функция, которая позволяет захватывать данные, обработанные на этапе вершинного шейдера, и сохранять их в буфер для дальнейшего использования.

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

Например:

  • Буфер A: Хранит текущее состояние частиц (чтение).

  • Буфер B: Хранит новое состояние частиц (запись). На следующем кадре буферы меняются местами. Буфер B становится источником данных, а Буфер A — местом для записи.

Преимущества

  • Параллельность: GPU может читать и записывать данные одновременно без конфликтов.

  • Эффективность: Нет необходимости возвращать данные на CPU для обновления.

  • Реактивность: Легко изменять свойства частиц (например, скорость или положение) прямо на GPU.

При обновлении частиц текущие позиции читаются из буфера A → на основе логики (физика, гравитация, столкновения) вычисляются новые позиции → новые данные записываются в буфер B.

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

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

Нужно было вспомнить математику, будучи гуманитарием, и привыкнуть к синтаксису GLSL —языка на котором пишутся шейдеры. 

https://codesandbox.io/p/sandbox/zen-snyder-pyl4zg
https://codesandbox.io/p/sandbox/zen-snyder-pyl4zg

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

Лучше описание этой задачи: not hard, not simple

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

Кроме математики было сложно привыкнуть к тому, что я не просто задаю начальное и конечное положение анимации, как, например, в GSAP. Мне нужно было написать формулу, которая самостоятельно обновляла положение частиц в зависимости от кадра анимации.

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

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

Задача изрядно потрепала мне нервы и я потратил где-то неделю на ее реализацию

В моем, случае я затрекал ~29 часов. Нужно было еще перетащить всю анимацию на React. Думаю где-то часов 10 я не трекал, потому что занимался ей на досуге, изучая информацию. Мне просто было интересно разобраться :-)

Самое главное, что я для себя узнал — как работать с WebGL. Думаю, что мне станет проще работать с Pixi.js и Three.js.


В общем, пишите в комментариях, как вам результат :-)

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