Canvas-конфетти без библиотек: пишем систему частиц и физику на чистом JS
- суббота, 21 февраля 2026 г. в 00:00:10
Привет! Я Сергей, разработчик. Когда мне понадобилась легкая анимация лайка в стиле YouTube, я столкнулся с проблемой: готовые решения либо слишком тяжелые, либо плохо дружат с прозрачностью в браузерах.
Не желая идти на компромиссы, я написал свою систему на Canvas. В статье поделюсь опытом создания частиц и физики на чистом JS, а также разберу встроенные инструменты браузера для таких задач.
Мы будем делать схематичную анимацию фейерверков и конфетти по нажатию на кнопку при помощи JS. Изученные принципы универсальны для любого ЯП. Нам понадобится немного знаний ООП, тригонометрии, физики и совсем чуть-чуть геометрии. Вот что получится в итоге:

Чтобы зря не изобретать велосипед, сначала быстро сравним готовые решения.
Формат/Технология | Прозрачность | Вес | Качество | Поддержка браузеров |
GIF | 1-бит | высокий | низкое | все |
APNG | RGBA | средний | высокое | все, кроме старых |
WebP (аним.) | RGBA | низкий | высокое | все |
AVIF (аним.) | RGBA | очень низкий | очень высокое | Chrome/Edge/Firefox |
WebM VP9/AV1 | альфа есть | низкий | высокое | все кроме Safari (без прозрачности) |
CSS/SVG | RGBA | минимальный | без потерь | все |
Canvas/WebGL | RGBA | оптимальный | высокая | все |
SVG+background-position | RGBA | низкий | без потерь | все |
GIF не даёт подходящего качества и плавности.
APNG слишком тяжёлый.
WebP - обратите внимание, WebP в целом отлично подходит для подобных задач. Уже после реализации я собрал последовательность из 57 PNG-файлов с прозрачностью в нормально работающий WebP c FPS ~60. Для решения задачи сборки нескольких PNG-файлов в один WebP я использовал инструмент img2webp и итоговый размер файла 370х100px из 57 кадров составил 21KB. Очень хороший результат, однако, качество получилось не на высоте. Пришлось увеличить разрешение в два раза, что вызвало увеличение веса до 43.0 kB, что тоже немного. Но это всё-таки 43kB ради не самой нужной фичи. В итоге на сайте использую анимированный webp на главной странице при анимации лого.
AVIF тоже подходит для микроанимаций, однако я так и не смог нормально его собрать, чтобы сохранялась прозрачность и последующий кадр анимации заменял предыдущий, а не рисовался поверх. Велика вероятность, что это тоже хорошее и рабочее решение. Нет поддержки в Safari до 2022 года.
WebM - нет поддержки прозрачности в Safari.
CSS/SVG - вполне рабочее, однако не самое удобное решение в плане реализации на мой взгляд.
Canvas/WebGL - работает в итоге неплохо, но нужно кодить. Бонусом получаем полный контроль над анимацией, включая рандомизацию, например.
SVG+background-position - несколько костыльный метод анимации, когда мы делаем SVG, где один кадр расположен рядом с другим. Например, делаем 10 кадров 100x20px в векторе, помещаем их в столбик в один SVG, используем получившийся файл размерами 100x200px как background элемента с размерами 100x20px и смещаем background в зависимости от того, какой кадр необходимо отрисовать. Метод хорош только тем, пожалуй, что он кроссбраузерный, быстр и прост в реализации. Технически - как-то не очень.
Для процедурных эффектов победил Canvas, поэтому идём кодить.
Оценивая выбор ретроспективно, я бы не стал делать анимацию на Canvas, если бы изначально не сообразил про анимированный webp. Однако, работа получилась интересной и познавательной, поэтому хотелось бы поделиться.
Для того, чтобы анимация была более или менее кастомизируемой, было принято решение создать архитектуру (смотрите более подробное объяснение в комментариях в коде):
Scene - сцена, в которую мы будем добавлять объекты для отрисовки. Грубо говоря, идея следующая: создаём сцену, добавляем в неё всякие кружки, линии и так далее. Сцена содержит в себе массив с объектами-наследниками Entity, умеет пробегаться по нему и вызывать отрисовку каждого из них.
class Scene { constructor() { //храним объекты сцены в массиве this.entities = []; } add(entity) { this.entities.push(entity); } update(time) { /* обновляем каждый элемент Entity, чтобы удалить объекты, которые уже "воспроизведены". Мы реализуем удаление элемента, но по сути, можно не удалять, если конечная анимация требует сохранения его на canvas. */ this.entities.forEach(e => e.update(time)); this.entities = this.entities.filter(e => e.alive); } draw(ctx, time) { /* отрисовываем каждый элемент, передавая текущий "кадр" time здесь, это количество миллисекунд, прошедшее от старта анимации до текущего кадра. Подобнее о том, откуда берётся "кадр" - ниже. */ this.entities.forEach(e => e.draw(ctx, time)); } }
Entity - любая сущность, которую мы можем добавить на сцену. Рассматриваем Entity, как абстрактный класс, то есть реализацию рисования будем делать только у потомков.
class Entity { //базовые свойства можно определить такие: constructor({ x = 0,//положение в px относительно canvas y = 0, start = 0,//время начала анимации внутри Scene в мс duration = 1000//абсолютное время анимации в мс } = {}) { this.x = x; this.y = y; this.start = start; this.duration = duration; this.alive = true; } /* здесь нормализуем time, то есть на основании длительности анимации сущности и проигранного времени, возвращаем значение между 0 и 1. */ progress(time) { return Math.min( Math.max((time - this.start) / this.duration, 0), 1 ); } /* этот метод вызывается из Scene для каждого entity. Если нужно удалить объект после того, как он закончил своё воспроизведение, - удаляем. */ update(time) { if (time > this.start + this.duration) { this.alive = false; } } //конкретное рисование будем реализовывать у классов-потомков Entity draw(ctx, time) {} }
Всё это будет оживляться при помощи класса Animator, который принимает в качестве параметров Scene и Canvas и отрисовывает созданную сцену в контексте выбранного canvas-элемента и, при необходимости вызывать callback-метод по завершении. Для вызова процесса рисования мы будем использовать метод браузеров requestAnimationFrame. О нём есть отдельная статья на хабре.
class Animator { constructor(canvas, scene) { this.canvas = canvas;//будем рисовать в полученном canvas this.ctx = canvas.getContext('2d');//сразу берём контекст this.scene = scene;//сцена для отрисовки - экземпляр Scene this.startTime = null;//абсолютное время начала воспроизведения, которое приходит из requestAnimationFrame this.running = false;//свойство определяет, проигрывается плеер или нет. this.onFinish = null;//callback-метод, срабатывающий в конце воспроизведения this.prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;//опция браузера, определяет, что пользователь предпочёл отключить анимации } start() { if (this.running) return; //Если пользователь отключил анимации, сразу вызываем callback onFinish(), если он есть if (this.prefersReducedMotion) { if (typeof this.onFinish === 'function') { this.onFinish(); } return; } this.running = true; /* метод браузера, который возвращает timestamp в миллисекундах в переданный callback. Привязан к частоте кадров экрана - вызывается прежде, чем браузер перерендерит страницу. */ requestAnimationFrame(this.loop); } //останавливаем анимацию, если нужно stop() { this.running = false; } //Делаем loop стрелочной функцией, чтобы не терять контекст (this) loop = (timestamp) => { //останавливаем воспроизведение if (!this.running) return; //записываем time, как количество миллисекунд, прошедшее с начала анимации по текущий кадр if (!this.startTime) this.startTime = timestamp; const time = timestamp - this.startTime; //очищаем canvas и рисуем заново. Это, в свою очередь, однозначным образом создаст необходимость для браузера перерендерить страницу и вызвать очередной requestAnimationFrame this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); //в Scene update() и draw() вызывается у каждого Entity внутри сцены. this.scene.update(time); this.scene.draw(this.ctx, time); if (this.scene.entities.length > 0) { //рекурсивно вызваем loop, продолжая передавать в него новый timestamp requestAnimationFrame(this.loop); } else { /* или останавливаем воспроизведение, если сцена пуста, то есть все добавленные Entity отрисованы и удалены через собственный update() */ this.running = false; if (typeof this.onFinish === 'function') { this.onFinish(); } } } }
Обратите внимание на window.matchMedia('(prefers-reduced-motion: reduce)').matches
Оказывается у браузера есть свойство отключения анимаций - на момент начала работы над этими самыми анимациями, я о нём не знал. В хроме, например, изменение этой настройки можно применить через DevTools или при помощи расширений.
Для начала нарисуем просто круг, который будет расти от центра canvas до заданного радиуса. Это позволит проверить, как работает Scene и Animator. После того, как проверим работоспособность, приступим к более интересным задумкам. По сути, нам нужно написать наследника Entity, расширить его свойства в зависимости от необходимостей и реализовать в нём метод draw(ctx, time); Сделаем такой класс.
class Circle extends Entity { //добавляем 2 свойства, остальные наследуем от Entity. Если свойства в конструктор не переданы, заполняем значениями по умолчанию. Значения по умолчанию наследуемых свойств указано в родительском классе Entity constructor({ radius = 10, color = '#ff0000', ...entityProps }={}) { super(entityProps); this.radius = radius; this.color = color; } //получаем контекст и количество миллисекунд, прошедших от начала анимации до текущего кадра draw(ctx, time) { //нормализуем значение времени const normal = this.progress(time); if (normal <= 0 || normal >= 1) return; /* Считаем radius для текущего кадра - normal здесь значение от 0 до 1. Соответсвенно в начале анимации normal меньше, чем в конце. Если нужно, например, чтобы круг уменьшался, то делаем просто (1-normal)*this.radius. this.radius - целевое значение радиуса. */ const renderRadius = normal * this.radius; //cобственно, рисуем. ctx.beginPath(); ctx.arc(this.x, this.y, renderRadius, 0, Math.PI * 2); ctx.fillStyle = this.color; ctx.fill(); } }
Далее создаём экземпляр Scene, добавляем в него экземпляр Circle. Объявляем Animator и передаём в качестве параметров созданный Scene и элемент Canvas. Например, так:
const startButton = document.getElementById("start"); const animate = (event)=>{ //создаём canvas и совмещаем его центр с центром кнопки. Его не обязательно создавать "налету", можно взять имебщийся. const target = event.currentTarget; const rect = target.getBoundingClientRect(); const canvas = document.createElement('canvas'); canvas.style.position = 'absolute'; canvas.style.left = (rect.left + rect.width/2 - 75) + 'px'; canvas.style.top = (rect.top + window.scrollY + rect.height/2 - 75) + 'px'; //для класса .canvas-animation в CSS указаны размеры и на всякий случай z-index. canvas.className = 'canvas-animation'; /* добавляем на страницу Canvas и устанавливаем для canvas ширину и высоту. В данном случае они будут браться из CSS по классу .canvas-animation */ document.body.appendChild(canvas); canvas.width = canvas.getBoundingClientRect().width; canvas.height = canvas.getBoundingClientRect().height; /* создаём сцену и добавляем на сцену круг. Если не передавать объект с параметрами в конструктор, круг нарисуется в левом верхнем углу */ const scene = new Scene(); const circle = new Circle({ x:canvas.width/2,//рисуем по центру canvas y:canvas.height/2, radius:50,//размер в px }); scene.add(circle); const animator = new Animator(canvas, scene); animator.onFinish = () => { //удаляем canvas, когда заканчивается анимация canvas.remove(); }; //запускаем рекурсивный вызов loop, который рисует объекты из Scene animator.start(); } startButton.addEventListener("click",animate);
Как видим, всё работает. И на данном этапе, мы видим, что анимация воспроизводится линейно, что соответствует ожидаемому поведению, ведь мы считаем const renderRadius = normal * this.radius;
линейно, в зависимости от нормализованного time. Чтобы добавить плавности, мы можем создать класс-хелпер с желаемыми функциями. Например, такой:
class Ease { static easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); } }
Можно добавить в этот класс и другие функции, которые будут управлять скоростью воспроизведения. В данном случае я сделал кубическую функцию, чтобы анимация проигрывалась в начале быстро и ускорялась ближе к концу, что соответствует поведению фейерверка, собственно. Далее нам достаточно просто считать изменяемые характеристики через эту функцию, вот так:const renderRadius = Ease.easeOutCubic(normal) * this.radius;
Соответственно, вместо radius мы можем управлять таким образом любым свойством.
Далее хотелось бы добавить немного динамики. Чтобы на протяжении времени анимации круг не просто рос от центра наружу, а в зависимости от стадии анимации, обладал различной прозрачностью и заливкой.
Например, так: на протяжении первой четверти анимации круг растёт от центра наружу, а далее расходится до размеров целевого радиуса в виде окружности, которая увеличиваясь, становится полностью прозрачной в конце. Всё что нужно для этого, это немного расширить метод draw() у Circle.
draw(ctx, time) { //нормализуем значение времени const normal = this.progress(time); if (normal <= 0 || normal >= 1) return; const split = 0.25;//определяем момент смены фазы - "на протяжении первой четверти анимации круг растёт от центра наружу" const R = this.radius;//записываем целевой радиус в отдельную переменную для удобства const r0 = Math.max(1.5, 0.05 * R);//определяем радиус на начало анимации. Math.max() здесь для защиты от слишком малого начального радиуса const r1 = 0.5 * R;//радиус на конец первой фазы - то есть за первую фазу радиус вырастет от нуля до половины целевого const r2 = 1.00 * R;//радиус на конец второй фазы и на конец анимации const o0 = 1.0;const o1 = 1.0;const o2 = 0.0;//прозрачность на начало анимации, на конец первой фазы и на конец анимации //толщина линии - подбираем подходящие значения эмпирически. Здесь я сделал абсолютные значения в px, но можно сделать значения и отностительно целевого radius const w0 = 10.0;//это значение не используем, в первой фазе вообще не рисуем stroke, оставил для наглядности const w1 = 10.0; const w2 = 2.0; //будем считать эти параметры в зависимости от стадии и времени let radius, alpha, strokeWidth; //в первой фазе делаем заливку. Начиная со второй фазы - не делаем заливку let fill; /* определяем фазу в зависимости от текущего значения normal, которое напомню, принимает значение от 0 до 1, где 0 - начало анимации, а 1 - конец. Для круга split определили 0.25 - смена фазы, после того, как проиграна четверть всей анимации. */ if (normal < split) { /* здесь определяем момент первой фазы - мы знаем, что в течение первой фазы радиус должен расти от r0 до r1, для этого нужно привести общий прогресс normal к нормали фазы. То есть normal / split - это нормализация времени фазы, а Ease.easeOutCubic() - применение плавности */ const phaseNormal = Ease.easeOutCubic(normal / split); /* используем функцию линейной интерполяции для того, чтобы посчитать конкретное значение для radius. Я добавил функцию lerp в Ease, чтобы не плодить классы. */ radius = Ease.lerp(r0, r1, phaseNormal); alpha = o1;//мы знаем, что прозрачность и толщина линии не меняется в течение первой фазы, ничего считать не нужно strokeWidth = w1; fill = true;//рисуем круг с заливкой } else { //нормализация времени второй фазы const phaseNormal = Ease.easeOutCubic( (normal - split) / (1 - split) ); //линейная интерполяция конкретных значений для радиуса, толщины линии и прозрачности radius = Ease.lerp(r1, r2, phaseNormal); alpha = Ease.lerp(o1, o2, phaseNormal); strokeWidth = Ease.lerp(w1, w2, phaseNormal); fill = false;//рисуем окружность без заливки } //cобственно, рисуем. ctx.globalAlpha = alpha;//применяем прозрачность ctx.beginPath(); ctx.arc(this.x, this.y, radius, 0, Math.PI * 2); if (fill) {//круг ctx.fillStyle = this.color; ctx.fill(); } else {//окружность ctx.strokeStyle = this.color; ctx.lineWidth = strokeWidth; ctx.stroke(); } ctx.globalAlpha = 1; }
Для разнообразия, проверим, как работает динамическая параметризация сущностей. Добавим на сцену 10 кругов со случайными параметрами (расширяем функцию animate):
//создаём сцену и добавляем в неё круги const scene = new Scene(); for(let i = 0; i < 10; i++){ //добавим в Ease фунцию random - она будет генерировать int от m до n включительно const radius = Ease.random(25,50);//случайный радиус от 25 до 50 const x = Ease.random(radius,canvas.width-radius);//случайные координаты с учётом радиуса const y = Ease.random(radius,canvas.width-radius);//чтобы круг не выходил за границы canvas const circle = new Circle({ x:x, y:y, radius:radius, start:i*100, }); scene.add(circle); }
Таким образом из линий, кругов и других фигур мы можем рисовать схематические украшения. Я использовал для фейерверков всё тот же круг, линии, которые расходятся из центра и вращающуюся звезду, которые в свою очередь всё также реализуют свой метод draw с разбиением на две фазы.
При желании можно написать унитарный фазовый обработчик, чтобы он принимал состояния изменяемых свойств в виде массива, это уже дело техники.
Что интересного в готовом фейерверке - я реализовал его как класс Firework - наследник Entity, который внутри себя содержит другие Entity: Line (25 штук), Star и Circle. Реализовал метод draw() у Firework таким образом, чтобы он просто вызывал draw() у своих children по очереди. То есть Firework сам ничего не рисует, а только служит определённым контейнером для других Entity, и передаёт им свои параметры.
class Firework extends Entity { static COLORS = [ '#ff3b3b', '#ffd93b', '#b83bff' ]; constructor({ radius = 50, color = '#ff0000', ...entityProps }={}) { super(entityProps); this.radius = radius; this.color = color; this.children = []; //определяем количество линий в фейерверке const linesCount = 25; //и шаг угла в радианах const angle = (360/linesCount) * Math.PI / 180; for (let i = 0; i < linesCount; i++) { this.add(new Line({ x: this.x, y: this.y, angle:angle*i, length: this.radius, start:this.start + 250,//откладываем старт для линии отностительно старта всего фейерверка color: color, duration: this.duration*1.25, })); } //добавляем круг и звезду this.add(new Circle({ x: this.x, y: this.y, start:this.start, radius:this.radius*.5, color: color, })); this.add(new Star({ x: this.x, y: this.y, radius:this.radius * .75, start:this.start + 450, duration: this.duration * 1.75, color: color, })); //изменяем длительность, чтобы Fireworks не удалился раньше, чем будут проиграны все его дети this.duration = this.recalculateDuration(); } add(child) { this.children.push(child); } draw(ctx, time) { //fireworks внутри себя сам ничего не рисует, только отрисовывает детей. const p = this.progress(time); if (p <= 0) return; this.children.forEach(c => c.draw(ctx, time)); } update(time) { super.update(time); this.children.forEach(c => c.update(time)); } //пересчитываем duration в зависимости от самого длинного ребёнка, который позже всех начинается recalculateDuration() { let maxEnd = this.start + this.duration; for (const c of this.children) { const end = c.start + c.duration; if (end > maxEnd) maxEnd = end; } this.duration = maxEnd - this.start; } }
Нам понадобилось добавить метод для определения изменённого duration у Firework с учётом start и duration добавленных Entity. Также мы сразу расширили класс-хэлпер Ease - добавили метод clamp().
class Ease { //кубическая функция для создания плавности анимации static easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); } //функция-ограничитель значений для избегания ошибок при вычеслении значений с плавающей точкой static clamp(v, min = 0, max = 1) { return Math.min(Math.max(v, min), max); } //функция линейной интерполяции static lerp(a, b, t){ return a + (b - a) * t; } //функция создания случайного целого от n до m включительно static random(n, m) { const min = Math.ceil(n); const max = Math.floor(m); return Math.floor(Math.random() * (max - min + 1)) + min; } }
Также для простоты параметризации и добавления Firework в сцену создадим класс FireworksSceneBuilder. В принципе, можно обойтись и без него, но он наглядно показывает удобство отделения сущности Scene от Animator. FireworksSceneBuilder создаёт внутри себя сцену, и кладёт в нее определённое количество Firework в случайные места со случайными размерами в указанных пределах.
class FireworksSceneBuilder { //метод будет возвращать сцену с фейерверками static build({ count = 3,//количество фейерверков minScale = 0.1,//минимальный масштаб относительно viewportWidth maxScale = 0.3, baseDuration = 750,//длительность всего фейерверка в мс на случай, если захочется её тоже рандомизировать viewportWidth = 150,//у нас canvas 150*150px viewportHeight = 150, } = {}) { const scene = new Scene(); let currentStart = 0; for (let i = 0; i < count; i++) { const scale = Ease.random(minScale*10,maxScale*10)/10; const radius = (viewportWidth * scale); const x = Ease.random(radius*2,viewportWidth - radius*2); const y = Ease.random(radius*2,viewportHeight - radius*2); console.log(scale,radius,viewportWidth - radius,x,y); scene.add( new Firework({ x:x, y:y, radius:radius, color: Firework.COLORS[Math.floor(Math.random() * Firework.COLORS.length)], start: currentStart, duration: baseDuration }) ); currentStart += 200 + Math.random() * 250; } return scene; } }
Достаточно передать в Animator возвращаемую билдером Scene. Реализацию методов draw() у Star и Line можно посмотреть в codepen - я постарался детально прокомментировать, что там происходит. Вот, что в итоге получилось:
А теперь - самое интересное. Переходим от примитивов к конфетти.
Для этого нужно понимать, что внутри наследников Entity мы можем изменять любые свойства в зависимости от времени, а не только от нормали. Поэтому сделаем класс Particle, который будет рисовать в методе draw() круг, запущенный в определённом направлении с определённой скоростью. Скорость эта будет постепенно затухать, а условная гравитация будет тянуть частицу вниз, даже если её собственная скорость будет стремиться к нулю.
class Particle extends Entity { constructor({ x = 0, y = 0, //стартовое положение vx = 0, vy = 0, //начальная скорость - по оси x частица будет двигаться со скоростью vx, с затуханием drag, а по оси y - с учётом drag и gravity size = 0.25, color = "#ff0000", shape = "circle", //форма частицы, для начала сделаем только круг gravity = 600, //ускорение, с которым частица тянется вниз drag = 0.98, //затухание скорости duration = 1200, start=0, } = {}) { super({ x: x, y: y, duration: duration, start: start }); //сохраняем все значения в свойствах Particle. В дальнейшем, будем обновлять некоторые из них в методе update, который наследуется от Entity и вызывается в Scene перед вызовом draw(); this.x = x; this.y = y; this.vx = vx; this.vy = vy; this.size = size; this.color = color; this.shape = shape; this.gravity = gravity; //это свойство определяет затухание собственной скорости частицы. Со временем, собственная скорость стремится к 0, но на частицу продолжает действовать гравитация, которая тянет её вниз this.drag = drag; //для частицы будем использовать "астрономическое" время и отвяжем изменение свойств от FPS this.lastTime = null; //эмуляция вращения через Yscale будет производиться через свойсво flip - оно будет равно значению от 0 до 1 и далее для плавности будем использовать косинус от flip this.flip = Math.random() * Math.PI * 2; //со скоростью flipSpeed this.flipSpeed = 6 + Math.random() * 6; } update(time) { super.update(time); if (!this.alive) return; if (this.lastTime === null) { this.lastTime = time; return; } //рассчитываем изменение времени const dt = (time - this.lastTime) / 1000; this.lastTime = time; //рассчитываем изменение скорости с учётом гравитвции и текущего момента только для оси Y this.vy += this.gravity * dt; //применяем затухание скорости с учётом времени const dragForce = 1 - this.drag; this.vx -= this.vx * dragForce * dt * 60; this.vy -= this.vy * dragForce * dt * 60; //двигаем частицу this.x += this.vx * dt; this.y += this.vy * dt; } draw(ctx, time) { const normal = this.progress(time); if (normal >= 1) return; //постепенно уменьшаем прозрачность const alpha = 1 - normal; //рисуем по высчитанным ctx.save(); ctx.translate(this.x, this.y); ctx.globalAlpha = alpha; ctx.fillStyle = this.color; ctx.beginPath(); ctx.arc(0, 0, this.size / 2, 0, Math.PI * 2); ctx.fill(); ctx.restore(); } }
Выше в коде школьная физика:
В контексте кода это:
𝑣0 - текущая скорость частицы (this.vx, this.vy) на предыдущем шаге
а - ускорение (в нашем случае гравитация this.gravity по оси Y)
t - шаг времени dt, вычисляемый из requestAnimationFrame
𝑣 - обновлённая скорость после применения ускорения
Сначала к скорости по оси Y добавляется вклад гравитации:this.vy += this.gravity * dt;
После этого к обеим компонентам скорости применяется затухание (drag) по той же формуле.
Для удобства будем создавать частицы при помощи класса Confetti - он будет возвращать только массив c Particle, а мы вручную будем добавлять их в Scene. Чуть ниже объясню почему так, а не как с FireworksSceneBuilder, где билдер возвращает новую сцену. Для Confetti определим количество Particle, которое он будет возвращать, а также границы угла в котором будут запущены созданные частицы при помощи spread - в нашем примере сделаем +/- 45 градусов от основного направления запуска, которое строго запишем, как -Math.PI / 2 - то есть вверх. Все углы, как и прежде, в радианах.
class Confetti { //класс для массового создания частиц static COLORS = ["#ff3b3b", "#ffd93b", "#3bff7a", "#3bd1ff", "#b83bff"]; static spawnConfetti({ x, y, count = 1, spread = Math.PI / 2, //угол распределения начальных скоростей, сделаем +/- 45 градусов speed = 200, start = 0 }) { this.x = x; this.y = y; this.start = start; const particles = []; for (let i = 0; i < count; i++) { //-Math.PI / 2 - это направление "вверх". Распределяем начальное направление частиц в пределах sprad const angle = -Math.PI / 2 + (Math.random() - 0.5) * spread; //определяем начальную случайную скорость const velocity = speed * (0.5 + Math.random()); particles.push( new Particle({ x: this.x, y: this.y, vx: Math.cos(angle) * velocity, //считаем скорость по осям через значения косинуса vy: Math.sin(angle) * velocity, //и синуса size: 3 + Math.random() * 6, //случайные размеры частицы color: Confetti.COLORS[Math.floor(Math.random() * Confetti.COLORS.length)], shape: "circle", gravity: 600 + Math.random() * 300, //случайное значение гравитации, но можно савязать с размером duration: 2000 + Math.random() * 1000, start:start }) ); } return particles; } }
Теперь для того, чтобы запустить анимацию, мы не будем создавать canvas каждый раз, когда нам необходимо добавить частицу на сцену. Раньше у нас создавался canvas при каждом клике на кнопку start и можно было накликать создание сотен canvas. Вместо этого будем создавать экземпляр Animator и записывать ссылку на него в свойства нажатой кнопки, либо брать готовый, если он уже был создан ранее. Соответственно, массив Particle нам нужен для того, чтобы добавить его во вновь созданную сцену, либо в уже запущенную, которую мы возьмём у работающего Animator:
const singleButton = document.getElementById("single");//будет выстреливать по 1 частице const multipleButton = document.getElementById("multiple");//будет выстреливать по 50 частиц const confettiMove = document.getElementById("move");//будет выстреливать по 10 частиц на каждое срабатывание mouseMove //функция создания частиц const shootConfetti = (e) => { let currentScene; //получаем кнопку, по которой пришло событие - в свойство этой кнопки запишем ссылку на экземпляр Animator, чтобы не плодить их и Scene с canvas, если они были созданы при предыдущем действии const target = e.currentTarget; if (!target.animator) { //создаём canvas и Animator, если их нет const canvas = document.createElement("canvas"); canvas.style.width = window.innerWidth + "px"; canvas.style.height = window.innerHeight + "px"; canvas.style.position = "fixed"; canvas.style.pointerEvents = "none"; canvas.style.left = "0"; canvas.style.top = "0"; canvas.style.zIndex = "15"; canvas.className = "confetti-animation"; document.body.appendChild(canvas); canvas.width = canvas.getBoundingClientRect().width; canvas.height = canvas.getBoundingClientRect().height; const scene = new Scene(); currentScene = scene; target.animator = new Animator(canvas, scene); target.animator.onFinish = () => { canvas.remove(); target.animator = null; }; } else { //если Animator уже создан во время предыдущего события, берём его сцену, так как scene публичное свойство currentScene = target.animator.scene; } //Придётся немного расширить класс Animator, чтобы частицы сразу не протухали, если они добавляются в сцену уже работающего Animator. Для них start будет равен времени Animator на момент добавления const now = target.animator.getTime(); Confetti.spawnConfetti({ x: e.clientX,//добавляем частицу на координаты курсора y: e.clientY, //количество частиц берём, например, из dataset. У кнопки confettiMove datset не указан, при срабатывании события mousemove будет создаваться по 10 штук - это много count: target.dataset.count ?? 10, start: now//start для новой частицы взят относительно текущего времени Animator }).forEach((p) => { currentScene.add(p); }); e.currentTarget.animator.start(); }; singleButton.addEventListener("click", shootConfetti); multipleButton.addEventListener("click", shootConfetti); confettiMove.addEventListener("mousemove", shootConfetti);
Расчёты производим в методе update(), а рисуем, как и прежде, в draw(). Обратите внимание, что для реализации возможности добавления новых Entity в уже работающий Animator пришлось реализовать метод target.animator.getTime(); для того, чтобы жизненный цикл вновь добавленных Entity начинался с момента их добавления. Вот результат:
Добавим вращение частиц при помощи Yscale - то есть будем сплющивать круг по горизонтали, чтобы эмулировать вращение вокруг оси X (по горизонтали от себя), а для вращения вокруг оси Z, которая является перпендикуляром к плоскости экрана устройства, будем использовать обычное свойство rotation - это будет заметно при сплющенном круге.
Добавляем rotation и angularVelocity в параметры Particle, а в теле update на ходу считаем свойство this.flip, которое по сути будет являться множителем изменения масштаба частицы по высоте.
class Particle extends Entity { constructor({ x = 0, y = 0, //стартовое положение vx = 0, vy = 0, //начальная скорость - по оси x частица будет двигаться со скоростью vx, с затуханием drag, а по оси y - с учётом drag и gravity size = 0.25, color = "#ff0000", shape = "circle", //форма частицы, для начала сделаем только круг rotation = 0, //начальное вращение частицы - частицы будут вращаться по всем остям. rotation отвечает за вращение вокруг оси z, которая является нормалью к плоскости экрана. Вращение вокруг других осей будем эмулировать при помощи scale angularVelocity = 0, //насколько быстро крутится частица gravity = 600, //ускорение, с которым частица тянется вниз drag = 0.98, //затухание скорости duration = 1200, start=0, } = {}) { super({ x: x, y: y, duration: duration, start: start }); //сохраняем все значения в свойствах Particle. В дальнейшем, будем обновлять некоторые из них в методе update, который наследуется от Entity и вызывается в Scene перед вызовом draw(); this.x = x; this.y = y; this.vx = vx; this.vy = vy; this.size = size; this.color = color; this.shape = shape; this.rotation = rotation; this.angularVelocity = angularVelocity; this.gravity = gravity; //это свойство определяет затухание собственной скорости частицы. Со временем, собственная скорость стремится к 0, но на частицу продолжает действовать гравитация, которая тянет её вниз this.drag = drag; //для частицы будем использовать "астрономическое" время и отвяжем изменение свойств от FPS this.lastTime = null; //эмуляция вращения через Yscale будет производиться через свойсво flip - оно будет равно значению от 0 до 1 и далее для плавности будем использовать косинус от flip this.flip = Math.random() * Math.PI * 2; //со скоростью flipSpeed this.flipSpeed = 6 + Math.random() * 6; } update(time) { super.update(time); if (!this.alive) return; if (this.lastTime === null) { this.lastTime = time; return; } //рассчитываем изменение времени const dt = (time - this.lastTime) / 1000; this.lastTime = time; //рассчитываем рассчитываем изменение скорости с учётом гравитвции и текущего момента только для оси Y this.vy += this.gravity * dt; //применяем затухание скорости с учётом времени const dragForce = 1 - this.drag; this.vx -= this.vx * dragForce * dt * 60; this.vy -= this.vy * dragForce * dt * 60; //двигаем частицу this.x += this.vx * dt; this.y += this.vy * dt; //изменяем угол вращения по осям z и x this.rotation += this.angularVelocity * dt; this.flip += this.flipSpeed * dt; } draw(ctx, time) { const normal = this.progress(time); if (normal >= 1) return; //постепенно уменьшаем прозрачность const alpha = 1 - normal; //конвертируем значение this.flip в степень сжатия частицы по горизонтали - при меньшем flip круг будет сжат в овал. Math.abs для того, чтобы частица не была "повёрнута" обратной стороной. Отрицательное значение косинуса можно использовать здесь, чтобы сделать частицы двуцветными с двух "сторон" const flip = Math.abs(Math.cos(this.flip)); //ограничиваем минимальное сжатие, чтобы частицы не исчезали полностью const squash = Math.max(0.15, flip); //рисуем по высчитанным ctx.save(); ctx.translate(this.x, this.y); ctx.rotate(this.rotation); ctx.scale(1, squash); ctx.globalAlpha = alpha; ctx.fillStyle = this.color; ctx.beginPath(); ctx.arc(0, 0, this.size / 2, 0, Math.PI * 2); ctx.fill(); ctx.restore(); } }
Обратите внимание, что положение для частицы мы считаем на основе скорости и времени, а плавное затухание, как и у примитивов - линейно, в виде нормали между началом жизни частицы до конца её duration. Вот результат применения вращения:
Осталось добавить ещё пару форм для частиц и реализовать отрисовку частицы в зависимости от формы. Свойство, отвечающее за форму мы спроектировали ещё в начале, пока что оно всегда "circle" и мы рисуем частицу в виде круга. Добавим треугольники, прямоугольники с возможностью скругления углов и полоски в виде змеек, так называемый серпантин. Определим дополнительные свойства, которые будут изменяться в процессе рисования. Для квадратов добавим свойство round - будет отвечать за скругление углов, а для серпантина - фазу изгиба wavePhase, чтобы змейка-серпантин как-бы ползла, и roll вращение вокруг своей оси.
В коде свойства частицы считаются независимо от её формы, что возможно излишне, но я оставил как есть для наглядности.

Реализация микро-анимаций на Canvas - это не только увлекательно, но и даёт полный контроль над визуальными эффектами. Используя архитектуру с Scene, Entity и Animator, мы можем создавать динамичные элементы, управлять их жизненным циклом, фазами анимации и параметрами с учётом времени.
Пример с фейерверками и конфетти показывает, как можно комбинировать простые примитивы (круги, линии, звёзды) и частицы для получения эффекта. Механику линии можно использовать, например, для анимации стрелки, движущейся от одной точки к другой.
У меня на сайте, который является сборником образовательных тестов, фейерверк запускается при нажатии на лайк, а конфетти выстреливает при получении максимального результата тестирования. Ну, и по двойному клику на лого на главной странице в качестве пасхалки)
Canvas + собственная архитектура анимаций - это лёгкий и гибкий способ оживить интерфейс и добавить интерактивность, которая будет отзывчивой и красивой на любых устройствах.
Всем хорошего кода и полного контроля над ним!