Эмиттеры и жизненный цикл
Сегодня твои частицы научатся рождаться, стареть и красиво угасать — а мёртвые перестанут засорять массив, и дымок будет вечно тянуться за бегущим цыплёнком.
Система частиц — это множество мелких объектов-частиц, у каждого своя короткая жизнь, а вместе они складываются в эффект: дым, искры, пыль. Сегодня мы добавим этим частицам главное, чего им не хватало, — возраст и смерть.
Зачем частице вообще умирать
В прошлом уроке про фейерверк ты уже раскидал над цыплёнком кучу искр: завёл массив, в цикле насыпал в него частицы, и каждый кадр пробегался по массиву и рисовал их. Красиво бахнуло один раз. Но если запустить тот фейерверк подольше, начинается странное.
Искры никуда не деваются. Они либо висят на экране вечно, либо улетают за край и продолжают жить там, невидимые, но всё ещё занимающие место в массиве. Добавляешь новые — массив пухнет. Сто искр, тысяча, десять тысяч. p5.js честно перебирает их все каждый кадр, скетч тормозит, ноутбук гудит как взлетающий самолёт. Знакомая история? Так же тормозит игра, в которой накопилось слишком много объектов, которые никто не убрал.
Проблема в том, что наши частицы — бессмертные. А настоящий дым, искры и пыль так не работают: они вспыхивают, живут долю секунды и тают. Сегодня мы дадим частице жизненный цикл: она родится полной сил, с каждым кадром будет понемногу стареть и тускнеть, а когда жизнь дойдёт до нуля — мы аккуратно вынем её из массива. Место освободится, скетч полетит, а эффект станет живым.
К концу урока за твоим цыплёнком будет тянуться настоящий дымок: пока он бежит, из-под него вылетают полупрозрачные облачка, всплывают, бледнеют и растворяются. Этот «генератор частиц» называют красивым словом эмиттер — точка, из которой непрерывно рождаются новые частицы.
Жизнь частицы — как заряд батареи телефона
Представь заряд батареи в телефоне. Утром — сто процентов. В течение дня он капает вниз: чуть-чуть на каждое действие. Дошло до нуля — телефон гаснет. Жизнь частицы устроена ровно так же.
Заведём у каждой частицы поле life — это её «заряд». Договоримся, что полная жизнь — это 255. Число не случайное: оно идеально совпадает с максимумом прозрачности (alpha), четвёртого параметра цвета. Это позволяет одним и тем же числом управлять сразу двумя вещами:
- Сколько частице осталось жить. Пока
lifeбольше нуля — частица живёт. Дошло до нуля — пора удалять. - Насколько она видна. Подставляем
lifeпрямо в alpha заливки. Полная жизнь (255) — частица яркая и плотная. Жизнь на исходе (около нуля) — она еле различима, призрак. Так частица не исчезает резко, а плавно растворяется, будто выдох на морозе.
Главный приём урока: одно число life отвечает и за «жива ли частица», и за «насколько она прозрачна». Стареет — значит, бледнеет. Поэтому смерть выглядит не как обрыв, а как мягкое угасание.А кто рождает частицы? Эмиттер — это просто точка на холсте (а в нашем случае — сам цыплёнок), которая на каждом кадре подбрасывает в массив одну-две свежие частицы с полным зарядом. Старые в это время тихо угасают и уходят. Получается вечный конвейер: на одном конце рождение, на другом — смерть, а в массиве всегда примерно одинаковое число живых частиц. Никакого бесконечного роста.
Разбираем на примерах
Пример 1. Одна частица, которая стареет
Прежде чем плодить стаю, разберёмся с одной-единственной частицей. Дадим ей жизнь и заставим бледнеть.
let life = 255; // полный заряд
let x = 200;
let y = 300;
function setup() {
createCanvas(400, 400);
}
function draw() {
background(30, 30, 40);
noStroke();
y = y - 1; // частица медленно всплывает
life = life - 2; // и стареет на 2 единицы за кадр
fill(255, 255, 255, life); // 4-й параметр — это life!
circle(x, y, 30);
}Результат: на тёмно-синем фоне появляется белое облачко, медленно всплывает вверх и одновременно тает — становится всё бледнее, пока не пропадает совсем где-то к верху холста. Дальше остаётся пустой фон.
Смотри, что происходит. Каждый кадр мы делаем два маленьких шага. y = y - 1 поднимает частицу на пиксель вверх (у нас экранный игрек растёт вниз, значит, вычитание — это движение вверх). life = life - 2 отщипывает от заряда две единицы. А ключевая строка — fill(255, 255, 255, life): четвёртым параметром мы суём туда же life. Пока заряд высокий, облачко плотное; заряд падает — облачко бледнеет. Когда life уходит в ноль и ниже, alpha становится нулевой, и частицу уже не видно.
Но есть нюанс: частица невидима, однако код всё ещё гоняет её каждый кадр. life уже −300, а мы продолжаем считать. Для одной частицы это пустяк, а для тысячи — та самая тормозящая беда. Значит, мёртвых надо не просто прятать, а по-настоящему удалять. Этим и займёмся.
Пример 2. Частица как объект и метод isDead()
Чтобы управлять сотнями частиц, удобно описать одну частицу как объект: пусть она сама хранит свою позицию, скорость и жизнь и сама умеет обновляться, рисоваться и сообщать, не померла ли. Используем класс — это как форма для печенья: один чертёж, а печений напечёшь сколько угодно.
class Particle {
constructor(x, y) {
this.pos = createVector(x, y);
this.vel = createVector(random(-1, 1), random(-2, -0.5)); // вверх и чуть в стороны
this.life = 255; // полный заряд
}
update() {
this.pos.add(this.vel);
this.life = this.life - 4; // стареет
}
show() {
noStroke();
fill(255, 255, 255, this.life); // прозрачность = жизнь
circle(this.pos.x, this.pos.y, 16);
}
isDead() {
return this.life < 0; // true, когда заряд кончился
}
}Результат: сам по себе класс ничего не рисует — это чертёж. Но теперь у каждой частицы есть всё своё: pos (где она), vel (куда летит, со случайным наклоном вверх), life (сколько осталось). И три понятных умения: update() — прожить кадр, show() — нарисоваться с учётом прозрачности, isDead() — честно ответить «я уже всё?».
Разберём метод isDead() — он сегодня главный герой. Он не делает ничего сложного: возвращает true, если this.life < 0, и false, пока заряд ещё держится. Это лакмусовая бумажка: спрашиваешь у частицы «ты мертва?» — она отвечает да или нет. А решать, что делать с мёртвой, будет уже эмиттер.
Пример 3. Эмиттер: рождаем и удаляем мёртвых
Вот сердце урока. Заведём массив частиц. Каждый кадр будем: подбрасывать пару новых, обновлять и рисовать всех, а мёртвых — вычищать. И всё это от точки, которую двигает мышь, — это и есть наш эмиттер.
let particles = [];
function setup() {
createCanvas(400, 400);
}
function draw() {
background(30, 30, 40);
// 1. ЭМИТТЕР: рождаем новые частицы у курсора
particles.push(new Particle(mouseX, mouseY));
particles.push(new Particle(mouseX, mouseY));
// 2. обновляем и рисуем всех — идём С КОНЦА массива
for (let i = particles.length - 1; i >= 0; i--) {
particles[i].update();
particles[i].show();
// 3. мёртвых вынимаем из массива
if (particles[i].isDead()) {
particles.splice(i, 1);
}
}
}Результат: за курсором мыши тянется живой шлейф из белых облачков. Двигаешь мышь — облачка вылетают следом, всплывают, бледнеют и растворяются. Стоит остановиться — шлейф собирается в облачко на месте и тает. И сколько ни води мышью, скетч не тормозит: мёртвые частицы исчезают из массива, а не копятся.
Тут три части, и каждая важна. Первая — две строки particles.push(...): это и есть эмиттер, он каждый кадр подсыпает в массив две свежие частицы прямо в точку курсора. Вторая — цикл, который обновляет и рисует каждую. Третья — проверка isDead() и splice(i, 1): эта команда вырезает из массива один элемент с индексом i. Мёртвую — за борт.
Обрати особое внимание на странность: цикл идёт задом наперёд, от particles.length - 1 к нулю. Это не каприз, а защита от коварного бага — про него отдельно в подводных камнях. Пока запомни как мантру: удаляешь из массива в цикле — иди с конца.
Пример 4. Дымок за цыплёнком
Соберём всё ради чего затевали. Пусть эмиттером будет не курсор, а сам бегущий цыплёнок: куда он, туда и шлейф дыма.
let particles = [];
let chickX = 0;
let chickY = 250;
function draw() {
background(135, 206, 235); // небо
chickX = chickX + 2; // цыплёнок бежит вправо
if (chickX > width) chickX = 0; // и снова слева
// ЭМИТТЕР у хвоста цыплёнка
particles.push(new Particle(chickX - 25, chickY));
for (let i = particles.length - 1; i >= 0; i--) {
particles[i].update();
particles[i].show();
if (particles[i].isDead()) {
particles.splice(i, 1);
}
}
// сам цыплёнок поверх дыма
noStroke();
fill(255, 221, 51);
circle(chickX, chickY, 50);
fill(255, 140, 0);
triangle(chickX + 22, chickY, chickX + 38, chickY - 5, chickX + 38, chickY + 5);
}Результат: по голубому небу слева направо катится жёлтый цыплёнок с оранжевым клювом, а из-под его хвоста тянется пушистый дымок: облачка всплывают, бледнеют и тают за спиной героя. Добежал до правого края — телепортируется налево и бежит снова, оставляя свежий след.
Единственное, что изменилось по сравнению с примером 3, — координата эмиттера. Вместо mouseX, mouseY мы рождаем частицы у хвоста: chickX - 25, chickY. Эмиттер «приклеен» к движущемуся герою. Вся остальная машинерия — рождение, старение, угасание, удаление мёртвых — работает один в один. В этом и красота: один раз написал жизненный цикл, а дальше просто переставляешь точку рождения куда захочешь.
Частые ошибки и подводные камни
Жизненный цикл частиц — место, где новички спотыкаются особенно часто и обидно. Разберём грабли по порядку.
1. Цикл по массиву идёт вперёд, а ты удаляешь элементы
Самая коварная ошибка. Кажется логичным писать обычный цикл for (let i = 0; i < particles.length; i++) и внутри вызывать splice. Но как только ты удалил элемент i, все элементы после него сдвигаются на одну позицию влево. Счётчик i++ прыгает на следующий индекс — и одну частицу ты перескочил, не обработав. На экране это выглядит как мерцание: часть частиц моргает или удаляется через раз.
Лекарство простое и его надо просто запомнить: когда удаляешь из массива внутри цикла, иди с конца к началу — for (let i = particles.length - 1; i >= 0; i--). Тогда сдвиг происходит позади курсора, среди уже обработанных элементов, и ничего не теряется.
2. Забыли удалять мёртвых — массив пухнет
Если оставить update() и show(), но выкинуть проверку isDead(), частицы перестанут исчезать из массива. Видно их уже не будет (alpha ушла в ноль), но p5.js продолжит обновлять и рисовать тысячи невидимых призраков каждый кадр. Сначала скетч просто чуть подлагивает, потом начинает заметно тормозить, а через минуту превращается в слайд-шоу. Правило: родил — позаботься похоронить. Эмиттер и чистильщик всегда работают в паре.
3. Прозрачность не работает, потому что фон не перерисовывается
Частица тает за счёт alpha, но если в начале draw() нет background(...), старые кадры не стираются. Полупрозрачные облачка накладываются друг на друга слой за слоем и в сумме дают плотную белую кашу вместо нежного дыма. Угасание просто не видно. Всегда начинай draw() с заливки фона — это стирает прошлый кадр начисто.
4. life уменьшается слишком быстро или слишком медленно
Шаг старения — это вкусовая настройка, и её легко проскочить. Вычитаешь по 20 за кадр — частицы гаснут почти мгновенно, дым выглядит рваным и жидким. Вычитаешь по 0.3 — живут так долго, что их накапливается тьма, и эффект превращается в плотное молоко. Покрути это число руками: начни с 4, как у нас, и подгоняй под вкус. Помни связь: чем дольше life, тем больше живых частиц одновременно в массиве.
5. Проверяют life > 0 вместо life < 0 при alpha
Тонкость: alpha не уходит «в минус» — значения ниже нуля p5.js просто считает нулём. Поэтому частица с life от 0 до примерно −4 уже невидима, но формально ещё «жива», если ты проверяешь life <= 0 слишком строго. Ничего страшного не ломается, но иногда удобнее дать частице досчитать чуть за ноль (isDead() через life < 0), чтобы наверняка пройти полное угасание до прозрачной. Главное — будь последователен: где уменьшаешь, там и проверяй один и тот же порог.
Мини-проект: костёр под цыплёнком
Пора собрать свой эмиттер. Возьми за основу пример с дымком и преврати его в живой костерок, у которого греется цыплёнок. Чек-лист:
- Поставь цыплёнка неподвижно по центру снизу, а эмиттер — прямо перед ним, где «горит огонь». Каждый кадр рождай 3–4 частицы из одной точки.
- Огненный цвет. Вместо белого сделай частицы тёплыми:
fill(255, random(120, 200), 0, this.life)— смесь красного, случайного зелёного и нуля синего даст оттенки от красного до оранжевого. Огонь заиграет. - Разлёт. Задай скорость так, чтобы искры летели вверх и чуть в стороны (вспомни
createVector(random(-1, 1), random(-3, -1))), — пламя станет узким снизу и широким сверху. - Размер от жизни. Сделай диаметр частицы зависящим от
life: молодая искра крупнее, угасающая — мельче. Подставь вcircle()что-то вродеthis.life / 20вместо фиксированного числа. - Бонус — ветер. Добавь всем частицам каждый кадр крошечный сдвиг вбок (
this.pos.x += 0.3), и пламя «поведёт» в сторону, будто дует ветер. Поиграй с величиной и знаком.
Запусти и поменяй пару чисел — скорость старения, цвет, разлёт. Почувствуй, как одни и те же три шага (родить, состарить, удалить мёртвых) дают то дым, то огонь, то искры. Ты только что построил настоящий эмиттер, на котором держатся эффекты в любой игре.
Итоги
Сегодня твои частицы научились жить и умирать по-настоящему. Главное:
- Поле
life— это заряд частицы. Полная жизнь — 255, чтобы то же число шло в alpha. Каждый кадр уменьшаем его: частица стареет и одновременно бледнеет. - Угасание через прозрачность делает смерть мягкой:
fill(255, 255, 255, life)— и частица растворяется, а не исчезает рывком. - Мёртвых надо удалять из массива, а не просто прятать. Метод
isDead()отвечает «жива ли я», аsplice(i, 1)вынимает покойника — иначе массив пухнет и скетч тормозит. - Удаляешь в цикле — иди с конца:
for (let i = particles.length - 1; i >= 0; i--), иначе послеspliceперескочишь соседнюю частицу. - Эмиттер — точка, которая каждый кадр подбрасывает новые частицы. Перенеси её к цыплёнку — получишь шлейф, к огню — пламя, к месту взрыва — искры.
Теперь у тебя есть вечный конвейер: на одном конце рождение, на другом — смерть, а посередине — живой эффект, который не растёт без меры. В следующем уроке мы заставим эти частицы не просто лететь по прямой, а слушать силы — притягиваться, отталкиваться и закручиваться в вихри. Дымок цыплёнка превратится в настоящую погоду. До встречи!