Розділ 4. Системи частинок

Це розумно. Якщо звернутися до логіки,

вона чітко підказує, що потреби багатьох

переважають потреби кількох.

— Спок

Позитрон (фото Карла Д. Андерсона)
Позитрон (фото Карла Д. Андерсона)

Ця фотографія початку 20-го століття зроблена камерою Вільсона (або туманною камерою), вперше зафіксувала спостережуваний позитрон і дозволяє зазирнути у світ субатомних частинок. Хмарні камери — це пристрої, які роблять видимими траєкторії заряджених частинок, коли вони рухаються крізь перенасичену пару.


У 1982 році Вільям Т. Рівз, дослідник із Lucasfilm Ltd., працював над фільмом Зоряний шлях 2: Гнів Хана. Значна частина фільму обертається навколо пристрою Генезис — торпеди, якою можна вистрілити у безплідну планету, щоб реорганізувати її матерію і створити придатний для колонізації світ. У фільмі під час “тераформування” планетою пройшлась велика вогняна хвиля. Термін система частинок був придуманий при створенні саме цього конкретного ефекту і наразі являє собою надзвичайно поширену і корисну техніку у комп’ютерній графіці. Як сказав Рівз:

Система частинок — це сукупність багатьох дрібних частинок, які разом представляють нечіткий об’єкт. Протягом певного часу частинки генеруються у системі, рухаються і змінюються всередині неї та вмирають, зникаючи із системи.

З початку 1980-х років системи частинок використовувалися у безлічі відеоігор, анімацій, творів цифрового мистецтва та інсталяцій для моделювання різноманітних нерегулярних природних явищ, таких як вогонь, дим, водоспади, туман, трава, бульбашки тощо.

Цей розділ присвячено розгляду стратегій програмування системи частинок і керування пов’язаними з нею даними. Як ви організуєте свій код? Де будете зберігати інформацію, пов’язану з окремими частинками та інформацію, що стосується системи в цілому? У прикладах, які я розгляну, для частинок використовуватимуться прості точки й застосовуватимуться лише найпростіші моделі поведінки. Однак ці характеристики не повинні обмежувати вашу уяву. Те, що системи частинок зазвичай виглядають як іскри, летять вперед чи падають під впливом гравітації, не означає, що ваша система також повинна мати такі характеристики. Спираючись на структуру цього розділу і додаючи більш творчі способи візуалізації частинок та обчислення їх поведінки, ви можете досягти різноманітних ефектів.

Іншими словами, фокус цього розділу полягає в тому як слідкувати за системою з багатьох елементів. Що ці елементи робитимуть і як вони виглядатимуть, залежить виключно від вас.

Чому системи частинок важливі

Система частинок — це сукупність незалежних об’єктів, часто представлених точками або іншими простими формами. Але чому це має значення? Звісно, перспектива моделювання деяких із перерахованих явищ є привабливою і потенційно корисною. Загалом, коли ви почнете розробляти складніші симуляції, ви, ймовірно, виявите, що працюєте з системами із багатьма об’єктами: групи з кульками, зграї птахів, еволюція екосистем, усілякі речі у множині. Обговорювані тут стратегії із системами частинок допоможуть вам у всіх цих ситуаціях.

Насправді майже кожен розділ, починаючи з цього, містить програми зі списками об’єктів, і це по суті саме те, що являє собою система частинок. Так, в деяких прикладах попередніх розділів, я вже використовував масиви. Але тепер настав час піти туди, куди раніше не заходив жоден масив (принаймні, у цій книзі).

По-перше, я хочу налаштувати гнучку кількість елементів. Деякі приклади можуть мати нуль речей, іноді одну річ, іноді десять речей, а інколи десять тисяч речей. По-друге, я хочу скористатися складнішим об’єктно-орієнтованим підходом. Окрім написання класу для опису окремої частинки, я також хочу написати клас, який описуватиме всю колекцію частинок — саму систему частинок. Мета полягає в можливості написати програму, яка виглядатиме якось так:

let system;

Ах, хіба ця основна програма не проста і мила?


function setup() {

  createCanvas(640, 360);

  system = new ParticleSystem();

}


function draw() {

  background(255);

  system.run();

}

У цьому коді не згадується жодна частинка, але результат буде сповнений частинок, що літають по всьому полотну. Це працює, тому що деталі приховані всередині класу ParticleSystem, який містить у собі посилання на безліч екземплярів класу Particle. Звикання до цієї техніки написання програм із кількома класами, включаючи класи, які зберігають списки екземплярів інших класів, виявиться дуже корисним, коли ви дійдете до наступних розділів цієї книги.

Нарешті, робота з системами частинок — це також можливість використати ще дві складові ООП: наслідування (успадкування) і поліморфізм. У прикладах, які ви бачили дотепер, я завжди використовував масив одного типу об’єктів, як-от масив кульок або масив осциляторів. З успадкуванням і поліморфізмом я продемонструю зручний спосіб використання єдиного списку для зберігання об’єктів різних типів. Таким чином, система частинок вмітиме працювати з частинками різних типів.

Окрема частинка

Перш ніж я зможу розпочинати кодування самої системи частинок, мені потрібно написати клас для опису окремої частинки. Гарна новина: я це вже зробив! Клас Mover з Розділу 2 слугує ідеальним шаблоном. Частинка — це незалежне тіло, яке рухається по полотну, тому як і об’єкт Mover, вона має властивості position, velocity і acceleration, конструктор для ініціалізації цих змінних й методи show() та update() для відображення себе та оновлення свого положення:

class Particle {

  constructor(x, y) {
    this.position = createVector(x, y);
    this.acceleration = createVector();
    this.velocity = createVector();
  }

Клас Particle – це просто інша назва для Mover. Він має положення, швидкість і прискорення.


  update() {

    this.velocity.add(this.acceleration);

    this.position.add(this.velocity);

    this.acceleration.mult(0);

  }


  show() {

    stroke(0);

    fill(175);

    circle(this.position.x, this.position.y, 8);

  }

}

Це найпростіша частинка, наскільки можливо. Звідси я можу розвинути частинку у кількох напрямках. Я можу додати метод applyForce() для впливу на поведінку частинки (я ще зроблю це в майбутньому прикладі). Я також можу додати змінні для опису кольору і форми, або завантажити зображення p5.Image, щоб малювати частинку цікавішим способом. Однак наразі я зосереджусь лише на додаванні однієї додаткової деталі: тривалості життя.

Деякі системи частинок включають так званий емітер, який слугує джерелом частинок. Емітер (або інколи випромінювач) керує початковими налаштуваннями частинок: положенням, швидкістю тощо. Він може видавати одиничний викид частинок, безперервний потік або якусь варіацію цього. Нововведення полягає в тому, що частинки, народжені в емітері, не можуть існувати вічно. Якби вони існували постійно, то програма p5.js зрештою зупинилася б, оскільки число частинок з часом збільшиться до занадто громіздкої кількості. В міру народження нових частинок, старі необхідно видаляти, створюючи ілюзію нескінченного потоку частинок без шкоди для продуктивності програми.

Існує багато способів визначити, коли частинка готова до видалення. Наприклад, вона може “померти” при зіткненні з іншим об’єктом, або при виході за межі полотна. Наразі я вирішив надати частинкам змінну lifespan, яка діє як таймер. Він розпочнеться зі значення 255 і буде зменшуватися до 0 у міру роботи програми, після чого частинка вважатиметься мертвою. Ось доданий код у клас Particle:

class Particle {

  constructor(x, y) {

    this.position = createVector(x, y);

    this.acceleration = createVector();

    this.velocity = createVector();

    this.lifespan = 255;

Нова змінна для відстеження того, як довго частинка була “живою”. Вона починається зі значення 255 і зменшується до 0.

  }


  update() {

    this.velocity.add(this.acceleration);

    this.position.add(this.velocity);

    this.lifespan -= 2.0;

Зменшення тривалості життя.

  }


  show() {

    stroke(0, this.lifespan);
    fill(175, this.lifespan);

Оскільки життя знаходиться у діапазоні від 255 до 0, його значення також можна використати для альфа-каналу (прозорості).

    circle(this.position.x, this.position.y, 8);

  }

}

Оскільки значення lifespan знаходиться у діапазоні від 255 до 0, його можна також використати для альфа-каналу кольору точки, що відповідатиме за прозорість частинки. Таким чином, коли частинка стане мертвою, вона буквально згасне.

З додаванням властивості lifespan, мені знадобиться ще один метод, який повертатиме truetrue або falsefalse, щоб визначити чи частинка вже мертва. Це стане у пригоді, коли я напишу окремий клас для керування списком частинок. Написати цей метод досить легко: мені просто потрібно перевірити, чи значення lifespan менше за 0. Якщо так, повертаємо true, інакше — false:

  isDead() {

    if (this.lifespan < 0.0) {
      return true;
    } else {
      return false;
    }

Частинка мертва чи жива?

  }

Навіть ще простіше, я можу просто повернути результат булевого виразу!

    isDead() {

      return (this.lifespan < 0.0);

Частинка мертва чи жива?

    }

Перш ніж перейти до наступного кроку зі створенням багатьох частинок, варто витратити пару хвилин, щоб переконатися, що частинка працює правильно. Для цього я створю програму із відображенням одного об’єкта типу Particle за раз. Ось повний код із кількома невеликими доповненнями де частинці надається випадкова початкова швидкість, а також використовується метод applyForce() для імітації гравітації.

let particle;


function setup() {

  createCanvas(640, 360);

  particle = new Particle(width / 2, 20);

}


function draw() {

  background(255);

  particle.update();
  particle.show();

Дії окремої частинки.

  let gravity = createVector(0, 0.1);
  particle.applyForce(gravity);

Застосування сили тяжіння.

  if (particle.isDead()) {
    particle = new Particle(width / 2, 20);
    console.log("Particle dead!");
  }

Перевірка стану частинки і створення нової частинки.

}


class Particle {

  constructor(x,y) {

    this.position =  createVector(x, y);

    this.velocity = createVector(random(-1, 1), random(-2, 0));

Для демонстраційних цілей, частинка отримує випадкову швидкість.

    this.acceleration = createVector(0, 0);

    this.lifespan = 255.0;

  }


  update() {

    this.velocity.add(this.acceleration);

    this.position.add(this.velocity);

    this.lifespan -= 2.0;

    this.acceleration.mult(0);

  }


  show() {

    stroke(0, this.lifespan);

    fill(0, this.lifespan);

    circle(this.position.x, this.position.y, 8);

  }


  applyForce(force) {
    this.acceleration.add(force);
  }

Використання того ж фізичного моделювання, що і у попередніх розділах.


  isDead() {
    return (this.lifespan < 0.0);
  }

Перевірка стану частинки.

}

Для спрощення і тестування у цьому прикладі створюється лише одна частинка за раз. Кожного разу, коли частинка досягає кінця свого життя, змінна particle перезаписується новим екземпляром класу Particle. Це фактично замінює попередній об’єкт з частинкою. Важливо розуміти, що попередній об’єкт Particle не стільки видалено, скільки він більше не доступний або не використовується в коді. Програма, по суті, забуває про стару частинку і починає працювати зі щойно створеною.

Вправа 4.1

У класі Particle створіть метод run(), який одночасно оброблятиме методи update(), show() і applyForce(), замість безпосереднього їх використання у функції draw(). Які переваги та недоліки цього підходу?

Вправа 4.2

Додайте кутову швидкість (обертання) до частинки й створіть частинку не круглої форми, щоб це обертання було видно.

Масив частинок

Тепер, коли у мене є клас для опису окремої частинки, настав час для наступного великого кроку: як я можу відстежувати багато частинок, точно не знаючи наперед, скільки їх може бути в будь-який момент? Відповідь — за допомогою масиву JavaScript, структури даних, яка зберігає довільно довгий список значень. У JavaScript масив є об’єктом, створеним із класу Array, який має багато вбудованих методів. Ці методи забезпечують всю необхідну функціональність для обслуговування списку об’єктів Particle, включаючи додавання частинок, видалення або інші маніпуляції із ними. Для відновлення знань про масиви, перегляньте відповідну JavaScript документацію на сайті MDN Web Docs.

З початком роботи із масивами, я використовуватиму рішення з вправи 4.1 і припускатиму існування методу Particle.run(), який керує всіма функціями окремої частинки. Хоча цей підхід також має деякі недоліки, він зробить подальші приклади коду більш лаконічними. Для початку я використаю цикл for у функції setup(), щоб заповнити масив частинками, а потім використаю інший цикл for у функції draw() для запуску кожної частинки:

let total = 10;

let particles = [];

Почнемо з порожнього масиву.


function setup() {

  for (let i = 0; i < total; i++) {
    particles[i] = new Particle(width / 2, height / 2);
  }

Відомий вам спосіб доступу до елементів масиву за допомогою індексу та квадратних дужок: [i].

}


function draw() {

  for (let i = 0; i < particles.length; i++) {

    let particle = particles[i];

    particle.run();

  }

}

Цикл for усередині draw() показує, як викликати метод для кожного елемента масиву, звертаючись до кожного його індексу. Я ініціалізую змінну i значенням 0 і поступово збільшую її на 1, отримуючи доступ до кожного елемента масиву допоки i не досягне значення particles.length і таким чином досягне кінця. Як виявляється, є ще кілька способів зробити те ж саме. Це те, що я одночасно люблю і ненавиджу в програмуванні на JavaScript — він має багато різних стилів і варіантів, які можна використати. З одного боку, це робить JavaScript дуже гнучкою й адаптивною мовою, але з іншого боку, велика кількість варіантів може бути приголомшливою та призвести до значної плутанини під час навчання.

Розглянемо можливі варіанти ітерування по масиву:

  • Традиційний цикл for. Ймовірно до нього ви звикли найбільше, він відповідає синтаксису інших мов програмування, таких як Java та C.
  • Цикл for...in. Цей вид циклу дозволяє ітерувати всі властивості об’єкта. Він не особливо корисний для масивів, тому я не розглядатиму його тут.
  • Цикл forEach(). Це чудовий варіант, і я раджу з ним ознайомитися! Це приклад функції вищого порядку, про що я розповім пізніше у цьому розділі.
  • Цикл for...of. Це підхід, про який я розповім далі. При роботі з масивами об’єктів він надає чистий і лаконічний синтаксис порівняно з традиційним циклом for.

Ось як виглядає цикл for...of:

function draw() {

  for (let particle of particles) {

    particle.run();

  }

}

Щоб перекласти цей код у слова, скажіть each замість let та in замість of. Зібравши все разом, ви прочитаєте: “Для кожної частинки із масиву частинок виконайте метод run()”. Метод run() у свою чергу оновить та відобразить кожну частинку.

Просто, елегантно, лаконічно, любо. Але перш ніж ви занадто захопитеся циклами for...of, зупиніться на мить і видихніть, оскільки я маю погану новину: вони не підійдуть для кожної ситуації. Так, я полюбляю цикли for...of і я використовуватиму їх у деяких наступних прикладах, де мені потрібно буде перебирати елементи у масиві, але не саме зараз. Зрештою, я хочу створити безперервний потік частинок з додаванням до масиву нових частинок кожного виконання функції draw() і видаленням із масиву старих частинок, після їх відмирання. Як ви незабаром побачите, саме тут цикл for...of мене підведе.

Створювати нову частинку на кожному кадрі легко: я можу просто викликати метод push() класу Array усередині функції draw(), щоб додати у кінець масиву новий об’єкт Particle. Це усуває необхідність попередньо створювати частинки у функції setup():

let particles = [];


function setup() {

  createCanvas(640, 360);

}


function draw() {

  background(255);

  particles.push(new Particle(width / 2, 20));

Новий об’єкт Particle додається до масиву кожного кадру під час виконання функції draw().


  for (let i = 0; i < particles.length; i++) {

    particles[i].run();

  }

}

Якщо запустити цей код на кілька хвилин, то ви побачите, що частота кадрів почне сповільнюватися все більше і більше, поки програма не зупиниться. У моїх тестах продуктивність суттєво знизилася вже через 15 хвилин. Проблема, звичайно, полягає в тому, що я додаю все більше і більше частинок без їх видалення.

Щоб виправити це, я можу використати метод splice(), щоб позбутися відмерлих частинок. Він видаляє один або кілька елементів з масиву, починаючи із заданого індексу. І саме тому я не можу використовувати тут цикл for...of, оскільки метод splice() потребує посилання на індекс частинки, яка видаляється, але цикли for...of не надають такої можливості. Тому натомість мені доведеться використовувати звичайний цикл for:

  for (let i = 0; i < particles.length; i++) {

    let particle = particles[i];

Призначення елемента масиву окремій змінній для покращення читабельності.

    particle.run();

    if (particle.isDead()) {

      particles.splice(i, 1);

Видалення частинки під індексом i.

    }

  }

Хоча цей код працюватиме нормально і не призведе до зупинки програми, я тим самим відкрив невелику банку з хробаками, намагаючись маніпулювати вмістом масиву під час ітерації через цей самий масив. Це просто пряме запрошення проблеми. Візьмемо, наприклад, наступний код:

  for (let i = 0; i < particles.length; i++) {

    let particle = particles[i];

    particle.run();

    particles.push(new Particle(width / 2, 20));

Додавання нової частинки до списку під час ітерації.

  }

Це дещо екстремальний сценарій (з недолугою логікою), але він підтверджує тезу. Проходячи кожен елемент списку цей код додає нову частинку до масиву, таким чином його довжина і властивість length постійно збільшується. Це призведе до нескінченного циклу, оскільки я ніколи не зможу дійти до кінця масиву!

Хоча видалення елементів із масиву під час циклу не призводить до збою програми (на відміну від додавання), ця проблема, можливо, більш підступна, оскільки не залишає слідів. Щоб виявити недолік, я повинен спочатку встановити важливий факт: коли елемент видаляється з масиву за допомогою методу splice(), всі наступні елементи зміщуються ліворуч. На малюнку 4.1 показано, що відбувається, коли частинка C (під індексом 2) видаляється. Частинки A і B зберігають той самий індекс, тоді як частинки D і E зсуваються з 3-го і 4-го на 2-й і 3-й індекси відповідно.

Малюнок 4.1: Коли елемент видаляється з масиву, наступні елементи зміщуються вліво, щоб заповнити порожнє місце
Малюнок 4.1: Коли елемент видаляється з масиву, наступні елементи зміщуються вліво, щоб заповнити порожнє місце

Розглянемо, що відбувається, коли лічильник i обходить елементи цього масиву:

iЧастинкаДія
0Частинка AНе видаляти!
1Частинка BНе видаляти!
2Частинка CВидалити! Переміщення частинок D і E зі слотів 3 і 4 у слоти 2 та 3.
3Частинка EНе видаляти!

Помітили проблему? Частинка D не перевірялася! Коли C видаляється зі слота 2, D займає його місце під індексом 2, але змінна i вже перейшла до слота 3. На практиці це, можливо, не катастрофічно, оскільки частинку D буде перевірено наступного разу під час виконання функції draw(). Проте, очікується, що код повинен проходити кожен елемент масиву. Пропускати елементи без причини неприпустимо!

Ця проблема має два вирішення. Перший — це ітерація масиву у зворотному порядку. Оскільки при видаленні елементи зміщуються справа наліво, таким чином неможливо пропустити елемент, як було перед цим. Ось як виглядає такий код:

  for (let i = particles.length - 1; i >= 0; i--) {

Ітерація масиву у зворотному напрямку.

    let particle = particles[i];

    particle.run();

    if (particle.isDead()) {

      particles.splice(i, 1);

    }

  }

Друге рішення полягає у використанні функції вищого порядку. Це функція, яка приймає іншу функцію як аргумент (або повертає функцію як результат своєї роботи). Масиви JavaScript використовують багато методів вищого порядку. Наприклад, одним з поширених є метод sort(), який приймає як аргумент функцію, яка визначає, як порівнювати два елементи масиву, а потім сортує масив відповідно до цього порівняння.

З масивом частинок я можу використовувати метод вищого порядку filter(). Ця функція вищого порядку приймає функцію, яка визначає певну умову, перевіряючи нею кожен елемент масиву. В результаті повертаються лише ті елементи, для яких задана умова повертає значення truetrue (виключаючи елементи, які повертають falsefalse):

  particles = particles.filter(function(particle) {

    return !particle.isDead();

Повернення частинок, які не відмерли!

  });

Зазвичай у JavaScript це записується у вигляді стрілкової нотації. Щоб дізнатися більше, перегляньте мій урок про функції вищого порядку та стрілкову нотацію.

  particles = particles.filter(particle => !particle.isDead());

Для цілей цієї книги я буду використовувати метод splice(), але раджу вам дослідити написання коду з використанням згаданих функцій вищого порядку.

let particles = [];


function setup() {

  createCanvas(640, 240);

}


function draw() {

  background(255);

  particles.push(new Particle(width / 2, 20));

  for (let i = particles.length - 1; i >= 0; i--) {

Перебір масиву у зворотному порядку із видаленням непотрібних елементів.

    let particle = particles[i];

    particle.run();

    if (particle.isDead()) {

      particles.splice(i, 1);

    }

  }

}

Вам може бути цікаво, чому замість того, щоб перевіряти кожну частинку окремо, я не видаляю просто найстарішу частинку після певного періоду часу (визначеного за допомогою перевірки змінної frameCount або довжини масиву). У цьому прикладі, де частинки вмирають у тому ж порядку, в якому вони народжуються, такий підхід дійсно спрацює. Я міг би навіть використати інший метод масиву під назвою shift(), який автоматично видаляє перший елемент масиву. Однак у багатьох системах частинок інші умови або взаємодії можуть призвести до того, що “молодші” частинки вмиратимуть раніше, ніж “старіші”. Перевірка isDead() у поєднанні зі splice() — це гарне комплексне рішення, яке забезпечує гнучкість керування частинками у різних сценаріях.

Емітер частинок

Я підкорив масив і використав його для керування списком об’єктів Particle з можливістю додавати та видаляти частинки за бажанням. Я міг би зупинитися на цьому і спочивати на лаврах, але я можу і повинен зробити додатковий крок: створити новий клас, який описуватиме сам список об’єктів Particle. На початку цього розділу, для представлення загальної колекції частинок, я використав спекулятивну назву класу ParticleSystem. Однак більш відповідним терміном для функціональності “емітування” (продукування) частинок є Emitter, який я відтепер і буду використовувати.

Клас Emitter дозволить мені спростити функцію draw(), прибравши громіздку логіку перебору всіх частинок. Як бонус, це також відкриє можливість до роботи з кількома емітерами частинок.

Згадайте, що одна з цілей, яку я поставив на початку цього розділу, полягала в тому, щоб написати функції setup() і draw() без посилань на окремі частинки. Ставлячи цю мету, я дражнив перспективою чудової простоти основного файлу програми. Ось ця програма знову, але вже з використанням нової назви класу Emitter замість ParticleSystem:

let emitter;

Лише один емітер частинок!


function setup() {

  createCanvas(640, 360);

  emitter = new Emitter();

}


function draw() {

  background(255);

  emitter.run();

}

Щоб дійти до цієї точки, подивіться на кожну частину функцій setup() і draw() з прикладу 4.2 та подумайте, як вони можуть вписатися у клас Emitter. Ніщо в поведінці коду не повинно змінюватися — єдина різниця полягає в організації коду:

Масив усередині функцій setup() і draw()Масив у класі Emitter


let particles = [];

function setup() {
  createCanvas(640, 240);
}

function draw() {
  particles.push(new Particle());



  let length = particles.length - 1;
  for (let i = length; i >= 0; i--) {
    let particles = particles[i];
    particle.run();
    if (particle.isDead()) {
      particles.splice(i, 1);
    }
  }
}

class Emitter {
  constructor() {
    this.particles = [];
  }




  addParticle() {
    this.particles.push(new Particle());
  }

  run() {
    let length = this.particles.length - 1;
    for (let i = length; i >= 0; i--) {
      let particle = this.particles[i];
      particle.run();
      if (particle.isDead()) {
        this.particles.splice(i, 1);
      }
    }
  }
}

Я міг би також додати нові можливості до самої системи частинок. Наприклад, для класу Emitter може бути корисним відстежувати початкову точку де народжуються частинки. Початкова точка може бути ініціалізована у конструкторі.

class Emitter {

  constructor(x, y) {

    this.origin = createVector(x, y);

Ця конкретна реалізація системи частинок включає початкову точку, з якої починається кожна частинка.

    this.particles = [];

  }


  addParticle() {

    this.particles.push(new Particle(origin.x, origin.y));

Початкова точка передається кожній частинці.

  }

}

Показаний приклад емітера являє собою статичне джерело частинок, що є гарним способом почати роботу з системами частинок. Однак, емітер може мати власну поведінку: відчувати фізику, коливатися, реагувати на дії користувача або проявляти будь-який інший вид руху, продемонстрований у попередніх розділах. З часом частинки можуть виходити з різних позицій, створюючи сліди або інші, складніші й цікавіші візерунки.

Вправа 4.3

Що, якщо випромінювач буде рухатися? Чи можете ви випускати частинки з позиції курсора або використовувати швидкість і прискорення, щоб рухати систему автономно?

Вправа 4.4

Спираючись на приклад гри Asteroids з Розділу 3, використовуйте систему частинок, щоб випускати частинки з двигунів корабля щоразу, коли застосовується сила тяги. Початкова швидкість частинок повинна бути пов’язана з поточним напрямком корабля.

Система емітерів

Поки що я описав окрему частинку та організував її код у класі Particle. Я також описав систему частинок і організував код у класі Emitter. Ця система частинок не що інше, як сукупність незалежних об’єктів Particle. Але якщо система частинок являє собою об’єкт класу Emitter, чому б мені не створити колекцію багатьох емітерів частинок: систему систем!

Я міг би розвинути цей напрямок думок ще далі, замкнувшись у підвалі на кілька днів і намалювавши діаграму системи систем системи систем у системі систем... допоки я не отримаю всю цю систему своєї системи. Зрештою, я можу описати світ схожим чином: орган — це система клітин, людське тіло — це система органів, район — це система людських тіл, місто — це система районів і так далі. Я ще не готовий зайти так далеко, але може бути корисно подумати, як написати програму для відстеження багатьох систем частинок, кожна з яких відстежує багато інших своїх частинок.

Розгляньте такий сценарій: ви починаєте з порожнього екрана (малюнок 4.2).

Малюнок 4.2: Початок з порожнім екраном

Ви клацаєте мишкою і створюєте систему частинок у позиції курсора (малюнок 4.3).

Малюнок 4.3: Додавання системи частинок

Ви продовжуєте клацати мишкою. Кожного разу там де ви клацнули, виникає інша система частинок (малюнок 4.4).

Малюнок 4.4: Додавання кількох систем частинок

Як це зробити? У прикладі 4.3 я завів змінну emitter для посилання на один об’єкт типу Emitter:

let emitter;


function setup() {

  createCanvas(640, 240);

  emitter = new Emitter(width / 2, 20);

}


function draw() {

  background(255);

  emitter.addParticle();

  emitter.run();

}

Тепер я назву змінну emitters у множині й зроблю її масивом, щоб я міг відстежувати кілька об’єктів типу Emitter. На початку програми масив порожній.

Приклад 4.4: Система систем

Клацання мишкою додаватиме новий емітер.
let emitters = [];

Цього разу тип елементів, які ми додаємо до масиву — це сам емітер частинок!


function setup() {

  createCanvas(640, 240);

}

Щоразу, коли відбувається клацання мишкою, створюється новий об’єкт типу Emitter, який додається у масив:

function mousePressed() {

  emitters.push(new Emitter(mouseX, mouseY));

}

Потім у функції draw(), замість посилання на один об’єкт типу Emitter, я тепер перебираю всі емітери й викликаю на кожному з них метод run():

function draw() {

  background(255);

  for (let emitter of emitters) {
    emitter.run();
    emitter.addParticle();
  }

Оскільки емітери тут не видаляються, тому можна використовувати цикл for...of!

}

Зверніть увагу, що я повернувся до використання циклу for...of, оскільки з масиву emitters не видаляються жодні елементи.

Вправа 4.5

Перепишіть приклад 4.4, щоб кожна система частинок не жила вічно. Встановіть обмеження на кількість частинок, які може створити окрема система. Потім, коли система частинок стане порожньою (більше не матиме частинок), видаліть її з масиву emitters.

Вправа 4.6

Створіть симуляцію об’єкта, що розбивається на багато частин. Як можна перетворити одну велику форму на багато малих частинок? Чи можете ви створити на екрані кілька великих форм, кожна з яких розбиватиметься при клацанні на них?

Наслідування і поліморфізм

Досі всі частинки в моїх системах були ідентичними, з однаковим виглядом і поведінкою. Хто сказав, що має бути тільки так? Використовуючи два фундаментальні принципи ООП, наслідування і поліморфізм, я можу створити системи частинок зі значно більшою різноманітністю.

Можливо, до цієї книги ви вже стикалися з цими двома термінами у своєму програмістському житті. Наприклад, в моїй книзі “Learning Processing” є цілий розділ (№22) присвячений їм. Можливо, ви вивчали наслідування і поліморфізм тільки на абстрактному рівні й ніколи не мали причин використовувати їх по-справжньому. Якщо це так, ви опинилися в потрібному місці. Без цих підходів ваша здатність програмувати різноманітні частинки та системи частинок вкрай обмежена. (У Розділі 6 я також покажу, як розуміння цих тем допоможе вам використовувати фізичні js-бібліотеки.)

Уявіть собі, що сьогодні суботній ранок. Ви щойно повернулися з чудової пробіжки, з’їли тарілку смачних пластівців і спокійно сидите за комп’ютером із чашкою теплого ромашкового чаю. У вашого старого друга день народження і ви вирішили зробити для нього вітальну листівку за допомогою p5.js. Як щодо імітації конфетті? Фіолетове конфетті, рожеве конфетті, конфетті у вигляді зірок, квадратне конфетті, швидке конфетті, миготливе конфетті — усі види конфетті, з різним виглядом і різною поведінкою, одночасно вибухають на екрані.

Набір окремих частинок конфетті — це явно кейс для системи частинок. Ви можете вміло переконструювати клас Particle, щоб в ньому були змінні для зберігання кольору, форми, поведінки тощо. Щоб створити різноманітні частинки, ви можете ініціалізувати ці змінні випадковими значеннями. Але що, якщо деякі з ваших частинок кардинально відрізняються? Вся ця історія може стати дуже безладною, якщо тримати всі варіації коду для різних частинок і їх особливостей в одному класі. Іншим підходом може бути наступне рішення:

class HappyConfetti {

}


class FunConfetti {

}


class WackyConfetti {

}

Це гарне рішення: створити три різних класи для опису різних типів конфеті, які є частиною вашої системи частинок. Тоді конструктор класу Emitter може містити код для випадкового вибору з трьох класів під час заповнення масиву (зверніть увагу, що цей спосіб випадкового вибору такий самим, який я використав у прикладах випадкових блукачів у Розділі 0):

class Emitter {

  constructor(num) {

    this.particles = [];

    for (let i = 0; i < num; i++) {

      let r = random(1);

      if (r < 0.33) {
        this.particles.add(new HappyConfetti());
      } else if (r < 0.67) {
        this.particles.add(new FunConfetti());
      } else {
        this.particles.add(new WackyConfetti());
      }

Випадковий вибір типу частинок.

    }

  }

Дозвольте мені на мить зупинитися. Ви не зробили нічого неправильного. Ви просто хотіли привітати свого друга з днем народження і насолодитися створенням коду. Але попри досить розумну аргументацію цього підходу, тут є проблема: чи не доведеться вам копіювати багато коду між різними класами конфетті?

Так, ймовірно, доведеться. Навіть якщо види частинок досить різні і є сенс винести їх в окремі класи, все одно між ними буде чимало спільного коду. Наприклад, усі вони матимуть вектори для відстеження положення, швидкості й прискорення, метод update() з реалізацією алгоритму руху та багато іншого.

Ось тут на сцену виходить наслідування. Наслідування дозволяє вам написати клас, який бере (успадковує) змінні та методи з іншого класу і при цьому може мати свої власні методи. Вам також може бути цікаво, чи спрацює додавання всіх цих різних типів конфетті в один масив particles. Все-таки я зазвичай не змішую різні види об’єктів в одному масиві, оскільки це може стати досить заплутаним. Як код у класі Emitter дізнається, яка частинка належить до якого виду конфетті? Чи не легше керувати окремими масивами?

 constructor() {

   this.happyParticles = [];

   this.funParticles = [];

   this.wackyParticles = [];

 }

Насправді розділяти частинки на різні масиви незручно. Єдиний масив для всіх частинок у системі буде набагато практичнішим. На щастя, можливість змішувати об’єкти різних типів в одному масиві є вбудованою особливістю JavaScript, а концепція поліморфізму дозволяє працювати зі змішаними об’єктами так, ніби вони належать до одного типу. Я можу наповнити масив різними видами частинок і кожна частинка все одно зберігатиме свою унікальну поведінку та характеристики, як визначено у відповідному класі.

Далі я детальніше проілюструю успадкування і поліморфізм, а потім створю систему частинок, яка включатиме ці концепції.

Основи наслідування

Щоб продемонструвати, як працює успадкування, я розгляну приклад зі світу тварин: собак, котів, мавп, панд, вомбатів, назвіть свій власний варіант. Я почну з класу Dog. Клас Dog матиме змінну age, а також методи eat(), sleep() і bark():

class Dog {

  constructor() {

    this.age = 0;

  }


  eat() {

    print("Ням!");

  }


  sleep() {

    print("Хррррр...");

  }


  bark() {

    print("ГАВ!");

  }

}

Тепер я створю кота:

class Cat {

  constructor() {
    this.age = 0;
  }

Собаки і коти мають багато спільних змінних (age) та методів (eat, sleep).


  eat() {

    print("Ням!");

  }


  sleep() {

    print("Хррррр...");

  }


  meow() {
    print("НЯВ!");
  }

У котів немає методу bark(), але натомість є унікальний метод для нявкання.

}

Коли я перейду до переписування того самого коду для риб, коней, коал і лемурів, цей процес стане досить нудним. Кращим рішенням буде розробка загального класу Animal, який може описувати будь-який тип тварин. Адже всі тварини їдять і сплять. Тоді я міг би сказати наступне:

  • Собака — це тварина, яка має всі властивості тварин та може робити все, що роблять тварини. Крім того, собака може гавкати.
  • Кішка — це тварина, яка має всі властивості тварин та може робити все, що роблять тварини. Крім того, кішка може нявкати.

Наслідування робить це можливим, дозволяючи класам Dog і Cat бути визначеними як діти (підкласи) класу Animal. Дочірні класи автоматично успадковують усі змінні й методи від свого батьківського класу (суперкласу), але вони також можуть включати методи та змінні, яких немає в батьківському класі. Подібно до філогенетичного дерева життя, успадкування схоже на структуру дерева (див. малюнок 4.2): собаки наслідують ссавців, які наслідуються від тварин і так далі.

Малюнок 4.2: Дерево наслідування
Малюнок 4.2: Дерево наслідування

Ось як працює синтаксис наслідування:

class Animal {

Клас Animal є батьківським класом (або суперкласом).

  constructor() {

    this.age = 0;

Dog і Cat успадкують змінну віку.

  }


  eat() {
    print("Ням!");
  }
  
  sleep() {
    print("Хррррр...");
  }

Dog і Cat успадкують методи eat() та sleep().

}


class Dog extends Animal {

Клас Dog є дочірнім класом або підкласом, що вказується за допомогою коду extends Animal.

  constructor() {

    super();

метод super() виконує код конструктора батьківського класу.

  }

  bark() {
    print("ГАВ!");
  }

Метод bark() визначено в дочірньому класі, оскільки він не є частиною батьківського класу.

}


class Cat extends Animal {

  constructor() {

    super();

  }

  meow() {

    print("НЯВ!");

  }

}

У цьому коді використовуються дві особливі можливості JavaScript. По-перше, зверніть увагу на ключове слово extends, яке вказує на батьківський клас для наслідування (розширення). Підклас може розширювати лише один суперклас. Однак класи можуть розширювати класи, які розширюють інші класи, наприклад Dog extends Animal, Terrier extends Dog. Все успадковується вниз по лінії від Animal до Terrier.

По-друге, зверніть увагу на виклик super() у конструкторах класів Dog і Cat. Таким чином викликається конструктор батьківського класу. Іншими словами, щоб ви не робили в батьківському конструкторі, виконайте це також і в дочірньому конструкторі. У цьому випадку це не є обов’язковим, але super() також може приймати аргументи, які відповідають аргументам батьківського конструктора, якщо такі були закладені.

Ви можете розширити підклас, щоб включити додаткові методи, окрім тих, що містяться в суперкласі. Тут я додав метод bark() до класу Dog і метод meow() до класу Cat. У конструктор підкласу ви також можете включити додатковий код, окрім виклику super(), щоб надати цьому підкласу додаткові змінні. Наприклад, припустимо, що окрім age, об’єкт типу Dog повинен мати змінну haircolor. Тепер клас виглядатиме так:

class Dog extends Animal {

  constructor() {

    super();

    this.haircolor = color(210, 105, 30);

Дочірній клас може додавати нові змінні, яких немає в батьківському.

  }


  bark() {

    print("ГАВ!");

  }

}

Зверніть увагу, що в першу чергу викликається батьківський конструктор через super(), який встановлює до змінної age значення 0, а потім змінна haircolor встановлюється всередині конструктора класу Dog.

Якщо об’єкт типу Dog їсть інакше, ніж описано в загальному класі Animal, батьківський метод можна перевизначити через створення власного визначення відповідного методу в середині підкласу:

class Dog extends Animal {

  constructor() {

     super();

     this.haircolor = color(210, 105, 30);

  }


  eat() {

Якщо необхідно, дочірній клас може перевизначити батьківський метод.

    print("Гав! Гав! Хльок! Хльок!")

Специфічні особливості харчування собаки.

  }


  bark() {

    print("ГАВ!");

  }

}

Але що, якщо собака їсть майже так само, як звичайна тварина, тільки з деякою додатковою особливістю? Підклас може запускати код із методу свого батьківського класу й одночасно включати власний код:

class Dog extends Animal {

   constructor() {

     super();

     this.haircolor = color(210, 105, 30);

   }


   eat() {

     super.eat();

Виклик батьківського методу eat() з класу Animal. Дочірній клас може виконувати методи батьківського класу.

     print("Гав!!!");

Додатковий код для специфічних дій притаманних харчуванню об’єкта Dog.

   }


   bark() {

    print("ГАВ!");

  }

}

Подібно до виклику super() в конструкторі, виклик super.eat() всередині методу eat() у класі Dog призводить до виклику методу eat() батьківського класу Animal. Після цього виклику метод підкласу можна продовжити з будь-яким додатковим кодом.

Основи поліморфізму

Ви застосували успадкування для створення купи різних підкласів. Тепер спробуйте уявити, як ваш код керуватиме цим різноманітним царством тварин із собаками, котами, черепахами й ківі, які бавляться довкола:

let dogs = [];
let cats = [];
let turtles = [];
let kiwis = [];

Окремі масиви для кожної тварини.


for (let i = 0; i < 10; i++) {

  dogs.push(new Dog());

}

for (let i = 0; i < 15; i++) {

  cats.push(new Cat());

}

for (let i = 0; i < 6; i++) {

  turtles.push(new Turtle());

}

for (let i = 0; i < 98; i++) {

  kiwis.push(new Kiwi());

}

На початку дня всі тварини дуже голодні й шукають їжу. Настав час для циклів:

for (let dog of dogs) {
  dog.eat();
}
for (let cat of cats) {
  cat.eat();
}
for (let turtle of turtles) {
  turtle.eat();
}
for (let kiwi of kiwis) {
  kiwi.eat();
}

Окремі цикли для кожної тварини.

Це працює, але з розширенням світу, при включенні більшої кількості видів тварин, ви застрягнете у написанні багатьох окремих циклів. Чи це дійсно необхідно? Зрештою, всі ці створіння є тваринами й всі вони полюбляють їсти. Чому б просто не створити один масив і заповнити його усіма видами тварин?

let kingdom = [];

Лише один масив для всіх тварин!


for (let i = 0; i < 10; i++) {

  kingdom.push(new Dog());

}

for (let i = 0; i < 15; i++) {

  kingdom.push(new Cat());

}

for (let i = 0; i < 6; i++) {

  kingdom.push(new Turtle());

}

for (let i = 0; i < 98; i++) {

  kingdom.push(new Kiwi());

}


for (let animal of kingdom) {
  animal.eat();
}

Один цикл для всіх тварин!

Це поліморфізм (від грецького polymorphos, що означає “багато форм”) у дії. Хоча всі тварини згруповані у масив і обробляються в одному циклі for, JavaScript може визначити їх справжні типи та викликати відповідний метод eat() для кожного з них. Все настільки просто!

Частинки з наслідуванням і поліморфізмом

Тепер, коли я розглянув теорію і синтаксис успадкування та поліморфізму, я готовий написати їх робочий приклад у p5.js на основі мого класу Particle. Спершу погляньте ще раз на базову реалізацію класу Particle адаптовану з прикладу 4.1:

class Particle {

  constructor(x, y) {

    this.acceleration = createVector(0, 0);

    this.velocity = createVector(random(-1, 1), random(-2, 0));

    this.position = createVector(x, y);

    this.lifespan = 255.0;

  }


  run() {

    this.update();

    this.show();

  }


  update() {

    this.velocity.add(this.acceleration);

    this.position.add(this.velocity);

    this.lifespan -= 2.0;

    this.acceleration.mult(0);

  }


  applyForce(force) {

    this.acceleration.add(force);

  }


  isDead() {

    return (this.lifespan < 0);

  }


  show() {

    fill(0, this.lifespan);

    circle(this.position.x, this.position.y, 8);

  }

}

Цей клас має змінні та методи, які повинен мати будь-який елемент системи частинок. Далі я створю підклас Confetti, який розширює клас Particle. Він використовує super(), щоб виконати код конструктора батьківського класу й успадковує більшість методів класу Particle. Однак я надам класу Confetti його власний метод show(), перевизначаючи батьківський, тому об’єкти типу Confetti будуть малюватися квадратами, а не кругами:

class Confetti extends Particle {

  constructor(x, y) {

    super(x, y);

    /* Тут я можу додати змінні притаманні лише для Confetti. */

  }


  /* Інші методи, такі як update(), успадковуються від батьківського класу. */


  show() {
    rectMode(CENTER);
    fill(0);
    square(this.position.x, this.position.y, 12);
  }

Перевизначення методу show().

}

Зробимо поведінку трохи складнішою. Скажімо, я хочу, щоб при польоті кожна частинка Confetti оберталася. Одним із варіантів є моделювання кутової швидкості та прискорення, як описано у Розділі 3. Однак для простоти я реалізую щось менш формальне.

Я знаю, що частинка має xx-позицію десь між 0 і шириною полотна. Що, якби я сказав, що коли xx-позиція частинки дорівнює 0, то її обертання має бути рівним 0. Коли ж положення xx дорівнює ширині, то обертання має дорівнювати 4π4\pi? Це вам щось нагадує? Як говорилося у Розділі 0, коли значення має один діапазон, який потрібно зіставити з іншим діапазоном, ви можете використовувати функцію map():

    let angle = map(this.position.x, 0, width, 0, TWO_PI * 2);

Ось як цей код вписується у метод show():

  show() {

    let angle = map(this.position.x, 0, width, 0, TWO_PI * 2);


    rectMode(CENTER);

    fill(0, this.lifespan);

    stroke(0, this.lifespan);

    push();
    translate(this.position.x, this.position.y);
    rotate(angle);
    rectMode(CENTER);
    square(0, 0, 12);
    pop();

Для обертання фігур у p5.js, потрібні певні трансформації. Щоб дізнатися більше, відвідайте сторінку https://thecodingtrain.com/transformations.

  }

Вибір 4π4\pi може здатися довільним, але це навмисно — два повних оберти додають частинці значний ступінь обертання порівняно з одним.

Вправа 4.7

Замість використання функції map() для обчислення angle, спробуйте змоделювати кутову швидкість і прискорення.

Тепер, коли у мене є підклас Confetti, який розширює базовий клас Particle, наступним кроком буде додавання об’єктів типу Confetti до масиву частинок, визначеного у класі Emitter.

class Emitter {

  constructor(x, y) {

    this.origin = createVector(x, y);

    this.particles = [];

Один масив для всіх частинок типу Particle або інших типів які розширюють клас Particle.

  }


  addParticle() {

    let r = random(1);

    if (r < 0.5) {
      this.particles.add(new Particle(this.origin.x, this.origin.y));
    } else {
      this.particles.add(new Confetti(this.origin.x, this.origin.y));
    }

Імовірність додавання кожного типу частинок становить 50%.

  }


  run() {

    for (let i = this.particles.length - 1; i >= 0; i--) {

      let particle = this.particles[i];

      particle.run();

      if (particle.isDead()) {

        this.particles.splice(i, 1);

      }

    }

  }

}

Чи ви бачите, як цей приклад використовує поліморфізм? Це те, що дозволяє об’єктам типу Particle і Confetti змішуватися в одному масиві particles в межах класу Emitter. Завдяки зв’язку через наслідування їх обидва можна розглядати як тип Particle, безпечно перебирати масив з ними й викликати для кожного об’єкта такі методи, як run() та isDead(). Разом успадкування і поліморфізм дозволяють керувати різними типами частинок, об’єднаних в одному масиві, незалежно від їх початкового класу.

Вправа 4.8

Створіть систему частинок із більш ніж двома типами частинок. Спробуйте додатково змінити поведінку частинок.

Системи частинок із силами

Поки що, для керування колекцією частинок, у цьому розділі я зосереджувався на структуруванні коду в об’єктно-орієнтованому стилі. Хоча я і залишив метод applyForce() у класі Particle, я зробив кілька скорочень, щоб спростити код. Тепер я поверну назад властивість mass, змінюючи у процесі методи constructor() і applyForce() (решта класу залишається незмінною):

class Particle {

  constructor(x, y) {

    this.position = createVector(x, y);

    this.velocity = createVector(random(-1, 1),random(-2, 0));

    this.acceleration = createVector(0, 0);

Тепер почнемо з прискорення (0, 0).

    this.lifespan = 255.0;

    this.mass = 1;

Додамо властивість маси. Спробуйте змінювати масу, щоб отримати різні цікаві результати!

  }


  applyForce(force) {

    let f = force.copy();
    f.div(this.mass);

Ділення сили на масу.

    this.acceleration.add(f);

	}

Тепер, коли клас Particle завершено, у мене є важливе запитання: де мені слід викликати метод applyForce()? Де в коді доцільно застосувати силу до частинки? На мою думку, тут немає правильної чи неправильної відповіді. Насправді все залежить від потрібної функціональності й цілей конкретної програми. Моїм швидким і простим рішенням у попередніх прикладах було просте створення та застосування сили gravity у методі run() для кожної частинки:

  run() {

    let gravity = createVector(0, 0.05);
    this.applyForce(gravity);

Створення захардкодженого вектора і застосування його як сили.

    this.update();

    this.show();

  }

Тепер я хотів би розглянути ширше й універсальніше рішення, яке дозволить застосовувати різні сили до окремих частинок у системі. Наприклад, що, якби для кожного кадру у функції draw() я прикладав глобальну силу до всіх частинок?

function draw() {

  background(255);


  /* Застосувати силу до всіх частинок? */


  emitter.addParticle();

  emitter.run();

}

Ой, здається є невелика проблема. Метод applyForce() написаний всередині класу Particle, але там немає посилань на окремі частинки, лише на emitter, об’єкт класу Emitter. Оскільки я хочу, щоб усі частинки отримували силу, я можу передати силу емітера і дозволити йому керувати всіма окремими частинками:

function draw() {

  background(255);


  let gravity = createVector(0, 0.1);

  emitter.applyForce(gravity);

Застосування сили до емітера.

  emitter.addParticle();

  emitter.run();

}

Звісно, якщо я викликаю метод applyForce() на об’єкті типу Emitter мені потрібно визначити цей метод у класі Emitter. Звичайною мовою цей метод повинен мати можливість отримати силу у вигляді p5.Vector і застосувати її до всіх частинок. Ось переклад цієї пропозиції у код:

  applyForce(force) {

    for (let particle of this.particles) {

      particle.applyForce(force);

    }

  }

Здається, що писати цей метод безглуздо. По суті код говорить: “Застосуйте силу до системи частинок, щоб система могла застосувати цю силу до всіх окремих частинок”. Хоча це може здатися дещо обхідним шляхом, цей підхід цілком розумний. Зрештою, саме емітер відповідає за керування частинками, тому, якщо ви хочете взаємодіяти з частинками, ви повинні взаємодіяти з ними через їх менеджера. (Також тут є можливість використати зручний цикл for...of, оскільки частинки не видаляються!)

Ось повний приклад із цією зміною. (Код припускає наявність класу Particle, написаного раніше, який немає сенсу показувати знову, оскільки в ньому нічого не змінилося.)

let emitter;


function setup() {

  createCanvas(640, 240);

  emitter = new Emitter(createVector(width / 2, 20));

}


function draw() {

  background(255);

  let gravity = createVector(0, 0.1);
  emitter.applyForce(gravity);

Застосування сили до емітера.

  emitter.addParticle();

  emitter.run();

}


class Emitter {

  constructor(x, y) {

    this.origin = createVector(x, y);

    this.particles = [];

  }


  addParticle() {

    this.particles.push(new Particle(this.origin.x, this.origin.y));

  }


  applyForce(force) {

    for (let particle of this.particles) {
      particle.applyForce(force);
    }

Використання циклу for...of для застосування сили до всіх частинок.

  }


  run() {

    for (let i = this.particles.length - 1; i >= 0; i--) {
      let particle = this.particles[i];
      particle.run();
      if (particle.isDead()) {
        this.particles.splice(i, 1);
      }
    }

Тут неможливо використати for...of через перевірку частинок на видалення.

  }

}

Хоча цей приклад має захардкоджену силу тяжіння, варто розглянути, як можна залучити інші сили з попередніх розділів, такі як вітер або опір. Ви також можете експериментувати з тим, як і коли застосовуються сили. Що якби замість сили, що постійно діє на частинки у кожному кадрі, сила діяла лише за певних умов або в особливі моменти? У проєктуванні систем з частинками багато простору для творчості та інтерактивних можливостей!

Системи частинок із відштовхувачами

Що якщо ми підемо ще далі зі своєю програмою і додамо об’єкт Repeller — протилежність об’єкта Attractor, описаного у Розділі 2, — який відштовхуватиме будь-які частинки, які знаходяться занадто близько? Це потребує трохи більшої складності аніж рівномірне застосування сили тяжіння, тому що сила, якою відштовхувач діє на конкретну частинку, є унікальною і повинна розраховуватися окремо для кожної частинки (див. малюнок 4.6).

Малюнок 4.6: Ліворуч — сила тяжіння, де всі вектори однакові. Праворуч — сила відштовхування, де всі вектори спрямовані у різні напрямки
Малюнок 4.6: Ліворуч — сила тяжіння, де всі вектори однакові. Праворуч — сила відштовхування, де всі вектори спрямовані у різні напрямки

Для включення у систему частинок нового об’єкта типу Repeller, мені знадобляться два важливі доповнення до коду:

  • Об’єкт типу Repeller (оголошений, ініціалізований і відображений)
  • Метод, який передає об’єкт типу Repeller у емітер частинок, щоб відштовхувач потім міг застосувати свою силу до кожної частинки
let emitter;

let repeller;

Новий код: оголошення змінної для зберігання об’єкту типу Repeller.


function setup() {

  createCanvas(640, 240);

  emitter = new Emitter(width / 2, 50);

  repeller = new Repeller(width / 2 - 20, height / 2);

Новий код: ініціалізація об’єкта типу Repeller.

}


function draw() {

  background(255);

  emitter.addParticle();

  let gravity = createVector(0, 0.1);

  emitter.applyForce(gravity);

  emitter.applyRepeller(repeller);

Новий код: метод для застосування сили відштовхувача.

  emitter.run();

  repeller.show();

Новий код: відображення об’єкта відштовхувача.

}

Створити клас Repeller просто — це дублікат класу Attractor прикладу 2.6 з Розділу 2. Оскільки в цьому розділі не розглядається концепція маси, я додам до класу Repeller властивість, яка називається power. Цю властивість можна використовувати для регулювання потужності сили відштовхування:

class Repeller {


  constructor(x, y)  {

    this.position = createVector(x, y);

Відштовхувач не рухається, тому нам потрібна лише позиція.

    this.power = 150;

Замість маси використовується концепція потужності для масштабування сили відштовхування.

  }


  show() {

    stroke(0);

    fill(127);

    circle(this.position.x, this.position.y, 32);

  }

}

Складніше завдання — це написання методу applyRepeller(). Замість передачі об’єкта p5.Vector аргументом, як у методі applyForce(), мені потрібно передати об’єкт Repeller у метод applyRepeller() і попросити цей метод обчислити силу між відштовхувачем та кожною частинкою. Подивіться на обидва ці методи поруч:

applyForce(force) {
  for (let particle of this.particles) {
    particle.applyForce(force);
  }
}
applyRepeller(repeller) {
  for (let particle of this.particles) {
    let force = repeller.repel(particle);
    particle.applyForce(force);
  }
}

Методи практично однакові, але мають дві відмінності. Одну з них я згадував раніше: аргумент методу applyRepeller() є об’єктом Repeller, а не p5.Vector. Друга відмінність важливіша. Для кожної частинки мені потрібно обчислити й застосувати власну силу p5.Vector. Як розраховується ця сила? Сила розраховується у методі repel() класу Repeller, який є зворотним до методу attract() з класу Attractor:

  repel(particle) {

Всі ті ж самі кроки для обчислення сили притягування, лише з протилежним напрямком.

    let force = p5.Vector.sub(this.position, particle.position);

Крок 1: Отримуємо напрямок сили.

    let distance = force.mag();
    distance = constrain(distance, 5, 50);

Крок 2: Отримуємо і обмежуємо відстань.

    let strength = -1 * this.power / (distance * distance);

Крок 3: Обчислюємо магнітуду, використовуючи змінну power.

    force.setMag(strength);
    return force;

Крок 4: Складаємо вектор із напрямку і магнітуди.

  }

Зауважте, що протягом всього цього процесу додавання відштовхувача у середовище я жодного разу не звертався до редагування класу Particle. Частинка не повинна нічого знати про деталі свого середовища, їй просто потрібно керувати своїм положенням, швидкістю і прискоренням, а також мати здатність сприймати зовнішню силу та реагувати на неї.

Тепер я готовий написати цей приклад повністю, знову оминувши клас Particle , який не змінився.

let emitter;

Один емітер частинок.

let repeller;

Один відштовхувач.


function setup() {

  createCanvas(640, 240);

  emitter = new Emitter(width / 2, 20);

  repeller = new Repeller(width / 2, 200);

}


function draw() {

  background(100);

  emitter.addParticle();

  let gravity = createVector(0, 0.1);
  emitter.applyForce(gravity);

Налаштування загальної гравітації.

  emitter.applyRepeller(repeller);

Застосування відштовхувача.

  emitter.run();

  repeller.show();

}


class Emitter {

Клас Emitter керує всіма частинками.

  constructor(x, y) {

    this.origin = createVector(x, y);

    this.particles = [];

  }


  addParticle() {

    this.particles.push(new Particle(this.origin.x, this.origin.y));

  }


  applyForce(force) {

    for (let particle of this.particles) {
      particle.applyForce(force);
    }

Застосування сили у вигляді вектора p5.Vector.

  }


  applyRepeller(repeller) {

    for (let particle of this.particles) {
      let force = repeller.repel(particle);
      particle.applyForce(force);
    }

Розрахунок сили для кожної частинки на основі відштовхувача.

  }


  run() {

    for (let i = this.particles.length - 1; i >= 0; i--) {

      let particle = this.particles[i];

      particle.run();

      if (particle.isDead()) {

        this.particles.splice(i, 1);

      }

    }

  }

}


class Repeller {

  constructor(x, y) {

    this.position = createVector(x, y);

    this.power = 150;

Потужність відштовхувача.

  }


  show() {

    stroke(0);

    fill(127);

    circle(this.position.x, this.position.y, 32);

  }


  repel(particle) {

    let force = p5.Vector.sub(this.position, particle.position);
    let distance = force.mag();

    distance = constrain(distance, 5, 50);
    let strength = -1 * this.power / (distance * distance);
    force.setMag(strength);
    return force;

Алгоритм цього методу майже такий самий, як у Розділі 2 де сила заснована на гравітаційному притяганні.

  }

}

Зверніть увагу на додавання змінної power у класі Repeller, яка є величиною відштовхуваної сили. Ця властивість стає особливо цікавою, коли ви маєте кілька притягувачів (атракторів) і відштовхувачів (репелерів), кожен з різними значеннями потужності. Наприклад, сильні атрактори й слабкі репелери можуть призвести до скупчення частинок навколо атракторів, тоді як більш потужні репелери можуть призвести до утворення деяких шляхів або каналів між ними. Це натяки на те, що вас очікує у Розділі 5, де я досліджуватиму концепцію складної системи.

Вправа 4.9

Розширте приклад 4.7, щоб включити в нього кілька репелерів і атракторів. Як ви можете використати наслідування і поліморфізм для створення окремих класів Repeller та Attractor без дублювання коду?

Вправа 4.10

Створіть систему частинок у якій кожна частинка реагує на кожну іншу частинку. (Як це можна зробити я також детально покажу в Розділі 5.)

Текстури зображення й адитивне змішування

Хоча ця книга майже повністю зосереджена на алгоритмах і поведінці, а не на комп’ютерній графіці та дизайні, я не думаю, що зміг би спокійно жити, якби завершив обговорення про системи частинок не глянувши хоча б раз на приклад, який включає текстурування частинок зображенням. Все-таки спосіб зображення частинок є ключовою складовою у головоломці створення певних типів візуальних ефектів. Наприклад, порівняйте дві симуляції диму, показані на малюнку 4.7.

Малюнок 4.7: Білі круги зліва і нечітке зображення кругів з прозорістю справа

Обидва зображення згенеровані ідентичними алгоритмами. Єдина різниця полягає в тому, що кожна частинка в анімації ліворуч намальована звичайним білим кругом, тоді як в анімації праворуч кожна частинка намальована нечітким кругом. На малюнку 4.8 показано обидва типи текстур для цих частинок.

Малюнок 4.8: Дві текстури зображень, повністю білий круг та нечіткий руго з розфокусованими краями
Малюнок 4.8: Дві текстури зображень, повністю білий круг та нечіткий круг з розфокусованими краями

Використання зображення для текстурування частинок може дати вам задешево багато можливостей для створення реалістичності візуального ефекту. Однак спочатку вам потрібно буде створити зображення необхідної текстури. Я рекомендую використовувати формат PNG, оскільки p5.js збереже його альфа-канал (відповідає за прозорість) при малюванні зображення, що необхідно для змішування текстур при накладанні частинок одна на одну. Ви можете створити ці текстури багатьма способами: ви навіть можете створити їх програмно за допомоги p5.js (я додам приклад на сайті книги), або ви можете використовувати інші безплатні чи комерційні інструменти редагування графіки.

Після створення PNG-файлу і розміщення його у теці data вашого проєкту, знадобиться додати лише кілька рядків коду.

Спочатку оголосіть змінну для зберігання зображення:

let img;

Потім завантажте зображення у функції preload():

function preload() {

  img = loadImage("texture.png");

Завантаження файлу PNG.

}

Під час малювання частинки використовуйте змінну img , замість малювання круга або прямокутника:

  show() {

    imageMode(CENTER);

    tint(255, this.lifespan);

Зверніть увагу, що функція tint() для зображення є еквівалентом функції fill() для фігур.

    image(img, this.position.x, this.position.y);

  }

Цей приклад із димом також є хорошою нагодою переглянути частину цієї книги про гауссівський розподіл, а саме “Нормальний розподіл випадкових чисел”. Замість запуску частинок у чисто випадковому напрямку, що створює фонтаноподібний ефект, результат виглядатиме більш схожим на дим, якщо значення початкових швидкостей векторів формуються переважно навколо середнього значення, з меншою ймовірністю значних відхилень.

Використовуючи функцію randomGaussian(), швидкості для частинок можна ініціалізувати наступним чином:

    let vx = randomGaussian(0, 0.3);

    let vy = randomGaussian(-1, 0.3);

    this.velocity = createVector(vx, vy);

Також у цьому прикладі я застосував до диму силу вітру, зіставлену з горизонтальним положенням курсора:

function draw() {

  background(0);


  let dx = map(mouseX, 0, width, -0.2, 0.2);
  let wind = createVector(dx, 0);

Напрямок сили вітру залежить від змінної mouseX.

  emitter.applyForce(wind);

  emitter.run();

  emitter.addParticle();

}

Крім розробки самої текстури, вам слід враховувати її роздільну здатність. Рендеринг великої кількості текстур високої роздільної здатності може значно вплинути на продуктивність програми, особливо якщо код має динамічно змінювати їх розмір. Найкращим сценарієм для оптимальної швидкості є підготовка текстури відповідного розміру до тої роздільної здатності, яку ви збираєтеся використовувати. Якщо вам потрібні частинки різного розміру, то в ідеалі ви повинні створити текстуру максимального розміру, яка може бути використана для будь-якої з частинок.

Вправа 4.11

Спробуйте створити інші текстури для різних типів ефектів. Чи можете ви зробити так, щоб ваша система частинок виглядала як вогонь, а не дим?

Вправа 4.12

Використайте масив зображень і застосуйте їх до різних об’єктів Particle. Деякі частинки малюватимуть одне й те саме зображення, але його достатньо завантажити лише один раз, а отже не потрібно викликати функцію loadImage() більше, ніж потрібно. (Досить одного разу для кожного файлу зображення!)

Також варто зазначити, що у комп’ютерній графіці існує багато різних алгоритмів змішування кольорів. Їх часто називають режимами змішування. Зазвичай, коли ви малюєте щось поверх чогось іншого в p5.js, ви бачите лише верхній шар — це типова поведінка для “змішування” без змішування взагалі. Коли ж пікселі мають значення альфа-прозорості (як у прикладі з димом), p5.js автоматично використовує алгоритм альфа-композиції, який на основі цих альфа-значень поєднує у відповідних відсотках пікселі фону з новими пікселями переднього плану.

Однак можна малювати за допомогою інших режимів змішування. Наприклад, дуже популярною технікою для систем частинок є адитивне змішування. Першопроходцем цього режиму був Роберт Ходжін у його класичному дослідженні системи частинок і сил Магнітосфери, яке згодом стало візуальною складовою для ранніх версій iTunes і відображенні анімованих візуальних ефектів, які реагували на звук.

Адитивне змішування — це простий алгоритм змішування, який додає значення пікселів одного шару до іншого, обмежуючи всі значення до 255. Це призводить до ефекту сяйва у космічному стилі, коли при додаванні більшої кількості шарів кольори стають все яскравішими і яскравішими.

Перш ніж щось малювати, встановіть режим змішування за допомогою функції blendMode():

function draw() {

  blendMode(ADD);

Використання адитивного змішування.

  clear();

Виклик функції clear() потрібен оскільки фон додається у результат і не перекриває раніше намальовану сцену.

  background(0);

Ефект світіння адитивного змішування не працюватиме з білим (або дуже яскравим) фоном.

  let dx = map(mouseX, 0, width, -0.2, 0.2);

  let wind = createVector(dx, 0);

  emitter.applyForce(wind);

  emitter.run();

  for (let i = 0; i < 3; i++) {
    emitter.addParticle();
  }

У цьому прикладі, для збільшення ефекту, замість додавання одної частинки за раз додаються одразу 3.

}

Адитивне змішування і система частинок дають можливість обговорити рендерери комп’ютерної графіки. Рендерер — це певна програма, яка відповідає за малювання на екрані (рендеринг). По замовчуванню бібліотека p5.js використовує стандартний рендерер для візуалізації 2D-зображень та анімації, який входить до складу сучасних веббраузерів і яким ви досі користувалися, не усвідомлюючи цього. Однак існує додатковий варіант рендерингу під назвою WEBGL. WebGL, що розшифровується як Web Graphics Library, є високопродуктивним рендерером на основі браузера для 2D та 3D графіки. Він використовує додаткові можливості, доступні у графічній карті вашого комп’ютера. Щоб увімкнути його, додайте третій аргумент до функції createCanvas().

function setup() {

  createCanvas(640, 240, WEBGL);

Увімкнення WEBGL рендерингу.

}

Як правило рендеринг WebGL необхідний лише якщо ви малюєте у своїй програмі 3D-форми. Однак навіть для двовимірних програм рендеринг WebGL може бути корисним у деяких випадках — в залежності від апаратного забезпечення комп’ютера і конкретних деталей вашої програми, він може значно покращити продуктивність малювання. Система частинок (особливо з увімкненим адитивним змішуванням) саме один із таких випадків, де у режимі WEBGL можна намалювати набагато більше частинок без сповільнення роботи програми. Майте на увазі, що режим WEBGL змінює початкову точку координат (0,0), зміщуючи її у центр полотна, на відміну від лівого верхнього кута як у звичайному режимі. Режим WEBGL також змінює поведінку деяких функцій і може вплинути на якість самого рендерингу. Крім того, деякі старіші пристрої або браузери не підтримують WebGL, хоча такі випадки трапляються рідко. Ви можете дізнатися більше інформації у відеороликах про WebGL на вебсайті Coding Train.

Вправа 4.13

У прикладі 4.9, щоб створити більш нашарований ефект, всередині функції draw() розміщено цикл for, який за один раз додає одразу по три частинки. Кращим рішенням було б змінити метод addParticle(), щоб саме він приймав аргумент кількості частинок, які слід додати, наприклад addParticle(3). Заповніть нове визначення цього методу. Зробіть так, щоб якщо не передається жодне значення, за замовчуванням додавалася лише одна частинка.

  addParticle(amount = 1) {

    for (let i = 0; i < amount; i++) {

      this.particles.push(new Particle(this.origin.x, this.origin.y));

    }

  }

Вправа 4.14

Використовуйте функцію tint() в поєднанні з адитивним змішуванням для створення ефекту веселки. Спробуйте змішування з іншими режимами, такими як: SUBTRACT, LIGHTEST, DARKEST, DIFFERENCE, EXCLUSION або MULTIPLY.

Проєкт “Екосистема”

Візьміть своє створіння з третього розділу і налаштуйте систему створінь. Як вони взаємодіють одне з одним? Чи можете ви використати наслідування і поліморфізм для створення різних створінь, що походять з одної кодової бази? Розробіть методологію того, як вони конкурують за ресурси (наприклад, за їжу). Чи можете ви відстежувати здоров’я створінь, так само як тривалість життя частинки, за необхідності видаляючи їх із системи? Які правила можна включити у систему для контролю народжуваності створінь?

(Для дизайну створіння ви також можете використати систему частинок. Що станеться, якщо емітер буде зв’язаний з положенням створіння?)