Система частиц (Particle System)
Частица (particle) — это простой объект с минимальным набором данных: позиция, скорость и время жизни. Эффект (огонь, дым, взрыв) достигается не за счёт сложности одного объекта, а за счёт того, что много таких простых объектов одновременно меняют свои параметры и постепенно исчезают.
Минимальное описание одной частицы:
function createParticle(x, y) { return { x: x, y: y, vx: (Math.random() - 0.5) * 2, // скорость по X, случайное направление vy: -Math.random() * 2, // скорость по Y, движение вверх life: 1.0, // текущее "здоровье" частицы, от 1 до 0 decay: 0.02 // скорость затухания за кадр }; }
life — ключевое поле: пока оно больше нуля, частица жива и отрисовывается; как только доходит до нуля, частица считается "умершей" и должна быть удалена. decay определяет, насколько быстро уменьшается life на каждом шаге обновления — а значит, и общую длительность жизни частицы.
Одна частица сама по себе незаметна и неинтересна — эффект возникает только при большом количестве частиц одновременно. Поэтому вместо одной переменной используется массив:
let particles = []; function spawnParticles(x, y, count) { for (let i = 0; i < count; i++) { particles.push(createParticle(x, y)); } }
spawnParticles вызывается там, где должен появиться эффект — например, в точке взрыва или в основании костра — и добавляет сразу несколько частиц за один момент времени.
Логика обновления идёт по паттерну update(), применяемому к каждой частице в массиве:
function update(deltaTime) { for (let i = particles.length - 1; i >= 0; i--) { const p = particles[i]; p.x += p.vx; p.y += p.vy; p.life -= p.decay; if (p.life <= 0) { particles.splice(i, 1); // частица "умерла" — удаляем из массива } } }
Важно что цикл идёт от конца массива к началу — for (let i = particles.length - 1; i >= 0; i--). Если удалять элемент через splice() во время прямого перебора (от 0 до length), индексы всех последующих элементов сдвигаются, и часть частиц может быть случайно пропущена на этой же итерации. При переборе с конца удаление не влияет на ещё не обработанные индексы.
Простую физику можно добавить, изменяя скорость на каждом кадре:
p.vy += 0.02; // гравитация — скорость по Y постепенно растёт p.vx *= 0.98; // сопротивление воздуха — скорость по X постепенно затухает
Разные комбинации этих коэффициентов и дают разные по ощущениям эффекты — это используется в при отрисовке конкретных видов частиц.
В данной лекции рассматривается самый примитивный вариант отрисовки — каждая частица является кружком, чья прозрачность прямо связана с оставшимся временем жизни:
function render() { ctx.clearRect(0, 0, canvas.width, canvas.height); for (const p of particles) { ctx.globalAlpha = p.life; // чем меньше life, тем прозрачнее частица ctx.fillStyle = 'orange'; ctx.beginPath(); ctx.arc(p.x, p.y, 3, 0, Math.PI * 2); ctx.fill(); } ctx.globalAlpha = 1; // обязательно возвращаем обратно после цикла }
ctx.globalAlpha — свойство контекста, задающее прозрачность всех последующих операций рисования. Используя p.life (значение от 1 до 0) напрямую как альфа-канал, получаем эффект плавного растворения частицы в конце её жизни — без какой-либо дополнительной анимации, просто как побочный эффект уже существующей механики затухания.
Важно не забыть сбросить globalAlpha = 1 после цикла — иначе оно повлияет на отрисовку всего остального на канвасе в этом же кадре.
Для примера будут расмотрены 3 типа симуляции системы частиц - огонь, дым и взрыв. По сути это одна и та же система с разными числовыми параметрами.
Частицы огня рождаются в одной точке (основание костра), летят вверх с небольшим случайным разбросом по X, и по мере "старения" меняют цвет от жёлтого к красному:
function createFireParticle(x, y) { return { x: x, y: y, vx: (Math.random() - 0.5) * 3, vy: -2 - Math.random() * 3, // быстрое движение вверх life: 1.0, decay: 0.014 + Math.random() * 0.008, size: 2 + Math.random() * 4 }; } // в render() для огня: const hue = 60 * p.life; // от 60 (жёлтый) при life=1 до 0 (красный) при life=0 ctx.fillStyle = `hsl(${hue}, 100%, 50%)`;
Здесь используется формат цвета hsl(), где первое значение (hue, оттенок) удобно линейно менять от жёлтого (60) к красному (0) — гораздо проще, чем вручную интерполировать RGB.

пример отрисовки системы частиц огня
Дым отличается от огня медленным движением, боковым разбросом и серым цветом, который со временем становится не только прозрачнее, но и крупнее:
function createSmokeParticle(x, y) { return { x: x, y: y, vx: (Math.random() - 0.5) * 1.2, // слабое движение в стороны vy: -0.6 - Math.random() * 1.2, // медленно вверх life: 1.0, decay: 0.004 + Math.random() * 0.003, // затухает гораздо медленнее огня size: 2 + Math.random() * 2 }; } // в update() дополнительно: p.size += 0.05; // дым расширяется со временем // в render(): ctx.fillStyle = `rgba(150, 150, 150, ${p.life})`; ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);

пример отрисовки системы частиц дыма
В отличие от огня и дыма, где частицы рождаются постоянно (пока горит костёр), взрыв — это разовый выброс частиц, разлетающихся радиально из одной точки:
function createExplosion(x, y, count) { for (let i = 0; i < count; i++) { const angle = (Math.PI * 2 * i) / count; // равномерно по кругу const speed = 3 + Math.random() * 6; particles.push({ x: x, y: y, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed, life: 1.0, decay: 0.015 + Math.random() * 0.01, size: 2 + Math.random() * 4 }); } }
Ключевая идея — направление каждой частицы задаётся через angle, равномерно распределённый по кругу (Math.cos/Math.sin), а не случайно, как в огне и дыме. Это даёт характерный симметричный "разлёт" вместо хаотичного облака.

пример отрисовки системы частиц взрыва
Показанный выше подход (создание нового объекта через createParticle() и удаление через splice()) прост для понимания, но не идеален с точки зрения производительности:
- Каждый вызов createParticle() создаёт новый объект в памяти — при сотнях частиц в секунду это создаёт заметную нагрузку на сборщик мусора (garbage collector), что может проявляться как кратковременные подтормаживания анимации.
- splice() физически сдвигает элементы массива — операция с линейной сложностью относительно размера массива, что при частых удалениях (а частицы умирают постоянно) складывается в заметные затраты.
Вместо того чтобы уничтожать "умершую" частицу и создавать новую, мы переиспользуем тот же объект, просто сбрасывая его параметры:
const POOL_SIZE = 500; const pool = []; for (let i = 0; i < POOL_SIZE; i++) { pool.push({ x: 0, y: 0, vx: 0, vy: 0, life: 0, active: false }); } function spawnParticle(x, y) { const p = pool.find(particle => !particle.active); // ищем свободную частицу в пуле if (!p) return; // пул исчерпан — пропускаем p.x = x; p.y = y; p.vx = (Math.random() - 0.5) * 2; p.vy = -Math.random() * 2; p.life = 1.0; p.active = true; }
Теперь вместо particles.splice(i, 1) при "смерти" частицы достаточно пометить её как неактивную:
if (p.life <= 0) { p.active = false; // частица не удаляется — просто становится доступной для переиспользования }
А в update() и render() перебираются только активные частицы (if (!p.active) continue;).
Такой подход требует немного больше кода на старте (нужно заранее выделить пул фиксированного размера), но полностью исключает создание новых объектов и удаление из массива во время выполнения анимации — что особенно важно, если частиц одновременно на экране сотни или тысячи.
