Система частиц (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;).
Такой подход требует немного больше кода на старте (нужно заранее выделить пул фиксированного размера), но полностью исключает создание новых объектов и удаление из массива во время выполнения анимации — что особенно важно, если частиц одновременно на экране сотни или тысячи.