Привет, меня зовут Ваня! Я фронтенд-разработчик в Лиге А. и часто работаю с анимациями на клиентских проектах. В основном, использую Gsap, простые CSS-анимации или Lottie. А вот проекты, которые нужно реализовывать на WebGL встречаются редко и почти всегда задача по ним звучит необычно.
Недавно клиент попросил сделать анимацию прелоадера в виде песка. И единственной вводной был только этот референс.
Референс дал понимание, что клиент хочет добавить анимацию частиц. И в моем случае эта задача оказалась нетривиальной, хотя она и не супер сложная.
У меня был опыт работы с пикселями в канвасе, так как для этого же клиента мы делали что-то вроде пиксель-батла. На том проекте нужно было только перекрашивать пиксели — не было физики, которая бы сжирала производительность. В общем, появилась необходимость в небольшом ресерче.
Этап 1: Планирование и ресерч
На этом этапе у меня было несколько важных параметров, которые определяли дальнейшую работу:
Прелоадер должен закрывать весь экран;
Частицы должны быть маленьким;
У частиц должны быть разные оттенки.
И первые два пункта на самом старте создают большую проблему. Они задают количество частиц. Простая математика:
Получается, нужно создать и управлять большим количеством объектов. И каждая частица должна обновлять свое состояние минимум 30 раз в секунду, а лучше 60 и более — в зависимости от частоты экрана.
Делать это все через JS и обычный канвас — не лучший метод, потому что любой процессор просто захлебнется. Так я понял, что нужно обратится к WebGL и шейдерам. Они напрямую взаимодействуют с видеокартой, которая лучше справится с большим количеством операций.
У меня появилось 3 пути, с которых можно начать:
Pixi.js
Three.js
На чистом WebGL
Я решил остановится на третьем варианте, хоть у меня и был хороший опыт работы с pixi.js и скромный с three.js. А в случае с последним можно найти много референсов по анимации.
Решил, что вряд ли сэкономлю много времени, используя эти библиотеки. Мне все также придется закапываться в их документацию и изучать основы, а знаний по ним было мало.
Чтобы разобраться с WebGL и шейдерами, я начал искать информацию. В процессе наткнулся на хороший референс с открытым кодом, на который ориентировался в будущем. Также нашел доки с видео, по которым изучал работу с WebGL и шейдерами:
База по WebGL2: WebGL2 Fundamentals
Плейлист с видео-гайдами
И так, все источники есть. Поэтому настало время закрепить примеры на практике.
Этап 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 и шейдерами. В результате чего у меня получилось следующее:
Я сделал так, чтобы при каждом обновление текстура отличалась от предыдущей — то, что надо
Этап 3: Боль гуманитария
Как оказалось позже, это была самая простая задача — дальше пошло сложней…
Теперь нужно было научить частицы двигаться. При этом, все расчёты перенести на видеокарту, а на JS оставить только первичную генерацию буферов. С её помощью все частицы принимают свою позу.
Так я узнал про волшебную штуку Transform Feedback. Это функция, которая позволяет захватывать данные, обработанные на этапе вершинного шейдера, и сохранять их в буфер для дальнейшего использования.
Для частиц обычно создают два буфера, которые хранят данные — это позволяет эффективно обновлять и сохранять их состояние между кадрами. В системе частиц один буфер используется для чтения текущего состояния (позиция, скорость и другие), а другой — для записи обновленных данных.
Например:
Буфер A: Хранит текущее состояние частиц (чтение).
Буфер B: Хранит новое состояние частиц (запись). На следующем кадре буферы меняются местами. Буфер B становится источником данных, а Буфер A — местом для записи.
Преимущества
Параллельность: GPU может читать и записывать данные одновременно без конфликтов.
Эффективность: Нет необходимости возвращать данные на CPU для обновления.
Реактивность: Легко изменять свойства частиц (например, скорость или положение) прямо на GPU.
При обновлении частиц текущие позиции читаются из буфера A → на основе логики (физика, гравитация, столкновения) вычисляются новые позиции → новые данные записываются в буфер B.
Это циклический процесс, где каждый кадр сохраняет плавное обновление системы частиц
Все звучит просто на первый взгляд, но это далеко нетривиальная задача. Не помню, с какой попытки у меня все заработало, но потом пришла задача ещё сложнее.
Нужно было вспомнить математику, будучи гуманитарием, и привыкнуть к синтаксису GLSL —языка на котором пишутся шейдеры.
Если кратко, то у меня получилось следующее: есть увеличивающаяся со временем область от которой должны отталкивать частицы. Также к моменту движения добавляем немного рандома, чтобы частицы были немного разбросаны.
Лучше описание этой задачи: not hard, not simple
Что касается шейдеров, которые отвечают за подсчет позиции частицы, я до сих пор считают это самой слабой частью работы. В основном, это сборная солянка из моих математических потуг и просьб GPT написать ту или иную формулу.
Кроме математики было сложно привыкнуть к тому, что я не просто задаю начальное и конечное положение анимации, как, например, в GSAP. Мне нужно было написать формулу, которая самостоятельно обновляла положение частиц в зависимости от кадра анимации.
К синтаксису GLSL пришлось привыкать не долго, хоть это и больно после JS или TS писать на языке с C-подобным синтаксисом. Возможно, помогло то, что в универе у меня был электив визуального программирования на шарпах.
В общем, это просто чуть-чуть более низкоуровневое программирование, чем то, к которому я привык. Рад, что у меня появилось возможность этим заняться. Единственный способ стать лучше в этом — изучать новую информацию и тренироваться. Еще пару таких проектов и мне будет стыдно возвращаться к тому коду, что я написал для этой статьи :D
Задача изрядно потрепала мне нервы и я потратил где-то неделю на ее реализацию
В моем, случае я затрекал ~29 часов. Нужно было еще перетащить всю анимацию на React. Думаю где-то часов 10 я не трекал, потому что занимался ей на досуге, изучая информацию. Мне просто было интересно разобраться :-)
Самое главное, что я для себя узнал — как работать с WebGL. Думаю, что мне станет проще работать с Pixi.js и Three.js.
В общем, пишите в комментариях, как вам результат :-)