Розділ 6. Фізичні бібліотеки

Бібліотека означає віри акт

В якому покоління, що і досі темрявою оповиті

Засвідчують в пітьмі, що стануть свідками свого світанку.

— Віктор Гюго

Фото Аршії Урведжі Босе
Живі кореневі мости (фото Аршії Урведжі Босе)

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


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

  1. Дізналися про концепції зі світу фізики (що таке вектор, сила, хвиля тощо)
  2. Зрозуміли математику й алгоритми, що стоять за цими концепціями
  3. Реалізували ці алгоритми в p5.js з об’єктно-орієнтованим підходом, що дозволило вам створювати симуляції автономних керованих агентів

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

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

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

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

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

Навіщо використовувати фізичну бібліотеку?

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

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

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

  1. Як визначити, що дві фігури стикаються (або перетинаються)? Тут потрібен алгоритм виявлення зіткнень.
  2. Як визначити швидкості фігур після зіткнення? Тут потрібен алгоритм розв’язання зіткнень.

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

Малюнок 6.1: Два кола з радіусами r_1 і r_2 стикаються, якщо відстань між ними менша ніж r_1 + r_2
Малюнок 6.1: Дві кульки з радіусами r1r_1 і r2r_2 стикаються, якщо відстань між ними менша ніж r1+r2r_1 + r_2

Це досить легко, а як щодо обчислення швидкостей кульок після зіткнення? На цьому я збираюся завершити обговорення. Чому? Справа не в тому, що розуміння математики, яка стоїть за зіткненнями, не важлива або не цінна. (Насправді я включаю додаткові приклади на вебсайті, пов’язані із зіткненнями без використання фізичної бібліотеки.) Причина зупинки полягає в тому, що життя коротке! (Нехай це також буде причиною для вас, щоб вийти на вулицю і трохи прогулятися перш ніж сісти писати свою наступну програму.) Не слід очікувати, що ви опануєте кожний можливий аспект фізичного моделювання. І хоча ви можете насолоджуватися вивченням розв’язків зіткнень для кульок, це лише спонукатиме у вас бажання перейти далі до роботи з прямокутниками. А потім до багатокутників складнішої форми. А потім до фігур з криволінійними поверхнями. А потім до маятників, що стикаються з пружними пружинами. А потім, а потім, а потім...

Можливість включити у програму p5.js складніший функціонал, такий як зіткнення, і при цьому мати необхідний час на спілкування з друзями й родиною — ось для чого цей розділ. Люди витратили роки на розробку рішень такого роду проблем і чудові JavaScript-бібліотеки, такі як Matter.js та Toxiclibs.js є плодами цих зусиль. Тож немає потреби заново винаходити колесо, принаймні поки що.

На завершення, якщо ви описуєте ідею для програми p5.js і згадуєте слово зіткнення, то, ймовірно, настав час навчитися використовувати фізичний рушій.

Інші фізичні бібліотеки

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

Box2D розпочався як набір уроків з фізики, написаних Еріном Катто на C++ для конференції розробників ігор у 2006 році. З того часу Box2D перетворився на багатий і складний фізичний рушій з відкритим кодом. Його використовували у безлічі проєктах та іграх, особливо вдалими з яких є визнана нагородами Crayon Physics і неперевершений хіт Angry Birds.

Однією з важливих особливостей Box2D є те, що це справжній фізичний рушій: він нічого не знає про комп’ютерну графіку і світ пікселів, а натомість виконує всі вимірювання та обчислення в реальних одиницях, таких як метри, кілограми та секунди. Просто його “світ” (ключовий термін у Box2D) — це 2D площина з верхнім, нижнім, лівим і правим краями. Ви говорите йому щось на кшталт: “Гравітація світу становить 9.81 ньютона на кілограм, а коло з радіусом 4 метри й масою 50 кілограмів розташоване на висоті 10 метрів від нижнього краю землі”. Box2D потім повідомить вам такі речі, як: “Через секунду коло знаходиться на відстані 8 метрів від нижнього краю, через дві секунди на відстані 5 метрів...” і так далі.

Попри те, що це забезпечує надзвичайно точний і надійний фізичний рушій (дуже оптимізований і швидкий для проєктів на C++), він також вимагає багато складного коду для переведення взаємодії між фізичним світом і світом, який ви хочете намалювати — піксельним світом графічного полотна. Це створює величезне навантаження для кодера. Наскільки зможу, я продовжуватиму підтримувати для цієї книги набір прикладів, сумісних із Box2D (є кілька портів на JavaScript), але вважаю, що відносна простота роботи з бібліотеками типу Matter.js, яка є нативною для JavaScript і використовує пікселі як одиницю вимірювання, зробить мої приклади на p5.js більш інтуїтивно зрозумілими й дружніми.

Іншою відомою бібліотекою є p5play — проєкт ініційований Паоло Педерчіні та наразі очолюваний Квінтоном Ешлі, який був спеціально розроблений для розробки ігор. Бібліотека спрощує створення візуальних об’єктів, відомих як спрайти й керує їх взаємодією, зокрема зіткненнями та перекриттями. Як ви, можливо, здогадалися з назви, p5play створена для зручної роботи з p5.js. Для симуляції фізики вона під капотом використовує Box2D.

Імпорт бібліотеки Matter.js

За мить я перейду до роботи з Matter.js, створеної Ліамом Броммітом у 2014 році. Але перш ніж ви зможете використовувати зовнішню бібліотеку JavaScript, вам потрібно імпортувати її у свій проєкт. Як ви вже добре знаєте, для розробки та поширення прикладів коду цієї книги, я використовую офіційний вебредактор p5.js. Найпростіший спосіб додати бібліотеку — відредагувати файл index.html, який є частиною кожної нової програми p5.js, створеної у редакторі.

Для цього спочатку розгорніть панель навігації файлів у лівій частині редактора і виберіть index.html, як показано на малюнку 6.2.

Файл містить послідовність тегів <script> усередині HTML тегів <head> і </head>. Саме так в проєкті p5.js підключаються JavaScript-бібліотеки. Це практично нічим не відрізняється від підключення файлів sketch.js або particle.js у <body>, тільки тут, замість збереження і редагування копії самого JavaScript коду, на код бібліотеки відбувається посилання через URL-адресу мережі доправлення вмісту (CDN). Це тип сервера для розміщення файлів. Для JavaScript-бібліотек, які використовуються на сотнях тисяч вебсторінок, до яких мають доступ мільйони користувачів, CDN-сервери повинні дуже добре справлятися зі своєю роботою передачі цих файлів.

Малюнок 6.2: Доступ до файлу index.html програми
Малюнок 6.2: Доступ до файлу index.html

Ви вже повинні були побачити тег <script> із посиланням на CDN для самого p5.js (на час вашого читання це може бути новіша версія):

<script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.9.0/p5.js"></script>

Для підключення Matter.js додайте ще один тег <script> із посиланням на його CDN прямо під підключенням p5.js:

<script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.9.0/p5.js"></script>

<script src="https://cdnjs.cloudflare.com/ajax/libs/matter-js/0.19.0/matter.min.js"></script>

На момент написання цього тексту останньою версією Matter.js була 0.19.0, саме та версія, яку я використав у наведеному вище фрагменті. При оновленнях Matter.js і випуску нових версій часто доцільною ідеєю є оновити бібліотеку проєкту, але посилаючись на конкретну версію про яку ви знаєте, що вона працює з вашим кодом, ви не повинні турбуватися, що нові функції бібліотеки порушать ваш поточну програму.

Огляд Matter.js

Коли ви використовуєте Matter.js (або будь-який інший фізичний рушій) у p5.js, ваш код виглядає дещо інакше. Ось узагальнений псевдокод усіх прикладів у розділах з 1-го по 5-й:

setup()

  1. Створення всіх об’єктів світу.

draw()

  1. Розрахунок усіх сил світу.
  2. Застосування всіх сил до об’єктів (F=MAF = M * A).
  3. Оновлення положення всіх об’єктів на основі їх прискорення.
  4. Малювання всіх об’єктів.

Для порівняння ось псевдокод для прикладу з Matter.js:

setup()

  1. Створення всіх об’єктів світу.

draw()

  1. Малювання всіх об’єктів.

Це, звісно, привабливість фізичного рушія. Я видалив усі ті болючі кроки обчислення того, як об’єкти рухаються відповідно до швидкості та прискорення. Matter.js подбає про це за мене!

Хоча буде ще багато деталей для розкриття, хороша новина полягає в тому, що простота цього псевдокоду точно відображає загальний процес. У цьому сенсі Matter.js трохи схожа на чарівну скриньку. У функції setup() я скажу до Matter: “Привіт! Ось усі речі, які я хочу у своєму світі”. А потім, у функції draw(), я ввічливо попрошу Matter: “О, привіт ще раз! Якщо це не дуже складно, я хотів би намалювати всі ці речі у своєму світі. Скажи мені, будь ласка, де вони знаходяться?”

Погана новина: це не так просто, як може здатися із псевдокоду. Насправді створення об’єктів, які потрапляють у світ Matter.js, включає кілька кроків, пов’язаних з тим, як будуються і налаштовуються різні типи фігур.

Також необхідно навчитися говорити мовою Matter.js щодо налаштувань різних сил та інших параметрів світу. Ось основні поняття:

  • Рушій (двигун): сутність, яка керує самою фізичною симуляцією. Рушій зберігає “світ” симуляції, а також різні властивості про те, як світ оновлюється з часом.
  • Тіла: слугують основними елементами світу, які відповідають фізичним об’єктам, що моделюються. Тіла мають положення і швидкість. Звучить знайомо? По суті, це ще одна версія класу, який я будував протягом усіх розділів з 1-го по 5-й. Тіла також мають геометрію для визначення їх форми. Важливо зауважити, що тіло — це загальний термін, який фізичні рушії використовують для опису речі у світі (подібно до терміну частинка), і це не має відношення до антропоморфного тіла.
  • Композит: контейнер, який дозволяє створювати складні сутності, що складаються з кількох тіл. Сам світ є прикладом композиту і кожне створене тіло має бути додане до світу.
  • Обмежувачі (обмеження): діють як з’єднання між тілами.

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

  • Вектор: описує об’єкт із магнітудою і напрямком за допомогою xyxy-компонентів, що визначають позиції, швидкості та сили у світі Matter.js.

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

Чудово, що p5.js має вбудоване векторне представлення, але кожного разу, коли ви користуєтеся фізичною бібліотекою, ви, швидше за все, виявите, що вона включає власну реалізацію вектора, розроблену для особливої сумісності з рештою коду бібліотеки. Це має сенс. Зрештою, чому Matter.js повинен знати про об’єкти p5.Vector?

У підсумку все зводиться до того, що вам не доведеться вивчати нові концепції, але доведеться звикнути до деяких нових конвенцій іменування і синтаксису. Для ілюстрації я покажу деякі вже знайомі вам операції класу p5.Vector поруч з еквівалентним кодом для Matter.Vector. Для початку поглянемо, як створити вектор:

p5.jsMatter.js
let v = createVector(1, -1);
let v = Matter.Vector.create(1, -1);

А як додати два вектори?

p5.jsMatter.js
let a = createVector(1, -1);
let b = createVector(3, 4);
a.add(b);
let a = Matter.Vector.create(1, -1);
let b = Matter.Vector.create(3, 4);
Matter.Vector.add(a, b, a);

Попередній приклад перезаписує вектор a отриманим результатом. А ось приклад як помістити результат операції в окремий вектор:

p5.jsMatter.js
let a = createVector(1, -1);
let b = createVector(3, 4);
let c = p5.Vector.add(a, b);
let a = Matter.Vector.create(1, -1);
let b = Matter.Vector.create(3, 4);
let c = Matter.Vector.add(a, b);

Як щодо масштабування вектора (множення на скалярне значення)?

p5.jsMatter.js
let v = createVector(1, -1);
v.mult(4);
let v = Matter.Vector.create(1, -1);
v = Matter.Vector.mult(v, 4);

Як отримати магнітуду і нормалізацію?

p5.jsMatter.js
let v = createVector(3, 4);
let m = v.mag();
v.normalize();
let v = Matter.Vector.create(3, 4);
let m = Matter.Vector.magnitude(v);
v = Matter.Vector.normalise(v);

Як бачите, поняття ті самі, але специфіка коду відрізняється. По-перше, кожному імені методу тепер передує запис Matter.Vector, що визначає простір імен вихідного коду. Це типово для JavaScript-бібліотек на відміну від p5.js. Наприклад, щоб намалювати круг у p5.js, ви просто викликаєте функцію circle(), а не p5.circle(). Функція circle() знаходиться в глобальному просторі імен. Це, на мій погляд, одна з особливостей, яка робить p5.js особливим з точки зору простоти використання та зручності для початківців. Однак це також означає, що при роботі з p5.js, ви не можете використовувати для своїх змінних назви подібні до circle. Простір імен бібліотеки захищає від такого роду помилок і конфліктів імен, тому ви побачите, що у Matter.js все викликається з префіксом Matter.

Крім того, на відміну від статичних і нестатичних методів векторів p5.js, таких як add() і mult(), усі векторні методи в Matter.js статичні. Якщо під час використання Matter.Vector ви хочете змінити вектор з яким працюєте, то можете додати його як необов’язковий третій аргумент: Matter.Vector.add(a, b, a) додає a і b та поміщає результат у a (третій аргумент). Ви також можете присвоїти результат, отриманий у результаті обчислення, вже наявній змінній: v = Matter.Vector.mult(v, 2). Однак це все одно створює новий вектор у пам’яті, а не оновлює старий.

У цьому розділі я розгляну деякі основні аспекти роботи з Matter.Vector, але для отримання більшої інформації, можна звернутися до повної документації на вебсайті Matter.js.

Рушій

Багато фізичних бібліотек включають особливий об’єкт для керування усією симуляцією, що називається світом. Світ, як правило, відповідає за координатний простір, веде список усіх тіл симуляції, контролює час тощо. У Matter.js світ створюється всередині об’єкта Engine — головному контролері симуляції вашого фізичного світу:

let Engine = Matter.Engine;

Посилання на Matter.Engine.


let engine;

Змінна для фізичного рушія Matter.js.


function setup() {

  createCanvas(640, 360);

  engine = Engine.create();

Створення рушія Matter.js.

}

Зверніть увагу, що перший рядок коду створює змінну Engine і призначає їй значення Matter.Engine. Тут я вирішив використати назву Engine, що посилатиметься на відповідний клас Engine у просторі імен Matter.js, щоб зробити свій код менш багатослівним. Це нормально, оскільки я знаю, що не використовуватиму слово Engine для інших змінних і воно не конфліктує з чимось із p5.js. У прикладах я буду робити це і для класів Vector, Bodies, Composite та інших. (Але хоча прикріплений вихідний код завжди включатиме усі потрібні посилання, я не завжди показуватиму їх у тексті книги.)

Коли ви викликаєте метод create() через Engine, Matter.js повертає новий фізичний рушій і світ з типовою гравітацією — вектором (0,1)(0,1), що вказує вниз. Ви можете змінити це дефолтне значення, звернувшись до властивості gravity і змінивши її значення:

  engine.gravity.x = 1;
  engine.gravity.y = 0;

Зміна гравітації рушія у горизонтальному напрямку.

Звісно, гравітація не мусить бути фіксованою протягом усієї симуляції. Ви можете змінювати вектор гравітації під час роботи програми. Ви також можете зовсім вимкнути гравітацію, встановивши для її вектора значення (0,0)(0,0).

Деструктуризація об’єкта

Деструктуризація об’єкта у JavaScript — це спосіб отримання властивостей об’єкта і присвоєння їх в окремі змінні. У випадку з Matter.js об’єкт Matter містить властивість Engine. Зазвичай посилання для цієї властивості можна встановити за допомогою запису let Engine = Matter.Engine, але з деструктуруванням це можна зробити більш лаконічно:

const { Engine } = Matter;

Зачекайте. Чи ви помітили, що я використав тут const? Я знаю, що сказав ще у Розділі 0, що для оголошення змінних у цій книзі використовуватиму лише let. Однак робота із зовнішньою бібліотекою — це дійсно хороший момент, щоб використати const. У JavaScript, const використовується для оголошення змінних, значення яких більше не повинні перепризначатись після ініціалізації. У цьому випадку я хочу захистити себе від випадкової можливості перезаписати змінну Engine пізніше у коді, що скоріш за все усе зламало б!

Тепер подивимось, як цей синтаксис деструктуризації справді виблискує, коли вам потрібно створити посилання для кількох властивостей одного об’єкта:

const { Engine, Vector } = Matter;

Використання деструктуризації об’єкта при створенні окремих посилань на Engine і Vector.

Це робить із змінних Engine і Vector посилання на Matter.Engine та Matter.Vector і все це в одному рядку. Я буду використовувати цей підхід і у подальших прикладах цього розділу.

Після ініціалізації світу в нього саме час щось додати — тіла!

Тіла

Тіло є основним елементом у світі Matter.js. Це еквівалент класу Vehicle, розвинутому на базі класу Particle, що виник на основі класу Mover, який я побудував у попередніх розділах — об’єкт, який рухається у просторі й відчуває силу. Тіло також може бути статичним (зафіксованим і нерухомим).

Тіла Matter.js створюються за допомогою фабричних методів класу Matter.Bodies, який має різні методи для створення різних типів тіл. Фабричний метод — це функція, яка створює об’єкт. Хоча ви, ймовірно, більше знайомі з викликом конструктора для створення об’єкта, наприклад з new Particle(), ви вже бачили фабричні методи раніше: createVector() це фабричний метод для створення об’єкта p5.Vector. Чи створюється об’єкт за допомогою конструктора, чи фабричного методу, залежить від стилю і вибору творця бібліотеки.

Усі фабричні методи для створення тіл можна знайти на сторінці документації Matter.Bodies. Я розпочну з методу rectangle():

let box = Bodies.rectangle(x, y, w, h);

Створення тіла Matter.js прямокутної форми.

Яка вдача! Сигнатура методу rectangle() точно така ж, як у функції rect() із p5.js. Однак у цьому випадку метод не малює прямокутник, а створює об’єкт геометрії класу Body. (Зауважте, що виклик Bodies.rectangle() спрацює лише якщо ви попередньо встановили посилання змінної Bodies на Matter.Bodies.)

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

let options = {
  friction: 0.5,
  restitution: 0.8,
  density: 0.002,
};

Налаштування властивостей для тіла у літералі об’єкта.

let box = Bodies.rectangle(x, y, w, h, options);

Кожен ключ у літералі об’єкта (наприклад, friction) служить унікальним ідентифікатором, а його значення (0.5) є даними, пов’язаними з цим ключем. Літерал об’єкта можна розглядати як простий словник або пошукову таблицю, що у цьому випадку зберігає бажані налаштування для нового тіла Matter.js. Зауважте, що хоча аргумент options корисний для налаштування тіла, інші початкові значення, такі як лінійна або кутова швидкість, можна встановити за допомогою статичних методів класу Matter.Body:

const v = Vector.create(2, 0);
Body.setVelocity(box, v);
Body.setAngularVelocity(box, 0.1);

Встановлення довільних значень для лінійної та кутової швидкостей.

Створення тіла і збереження його у змінній недостатньо. Будь-яке тіло має бути явно додано до світу, щоб його можна було симулювати за допомогою фізики. Фізичний світ — це об’єкт класу Composite, який називається world і зберігається всередині об’єкту engine. Додати до цього світу тіло box можна за допомогою статичного методу add():

Composite.add(engine.world, box);

Додавання об’єкту box до світу рушія.

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

Вправа 6.1

Знаючи те, що ви вже знаєте про Matter.js, заповніть пропуск у наведеному нижче коді, який демонструє, як створити кругле тіло:

let options = {

  friction: 0.5,

  restitution: 0.8,

};

let ball = Bodies.circle(x, y, radius, options);

Рендер

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

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

Matter.js включає досить простий і зрозумілий клас Render, неймовірно корисний для швидкого перегляду та налагодження спроєктованого світу. Для цілей відладки його можна налаштувати під свій смак, але стандартних параметрів цілком достатньо для швидкої перевірки вашого світу.

Першим кроком потрібно викликами Matter.Render.create() (або Render.create() при наявності змінної-псевдоніма). Цей метод очікує об’єкт із бажаними налаштуваннями для рендерера, який я назвав params:

let canvas = createCanvas(640, 360);

Збереження у змінній посилання на полотно.

let params = {
  canvas: canvas.elt,
  engine: engine,
  options: { width: width, height: height },
};

Налаштування параметрів для рендереру.

let render = Render.create(params);

Створення рендереру.

Зверніть увагу, що я зберігаю посилання на полотно p5.js у змінній canvas. Це необхідно, оскільки рендереру потрібно сказати на якому саме полотні йому потрібно малювати. Matter.js не знає про p5.js, але йому потрібно передати відповідне нативне HTML5-полотно, яке зберігається у властивості elt об’єкта полотна, створеного через p5.js. Рушій — це engine, який я створив раніше. Розміри полотна за замовчуванням у Matter.js становлять 800 на 600 пікселів, тому, якщо я віддаю перевагу іншому розміру, мені потрібно налаштувати властивість options з полями width і height.

Після створення об’єкта render мені потрібно запустити його:

Render.run(render);

Запуск рендерера!

Є ще один важливий робочий момент: фізичним рушіям потрібно повідомляти коли робити крок у часі. Оскільки я використовую вбудований рендерер, я також можу використати вбудований бігунець, який запускатиме рушій із типовою частотою кадрів, що за замовчуванням складає 60 кадрів на секунду. Бігунець також можна налаштовувати, але ці деталі наразі не дуже важливі, оскільки мета полягає в тому, щоб перейти замість нього до використання циклу draw() із p5.js (про це ще буде розказано далі).

Runner.run(engine);

Запуск рушія!

Ось весь код Matter.js разом із доданим об’єктом ground — ще одним прямокутним тілом. Зверніть увагу на використання опції { isStatic: true } для тіла ground, що забезпечує його фіксоване положення. Я розповім більше про статичні тіла пізніше у частині про “Статичні тіла Matter.js”.

const { Engine, Bodies, Composite, Body, Vector, Render } = Matter;

Зверніть увагу на створення посилань на класи Matter.js, які необхідні для цієї програми.


function setup() {

  let canvas = createCanvas(640, 360);

Збереження посилання на полотно.

  let engine = Engine.create();

Створення фізичного рушія.

  let render = Render.create({
    canvas: canvas.elt, engine,
    options: { width: width, height: height },
  });
  Render.run(render);

Створення рендерера і передача йому полотна p5.js.

  let options = { friction: 0.01, restitution: 0.75 };
  let box = Bodies.rectangle(100, 100, 50, 50, options);

Створення прямокутного тіла із власними значеннями тертя і віддачі.

  Body.setVelocity(box, Vector.create(5, 0));
  Body.setAngularVelocity(box, 0.1);

Встановлення початкової швидкості для тіла.

  Composite.add(engine.world, box);

Додавання тіла до світу.

  let ground = Bodies.rectangle(width / 2, height - 5,
                 width, 10, { isStatic: true });
  Composite.add(engine.world, ground);

Створення статичного тіла грунту.

  let runner = Matter.Runner.create();

Створення бігунця.

  Matter.Runner.run(runner, engine);

Запуск рушія.

}

Тут немає функції draw(), а всі змінні є локальними для setup(). Фактично я не використовую тут жодних можливостей p5.js (окрім додавання полотна на сторінку). Але саме цим я і збираюся зайнятися далі!

Matter.js з p5.js

Matter.js зберігає список усіх тіл, які існують у світі й, як ви щойно бачили, може малювати та анімувати їх за допомогою об’єктів Render і Runner. (Цей список тіл до речі зберігається в engine.world.bodies.) Однак зараз я хочу показати вам підхід зі збереження власного списку(ів) тіл Matter.js, щоб ви могли малювати їх за допомогою p5.js.

Так, цей підхід може додати надмірність і пожертвувати невеликою кількістю ефективності, але він з лишком компенсує це простотою використання і налаштування. За допомогою цієї методології ви зможете програмувати, як ви звикли в p5.js, відстежуючи потрібні тіла і малюючи їх відповідним чином. Розглянемо файлову структуру проєкту, показану на малюнку 6.3.

Малюнок 6.3: Файлова структура типового p5.js проєкту
Малюнок 6.3: Файлова структура типового p5.js проєкту

Структурно проєкт виглядає як ще одна програма p5.js. Є основний файл sketch.js, а також box.js. У такому додатковому файлі я зазвичай оголошую клас, потрібний для програми — у цьому випадку клас Box, який описує прямокутне тіло:

class Box {

  constructor(x, y) {

    this.x = x;
    this.y = y;
    this.w = 16;

Тіло має координати положення (x, y) і ширину.

  }


  show() {

    rectMode(CENTER);
    fill(127);
    stroke(0);
    strokeWeight(2);
    square(this.x, this.y, this.w);

Тіло малюється як квадрат за допомоги функції square().

  }

}

Тепер я напишу вміст файлу sketch.js який при клацанні мишки створюватиме новий об’єкт класу Box і зберігатиме всі об’єкти Box у масиві. (Це такий самий підхід, який я використовував у прикладах системи частинок із Розділу 4.)

let boxes = [];

Масив для зберігання всіх об’єктів Box.


function setup() {

  createCanvas(640, 360);

}


function draw() {

  background(255);

  if (mouseIsPressed) {
    let box = new Box(mouseX, mouseY);
    boxes.push(box);
  }

При клацанні мишки додаватиметься новий об’єкт Box.

  for (let box of boxes) {
    box.show();
  }

Відображення усіх об’єктів Box.

}

Зараз ця програма малює на екрані фіксовані квадрати. Ось моє завдання: як мені намалювати тіла, на які впливатиме фізика (розрахована за допомогою Matter.js), щойно вони з’являються на екрані, при цьому мінімально змінюючи код?

Для досягнення цієї мети мені потрібно виконати три кроки.

Крок 1: Додаємо Matter.js до програми p5.js

Наразі у програмі не має посилання на Matter.js. Це явно потрібно змінити. На щастя, ця частина не дуже складна: я вже продемонстрував усі елементи, необхідні для створення світу Matter.js. (І не забудьте переконатися, що бібліотека імпортована в index.html.)

Спершу мені потрібно додати посилання на необхідні класи Matter.js і створити у функції setup() об’єкт Engine:

const { Engine, Bodies, Composite } = Matter;

Посилання для Engine, Bodies і Composite.

let engine;

Для рушія тепер використовується глобальна змінна!


function setup() {

  engine = Engine.create();

Створення рушія.

}

Далі, у функції draw(), мені потрібно викликати один важливий метод з Matter.js — Engine.update():

function draw() {

  Engine.update(engine);

Оновлення рушія з плином часу!

}

Метод Engine.update() рухає фізичний світ на один крок вперед у часі. Його виклик усередині функції draw() забезпечує оновлення фізики на кожному кадрі анімації. Цей механізм замінює вбудований об’єкт Runner з Matter.js, який я використовував у прикладі 6.1. Тепер роль бігунця виконує функція draw()!

Внутрішньо, коли викликається Engine.update(), Matter.js проходиться світом, переглядає всі його тіла і вирішує, що з ними робити. Звичайний виклик методу Engine.update() рухає світ уперед зі стандартними параметрами. Але, як і у випадку з Render, ці параметри можна налаштувати, як зазначено у документації Matter.js.

Я налаштував свій світ Matter.js і тепер мені потрібно зв’язати кожен об’єкт Box у моїй програмі p5.js із тілом у цьому світі. Оригінальний клас Box містить змінні для позиції та ширини. Тепер я хочу сказати: “Я відмовляюся від керування позицією цього об’єкта на користь Matter.js. Мені більше не потрібно відстежувати нічого, що пов’язано з положенням, швидкістю чи прискоренням. Натомість мені потрібно лише відстежувати існування тіла Matter.js і вірити, що фізичний рушій зробить решту”.

class Box {

  constructor(x, y) {

    this.w = 16;

    this.body = Bodies.rectangle(x, y, this.w, this.w);

Замість звичайних значень змінних, збережемо посилання на тіло.

    Composite.add(engine.world, this.body);

Не забуваємо додати тіло до світу!

  }

Мені більше не потрібні змінні this.x і this.y для відстеження позиції. Конструктор Box отримує початкові xyxy-координати, передає їх у Bodies.rectangle() для створення нового тіла Matter.js, а потім забуває про них. Як ви побачите, тіло саме стежитиме за своїм положенням. Технічно тіло також може відстежувати свої розміри, але оскільки Matter.js зберігає їх у вигляді списку вершин, зручніше зберігати ширину квадрата у змінній this.w для використання її під час малювання тіла.

Крок 3: Малюємо тіло об’єкта

Майже готово. До того, як я додав у програму Matter.js, намалювати об’єкт Box було легко. Позиція об’єкта зберігалася у змінних this.x і this.y:

  show() {
    rectMode(CENTER);
    fill(127);
    stroke(0);
    strokeWeight(2);
    square(this.x, this.y, this.w);
  }

Малювання об’єкта за допомогою функції square().

Тепер, коли Matter.js керує положенням об’єкта, для малювання форми я вже не можу використовувати власні змінні x і y. Але не бійтеся! Об’єкт Box має посилання на пов’язане з ним тіло Matter.js і це тіло знає свою позицію. Мені потрібно лише ввічливо запитати тіло: “Вибачте, де ви розташовані?”:

let position = this.body.position;

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

let angle = this.body.angle;

Отримавши позицію і кут, я можу намалювати об’єкт за допомогою вбудованих функцій p5.js: translate(), rotate() і square():

  show() {

    let position = this.body.position;
    let angle = this.body.angle;

Нам потрібні положення і кут тіла.


    rectMode(CENTER);

    fill(127);

    stroke(0);

    strokeWeight(2);

    push();

    translate(position.x, position.y);
    rotate(angle);

Використовуємо положення і кут, щоб перемістити та повернути квадрат.

    square(0, 0, this.w);

    pop();

  }

Важливо зауважити, що якщо ви видаляєте об’єкти типу Box із масиву boxes, наприклад, коли вони виходять за межі полотна або завершують своє існування, як це було продемонстровано у Розділі 4, вам також потрібно явно видалити тіло, пов’язане з цим об’єктом Box зі світу Matter.js. Це можна зробити за допомогою методу removeBody() класу Box:

  removeBody() {
    Composite.remove(engine.world, this.body);
  }

Ця функція видаляє тіло зі світу Matter.js.

У функції draw() вам потім потрібно перебрати масив у зворотному порядку, так само як у прикладах із системою частинок, і викликати як removeBody() так і splice(), щоб видалити об’єкт зі світу Matter.js і з вашого масиву boxes.

Вправа 6.2

Розпочніть з коду прикладу 6.2 і, використовуючи методику описану у цьому розділі, додайте код для реалізації фізики Matter.js. Видаляйте тіла, які виходять за межі полотна. Результат має бути подібним до зображеного прикладу. Проявіть творчість у малюванні тіл!

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

Статичні тіла Matter.js

У прикладі, який я щойно створив, об’єкти Box з’являються у позиції курсора і падають вниз через вплив дефолтної гравітації. Що, якщо я хочу додати до світу нерухомі межі, які перекриватимуть шлях об’єктам Box? Matter.js дозволяє це легко зробити за допомогою властивості isStatic:

let options = { isStatic: true };
let boundary = Bodies.rectangle(x, y, w, h, options);

Створення фіксованого (статичного) тіла.

Я все ще створюю тіло за допомогою фабричного методу Bodies.rectangle(), але додана властивість isStatic гарантує, що тіло ніколи не буде рухатись. Я додам цей функціонал у програму на основі рішення вправи 6.2, створивши окремий клас Boundary, який пов’язує прямокутник p5.js зі статичним тілом Matter.js. Для різноманіття я також рандомізую розміри кожної квадратної коробки. (Перегляньте онлайн-код для ознайомлення зі змінами у класі Box.)

class Boundary {

  constructor(x, y, w, h) {

    this.x = x;
    this.y = y;
    this.w = w;
    this.h = h;

Межа — це простий прямокутник з xy-координатами, шириною і висотою.

    let options = { isStatic: true };

Фіксація тіла через властивість isStatic зі значенням true!

    this.body = Bodies.rectangle(this.x, this.y, this.w, this.h, options);

    Composite.add(engine.world, this.body);

  }


  show() {
    rectMode(CENTER);
    fill(127);
    stroke(0);
    strokeWeight(2);
    rect(this.x, this.y, this.w, this.h);

Оскільки тіло ніколи не рухатиметься, метод show() може намалювати його стандартним способом, використовуючи оригінальні змінні без отримання їх у Matter.js.

  }

}

Статичні тіла не включають властивостей матеріалу таких як restitution або friction. Переконайтеся, що ви встановили їх для динамічних тіл вашого світу.

Багатокутники та групи фігур

Тепер, коли я продемонстрував, наскільки легко з Matter.js використовувати примітивну форму, таку як прямокутник або круг, уявімо, що ви хочете використати цікавіше тіло, наприклад абстрактну фігуру як на малюнку 6.4.

Малюнок 6.4: Складне тіло, складене з декількох форм
Малюнок 6.4: Складне тіло, складене з декількох форм

Для створення таких складних форм можна використати дві стратегії. Загальний метод Bodies.polygon() може створити будь-який правильний багатокутник (п’ятикутник, шестикутник тощо). Також є метод Bodies.trapezoid() для створення трапеції — чотирикутника у якого одна пара сторін паралельна:

let hexagon = Bodies.polygon(x, y, 6, radius);

Гексагон (рівносторонній шестикутник).

let trapezoid = Bodies.trapezoid(x, y, width, height, slope);

Трапеція.

Більш універсальним методом є Bodies.fromVertices(). Він будує фігуру з масиву векторів, розглядаючи їх як послідовність з’єднаних вершин. Я інкапсулюю цю логіку у класі CustomShape.

class CustomShape {

  constructor(x, y) {

    let vertices = [];
    vertices[0] = Vector.create(-10, -10);
    vertices[1] = Vector.create(20, -15);
    vertices[2] = Vector.create(15, 0);
    vertices[3] = Vector.create(0, 10);
    vertices[4] = Vector.create(-20, 15);

Масив із 5 векторів.

    let options = { restitution: 1 };
    this.body = Bodies.fromVertices(x, y, vertices, options);

Створення тіла за допомогою вершин фігури.


    Body.setVelocity(this.body, Vector.create(random(-5, 5), 0));

    Body.setAngularVelocity(this.body, 0.1);

    Composite.add(engine.world, this.body);

  }

Створюючи власний багатокутник у Matter.js, ви повинні пам’ятати про два важливих моменти. По-перше, вершини повинні бути вказані в годинниковому порядку. Наприклад, на малюнку 6.5 показано п’ять вершин, використаних для створення тіл у прикладі 6.4. Зверніть увагу, що у коді прикладу вони додані до масиву vertices у годинниковому напрямку, починаючи з верхнього лівого кута.

Малюнок 6.5: Вершини багатокутника, спрямовані за годинниковою стрілкою
Малюнок 6.5: Вершини багатокутника, спрямовані за годинниковою стрілкою

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

Малюнок 6.6: Увігнуту фігуру можна намалювати кількома опуклими фігурам
Малюнок 6.6: Увігнуту фігуру можна намалювати, об’єднавши кілька опуклих фігур

Оскільки форма побудована з довільних вершин для малювання відповідного тіла ви зможете використовувати p5.js-функції beginShape(), endShape() і vertex(). Для цілей малювання клас CustomShape може містити масив для зберігання піксельних позицій вершин відносно до початкової точки (0,0)(0, 0). Однак краще дізнаватися позиції вершин через Matter.js. Таким чином не потрібно використовувати функції translate() чи rotate(), оскільки тіло Matter.js зберігає свої вершини як абсолютні позиції світу:

 show() {

    fill(127);

    stroke(0);

    strokeWeight(2);

    beginShape();

Початок малювання фігури.

    for (let v of this.body.vertices) {
      vertex(v.x, v.y);
    }

Перебір усіх вершини тіла.

    endShape(CLOSE);

Завершення фігури із замиканням форми.

  }

}

Тіло Matter.js зберігає масив позицій вершин у властивості vertices. Зверніть увагу, що я можу використовувати цикл for...of для проходження вершин між функціями beginShape() і endShape().

Вправа 6.3

Використовуючи метод Bodies.fromVertices(), створіть власну форму багатокутника (пам’ятайте, що він має бути опуклим). Деякі можливі варіанти показані нижче.

 
 

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

Я почну зі створення двох окремих тіл: прямокутника і круга. Потім я можу об’єднати їх, помістивши у масив parts і передавши його до методу Body.create():

let part1 = Bodies.rectangle(x, y, w, h);
let part2 = Bodies.circle(x, y, r);

Створення тіл.

let body = Body.create({ parts: [part1, part2] });

Об’єднання двох тіл у масиві.

Composite.add(engine.world, body);

Додавання складеного тіла до об’єкту світу.

Хоча це і створює складене тіло через поєднання двох форм, код не зовсім правильний. Якщо ви запустите його, то побачите, що обидві фігури відцентровані на одній і тій самій позиції (x,y)(x, y), як показано на малюнку 6.7.

Малюнок 6.7: Прямокутник і круг з однаковою опорною точкою (x, y)
Малюнок 6.7: Прямокутник і круг з однаковою опорною точкою (x,y)(x, y)

Натомість мені потрібно змістити центр кола по горизонталі відносно центру прямокутника, як на малюнку 6.8.

Малюнок 6.8: Круг, розміщений відносно прямокутника з горизонтальним зміщенням
Малюнок 6.8: Круг, розміщений відносно прямокутника з горизонтальним зміщенням

Для значення зсуву я використаю половину ширини прямокутника, щоб круг був вирівняний з краєм прямокутника:

let part1 = Bodies.rectangle(x, y, w, h);

let offset = w / 2;
let part2 = Bodies.circle(x + offset, y, r);

Додавання зміщення від x-позиції палички льодяника.

Оскільки тіло льодяника складається з двох частин, його малювання трохи складніше. Я можу використати кілька варіантів. Наприклад, я міг би використати масив вершин тіла vertices і намалювати льодяник як індивідуальну форму, подібно до прикладу 6.4. (Кожне тіло зберігає масив вершин, навіть якщо воно не створювалося методом fromVertices(). Але оскільки кожна частина льодяника є примітивною формою, я віддам перевагу окремому малюванню кожної частини через відносне переміщення їх у потрібне положення під потрібним кутом тіла.

  show() {

    let angle = this.body.angle;

Кут береться від складеного тіла.

    let position1 = this.part1.position;
    let position2 = this.part2.position;

Отримання позиції для кожної частини.

    fill(200);

    stroke(0);

    push();
    translate(position1.x, position1.y);
    rotate(angle);
    rectMode(CENTER);
    rect(0, 0, this.w, this.h);
    pop();

Переміщення і оберт прямокутника (перша частина тіла).

    push();
    translate(position2.x, position2.y);
    rotate(angle);
    circle(0, 0, this.r * 2);
    pop();

Переміщення і оберт круга (друга частина тіла).

  }

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

Щоб проілюструвати це, дозвольте мені повернутися до прикладу 6.5. Льодяник — складне тіло, що складається з двох частин — прямокутника (this.part1) і круга (this.part2). Я малюю кожен льодяник, отримуючи позиції для двох частин окремо: this.part1.position і this.part2.position. Однак загальне складене тіло також має позицію — this.body.position. Було б спокусливо використати його як положення для малювання прямокутника, а положення кола визначити вручну, використавши зміщення. Зрештою, саме так я уявляв складену форму спочатку (подивіться на малюнок 6.8):

  show() {

    let position = this.body.position;

Отримання положення сукупного тіла.

    let angle = this.body.angle;

    push();

    translate(position.x, position.y);

    rotate(angle);

    rect(0, 0, this.w, this.h);

    circle(0, this.h / 2, this.r * 2);

    pop();

  }

Малюнок 6.9 показує результат цієї зміни.

Малюнок 6.9 Що станеться, якщо фігури намальовані не так, як вони налаштовані для Matter.js

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

Вправа 6.4

Створіть власного маленького інопланетянина, використовуючи кілька фігур, прикріплених до одного тіла. Пам’ятайте, що ви не обмежені використанням лише базових функцій малювання фігур у p5.js, а можете використовувати зображення і кольори, малювати волосся за допомогою ліній тощо. Думайте про форми Matter.js як про кістяки для вашого оригінального фантастичного дизайну!

Обмежувачі Matter.js

Обмежувачі (обмеження) Matter.js — це механізм з’єднання одного тіла з іншим, що дозволяє моделювати коливальні маятники, пружні мости, м’які поверхні, об’єкти, які обертаються навколо вісі тощо. Існує три типи обмежувачів: обмежувач відстані й обмежувач обертання, які керуються класом Constraint, та обмежувач мишки, керований класом MouseConstraint.

Обмежувач відстані

Малюнок 6.10: Обмежувач — це з’єднання між двома тілами у якірній точці для кожного тіла
Малюнок 6.10: Обмежувач — це з’єднання між двома тілами у певних точках обох тіл

Обмежувач відстані — це зв’язок фіксованої довжини між двома тілами, подібно до того, як сила пружини з’єднує дві фігури у Розділі 3. Обмеження прикріплюється до кожного тіла у певній якірній точці відносно центру тіла (див. малюнок 6.10). Залежно від жорсткості обмеження, “фіксована” довжина може змінюватися, подібно до того, як пружина може бути більш або менш жорсткою.

Додавання обмеження використовує подібний підхід, що і створення тіл, але тут необхідно вже мати готові тіла, які потрібно обмежити. Припустимо, що є два об’єкти Particle, кожен з яких зберігає посилання на тіло Matter.js у властивості під назвою body. Я назву їх particleA і particleB:

let particleA = new Particle();

let particleB = new Particle();

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

  • bodyA: перше тіло, з яким з’єднується обмеження, формуючи один кінець обмеження.
  • bodyB: друге тіло, з яким з’єднується обмеження, утворюючи інший кінець.
  • pointA: позиція відносно bodyA, де обмеження прикріплюється до першого тіла.
  • pointB: позиція відносно bodyB, де обмеження прикріплюється до другого тіла.
  • length: цільова довжина обмеження або довжина у спокої. Під час симуляції обмеження намагатиметься підтримувати цю довжину.
  • stiffness: значення від 0 до 1, яке представляє жорсткість обмеження, де 1 означає повністю жорстке обмеження, а 0 — абсолютно м’яке.

Усі ці налаштування упаковуються у літерал об’єкта:

let options = {

  bodyA: particleA.body,

  bodyB: particleB.body,

  pointA: Vector.create(0, 0),

  pointB: Vector.create(0, 0),

  length: 100,

  stiffness: 0.5

};

Технічно обов’язковими параметрами є тільки bodyA і bodyB, два тіла, з’єднані обмеженням. Якщо ви не вкажете ніякі інші параметри, Matter.js вибере для них значення за замовчуванням. Наприклад, для кожної відносної точки кріплення (центр тіла) він встановить (0, 0), для length встановить значення поточної відстані між тілами, а для stiffness використає значення 0.7. Ще два важливих параметри, які я не налаштував — це damping і angularStiffness. Опція damping впливає на опір обмеження руху, де вищі значення призводять обмеження до швидшої втрати енергії. Опція angularStiffness контролює жорсткість обмеження кутового руху, де вищі значення призводять до меншої кутової гнучкості між тілами.

Після налаштування параметрів можна створити саме обмеження. Як зазвичай, це передбачає створення нового скорочення де змінна Constraint посилається на Matter.Constraint:

let constraint = Constraint.create(options);

Composite.add(engine.world, constraint);

Не забуваємо додати обмеження до світу!

Я можу включити обмеження до класу для інкапсуляції та керування взаємозв’язком між кількома тілами. Ось приклад класу, який представляє маятник (подібний до прикладу 3.11 з Розділу 3).

class Pendulum {

  constructor(x, y, len) {

    this.r = 12;

    this.len = len;

    this.anchor = Bodies.circle(x, y, this.r, { isStatic: true });
    this.bob = Bodies.circle(x + len, y, this.r, { restitution: 0.6 });

Створення двох тіл: одного для якоря (точки опори) та іншого для підвісу. Якір буде статичним.

    let options = {
      bodyA: this.anchor,
      bodyB: this.bob,
      length: this.len,
    };
    this.arm = Constraint.create(options);

Створення обмеження, що з’єднує якір і підвіс.

    Composite.add(engine.world, this.anchor);
    Composite.add(engine.world, this.bob);
    Composite.add(engine.world, this.arm);

Додавання до світу обох тіл та обмеження.

  }


  show() {

    fill(127);

    stroke(0);

    strokeWeight(2);

    line(this.anchor.position.x, this.anchor.position.y, this.bob.position.x, this.bob.position.y);

Малювання лінії, що представляє плече маятника.

    push();
    translate(this.anchor.position.x, this.anchor.position.y);
    rotate(this.anchor.angle);
    circle(0, 0, this.r * 2);
    line(0, 0, this.r, 0);
    pop();

Малювання опорної точки (якоря).

    push();
    translate(this.bob.position.x, this.bob.position.y);
    rotate(this.bob.angle);
    circle(0, 0, this.r * 2);
    line(0, 0, this.r, 0);
    pop();

Малювання підвісу.

  }

}

У прикладі 6.6 для параметру stiffness використовується значення за замовчуванням 0.7. Якщо ви спробуєте знизити значення, маятник буде схожим на м’яку пружину.

Вправа 6.5

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

Обмежувач обертання

Малюнок 6.11: Обмеження обертання — це з’єднання між двома тілами в одній опорній точці або шарнірі
Малюнок 6.11: Обмеження обертання — це з’єднання між двома тілами в одній опорній точці або шарнірі

Інший поширений у фізичних рушіях вид зв’язку між тілами називається обертальним або шарнірним з’єднанням. Цей тип обмеження з’єднує два тіла у спільній опорній точці, яку також називають шарніром (див. малюнок 6.11). Хоча в Matter.js немає окремого обмежувача для обертання, ви можете створити його за допомогою звичайного Constraint з довжиною рівною нулю. Таким чином тіла можуть обертатися навколо спільної опорної точки.

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

let body = Bodies.rectangle(x, y, w, h);
Composite.add(engine.world, body);

Створення тіла з наданими координатами, шириною і висотою.

Далі я можу створити потрібне обмеження. Властивість length буде дорівнювати 0, а для властивості stiffness потрібно встановити значення 1, інакше обмеження може бути недостатньо стабільним, щоб утримати тіло з’єднаним у точці прив’язки (якоря):

let options = {
  bodyA: this.body,
  pointB: { x: x, y: y },
  length: 0,
  stiffness: 1,
};

Обмеження з’єднує тіло з фіксованою xy-позицією, з довжиною рівною 0 і жорсткістю рівною 1.

let constraint = Matter.Constraint.create(options);
Composite.add(engine.world, constraint);

Створення обмеження та додавання його до світу.

Зібравши код разом, я напишу програму із класом Windmill, який представляє тіло, що обертається. Програма також містить клас Particle для скидування на вітряк частинок.

class Windmill {


  constructor(x, y, w, h) {

    this.w = w;

    this.h = h;

    this.body = Bodies.rectangle(x, y, w, h);
    Composite.add(engine.world, this.body);

Тіло для обертання.

    let options = {
      bodyA: this.body,
      pointB: { x: x, y: y },
      length: 0,
      stiffness: 1,
    };
    this.constraint = Constraint.create(options);
    Composite.add(engine.world, this.constraint);

Налаштування обертового обмеження.

  }


  show() {

    rectMode(CENTER);

    fill(127);

    stroke(0);

    strokeWeight(2);

    push();

    translate(this.body.position.x, this.body.position.y);

    push();

    rotate(this.body.angle);

    rect(0, 0, this.w, this.h);

    pop();

    line(0, 0, 0, height);

Малювання стійки вітряка (не відноситься до фізичного світу).

    pop();

  }

}

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

Вправа 6.6

Створіть подобу автівки, яка має поворотні шарніри для своїх коліс. Враховуйте розмір і розташування коліс. Як зміна властивості stiffness впливає на їх рух?

Обмежувач мишки

Перш ніж я представлю клас MouseConstraint, подумайте про таке запитання: як встановити положення тіла Matter.js у позицію курсора миші? І навіщо вам для цього потрібен обмежувач? Зрештою, у вас є доступ і до позиції тіла і до положення курсора. Що поганого в тому, щоб призначити одне іншому?

body.position.x = mouseX;

body.position.y = mouseY;

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

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

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

Однак перед тим, як зробити прив’язку до мишки, вам потрібно створити об’єкт Matter.js Mouse, який слухатиме події мишки на полотні p5.js:

const { Mouse, MouseConstraint } = Matter;

Посилання на класи Mouse і MouseConstraint з Matter.js.

let canvas = createCanvas(640, 240);

Для прослуховування миші потрібне посилання на полотно p5.js.

let mouse = Mouse.create(canvas.elt);

Створення об’єкта Mouse, пов’язаного з нативним елементом canvas.

Далі використайте об’єкт mouse для створення MouseConstraint:

let mouseConstraint = MouseConstraint.create(engine, { mouse });

Composite.add(engine.world, mouseConstraint);

Це миттєво дозволить вам взаємодіяти з усіма тілами Matter.js за допомогою миші. Явно прикріплювати обмежувач до певного тіла тут не потрібно — будь-яке тіло, на яке ви клацнете, буде обмежено мишею автоматично.

Ви також можете налаштувати всі звичайні змінні обмеження, додавши властивість constraint до параметрів, які передаються у метод MouseConstraint.create():

mouse = Mouse.create(canvas.elt);

let options = {

  mouse,

  constraint: { stiffness: 0.7 }

Налаштування обмеження з додатковою властивістю.

};

mouseConstraint = MouseConstraint.create(engine, options);

Composite.add(engine.world, mouseConstraint);

Ось приклад демонстрації MouseConstraint з двома об’єктами Box. Тут також налаштовні статичні тіла, що діють як стіни навколо периметру полотна.

У цьому прикладі ви побачите, що властивість stiffness обмежувача встановлено на 0.7, надаючи трохи еластичності уявному мотузку мишки. Інші властивості, такі як angularStiffness і damping також можуть впливати на взаємодію з мишею. Пограйте з цими значеннями й подивіться, що станеться?

Додаємо більше сил

У Розділі 2 я розповів, як побудувати середовище у якому діє кілька сил. Об’єкт може реагувати на гравітаційне тяжіння, вітер, опір повітря тощо. Очевидно, що в Matter.js також діють сили, коли прямокутники та круги обертаються та літають по екрану! Але наразі я продемонстрував, як контролювати лише одну глобальну силу — гравітацію:

  let engine = Engine.create();

  engine.gravity.x = 1;
  engine.gravity.y = 0;

Налаштування сили тяжіння світу у горизонтальному напрямку.

Якщо я хочу використати будь-який підхід Розділу 2 із Matter.js, мені знадобиться лише надійний метод applyForce(), який я написав як частину класу Mover. Він приймав вектор, ділив його на масу і накопичував у прискоренні об’єкта. У Matter.js працює той самий підхід, тому мені більше не потрібно самостійно писати всі деталі! Я можу викликати його як статичний метод Body.applyForce(). Ось як це виглядатиме у поточному класі Box:

class Box {

  applyForce(force) {

    Body.applyForce(this.body, this.body.position, force);

Виклик методу applyForce() з класу Body.

  }

}

Тут метод applyForce() з класу Box отримує вектор сили й просто передає його методу applyForce() Matter.js для застосування його до відповідного тіла. Основна відмінність цього підходу в тому, що Matter.js має складніший механізм, ніж приклади з Розділу 2. У попередніх прикладах припускалося, що сила завжди застосовується у центрі рухомого об’єкта. Тут я вказав точне положення на тілі, де прикладається сила. У цьому випадку я просто застосував її до центру, як і раніше, вказавши положення тіла, але це можна було б налаштувати інакше. Наприклад, уявіть сценарій, де сила діє на край об’єкта, змушуючи його обертатися на полотні, подібно до перекочування кинутих гральних кубиків.

Як я можу ввести сили у програму, керовану за допомогою Matter.js? Скажімо, я хочу використати силу тяжіння. Пам’ятаєте код з прикладу 2.6 у класі Attractor?

  attract(mover) {

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

    let distance = force.mag();

    distance = constrain(distance, 5, 25);

    let strength = (G * this.mass * mover.mass) / (distance * distance);

    force.setMag(strength);

    return force;

  }

Я можу переписати такий самий метод, використовуючи Matter.Vector і включити його в новий клас Attractor.

class Attractor {

  constructor(x, y) {

    this.radius = 32;
    this.body = Bodies.circle(x, y, this.radius, { isStatic: true });
    Composite.add(engine.world, this.body);

Атрактор — це статичне тіло Matter.js.

  }


  attract(mover) {

    let force = Vector.sub(this.body.position, mover.body.position);
    let distance = Vector.magnitude(force);

Метод attract() тепер використовує векторні методи Matter.js.

    distance = constrain(distance, 5, 25);


    let G = 0.02;

Використання невеликого значення для G утримує стабільність системи.

    let strength = (G * mover.body.mass) / (distance * distance);

Оскільки атрактор статичне тіло, його маса тут буде проігнорована.

    force = Vector.normalise(force);
    force = Vector.mult(force, strength);

Інші векторні методи Matter.js.

    return force;

  }

}

Окрім написання власного методу attract() для прикладу 6.9, є ще два ключові елементи, необхідні для програми, щоб поведінка була схожа на приклад з Розділу 2. По-перше, пам’ятайте, що Matter.js Engine має дефолтну гравітацію, спрямовану вниз. Мені потрібно відключити її у функції setup() за допомогою вектора (0, 0):

engine = Engine.create();

engine.gravity = Vector.create(0, 0);

Вимкнення гравітації за замовчуванням.

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

class Mover {

  constructor(x, y, radius) {

    this.radius = radius;

    let options = { frictionAir: 0 };

Вимкнення дефолтного опору повітря.

    this.body = Bodies.circle(x, y, this.radius, options);

  }

Це також хороша нагода повернутися до поняття маси. Хоча я маю доступ до властивості mass тіла, пов’язаного з рухомим тілом у методі attract(), я ніколи не встановлював її явно. У Matter.js маса тіла автоматично розраховується на основі його розміру (площі) і щільності. Отже, більші тіла матимуть більшу масу. Щоб збільшити масу відносно розміру, ви можете спробувати встановити властивість density в об’єкті options (за замовчуванням 0.001). Для статичних тіл, таких як атрактор, маса вважається нескінченною. Так атрактор залишається заблокованим на місці, попри постійні зіткнення з ним інших рухомих об’єктів.

Вправа 6.7

Включіть Body.applyForce() у новий метод spin() класу Windmill, щоб вітряк постійно виконував оберти.

Вправа 6.8

Переробіть будь-які приклади керувального руху з Розділу 5 із використанням Matter.js. Як виглядатиме флокінг із зіткненнями?

Події зіткнення

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

Ось питання, яким ви, ймовірно, задавалися: “А якщо мені потрібно, щоб під час зіткнення двох тіл сталося щось додаткове? Не зрозумійте мене неправильно — я в захваті від того, що Matter.js справляється з усіма зіткненнями замість мене. Але якщо Matter.js піклується про зіткнення за мене, як я маю дізнатися, коли саме вони відбуваються?”

Ваша перша думка, щоб відповісти на це запитання, може бути такою: “Ну, я знаю всі тіла в системі, і я знаю де вони всі розташовані. Я можу просто почати порівнювати положення тіл і побачити, які з них перетинаються. Тоді для тіл, що будуть у стані зіткнення, я можу зробити щось додаткове”.

Гарна думка, але агов? Суть використання фізичного рушія, такого як Matter.js, полягає в тому, що він виконує всю цю роботу за вас. Якщо ви збираєтеся реалізовувати алгоритми обчислення геометрії для перевірки перетину, тоді по суті, ви робите свій власний Matter.js!

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

function mousePressed() {
  print("The mouse was pressed!");
}

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

Глобальна функція mousePressed() у p5.js виконується кожного разу, коли відбувається клацання мишкою. Це називається callback (колбек або зворотний виклик) — функція, яка викликається у відповідь на певну подію. Події зіткнення Matter.js працюють подібним чином, але на відміну від функції mousePressed(), яку p5.js самостійно шукає, щоб виконати під час клацання, для події зіткнень Matter.js ви повинні явно створити та зареєструвати колбек функцію:

Matter.Events.on(engine, 'collisionStart', handleCollisions);

Цей код вказує, що функцію із назвою handleCollisions слід виконувати щоразу, коли починається зіткнення між двома тілами. Matter.js також має події для 'collisionActive' (виконується знову і знову під час дії зіткнення) та 'collisionEnd' (виконується, коли два тіла припиняють зіткнення), але для базової демонстрації, розуміння, коли починається зіткнення, більш ніж достатньо.

Подібно до того, як функція mousePressed() спрацьовує під час натискання мишки, функція handleCollisions() (або будь-яка інша, яку ви оберете для функції зворотного виклику), спрацьовує, коли дві фігури зіткнулися. Цю функцію можна записати наступним чином:

function handleCollisions(event) {


}

Зверніть увагу, що функція має параметр event. Це об’єкт, який містить усі дані, пов’язані із зіткненням тіл (або кількома зіткненнями, якщо одночасно відбулося більше одного зіткнення). Matter.js автоматично створює цей об’єкт і передає його як параметр у колбек handleCollisions() кожного разу, коли виникає колізія.

Скажімо, у мене є програма з об’єктами Particle. Кожен містить посилання на тіло Matter.js і я хочу, щоб при зіткненні між собою частинки змінювали свій колір. Щоб це сталося, слід виконати наступний процес:

Крок 1. Подія, підкажи мені, які два тіла зіткнулися?

Що тут зіткнулося? Matter.js виявляє зіткнення між парою тіл. Будь-яка пара тіл, що зіткнулися, буде в масиві під назвою pairs в об’єкті event. Усередині функції handleCollisions() я можу використовувати цикл for...of, щоб перебрати ці пари:

for (let pair of event.pairs) {


}

Крок 2: Пара, підкажи мені, з яких двох тіл ти складаєшся?

Кожна пара в масиві pairs є об’єктом із посиланнями на два тіла, що беруть участь у зіткненні: bodyA і bodyB. Я витягну ці тіла в окремі змінні:

for (let pair of event.pairs) {

  let bodyA = pair.bodyA;

  let bodyB = pair.bodyB;

}

Крок 3. Тіла, підкажіть, які частинки ви представляєте?

Знайти відповідні об’єкти Particle, що пов’язані з тілами Matter.js трохи складніше. Зрештою, Matter.js нічого не знає про мій код. Звісно, він робить усілякі речі для відстеження зв’язків між тілами й обмеженнями Matter.js, але керування зв’язками між моїми власними об’єктами p5.js та елементами Matter.js вже залежать від мене. Втім, кожне тіло Matter.js створюється з порожнім об’єктом { }, що має назву plugin і готове для зберігання будь-яких додаткових користувацьких даних про це тіло. Я можу зв’язати тіло з власним об’єктом (у цьому випадку з Particle), зберігаючи посилання на цей об’єкт у властивості plugin.

Подивіться на оновлений конструктор у класі Particle, де створюється тіло. Зверніть увагу, що функція створення тіла була розширена на один рядок коду, щоб додати властивість particle усередину об’єкта plugin. Важливо переконатися, що ви додаєте нову властивість до об’єкта plugin, що вже існує (у цьому випадку, plugin.particle = this), а не перезаписуєте сам об’єкт plugin (наприклад, таким чином plugin = this). Останнє може видалити з об’єкту plugin інші додані функції або налаштування.

class Particle {


  constructor(x, y, radius) {

    this.radius = radius;

    this.body = Bodies.circle(x, y, this.radius);

    this.body.plugin.particle = this;

Ключове слово this вказує на поточний об’єкт Particle, говорячи тілу Matter.js зберегти посилання на цю частинку, щоб пізніше можна було отримати до неї доступ.

    Composite.add(engine.world, this.body);

  }

Пізніше, у функції зворотного виклику handleCollision(), до цього об’єкта Particle можна буде отримати доступ із самого тіла за допомогою об’єкта plugin.

function handleCollisions(event) {

  for (let pair of event.pairs) {

    let bodyA = pair.bodyA;

    let bodyB = pair.bodyB;

    let particleA = bodyA.plugin.particle;
    let particleB = bodyB.plugin.particle;

Отримання частинок за допомогою об’єкта plugin, які пов’язані з тілами, що зіткнулися.

    if (particleA instanceof Particle && particleB instanceof Particle) {
      particleA.change();
      particleB.change();
    }

Якщо обидва об’єкта є частинками, змінимо їх колір!

  }

}

По замовчуванню ви не можете вважати, що об’єкти, які зіткнулися, будуть саме об’єктами Particle. Зрештою, частинка могла зіткнутися з об’єктом Boundary (або чимось іншим, залежно від того, що є у вашому світі). Ви можете перевірити тип об’єкта за допомогою JavaScript-оператора instanceof, як я зробив у цьому прикладі.

Вправа 6.9

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

Коротка інтерлюдія: Методи інтегрування

З вами колись таке траплялося? Ви на елегантній коктейльній вечірці, розповідаєте своїм друзям про неймовірні програми фізичних симуляцій. Раптом, як грім серед ясного неба, хтось запитує: “Чарівно! Але який метод інтегрування ви використовуєте?”

Що?! думаєте ви собі. Інтегрування?

Можливо, ви вже чули цей термін. Разом із диференціюванням, це одна з двох основних операцій числення.

Мені вдалося пройти більшу частину матеріалу в цій книзі, пов’язаного з моделюванням фізики, без необхідності занурюватися у числення. Однак, завершуючи першу половину цієї книги, варто приділити хвилину для розгляду числення, що лежить в основі того, що я продемонстрував, і як воно пов’язане з методологією певних фізичних бібліотек (наприклад, Box2D, Matter.js і Toxiclibs.js до якої ми підходимо). Таким чином, ви будете знати, що відповісти на наступній коктейльній вечірці, коли хтось запитає вас про інтегрування.

Я почну з питання: “Що спільного має інтегрування з положенням, швидкістю і прискоренням?” Щоб відповісти, я повинен спочатку визначити диференціювання — процес знаходження похідної. Похідна функції є мірою того, як функція змінюється з часом. Розглянемо положення та її похідну. Положення — це точка у просторі, тоді як швидкість — це зміна положення з часом. Тому швидкість можна описати як похідну положення. А що таке прискорення? Зміна швидкості з часом. Прискорення — це похідна швидкості.

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

Оскільки фізичні симуляції в цій книзі ґрунтуються на ідеї обчислення прискорення на основі сил, інтегрування потрібне для визначення місця розташування об’єкта через певний період часу (наприклад, через один цикл роботи функції draw()). Іншими словами, ви весь час вже займалися інтегруванням! Наступний код показує, як це виглядає:

velocity.add(acceleration);

position.add(velocity);

Ця методика відома як Інтегрування Ейлера (названа на честь математика Леонарда Ейлера), або метод Ейлера. По суті, це найпростіша форма інтегрування і її дуже легко реалізувати в коді — всього два рядки! Проте, хоча це просто у плані обчислення, це аж ніяк не найточніший або стабільний вибір для певних типів симуляцій.

Чому метод Ейлера неточний? Подумайте про це так: коли ви підстрибуєте на пого-палиці, чи залишається вона в одній позиції протягом 1 секунди, а потім зникає та раптово з’являється в новій позиції на другій секунді й потім те саме на 3 секунді, 4, 5...? Ні, звичайно, ні. Пого-палиця рухається у часі безперервно.

Але що відбувається у програмі p5.js? На першому кадрі об’єкт знаходиться в одному положенні, на наступному кадрі в іншому і так далі. Звісно, при 30-60 кадрах на секунду ви бачите ілюзію руху. Але нове положення обчислюється лише кожні NN-одиниць часу, тоді як реальний світ абсолютно безперервний. Це призводить до деяких неточностей, як показано на малюнку 6.12.

Figure 6.12: The Euler approximation of a curve
Малюнок 6.12: Ейлерова апроксимація кривої

“Реальний світ” — це гладенька крива. Симуляція Ейлера — це послідовність прямих відрізків. Одним із варіантів покращення методу Ейлера є використання менших часових кроків — замість одного разу на кадр, ви можете перераховувати положення об’єкта 20 разів на кадр. Але це не практично, бо програма може почати працювати дуже повільно.

Я все ще вважаю, що метод Ейлера — це найкращий метод для вивчення основ і він також цілком підходить для більшості проєктів, які ви забажаєте зробити з p5.js. Усе, що втрачається в ефективності або неточності, компенсується простотою використання і зрозумілістю. Для покращення точності рушій Box2D, наприклад, використовує симплектичний або напів-експліцитний метод Ейлера, що є невеликою модифікацією метода Ейлера. Інші рушії використовують метод інтегрування, який називається методом Рунге-Кутти (названий на честь німецьких математиків Карла Рунге і Мартіна Кутти).

Іншим популярним методом інтегрування, який використовується у фізичних бібліотеках, включаючи Matter.js і Toxiclibs.js, є метод Верле. Простий спосіб описати метод Верле — це подумати про типовий алгоритм руху без явного зберігання швидкості. Зрештою, вам дійсно не потрібно зберігати швидкість оскільки, якщо ви завжди знаєте де об’єкт був у певний момент часу і де він знаходиться зараз, то зможете екстраполювати його швидкість. Метод Верле робить саме це, обчислюючи швидкість на льоту під час роботи програми, замість того, щоб зберігати окрему змінну для швидкості.

Метод Верле дуже добре підходить для систем із частинками, особливо з пружинними з’єднаннями між частинками. Фізичні бібліотеки приховують від вас деталі реалізації, тож вам не потрібно перейматися про те, як це все працює, але якщо ви зацікавлені у глибшому зануренні у фізику Верле, я пропоную прочитати основоположну статтю Томаса Якобсена, з якої походять майже всі симуляції Верле у комп’ютерній графіці: “Advanced Character Physics”.

Фізика Верле з використанням Toxiclibs.js

Приблизно у 2005 році Карстен Шмідт розпочав роботу над Toxiclibs, широкомасштабною та передовою бібліотекою з відкритим кодом для обчислювального проєктування, спеціально створеною для Java-версії програми Processing. Попри те, що бібліотека особливо не розвивалася понад 10 років, концепції й методи, які вона продемонструвала свого часу, сьогодні можна знайти у безлічі проєктів творчого програмування. Вебсайт бібліотеки описував її наступним чином:

Toxiclibs — це незалежна бібліотека з відкритим кодом, призначена для задач обчислювального проєктування на Java і Processing, розроблена Карстеном “toxi” Шмідтом. Класи бібліотеки цілеспрямовано зроблені досить загальними, щоб максимізувати можливість їх повторного використання у різних контекстах, включаючи генеративний дизайн, анімацію, інтерактивний дизайн, візуалізації даних для архітектурних та цифрових цілей, використання в якості навчального інструменту тощо.

Шмідт і сьогодні продовжує робити свій внесок у сферу креативного кодування завдяки своєму проєкту thi.ng/umbrella. Цю роботу можна вважати непрямим наступником Toxiclibs.js, але з набагато більшим обсягом і деталями. Якщо вам сподобалася ця книга, вам можливо особливо сподобається досліджувати thi.ng/vectors, який містить понад 800 функцій векторної алгебри, що використовують звичайні масиви JavaScript.

Хоча thi.ng/umbrella може бути більш сучасним і витонченим підходом, Toxiclibs.js залишається універсальним інструментом і я досі продовжую використовувати версію сумісну з останньою версією Processing (4.3 на момент написання цього тексту). Ми маємо подякувати нашим зіркам удачі за існування Toxiclibs.js, адаптації цієї бібліотеки на JavaScript, створеної Кайлом Філліпсом (відомий під нікнеймом hapticdata). Я покажу лише кілька прикладів, пов’язаних з фізикою Верле, але бібліотека Toxiclibs.js також містить низку інших пакетів із функціональністю, пов’язаною з кольорами, геометрією, математикою тощо.

Приклади, які я збираюся продемонструвати, також можна створити з використанням Matter.js, але я вирішив перейти до Toxiclibs.js з кількох причин. Ця бібліотека займає особливе місце в моєму серці як особистий фаворит і має важливе історичне значення. Також я вважаю, що розгляд більш ніж одної фізичної бібліотеки важливо для ширшого розуміння доступних інструментів і підходів.

Цей перехід від Matter.js до Toxiclibs.js порушує важливе питання: як вирішити, яку саме бібліотеку краще використовувати для проєкту? Matter.js, Toxiclibs.js чи щось інше? Якщо ви належите до однієї з наступних двох категорій, ваш вибір буде трохи легшим:

  • Ваш проєкт передбачає зіткнення. Ви маєте круги, квадрати й інші об’єкти певних форм, які вдаряються і відскакують один від одного. У цьому випадку вам захочеться використовувати Matter.js, оскільки Toxiclibs.js не обробляє зіткнення твердих тіл.
  • Ваш проєкт включає багато частинок, які рухаються по екрану. Іноді вони притягують одна одну. Іноді вони відштовхуються одна від одної. А іноді вони пов’язані пружинними з’єднаннями. У цьому випадку Toxiclibs.js, ймовірно, буде найкращим вибором. У деяких прикладах він простіший у використанні ніж Matter.js, і особливо добре підходить для з’єднаних систем частинок. Він також має високу продуктивність, оскільки ігнорує всю геометрію зіткнень.

Ось невелика порівняльна таблиця, яка охоплює деякі функції даних бібліотек:

ОсобливістьMatter.jsToxiclibs.js
Зіткнення твердих тілЄНема
3D фізикаНемаЄ
Сили притягування і відштовхування частинокНемаЄ
Пружинні з’єднання (на основі сили)ЄЄ
Обмеження (з’єднання загального призначення)ЄНема

Усю документацію та файли бібліотеки можна знайти на вебсайті Toxiclibs.js. Для прикладів у цій книзі я працюватиму з версією, яка зберігається на CDN і зроблю відповідне посилання на неї у файлі index.html, так само як я робив це для Matter.js. Нижче приклад з підключенням бібліотеки через тег <script>:

<script src="https://cdn.jsdelivr.net/gh/hapticdata/toxiclibsjs@0.3.2/build/toxiclibs.js"></script>

Мій огляд Matter.js зосереджувався на кількох ключових функціональностях цієї бібліотеки: світ, вектор, тіло, обмеження. Це також дасть вам початкове розуміння Toxiclibs.js, оскільки він має схожу структуру. У наступній таблиці показано відповідний функціонал зі сторони Toxiclibs.js:

Matter.jsToxiclibs.js
World
VerletPhysics2D
Vector
Vec2D
Body
VerletParticle2D
Constraint
VerletSpring2D

Спершу я розгляну як деякі з цих функціональностей перекладаються у Toxiclibs.js, а потім почну поєднувати їх для створення цікавих прикладів.

Вектори

І ось ми знову на початку. Пам’ятаєте весь той час, витрачений на вивчення усіх деталей класу p5.Vector? А пам’ятаєте, як потім вам довелося переглянути всі ці концепції з Matter.js і його класом Matter.Vector? Що ж, настав час зробити це знову, оскільки Toxiclibs.js також містить власні векторні класи. Він має один клас для двох вимірів і один для трьох: Vec2D та Vec3D. Обидва містяться в пакеті toxi.geom і можуть бути деструктуровані з нього як клас Vector з Matter.js:

const { Vec2D, Vec3D } = toxi.geom;

Знову ж таки, концептуально вектори Toxiclibs.js такі ж, що і вектори p5.js, які ми знаємо й любимо, але вони мають власний стиль та синтаксис. Ось огляд того, як деякі основні операції векторної математики з p5.Vector перекладаються на Vec2D. (Я використовую 2D, щоб відповідати решті цієї книги, але закликаю вас так само досліджувати 3D-вектори):

p5.VectorVec2D
let a = createVector(1, -1);
let b = createVector(3, 4);
a.add(b);
let a = new Vec2D(1, -1);
let b = new Vec2D(3, 4);
a.addSelf(b);
let a = createVector(1, -1);
let b = createVector(3, 4);
let c = p5.Vector.add(a, b);
let a = new Vec2D(1, -1);
let b = new Vec2D(3, 4);
let c = a.add(b);
let a = createVector(1, -1);
let m = a.mag();
a.normalize();
let a = new Vec2D(1, -1);
let m = a.magnitude();
a.normalize();

Зокрема зверніть увагу, що вектори Toxiclibs.js створюються шляхом виклику конструктора Vec2D з ключовим словом new, а не за допомогою фабричного методу, подібно до Matter.Vector() або createVector().

Світ фізики

Класи для опису світу і його частинок й пружин у Toxiclibs.js містяться в toxi.physics2d. Я також збираюся використовувати об’єкт Rect для опису загальної межі прямокутника і GravityBehavior для застосування до світу глобальної сили тяжіння. Разом із Vec2D я створю для них наступні змінні-посилання:

const { Vec2D, Rect } = toxi.geom;

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

const { VerletPhysics2D, VerletParticle2D, VerletSpring2D } = toxi.physics2d;

Змінні важливих класів з toxi.physics2d.

const { GravityBehavior } = toxi.physics2d.behaviors;

Клас для гравітації.

Перший крок — створення світу:

let physics;


function setup() {

  physics = new VerletPhysics2D();

Створення світу Toxiclibs.

Маючи створений світ VerletPhysics, я можу встановити глобальні властивості. Наприклад, якщо мені потрібні жорсткі кордони, за межі яких частинки не мають рухатися, я можу зробити це за допомогою прямокутника:

  physics.setWorldBounds(new Rect(0, 0, width, height));

Крім того, я можу додати гравітацію за допомогою об’єкта GravityBehavior. Для гравітації потрібно задати вектор, щоб вказати її силу та напрямок дії:

  physics.addBehavior(new GravityBehavior(new Vec2D(0, 0.5)));

}

Нарешті, щоб обчислити фізику світу і переміщувати його об’єкти, потрібно викликати метод оновлення світу — update(). Зазвичай це відбувається один раз на кадр у функції draw():

function draw() {

  physics.update();

Це те ж саме, що і Engine.update() у Matter.js.

}

Тепер залишається лише наповнити світ.

Частинки

Еквівалент тіла Matter.js у Toxiclibs.js — річ, яка існує у світі й піддається фізиці — це частинка, представлена класом VerletParticle2D. Однак, на відміну від тіл Matter.js, частинки Toxiclibs.js не зберігають геометрію — це просто точки в просторі.

Як мені інтегрувати частинки Toxiclibs.js у програму p5.js? Для прикладів Matter.js я створював власний клас (названий Particle) і включав у ньому посилання на тіло Matter.js:

class Particle {

  constructor(x, y, r) {

    this.body = Bodies.circle(x, y, r);

  }

}

Цей підхід був дещо зайвим, оскільки Matter.js відстежує тіла свого світу. Однак це дозволило мені розуміти яке саме тіло до якої частинки відноситься (і знати, як кожне тіло має бути намальоване), не використовуючи ітерації внутрішніх масивів Matter.js. Я міг би піти таким же шляхом з Toxiclibs.js, створивши власний клас Particle, який зберігає посилання на об’єкт VerletParticle2D. Таким чином я зможу надати частинкам власні властивості та малювати їх, як захочу. Ймовірно, я б написав код наступним чином:

class Particle {

  constructor(x, y, r) {

    this.particle = new VerletParticle2D(x, y);

VerletParticle потребує початкові координати (x, y), але не має геометрії, тому властивість r використовується лише для малювання.

    this.r = r;

  }


  show() {

    fill(127);

    stroke(0);

    circle(this.particle.x, this.particle.y, this.r * 2);

При малюванні частинки використовуються координати (x, y), що зберігаються у this.particle.

  }

}

Переглядаючи цей код, ви можете спочатку помітити, що намалювати частинку дуже просто, достатньо взяти координати x і y та використати їх у функції circle(). По-друге, ви можете помітити, що цей клас Particle не робить більше нічого корисного, окрім збереження посилання на об’єкт VerletParticle2D. Це натякає на щось важливе. Пригадайте обговорення про наслідування у Розділі 4, а потім запитайте себе: що таке об’єкт Particle, як не “доповнений” об’єкт VerletParticle2D? Навіщо створювати два об’єкти — Particle і VerletParticle2D — для кожної окремої частинки у світі, коли я можу просто розширити клас VerletParticle2D, щоб включити додатковий код для малювання частинки?

class Particle extends VerletParticle2D {

  constructor(x, y, r) {

    super(x, y);

Виклик методу super() з xy-значеннями для правильної ініціалізації об’єкта.

    this.r = r;

Додавання змінної для збереження радіуса.

  }


  show() {

Доповнення класу за рахунок методу show().

    fill(127);

    stroke(0);

    circle(this.x, this.y, this.r * 2);

x і y з VerletParticle2D!

  }

}

Крім того, насправді клас VerletParticle2D є підкласом Vec2D. Це означає, що крім успадкування всього від VerletParticle2D, клас Particle також успадкував і усі методи класу Vec2D!

Тепер я можу створювати нові частинки:

let particle = new Particle(width / 2, height / 2, 8);

Однак просто створити частинку недостатньо. Подібно до Matter.js, нам потрібно явно додати нову частинку до світу. У Toxiclibs.js це робиться за допомогою методу addParticle():

physics.addParticle(particle);

Якщо ви подивитеся на документацію Toxiclibs.js, то побачите, що метод addParticle() очікує об’єкт VerletParticle2D. Але я передав йому об’єкт Particle. Чи це спрацює?

Так! Пригадайте один із принципів ООП, а саме поліморфізм. Оскільки тут клас Particle розширює клас VerletParticle2D, я можу розглядати частинку двома різними способами — як Particle або як VerletParticle2D. Це неймовірно потужна особливість ООП. Якщо ви створюєте власні класи, які наслідують класи Toxiclibs.js, то можете використовувати об’єкти цих класів у поєднанні з усіма методами, які пропонує Toxiclibs.js.

Пружини

Окрім класу VerletParticle2D, Toxiclibs.js має набір класів, які дозволяють з’єднувати частинки за допомогою пружинних сил. У Toxiclibs.js є три види пружин:

  • VerletSpring2D: пружне з’єднання між двома частинками. Властивості пружини можна налаштувати таким чином, щоб створити жорстке палицеподібне або дуже еластичне з’єднання, що розтягується. Частинку також можна зафіксувати, щоб рухався лише один кінець пружини.
  • VerletConstrainedSpring2D: пружина, максимальна відстань якої може бути обмежена. Це може допомогти всій системі пружин досягти кращої стабільності.
  • VerletMinDistanceSpring2D: пружина, яка відновлює свою довжину спокою, лише якщо поточна відстань менша за її довжину спокою. Це зручно, якщо ви хочете забезпечити між об’єктами певну відстань, але вас не хвилює, якщо відстань перевищуватиме встановлений мінімум.

Наслідування і поліморфізм знову виявилися корисними при створенні пружин. Конструктор пружини потребує два об’єкти типу VerletParticle2D, але як і раніше, тут підійдуть і два об’єкти Particle, оскільки вони є розширенням класу VerletParticle2D.

Ось приклад коду для створення пружини. Цей фрагмент припускає існування двох частинок, particle1 і particle2, та створює зв’язок між ними із заданою довжиною спокою та міцністю:

let length = 80;

Довжина пружини у спокої.

let strength = 0.01;

Міцність пружини. Чим вище значення, тим жорсткіша пружина.

let spring = new VerletSpring2D(particle1, particle2, length, strength);

Як і з частинками, щоб з’єднання було частиною світу фізики, його необхідно явно додати до цього світу:

physics.addSpring(spring);

Я вже маю майже все, що мені потрібно для створення простого першого прикладу з Toxiclibs.js: дві частинки, з’єднані в пружинний маятник. Однак я хочу додати ще один елемент: взаємодію з курсором.

На прикладі з Matter.js я пояснив, що фізична симуляція ламається, якщо ви вручну переписуєте положення тіла, налаштовуючи його на положення курсора. З Toxiclibs.js такої проблеми немає. Якщо я хочу, я можу встановити положення частинки (x,y)(x, y) вручну. Однак перед тим, як це зробити, зазвичай гарною ідеєю буде викликати метод частинки lock(), який фіксує частинку на місці. Це ідентично встановленню властивості isStatic до значення true у Matter.js.

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

  if (mouseIsPressed) {

    particle1.lock();
    particle1.x = mouseX;
    particle1.y = mouseY;
    particle1.unlock();

Спершу заблокуємо частинку, потім встановимо нові значення для x і y та розблокуємо її методом unlock().

  }

І з цими знаннями я готовий об’єднати всі потрібні елементи в просту програму із двома частинками, з’єднаними пружиною. Одна частинка буде перманентно зафіксована на місці, а іншу можна буде переміщувати, перетягуючи мишкою. Цей приклад практично ідентичний прикладу 3.11 із Розділу 3.

const { Vec2D, Rect } = toxi.geom;

const { VerletPhysics2D, VerletParticle2D, VerletSpring2D } = toxi.physics2d;

const { GravityBehavior } = toxi.physics2d.behaviors;


let physics;

let particle1, particle2;


function setup() {

  createCanvas(640, 240);

  physics = new VerletPhysics2D();
  physics.setWorldBounds(new Rect(0, 0, width, height));
  physics.addBehavior(new GravityBehavior(new Vec2D(0, 0.5)));

Створення світу фізики з Toxiclibs.js.

  let length = 120;

Довжина пружини у спокої.

  particle1 = new Particle(width / 2, 0, 8);
  particle2 = new Particle(width / 2 + length, 0, 8);

Створення двох частинок.

  particle1.lock();

Фіксація однієї частинки на місці.

  let spring = new VerletSpring2D(particle1, particle2, length, 0.01);

Створення однієї пружини.

  physics.addParticle(particle1);
  physics.addParticle(particle2);
  physics.addSpring(spring);

Додавання всіх елементів до світу фізики.

}


function draw() {

  physics.update();

Оновлення фізики.

  background(255);

  stroke(0);
  line(particle1.x, particle1.y, particle2.x, particle2.y);
  particle1.show();
  particle2.show();

Малювання усіх потрібних елементів.

  if (mouseIsPressed) {
    particle2.lock();
    particle2.x = mouseX;
    particle2.y = mouseY;
    particle2.unlock();
  }

Переміщення частинки за допомогою мишки.

}


class Particle extends VerletParticle2D {
  constructor(x, y, r) {
    super(x, y);
    this.r = r;
  }

Чи не гарно виглядає цей простий клас Particle?


  show() {

    fill(127);

    stroke(0);

    circle(this.x, this.y, this.r * 2);

  }

}

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

Симуляції м’яких тіл

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

Одним із перших популярних прикладів фізики м’яких тіл був у грі SodaConstructor, створеній на початку 2000-х років. Гравці могли конструювати й анімувати власні 2D створіння, побудовані із мас та пружин. Інші приклади за ці роки включали такі ігри як LocoRoco, World of Goo, і трохи новіша JellyCar.

Основними будівельними блоками симуляції м’яких тіл є частинки, з’єднані пружинами — так само як пара частинок у прикладі 6.11. На малюнку 6.13 показано, як налаштувати мережу з’єднань частинок-пружинок для створення різних форм.

Малюнок 6.13: Моделювання м’якого тіла
Малюнок 6.13: Моделювання м’якого тіла

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

Ланцюжок

Я почну із симуляції м’якого маятника — підвісу, що висить на гнучкому ланцюжку замість жорсткого плеча. Toxiclibs.js має зручний клас ParticleString2D, який створює рядок частинок, з’єднаних пружинами. Однак для демонстрації я створю власний рядок частинок за допомогою масиву і циклу for. Таким чином ви отримаєте глибше розуміння системи, що дозволить вам у майбутньому створювати власні нестандартні конструкції, окрім одинарного рядка.

Спочатку мені потрібен масив частинок. Я буду використовувати той самий клас Particle, створений у прикладі 6.11:

let particles = [];

Тепер, припустимо, що я хочу отримати 20 частинок, розташованих одна від одної на відстані 10 пікселів, як на малюнку 6.14.

Малюнок 6.14: Двадцять частинок, розташованих одна від одної на відстані 10 пікселів
Малюнок 6.14: Двадцять частинок, розташованих одна від одної на відстані 10 пікселів

Я можу пройтися циклом від i, що початково дорівнює 0, до значення total, створюючи нові частинки й встановлюючи для кожної позицію y рівну значенню i * 10. Таким чином перша частинка буде знаходитися на позиції (0,10)(0,10), друга матиме координати (0,20)(0,20), третя — (0,30)(0,30) і так далі:

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

  let particle = new Particle(i * length, 10, 4);

Розташуємо частинки вздовж x-вісі.

  physics.addParticle(particle);

Додамо частинку до світу фізики.

  particles.push(particle);

Додамо частинку до масиву.

}

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

Тепер найцікавіше: час з’єднати всі частинки. Частинка з індексом 0 повинна бути з’єднана з частинкою під індексом 1, частинка з індексом 1 — з частинкою під індексом 2, 2 з 3, 3 з 4 і так далі (див. малюнок 6.15).

Малюнок 6.15: Кожна частинка з’єднана з наступною частинкою масиву
Малюнок 6.15: Кожна частинка з’єднана з наступною частинкою масиву

Іншими словами, частинку i потрібно з’єднати з частинкою i+1 (окрім випадку, коли i представляє останній елемент масиву):

for (let i = 0; i < total - 1; i++) {

Цикл зупиняється перед останнім елементом (total – 1).

  let spring = new VerletSpring2D(particles[i], particles[i + 1], spacing, 0.01);

Пружина з’єднує частинку i з частинкою i + 1.

  physics.addSpring(spring);

Пружину також потрібно додати до світу.

}

А що, якщо я хочу, щоб ланцюжок звисав із фіксованої точки? Я можу зафіксувати одну з частинок, можливо першу, останню чи середню. Я виберу першу:

particles[0].lock();

Нарешті, мені потрібно намалювати частинки. Однак, замість того, щоб малювати їх як кружечки, я хочу розглядати їх як точки на лінії. Для цього я можу використати функції beginShape(), endShape() і vertex(), отримуючи доступ до окремих позицій частинок із масиву. Я використаю метод show() лише для малювання останньої частинки у вигляді кола, створюючи підвіс на кінці ланцюжка.

function draw() {

  physics.update();


  background(255);


  stroke(0);

  noFill();

  beginShape();

  for (let particle of particles) {

    vertex(particle.x, particle.y);

Кожна частинка представляє одну вершину ланцюжка.

  }

  endShape();


  particles[particles.length - 1].show();

Остання частинка малюється як круг.

}

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

Вправа 6.10

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

Персонаж із м’яким тілом

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

Малюнок 6.16: Скелет персонажа з м’яким тілом. Вершини прону�меровані відповідно до їх позицій у масиві
Малюнок 6.16: Скелет персонажа з м’яким тілом. Вершини пронумеровані відповідно до їх позицій у масиві

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

class Particle extends VerletParticle2D {

  constructor(x, y, r) {

    super(x, y);

    this.r = r;

    physics.addParticle(this);

Додавання об’єкта до глобального світу фізики. Всередині класу посилання на поточний об’єкт відбувається через ключове слово this.

  }


  show() {

    fill(127);

    stroke(0);

    circle(this.x, this.y, this.r * 2);

  }

}

Хоча це не обов’язково, я також хочу створити клас Spring, який успадкує свою функціональність від VerletSpring2D. Для цього прикладу я хочу, щоб довжина пружини у спокої завжди дорівнювала відстані між частинками скелета на момент їх створення. Крім того, для простоти, я захардкодив у конструкторі Spring значення для міцності рівне 0.01 . Можливо ви захочете покращити приклад за допомогою складнішого дизайну, де різні частини м’якого тіла матимуть різний ступінь пружності:

class Spring extends VerletSpring2D {

  constructor(a, b) {

У якості аргументів конструктор отримує дві частинки.

    let length = dist(a.x, a.y, b.x, b.y);

Обчислення довжини спокою, що рівна відстані між частинками.

    super(a, b, length, 0.01);

Жорстке кодування пружності пружини.

    physics.addSpring(this);

Ще одне покращення полягає в тому, що об’єкт самостійно додається у загальний світ!

  }

}

Тепер, коли у мене є класи Particle і Spring, я можу зібрати персонажа, додавши серію частинок із жорстко закодованими початковими позиціями до масиву particles і серію пружинних з’єднань до масиву springs:

let particles = [];
let springs = [];

Збереження всіх частинок і пружин у масивах.


function setup() {

  createCanvas(640, 240);

  physics = new VerletPhysics2D();

  particles.push(new Particle(200, 25));
  particles.push(new Particle(400, 25));
  particles.push(new Particle(350, 125));
  particles.push(new Particle(400, 225));
  particles.push(new Particle(200, 225));
  particles.push(new Particle(250, 125));

Створення положень вершин персонажа, використовуючи частинки.

  springs.push(new Spring(particles[0], particles[1]));
  springs.push(new Spring(particles[1], particles[2]));
  springs.push(new Spring(particles[2], particles[3]));
  springs.push(new Spring(particles[3], particles[4]));
  springs.push(new Spring(particles[4], particles[5]));
  springs.push(new Spring(particles[5], particles[0]));

З’єднання вершин за допомогою пружин.

}

Краса цієї системи полягає в тому, що ви можете легко її розширити, щоб створити власний дизайн, додавши більше частинок і пружин! Однак тут є одна серйозна проблема: я встановив зв’язки лише по периметру персонажа. Якби я застосував силу (наприклад, гравітацію) до тіла, воно б миттєво обвалилося. Саме тут у гру вступають додаткові внутрішні пружини, як показано на малюнку 6.17. Вони утримують структуру персонажа стабільною, дозволяючи йому водночас рухатися і похитуватися реалістичним чином.

Малюнок 6.17: Внутрішні пружини утримують конструкцію від колапсу. Це лише один із можливих дизайнів
Малюнок 6.17: Внутрішні пружини утримують конструкцію від колапсу. Це лише один із можливих дизайнів. Спробуйте інші!

Останній приклад включає додаткові пружини з малюнку 6.17, силу гравітації та взаємодію з мишкою.

let physics;

let particles = [];

let springs = [];


function setup() {

  createCanvas(640, 240);

  physics = new VerletPhysics2D();

  physics.setWorldBounds(new Rect(0, 0, width, height));

  physics.addBehavior(new GravityBehavior(new Vec2D(0, 0.5)));

  particles.push(new Particle(200, 25));
  particles.push(new Particle(400, 25));
  particles.push(new Particle(350, 125));
  particles.push(new Particle(400, 225));
  particles.push(new Particle(200, 225));
  particles.push(new Particle(250, 125));

Частинки-вершини персонажа.

  springs.push(new Spring(particles[0], particles[1]));
  springs.push(new Spring(particles[1], particles[2]));
  springs.push(new Spring(particles[2], particles[3]));
  springs.push(new Spring(particles[3], particles[4]));
  springs.push(new Spring(particles[4], particles[5]));
  springs.push(new Spring(particles[5], particles[0]));

Пружини, що з’єднують вершини персонажа.

  springs.push(new Spring(particles[5], particles[2]));
  springs.push(new Spring(particles[0], particles[3]));
  springs.push(new Spring(particles[1], particles[4]));

Три внутрішні пружини!

}


function draw() {

  background(255);

  physics.update();

  fill(127);
  stroke(0);
  beginShape();
  for (let particle of particles) {
    vertex(particle.x, particle.y);
  }
  endShape(CLOSE);

Малювання персонажа у вигляді однієї форми.

  if (mouseIsPressed) {
    particles[0].lock();
    particles[0].x = mouseX;
    particles[0].y = mouseY;
    particles[0].unlock();
  }

Взаємодія з мишкою.

}

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

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

Вправа 6.11

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

Силові алгоритми візуалізації графів

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

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

Малюнок 6.18: Приклад візуалізації, де кластери частинок з’єднані пружинними силами

Щоб створити приклад силового алгоритму візуалізації графу, мені спочатку знадобиться клас для опису окремого вузла в системі. Оскільки термін нода асоціюється із JavaScript-платформою Node.js, я продовжу з терміном частинка, щоб уникнути будь-якої плутанини й продовжу використовувати свій клас Particle із попередніх прикладів м’яких тіл.

Далі я інкапсулюю список з NN частинок у новий клас під назвою Cluster, який представлятиме граф у цілому. Усі частинки спочатку будуть розташовані біля центру полотна:

class Cluster {

  constructor(n, length) {
    this.particles = [];
    for (let i = 0; i < n; i++) {

Кластер ініціалізується N вузлами.

      let x = width / 2 + random(-1, 1);
      let y = height / 2 + random(-1, 1);
      this.particles.push(new Particle(x, y, 4));

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

    }

  }

Припустімо, що у класі Cluster також є метод show() для малювання всіх частинок кластера і що я створю новий об’єкт Cluster у функції setup() та відображу його у функції draw(). Якщо запустити програму в поточному стані нічого не відбудеться. Чому? Тому що мені ще належить реалізувати всю частину силового алгоритму графа! Мені потрібно з’єднати кожен вузол з кожним іншим вузлом за допомогою пружини. Це дещо схоже на створення персонажа із м’яким тілом, але замість того, щоб створювати скелет вручну, я хочу написати алгоритм для автоматичного створення всіх зв’язків.

Що саме я маю на увазі? Скажімо, є п’ять об’єктів Particle: 0, 1, 2, 3 і 4. На малюнку 6.19 показано їх зв’язки.

Малюнок 6.19: зв’язний граф, що показує кожен із п’яти вузлів, з’єднаних з кожним іншим вузлом
Малюнок 6.19: Зв’язний граф, що показує кожен із п’яти вузлів, з’єднаних з кожним іншим вузлом

Зверніть увагу на дві важливі деталі щодо списку зв’язків:

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

Як написати код для створення таких з’єднань для NN частинок? Подивіться на чотири стовпці, зображені на малюнку 6.19. Вони повторюють усі з’єднання, починаючи з частинки 0 до 4. Це означає, що мені потрібно отримати доступ до кожної частинки у списку від 00 до N1N-1:

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

      let particle_i = this.particles[i];

Використання змінної particle_i для збереження посилання на частинку.

Тепер подивіться на з’єднання, зображені на малюнку 6.19. Мені потрібно з’єднати вузол 0 із вузлами 1, 2, 3 і 4. Вузол 1 із вузлами 2, 3 і 4. Вузол 2 із вузлами 3 і 4. Вузол 3 тільки із вузлом 4. Загалом, для кожного вузла i мені потрібно пройтися від i + 1 до кінця масиву. Для цього я буду використовувати змінну лічильника j:

      for (let j = i + 1; j < this.particles.length; j++) {

Зверніть увагу, що j починається зі значення i + 1.

        let particle_j = this.particles[j];

Для кожної пари частинок i та j я можу створити пружину. Я повернуся до безпосереднього використання VerletSpring2D, але ви також можете включити власний клас Spring:

        physics.addSpring(new VerletSpring2D(particle_i, particle_j, length, 0.01));

Пружина з’єднує частинки i та j.

      }

    }

Припускаючи, що ці з’єднання створюються в конструкторі Cluster, все, що залишилося, це створити кластер усередині setup() і викликати метод show() у функції draw()!

const { VerletPhysics2D, VerletParticle2D, VerletSpring2D } = toxi.physics2d;


let physics;

let cluster;


function setup() {

  createCanvas(640, 240);

  physics = new VerletPhysics2D();

  cluster = new Cluster(floor(random(2, 20)), random(10, height / 2));

Створення випадкового кластеру.

}


function draw() {

  physics.update();

  background(255);

  cluster.show();

Малювання кластеру.

}

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

Вправа 6.12

Створіть структуру, схожу на кластер, як скелет для милого, приємного, м’якого створіння. Додайте гравітацію та взаємодію з мишкою.

Вправа 6.13

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

Поведінки притягання і відштовхування

Коли прийшов час створити приклад притягання для Matter.js, я показав як клас Matter.Body включає метод applyForce(). Все, що мені потім потрібно було зробити, це обчислити силу притягання Fg=(G×m1×m2)÷d2F_g = (G \times m_1 \times m_2) \div d^2 у вигляді вектора і застосувати його до тіла. Так само клас VerletParticle2D з бібліотеки Toxiclibs.js також включає метод під назвою addForce(), який може застосувати будь-яку розраховану силу до частинки.

Однак Toxiclibs.js також просуває цю ідею на крок далі, пропонуючи вбудовану функціональність для загальних сил (названих поведінками), таких як притягування! Наприклад, якщо додати об’єкт AttractionBehavior до певного об’єкта VerletParticle2D, усі інші частинки у світі фізики відчуватимуть силу притягання до цієї частинки.

Скажімо, я створюю екземпляр свого класу Particle (який розширює клас VerletParticle2D):

let particle = new Particle(320, 120);

Тепер я можу створити поведінку AttractionBehavior, пов’язану з цією частинкою:

let distance = 20;

let strength = 0.1;

let behavior = new AttractionBehavior(particle, distance, strength);

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

Зрештою, щоб сила активувалася, поведінку потрібно додати до світу фізики:

physics.addBehavior(behavior);

Тепер усе, що існує у фізичній симуляції, завжди притягуватиметься до цієї частинки, доки вона знаходиться в межах порогової відстані.

Клас AttractionBehavior — це дуже потужний інструмент. Наприклад, навіть попри те, що Toxiclibs.js не обробляє зіткнення автоматично, як це робить Matter.js, ви можете створити симуляцію подібну до зіткнень, додавши до кожної частинки поведінку AttractionBehavior із від’ємною силою, що призведе до відштовхувальної поведінки. Якщо сила велика й активується лише в короткому діапазоні (у масштабі радіуса самої частинки), результат схожий на зіткнення твердих тіл. Ось як для цього можна змінити клас Particle:

class Particle extends VerletParticle2D {

  constructor(x, y, r) {

    super(x, y);

    this.r = r;

    physics.addBehavior(new AttractionBehavior(this, r * 4, -1));

Кожного разу, коли створюється частинка, генерується AttractionBehavior і додається до світу фізики. Зауважте, що коли значення сили від’ємне, воно діятиме як сила відштовхування!

  }


  show() {

    fill(127);

    stroke(0);

    circle(this.x, this.y, this.r * 2);

  }

}

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

class Attractor extends VerletParticle2D {

  constructor(x, y, r) {

    super(x, y);

    this.r = r;

    physics.addBehavior(new AttractionBehavior(this, width, 0.1));

Постійне притягування всіх частинок.

    physics.addBehavior(new AttractionBehavior(this, this.r, -10));

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

    physics.addParticle(this);

Додавання атрактора до світу фізики.

  }


  show() {

    fill(0);

    circle(this.x, this.y, this.r * 2);

  }

}

Так само як обговорювалося у розділі про “Просторовий поділ”, проєкти Toxiclibs.js із великою кількістю частинок, які взаємодіють одна з одною, можуть працювати дуже повільно через N2N^2 природу алгоритму (кожна частинка перевіряє кожну іншу частинку). Щоб прискорити симуляцію, ви можете використовувати метод addForce() вручну у поєднанні з алгоритмом бінінгу. Майте на увазі, що для цього вам також знадобиться розрахувати силу притягання вручну, оскільки вбудована поведінка AttractionBehavior більше не застосовуватиметься.

Вправа 6.14

Використайте клас AttractionBehavior у поєднанні з пружинними силами.

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

Візьміть свою систему створінь із Розділу 5 і використайте фізичний рушій для керування їх рухом та поведінкою. Ось деякі можливості:

  • Використовуйте Matter.js, щоб налаштувати зіткнення між створіннями. Розгляньте можливість запускати певні події при зіткненні двох створінь.
  • Використовуйте Matter.js для покращення дизайну ваших створінь. Побудуйте скелет з віддаленими з’єднаннями або зробіть відростки з шарнірними сполученнями.
  • Використовуйте Toxiclibs.js, щоб покращити дизайн вашого створіння. Використовуйте ланцюжок частинок Toxiclibs.js для щупалець або сітку пружин як скелет.
  • Використовуйте Toxiclibs.js, щоб додати поведінку притягання і відштовхування для ваших створінь.
  • Використовуйте пружинні (або шарнірні) з’єднання між об’єктами, щоб контролювати їх взаємодію. Створюйте і видаляйте ці пружини на льоту. Розгляньте можливість робити ці з’єднання видимими або невидимими для глядача.